- Zig 94.4%
- JavaScript 2.8%
- Nix 1%
- HTML 1%
- Python 0.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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 |
||
| .forgejo/workflows | ||
| LICENSES | ||
| src | ||
| tools | ||
| web | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| build.zig.zon.nix | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
| REUSE.toml | ||
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.
Differences from hyperlink
portis what was written.hyperlink's.portfalls back to the scheme's default; hereportstaysnullunless a port was present, sohttp://xandhttp://x:80round-trip distinctly. UseeffectivePort()for the fallback behaviour.rootedandpathinstead of a leading empty segment./a/bisrooted = truewithpath = .{ "a", "b" }.- An empty fragment is preserved.
hyperlinkcannot tellhttp://x/#fromhttp://x/; herefragmentis""versusnull. clickfollows RFC 3986 §5.2.2 strictly.hyperlinkraisesNotImplementedErrorfor a reference with a scheme and a rootless path;click("g:h")here returnsg: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.hyperlinkemits it for any scheme that could have one, turninghttp:/foointohttp:///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.
nfsis 2049 rather than the portmapper's 111, andrtspuis 554 rather than 5005. Both are wrong inhyperlink; see Default ports below for the citations. setappends when the key is absent, rather than inserting before the last parameter.- Address literals are validated.
hyperlinkaccepts 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'snormalize()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\nwrites 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.
hyperlinkalways has them; here they are a second module,uri_unicode, which keepsuriitself free of Unicode tables. ItstoUri(alloc, u)andtoIri(alloc, u)correspond toto_uri()andto_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/../brelativizes to something resolving tohttp://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, whichisAbsolute()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 yourIofor the directory and descend withchild. - A segment holding a separator has no path.
file:///a%2Fbnames one file calleda/b, and writing it out would name two. - The Win32 device namespace —
\\?\C:\x,\\.\PhysicalDrive0— is not a share, and neither is thefile://./xthat would read back as one. - A path that would be ambiguous once written:
\C:becomesfile:///C:, which everything reading it takes for the drive, andfile:////awould 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.parseon the other side costs a line and nothing crosses. - The relay stamp has no properties word.
0x81is the one layout that starts with a length rather than eight bytes of flags, which is whyproperties()returns an optional. - The properties are advertisement, not evidence. A stamp is written by
whoever publishes the resolver;
dnssec,no_logandno_filterare 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.txtand theidna/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
nfsdefault 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
rtspudefault 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
wsandwssdefault 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
ventrilouses 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
ldapswas 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
- Denis, F. DNS Stamps Specification. DNSCrypt project, 2024. Retrieved 22 September 2026. https://dnscrypt.info/stamps-specifications/
- Denis, F. The DNSCrypt Protocol. Internet-Draft draft-denis-dprive-dnscrypt, 2026. Work in Progress; editor's copy retrieved 22 September 2026. https://dnscrypt.github.io/dnscrypt-protocol/
- Hoffman, P., and P. McManus. DNS Queries over HTTPS (DoH). RFC 8484. Internet Engineering Task Force, October 2018. Proposed Standard. https://www.rfc-editor.org/info/rfc8484
- Hu, Z., L. Zhu, J. Heidemann, A. Mankin, D. Wessels, and P. Hoffman. Specification for DNS over Transport Layer Security (TLS). RFC 7858. Internet Engineering Task Force, May 2016. Proposed Standard; updated by RFC 8310. https://www.rfc-editor.org/info/rfc7858
- Huitema, C., S. Dickinson, and A. Mankin. DNS over Dedicated QUIC Connections. RFC 9250. Internet Engineering Task Force, May 2022. Proposed Standard. https://www.rfc-editor.org/info/rfc9250
- Kinnear, E., P. McManus, T. Pauly, T. Verma, and C. A. Wood. Oblivious DNS over HTTPS. RFC 9230. Independent Submission, June 2022. Experimental. https://www.rfc-editor.org/info/rfc9230
Registries
- Internet Assigned Numbers Authority. Service Name and Transport Protocol
Port Number Registry. Retrieved 23 September 2026. Every entry in
defaultPortwas checked against it. https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml - Internet Assigned Numbers Authority. Uniform Resource Identifier (URI)
Schemes. Retrieved 23 September 2026. What the tables in
schemes.zigfollow. https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml
Prior art and dependencies
- Hashemi, M. hyperlink: Immutable, Pythonic, correct URLs. Python Hyper. The library whose API shape and default-port table this one was adapted from. https://github.com/python-hyper/hyperlink
- Hashemi, M. boltons. Its
boltons.urlutilscarries the same default-port table, including the two entries corrected here. https://github.com/mahmoud/boltons - Sandlund, J. uucode: Unicode character data for Zig. The source of every
Unicode property the
uri_unicodemodule uses except the UTS #46 mapping table. https://github.com/jacobsandlund/uucode
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.