Functional Weave
Code in Rust

encoding.base64

Bytes to base64 and base64url text and back (RFC 4648), with strict decoding, identically in every language.

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

Pinned by 46 tests, run in TypeScript, Python and Rust.base64Encode 12 · base64Decode 14 · base64UrlEncode 9 · base64UrlDecode 11

What it does

Base64 from RFC 4648: `base64Encode` / `base64Decode` use the standard alphabet (`+`, `/`) with `=` padding, and `base64UrlEncode` / `base64UrlDecode` use the URL- and filename-safe alphabet (`-`, `_`) of section 5, without padding, which is the form JWTs (RFC 7515 section 2) and most URL tokens use. The four ship as one group because the two alphabets share one codec; install only what you call with `only=`.

Bytes are lists of integers 0 to 255, as everywhere in the registry (see `encoding.hex`). Python callers can pass a `bytes` value directly.

The functions

A group: 4 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. base64_encode (bytes: int[]) -> string
  2. base64_decode (text: string) -> int[]
  3. base64_url_encode (bytes: int[]) -> string
  4. base64_url_decode (text: string) -> int[]

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

base64_encode throws on bad input 12 tests

pub fn base64_encode(bytes: &[i64]) -> String
bytesint[]each an integer from 0 to 255
returnsstringstandard alphabet (+ and /), padded with = to a multiple of 4

For example

  • base64_encode() → RFC 4648 section 10: the empty string
  • base64_encode(102) → Zg== RFC 4648 section 10: "f", two padding characters
  • base64_encode(102, 111) → Zm8= RFC 4648 section 10: "fo", one padding character
fune!(encoding.base64@^1);  // then call base64_encode(…)
impl/rust/base64_encode.rs · 50 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 ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

/// Bytes as standard base64 (RFC 4648 section 4), padded with "=".
///
/// # Panics
/// Panics if any value is outside 0-255.
pub fn base64_encode(bytes: &[i64]) -> String {
    if bytes.iter().any(|b| !(0..=255).contains(b)) {
        panic!("bytes must be a list of integers from 0 to 255");
    }
    let mut out = String::with_capacity((bytes.len() + 2) / 3 * 4);
    let push = |out: &mut String, n: i64, count: usize| {
        for k in 0..4 {
            if k < count {
                out.push(ALPHABET[((n >> (18 - 6 * k)) & 63) as usize] as char);
            } else {
                out.push('=');
            }
        }
    };
    let mut chunks = bytes.chunks_exact(3);
    for c in &mut chunks {
        push(&mut out, (c[0] << 16) | (c[1] << 8) | c[2], 4);
    }
    match chunks.remainder() {
        [a] => push(&mut out, a << 16, 2),
        [a, b] => push(&mut out, (a << 16) | (b << 8), 3),
        _ => {}
    }
    out
}

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(&base64_encode(&bytes_from_value(&args[0], "bytes")))
}

base64_decode throws on bad input 14 tests

pub fn base64_decode(text: &str) -> Vec<i64>
textstringstandard alphabet, padded; no whitespace or line breaks
returnsint[]

For example

  • base64_decode() → the empty string
  • base64_decode(Zg==) → 102 RFC 4648 section 10: "Zg==" is "f"
  • base64_decode(Zm8=) → 102, 111 RFC 4648 section 10: "Zm8=" is "fo"
fune!(encoding.base64@^1);  // then call base64_decode(…)
impl/rust/base64_decode.rs · 73 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 sextet(b: u8) -> i64 {
    match b {
        b'A'..=b'Z' => (b - b'A') as i64,
        b'a'..=b'z' => (b - b'a' + 26) as i64,
        b'0'..=b'9' => (b - b'0' + 52) as i64,
        b'+' => 62,
        b'/' => 63,
        _ => -1,
    }
}

/// Standard, padded base64 back to bytes, strictly: no whitespace, no
/// characters outside the alphabet, padding to a multiple of four, and zero
/// bits after the last byte, so every byte string has exactly one spelling.
///
/// # Panics
/// Panics on any of those.
pub fn base64_decode(text: &str) -> Vec<i64> {
    let mut values: Vec<i64> = Vec::with_capacity(text.len());
    let mut padding = 0usize;
    // Bytes, not chars: every accepted character is ASCII, and any byte of a
    // multi-byte character fails the alphabet check just as the character does.
    for &b in text.as_bytes() {
        if b == b'=' {
            padding += 1;
            continue;
        }
        let v = sextet(b);
        if v < 0 || padding > 0 {
            panic!("base64 text may only contain A-Z, a-z, 0-9, + and /, with = padding at the end");
        }
        values.push(v);
    }
    if text.len() % 4 != 0 {
        panic!("base64 text must be a multiple of 4 characters long, received {}", text.len());
    }
    if padding > 2 {
        panic!("base64 text has too much = padding");
    }
    let mut out = Vec::with_capacity(values.len() / 4 * 3);
    let mut chunks = values.chunks_exact(4);
    for c in &mut chunks {
        let n = (c[0] << 18) | (c[1] << 12) | (c[2] << 6) | c[3];
        out.extend_from_slice(&[(n >> 16) & 255, (n >> 8) & 255, n & 255]);
    }
    match chunks.remainder() {
        [a, b] => {
            if b & 15 != 0 {
                panic!("base64 text has non-zero bits after its last byte");
            }
            out.push(((a << 2) | (b >> 4)) & 255);
        }
        [a, b, c] => {
            if c & 3 != 0 {
                panic!("base64 text has non-zero bits after its last byte");
            }
            let n = (a << 18) | (b << 12) | (c << 6);
            out.extend_from_slice(&[(n >> 16) & 255, (n >> 8) & 255]);
        }
        _ => {}
    }
    out
}

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

base64_url_encode throws on bad input 9 tests

pub fn base64_url_encode(bytes: &[i64]) -> String
bytesint[]each an integer from 0 to 255
returnsstringURL-safe alphabet (- and _), no padding, as JWTs use

For example

  • base64_url_encode() → the empty string
  • base64_url_encode(102) → Zg "f" without its padding
  • base64_url_encode(102, 111) → Zm8 "fo" without its padding
fune!(encoding.base64@^1);  // then call base64_url_encode(…)
impl/rust/base64_url_encode.rs · 32 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
use super::encoding_base64_base64_encode::base64_encode;  ← base64Encode, another function of this group · built into the same file, even by a slim install

/// Bytes as base64url (RFC 4648 section 5) without padding, as JWTs use it.
///
/// # Panics
/// Panics if any value is outside 0-255.
pub fn base64_url_encode(bytes: &[i64]) -> String {
    base64_encode(bytes)
        .trim_end_matches('=')
        .chars()
        .map(|c| match c {
            '+' => '-',
            '/' => '_',
            other => other,
        })
        .collect()
}

pub fn fune_vector(args: &[Value]) -> Value {
    let bytes: Vec<i64> = match &args[0] {
        Value::Arr(items) => items
            .iter()
            .map(|item| match item {
                Value::Int(i) => *i,
                _ => panic!("bytes must be a list of integers from 0 to 255"),
            })
            .collect(),
        _ => panic!("bytes must be a list of integers from 0 to 255"),
    };
    Value::str(&base64_url_encode(&bytes))
}

base64_url_decode throws on bad input 11 tests

pub fn base64_url_decode(text: &str) -> Vec<i64>
textstringURL-safe alphabet, with or without correct = padding
returnsint[]

For example

  • base64_url_decode() → the empty string
  • base64_url_decode(Zg) → 102 unpadded, as a JWT writes it
  • base64_url_decode(Zg==) → 102 correct padding is accepted too
fune!(encoding.base64@^1);  // then call base64_url_decode(…)
impl/rust/base64_url_decode.rs · 50 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
use super::encoding_base64_base64_decode::base64_decode;  ← base64Decode, another function of this group · built into the same file, even by a slim install

/// base64url text back to bytes. Padding is optional, since JWTs leave it
/// off and some producers keep it, but padding that is present must be right.
///
/// # Panics
/// Panics on a character outside the URL-safe alphabet, an impossible
/// length, wrong padding, or non-zero bits after the last byte.
pub fn base64_url_decode(text: &str) -> Vec<i64> {
    let mut body = String::with_capacity(text.len() + 2);
    let mut padding = 0usize;
    for &b in text.as_bytes() {
        if b == b'=' {
            padding += 1;
            continue;
        }
        let ok = b.is_ascii_alphanumeric() || b == b'-' || b == b'_';
        if !ok || padding > 0 {
            panic!("base64url text may only contain A-Z, a-z, 0-9, - and _, with optional = padding at the end");
        }
        body.push(match b {
            b'-' => '+',
            b'_' => '/',
            other => other as char,
        });
    }
    if body.len() % 4 == 1 {
        panic!(
            "base64url text cannot be {} characters long; no number of bytes encodes to that",
            body.len()
        );
    }
    let needed = (4 - body.len() % 4) % 4;
    if padding != 0 && padding != needed {
        panic!("base64url text has the wrong amount of = padding");
    }
    for _ in 0..needed {
        body.push('=');
    }
    base64_decode(&body)
}

pub fn fune_vector(args: &[Value]) -> Value {
    let text = match &args[0] {
        Value::Str(s) => s.as_str(),
        _ => panic!("base64url text must be a string"),
    };
    Value::Arr(base64_url_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.base64

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

fune add encoding.base64 --only base64Encode
Download for Rust encoding.base64-1.0.0-rust.fune · 20,185 bytes sha256 0519505836a1ccb14e7a6304e8a2104b4cf7e327af9f0daa69645d52b19f44ca

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

The whole function, every language, is one file too: encoding.base64-1.0.0.fune, 31,225 bytes, sha256 9dc42678ee52da6f5471c4c64d22da5ce75f681e734cea0ad5dd0ce6a188c403. 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.base64.base64Encode
// fune: before encoding.base64.base64Decode
// fune: before encoding.base64.base64UrlEncode
// fune: before encoding.base64.base64UrlDecode

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

// fune: after encoding.base64.base64Encode
// fune: after encoding.base64.base64Decode
// fune: after encoding.base64.base64UrlEncode
// fune: after encoding.base64.base64UrlDecode

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.base64 --steps.

// fune: step encoding.base64.<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.

base64Encode 12 tests

CaseArgumentsExpected
RFC 4648 section 10: the empty string →
RFC 4648 section 10: "f", two padding characters 102 → Zg==
RFC 4648 section 10: "fo", one padding character 102, 111 → Zm8=
RFC 4648 section 10: "foo", no padding 102, 111, 111 → Zm9v
RFC 4648 section 10: "foob" 102, 111, 111, 98 → Zm9vYg==
RFC 4648 section 10: "fooba" 102, 111, 111, 98, 97 → Zm9vYmE=
RFC 4648 section 10: "foobar" 102, 111, 111, 98, 97, 114 → Zm9vYmFy
high bytes use the + and / characters of the standard alphabet 251, 255, 191 → +/+/
zero bytes are A, not dropped 0, 0, 0, 0 → AAAAAA==
256 is not a byte 256 → error: bytes must be a list of integers from 0 to 255
Show the other 2 tests
CaseArgumentsExpected
a fraction is not a byte 1.5 → error: bytes must be a list of integers from 0 to 255
a string inside the list is not a byte f → error: bytes must be a list of integers from 0 to 255

base64Decode 14 tests

CaseArgumentsExpected
the empty string →
RFC 4648 section 10: "Zg==" is "f" Zg== → 102
RFC 4648 section 10: "Zm8=" is "fo" Zm8= → 102, 111
RFC 4648 section 10: "Zm9vYg==" is "foob" Zm9vYg== → 102, 111, 111, 98
RFC 4648 section 10: "Zm9vYmFy" is "foobar" Zm9vYmFy → 102, 111, 111, 98, 97, 114
the + and / characters +/+/ → 251, 255, 191
missing padding is refused in standard base64 Zg → error: base64 text must be a multiple of 4 characters long, received 2
non-zero bits after the last byte, which a lenient decoder reads as "f" Zh== → error: base64 text has non-zero bits after its last byte
non-zero bits after the last of two bytes Zm9= → error: base64 text has non-zero bits after its last byte
three padding characters Z=== → error: base64 text has too much = padding
Show the other 4 tests
CaseArgumentsExpected
padding in the middle Zg==Zg== → error: base64 text may only contain A-Z, a-z, 0-9, + and /, with = padding at the end
a trailing newline is not skipped Zm9v → error: base64 text may only contain A-Z, a-z, 0-9, + and /, with = padding at the end
base64url characters in standard text -_-_ → error: base64 text may only contain A-Z, a-z, 0-9, + and /, with = padding at the end
a non-ASCII letter Zm9é → error: base64 text may only contain A-Z, a-z, 0-9, + and /, with = padding at the end

base64UrlEncode 9 tests

CaseArgumentsExpected
the empty string →
"f" without its padding 102 → Zg
"fo" without its padding 102, 111 → Zm8
"foobar" needs no padding in either alphabet 102, 111, 111, 98, 97, 114 → Zm9vYmFy
- and _ replace + and / 251, 255, 191 → -_-_
- and _ with padding removed 251, 255 → -_8
RFC 7515 appendix A.1: the example JWS header 123, 34, 116, 121, 112, 34, 58, 34, 74, 87, 84, 34, 44, 13, 10, 32, 34, 97, 108, 103, 34, 58, 34, 72, 83, 50, 53, 54, 34, 125 → eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9
300 is not a byte 300 → error: bytes must be a list of integers from 0 to 255
a negative value is not a byte -5 → error: bytes must be a list of integers from 0 to 255

base64UrlDecode 11 tests

CaseArgumentsExpected
the empty string →
unpadded, as a JWT writes it Zg → 102
correct padding is accepted too Zg== → 102
- and _ -_-_ → 251, 255, 191
- and _ in an unpadded tail -_8 → 251, 255
RFC 7515 appendix A.1: the example JWS header eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9 → 123, 34, 116, 121, 112, 34, 58, 34, 74, 87, 84, 34, 44, 13, 10, 32, 34, 97, 108, 103, 34, 58, 34, 72, 83, 50, 53, 54, 34, 125
standard-alphabet characters are refused +/+/ → error: base64url text may only contain A-Z, a-z, 0-9, - and _, with optional = padding at the end
one character past a multiple of four encodes nothing Zm9vY → error: base64url text cannot be 5 characters long; no number of bytes encodes to that
padding that is present must be complete Zg= → error: base64url text has the wrong amount of = padding
non-zero bits after the last byte Zh → error: base64 text has non-zero bits after its last byte
Show the other 1 test
CaseArgumentsExpected
a space inside the text Zm9v YmFy → error: base64url text may only contain A-Z, a-z, 0-9, - and _, with optional = padding at the end

More from the author

**Decoding is strict.** Line breaks, spaces and characters outside the alphabet are errors, not skipped (RFC 4648 section 3.3 says to reject them unless a specification says otherwise). Standard base64 must be padded to a multiple of four characters. The unused bits in the last character must be zero: `"Zh=="` is refused even though a lenient decoder reads it as `"f"`, because accepting it would give one byte string several spellings, which matters when the text is compared or signed (section 3.5). The URL-safe decoder accepts text with or without padding, but padding that is present has to be right, and a length that no byte count produces (one character past a multiple of four) is an error.

Mixing alphabets is an error in both directions: `+` or `/` in base64url text, and `-` or `_` in standard base64 text. That is almost always a token pasted into the wrong field.

Source: RFC 4648, The Base16, Base32, and Base64 Data Encodings, sections 4, 5, 3.3, 3.5, and the test vectors in section 10 (https://www.rfc-editor.org/rfc/rfc4648). The base64url example of a JWS header is from RFC 7515 appendix A.1.

Files

PathBytes
README.md1,692
impl/python/base64_decode.py2,204
impl/python/base64_encode.py1,327
impl/python/base64_url_decode.py1,153
impl/python/base64_url_encode.py303
impl/rust/base64_decode.rs2,441
impl/rust/base64_encode.rs1,603
impl/rust/base64_url_decode.rs1,684
impl/rust/base64_url_encode.rs942
impl/typescript/base64_decode.ts2,059
impl/typescript/base64_encode.ts1,357
impl/typescript/base64_url_decode.ts1,322
impl/typescript/base64_url_encode.ts523
vectors.json6,687