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.
- base64_encode (bytes: int[]) -> string
- base64_decode (text: string) -> int[]
- base64_url_encode (bytes: int[]) -> string
- 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
| bytes | int[] | each an integer from 0 to 255 |
| returns | string | standard alphabet (+ and /), padded with = to a multiple of 4 |
For example
base64_encode()→ RFC 4648 section 10: the empty stringbase64_encode(102)→ Zg== RFC 4648 section 10: "f", two padding charactersbase64_encode(102, 111)→ Zm8= RFC 4648 section 10: "fo", one padding character
fune!(encoding.base64@^1); // then call base64_encode(…)
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>
| text | string | standard alphabet, padded; no whitespace or line breaks |
| returns | int[] |
For example
base64_decode()→ the empty stringbase64_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(…)
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
| bytes | int[] | each an integer from 0 to 255 |
| returns | string | URL-safe alphabet (- and _), no padding, as JWTs use |
For example
base64_url_encode()→ the empty stringbase64_url_encode(102)→ Zg "f" without its paddingbase64_url_encode(102, 111)→ Zm8 "fo" without its padding
fune!(encoding.base64@^1); // then call base64_url_encode(…)
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>
| text | string | URL-safe alphabet, with or without correct = padding |
| returns | int[] |
For example
base64_url_decode()→ the empty stringbase64_url_decode(Zg)→ 102 unpadded, as a JWT writes itbase64_url_decode(Zg==)→ 102 correct padding is accepted too
fune!(encoding.base64@^1); // then call base64_url_decode(…)
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
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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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.