---
title: "Edge cases"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{Edge cases}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r, include = FALSE}
knitr::opts_chunk$set(
  collapse = TRUE,
  comment = "#>"
)
```

```{r setup}
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.

```{r}
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.

```{r}
show(c("0177.0.0.1", "010.0.0.1", "192.0.010.1", "192.0.048.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

```{r}
show(c("10.1", "127.1", "2130706433", "4294967296", "1.4294967296"))
```

`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

```{r}
show(c("1.2.3.4.", "1.2.3.04", "::1.2.3.04"))
```

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:

```{r}
show(c("::1", "00001::", "12345::", "fe80:abcd::1", "1:2:3:4:5:6:7:8:"))
```

`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

```{r}
show(c("fe80::1%lo0", "fe80::1%25lo0", "fe80::1%lo0%en0"))
```

```{r}
addr_zone(addr_pton("fe80::1%lo0"))
addr_pton("fe80::1%lo0") == addr_pton("fe80::1%en0")
addr_zone(addr_pton("fe80::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:

```{r}
lpm <- as.data.frame(addr_classify(addr_pton(c("192.0.0.1", "192.0.0.9"))))
lpm[, c("block", "name", "category", "globally_reachable")]
```

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

```{r}
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")]
```

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

```{r}
zero <- as.data.frame(addr_classify(addr_pton(c("0.0.0.0", "0.1.2.3"))))
zero[, c("block", "name", "category")]
```

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:

```{r}
sel <- !is.na(reg$globally_reachable) & !reg$globally_reachable &
  !is.na(reg$forwardable) & reg$forwardable
reg[sel, c("block", "name", "globally_reachable", "forwardable")]
```

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`

```{r}
reg[is.na(reg$globally_reachable),
    c("block", "name", "footnotes", "termination_date")]
```

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:

```{r}
codes <- addr_codes_registry()
codes[codes$layer == "classify", c("code", "strength", "rfc")]
```

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 `r nrow(codes)` 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:

```{r}
addr_registry_version()
addr_address_space_version()
addr_transition_version()
addr_category_version()
```

```{r}
addr_registry_outdated()
```

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.
