An allocation-free Zig library for parsing, validating, formatting and classifying IPv4 and IPv6 addresses, networks, ranges and endpoints.
  • Zig 99.4%
  • Nix 0.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie cde52f655f
All checks were successful
test / docs (push) Successful in 5m6s
test / test (push) Successful in 9m1s
Release 0.2.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FSMLHororSJMQozqXeqGwD
2026-10-02 18:55:09 -05:00
.forgejo/workflows Package the command line tool with Nix 2026-09-25 16:42:17 -05:00
LICENSES Add a Forgejo Actions workflow 2026-08-27 21:52:01 -05:00
src Sweep Ipv4Range.merge against a bitmap of the addresses covered 2026-09-30 21:56:09 -05:00
tools Use the current names for the std.mem search functions 2026-09-23 02:51:14 -05:00
.gitignore Package the command line tool with Nix 2026-09-25 16:42:17 -05:00
build.zig Compile the library's tests with LLVM so fuzzing and kcov work in Debug 2026-09-30 21:56:04 -05:00
build.zig.zon Release 0.2.0 2026-10-02 18:55:09 -05:00
flake.lock Build with the Zig 0.17.0 release 2026-10-02 16:49:27 -05:00
flake.nix Build with the Zig 0.17.0 release 2026-10-02 16:49:27 -05:00
package.nix Build with Zig 0.17 from zig-overlay 2026-09-30 21:55:37 -05:00
README.md Release 0.2.0 2026-10-02 18:55:09 -05:00
REUSE.toml Add a Forgejo Actions workflow 2026-08-27 21:52:01 -05:00

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_aton shorthands (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 by Ipv6Address.parse; Ipv6Address.parseZoned accepts 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, with parseZonedWithOptions doing the same for [fe80::1%eth0] and Address.parseWithOptions taking the same option for text that could be either version. Only the address is parsed, so an authority such as [2001:db8::1]:443 has 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 as error.MissingBrackets rather 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/24 is error.HasHostBits. Use parseTruncate to 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.spf and its IPv6 counterpart are the ip4: and ip6: 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 /32 or /128 — is what this parser does anyway. Strip the ip4: 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 keep error.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.