A URI parser and formatter for Zig, modelled on Python's hyperlink package, with LDAP URLs, file: URIs and sdns:// DNS stamps beside it.
  • Zig 94.4%
  • JavaScript 2.8%
  • Nix 1%
  • HTML 1%
  • Python 0.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 0c174b5afc
All checks were successful
test / web (push) Successful in 16m35s
test / reuse (push) Successful in 1m29s
test / test (push) Successful in 12m17s
test / docs (push) Successful in 6m11s
Move to Unicode 18.0.0, and release 0.4.0
The UTS #46 mapping table is regenerated from the 18.0.0
IdnaMappingTable.txt (UTS #46 revision 36), and the committed conformance
sample from the 18.0.0 IdnaTestV2.txt and NormalizationTest.txt. The full
suites pass: 12,788 IDNA checks and 100,855 NFC checks, no failures.

The table had trailed at 16.0.0 since the README believed Unicode had
published no IDNA data for 17. It had, in a new place: from 17.0.0 the
files are under Public/<version>/idna/, and Public/idna/ stops at 16.0.0.
The generators now name that location, and the regenerate command in the
table's header carries the version argument it needs.

uucode moves to 1fb7343, the head of its main, which targets the Zig 0.17.0
release. Its Unicode 18.0.0 data was already in the previous pin, so the
character properties and the mapping table now agree on a version.

The references cite UTS #46 revision 36, UAX #15 revision 58 and UAX #44
revision 38.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UV3xh1gLDQDw3o5VMA5gVc
2026-10-05 20:03:37 -05:00
.forgejo/workflows Split the Unicode support into a module of its own, and release 0.3.0 2026-10-04 14:08:24 -05:00
LICENSES Add optional IDNA and IRI support 2026-08-27 17:07:35 -05:00
src Move to Unicode 18.0.0, and release 0.4.0 2026-10-05 20:03:37 -05:00
tools Move to Unicode 18.0.0, and release 0.4.0 2026-10-05 20:03:37 -05:00
web Split the Unicode support into a module of its own, and release 0.3.0 2026-10-04 14:08:24 -05:00
.gitignore Remove the result symlink committed by mistake 2026-09-26 21:39:42 -05:00
build.zig Split the Unicode support into a module of its own, and release 0.3.0 2026-10-04 14:08:24 -05:00
build.zig.zon Move to Unicode 18.0.0, and release 0.4.0 2026-10-05 20:03:37 -05:00
build.zig.zon.nix Move to Unicode 18.0.0, and release 0.4.0 2026-10-05 20:03:37 -05:00
flake.lock Build with Zig 0.17.0, and release 0.2.0 2026-10-02 20:33:27 -05:00
flake.nix Move to Unicode 18.0.0, and release 0.4.0 2026-10-05 20:03:37 -05:00
README.md Move to Unicode 18.0.0, and release 0.4.0 2026-10-05 20:03:37 -05:00
REUSE.toml Split the Unicode support into a module of its own, and release 0.3.0 2026-10-04 14:08:24 -05:00

zig-uri

A URI parser and formatter for Zig, modelled on Python's hyperlink package.

Requires Zig 0.17.0; the zig-0.16 branch and the v0.1.0 tag build with Zig 0.16.0 (see Using it as a dependency). Address literals are validated with z46. It is two modules: uri, the parser and formatter, which depends on nothing else; and uri_unicode, IDNA and IRI conversion, which needs uucode and is only fetched by a dependent that asks for it (see Using it as a dependency).

API documentation — generated from the source and published on every green build of main.

const uri = @import("uri");

const u = try uri.Uri.parse(alloc, "https://example.com/docs/intro.html?lang=en#top");
defer u.deinit();

u.scheme.?          // "https"
u.host.?.hostname   // "example.com"
u.rooted            // true
u.path              // .{ "docs", "intro.html" }
u.first("lang").?   // .{ .key = "lang", .value = "en" }
u.effectivePort()   // 443

const link = try u.click(alloc, "../api/v1/");
defer link.deinit();
// https://example.com/api/v1/

Components are stored decoded

Every string on a Uri is already percent-decoded, so you can use it directly:

const u = try uri.Uri.parse(alloc, "http://x/a%2Fb?q=1%262");
u.path[0]           // "a/b"   — one segment, not two
u.first("q").?.value.?  // "1&2"

Writing re-applies the encoding, escaping each component with the largest character set that cannot change how the result re-parses. Output is therefore minimally and canonically escaped rather than byte-identical to the input: %2f comes back as %2F, and %41 comes back as A. Parsing the output always yields exactly the same components, and writing it again is a fixed point. The test suite enforces both on every table case and on 20,000 randomized delimiter-heavy inputs.

Decoded values are byte strings, not validated UTF-8 — %FF is legal in a URI and carries no encoding guarantee.

By default a % that is not followed by two hex digits is kept as a literal %, which is what browsers do with the malformed URLs that occur in practice. Uri.parseOpts(alloc, text, .{ .strict_percent = true }) rejects it instead.

Ownership

A Uri owns an arena holding all of its strings, so every accessor is allocation-free and no field needs freeing individually. A Uri is immutable: replace, child, sibling, click, relativize, normalize, add, set and remove each return a new Uri with its own arena, independent of the receiver.

const a = try uri.Uri.parse(alloc, "http://example.com/a");
const b = try a.replace(alloc, .{ .scheme = "https" });
a.deinit();   // b is still valid
defer b.deinit();

Replace fields are doubly optional: omitting one keeps the current value, .port = 8080 sets it, and .port = @as(?u16, null) clears it.

API

Parsing and building

Uri.parse(alloc, text) parse a URI reference
Uri.parseOpts(alloc, text, opts) as above, with .strict_percent
Uri.init(alloc, components) build from loose Components
Uri.fromPath(alloc, path, opts) build a file: URI from a filesystem path
Uri.clone(alloc) / deinit() copy / free
parts() the components as borrowed plain data

Inspection

scheme, username, password, host, port, rooted, path, query, fragment are plain fields. Plus isAbsolute(), usesNetloc(), defaultPort(), effectivePort(), eql(), hash().

Text

write(w, opts), toText(alloc, opts), authority(alloc, opts), and {f} formatting. The password is withheld unless .with_password = true, so a stray log line cannot leak a credential.

Derivation

replace, child, sibling, click, clickUri, relativize, normalize.

File paths

Uri.fromPath and Uri.toPath convert between a filesystem path and the RFC 8089 file: URI naming it, in either platform's path syntax. The file submodule holds the same pair over plain Components, along with file.Platform. Always available. See File paths below.

Query

get, first, last, has, add, set, remove, removeWhere.

Unicode

TextOptions.form and the punycode submodule are in uri. toUri, toIri and the idna and normalization submodules are in the separate uri_unicode module. See IDNA and IRI below.

LDAP URLs

ldap.Url, also exported as LdapUrl, parses RFC 4516 ldap://, ldaps:// and ldapi:// URLs, whose ?-separated positional sections a generic URI parser cannot recover. Always available. See LDAP URLs below.

Data URLs

data.Url, also exported as DataUrl, parses RFC 2397 data: URLs — the media type, its parameters, and the document itself, decoded — which a generic parser splits at every / of a base64 body. Always available. See Data URLs below.

DNS stamps

stamp.Stamp, also exported as Stamp, parses the sdns:// strings a DNS resolver is published as — all eight protocols, from plain DNS to an oblivious DoH relay. Always available. See DNS stamps below.

Free functions

defaultPort, usesNetloc, percentEncode, percentDecode, plus the percent, parse, format, normalize, query, schemes, components, punycode, ldap, data and stamp submodules.

  • port is what was written. hyperlink's .port falls back to the scheme's default; here port stays null unless a port was present, so http://x and http://x:80 round-trip distinctly. Use effectivePort() for the fallback behaviour.
  • rooted and path instead of a leading empty segment. /a/b is rooted = true with path = .{ "a", "b" }.
  • An empty fragment is preserved. hyperlink cannot tell http://x/# from http://x/; here fragment is "" versus null.
  • click follows RFC 3986 §5.2.2 strictly. hyperlink raises NotImplementedError for a reference with a scheme and a rootless path; click("g:h") here returns g:h, as the RFC specifies. All 41 examples in RFC 3986 §5.4 are in the test suite.
  • // is written only for a real authority. hyperlink emits it for any scheme that could have one, turning http:/foo into http:///foo — which re-parses with an empty host rather than no host. Ask for an empty authority explicitly with .host = .{ .hostname = "" }.
  • Two default ports are corrected. nfs is 2049 rather than the portmapper's 111, and rtspu is 554 rather than 5005. Both are wrong in hyperlink; see Default ports below for the citations.
  • set appends when the key is absent, rather than inserting before the last parameter.
  • Address literals are validated. hyperlink accepts whatever sits between the brackets; here an IPv6 literal has to parse, and an IPv4 one has to be four dec-octets. See Address literals below.
  • Scheme-based normalization is opt-in. hyperlink's normalize() always gives an empty path a /; here that is .root_empty_path = true, alongside .drop_default_port, so the syntax-based rules RFC 3986 §6.2.2 gives stay separable from the scheme-based ones in §6.2.3.
  • A few inputs are accepted rather than refused. A control character in a component is escaped on the way out (#\n\n writes as #%0A%0A) instead of being a parse error, and an authority ending in a bare : has no port rather than being rejected, which is what RFC 3986 §3.2.3 allows and what browsers do.
  • IDNA and IRI are opt-in. hyperlink always has them; here they are a second module, uri_unicode, which keeps uri itself free of Unicode tables. Its toUri(alloc, u) and toIri(alloc, u) correspond to to_uri() and to_iri(). See IDNA and IRI below.

hyperlink's own round-trip corpus and the inputs its tests use to pin down corner cases are in src/tests_hyperlink.zig, along with every place the two libraries write the same URI differently — so a deliberate divergence cannot quietly become an accidental one.

Relative references

click resolves a reference against a base; relativize is the inverse, and finds a reference that resolves back to a given target. It is what generates the link from one document to another:

const base = try uri.Uri.parse(alloc, "https://example.com/docs/intro.html");
const target = try uri.Uri.parse(alloc, "https://example.com/api/v1/index.html");

const rel = try base.relativize(alloc, target, .{});
defer rel.deinit();
try rel.toText(alloc, .{});     // ../api/v1/index.html

RFC 3986 specifies resolution and says nothing about going the other way, so what counts as the answer is a choice. The one made here is the shortest reference that resolves to the target, preferring the forms §4.2 lists in the order it lists them — same-document, then relative path, then absolute path, then network path, and failing all of those the target itself.

The round trip is guaranteed by construction, not by hope. relativize resolves its own answer against the base and compares it with the target before returning it, falling back to a copy of the target when the two disagree. So:

const there_and_back = try base.clickUri(alloc, rel);
// equals base.clickUri(alloc, target), always

Two consequences follow from that check rather than from the algorithm:

  • Resolution removes dot segments, so a reference cannot reproduce them. A target of http://x/a/../b relativizes to something resolving to http://x/b, which is what resolving the target itself gives as well.
  • Some targets no reference can reach. http://x — an authority with no path at all — is one: any reference resolved against a base that has a path yields at least /. The answer there is the target, unchanged and still absolute, which isAbsolute() reports.

A differing scheme has the same answer, since a relative reference carries no scheme and can only be read back under the base's. A differing authority does too, unless .network_path = true asks for the protocol-relative form:

try base.relativize(alloc, other_host, .{ .network_path = true });
// //other.example/a/b

That is off by default because such a reference means different things under different schemes, and a caller relativizing across hosts is usually better told that it cannot be done. .absolute_path = true is the other option: it emits /a/b rather than walking up with .., which is what links that must survive the document itself moving want.

File paths

Uri.fromPath builds the RFC 8089 file: URI naming a filesystem path, and Uri.toPath reads one back:

const u = try uri.Uri.fromPath(alloc, "/home/jeff/a b.txt", .{});
defer u.deinit();
try u.toText(alloc, .{});               // file:///home/jeff/a%20b.txt
try u.toPath(alloc, .{});               // /home/jeff/a b.txt

A local path is written with an empty authority and three slashes, which is what browsers, editors and desktop environments expect. Reading accepts the other two forms RFC 8089 §2 makes equivalent: file://localhost/a and the authority-less file:/a.

.platform says whose path syntax is being read or written, and defaults to the platform being built for. Naming one explicitly — .{ .platform = .windows } — is how a test, or a tool handling paths from somewhere else, converts a Windows path on a POSIX machine. Windows has three shapes, and RFC 8089 Appendix E describes the first two:

C:\Users\me a drive path, whose letter becomes the first path segment: file:///C:/Users/me. The legacy file:///C|/Users/me reads back as the same path.
\\server\share\file a UNC path, whose server becomes the URI's host: file://server/share/file.
\Users\me rooted on whatever the current drive is. Not fully qualified, but exactly what file:///Users/me reads back as.

A POSIX path round-trips exactly, bytes and all — it is a split and a join, and a path is not required to be UTF-8. A Windows path round-trips canonically: both separators are accepted and only \ is written, so C:/a comes back as C:\a.

What has no conversion is refused rather than mangled, which is most of what the tests are about:

  • A relative path has no file: URI, since resolving one needs the working directory and that is I/O this library does not do. Ask your Io for the directory and descend with child.
  • A segment holding a separator has no path. file:///a%2Fb names one file called a/b, and writing it out would name two.
  • The Win32 device namespace — \\?\C:\x, \\.\PhysicalDrive0 — is not a share, and neither is the file://./x that would read back as one.
  • A path that would be ambiguous once written: \C: becomes file:///C:, which everything reading it takes for the drive, and file:////a would become a UNC prefix under an authority that says it is not one. (A colon cannot appear in a Windows filename anyway.)
  • A host this platform cannot reach by path, which on POSIX is every host but localhost.

Query and fragment are ignored by toPath, since neither names anything on a filesystem. Everything else about a file: URI is ordinary, so click, relativize and normalize all work on one.

Address literals

Hosts that look like IP addresses are checked with z46, which is a required dependency of uri — unlike the Unicode support, address literals are core RFC 3986 §3.2.2 syntax rather than an add-on.

The literal is validated, never rewritten. [1080:0:0:0:8:800:200C:417A] round-trips exactly as written rather than being canonicalised to [1080::8:800:200c:417a], which keeps the guarantee in Components are stored decoded intact. The consequence is that two spellings of one address are still different Uri values: [::1] does not equal [0:0:0:0:0:0:0:1].

What changes is that malformed literals are now rejected rather than carried through:

try uri.Uri.parse(alloc, "http://[::gg]/");   // error.InvalidIPv6Literal
try uri.Uri.parse(alloc, "http://[1:2:3]/");  // error.InvalidIPv6Literal

An RFC 6874 zone identifier is accepted in its percent-encoded form, [fe80::1%25eth0], and rejected when the % is left bare. RFC 3986's IPvFuture form ([v7.something]) is accepted on shape alone, since it has no defined interpretation to check against.

For IPv4, RFC 3986 tries IPv4address first and falls back to reg-name, so anything that is not exactly four dec-octets is simply a host name:

u.host.?.ipv4       // "192.0.2.16"
u.host.?.hostname   // "192.168.01.1" -- a leading zero is not a dec-octet

That last case is the reason to be strict. A parser reading 192.168.01.1 as octal and one reading it as decimal disagree about which host it names.

IDNA and IRI

Internationalized domain names and IRIs are the uri_unicode module, which imports uri and takes its Unicode character data from uucode. The uri module knows nothing of it and has no dependency but z46; a program that uses both gets one copy of uri, shared by the two.

const uri = @import("uri");
const uri_unicode = @import("uri_unicode");

const u = try uri.Uri.parse(alloc, "https://Bücher.example/straße?q=café#ü");

const as_uri = try uri_unicode.toUri(alloc, u);       // host to its xn-- form
try as_uri.toText(alloc, .{});
// https://xn--bcher-kva.example/stra%C3%9Fe?q=caf%C3%A9#%C3%BC

const as_iri = try uri_unicode.toIri(alloc, as_uri);  // host back to Unicode
try as_iri.toText(alloc, .{ .form = .iri });
// https://bücher.example/straße?q=café#ü

toUri and toIri convert only the host, because that is the only component whose two forms differ by more than escaping. Everything else is stored decoded, so TextOptions.form decides it: .uri percent-encodes non-ASCII (RFC 3986), .iri writes the characters RFC 3987 permits literally.

uri_unicode.idna.toAscii and uri_unicode.idna.toUnicode are also available directly, along with uri_unicode.normalization.nfc. Punycode (RFC 3492) needs no tables and is uri.punycode.

What it implements

UTS #46 — the profile browsers implement — with non-transitional processing by default, so faß.de encodes to xn--fa-hia.de rather than folding to fass.de. Options exposes transitional, use_std3_ascii_rules, check_hyphens, check_bidi, check_joiners and verify_dns_length.

Only the UTS #46 mapping table is generated into this repository (src/unicode/idna_table.zig, from IdnaMappingTable.txt); it is derived data of the UTS with no equivalent in the UCD. Every other Unicode property — general category, script, joining type, bidi class, combining class, canonical decompositions and composition exclusions — comes from uucode.

NFC is implemented here rather than taken from a table, on uucode's data. Its inverse composition map is derived on first use by inverting uucode's decompositions, so it can never disagree with the Unicode version uucode was built against.

punycode needs no Unicode data at all, which is why it is in uri.

Conformance

Verified against Unicode's own suites:

Suite Result
IdnaTestV2.txt (UTS #46) 12,788 checks, 0 failures
NormalizationTest.txt (NFC) 100,855 checks, 0 failures
RFC 3492 §7.1 Punycode vectors all pass

Those suites total about 3.5 MB, too much to ship to every consumer, so a deterministic sample is committed as src/unicode/vectors.zig and runs on every build: every 13th case that expects an error, plus a spaced sample of those expected to succeed — 454 IDNA and 366 NFC cases. To re-run the full suites, download them into testdata/ and regenerate with the scripts in tools/, which name the exact source URLs.

Two version notes. Everything is Unicode 18.0.0: uucode's character properties, the IdnaMappingTable.txt the mapping table is generated from, and both conformance suites. And lone surrogates cannot be carried through a Zig []const u8, so those rows of IdnaTestV2.txt are skipped rather than mis-tested; such input is rejected as invalid UTF-8 regardless.

LDAP URLs

RFC 4516 URLs are not URIs with an interesting query. Everything after the host is positional, separated by ? rather than by &, and the third position holds an RFC 4515 search filter — which is full of the characters a generic parser reads as structure:

const uri = @import("uri");

const url = try uri.ldap.Url.parse(
    alloc,
    "ldap://ldap.example.com/dc=example,dc=com?cn,mail?sub?(&(objectClass=person)(sn=Jensen))",
);
defer url.deinit();

url.host.?.hostname     // "ldap.example.com"
url.effectivePort().?   // 389
url.dn.?                // "dc=example,dc=com"
url.attributes          // .{ "cn", "mail" }
url.effectiveScope()    // .sub
url.effectiveFilter()   // "(&(objectClass=person)(sn=Jensen))"

Through Uri.parse that same text gives two query parameters with the filter shredded between them, because the & in (&(objectClass=…)…) is a parameter separator there. Hence a parser of its own, in the ldap submodule of uri.

ldap.Url.parse(alloc, text) parse an LDAP URL
ldap.Url.parseOpts(alloc, text, opts) as above, with .strict_percent
deinit(), eql() free / compare
scheme, host, port, dn, attributes, scope, filter, extensions plain fields
effectivePort(), effectiveDn(), effectiveScope(), effectiveFilter() with RFC 4516 §2's defaults applied
extension(name), hasCriticalExtension() the extensions list
write(w), toText(alloc), {f} back to text

Three schemes are accepted. ldap is the one RFC 4516 defines. ldaps — LDAP over TLS on port 636 — was never standardized, and RFC 4513 §2.1 recommends StartTLS on an ldap URL instead, but it is too widely deployed to refuse. ldapi is OpenLDAP's extension for a Unix domain socket, where the host is the percent-encoded socket path and there is no port at all.

As everywhere else in this library, the fields hold exactly what was written and the defaults are a separate question: dn is null for ldap://host and "" for ldap://host/, and effectiveDn() is "" either way. Text comes back canonically rather than identically escaped, and a trailing run of empty ? sections is dropped, since nothing distinguishes it from their absence.

Two things are deliberately not done. The DN is a single decoded string rather than a parsed sequence of RDNs — RFC 4514 is a grammar of its own, with its own backslash escapes that the URL decoding must leave alone, so o=An%20Example%5C2C%20Inc.,c=US yields o=An Example\2C Inc.,c=US with the \2C still in it. The filter, likewise, is handed over as written rather than parsed into RFC 4515 terms. Both belong to an LDAP client, not to a URL parser.

Data URLs

RFC 2397 URLs carry a document in the URL itself, and have none of the structure RFC 3986 looks for: no authority, no hierarchy, and a body that, in base64, is full of the / and + a generic parser treats as delimiters.

const uri = @import("uri");

const url = try uri.data.Url.parse(alloc, "data:text/plain;charset=utf-8;base64,aMOpbGxv");
defer url.deinit();

url.media_type.?            // "text/plain"
url.parameter("charset").?  // "utf-8"
url.base64                  // true
url.data                    // "héllo" -- the document, decoded

Through Uri.parse the same text gives a path of segments cut at each / of the base64, which then has to be glued back together and decoded by hand.

data.Url.parse(alloc, text) parse a data URL
data.Url.parseOpts(alloc, text, opts) as above, with .strict_percent
deinit(), eql() free / compare
media_type, parameters, base64, data, fragment plain fields
effectiveMediaType(), effectiveCharset() with RFC 2397 §2's default of text/plain;charset=US-ASCII applied
parameter(name) a parameter's value, the name matched without regard to case
write(w), toText(alloc), {f} back to text

data is the document: percent-decoded, and then base64-decoded when the URL says ;base64. Base64 is accepted with or without its padding and with the line breaks and spaces a URL copied out of a document picks up; it is written back padded and unbroken. Parameters may be written with no media type in front of them — data:;charset=utf-8,… — as RFC 2397 allows. The header is split before anything is decoded, so %2C in a parameter is a comma in its value rather than the end of the header. A # ends the data, and what follows it is kept as fragment.

DNS stamps

A DNS stamp is how a resolver is published as one string: sdns:// and then base64url of a binary structure saying which protocol to speak, where to reach it, and — for the two protocols that authenticate without the web PKI — what key or hash to insist on. It is what a resolver list such as public-resolvers.md is made of, and what a DNS client is handed when somebody says "use this resolver".

const uri = @import("uri");

const s = try uri.Stamp.parse(
    alloc,
    "sdns://AgcAAAAAAAAABzEuMC4wLjEAEmRucy5jbG91ZGZsYXJlLmNvbQovZG5zLXF1ZXJ5",
);
defer s.deinit();

s.protocol()                 // .doh
s.properties().?.no_log      // true
s.body.doh.address           // "1.0.0.1"
s.body.doh.hostname          // "dns.cloudflare.com"
s.body.doh.path              // "/dns-query"

This is a parser of its own for the same reason ldap is. Routing a stamp through Uri.parse appears to work — the base64url body lands in Host.hostname and comes back unharmed by a coincidence of the alphabet — but NormalizeOptions.host would lowercase it, percent.decode runs over it whether or not it holds a %, and a mistyped stamp is reported as error.InvalidPort. None of that is a base to build on.

The eight protocols and what each one's body holds. LP is a field behind a one-byte length; VLP is a list of them, with the high bit set on the length of every element but the last:

Protocol Body
0x00 plain properties, LP(address)
0x01 dnscrypt properties, LP(address), LP(provider key), LP(provider name)
0x02 doh properties, LP(address), VLP(hashes), LP(hostname), LP(path), VLP(bootstrap)
0x03 dot as doh, with no path
0x04 doq as dot
0x05 odoh_target properties, LP(hostname), LP(path)
0x81 dnscrypt_relay LP(address) — and no properties word
0x85 odoh_relay as doh
Stamp.parse(alloc, text) parse a stamp
deinit(), eql() free / compare
body the union, tagged by Protocol
protocol(), properties(), address() what every stamp answers, whichever variant it is
write(w), toText(alloc), {f} back to text

Four things are worth knowing before reading a stamp:

  • Addresses come back as text, not as a z46.Endpoint, even though this library parses address literals with z46. A z46 type in the API would force every consumer's z46 pin to match this one's; z46.Endpoint.parse on the other side costs a line and nothing crosses.
  • The relay stamp has no properties word. 0x81 is the one layout that starts with a length rather than eight bytes of flags, which is why properties() returns an optional.
  • The properties are advertisement, not evidence. A stamp is written by whoever publishes the resolver; dnssec, no_log and no_filter are claims. Bits with no assigned meaning are kept, so a stamp written by something newer still round-trips.
  • An unknown protocol byte is an error, not a variant to pass along. A stamp says how to reach a resolver, and a caller handed one it cannot interpret would have to guess.

The body is base64url without padding. Padding is accepted when it is there and anything outside the alphabet is refused, and a stamp with bytes left over after its last field is error.TrailingBytes rather than a partial parse.

Specifications

The library implements RFC 3986, Uniform Resource Identifier (URI): Generic Syntax. Where the code cites a section, this is what it means:

Section Where
§2.1 Percent-Encoding percent.encode, percent.decode
§2.2 Reserved Characters the sub_delims set in percent
§2.3 Unreserved Characters percent.unreserved_punct
§3.1 Scheme scheme parsing; case-insensitive lookup in schemes
§3.2.1 User Information username / password
§3.2.2 Host Host.ipv4, .ipv6, .hostname
§3.2.3 Port a bare : is an absent port, not a zero one
§3.3 Path rooted + path; segment-nz-nc; the no-authority // rule
§4.2 Relative Reference isAbsolute; the reference forms relativize chooses between
§5.2.2 Transform References click, strictly; relativize inverts it
§5.2.3 Merge Paths merging a relative path against the base
§5.2.4 Remove Dot Segments normalize.resolveDotSegments
§5.4 Reference Resolution Examples all 41 are in the test suite
§6.2.2 Syntax-Based Normalization Uri.normalize
§6.2.2.1 Case Normalization lowercased scheme/host, uppercase %XX
§6.2.3 Scheme-Based Normalization .drop_default_port, .root_empty_path

RFC 3986 obsoletes RFC 2396 and updates RFC 1738, whose remaining scheme definitions were themselves obsoleted piecemeal by RFC 4248 and RFC 4266; neither older document is implemented here, and the RFC 1738 percent-encoding rules in particular should not be assumed.

Three related specifications are implemented, the first two in the uri_unicode module, and one is deliberately left alone:

RFC 3987 Internationalized Resource Identifiers. uri_unicode.toUri and uri_unicode.toIri convert the host between its Unicode and xn-- forms, and TextOptions.form in uri decides whether everything else is escaped or written literally.
RFC 5890, RFC 5891 IDNA2008, by way of the UTS #46 profile browsers implement. In uri_unicode; uri alone percent-encodes a non-ASCII host as UTF-8 rather than converting it to Punycode.
RFC 6874, obsoleted by RFC 9844 IPv6 zone identifiers. [fe80::1%25eth0] is accepted in the percent-encoded form the RFC requires, and rejected when the % is left bare. The zone is validated for shape and never interpreted.
RFC 5952 IPv6 text representation, deliberately not implemented. Address literals are never rewritten, so case and :: placement survive a round trip.

The ldap submodule implements RFC 4516, Lightweight Directory Access Protocol (LDAP): Uniform Resource Locator, whose host and port come straight from RFC 3986 §3.2.2 and §3.2.3. The two grammars it defers to — RFC 4514 for a distinguished name and RFC 4515 for a search filter — are carried as decoded strings rather than parsed, and the scopes are those of RFC 4511 §4.5.1.2.

The data submodule implements RFC 2397, The "data" URL scheme. Media type parameter names are matched without regard to case, as RFC 2045 §5.1 has it for MIME.

The stamp submodule implements the DNS Stamps specification, which is not an RFC: it is maintained by Frank Denis alongside dnscrypt-proxy, and the protocols it names are defined by the DNSCrypt version 2 protocol specification, RFC 8484 (DoH), RFC 7858 (DoT), RFC 9250 (DoQ) and RFC 9230 (Oblivious DoH).

The file submodule implements RFC 8089, The "file" URI Scheme, including the Windows drive letters and UNC paths of its Appendix E. See File paths above for what it does not convert.

The scheme tables in schemes.zig follow RFC 7595 (BCP 35) and the IANA registries. Schemes appearing in the tests and in the rootless-scheme list are defined by RFC 6068 (mailto), RFC 8089 (file) and RFC 8141 (urn).

Default ports

All 40 entries in defaultPort were checked against IANA's Service Name and Transport Protocol Port Number Registry and, for schemes whose defining document states a default, against that RFC. Where the two disagree the RFC wins: a default port belongs to the URI scheme, and a scheme need not share a name — or a protocol — with the IANA service sitting on its port.

Twenty-three entries match an IANA service of the same name outright. The rest are worth recording:

Scheme Port
nfs 2049 Corrected. Was 111, which is sunrpc, the portmapper rather than NFS. RFC 2224 §3 specifies 2049, matching IANA.
rtspu 554 Corrected. Was 5005, which IANA assigns to avt-profile-2. RFC 2326 §3.2 gives rtsp and rtspu one default of 554; they differ in transport, TCP versus UDP, not in port.
sftp 22 Correct. SSH File Transfer runs over SSH. IANA's sftp is 115, an unrelated and long-obsolete Simple File Transfer Protocol.
prospero 1525 Correct. RFC 4157 says "the port defaults to 1525". IANA's prospero is 191; 1525 is its prospero-np.
ventrilo 3784 Convention, not an assignment — IANA gives 3784 to bfd-control (RFC 5881). Kept because it is what clients use.
afp dns ircs mms pop smb vnc wais ws wss — Correct, but filed by IANA under another service name: afpovertcp, domain, ircs-u, ms-streaming, pop3, microsoft-ds, rfb, z39.50, and — for ws/wss — http and https (RFC 6455 §3).

The two corrections are also wrong in Python's hyperlink and in boltons.urlutils, which is where this table was originally adapted from.

defaultPort reports what a scheme means by an absent port. It is not a claim that a host is listening there, and Uri.port still holds only what was actually written — see effectivePort().

Repository

The repository's home is my Forgejo instance at git.jcollie.dev/jeff/zig-uri, which is where CI runs and the API documentation is published from. It is also published on Radicle and mirrored to Codeberg and Tangled. Any of the four will do; the Radicle copy needs no account and no forge.

# Forgejo
git clone https://git.jcollie.dev/jeff/zig-uri.git

# Radicle
rad clone rad:zrcnvxgQxKm2DSmX3Q9VSF2dHJ8m

# Codeberg
git clone https://codeberg.org/jcollie/zig-uri.git

# Tangled
git clone https://tangled.org/jcollie.dev/zig-uri

rad clone finds seeds through your local node's routing table, so the node has to be running first:

rad node start

If you already have the repository and only want to follow it, seeding it makes your node help host it for others:

rad seed rad:zrcnvxgQxKm2DSmX3Q9VSF2dHJ8m

The Radicle repository is public, its default branch is main, and it is named zig-uri — the same name and default branch as the Forgejo, Codeberg and Tangled copies, so any of the four gives the same history.

Building

zig build test                    # run the test suite, both modules
zig build test --fuzz=1M          # ...and search for new failures
zig build docs                    # generate API docs into zig-out/docs
zig build docs-serve              # ...and read them at http://127.0.0.1:8000/
zig build wasm                    # build the browser bundle into zig-out/web
zig build web-serve               # ...and try it at http://127.0.0.1:8001/
zig build test-web                # test the JavaScript binding under node
nix build .#checks.x86_64-linux.browser   # ...and in a real browser

Fuzzing

src/fuzz.zig and src/fuzz_unicode.zig are a module of their own, which reaches the library through uri and uri_unicode as a dependent would. They state the properties that have to hold for any input — parsing and writing are inverses, writing is a fixed point, normalization and reference resolution leave a reference that still round-trips, percent-encoding and Punycode round-trip exactly, and both directions of IDNA agree — and let std.testing.Smith generate the inputs. Smith is structure-aware: it is asked for a length, an index or a byte from a restricted alphabet rather than for a flat blob, so the search moves through the shape of a URI reference instead of hammering a string that stops parsing after its first byte. One of the targets builds Components by hand, which is how the writer gets fed the values parsing can never produce — a / inside a path segment, an @ inside a host.

zig build test builds one test binary per module — uri, uri_unicode and the fuzz targets — and runs each fuzz test against one smoke input, so they cost an ordinary test run nothing. zig build test --fuzz=N is what searches; a failure prints the input it was reached with and saves it to .zig-cache/f/crash.

The test binary is compiled with LLVM even in Debug (use_llvm in build.zig), because the self-hosted backend Debug otherwise uses emits no coverage instrumentation: without it the fuzzer's table of program counters is empty, --fuzz=N cannot steer and fails on its coverage report, and plain --fuzz panics in the build runner rather than serving the web UI. The bounded report names only the first fuzz test in each binary, but the others ran too and share its coverage figure.

There is a loop of our own as well, in tools/fuzz.zig, which runs the same targets against input it makes up and, unlike the other one, says how many inputs it got through and checks every input for leaks:

$ zig build fuzz-run                                  # a minute of each target
$ zig build fuzz-run -- --seconds 300 --target click
$ zig build fuzz-run -- --input fuzz-findings/x.bin --target click

What it has in place of coverage feedback is a corpus: real references, paths, LDAP URLs and stamps to mutate, since a URI of random bytes stops parsing at its first byte. Two things about that are worth knowing before adding to it. A seed reaches a target through that target's own alphabet — Smith copies a byte only when the weights list it and replaces every other with the first weight's, so a seed written as https://example.com arrives as %%%%%://%%a%%%e.%%%, keeping its shape and losing everything else; the corpora are therefore written in each target's letters, and the targets that ask for plain bytes get a separate one spelled the ordinary way. And the tail of an input is a run of small numbers rather than random bytes, because Smith.value answers anything out of range with the range's minimum: a random tail would make every boolean false, so normalize's five options would only ever be tested in one of their thirty-two combinations.

The loop also holds a DebugAllocator and asks it after every input whether anything was left behind, which is a property no fixed test states: every path through Uri.parse and what is built on it has to free what it allocated, including the paths an error takes. A leak ends the run, because the memory is still outstanding and every later input would be reported as leaking it too.

docs-serve exists because the generated viewer fetches sources.tar and main.wasm at runtime, which a browser refuses to do from a file:// page — the same reason zig std runs a server. Use -Ddocs-port=N for a different port.

zig build docs writes one bundle per module, uri/ and uri_unicode/, under an index.html that links the two. The published copy at jeff.jcollie.page/zig-uri is the same output. CI republishes it whenever main goes green.

In a browser

The library also compiles to WebAssembly, with a JavaScript binding on top of it, so a page can parse and resolve URIs with the same code the Zig tests exercise:

zig build wasm                    # -> zig-out/web/{uri.wasm,uri.js,index.html}
zig build web-serve               # a demo page at http://127.0.0.1:8001/

zig-out/web is the whole of what has to be served. uri.wasm always has the uri_unicode module in it, since IDNA and IRI display are much of the reason to want this in a browser. It is 459 KB, or 123 KB over the wire once a server has gzipped it, and most of that is the UTS #46 and normalization tables.

import { init, Uri } from "./uri.js";

await init();

const u = Uri.parse("https://[email protected]/a/b/?q=1&flag#top");
u.host;         // "example.com"
u.path;         // ["a", "b", ""] -- the empty segment is the trailing slash
u.first("q");   // { key: "q", value: "1" }
u.first("flag") // { key: "flag", value: null } -- a bare key, not an empty one

u.resolve("../c/").toString();  // "https://[email protected]/a/c/"
u.set("q", "2").toString();     // "https://[email protected]/a/b/?q=2&flag#top"
u.child("d").toString();        // "https://[email protected]/a/b/d?q=1&flag#top"

Uri.parse("HTTPS://Example.COM:443/a/./b/..")
  .normalize({ dropDefaultPort: true })
  .toString();                  // "https://example.com/a/"

Uri.parse("http://münchen.de/straße").toUri().toString();
  // "http://xn--mnchen-3ya.de/stra%C3%9Fe"
Uri.parse("http://xn--mnchen-3ya.de/stra%C3%9Fe").toIri().toString({ form: "iri" });
  // "http://münchen.de/straße"

init() fetches uri.wasm from beside uri.js; pass it a URL, a Response, or the bytes if they are already in hand. initSync(bytes) is the same thing without the await, which is how the node tests load it.

As in Zig, a Uri is immutable: with, child, sibling, resolve, normalize, add, set and remove each return a new one, and instances are frozen. Alongside the class there are text-in/text-out functions — resolve, normalize, toUri, toIri, idnaToAscii, idnaToUnicode, percentEncode, percentDecode — which never build a JavaScript string from a component and so are exactly as faithful as the Zig library.

That distinction matters in one place. A decoded component is a byte string and a JavaScript string is not, so a percent escape that is not valid UTF-8 — http://x/%FF, a stray Latin-1 query from a legacy site — arrives as U+FFFD in a Uri field and re-encodes as %EF%BF%BD. Reading components apart and putting them back together is lossy for such input; the text functions above carry it through unharmed.

The ABI itself is documented in src/wasm.zig: (ptr, len) pairs of UTF-8 in and a static { ok, ptr, len } record out, with option structs flattened to bitfields. It has no imports at all, so WebAssembly.instantiate(bytes, {}) is the whole of what instantiating it takes.

Testing it

web/tests.js is one suite run by two runners. zig build test-web runs it under node, which is fast enough to keep in the edit loop. nix flake check runs the same suite in a real browser: it boots a NixOS virtual machine, serves the bundle from nginx inside it, and drives headless Chromium against browser-test.html, then checks the demo page renders what the library computed. Only the second one exercises what a browser actually does — module resolution, WebAssembly.instantiateStreaming, and whether the server sends application/wasm — so both run in CI.

Using it as a dependency

The repository lives at git.jcollie.dev/jeff/zig-uri, mirrored to codeberg.org/jcollie/zig-uri and tangled.org/jcollie.dev/zig-uri, and on Radicle as rad:zrcnvxgQxKm2DSmX3Q9VSF2dHJ8m (see Repository above). zig fetch needs an HTTP or git URL, which rules out the Radicle copy; any of the https copies serves the same history, and the commands below use the Codeberg mirror because that is what REUSE.toml names as the download location.

Pin a specific commit — zig fetch wants a full 40-character SHA or a ref name, and rejects an abbreviated hash:

zig fetch --save git+https://codeberg.org/jcollie/zig-uri.git#<full-commit-sha>

Or name a branch, or take a tarball:

zig fetch --save git+https://codeberg.org/jcollie/zig-uri.git#main
zig fetch --save https://codeberg.org/jcollie/zig-uri/archive/main.tar.gz

Naming a branch does not leave the dependency floating: zig fetch resolves the ref at fetch time and records it as ?ref=main#<sha>, so the pin is still to one commit.

Either way --save writes the entry and its integrity hash into your build.zig.zon, under the name uri:

.dependencies = .{
    .uri = .{
        .url = "git+https://codeberg.org/jcollie/zig-uri.git#04ded71f8d88fded46b3eaf1e8e02f44c8c47b7e",
        .hash = "uri-0.1.0-yCrwNFS4AQCXVzzGneu7zkEf9kopQC3n8KbzJpyDnFnP",
    },
},

The main branch needs Zig 0.17.0. For Zig 0.16.0, take the zig-0.16 branch, which holds the last of zig-uri to build with it, or the v0.1.0 tag, which predates the move:

zig fetch --save git+https://codeberg.org/jcollie/zig-uri.git#zig-0.16
zig fetch --save https://codeberg.org/jcollie/zig-uri/archive/zig-0.16.tar.gz

Then wire the module into whatever needs it, in build.zig:

const uri_dep = b.dependency("uri", .{ .target = target, .optimize = optimize });
exe_module.addImport("uri", uri_dep.module("uri"));
const uri = @import("uri");

That is all uri needs, and it fetches nothing but z46. IDNA and IRI support is the second module, uri_unicode, which needs uucode. uucode is a lazy dependency, so it is not a module zig-uri's build adds for everybody: ask for it by calling unicodeModule from zig-uri's build.zig, which creates it the first time and fetches uucode only then:

const uri_dep = b.dependency("uri", .{ .target = target, .optimize = optimize });
exe_module.addImport("uri", uri_dep.module("uri"));
if (@import("uri").unicodeModule(uri_dep, target, optimize)) |uri_unicode| {
    exe_module.addImport("uri_unicode", uri_unicode);
}
const uri = @import("uri");
const uri_unicode = @import("uri_unicode");

It returns null only on the first configure pass, while uucode has not been fetched yet; the build runner then fetches it and runs build again, so there is nothing to do with the null but carry on. Every caller gets the same module, and it imports the same uri module uri_dep.module("uri") hands out, so a program that reaches zig-uri through several packages compiles one copy of each — as long as every one of them passes b.dependency("uri", …) the same options. Zig makes a separate instance of a package for each distinct set of options, and two instances of uri in one compilation are a compile error ("file exists in modules 'uri' and 'uri0'"). Passing .target and .optimize, as above, is the convention to keep to.

References cited

Everything this library was written against, with the status each document held when it was consulted. Specifications above says what the code does with them; this is the bibliography.

Generic syntax

  • Berners-Lee, T., R. Fielding, and L. Masinter. Uniform Resource Identifier (URI): Generic Syntax. RFC 3986, STD 66. Internet Engineering Task Force, January 2005. Internet Standard; updated by RFC 7320 and RFC 8820. https://www.rfc-editor.org/info/rfc3986
  • Berners-Lee, T., R. Fielding, and L. Masinter. Uniform Resource Identifiers (URI): Generic Syntax. RFC 2396. Network Working Group, August 1998. Draft Standard; obsoleted by RFC 3986. https://www.rfc-editor.org/info/rfc2396
  • Berners-Lee, T., L. Masinter, and M. McCahill. Uniform Resource Locators (URL). RFC 1738. Network Working Group, December 1994. Proposed Standard; updated by RFC 3986, obsoleted in part by RFC 4248 and RFC 4266. https://www.rfc-editor.org/info/rfc1738
  • Thaler, D., T. Hansen, and T. Hardie. Guidelines and Registration Procedures for URI Schemes. RFC 7595, BCP 35. Internet Engineering Task Force, June 2015. Best Current Practice; updated by RFC 8615. https://www.rfc-editor.org/info/rfc7595
  • Carpenter, B., S. Cheshire, and R. Hinden. Representing IPv6 Zone Identifiers in Address Literals and Uniform Resource Identifiers. RFC 6874. Internet Engineering Task Force, February 2013. Proposed Standard; obsoleted by RFC 9844. https://www.rfc-editor.org/info/rfc6874
  • Carpenter, B., and R. Hinden. Entering IPv6 Zone Identifiers in User Interfaces. RFC 9844. Internet Engineering Task Force, August 2025. Proposed Standard. https://www.rfc-editor.org/info/rfc9844
  • Kawamura, S., and M. Kawashima. A Recommendation for IPv6 Address Text Representation. RFC 5952. Internet Engineering Task Force, August 2010. Proposed Standard. Read and deliberately not implemented; see Address literals above. https://www.rfc-editor.org/info/rfc5952

Internationalization

  • Duerst, M., and M. Suignard. Internationalized Resource Identifiers (IRIs). RFC 3987. Internet Engineering Task Force, January 2005. Proposed Standard. https://www.rfc-editor.org/info/rfc3987
  • Klensin, J. Internationalized Domain Names for Applications (IDNA): Definitions and Document Framework. RFC 5890. Internet Engineering Task Force, August 2010. Proposed Standard. https://www.rfc-editor.org/info/rfc5890
  • Klensin, J. Internationalized Domain Names in Applications (IDNA): Protocol. RFC 5891. Internet Engineering Task Force, August 2010. Proposed Standard. https://www.rfc-editor.org/info/rfc5891
  • Faltstrom, P. The Unicode Code Points and Internationalized Domain Names for Applications (IDNA). RFC 5892. Internet Engineering Task Force, August 2010. Proposed Standard; updated by RFC 8753. The source of the CONTEXTJ rules in idna.zig. https://www.rfc-editor.org/info/rfc5892
  • Alvestrand, H., and C. Karp. Right-to-Left Scripts for Internationalized Domain Names for Applications (IDNA). RFC 5893. Internet Engineering Task Force, August 2010. Proposed Standard. The Bidi rule. https://www.rfc-editor.org/info/rfc5893
  • Costello, A. Punycode: A Bootstring Encoding of Unicode for Internationalized Domain Names in Applications (IDNA). RFC 3492. Internet Engineering Task Force, March 2003. Proposed Standard; updated by RFC 5891. https://www.rfc-editor.org/info/rfc3492
  • Davis, M., and M. Scherer. Unicode IDNA Compatibility Processing. Unicode Technical Standard #46, revision 36, Unicode 18.0.0. Unicode Consortium, 31 August 2026. https://www.unicode.org/reports/tr46/tr46-36.html
  • Whistler, K. Unicode Normalization Forms. Unicode Standard Annex #15, revision 58, Unicode 18.0.0. Unicode Consortium, 12 August 2026. https://www.unicode.org/reports/tr15/tr15-58.html
  • Whistler, K. Unicode Character Database. Unicode Standard Annex #44, revision 38, Unicode 18.0.0. Unicode Consortium, 2 September 2026. The publication that defines NormalizationTest.txt and the idna/ data files the conformance suites are drawn from. https://www.unicode.org/reports/tr44/tr44-38.html

Individual schemes

  • Duerst, M., L. Masinter, and J. Zawinski. The 'mailto' URI Scheme. RFC 6068. Internet Engineering Task Force, October 2010. Proposed Standard. https://www.rfc-editor.org/info/rfc6068
  • Kerwin, M. The "file" URI Scheme. RFC 8089. Internet Engineering Task Force, February 2017. Proposed Standard; updated by RFC 9844. https://www.rfc-editor.org/info/rfc8089
  • Saint-Andre, P., and J. Klensin. Uniform Resource Names (URNs). RFC 8141. Internet Engineering Task Force, April 2017. Proposed Standard. https://www.rfc-editor.org/info/rfc8141
  • Callaghan, B. NFS URL Scheme. RFC 2224. Network Working Group, October 1997. Informational. The source of the corrected nfs default port. https://www.rfc-editor.org/info/rfc2224
  • Schulzrinne, H., A. Rao, and R. Lanphier. Real Time Streaming Protocol (RTSP). RFC 2326. Internet Engineering Task Force, April 1998. Proposed Standard; obsoleted by RFC 7826. The source of the corrected rtspu default port. https://www.rfc-editor.org/info/rfc2326
  • Hoffman, P. The prospero URI Scheme. RFC 4157. Internet Engineering Task Force, August 2005. Historic. https://www.rfc-editor.org/info/rfc4157
  • Fette, I., and A. Melnikov. The WebSocket Protocol. RFC 6455. Internet Engineering Task Force, December 2011. Proposed Standard; updated by RFC 7936, RFC 8307 and RFC 8441. Why ws and wss default to the HTTP ports. https://www.rfc-editor.org/info/rfc6455
  • Katz, D., and D. Ward. Bidirectional Forwarding Detection (BFD) for IPv4 and IPv6 (Single Hop). RFC 5881. Internet Engineering Task Force, June 2010. Proposed Standard. What IANA assigns port 3784 to, which ventrilo uses by convention anyway. https://www.rfc-editor.org/info/rfc5881

LDAP

  • Smith, M., and T. Howes. Lightweight Directory Access Protocol (LDAP): Uniform Resource Locator. RFC 4516. Internet Engineering Task Force, June 2006. Proposed Standard. https://www.rfc-editor.org/info/rfc4516
  • Sermersheim, J. Lightweight Directory Access Protocol (LDAP): The Protocol. RFC 4511. Internet Engineering Task Force, June 2006. Proposed Standard. The search scopes of §4.5.1.2. https://www.rfc-editor.org/info/rfc4511
  • Zeilenga, K. Lightweight Directory Access Protocol (LDAP): String Representation of Distinguished Names. RFC 4514. Internet Engineering Task Force, June 2006. Proposed Standard. Carried as a decoded string, not parsed. https://www.rfc-editor.org/info/rfc4514
  • Smith, M., and T. Howes. Lightweight Directory Access Protocol (LDAP): String Representation of Search Filters. RFC 4515. Internet Engineering Task Force, June 2006. Proposed Standard. Also carried as a decoded string. https://www.rfc-editor.org/info/rfc4515
  • Harrison, R. Lightweight Directory Access Protocol (LDAP): Authentication Methods and Security Mechanisms. RFC 4513. Internet Engineering Task Force, June 2006. Proposed Standard; updated by RFC 8996. Why ldaps was never standardized. https://www.rfc-editor.org/info/rfc4513

Data URLs

  • Masinter, L. The "data" URL scheme. RFC 2397. Network Working Group, August 1998. Proposed Standard. https://www.rfc-editor.org/info/rfc2397
  • Freed, N., and N. Borenstein. Multipurpose Internet Mail Extensions (MIME) Part One: Format of Internet Message Bodies. RFC 2045. Network Working Group, November 1996. Draft Standard. Why parameter names are compared without regard to case. https://www.rfc-editor.org/info/rfc2045

DNS stamps

Registries

Prior art and dependencies

These are kept in a Zotero collection named zig-uri, which holds the full text of each RFC alongside its entry.

Licence

MIT. See LICENSES/MIT.txt.