- Zig 99.4%
- Nix 0.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01FSMLHororSJMQozqXeqGwD |
||
| .forgejo/workflows | ||
| LICENSES | ||
| src | ||
| tools | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| flake.lock | ||
| flake.nix | ||
| package.nix | ||
| README.md | ||
| REUSE.toml | ||
z46
A Zig library for parsing, validating, formatting and classifying IPv4 and IPv6 addresses, CIDR networks, the ranges that CIDR cannot express, and the endpoints that pair an address with a port.
Everything is allocation-free and works at comptime as readily as at runtime.
These are values, not sockets: nothing here opens a connection or touches the
network, and conversions to and from the std.Io.net socket address types are
provided for when you need one.
The API reference is published from the
default branch, and every function in it carries a worked example that is
compiled and run as part of the test suite. zig build docs-serve builds the
same pages and serves them at http://127.0.0.1:8000/, with -Ddocs-port=N to
choose another port. A server
is needed rather than opening the files directly, because the viewer fetches
its data at runtime and a browser refuses that from a file:// page; zig std
works this way for the same reason. zig build docs alone just writes the
pages to zig-out/docs.
Quick start
const z46 = @import("z46");
const network = try z46.Ipv4Network.parse("192.168.1.0/24");
const address = try z46.Ipv4Address.parse("192.168.1.42");
std.debug.assert(network.contains(address));
std.debug.assert(address.isPrivate());
// {f} formats any of these types in canonical form.
std.debug.print("{f} in {f}\n", .{ address, network });
An address that remembers the network it sits on — the 192.168.1.42/24 an
operating system configures on a link, host bits and all — is an interface
rather than a network:
const eth0 = try z46.Ipv4Interface.parse("192.168.1.42/24");
std.debug.assert(eth0.address.eql(address));
std.debug.assert(eth0.network().eql(network));
std.debug.print("{f} on {f}\n", .{ eth0.address, eth0.network() });
The name an address is looked up under in the reverse DNS is one of the few derived forms nobody writes out reliably by hand, so it has a function:
var buffer: [z46.Address.max_reverse_name_len]u8 = undefined;
const v4 = try z46.Ipv4Address.parse("10.2.0.52");
try expectEqualStrings("52.0.2.10.in-addr.arpa", try v4.reverseName(&buffer));
const v6 = try z46.Ipv6Address.parse("2001:db8::cb01");
// 1.0.b.c.0.0. ... .0.8.b.d.0.1.0.0.2.ip6.arpa
_ = try v6.reverseName(&buffer);
writeReverseName is the same name written straight to a *std.Io.Writer,
for a caller that is already writing a stream — a zone file, a log line, a
query being assembled — and has no reason to hold the name in a buffer first:
try address.writeReverseName(w);
Both follow the label order the DNS delegates in, so an IPv4 name is the
dotted quad read backwards under in-addr.arpa (RFC 1035 §3.5) and an IPv6
name is all 32 nibbles read backwards under ip6.arpa (RFC 3596 §2.5) — no
zero nibble elided, whatever the canonical text form compresses. The names
carry no trailing dot.
Ipv6Address.nibbles writes those 32 nibbles the other way round, most
significant first and with no suffix. That is the reverse name with neither
step applied, and it is worth having on its own because the nibble form turns
up where no lookup does: SPF's %{i} macro expands an IPv6 client to exactly
this and leaves the reversing to a separate transformer.
Note that an IPv4-mapped IPv6 address gets an ip6.arpa name like any other
IPv6 address, which is almost certainly not the name that answers; convert it
with toIpv4Mapped first if the IPv4 name is the one wanted.
When the version is not known ahead of time, use the union types:
const address = try z46.Address.parse("2001:db8::1");
switch (address) {
.ipv4 => |v4| ...,
.ipv6 => |v6| ...,
}
Parsing the host out of a URL, where an IPv6 address arrives in brackets and an IPv4 address does not:
const host = try z46.Address.parseWithOptions(text, .{ .allow_brackets = true });
When the port comes along too, parse the authority as an endpoint:
const endpoint = try z46.Endpoint.parse("[2001:db8::1]:443");
const connect_to = endpoint.toStdAddress(); // a std.Io.net.IpAddress
Validation alone needs no error handling:
if (!z46.Network.isValid(user_input)) return error.BadConfig;
Types
| Type | Holds |
|---|---|
Ipv4Address |
a 32-bit address |
Ipv6Address |
a 128-bit address |
Ipv4Network |
an IPv4 network in CIDR notation |
Ipv6Network |
an IPv6 network in CIDR notation |
Ipv4Interface |
an IPv4 address with the prefix length of its network |
Ipv6Interface |
an IPv6 address with the prefix length of its network |
Ipv4Range |
an inclusive range of IPv4 addresses |
Ipv6Range |
an inclusive range of IPv6 addresses |
Ipv4Endpoint |
an IPv4 address paired with a port |
Ipv6Endpoint |
an IPv6 address paired with a port |
Address |
a tagged union of either address type |
Network |
a tagged union of either network type |
Interface |
a tagged union of either interface type |
Range |
a tagged union of either range type |
Endpoint |
a tagged union of either endpoint type |
Family |
.ipv4 or .ipv6 |
Scope |
the one category an address falls into |
Every type provides parse, isValid, format (via the {f} specifier),
bufPrint, eql, order, and the jsonStringify and jsonParse that
std.json looks for. Addresses add fromInt/toInt, conversions
between the two versions, next, prev, add and sub for stepping through
the address space, reverseName and writeReverseName for the name
the address is looked up under in the reverse DNS, and a set of classification
predicates
(isLoopback, isPrivate, isLinkLocal, isMulticast, isDocumentation,
isGlobal and more) that follow the IANA special-purpose address registries.
Networks add netmask, hostmask, networkAddress, broadcastAddress or
lastAddress, addressCount, hostCount, firstHost, lastHost,
isSingleAddress, contains, containsNetwork, overlaps, supernet,
exclude, join, merge, and the subnets, addresses and hosts
iterators.
Which category an address is in
The predicates each answer one question, and asking "what is this address"
with them means calling a dozen of them in an order that matters:
255.255.255.255 is inside 240.0.0.0/4, so checking the reserved block
first gets the wrong answer. scope runs that cascade once and returns a
single Scope:
switch (address.scope()) {
.private, .loopback, .link_local => return error.NotRoutable,
.global => {},
else => |other| log.warn("unexpected {f} address", .{other}),
}
It is a summary and not a substitute: an address can be a documentation
address and inside 2001::/23, and only the predicates will say so.
Writing an IPv6 address out in full
format writes the canonical RFC 5952 form, which is the one an address
should be published as. writeExpanded writes the other one — every group at
its full four digits, no :: — because it is the only form with a fixed
width:
var buffer: [z46.Ipv6Address.max_expanded_len]u8 = undefined;
const text = try address.expanded(&buffer);
// 2001:0db8:0000:0000:0000:0000:0000:0001
try w.print("{f}", .{std.fmt.alt(address, .writeExpanded)});
That fixed width is what makes a column of addresses line up, makes them sort
lexically in the same order they sort numerically, and makes a regenerated
configuration diff a line at a time. IPv4 has no separate expanded form — its
text is already fixed in structure, and padding the octets would produce the
leading zeros this library refuses to parse — so Address.writeExpanded
writes an IPv4 address exactly as format does.
An interface is a network read the other way round. A network is
192.168.1.0/24 and rejects anything else; an interface is 192.168.1.42/24,
keeping the host bits that identify one machine on that network, so it is the
type for what is configured on a link or read back out of ip addr. It adds
network(), which hands back the Ipv4Network or Ipv6Network it sits on,
along with netmask, hostmask, networkAddress, broadcastAddress or
lastAddress, hostBits for the host part on its own, and the
isNetworkAddress, isBroadcastAddress and isHostAddress predicates that
say whether the address is one a host may actually be given.
Endpoints hold an address and a port and convert to and from the standard
library's std.Io.net socket address types, and to and from the C socket
address structures.
Ranges, and the networks that cover them
10.0.0.10-10.0.0.20 is a perfectly ordinary thing for a DHCP pool or an
access list to name, and it is not a network: it does not start on a
power-of-two boundary and its size is not a power of two. CIDR notation cannot
write it down at all. Ipv4Range and Ipv6Range hold what was meant, and
prefixes hands over the blocks it takes to cover it:
const pool = try z46.Ipv4Range.parse("10.0.0.10-10.0.0.20");
var it = pool.prefixes();
while (it.next()) |network| std.debug.print("{f}\n", .{network});
// 10.0.0.10/31
// 10.0.0.12/30
// 10.0.0.16/30
// 10.0.0.20/32
The blocks are the smallest set that covers the range exactly: nothing outside
it, everything inside it, no two overlapping. They come out in ascending
address order and are not all the same size, which is why this is not a
conversion anyone does by hand. toNetwork returns the single network when
the range happens to be one, and null when it is not.
Nothing in the IETF documents defines a text form for a range, so the one
parsed and written here — two addresses with a - between them and no spaces
— is a convention rather than a standard. It is the one nmap and most access
lists use.
Going the other way, exclude takes one network out of another:
const allowed = try z46.Ipv4Network.parse("192.0.2.0/24");
const blocked = try z46.Ipv4Network.parse("192.0.2.0/26");
var it = allowed.exclude(blocked);
while (it.next()) |network| std.debug.print("{f}\n", .{network});
// 192.0.2.128/25
// 192.0.2.64/26
Removing one prefix from another cannot leave one prefix behind, so the answer is an iterator — at most 32 networks for IPv4 and 128 for IPv6, allocating nothing, largest block first. Nothing is an error: a network that does not overlap is not removed and comes back whole, and one that covers everything leaves nothing behind.
merge is the operation that keeps a generated list from growing without
bound. It sorts a slice in place and folds it down to the smallest set of
networks covering exactly the same addresses:
var list = [_]z46.Ipv4Network{
try .parse("192.0.2.64/26"),
try .parse("192.0.2.0/26"),
try .parse("192.0.2.128/25"),
};
for (z46.Ipv4Network.merge(&list)) |network| std.debug.print("{f}\n", .{network});
// 192.0.2.0/24
Two things are folded away: a network already inside another disappears, and
two networks that are the two halves of one larger network become that larger
network. The second is applied repeatedly, which is why the three entries
above come back as one rather than two — the two /26s make a /25, and that
/25 and the next one make the /24. Ipv4Network.join is that step on its
own, for when the question is whether two entries in a list can be written as
one.
This is the one operation here that takes a mutable slice rather than
returning something. Sorting is all it needs and sorting can be done in place,
so the caller keeps ownership of the storage and decides where it came from;
what is past the returned slice is still the caller's networks, merely no
longer needed, rather than freed memory. Network.merge takes a mixed slice
and merges the two versions independently, since nothing folds across the
boundary.
The result is checked against a bitmap of every address covered, built one address at a time over two thousand random inputs, which is the only way to be sure a fold was neither missed nor overreached.
Ipv4Range.merge is the same operation on ranges, and it folds further.
Networks combine only when they are the two halves of a larger network, which
is a question about alignment; ranges combine whenever no address separates
them, which is a question about nothing but the addresses:
var pools = [_]z46.Ipv4Range{
try .parse("10.0.0.6-10.0.0.9"),
try .parse("10.0.0.1-10.0.0.5"),
};
const one = z46.Ipv4Range.merge(&pools);
// one.len == 1, holding 10.0.0.1-10.0.0.9
The two networks covering those pools would have stayed apart. Folding as
ranges and re-splitting with prefixes afterwards therefore gives a CIDR set
no larger than merging as networks would have, which is what the command line
tool's --merge does.
What an IPv6 address is carrying
Several IPv6 ranges are not just addresses but packed structures, and the predicates that recognise them are only half the answer. These pull the contents out:
// 6to4 (RFC 3056) names the IPv4 host doing the tunnelling.
const v4 = (try z46.Ipv6Address.parse("2002:c000:204::1")).sixtofour().?;
// Teredo (RFC 4380) packs a whole tunnel endpoint into one address.
const t = (try z46.Ipv6Address.parse("2001:0:4136:e378:8000:63bf:3fff:fdd2")).teredo().?;
// t.server 65.54.227.120, t.client 192.0.2.45, t.port 40000
The client address and port are stored complemented in the address, because a NAT rewriting addresses it finds in payloads would otherwise rewrite these too and break the tunnel. They come back in the clear.
NAT64 is the awkward one. RFC 6052 §2.2 defines six layouts, one per
translation prefix length, and only a /32 and a /96 keep the four octets
of the embedded IPv4 address together — bits 64 to 71 are reserved, and the
address has to step over them:
const embedded = try address.translatedIpv4(64);
Nothing in the address says which layout was used, which is why this takes a
prefix length rather than working it out. A caller that does not know its own
translation prefix cannot ask the question at all; the well-known prefix
64:ff9b::/96 is the one case where the length is known without being
configured. (Building such an address is not provided — that is the
translator's job, not a parser's.)
Two derived addresses that nothing else in this family of libraries provides:
// The group neighbour discovery asks on (RFC 4291 §2.7.1).
const group = address.solicitedNodeMulticast();
// The address a MAC gives itself under SLAAC (RFC 4862, RFC 4291 appendix A).
const slaac = z46.Ipv6Address.fromMac(try .parse("2001:db8:1:2::"), mac);
const back = slaac.macFromInterfaceIdentifier(); // ?[6]u8
The ff fe that marks a MAC-derived identifier is two bytes, so an identifier
that was never derived from one has about a one in 65536 chance of answering
anyway. Read macFromInterfaceIdentifier as "shaped like a MAC-derived
address" rather than as proof.
Text that means two things
parse is strict because the alternative is a security problem, and this is
the function that makes the argument concrete:
std.debug.assert(z46.Ipv4Address.isAmbiguous("0x7f.0.0.1"));
std.debug.assert(z46.Ipv4Address.isAmbiguous("127.1"));
std.debug.assert(z46.Ipv4Address.isAmbiguous("127.0.0.1 evil"));
std.debug.assert(!z46.Ipv4Address.isAmbiguous("127.0.0.1"));
A program that checks an address against a deny list with a strict parser and
then hands the original text to something that resolves or connects with the
system's own parser has two readings of one string, and a filter that can be
walked straight past. isAmbiguous says which text to reject loudly:
parseLegacy gives the other reading, for the log line that explains why.
parseLegacy accepts everything inet_aton does — fewer than four parts,
octal from a leading 0, hexadecimal from a leading 0x. It also stops at
the first whitespace character and discards everything after it, which is not
a shorthand but a consequence of how the C function decides it has finished,
and which turns a trailing field of any kind into something a C parser
silently throws away. That behaviour was measured against glibc rather than
read off the manual page, and the two agree on 24,000 generated strings.
The command line tool says so when it meets such text:
$ z46 0x7f.0.0.1
0x7f.0.0.1
invalid address: InvalidCharacter
ambiguous a C library's inet_aton reads this as 127.0.0.1
There is no IPv6 counterpart: inet_pton has no shorthands to disagree about.
JSON
Every type declares jsonStringify and jsonParse, which std.json picks up
by name, so each is a JSON string holding exactly what format writes:
const Config = struct { listen: z46.Endpoint, allow: []const z46.Network };
const parsed = try std.json.parseFromSlice(Config, allocator, text, .{});
Parsing is as strict inside a document as anywhere else, so a configuration
naming 192.168.01.1 is a configuration with a bad address in it. The one
thing lost is the specific reason: std.json's error set is closed and
everything arrives as error.InvalidCharacter, so a program that has to tell
a user what was wrong with what they wrote should read the field as a string
and call parse itself. Reading a string is bounded by the type's own
max_text_len, so a document offering a megabyte-long "address" is refused
before it is assembled.
C socket addresses
std.Io.net is what most Zig code wants, but a bind, a connect, a
getaddrinfo result or anything reached through std.posix deals in the C
structures instead. Addresses and endpoints fill one in through a pointer and
read one back:
var storage: std.posix.sockaddr.storage = undefined;
const endpoint = try z46.Endpoint.parse("[2001:db8::1]:443");
// Returns the number of bytes written: the socklen_t that goes with it.
const len = endpoint.writeSockaddr(&storage);
try std.posix.connect(fd, @ptrCast(&storage), len);
// And back again, deciding the version from the structure's family.
const peer = try z46.Endpoint.fromSockaddr(&storage);
std.posix.sockaddr, std.c.sockaddr and std.os.linux.sockaddr are one
family of structures under three names, and nothing in the signature names any
of them: the pointer is taken as anytype and its type says which shape it
points at — a sockaddr_in, a sockaddr_in6, or the family-agnostic
sockaddr and sockaddr_storage that carry either. So the call compiles
against whichever namespace the surrounding code already uses, and a pointer
that is not a socket address at all is a compile error naming the type.
The AF_INET and AF_INET6 values come from that same namespace rather than
from the host, read off the defaults its own in and in6 declarations give
their family fields. That is worth doing rather than assuming, because
AF_INET6 is 10 on Linux, 30 on Darwin, 28 on FreeBSD and 23 on Windows: a
constant taken from the wrong namespace is wrong silently.
| Call | Effect |
|---|---|
Ipv4Address.writeSockaddr(port, sa) |
fills in a sockaddr_in |
Ipv6Address.writeSockaddr(port, sa) |
fills in a sockaddr_in6 |
Ipv4Endpoint.writeSockaddr(sa) |
fills in a sockaddr_in |
Ipv6Endpoint.writeSockaddr(sa) |
fills in a sockaddr_in6 |
Address.writeSockaddr(port, sa) |
fills in whichever the version calls for |
Endpoint.writeSockaddr(sa) |
fills in whichever the version calls for |
T.fromSockaddr(sa) |
reads one back, for any of those T |
Ipv6Address.sockaddrOptions(sa) |
the flow label and scope identifier |
The whole structure is written each time, so nothing is left undefined — the
sin_zero padding included, which some kernels compare. Writing an IPv6
address leaves the flow label and the scope identifier zero;
writeSockaddrWithOptions sets them, and a link-local address wants it, since
fe80::1 means nothing without the interface it is scoped to.
Reading is where it can fail, and only in one way:
error.UnexpectedAddressFamily when the structure's family is not one the
type being read accepts. A
pointer to an in or an in6 has already settled the version and its family
is checked against the one that type declares; a pointer to a sockaddr or a
sockaddr_storage is read as whichever version its family names.
A sockaddr pointer says nothing about how much storage is really behind it —
that is as true here as it is in C, because it is the same pointer. A
sockaddr_in6 is 28 bytes where a sockaddr_in is 16, so a *sockaddr
written through has to point at something large enough for the version being
written; sockaddr.storage is the usual way to have that, and is what
accept and recvfrom are given. A pointer to an in or an in6 carries its
own size and needs no such care: writing the wrong version into one is a
compile error, as is asking either union type to write into one at all, since
the version is not known until run time.
Parsing is strict
Only canonical text is accepted, because the alternative is a security
problem: a parser that reads 192.168.01.1 as octal and another that reads it
as decimal will disagree about which network an address belongs to.
-
Exactly four decimal octets for IPv4. The
inet_atonshorthands (127.1,0x7f.0.0.1, octal octets) are rejected. -
No leading zeros in an IPv4 octet or in a prefix length.
-
No leading or trailing whitespace.
-
IPv6 accepts
::compression, uppercase hex and a trailing dotted-quad (::ffff:192.0.2.1), and rejects a::that compresses nothing. -
A zone identifier (
fe80::1%eth0) is rejected byIpv6Address.parse;Ipv6Address.parseZonedaccepts one and hands back the zone text. -
The square brackets a URL puts around an IPv6 address are rejected too.
Ipv6Address.parseWithOptions(text, .{ .allow_brackets = true })accepts both[2001:db8::1]and the bare form, withparseZonedWithOptionsdoing the same for[fe80::1%eth0]andAddress.parseWithOptionstaking the same option for text that could be either version. Only the address is parsed, so an authority such as[2001:db8::1]:443has to have its port split off first. -
An endpoint is an address and a port, so a bare address is
error.MissingPort. An IPv6 endpoint must bracket its address ([2001:db8::1]:443); without brackets the:before the port cannot be told from the colons in the address, which is reported aserror.MissingBracketsrather than as a malformed address. Ports are parsed as strictly as everything else: no leading zeros, and nothing above 65535. Port 0 is accepted, since that is how a caller asks the operating system to pick one. -
Networks must have their host bits clear:
192.168.1.1/24iserror.HasHostBits. UseparseTruncateto clear them instead, or one of the interface types to keep them. An IPv4 network or interface may be written with a dotted netmask (10.0.0.0/255.0.0.0), and text with no/is read as a single host. -
A network can also be parsed by the rules of another standard, through
parseWithOptions.Ipv4Network.ParseOptions.spfand its IPv6 counterpart are theip4:andip6:networks of an SPF record (RFC 7208 §5.6):// The netmask spelling is not in SPF's grammar at all... try expectError( error.InvalidPrefix, z46.Ipv4Network.parseWithOptions("10.0.0.0/255.0.0.0", .spf), ); // ...while host bits are, since the mechanism compares only the // high-order bits, so `ip4:192.0.2.130/24` means `192.0.2.0/24`. const network = try z46.Ipv4Network.parseWithOptions("192.0.2.130/24", .spf); std.debug.assert(network.eql(try z46.Ipv4Network.parse("192.0.2.0/24")));Everything else §5.6 asks for — four
qnums with no leading zeros, a prefix length of one or two digits (three for IPv6) with none of its own, an omitted length meaning/32or/128— is what this parser does anyway. Strip theip4:first; only the network is parsed. The two switches are separate, so a validator that wants to call a record sloppy rather than read it the way a checker would can ask for.{ .allow_netmask = false }and keeperror.HasHostBits.
Errors are specific — error.LeadingZero, error.OctetOutOfRange,
error.RedundantEllipsis, error.HasHostBits — so a program can explain what
was wrong with what a user typed.
Output is canonical too: IPv6 follows RFC 5952 (lowercase, no leading zeros,
longest run of zero groups compressed, leftmost run on a tie). IPv4-mapped
addresses print as ::ffff:a.b.c.d; the deprecated IPv4-compatible range
::/96 prints in plain hex, so ::3 does not come out as ::0.0.0.3.
Using it as a dependency
zig fetch --save https://git.jcollie.dev/jeff/z46/archive/v0.2.0.tar.gz
Fetching a tag keeps the hash stable; archive/main.tar.gz tracks the branch
instead and changes hash on every push.
// build.zig
const z46 = b.dependency("z46", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("z46", z46.module("z46"));
Command line tool
The package also builds a small z46 binary that validates and describes its
arguments. Text holding a - is read as a range, text holding a / as a
network when its host bits are clear and as an interface when they are not,
text carrying a port as an endpoint, and everything else as an address:
$ z46 192.168.1.42 '[2001:db8::1]:443' 2001:db8::/126
192.168.1.42
family IPv4
canonical 192.168.1.42
scope private
reverse 42.1.168.192.in-addr.arpa
as IPv6 ::ffff:192.168.1.42
[2001:db8::1]:443
family IPv6
canonical [2001:db8::1]:443
address 2001:db8::1
port 443
scope documentation
reverse 1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa
expanded 2001:0db8:0000:0000:0000:0000:0000:0001
2001:db8::/126
family IPv6
canonical 2001:db8::/126
network 2001:db8::
netmask ffff:ffff:ffff:ffff:ffff:ffff:ffff:fffc
hostmask ::3
last 2001:db8::3
hosts 2001:db8::1 - 2001:db8::3 (3)
addresses 4
An interface is described as the address it carries and then as the network it sits on:
$ z46 192.168.1.42/24
192.168.1.42/24
family IPv4
canonical 192.168.1.42/24
address 192.168.1.42
network 192.168.1.0
netmask 255.255.255.0
hostmask 0.0.0.255
broadcast 192.168.1.255
hosts 192.168.1.1 - 192.168.1.254 (254)
scope private
reverse 42.1.168.192.in-addr.arpa
as IPv6 ::ffff:192.168.1.42
A range is described by its two ends and then by the blocks it takes to cover it, which is the form a routing table or a firewall wants:
$ z46 10.0.0.10-10.0.0.20
10.0.0.10-10.0.0.20
family IPv4
canonical 10.0.0.10-10.0.0.20
first 10.0.0.10
last 10.0.0.20
addresses 11
networks 10.0.0.10/31 10.0.0.12/30 10.0.0.16/30 10.0.0.20/32
--merge folds the arguments together instead of describing them. Everything
is read as a range — a bare address is a range of one, a network is the range
it covers — merged, and printed as the smallest set of CIDR networks covering
exactly the same addresses, which is the form a routing table or a firewall
takes:
$ z46 --merge 192.0.2.0/26 192.0.2.64/26 192.0.2.128/25 10.0.0.5 2001:db8::/33 2001:db8:8000::/33
10.0.0.5/32
192.0.2.0/24
2001:db8::/32
Reading everything as a range rather than as a network is what makes this the tightest answer available: two networks that merely touch cannot be written as one network but are one range, and re-splitting afterwards costs nothing. The two versions fold separately and IPv4 is printed first.
It exits 1 if any argument is invalid, so z46 -q "$input" works as a
validation check in a script. Note that this accepts an interface wherever it
accepts a network, so 10.0.0.1/8 passes; the library's Network.isValid is
the stricter check.
The flake packages the tool, so it runs without a checkout or a Zig toolchain:
$ nix run git+https://git.jcollie.dev/jeff/z46.git -- 192.168.1.42/24
$ nix profile install git+https://git.jcollie.dev/jeff/z46.git
nix build in a checkout leaves it at result/bin/z46. The package is built
ReleaseSafe for a baseline CPU and runs the whole test suite before it
installs anything.
Tests
zig build test
The suite covers the RFC text forms, the malformed text each error is meant to
catch, the classification registries, iteration at the edges of the address
space, and 10,000 seeded random round trips per type. There are also three
std.testing.fuzz entry points, which run once each under zig build test
and keep going under zig build test --fuzz:
zig build test --fuzz # until interrupted, with a web interface
zig build test --fuzz=1M # a bounded run, then a report
The report lists one entry per test binary, named after its first fuzz test, but every fuzz test in that binary runs. The library's test binary is always compiled by LLVM, even in Debug, because the fuzzer's coverage feedback and kcov both need what the self-hosted backend leaves out.
Every function additionally has a test named after it — test parse, test isGlobal — which Zig attaches to that declaration and the documentation
viewer shows as its worked example. They are written to be read as examples
rather than to be exhaustive, so each one shows the ordinary use and the one
edge that is easy to get wrong; the behavior tests around them carry the rest.
The private helpers have them too, where the example is what the public
function above cannot show: which byte order a field is in, what a checksum is
made of, which of two readings a piece of punctuation picks. Writing one for a
new function is part of adding it. The only functions without one are the three
main entry points, which take what the process gives them.
zig build test builds four test binaries, one per module: the library, the
CLI, the documentation server and the benchmark harness.
zig build coverage runs the library's tests under kcov and writes an HTML
report to zig-out/coverage, currently 99.67% of the 21 source files. It needs
kcov on the PATH, which the nix dev shell provides. The other three test
binaries are not included: kcov runs them without complaint but records no
lines from them.
Benchmarks
zig build bench
This times the parsing, formatting and classification paths of every type over
seeded random corpora, always as a ReleaseFast build whatever -Doptimize
says, and reports the best of twenty passes for each. On one 2026 x86-64
machine, parsing sits at around 16 ns for an IPv4 address and 45–60 ns for an
IPv6 address depending on its shape, and formatting at around 11 ns and 23 ns;
the network, interface and endpoint types cost a few nanoseconds beyond the
address they wrap, and a contains check is fractions of a nanosecond. The
interface types look slower to parse than the networks only because their text
is longer: host bits that a network truncates away are digits an address
parser still has to read. A range holds two addresses and costs about two
addresses' worth to parse and to format.
prefixes is the one line that is not a per-operation cost in the same sense.
It is timed over the whole cover rather than over one block, so what it
reports is the price of a range, and that scales with how many networks the
range takes — one for a range that happens to be a network, tens of them for
a wide one whose ends line up badly. The corpus draws its spans
log-uniformly for that reason: uniformly random ends would be nearly the whole
address space every time and would measure only the worst case.
Run it on your own machine rather than trusting any of those numbers.
RFCs referenced
The behaviour of this library is drawn from the documents below. Each is cited
in a doc comment next to the code that implements it, so zig build docs and
the source agree with this table.
Notation and text representation
| RFC | Title | Used for |
|---|---|---|
| 791 | Internet Protocol | Dotted-decimal IPv4 addresses; 0.0.0.0/8 as "this network" |
| 3986 | Uniform Resource Identifier (URI): Generic Syntax | The square brackets around an IPv6 address in a URL authority, accepted by Ipv6Address.parseWithOptions |
| 1035 | Domain Names - Implementation and Specification | §3.5's in-addr.arpa names, written by Ipv4Address.reverseName |
| 3596 | DNS Extensions to Support IP Version 6 | §2.5's ip6.arpa nibble names, written by Ipv6Address.reverseName |
| 4007 | IPv6 Scoped Address Architecture | Zone identifiers (fe80::1%eth0), accepted by Ipv6Address.parseZoned |
| 4291 | IP Version 6 Addressing Architecture | The IPv6 text form: :: compression, the trailing dotted-quad, ::, ::1, IPv4-mapped and IPv4-compatible addresses |
| 4632 | Classless Inter-domain Routing (CIDR): The Internet Address Assignment and Aggregation Plan | The address/prefix-length notation the network and interface types parse and print |
| 5952 | A Recommendation for IPv6 Address Text Representation | Canonical IPv6 output: lowercase, no leading zeros, longest zero run compressed, leftmost run on a tie |
| 6874 | Representing IPv6 Zone Identifiers in Address Literals and Uniform Resource Identifiers | The %25 escape for a zone in a URI, which parseZonedWithOptions leaves for the caller to undo |
| 7208 | Sender Policy Framework (SPF) for Authorizing Use of Domains in Email, Version 1 | §5.6's ip4-network and ip6-network, parsed by Ipv4Network.parseWithOptions(text, .spf) and its IPv6 counterpart |
Address structure
| RFC | Title | Used for |
|---|---|---|
| 3021 | Using 31-Bit Prefixes on IPv4 Point-to-Point Links | Both addresses of an IPv4 /31 counting as usable hosts |
| 4291 | IP Version 6 Addressing Architecture | §2.6.1's Subnet-Router anycast address, the one address Ipv6Network.hostCount holds back; §2.7.1's solicited-node multicast group, written by solicitedNodeMulticast; appendix A's modified EUI-64, which interfaceIdentifier builds |
| 4862 | IPv6 Stateless Address Autoconfiguration | The address a host forms for itself out of a prefix and its own MAC, which Ipv6Address.fromMac builds |
| 8981 | Temporary Address Extensions for Stateless Address Autoconfiguration | Why a host may have no MAC-derived address to find, noted on fromMac |
| 6164 | Using 127-Bit IPv6 Prefixes on Inter-Router Links | Both addresses of an IPv6 /127 counting as usable hosts |
| 6890 | Special-Purpose IP Address Registries | The registry framework behind isGlobal; 192.0.0.0/24 IETF protocol assignments |
| 7346 | IPv6 Multicast Address Scopes | The scope nibble returned by Ipv6Address.multicastScope |
IPv4 special-purpose ranges
| RFC | Title | Range |
|---|---|---|
| 919 | Broadcasting Internet Datagrams | 255.255.255.255, the limited broadcast address |
| 1112 | Host extensions for IP multicasting | 240.0.0.0/4, reserved for future use |
| 1122 | Requirements for Internet Hosts - Communication Layers | 127.0.0.0/8, loopback |
| 1918 | Address Allocation for Private Internets | 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 |
| 2544 | Benchmarking Methodology for Network Interconnect Devices | 198.18.0.0/15 |
| 3927 | Dynamic Configuration of IPv4 Link-Local Addresses | 169.254.0.0/16 |
| 5737 | IPv4 Address Blocks Reserved for Documentation | 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 |
| 5771 | IANA Guidelines for IPv4 Multicast Address Assignments | 224.0.0.0/4 |
| 6598 | IANA-Reserved IPv4 Prefix for Shared Address Space | 100.64.0.0/10, carrier-grade NAT |
| 7526 | Deprecating the Anycast Prefix for 6to4 Relay Routers | 192.88.99.0/24 |
| 7600 | IPv4 Residual Deployment via IPv6 — A Stateless Solution | 192.0.0.8, the IPv4 dummy address |
| 7723 | Port Control Protocol (PCP) Anycast Addresses | 192.0.0.9, globally reachable despite the block around it |
| 8155 | Traversal Using Relays around NAT (TURN) Server Auto Discovery | 192.0.0.10, likewise |
IPv6 special-purpose ranges
| RFC | Title | Range |
|---|---|---|
| 3056 | Connection of IPv6 Domains via IPv4 Clouds | 2002::/16, 6to4, whose embedded IPv4 address sixtofour returns |
| 3849 | IPv6 Address Prefix Reserved for Documentation | 2001:db8::/32 |
| 4193 | Unique Local IPv6 Unicast Addresses | fc00::/7 |
| 4380 | Teredo: Tunneling IPv6 over UDP through Network Address Translations (NATs) | 2001::/32; §4's layout, which teredo unpacks |
| 6052 | IPv6 Addressing of IPv4/IPv6 Translators | 64:ff9b::/96, the NAT64 well-known prefix; §2.2's six layouts, read by translatedIpv4 |
| 6666 | A Discard Prefix for IPv6 | 100::/64 |
| 7343 | An IPv6 Prefix for Overlay Routable Cryptographic Hash Identifiers Version 2 (ORCHIDv2) | 2001:20::/28 |
| 2928 | Initial IPv6 Sub-TLA ID Assignments | 2001::/23, IETF protocol assignments |
| 3879 | Deprecating Site Local Addresses | fec0::/10, reported by isSiteLocal and by Scope.site_local |
| 5180 | IPv6 Benchmarking Methodology for Network Interconnect Devices | 2001:2::/48 |
| 7450 | Automatic Multicast Tunneling | 2001:3::/32, globally reachable inside 2001::/23 |
| 7535 | AS112 Redirection Using DNAME | 2001:4:112::/48, likewise |
| 7723 | Port Control Protocol (PCP) Anycast Addresses | 2001:1::1, likewise |
| 8155 | Traversal Using Relays around NAT (TURN) Server Auto Discovery | 2001:1::2, likewise |
| 8215 | Local-Use IPv4/IPv6 Translation Prefix | 64:ff9b:1::/48, which unlike the well-known prefix does not route |
| 9374 | DRIP Entity Tag (DET) for Unmanned Aircraft System Remote ID | 2001:30::/28 |
| 9602 | Segment Routing over IPv6 (SRv6) Segment Identifiers in the IPv6 Addressing Architecture | 5f00::/16 |
| 9637 | Expanding the IPv6 Documentation Space | 3fff::/20, the second documentation block |
| 9665 | Service Registration Protocol for DNS-Based Service Discovery | 2001:1::3, globally reachable inside 2001::/23 |
| 9780 | Bidirectional Forwarding Detection (BFD) for Multipoint Networks over Point-to-Multipoint MPLS LSPs | 100:0:0:1::/64, the dummy prefix its §7.1 allocates |
Ranges that RFC 4291 defines directly — ::, ::1, ::ffff:0:0/96, ::/96,
fe80::/10 and ff00::/8 — are listed under notation above rather than
repeated here.
The IPv6 table is longer than its IPv4 counterpart mostly because of
2001::/23. The registry marks the whole block as not globally reachable and
then carves out of it several assignments that are, so each of those has to be
known by name or isGlobal would hide it behind the block it sits in.
Where this lives
The canonical repository is on Forgejo:
$ git clone https://git.jcollie.dev/jeff/z46.git
It is also on the Radicle network, where the repository's identifier is
rad:z2PkQaFgcSqhiwc9T9iSA3JjAJh1z
and rad clone rad:z2PkQaFgcSqhiwc9T9iSA3JjAJh1z fetches it from any node that
seeds it, needing no account and no forge. Further copies are mirrored to
https://codeberg.org/jcollie/z46 and https://tangled.org/jcollie.dev/z46.
All four carry the same history on main; the Forgejo one is where the tests
and the documentation build run.
Requirements
Zig 0.17.0. The development shell and the Nix package take the official release binary from zig-overlay.
For Zig 0.16.0, use the zig-0.16 branch, which holds the last of z46 to
build with it:
$ git clone -b zig-0.16 https://git.jcollie.dev/jeff/z46.git
or, as a dependency:
zig fetch --save https://git.jcollie.dev/jeff/z46/archive/zig-0.16.tar.gz
The v0.1.0 tag also predates the move to 0.17 and builds with 0.16.0.