This is a “be warned” catalog: the places where address handling surprises people, and what raddr answers there.
Every table below is computed when this vignette is built, from raddr’s own parsers, its exported accessors and the vendored IANA snapshots. None of it is typed out by hand. That is deliberate — a stale warnings document is worse than no warnings document, because people trust it. If raddr’s behavior changes, these tables change with it, and if they stop being true the package stops building.
The framing between the tables is hand-written, and it is the part to read skeptically.
show <- function(literals) {
p <- addr_parse(literals)
out <- data.frame(input = literals)
for (d in c("strict", "whatwg", "pton", "aton", "getaddrinfo", "curl")) {
out[[d]] <- format(addr_reading(p, d))
}
out
}The headline case, and the one that changes which host you reach.
show(c("0177.0.0.1", "010.0.0.1", "192.0.010.1", "192.0.048.1"))
#> input strict whatwg pton aton getaddrinfo curl
#> 1 0177.0.0.1 <NA> 127.0.0.1 177.0.0.1 127.0.0.1 177.0.0.1 127.0.0.1
#> 2 010.0.0.1 <NA> 8.0.0.1 10.0.0.1 8.0.0.1 10.0.0.1 8.0.0.1
#> 3 192.0.010.1 <NA> 192.0.8.1 192.0.10.1 192.0.8.1 192.0.10.1 192.0.8.1
#> 4 192.0.048.1 <NA> <NA> 192.0.48.1 <NA> 192.0.48.1 192.0.48.10177.0.0.1 reaches loopback in a browser and a routable
LACNIC address through inet_pton. 192.0.048.1
is worse in a quieter way: 048 is not a valid octal number,
so the octal readers reject the whole literal while
inet_pton strips the zero and reads decimal — curl
reaches a host a browser refuses to dial.
If you take one thing from this vignette: a leading zero in an address literal is never safe to normalize away, and never safe to pass through unexamined.
show(c("10.1", "127.1", "2130706433", "4294967296", "1.4294967296"))
#> input strict whatwg pton aton getaddrinfo curl
#> 1 10.1 <NA> 10.0.0.1 <NA> 10.0.0.1 10.0.0.1 10.0.0.1
#> 2 127.1 <NA> 127.0.0.1 <NA> 127.0.0.1 127.0.0.1 127.0.0.1
#> 3 2130706433 <NA> 127.0.0.1 <NA> 127.0.0.1 127.0.0.1 127.0.0.1
#> 4 4294967296 <NA> <NA> <NA> 0.0.0.0 0.0.0.0 0.0.0.0
#> 5 1.4294967296 <NA> <NA> <NA> <NA> <NA> <NA>inet_aton accepts one, two, three or four parts, filling
the gap. It also range-checks every arity except the
whole-host number: with two to four parts the final part is bounded, so
1.4294967296 is rejected, while a single part is truncated
modulo 2^32 with no check at all. That is why 4294967296 is
0.0.0.0 rather than an error.
show(c("1.2.3.4.", "1.2.3.04", "::1.2.3.04"))
#> input strict whatwg pton aton getaddrinfo curl
#> 1 1.2.3.4. <NA> 1.2.3.4 <NA> <NA> <NA> <NA>
#> 2 1.2.3.04 <NA> 1.2.3.4 1.2.3.4 1.2.3.4 1.2.3.4 1.2.3.4
#> 3 ::1.2.3.04 <NA> <NA> ::102:304 <NA> ::102:304 ::102:304Look at the last two rows together. whatwg reads
1.2.3.04 as 1.2.3.4 — a leading zero in a
standalone IPv4 host is only a validation warning in that
specification. The same leading zero inside the IPv4 tail of an IPv6
literal is a hard failure, so whatwg refuses
::1.2.3.04 while pton reads it happily.
The WHATWG IPv6 tail is stricter than the WHATWG standalone IPv4 parser: no hex, no octal, no short forms and no trailing dot reach it. rust-url implements it as a separate loop rather than by calling its own IPv4 parser, which is the clearest evidence the asymmetry is intended rather than accidental.
A trailing dot goes the other way: whatwg drops one
before parsing, so it alone accepts 1.2.3.4..
The two paper dialects disagree constantly about IPv4. About IPv6 they largely agree, and the divergence moves to the reality side:
show(c("::1", "00001::", "12345::", "fe80:abcd::1", "1:2:3:4:5:6:7:8:"))
#> input strict whatwg pton aton getaddrinfo
#> 1 ::1 ::1 ::1 ::1 <NA> ::1
#> 2 00001:: <NA> <NA> 1:: <NA> 1::
#> 3 12345:: <NA> <NA> <NA> <NA> <NA>
#> 4 fe80:abcd::1 fe80:abcd::1 fe80:abcd::1 fe80:abcd::1 <NA> fe80::1%43981
#> 5 1:2:3:4:5:6:7:8: <NA> <NA> <NA> <NA> <NA>
#> curl
#> 1 ::1
#> 2 1::
#> 3 <NA>
#> 4 fe80::1%43981
#> 5 <NA>inet_pton puts no width limit on leading zeros in a
hextet and then caps the significant digits at four, so
00001:: is 1:: while 12345:: is a
rejection. The RFC 4291 grammar caps the raw digits, so the paper
dialects reject the whole family.
fe80:abcd::1 is the IPv6 counterpart of
0177.0.0.1: two entry points into one C library returning
different bits for one string. Apple’s getaddrinfo reads
the second hextet of a link-local address as a scope ID and clears it;
inet_pton does not.
1:2:3:4:5:6:7:8: is here as a warning about text
handling rather than about raddr. R’s strsplit() drops
a trailing empty field, so code that counts groups by splitting on
":" sees eight where the literal has a ninth empty one.
Every dialect refuses this literal; a hand-rolled group counter may
not.
show(c("fe80::1%lo0", "fe80::1%25lo0", "fe80::1%lo0%en0"))
#> input strict whatwg pton aton getaddrinfo curl
#> 1 fe80::1%lo0 <NA> <NA> fe80::1%lo0 <NA> fe80::1%lo0 fe80::1%lo0
#> 2 fe80::1%25lo0 <NA> <NA> fe80::1%25lo0 <NA> fe80::1%25lo0 fe80::1%25lo0
#> 3 fe80::1%lo0%en0 <NA> <NA> <NA> <NA> <NA> <NA>addr_zone(addr_pton("fe80::1%lo0"))
#> [1] "lo0"
addr_pton("fe80::1%lo0") == addr_pton("fe80::1%en0")
#> [1] TRUE
addr_zone(addr_pton("fe80::1%25lo0"))
#> [1] "25lo0"An RFC 4007 zone ID names an interface, not an address, so it is
stored beside the bits and takes no part in equality. Two addresses
differing only by zone are the same address, and
addr_zone() is the only thing that tells them apart —
including in a deduplication, a join, or a unique()
call.
The percent-encoded %25lo0 form was RFC 6874’s answer to
putting a zone in a URI. Note the third line above: raddr reads a zone
as literal text and does not percent-decode it, so
%25lo0 yields the zone "25lo0", not
"lo0". That is the honest reading — decoding would be a
URL-layer step, and raddr never sees a URL — but it means zone text
arriving from a URI needs decoding before it reaches raddr, or it will
silently name a different interface.
RFC 9844 (August 2025) completely obsoletes RFC 6874 and drops the URI syntax entirely, so there is no live standard for a zone in a URI. Browsers never implemented it.
strict and whatwg are fixed specifications
and read the same everywhere. pton and aton
model measured behavior, and the C library matters:
| literal | Apple | glibc | musl |
|---|---|---|---|
1.2.3.4 (trailing space) |
accept | accept | reject |
1.2.3.4junk |
reject | accept | reject |
raddr’s reality dialects are measured against Apple
libc. glibc’s public inet_aton ignores trailing
garbage outright — which is why glibc had to ship a separate
__inet_aton_exact for callers that must not tolerate it,
the clearest possible evidence that the leniency is deliberate. musl
rejects any trailing byte that is not . or NUL.
Three answers, and raddr currently ships one. Where a reality dialect
appears in this vignette, read it as “what this libc does”, not “what
every libc does”. The glibc and musl inet_pton rows are not
measured at all yet.
raddr itself is pure R and calls no resolver, so it returns these same answers on every machine — including a machine whose libc would answer differently. That is a feature for reproducibility and a trap if you assume the local system agrees.
The registry nests. A more specific block can reverse its parent’s policy, so matching a prefix is not enough — you must match the longest one:
lpm <- as.data.frame(addr_classify(addr_pton(c("192.0.0.1", "192.0.0.9"))))
lpm[, c("block", "name", "category", "globally_reachable")]
#> block name category globally_reachable
#> 1 192.0.0.0/29 IPv4 Service Continuity Prefix protocol FALSE
#> 2 192.0.0.9/32 Port Control Protocol Anycast anycast TRUE192.0.0.9 is globally reachable and sits inside
192.0.0.0/24, which is not. A check written against the
/24 gets that address exactly backwards. Here is every
carve-out of that shape in the vendored snapshot, generated rather than
listed:
reg <- addr_registry()
network <- addr_pton(sub("/.*$", "", reg$block))
closed <- !is.na(reg$globally_reachable) & !reg$globally_reachable
open <- !is.na(reg$globally_reachable) & reg$globally_reachable
reg[open & addr_within_any(network, reg$block[closed]), c("block", "name")]
#> block name
#> 11 192.0.0.9/32 Port Control Protocol Anycast
#> 12 192.0.0.10/32 Traversal Using Relays around NAT Anycast
#> 36 2001:1::1/128 Port Control Protocol Anycast
#> 37 2001:1::2/128 Traversal Using Relays around NAT Anycast
#> 38 2001:1::3/128 DNS-SD Service Registration Protocol Anycast
#> 40 2001:3::/32 AMT
#> 41 2001:4:112::/48 AS112-v6
#> 43 2001:20::/28 ORCHIDv2
#> 44 2001:30::/28 Drone Remote ID Protocol Entity Tags (DETs) PrefixThose are found by asking raddr itself — each block’s network address tested for containment in every block IANA marks as not globally reachable — so the list is the registry’s, not an editor’s.
0.0.0.0 is two rows, not onezero <- as.data.frame(addr_classify(addr_pton(c("0.0.0.0", "0.1.2.3"))))
zero[, c("block", "name", "category")]
#> block name category
#> 1 0.0.0.0/32 "This host on this network" unspecified
#> 2 0.0.0.0/8 "This network" this_networkOnly 0.0.0.0/32 is the unspecified address. The rest of
0.0.0.0/8 is “this network”, a different thing with a
different meaning. Tables that collapse these into one row — including
RFC 6890’s own Table 2 — lose the distinction; the current registry
splits them and so does raddr.
Two separate IANA columns, and they genuinely differ:
sel <- !is.na(reg$globally_reachable) & !reg$globally_reachable &
!is.na(reg$forwardable) & reg$forwardable
reg[sel, c("block", "name", "globally_reachable", "forwardable")]
#> block name globally_reachable forwardable
#> 3 10.0.0.0/8 Private-Use FALSE TRUE
#> 4 100.64.0.0/10 Shared Address Space FALSE TRUE
#> 7 172.16.0.0/12 Private-Use FALSE TRUE
#> 9 192.0.0.0/29 IPv4 Service Continuity Prefix FALSE TRUE
#> 19 192.88.99.2/32 6a44-relay anycast address FALSE TRUE
#> 20 192.168.0.0/16 Private-Use FALSE TRUE
#> 22 198.18.0.0/15 Benchmarking FALSE TRUE
#> 31 64:ff9b:1::/48 IPv4-IPv6 Translat. FALSE TRUE
#> 32 100::/64 Discard-Only Address Block FALSE TRUE
#> 39 2001:2::/48 Benchmarking FALSE TRUE
#> 49 5f00::/16 Segment Routing (SRv6) SIDs FALSE TRUE
#> 50 fc00::/7 Unique-Local FALSE TRUEEvery one of those blocks is forwardable by a router and not globally reachable. Collapsing the two columns into a single “is it private” boolean silently misfiles all of them.
N/A rows, and two different reasons for one
NAreg[is.na(reg$globally_reachable),
c("block", "name", "footnotes", "termination_date")]
#> block name footnotes termination_date
#> 18 192.88.99.0/24 Deprecated (6to4 Relay Anycast) 2015-03
#> 35 2001::/32 TEREDO [2] <NA>
#> 42 2001:10::/28 Deprecated (previously ORCHID) 2014-03
#> 46 2002::/16 6to4 [3] <NA>Four blocks carry no value, and they do not carry it for the same
reason. Two are deprecated and say so with a
termination_date. Two are live rows where IANA itself
records the value as N/A, with a footnote explaining
why.
raddr ships the fields that tell them apart rather than collapsing them, because “IANA declined to state this” and “this block was withdrawn in 2015” are not the same fact and should not arrive looking alike.
Every rule raddr reports about an address, with the normative force of its source:
codes <- addr_codes_registry()
codes[codes$layer == "classify", c("code", "strength", "rfc")]
#> code strength rfc
#> 16 nat64_wk_embedded_not_global must RFC 6052 section 3.1
#> 17 sixtofour_embedded_not_global must RFC 3056 section 9
#> 18 teredo_client_not_global must RFC 4380 section 4
#> 19 nat64_u_byte_nonzero must RFC 6052 section 2.2
#> 20 link_local_outside_fe80_64 must RFC 4291 section 2.5.6
#> 21 link_local_reserved_range must RFC 3927 section 2.1
#> 22 ipv4_compatible_low_tail may RFC 4291 section 2.5.5.1
#> 23 nat64_local_layout_unspecified unspecified RFC 8215 section 5
#> 24 ula_l_bit_unset unspecified RFC 4193 section 3.1Reporting only the MUST rules would collapse a spectrum into a
binary. The grade follows the rule’s substance, not a keyword
search: RFC 4291 and RFC 8215 invoke RFC 2119 nowhere, so grading by
keyword would mark a binding format definition as unspecified purely
because of how its author wrote it down. See
vignette("reason-codes") for all 24 codes.
Many libraries cite RFC 6890 (2013) as the source for special-purpose
ranges. It is the RFC that created the registry, and it is
thirteen years out of date: it is missing 3fff::/20,
5f00::/16, 64:ff9b:1::/48,
2001:20::/28, 2001:30::/28,
100:0:0:1::/64, 2001:1::1/128,
2001:1::2/128, 2001:1::3/128,
2001:3::/32, 2001:4:112::/48 and
2620:4f:8000::/48, and it still lists
2001:10::/28 as live ORCHID rather than deprecated.
Cite RFC 6890 for the framework — it defines what the policy columns mean — and the IANA registry for the contents. raddr does exactly that.
Every classification carries the stamp of the snapshot that answered it, so a result can be traced to a specific version of the data:
addr_registry_version()
#> [1] "2025-10-09"
addr_address_space_version()
#> [1] "2025-10-10"
addr_transition_version()
#> [1] "2026-07-27"
addr_category_version()
#> [1] "2026-07-27"The snapshots are vendored, so raddr never touches the network and a
classification is reproducible. The cost is that the data is exactly as
fresh as the installed package. addr_registry_outdated() is
how you ask.
raddr reports facts. Everything above is a description of what a specification says or what an implementation does, and none of it is a verdict about whether you should accept an address. That decision depends on what you are defending — it is policy, and it belongs in a different package.
The failure mode this vignette exists to prevent is the quiet one: a
literal that passes your validator and reaches a different host than the
one you checked. That is not a rare adversarial edge case. It is
0177.0.0.1, and it is the first row of the first table.