Functional Weave
Code in Rust

encoding.hex

Bytes to lowercase hexadecimal text and back (RFC 4648 base16), strictly, identically in every language.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 25 tests, run in TypeScript, Python and Rust.hexEncode 12 · hexDecode 13

What it does

`hexEncode([222, 173, 190, 239])` is `"deadbeef"`, and `hexDecode("DEADbeef")` is `[222, 173, 190, 239]`. This is base16 from RFC 4648 section 8, the form digests and keys are usually printed in.

**Bytes are lists of integers.** The registry's type vocabulary has no bytes type, so every encoding and crypto capability (`encoding.*`, `crypto.*`, `auth.*`) takes and returns bytes as `int[]`, each 0 to 255. In Python a `bytes` value is already a sequence of such integers and can be passed directly (`hex_encode(b"\x00\xff")`); the result is a `list`, so wrap it in `bytes(...)` when you need one. In TypeScript pass a plain array (`Array.from(uint8array)`). Anything else in the list (256, -1, 1.5, a string, `true`) is an error, never silently truncated to a byte.

The functions

A group: 2 functions that work together, each in its own file, each pinned by its own tests in TypeScript, Python and Rust. A project can install only the ones it calls.

  1. hex_encode (bytes: int[]) -> string
  2. hex_decode (text: string) -> int[]

Once installed, your code imports each one from the group's module.

hex_encode throws on bad input 12 tests

pub fn hex_encode(bytes: &[i64]) -> String
bytesint[]each an integer from 0 to 255
returnsstringtwo lowercase digits per byte; empty for no bytes

For example

  • hex_encode() → no bytes is the empty string
  • hex_encode(0) → 00 a zero byte keeps its leading zero
  • hex_encode(255) → ff the largest byte
fune!(encoding.hex@^1);  // then call hex_encode(…)
impl/rust/hex_encode.rs · 43 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one

const DIGITS: &[u8; 16] = b"0123456789abcdef";

/// Bytes as lowercase hexadecimal, two digits per byte.
///
/// Bytes are a list of integers 0-255 across the registry (there is no bytes
/// type), so a value outside that range is refused rather than masked to a
/// byte: 256 quietly becoming 0 would change key material without a trace.
///
/// # Panics
/// Panics if any value is outside 0-255.
pub fn hex_encode(bytes: &[i64]) -> String {
    let mut out = String::with_capacity(bytes.len() * 2);
    for &b in bytes {
        if !(0..=255).contains(&b) {
            panic!("bytes must be a list of integers from 0 to 255");
        }
        out.push(DIGITS[(b >> 4) as usize] as char);
        out.push(DIGITS[(b & 15) as usize] as char);
    }
    out
}

/// A JSON list of byte values, refusing what the other languages refuse (a
/// fraction, a string, a boolean) with the same words instead of letting
/// `as_i64` turn it into a number.
fn bytes_from_value(value: &Value, name: &str) -> Vec<i64> {
    match value {
        Value::Arr(items) => items
            .iter()
            .map(|item| match item {
                Value::Int(i) => *i,
                _ => panic!("{} must be a list of integers from 0 to 255", name),
            })
            .collect(),
        _ => panic!("{} must be a list of integers from 0 to 255", name),
    }
}

pub fn fune_vector(args: &[Value]) -> Value {
    Value::str(&hex_encode(&bytes_from_value(&args[0], "bytes")))
}

hex_decode throws on bad input 13 tests

pub fn hex_decode(text: &str) -> Vec<i64>
textstringan even number of hex digits, either case; no prefix, no spaces
returnsint[]one integer from 0 to 255 per pair of digits

For example

  • hex_decode() → the empty string is no bytes
  • hex_decode(666F6F626172) → 102, 111, 111, 98, 97, 114 RFC 4648 section 10: "foobar" as the RFC prints it, in uppercase
  • hex_decode(666f6f626172) → 102, 111, 111, 98, 97, 114 the same bytes in lowercase
fune!(encoding.hex@^1);  // then call hex_decode(…)
impl/rust/hex_decode.rs · 41 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one

fn digit_value(b: u8) -> i64 {
    match b {
        b'0'..=b'9' => (b - b'0') as i64,
        b'a'..=b'f' => (b - b'a' + 10) as i64,
        b'A'..=b'F' => (b - b'A' + 10) as i64,
        _ => -1,
    }
}

/// Hexadecimal text back to bytes, either case, strictly.
///
/// A 0x prefix, whitespace or an odd digit count is an error, not skipped.
/// Characters are checked before the length, so the answer for non-ASCII
/// input does not depend on how a language counts its length.
///
/// # Panics
/// Panics on any character that is not a hex digit, or an odd digit count.
pub fn hex_decode(text: &str) -> Vec<i64> {
    let mut digits = Vec::with_capacity(text.len());
    for &b in text.as_bytes() {
        let d = digit_value(b);
        if d < 0 {
            panic!("hex text may only contain the digits 0-9, a-f and A-F");
        }
        digits.push(d);
    }
    if digits.len() % 2 != 0 {
        panic!("hex text must have an even number of digits, received {}", digits.len());
    }
    digits.chunks(2).map(|pair| pair[0] * 16 + pair[1]).collect()
}

pub fn fune_vector(args: &[Value]) -> Value {
    let text = match &args[0] {
        Value::Str(s) => s.as_str(),
        _ => panic!("hex text must be a string"),
    };
    Value::Arr(hex_decode(text).into_iter().map(Value::Int).collect())
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add encoding.hex

That builds the whole group. To build only what you call, and whatever it uses inside the group:

fune add encoding.hex --only hexEncode
Download for Rust encoding.hex-1.0.0-rust.fune · 10,723 bytes sha256 cf3885fa039115253afa81c19178a4b2fd2900fc62a205612f9f0beebf18035f

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./encoding.hex-1.0.0-rust.fune, or fetch it from a terminal with fune pull encoding.hex@1.0.0:rust.

The whole function, every language, is one file too: encoding.hex-1.0.0.fune, 15,224 bytes, sha256 8b494ecd32a0b545be0e436a0b8a598e80291fbf890387eaa70883258de64d2a. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before encoding.hex.hexEncode
// fune: before encoding.hex.hexDecode

after — your function gets the result and the arguments, and returns the final result.

// fune: after encoding.hex.hexEncode
// fune: after encoding.hex.hexDecode

replace — it requires no other capability, so there is no dependency to replace.

step — your function runs at a numbered point inside a function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show encoding.hex --steps.

// fune: step encoding.hex.<fn> after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

hexEncode 12 tests

CaseArgumentsExpected
no bytes is the empty string →
a zero byte keeps its leading zero 0 → 00
the largest byte 255 → ff
RFC 4648 section 10: "f" 102 → 66
RFC 4648 section 10: "foobar", written in lowercase 102, 111, 111, 98, 97, 114 → 666f6f626172
the classic deadbeef 222, 173, 190, 239 → deadbeef
every small byte keeps two digits, which toString(16) alone gets wrong 0, 1, 15, 16, 127, 128, 254 → 00010f107f80fe
256 is not a byte and is not masked to 00 1, 256 → error: bytes must be a list of integers from 0 to 255
a negative value is not a byte -1 → error: bytes must be a list of integers from 0 to 255
a fraction is not a byte 1.5 → error: bytes must be a list of integers from 0 to 255
Show the other 2 tests
CaseArgumentsExpected
a string inside the list is not a byte a → error: bytes must be a list of integers from 0 to 255
a boolean is not a byte, even where the language treats it as 1 true → error: bytes must be a list of integers from 0 to 255

hexDecode 13 tests

CaseArgumentsExpected
the empty string is no bytes →
RFC 4648 section 10: "foobar" as the RFC prints it, in uppercase 666F6F626172 → 102, 111, 111, 98, 97, 114
the same bytes in lowercase 666f6f626172 → 102, 111, 111, 98, 97, 114
mixed case DEADbeef → 222, 173, 190, 239
the smallest and largest bytes 00ff → 0, 255
a single digit is half a byte 0 → error: hex text must have an even number of digits, received 1
an odd number of digits abc → error: hex text must have an even number of digits, received 3
a 0x prefix is not accepted 0x00 → error: hex text may only contain the digits 0-9, a-f and A-F
spaces between bytes are not skipped 00 ff → error: hex text may only contain the digits 0-9, a-f and A-F
a trailing newline is an error, not trimmed 00 → error: hex text may only contain the digits 0-9, a-f and A-F
Show the other 3 tests
CaseArgumentsExpected
letters past f gg → error: hex text may only contain the digits 0-9, a-f and A-F
Arabic-Indic digits are not hex digits ٠٠ → error: hex text may only contain the digits 0-9, a-f and A-F
full-width digits are not hex digits 01 → error: hex text may only contain the digits 0-9, a-f and A-F

More from the author

Encoding writes lowercase, which is what `sha256sum`, Python's `bytes.hex()` and most JSON APIs print. RFC 4648 writes its test vectors in uppercase; decoding accepts either case, and mixed case, so both round-trip.

Decoding is strict on purpose: an odd number of digits, a `0x` prefix, spaces, a trailing newline or a non-ASCII digit such as `"٠٠"` are errors rather than being skipped, because a permissive decoder turns a pasted typo into different key material without anyone noticing.

Source: RFC 4648, The Base16, Base32, and Base64 Data Encodings, section 8 and the test vectors in section 10 (https://www.rfc-editor.org/rfc/rfc4648).

Files

PathBytes
README.md1,431
impl/python/hex_decode.py1,204
impl/python/hex_encode.py871
impl/rust/hex_decode.rs1,343
impl/rust/hex_encode.rs1,504
impl/typescript/hex_decode.ts1,282
impl/typescript/hex_encode.ts844
vectors.json3,477