Edge cases

library(raddr)

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
}

Reading a literal

The leading zero

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.1

0177.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.

Short forms and the whole-host number

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.

The trailing dot, and the WHATWG asymmetry

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:304

Look 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..

IPv6: where the disagreement moves

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.

The zone travels beside the bits

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.

What raddr models, and what it does not

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.

Classification

Longest prefix match, and why a parent block is not the answer

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               TRUE

192.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) Prefix

Those 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 one

zero <- 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_network

Only 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.

“Not globally reachable” is not “not forwardable”

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        TRUE

Every 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.

The N/A rows, and two different reasons for one NA

reg[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.

Reason codes at the classify layer

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.1

Reporting 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.

RFC 6890 is a stale snapshot

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.

Provenance

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"
addr_registry_outdated()
#> [1] FALSE

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.

What none of this decides

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.

mirror server hosted at Truenetwork, Russian Federation.