net.ipv6
Parse IPv6 addresses and write them in RFC 5952 canonical form: lower-case, zeros compressed, IPv4 embedded.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 47 tests, run in TypeScript, Python and Rust.parseIpv6 22 · formatIpv6 15 · isIpv6 10
What it does
Parse IPv6 text to its eight 16-bit groups, write groups back as the one canonical text RFC 5952 defines, and check text without raising. The canonical form of any address is `formatIpv6(parseIpv6(text))`.
## Why canonical text matters
The functions
A group: 3 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.
- parse_ipv6 (text: string) -> int[]
- format_ipv6 (groups: int[]) -> string
- is_ipv6 (text: string) -> bool
Once installed, your code imports each one from the group's module.
parse_ipv6 throws on bad input 22 tests
pub fn parse_ipv6(text: &str) -> Vec<i64>
| text | string | RFC 4291 text: hex groups, at most one ::, optionally a dotted IPv4 tail; no zone id or prefix |
| returns | int[] | the eight 16-bit groups, each 0 to 65535 |
For example
parse_ipv6(2001:db8::1)→ 8,193, 3,512, 0, 0, 0, 0, 0, 1 documentation address with ::parse_ipv6(::)→ 0, 0, 0, 0, 0, 0, 0, 0 the unspecified address is all zerosparse_ipv6(::1)→ 0, 0, 0, 0, 0, 0, 0, 1 loopback
fune!(net.ipv6@^1); // then call parse_ipv6(…)
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::net_ipv4_parse_ipv4::ipv4_value;
/// The eight 16-bit groups of an IPv6 address written as RFC 4291 allows.
///
/// Hex groups of one to four digits in either case, at most one "::" standing
/// for one or more zero groups, and optionally a dotted IPv4 address as the
/// last 32 bits. A zone id ("%eth0") or a prefix ("/64") is not part of an
/// address and is refused, as is anything around it (spaces, newlines).
///
/// # Panics
/// Panics when the text is not an IPv6 address.
pub fn parse_ipv6(text: &str) -> Vec<i64> {
match ipv6_groups(text) {
Some(groups) => groups,
None => panic!("\"{}\" is not an IPv6 address", text),
}
}
// Exported for is_ipv6 and for capabilities that parse IPv6 text without
// wanting a panic; not part of the group's contract.
/// The eight groups, or None when the text is not an IPv6 address.
pub fn ipv6_groups(text: &str) -> Option<Vec<i64>> {
if text.is_empty() || !text.bytes().all(|b| b.is_ascii_hexdigit() || b == b':' || b == b'.') {
return None;
}
let at = match text.find("::") {
None => {
let groups = pieces(&text.split(':').collect::<Vec<_>>(), true)?;
return if groups.len() == 8 { Some(groups) } else { None };
}
Some(at) => at,
};
let before = &text[..at];
let after = &text[at + 2..];
if after.contains("::") {
return None;
}
let split = |s: &str| -> Vec<String> {
if s.is_empty() {
Vec::new()
} else {
s.split(':').map(str::to_string).collect()
}
};
let head = pieces(&split(before), false)?;
let tail = pieces(&split(after), true)?;
// "::" stands for at least one group, so at most seven are written.
if head.len() + tail.len() > 7 {
return None;
}
let mut groups = head.clone();
groups.extend(std::iter::repeat(0).take(8 - head.len() - tail.len()));
groups.extend(tail);
Some(groups)
}
fn pieces<S: AsRef<str>>(parts: &[S], may_end_with_ipv4: bool) -> Option<Vec<i64>> {
let mut groups = Vec::new();
for (i, part) in parts.iter().enumerate() {
let part = part.as_ref();
if part.contains('.') {
if !may_end_with_ipv4 || i != parts.len() - 1 {
return None;
}
let v4 = ipv4_value(part)?;
groups.push(v4 / 65536);
groups.push(v4 % 65536);
continue;
}
if part.is_empty() || part.len() > 4 {
return None;
}
groups.push(i64::from_str_radix(part, 16).ok()?);
}
Some(groups)
}
pub fn fune_vector(args: &[Value]) -> Value {
Value::Arr(parse_ipv6(args[0].as_str()).into_iter().map(Value::Int).collect())
}format_ipv6 throws on bad input 15 tests
pub fn format_ipv6(groups: &[i64]) -> String
| groups | int[] | exactly eight integers 0 to 65535 |
| returns | string | RFC 5952 canonical text; ::ffff:0:0/96 (IPv4-mapped) keeps a dotted-quad tail |
For example
format_ipv6(8,193, 3,512, 0, 0, 0, 0, 0, 1)→ 2001:db8::1 compresses the zero runformat_ipv6(0, 0, 0, 0, 0, 0, 0, 0)→ :: all zeros is ::format_ipv6(0, 0, 0, 0, 0, 0, 0, 1)→ ::1 loopback
fune!(net.ipv6@^1); // then call format_ipv6(…)
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::net_ipv4_format_ipv4::format_ipv4;
/// RFC 5952 canonical text for eight 16-bit groups.
///
/// Lower-case hex, no leading zeros, the longest run of two or more zero
/// groups written "::" (the first on a tie, never a single group). An
/// IPv4-mapped address (::ffff:0:0/96) keeps its dotted-quad tail.
///
/// # Panics
/// Panics unless there are exactly eight groups, each 0 to 65535.
pub fn format_ipv6(groups: &[i64]) -> String {
if groups.len() != 8 || groups.iter().any(|g| !(0..=65535).contains(g)) {
panic!("IPv6 groups must be eight integers from 0 to 65535");
}
if groups[..6] == [0, 0, 0, 0, 0, 0xffff] {
return format!("::ffff:{}", format_ipv4(groups[6] * 65536 + groups[7]));
}
let (mut best_start, mut best_length) = (0usize, 0usize);
let mut i = 0;
while i < 8 {
if groups[i] != 0 {
i += 1;
continue;
}
let mut j = i;
while j < 8 && groups[j] == 0 {
j += 1;
}
// Strictly longer, so the first of two equal runs wins.
if j - i > best_length {
best_start = i;
best_length = j - i;
}
i = j;
}
let hex: Vec<String> = groups.iter().map(|g| format!("{:x}", g)).collect();
if best_length < 2 {
return hex.join(":");
}
format!(
"{}::{}",
hex[..best_start].join(":"),
hex[best_start + best_length..].join(":")
)
}
pub fn fune_vector(args: &[Value]) -> Value {
let groups: Vec<i64> = args[0]
.as_arr()
.iter()
.map(|g| match g {
Value::Int(i) => *i,
_ => panic!("IPv6 groups must be eight integers from 0 to 65535"),
})
.collect();
Value::str(&format_ipv6(&groups))
}is_ipv6 10 tests
pub fn is_ipv6(text: &str) -> bool
| text | string | any text; true exactly when parseIpv6 would accept it |
| returns | bool |
For example
is_ipv6(::1)→ true loopbackis_ipv6(2001:db8::1)→ true documentation addressis_ipv6(::ffff:192.0.2.1)→ true IPv4-mapped
fune!(net.ipv6@^1); // then call is_ipv6(…)
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::net_ipv6_parse_ipv6::ipv6_groups; ← parseIpv6, another function of this group · built into the same file, even by a slim install
/// True exactly when parse_ipv6 would accept the text; never panics.
pub fn is_ipv6(text: &str) -> bool {
ipv6_groups(text).is_some()
}
pub fn fune_vector(args: &[Value]) -> Value {
Value::Bool(is_ipv6(args[0].as_str()))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 1 dependency, 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 net.ipv6
That builds the whole group. To build only what you call, and whatever it uses inside the group:
fune add net.ipv6 --only parseIpv6
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./net.ipv6-1.0.0-rust.fune, or fetch it from a terminal with fune pull net.ipv6@1.0.0:rust.
The whole function, every language, is one file too: net.ipv6-1.0.0.fune, 25,384 bytes, sha256 f6cadea3f28991bad7dc0085b557c9d1e5f7db5ed39785b4040b78946ff72b56. 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 net.ipv6.parseIpv6
// fune: before net.ipv6.formatIpv6
// fune: before net.ipv6.isIpv6
after — your function gets the result and the arguments, and returns the final result.
// fune: after net.ipv6.parseIpv6
// fune: after net.ipv6.formatIpv6
// fune: after net.ipv6.isIpv6
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace net.ipv4 in net.ipv6
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 net.ipv6 --steps.
// fune: step net.ipv6.<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.
parseIpv6 22 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| documentation address with :: | 2001:db8::1 | → | 8,193, 3,512, 0, 0, 0, 0, 0, 1 |
| the unspecified address is all zeros | :: | → | 0, 0, 0, 0, 0, 0, 0, 0 |
| loopback | ::1 | → | 0, 0, 0, 0, 0, 0, 0, 1 |
| :: at the end | fe80:: | → | 65,152, 0, 0, 0, 0, 0, 0, 0 |
| full form, upper case and leading zeros | 2001:0DB8:0000:0000:0000:ff00:0042:8329 | → | 8,193, 3,512, 0, 0, 0, 65,280, 66, 33,577 |
| IPv4-mapped with a dotted tail: 192.0 is c000, 2.128 is 0280 | ::ffff:192.0.2.128 | → | 0, 0, 0, 0, 0, 65,535, 49,152, 640 |
| NAT64 well-known prefix with a dotted tail | 64:ff9b::192.0.2.33 | → | 100, 65,435, 0, 0, 0, 0, 49,152, 545 |
| six groups and a dotted tail without :: | 1:2:3:4:5:6:1.2.3.4 | → | 1, 2, 3, 4, 5, 6, 258, 772 |
| seven groups and :: standing for one zero group | 1:2:3:4:5:6:7:: | → | 1, 2, 3, 4, 5, 6, 7, 0 |
| two :: are ambiguous | 2001:db8::1::1 | → | error: is not an IPv6 address |
Show the other 12 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| nine groups | 1:2:3:4:5:6:7:8:9 | → | error: is not an IPv6 address |
| seven groups without :: | 1:2:3:4:5:6:7 | → | error: is not an IPv6 address |
| eight groups and :: (:: must stand for at least one) | 1:2:3:4:5:6:7:8:: | → | error: is not an IPv6 address |
| a five-digit group | 12345:: | → | error: is not an IPv6 address |
| a zone id is not part of the address | fe80::1%eth0 | → | error: is not an IPv6 address |
| a prefix length is not part of the address | 2001:db8::/32 | → | error: is not an IPv6 address |
| a dotted part that is not last | ::192.0.2.1:1 | → | error: is not an IPv6 address |
| the dotted tail follows the IPv4 rules (no leading zeros) | ::ffff:01.2.3.4 | → | error: is not an IPv6 address |
| a single leading colon | :1:: | → | error: is not an IPv6 address |
| a trailing newline | ::1 | → | error: is not an IPv6 address |
| a non-ASCII digit | ١::1 | → | error: is not an IPv6 address |
| empty text | → | error: is not an IPv6 address |
formatIpv6 15 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| compresses the zero run | 8,193, 3,512, 0, 0, 0, 0, 0, 1 | → | 2001:db8::1 |
| all zeros is :: | 0, 0, 0, 0, 0, 0, 0, 0 | → | :: |
| loopback | 0, 0, 0, 0, 0, 0, 0, 1 | → | ::1 |
| a run at the end | 65,152, 0, 0, 0, 0, 0, 0, 0 | → | fe80:: |
| the longest run wins, not the first (RFC 5952 4.2.3) | 8,193, 3,512, 0, 1, 0, 0, 0, 1 | → | 2001:db8:0:1::1 |
| equal runs: the first is compressed | 8,193, 3,512, 0, 0, 1, 0, 0, 1 | → | 2001:db8::1:0:0:1 |
| equal runs at both ends: the first | 0, 0, 1, 2, 3, 4, 0, 0 | → | ::1:2:3:4:0:0 |
| a single zero group is never :: (RFC 5952 4.2.2) | 8,193, 3,512, 0, 1, 1, 1, 1, 1 | → | 2001:db8:0:1:1:1:1:1 |
| lower case, leading zeros dropped | 8,193, 3,512, 0, 0, 0, 65,280, 66, 33,577 | → | 2001:db8::ff00:42:8329 |
| IPv4-mapped keeps a dotted tail (RFC 5952 5) | 0, 0, 0, 0, 0, 65,535, 49,152, 640 | → | ::ffff:192.0.2.128 |
Show the other 5 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| not mapped (a non-zero fifth group): plain hex | 0, 0, 0, 0, 1, 65,535, 49,152, 640 | → | ::1:ffff:c000:280 |
| seven groups | 1, 2, 3, 4, 5, 6, 7 | → | error: IPv6 groups must be eight integers from 0 to 65535 |
| a group above 65535 | 65,536, 0, 0, 0, 0, 0, 0, 0 | → | error: IPv6 groups must be eight integers from 0 to 65535 |
| a negative group | 0, 0, 0, 0, 0, 0, 0, -1 | → | error: IPv6 groups must be eight integers from 0 to 65535 |
| a fractional group | 0, 0, 0, 0, 0, 0, 0, 1.5 | → | error: IPv6 groups must be eight integers from 0 to 65535 |
isIpv6 10 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| loopback | ::1 | → | true |
| documentation address | 2001:db8::1 | → | true |
| IPv4-mapped | ::ffff:192.0.2.1 | → | true |
| upper case | FE80::1 | → | true |
| a zone id | fe80::1%eth0 | → | false |
| an IPv4 address | 1.2.3.4 | → | false |
| a trailing newline | ::1 | → | false |
| three colons | 2001:db8:::1 | → | false |
| two :: | 1::2::3 | → | false |
| empty text | → | false |
More from the author
`2001:DB8:0:0:1:0:0:1`, `2001:db8::1:0:0:1` and `2001:0db8:0:0:1::1` are the same address. Compare them as text, log them, or use them as a map key, and they are three. RFC 5952 fixes one spelling:
- lower-case hex, no leading zeros in a group; - the longest run of two or more zero groups becomes `::`; on a tie the first run; a single zero group is written `0`, never `::`; - an IPv4-mapped address (`::ffff:0:0/96`) keeps its dotted-quad tail (`::ffff:192.0.2.128`), as section 5 recommends. Other prefixes that can carry IPv4 (the deprecated `::a.b.c.d` compatible form, `64:ff9b::/96`) are written in hex; the parser accepts a dotted tail on any of them.
## What parses
RFC 4291 section 2.2 text: one to four hex digits per group in either case, at most one `::` (standing for at least one zero group), and optionally a dotted IPv4 address, following `net.ipv4`'s strict rules, as the last 32 bits. Refused: a zone id (`fe80::1%eth0`, RFC 4007: it names an interface, it is not part of the address), a prefix (`/64`, see `net.cidr`), brackets, whitespace and non-ASCII digits.
`ipv6Groups` (`ipv6_groups`) is exported for sibling capabilities that want "groups or null"; it is not part of the contract.
Sources: RFC 4291 (IP Version 6 Addressing Architecture) section 2.2; RFC 5952 (A Recommendation for IPv6 Address Text Representation) sections 4 and 5.