Reason codes

library(raddr)

A reason code is raddr’s answer to “why”. A reading that produced no address comes with the code that says what stopped it, and an address that classified into a registry block comes with codes for the rules it sits awkwardly against. Codes are the reason a refusal is a finding rather than a shrug.

The whole vocabulary is one data frame:

registry <- addr_codes_registry()
nrow(registry)
#> [1] 24
table(registry$layer)
#> 
#> classify    parse 
#>        9       15

This vignette walks every code in it. Each entry says what the code means, why it is a signal worth having, and how it is detected — including what it does not prove, which is usually the part that matters.

Two layers, and they are not the same kind of statement

registry[registry$layer == "parse", "code"]
#>  [1] "not_a_number"       "leading_zero"       "empty_part"        
#>  [4] "empty_hex"          "out_of_range"       "wrong_part_count"  
#>  [7] "trailing_dot"       "zone_not_permitted" "multiple_zones"    
#> [10] "bad_hextet"         "empty_group"        "bad_elision"       
#> [13] "wrong_group_count"  "bad_embedded_ipv4"  "whitespace"
registry[registry$layer == "classify", "code"]
#> [1] "nat64_wk_embedded_not_global"   "sixtofour_embedded_not_global" 
#> [3] "teredo_client_not_global"       "nat64_u_byte_nonzero"          
#> [5] "link_local_outside_fe80_64"     "link_local_reserved_range"     
#> [7] "ipv4_compatible_low_tail"       "nat64_local_layout_unspecified"
#> [9] "ula_l_bit_unset"

A parse code describes what a parser did with a piece of text. It is a fact about one dialect’s reading, and a different dialect may have no objection at all. A classify code describes an address against a rule in a specification: the address parsed fine, and something about it contradicts a document. That difference is why only the classify layer is graded:

table(registry$layer, registry$strength, useNA = "ifany")
#>           
#>            may must unspecified <NA>
#>   classify   1    6           2    0
#>   parse      0    0           0   15

strength is NA for all fifteen parse codes, and that is the honest value rather than a placeholder. “This dialect wanted digits here” is not a rule with normative force; it is a description of a grammar.

A helper for the examples below

Parse codes are per-dialect, so the interesting question about any example is always which dialects said this. One helper answers it for every example in the next section:

codes_by_dialect <- function(literal) {
  p <- addr_parse(literal)
  found <- vapply(
    c("strict", "whatwg", "pton", "aton", "getaddrinfo", "curl"),
    function(d) paste(unlist(addr_codes(p, d)), collapse = ", "),
    character(1)
  )
  data.frame(codes = found)
}

The parse layer

not_a_number

Meaning. A dot-separated part is not a number in any radix this dialect reads.

Why it is a signal. It is the ordinary “that is not an address” answer, and its value is mostly that it is not one of the interesting codes below. A literal that fails this way fails everywhere, so there is no divergence to reason about.

Detection and precision. Raised on the first part that fails to parse. A code fires once per part, first match wins, so a part that is not a number does not also report the range its garbage value happened to land outside of.

codes_by_dialect("1.2.a.4")
#>                    codes
#> strict      not_a_number
#> whatwg      not_a_number
#> pton        not_a_number
#> aton        not_a_number
#> getaddrinfo not_a_number
#> curl        not_a_number

leading_zero

Meaning. A part carries a leading zero and this dialect forbids one.

Why it is a signal. This is the most important code in the package. A leading zero is where the dialects part company: the RFC dotted-quad grammar refuses it, a browser reads it as octal, and inet_pton strips it and reads decimal. One string, several hosts.

Detection and precision. Note carefully what the example shows — only strict raises it. A leading_zero code does not mean the literal was rejected. It means one dialect objected while the others read the literal happily, and quite possibly as different addresses. Seeing this code is a reason to go look at the readings, not a reason to stop.

codes_by_dialect("01.2.3.4")
#>                    codes
#> strict      leading_zero
#> whatwg                  
#> pton                    
#> aton                    
#> getaddrinfo             
#> curl
addr_reading(addr_parse("0177.0.0.1"), "whatwg")
#> <raddr_address[1]>
#> [1] 127.0.0.1
addr_reading(addr_parse("0177.0.0.1"), "pton")
#> <raddr_address[1]>
#> [1] 177.0.0.1

empty_part

Meaning. Two consecutive dots, or a leading dot, leave a part empty.

Why it is a signal. An empty part is almost always a truncation or a concatenation bug upstream rather than a deliberate spelling.

Detection and precision. Every dialect raises it; there is no divergence here. A trailing dot is a different case with its own code, because the dialects disagree about that one.

codes_by_dialect(".1.2.3")
#>                  codes
#> strict      empty_part
#> whatwg      empty_part
#> pton        empty_part
#> aton        empty_part
#> getaddrinfo empty_part
#> curl        empty_part

empty_hex

Meaning. A part is a digitless "0x" where this dialect requires digits.

Why it is a signal. inet_aton tolerates a digitless 0x in any part but the last, treating it as zero, and WHATWG has no such carve-out — a bare 0x is simply zero there. It is a small, real difference between two widely deployed number parsers.

Detection and precision. Only aton raises it, and only in the final part; 0x.1 and 0x.0x.0 parse without complaint. The other dialects reach a different objection to the same text, which is why the example below shows three different sets of codes for one literal.

codes_by_dialect("0x")
#>                                                 codes
#> strict                 not_a_number, wrong_part_count
#> whatwg                                               
#> pton                   not_a_number, wrong_part_count
#> aton                                        empty_hex
#> getaddrinfo not_a_number, empty_hex, wrong_part_count
#> curl        not_a_number, empty_hex, wrong_part_count

out_of_range

Meaning. A part exceeds the largest value its position can hold.

Why it is a signal. It separates “this was never an address” from “this was an address that overflowed”, which is a real distinction when you are looking at logs.

Detection and precision. The bound is positional, not a flat 255. inet_aton range-checks every arity except the whole-host number: with two to four parts the final part is bounded by 256^(5-k) - 1, so 127.16777215 is fine and 127.16777216 is not. With one part there is no check at all and the value is truncated modulo 2^32 — which is why 4294967296 is 0.0.0.0 under aton and rejected everywhere else.

codes_by_dialect("256.1.1.1")
#>                    codes
#> strict      out_of_range
#> whatwg      out_of_range
#> pton        out_of_range
#> aton        out_of_range
#> getaddrinfo out_of_range
#> curl        out_of_range
addr_aton(c("127.16777215", "127.16777216", "4294967296"))
#> <raddr_address[3]>
#> [1] 127.255.255.255 <NA>            0.0.0.0

wrong_part_count

Meaning. The number of dot-separated parts is not one this dialect accepts.

Why it is a signal. The short forms are the reason 10.1 reaches 10.0.0.1 in a browser and a shell but not in a dotted-quad validator.

Detection and precision. Only the dialects that require exactly four parts raise it. whatwg and aton accept one, two, three or four parts, so a three-part literal is an address to them and an error only to strict and pton.

codes_by_dialect("1.2.3")
#>                        codes
#> strict      wrong_part_count
#> whatwg                      
#> pton        wrong_part_count
#> aton                        
#> getaddrinfo                 
#> curl
addr_whatwg("1.2.3")
#> <raddr_address[1]>
#> [1] 1.2.0.3

trailing_dot

Meaning. The literal ends in a dot that this dialect does not drop.

Why it is a signal. A trailing dot is a fully qualified domain name’s spelling, so it turns up whenever host text has been through a DNS-shaped code path.

Detection and precision. whatwg is the odd one out: it drops a single trailing dot before parsing, so it alone has no objection. Every other dialect raises the code. Do not read this as “harmless” — it is the WHATWG parser accepting a literal the others reject.

codes_by_dialect("1.2.3.4.")
#>                    codes
#> strict      trailing_dot
#> whatwg                  
#> pton        trailing_dot
#> aton        trailing_dot
#> getaddrinfo trailing_dot
#> curl        trailing_dot
addr_whatwg("1.2.3.4.")
#> <raddr_address[1]>
#> [1] 1.2.3.4

zone_not_permitted

Meaning. The literal carries a zone ID and this dialect has no zone ID at all.

Why it is a signal. RFC 4007 zone IDs exist in the socket API and in what people type, and not in the address grammars. The code marks that boundary rather than pretending the text was malformed.

Detection and precision. Raised by the two paper dialects only. The reality dialects read the zone and keep it beside the bits, where it takes no part in equality.

codes_by_dialect("fe80::1%lo0")
#>                          codes
#> strict      zone_not_permitted
#> whatwg      zone_not_permitted
#> pton                          
#> aton                          
#> getaddrinfo                   
#> curl
addr_zone(addr_pton("fe80::1%lo0"))
#> [1] "lo0"

multiple_zones

Meaning. More than one %, so the zone ID has no single delimiter.

Why it is a signal. There is no reading of two zone IDs, so this is a genuine malformation rather than a difference of opinion.

Detection and precision. The paper dialects reach zone_not_permitted first — they object to the first % and never get as far as the second — so this code comes only from the dialects that have zones at all. Two dialects refusing one literal for two different reasons is normal and is why codes are per-dialect.

codes_by_dialect("fe80::1%lo0%en0")
#>                          codes
#> strict      zone_not_permitted
#> whatwg      zone_not_permitted
#> pton            multiple_zones
#> aton                          
#> getaddrinfo     multiple_zones
#> curl            multiple_zones

bad_hextet

Meaning. A group is not one to four hexadecimal digits.

Why it is a signal. It catches both non-hex characters and over-long groups.

Detection and precision. Note that aton raises nothing at all here, and that is not an oversight. inet_aton is an AF_INET parser: it has no objection to a colon literal, it has no reading of one. That is an outcome, not a code, and it is why there is no no_ipv6_reading in the vocabulary.

codes_by_dialect("g::1")
#>                  codes
#> strict      bad_hextet
#> whatwg      bad_hextet
#> pton        bad_hextet
#> aton                  
#> getaddrinfo bad_hextet
#> curl        bad_hextet
addr_outcome(addr_parse("g::1"), "aton")
#> [1] not_an_address
#> Levels: ok rejected not_an_address

empty_group

Meaning. A stray colon leaves a group empty.

Why it is a signal. It is the IPv6 counterpart of empty_part, and like it, usually a truncation.

Detection and precision. A single leading or trailing colon is an empty group; a doubled one is the elision :: and is fine. The engine narrows row by row through one gate at a time, so the first gate a row fails is its reason.

codes_by_dialect(":1")
#>                   codes
#> strict      empty_group
#> whatwg      empty_group
#> pton        empty_group
#> aton                   
#> getaddrinfo empty_group
#> curl        empty_group

bad_elision

Meaning. More than one "::", or a ":::" run.

Why it is a signal. The elision has to be unambiguous — two of them cannot be expanded to a unique address.

Detection and precision. Raised by every dialect that reads IPv6. It is about the count of elisions, not their position; a single :: anywhere is legal, including at either end.

codes_by_dialect("::1::2")
#>                   codes
#> strict      bad_elision
#> whatwg      bad_elision
#> pton        bad_elision
#> aton                   
#> getaddrinfo bad_elision
#> curl        bad_elision

wrong_group_count

Meaning. The literal does not resolve to exactly eight groups.

Why it is a signal. Eight groups is the whole of the IPv6 address, and a literal that resolves to seven or nine is not an address under any reading.

Detection and precision. “Resolve to” is the operative phrase: the check runs after the elision has been expanded, so ::1 is one written group and eight resolved ones. Watch out for text handling that drops a trailing empty field before this check runs — a literal ending in : can be made to look like it has one fewer group than it does.

codes_by_dialect("1:2:3:4:5:6:7")
#>                         codes
#> strict      wrong_group_count
#> whatwg      wrong_group_count
#> pton        wrong_group_count
#> aton                         
#> getaddrinfo wrong_group_count
#> curl        wrong_group_count

bad_embedded_ipv4

Meaning. The dotted-quad tail is not an address under this dialect’s own IPv4 rules.

Why it is a signal. It is the one place the two families meet, and each dialect judges the tail by its own IPv4 grammar rather than by a shared one.

Detection and precision. Because the tail is judged by the dialect’s own rules, a tail that one dialect reads as an address is a bad_embedded_ipv4 to another — ::ffff:1.2.3.04 is fine to pton and not to strict. The IPv4 codes are not repeated on the outer literal; you get this code instead.

codes_by_dialect("::ffff:1.2.3.999")
#>                         codes
#> strict      bad_embedded_ipv4
#> whatwg      bad_embedded_ipv4
#> pton        bad_embedded_ipv4
#> aton                         
#> getaddrinfo bad_embedded_ipv4
#> curl        bad_embedded_ipv4
addr_pton("::ffff:1.2.3.04")
#> <raddr_address[1]>
#> [1] ::ffff:1.2.3.4
addr_strict("::ffff:1.2.3.04")
#> <raddr_address[1]>
#> [1] <NA>

whitespace

Meaning. The literal contains whitespace, which this dialect rejects outright.

Why it is a signal. inet_aton stops at the first whitespace character and ignores everything after it, so "1.2.3.4 junk" is an address to it. getaddrinfo refuses whitespace-bearing input before either primitive sees it. The code marks a gate, not a grammar.

Detection and precision. Look hard at the example: getaddrinfo raises it and curl does not, even though curl composes getaddrinfo. curl tries aton first, and aton answers, so the gate is never reached. A gate in a composition is not a gate in everything built on it. The gate also covers the address and not the zone ID — it tests the text before the %.

codes_by_dialect("1.2.3.4 junk")
#>                  codes
#> strict                
#> whatwg                
#> pton                  
#> aton                  
#> getaddrinfo whitespace
#> curl
addr_curl("1.2.3.4 junk")
#> <raddr_address[1]>
#> [1] 1.2.3.4

The classify layer

These are statements about an address that parsed successfully, and each one reports a rule from a specification. Every one of them is graded:

classify <- registry[registry$layer == "classify", ]
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, which is the move raddr exists to refuse. So a rule stated in weaker language is still reported, and the grade is what tells you not to act on it as though it were a MUST.

Classify codes live in the codes field of the classification, which the print method leaves out. One more helper puts them next to the block that matched:

classify_view <- function(literals) {
  d <- as.data.frame(addr_classify(addr_pton(literals)))
  data.frame(
    input = literals,
    category = as.character(d$category),
    block = d$block,
    codes = vapply(d$codes, paste, character(1), collapse = ", ")
  )
}

The grade follows the rule’s substance, not a keyword search. The sources do not agree about RFC 2119: RFC 4291 and RFC 8215 invoke it nowhere and state their rules in lowercase or as a format diagram, while RFC 3056, 3927, 4193, 4380 and 6052 all invoke it. Grading by keyword would mark a binding format definition as unspecified purely because of how its author wrote it down.

nat64_wk_embedded_not_globalmust

Meaning. The NAT64 well-known prefix carries a non-global embedded IPv4 address.

Why it is a signal. RFC 6052 §3.1 says translators MUST NOT translate such packets and MUST drop them. An address like 64:ff9b::a9fe:a9fe embeds 169.254.169.254 inside the well-known prefix — a link-local address wrapped in an IPv6 costume.

Detection and precision. The rule binds 64:ff9b::/96 alone, never a network-specific prefix, and RFC 8215 §5 says it does not reach 64:ff9b:1::/48. raddr applies it exactly that narrowly.

classify_view("64:ff9b::a9fe:a9fe")
#>                input category        block                        codes
#> 1 64:ff9b::a9fe:a9fe protocol 64:ff9b::/96 nat64_wk_embedded_not_global

sixtofour_embedded_not_globalmust

Meaning. The 6to4 V4ADDR is not in the format of a global unicast address.

Why it is a signal. RFC 3056 §9 requires such traffic to be silently discarded by both encapsulators and decapsulators, so an address carrying a private V4ADDR is one no correct implementation should be relaying.

Detection and precision. Spelled sixtofour because a code may not begin with a digit; CPython’s ipaddress names the same property the same way. The code is about the embedded address, so the outer address still classifies into 2002::/16 normally.

classify_view("2002:a00:1::")
#>          input category     block                         codes
#> 1 2002:a00:1:: protocol 2002::/16 sixtofour_embedded_not_global

teredo_client_not_globalmust

Meaning. A global Teredo address embeds a non-global IPv4 client address.

Why it is a signal. RFC 4380 §4 requires a global Teredo address to embed a global-scope unicast IPv4 as its client address.

Detection and precision. The rule is conditional on the outer address. Only a link-local Teredo identifier MAY embed a private client address, so the code fires on global Teredo addresses and not on link-local ones. Note also that Teredo stores the client address ones-complemented; raddr undoes that before judging it.

classify_view("2001:0:4136:e378:8000:63bf:f5ff:fffe")
#>                                  input category     block
#> 1 2001:0:4136:e378:8000:63bf:f5ff:fffe protocol 2001::/32
#>                      codes
#> 1 teredo_client_not_global
addr_embeddings(addr_pton("2001:0:4136:e378:8000:63bf:f5ff:fffe"))
#> <list_of<raddr_embedding>[1]>
#> [[1]]
#> <raddr_embedding[2]>
#> [1] teredo/server 65.54.227.120 global teredo/client 10.0.0.1 private

ipv4_compatible_low_tailmay

Meaning. The deprecated IPv4-compatible tail is below 1.0.0.0, so it lands in 0.0.0.0/8 and is not a host address.

Why it is a signal. ::2 looks like an IPv4-compatible IPv6 address carrying 0.0.0.2, which is not a host address at all.

Detection and precision. Graded may because implementations are “not required to support this address type”, so a consumer may reasonably discount the reading entirely. It is reported rather than suppressed: the exclusion raddr applies is the registry fact that :: and ::1 are separate rows, not a judgment about the rest of the range.

classify_view("::2")
#>   input    category block                    codes
#> 1   ::2 unallocated  ::/8 ipv4_compatible_low_tail

nat64_local_layout_unspecifiedunspecified

Meaning. raddr read RFC 6052 /48 geometry under 64:ff9b:1::/48, whose syntax RFC 8215 leaves deliberately undefined.

Why it is a signal. This code exists to mark raddr’s own extraction as contested. RFC 8215 says nodes “must not make any assumptions regarding the syntax or properties of those addresses (e.g., the existence and location of embedded IPv4 addresses)” — lowercase, in a document that invokes no RFC 2119.

Detection and precision. The extraction is kept because deployments do use that geometry, and the code is the disclosure that it is a convention rather than something the prefix implies. This is the one code that tells you to trust the accompanying embeddings field less.

classify_view("64:ff9b:1::c000:201")
#>                 input category          block                          codes
#> 1 64:ff9b:1::c000:201 protocol 64:ff9b:1::/48 nat64_local_layout_unspecified

ula_l_bit_unsetunspecified

Meaning. The ULA L bit is 0, so the address is in fc00::/8 rather than the locally assigned fd00::/8.

Why it is a signal. RFC 4193 §3.1 defines only L = 1 and says L = 0 “may be defined in the future”. No allocation mechanism ever was defined, so such an address is unspecified rather than merely unusual — nothing says what it means or who may use it.

Detection and precision. The address still classifies as private, because fc00::/7 is the registry row and that is a fact about the registry. The code is the finer statement layered on top.

classify_view(c("fc00::1", "fd00::1"))
#>     input category    block           codes
#> 1 fc00::1  private fc00::/7 ula_l_bit_unset
#> 2 fd00::1  private fc00::/7

What a code does not prove

Three habits are worth keeping.

A parse code is not a rejection. It belongs to one dialect. leading_zero on strict sits beside two other dialects that read the literal as two different addresses. Always ask which dialect raised it:

codes_by_dialect("0177.0.0.1")
#>                    codes
#> strict      leading_zero
#> whatwg                  
#> pton                    
#> aton                    
#> getaddrinfo             
#> curl

An empty code set is not an endorsement. A dialect with no reading of a literal raises nothing, because it has no objection to state — aton is silent on every IPv6 literal in this vignette. Read addr_outcome() alongside the codes, never the codes alone.

A classify code is evidence, not a verdict. raddr states what a specification says about an address. It does not decide what you should do about it, and strength exists precisely so a may and a must do not arrive looking alike. Turning these facts into an allow-or-deny decision is policy, depends on what you are defending, and belongs in a different package.

The registry itself records when each code entered the vocabulary, so a consumer can tell an unfamiliar code from a new one:

unique(registry$since)
#> [1] "0.1.2"

Adding a code is an addition to raddr’s API; removing one is a breaking change.