diff --git a/docs/netlink/README.md b/docs/netlink/README.md index eb9a4f5b..49f8ba50 100644 --- a/docs/netlink/README.md +++ b/docs/netlink/README.md @@ -26,6 +26,7 @@ If you are looking for the daemon as a whole, start at the | [goip parity: link kinds](goip-link-kinds.md) | The plan after the gaps plan: every link kind a VM can create (bridge, the VLAN family, the UDP overlays, the IP tunnels and the rest), parsed, encoded and `-d`-rendered by goip, plus iproute2 7.2.0's `ip link set dev X inet …` devconf write. The per-kind inventory at `v7.2.0`, the exclusions, and the same three tiers: captures, parsers, then goip and the matrix. | | [goip parity: link kinds — status](goip-link-kinds-status.md) | The tracker for that plan: the next action, the state of each phase from KC0 to KG5, the task checklist, the verification ledger and the decision log. | | [vrf show NAME](vrf-show-name.md) | **Landed.** `goip vrf show NAME`, the refused `ipvrf_get_table` single lookup — the one netlink path left on the `vrf` object. Five tasks (VS0–VS4), each with its test table: pin the already-committed request bytes, the `VrfTableByName` accessor, the argv and output, the capture rows, and the three matrix rows — gated. | +| [vrf family forms](vrf-family-forms.md) | **Landed.** `goip -4/-6/-0 vrf show NAME`, the parked decision-8 family byte — the kernel resolves the name without consulting `ifi_family`, so the three forms are the 44-byte get with one byte changed. Three tasks (VF0–VF2): the captures with the family-byte pin, the guard deletion (the object change is one line), and the three matrix rows — gated. | | [Upstream bug: bond partner state width](upstream/iproute2-bond-partner-state-width.md) | An iproute2 bug found while designing the link write path: `ip` reads the u16 `IFLA_BOND_SLAVE_AD_PARTNER_OPER_PORT_STATE` as a u8, so big-endian hosts always print `0 <>`. | The last three are a chain — the audit finds the gaps, the roadmap decides the @@ -41,9 +42,9 @@ each sent** and the **text each printed**. It is the strongest evidence in the tree that `pkg/xtcpnl` decodes what the kernel actually said, because the reference is iproute2 itself rather than our reading of iproute2. -510 commands are compared, 275 of them dry-run link, link property, route, +513 commands are compared, 275 of them dry-run link, link property, route, address, neighbor, rule, nexthop, address label, neighbor table, statistics, -token, nsid, tcp_metrics, sr, ioam, mptcp, fou, ila, l2tp, macsec, XFRM state and XFRM policy writes, flushes and restores, and 509 are gated. The counts, what "gated" costs to earn, and the scope boundaries are +token, nsid, tcp_metrics, sr, ioam, mptcp, fou, ila, l2tp, macsec, XFRM state and XFRM policy writes, flushes and restores, and 512 are gated. The counts, what "gated" costs to earn, and the scope boundaries are in [parity at a glance](coverage-status.md#goip--ip-parity-at-a-glance). The comparator is `pkg/nlparity` (protocol level) and `internal/goipparity` (command level); the accepted-divergence list is diff --git a/docs/netlink/coverage-status.md b/docs/netlink/coverage-status.md index 7159ae2b..fd7d6ab1 100644 --- a/docs/netlink/coverage-status.md +++ b/docs/netlink/coverage-status.md @@ -2,7 +2,7 @@ ## goip ↔ `ip` parity at a glance -Counted from the tree on 2026-11-01. Every number has a file behind it, named +Counted from the tree on 2026-11-04. Every number has a file behind it, named in the last column; **if this table and the file disagree, the file is right and this table is stale.** That has happened before in this document — see the "no route command is in `gated_commands`" line that outlived its own truth by @@ -10,9 +10,9 @@ several steps, further down under [Remaining](#remaining). | | count | counted from | |---|---|---| -| commands in the comparison matrix | **510** | `internal/goipparity/commands.go` — 235 read rows, the 3 `nexthop show` dev, master and vrf selector rows of the iproute2 7.2.0 move and the 3 `vrf show NAME` single-get rows of VS4 ([vrf show NAME](vrf-show-name.md)) among them, 8 of them G6-T01's `tcp_metrics` reads, each with a `Setup`, 4 G6-T02's `sr` reads, 4 G6-T03's `ioam` shows, 9 G6-T04's `mptcp` reads, 8 G6-T05's `fou` reads, 7 G6-T06's `ila` reads, 13 G6-T07's `l2tp` reads, 15 G6-T08's `macsec` reads, 9 G7-T01's `xfrm state` reads, G7-T02's `xfrm state delall` with no SA to delete, 10 G7-T03's `xfrm policy` reads and G7-T04's `xfrm policy delall` with no policy to delete, NETLINK_XFRM, (31 of them `addr show` state, selector and output-mode rows with a `Setup`, and 6 `route get`, 2 `neigh get`, 4 `nexthop get` and `nexthop bucket`, 3 `link xstats` and `link afstats`, 6 `mroute show`, 3 `token`, 4 `netns list-id`, 3 `route save` and `route showdump`, 1 `route flush`, 1 `neigh flush`, 1 `rule save` and 1 `nexthop flush` rows with one; the missing-table `route flush` row has none), the 21 link write rows, the 26 route write rows, the 27 address write rows, the 7 address flush rows, the 2 address restore rows, the route restore row, the 2 route flush write rows, the 6 neigh write rows, the 2 neigh flush write rows, the 17 rule write rows, the rule flush write row, the rule restore row, the 6 nexthop write rows, the nexthop flush write row, the 2 addrlabel write rows, the addrlabel flush write row, the 3 ntable write rows, the stats write row, the 3 link property write rows, the 2 token write rows, the netns set write row, the 6 `tcp_metrics` delete and flush write rows, the 7 `sr` set write rows, the 16 `ioam` namespace and schema write rows, the 19 `mptcp` endpoint and limits write rows, the 16 `fou` add and del write rows, the 11 `ila` add and del write rows, the 15 `l2tp` tunnel and session add and del write rows and the 24 `macsec` SA, receive channel and offload write rows, generic netlink, the 16 `xfrm state` add, update, delete, deleteall, flush and allocspi write rows and the 12 `xfrm policy` add, update, delete, deleteall, flush, set and setdefault write rows, NETLINK_XFRM | -| of those, `Implemented: true` | **510** | same file — every row: none is `SKIP` and, since G6-T08, none is an `ip` stand-in (`IPStandIn`), which is why the comparator's unimplemented branch and the stand-in's rendering are exercised by synthetic commands | -| of those, in `gated_commands` | **509** | `pkg/nlparity/goip-parity-allowlist.json` | +| commands in the comparison matrix | **513** | `internal/goipparity/commands.go` — 238 read rows, the 3 `nexthop show` dev, master and vrf selector rows of the iproute2 7.2.0 move, the 3 `vrf show NAME` single-get rows of VS4 ([vrf show NAME](vrf-show-name.md)) and the 3 `vrf show NAME` family rows of VF2 ([vrf family forms](vrf-family-forms.md)) among them, 8 of them G6-T01's `tcp_metrics` reads, each with a `Setup`, 4 G6-T02's `sr` reads, 4 G6-T03's `ioam` shows, 9 G6-T04's `mptcp` reads, 8 G6-T05's `fou` reads, 7 G6-T06's `ila` reads, 13 G6-T07's `l2tp` reads, 15 G6-T08's `macsec` reads, 9 G7-T01's `xfrm state` reads, G7-T02's `xfrm state delall` with no SA to delete, 10 G7-T03's `xfrm policy` reads and G7-T04's `xfrm policy delall` with no policy to delete, NETLINK_XFRM, (31 of them `addr show` state, selector and output-mode rows with a `Setup`, and 6 `route get`, 2 `neigh get`, 4 `nexthop get` and `nexthop bucket`, 3 `link xstats` and `link afstats`, 6 `mroute show`, 3 `token`, 4 `netns list-id`, 3 `route save` and `route showdump`, 1 `route flush`, 1 `neigh flush`, 1 `rule save` and 1 `nexthop flush` rows with one; the missing-table `route flush` row has none), the 21 link write rows, the 26 route write rows, the 27 address write rows, the 7 address flush rows, the 2 address restore rows, the route restore row, the 2 route flush write rows, the 6 neigh write rows, the 2 neigh flush write rows, the 17 rule write rows, the rule flush write row, the rule restore row, the 6 nexthop write rows, the nexthop flush write row, the 2 addrlabel write rows, the addrlabel flush write row, the 3 ntable write rows, the stats write row, the 3 link property write rows, the 2 token write rows, the netns set write row, the 6 `tcp_metrics` delete and flush write rows, the 7 `sr` set write rows, the 16 `ioam` namespace and schema write rows, the 19 `mptcp` endpoint and limits write rows, the 16 `fou` add and del write rows, the 11 `ila` add and del write rows, the 15 `l2tp` tunnel and session add and del write rows and the 24 `macsec` SA, receive channel and offload write rows, generic netlink, the 16 `xfrm state` add, update, delete, deleteall, flush and allocspi write rows and the 12 `xfrm policy` add, update, delete, deleteall, flush, set and setdefault write rows, NETLINK_XFRM | +| of those, `Implemented: true` | **513** | same file — every row: none is `SKIP` and, since G6-T08, none is an `ip` stand-in (`IPStandIn`), which is why the comparator's unimplemented branch and the stand-in's rendering are exercised by synthetic commands | +| of those, in `gated_commands` | **512** | `pkg/nlparity/goip-parity-allowlist.json` | | allowlisted divergences | **5** | same file, `entries` | | of those, `kind=version-skew` | **0** | the pin is `ip` 7.2.0 and goip follows it; the seven 7.1.0 entries were retired on 2026-10-21 (see [iproute2 7.2.0](#iproute2-720-the-pin-moves-and-goip-moves-with-it)) | | of those, `kind=accepted-divergence` | **5** | the five `ip netns` rows' `NETNSA_FD`, the descriptor each process opened the namespace file as, a slot in its own table (G4-T04). Nothing else is: the nine wire divergences runs 8 and 9 measured **could not** be, because a transaction-count divergence is unsuppressible (`nlparity_allowlist.go:143`), and G0 retired every one by changing goip to send `ip`'s requests | diff --git a/docs/netlink/goip-parity-gaps.md b/docs/netlink/goip-parity-gaps.md index b880ffd8..715e5481 100644 --- a/docs/netlink/goip-parity-gaps.md +++ b/docs/netlink/goip-parity-gaps.md @@ -109,7 +109,7 @@ G4 delivered all six: goip runs `mroute`, `mrule`, `token`, `netns list-id` and | monitor | Every NEW/DEL notification, across the multicast groups at `ip/ipmonitor.c:267-367` | `ip/ipmonitor.c:194` | each subsystem's notifier | The 10 decoded event types (`IsRtnetlinkEventType`, `RTM_NEWRULE`/`RTM_DELRULE` among them since G4-T05) were not rendered; `goip monitor` renders every type `accept_msg` prints since G4-T06 | | prefix (in monitor) | `RTM_NEWPREFIX` (`prefixmsg`) | `ip/ipprefix.c:33` | `net/ipv6/addrconf.c` | Emitted only when a router advertisement is received | -**maddress depends on the version.** At the pin, `ip maddress show` reads `/proc/net/{dev_mcast,igmp,igmp6}` (`ip/ipmaddr.c:95-179`), and add/del are ioctls, so it is class F at 7.1.0. It is still class F at the 7.2.0 pin: `RTM_GETMULTICAST` reached `ip/ipmaddr.c` in `b8cdda55`, after the tag (`v7.2.0-34`), so maddress moves to this class at the pin bump after 7.2.0. This plan first said the 7.2 release made the move; the checkout was past the tag ([goip-link-kinds.md](goip-link-kinds.md#what-720-adds)). +**maddress depends on the version.** At the pin, `ip maddress show` reads `/proc/net/{dev_mcast,igmp,igmp6}` (`ip/ipmaddr.c:95-179`), and add/del are ioctls, so it is class F at 7.1.0. It is still class F at the 7.2.0 pin: `RTM_GETMULTICAST` reached `ip/ipmaddr.c` in `b8cdda55`, first released in v7.3.0 (the checkout was `v7.2.0-34`), so maddress moves to this class at the 7.3.0 pin bump. This plan first said the 7.2 release made the move; the checkout was past the tag ([goip-link-kinds.md](goip-link-kinds.md#what-720-adds)). At 7.2.0 every form reads /proc, family-filtered included: a capture run on 2026-11-04 recorded zero netlink datagrams for `ip -4/-6 maddress show`, which is what the rows' floors are for. The reply is already grounded either way: the addr plan's self-sent mcdump captures are the corpus's only `IFA_MULTICAST`/`IFA_MC_USERS` holders ([addr-write-path.md](addr-write-path.md), table 6). ### D. Generic-netlink objects goip does not run @@ -144,7 +144,7 @@ These surfaces have no netlink message to capture, so they are out of scope for |---|---| | `tunnel`/`tunl6` parameters and add/change/del/prl/6rd | ioctls `SIOC{GET,ADD,CHG,DEL}TUNNEL`, `SIOC{ADD,DEL}PRL`, `SIOC{ADD,DEL}6RD` (`ip/tunnel.c:63`) | | `tuntap` add/del, and its flags, owner and group | `/dev/net/tun` plus `TUNSETIFF` and related ioctls (`ip/iptuntap.c:62`), and sysfs | -| `maddress` at 7.1.0 and 7.2.0 | `/proc` reads and `SIOCADDMULTI`/`SIOCDELMULTI` | +| `maddress` through the 7.2.0 pin | `/proc` reads and `SIOCADDMULTI`/`SIOCDELMULTI`; `b8cdda55` (v7.3.0) moves `show` to an `RTM_GETMULTICAST` dump | | `netns add/del/exec` | `unshare`, bind mount, `setns` | | `vrf exec/identify/pids` | cgroup2 plus `bpf(BPF_PROG_LOAD)` (`ip/ipvrf.c:55`, `:288`) | | `route flush cache` | a write to `/proc/sys/net/ipv4/route/flush` (`ip/iproute.c:1649`) | diff --git a/docs/netlink/vrf-family-forms.md b/docs/netlink/vrf-family-forms.md new file mode 100644 index 00000000..bc29ce23 --- /dev/null +++ b/docs/netlink/vrf-family-forms.md @@ -0,0 +1,225 @@ +# Plan: `goip -4/-6/-0 vrf show NAME` — the family byte + +## Status + +**Last updated:** 2026-11-04. All three tasks have landed (VF001–VF003); +the plan is complete. This document is the plan and its own status. + +[vrf-show-name.md](vrf-show-name.md) parked the family forms in its +decision 8: "-4/-6 changes only the request's `ifi_family` byte on this +form — the kernel resolves the name without consulting the family — but +**no capture grounds those bytes**". This plan grounds them: three capture +rows, one guard deleted, three matrix rows. It is the parked increment +reopened, the smallest one left at the 7.2.0 pin — see the corrective note +in [goip-parity-gaps.md](goip-parity-gaps.md)'s class C (maddress waits for +the 7.3.0 pin bump). + +### Update rules + +- A task is `done` only when its "Done when" has passed, with evidence in + the checklist's Evidence column. +- Task IDs (`VFn-Tmm`) are stable and never renumbered. Evidence IDs are + `VF###`, so they cannot be confused with the `VS###`, `A###`, `DOC###` or + `MA###` ledgers of the other plans. +- A statement elsewhere in `docs/netlink/` that a task makes false is + amended in that task's commit. Two are named here: VF0 (this plan's own + commit) adds the README index row; VF2 amends + [coverage-status.md](coverage-status.md)'s + [parity-at-a-glance] table and the README counts. +- One task, one commit, as a rule. + +[parity-at-a-glance]: coverage-status.md#goip--ip-parity-at-a-glance + +### Task checklist + +| Done | Task | Work | State | Evidence | +|---|---|---|---|---| +| [x] | [VF0-T01](#vf0--the-captures-and-the-family-byte-pin) | the captures and the family-byte pin | done | VF001 | +| [x] | [VF1-T01](#vf1--the-object) | delete the guard, pass the family | done | VF002 | +| [x] | [VF2-T01](#vf2--the-matrix-rows-and-gating) | matrix rows and gating | done | VF003 | + +``` +VF0 ──> VF1 ──> VF2 +``` + +## Why this increment + +1. **The parked decision names it.** Decision 8's refusal exists *only* + because no capture grounds the family byte; this plan is the capture, + the guard deletion and nothing else. +2. **The kernel is family-blind on the get.** `rtnl_getlink`'s by-name arm + (`rtnl_dev_get_rcu`) resolves the device without consulting + `ifi_family` (`net/core/rtnetlink.c:4346-4353`), so all three forms + print the same `goipvrf 100` — the rows pin the request byte, not a new + answer shape. +3. **One guard, already plumbed.** `vrfShowName`'s refusal + (`internal/goip/obj_vrf.go:89-91`) is the whole delta; line 93 already + passes `c.family` to `VrfTableByName`, and the builder takes the family + (`req.VrfCheckByName`, `internal/goip/req/req.go:251`). + +## Reference sources and ground truth + +The pin is `ip` 7.2.0; kernel cites are the 7.3-rc6 tree. Both trees +read-only; cite `file:line` for every claim. + +| behavior | source | +|---|---| +| the request's `ifi_family` is `preferred_family`, the `-4/-6/-0` flag | `ip/iplink_vrf.c:110-113` (`ipvrf_get_table`) | +| the kernel resolves the name without consulting the family; ENODEV only when the name misses | `net/core/rtnetlink.c:4346-4355` | +| everything else — flags, `IFLA_IFNAME`, the answer's decode, the plain-line print — is the parked form's, byte for byte | `vrf-show-name.md`'s table, VS0–VS2's pins | +| the dump form's family refusal is separate and stays | `internal/goip/obj_vrf.go:142-148` | + +## Decisions + +1. **All three family forms, not two.** `-4` and `-6` pin AF_INET and + AF_INET6; `-0` pins AF_PACKET, the byte no other vrf request carries. + The guard covers all three at once, so refusing `-0` alone would be a + special case with no ground to stand on. +2. **Only the `goipvrf` name, not `default` or a non-VRF device.** The + ENODEV arm is family-blind — the kernel answers ENODEV before any family + question could exist — so the parked rows already ground those answers; + three more rows would re-pin one reply shape three times. +3. **The dump form's refusal is untouched.** `-4 vrf show`'s dump really + does change shape (a minimal inet6 link dump whose replies carry no + `IFLA_LINKINFO`), a form goip does not ground + (`obj_vrf.go:142-148`); only the NAME form's guard goes. + +## Phases and tasks + +### VF0 — The captures and the family-byte pin + +#### VF0-T01 Three rows, three namespaces + +**Depends on: nothing. Delivers testdata and the pin test; no production +code.** + +Three `capio` rows in `capture_set` (`nix/microvms/scripts/capture-netlink-dumps.exp`, +beside the `netlink_route_getvrf_name` row at `:354`), floor 2, run in +every namespace — mesh and tunnel hold no `goipvrf`, so the same rows +ground the ENODEV arm for free, exactly as the plain rows did: + +| row | command | request shape | +|---|---|---| +| `netlink_route_getvrf_name_v4` | `ip -4 vrf show goipvrf` | VS0's 44-byte get, `ifi_family` AF_INET | +| `netlink_route_getvrf_name_v6` | `ip -6 vrf show goipvrf` | the same, AF_INET6 | +| `netlink_route_getvrf_name_0` | `ip -0 vrf show goipvrf` | the same, AF_PACKET | + +Run `nix run .#microvm-x86_64-netlink-dump-capture -- --out ` and +commit only the new directories into `pkg/xtcpnl/testdata/7_3_0/dumps/` +(`git add` before any nix build). The pin test — +`TestVrfFamilyFormsRequestIsTheNameGet`, in +`internal/goip/obj_vrf_test.go` beside VS0's — is the commit's fixture +consumer: + +| description | expected | +|---|---| +| positive: each base pcap's recorded request | byte-identical to `req.VrfCheckByName(, "goipvrf", seq)`, `nlmsg_seq`/`nlmsg_pid` zeroed | +| boundary: the family is the only delta | each equals the plain form's recorded request (VS0's pinned bytes) in every byte but `ifi_family` | +| corner: the family byte's offset | byte 16 — the `ifinfomsg`'s first field, straight after the 16-byte `nlmsghdr` | +| positive: mesh and tunnel | the get and an `NLMSG_ERROR` ENODEV; the sidecar empty | +| boundary: each pcap's datagram count meets its floor | 2; a short capture is discarded, never committed | +| corner: the pin walks all three namespaces | every sidecar and pcap pair the driver wrote is pinned | + +Done when: the driver prints `NLCAP_DRIVER_PASS`, the pin is green, the +inherited gates pass (including `vm-lib-exp` — an `.exp` file changed), and +a scratch worktree mutant that hardcodes AF_UNSPEC in the family-arm pin +is killed by the byte-compare rows. + +### VF1 — The object + +#### VF1-T01 Delete the guard, pass the family + +**Depends on: VF0-T01.** + +Delete `vrfShowName`'s family refusal (`obj_vrf.go:89-91`) and the doc +comment's decision-8 paragraph (`:82-87`), leaving the dump form's refusal +alone. Extend `TestVrfShowName` — VS2's `-4 vrf show goipvrf` corner row +(refused, rc 1, nothing sent) flips to a positive row — and add +`TestVrfShowNameFamilyReplay` over VF0's pcaps; the corpus rows (three per +namespace, `cleanOnly`, beside the plain NAME row in +`internal/goip/corpus_7_3_0_test.go`) land in this commit, the first one +where goip can replay them: + +| description | expected | +|---|---| +| positive: `-4 vrf show goipvrf` | `goipvrf 100\n`, rc 0; the captured request carries AF_INET | +| positive: `-6` and `-0` likewise | the same line; AF_INET6 and AF_PACKET in the request | +| positive: the replay over the three pcaps | stdout byte-equal to the sidecar (`goipvrf 100\n` in base) | +| negative: the family forms in mesh and tunnel | `Invalid VRF\n` on stderr, rc 1, stdout empty — the ENODEV arm, family-blind | +| corner: the dump form still refuses `-4` | `vrf show` with a family stays refused, rc 1 — decision 3 | + +Done when: the tests are green, the corpus rows replay green, `go test +./internal/goip/...` is green, the inherited gates pass, and a scratch +worktree mutant that restores the guard is killed by exactly the three +family rows. + +### VF2 — The matrix rows and gating + +#### VF2-T01 Three rows, four clean runs + +**Depends on: VF1-T01.** + +Three read rows in `internal/goipparity/commands.go`'s vrf block — `-4 +vrf show goipvrf` (`vrf_show_name_v4`), `-6 vrf show goipvrf` +(`vrf_show_name_v6`) and `-0 vrf show goipvrf` (`vrf_show_name_0`), each +`Floor: 2, Implemented: true`, no `Setup`, no `Write`. Expected live +shape, the parked rows' own bar: `txns: ip=1 goip=1`, +`control: nl=0 stdout=0`. + +Run `nix run .#microvm-x86_64-goip-parity -- --timeout 32400` twice. After +two consecutive clean ungated runs: add the three names to +`gated_commands` (`pkg/nlparity/goip-parity-allowlist.json`); pin the rows +in `TestFamilyTableAndJSONRows` and with a cohort test +`TestVrfFamilyFormRowsGated` (following `TestVrfShowNameRowsGated`, +`internal/goipparity/commands_test.go:4684`) plus the earned list in +`TestAllowlistCommitted`; amend coverage-status's +[parity-at-a-glance] table and the README counts; then two consecutive +clean gated runs, back to back with no edit between them — the `-d` rows' +bar. + +| description | expected | +|---|---| +| positive: each form | both sides send the 44-byte get, family byte and all; both print `goipvrf 100` | +| boundary: a divergence in any run | reported and decided; a transaction-count divergence is unsuppressible (`nlparity_allowlist.go:143`) | +| corner: the ungated surface | `GOIP_PARITY_UNGATED_CLEAN` still reads `addr show dynamic` and nothing else | + +Done when: two consecutive ungated runs and the gated pair after them +print `GOIP_PARITY_DRIVER_PASS` and `GOIP_PARITY_OVERALL_PASS` with all +three rows `GOIP_PARITY_PASS`, the rows are gated with their cohort test +and earned-list entries green, the docs are amended, and the task's +checklist row and Evidence column here are flipped. + +## Gates every task inherits + +The table [vrf-show-name.md](vrf-show-name.md) carries, unchanged: +unit tests on the packages touched, `.#test-focused-goip`, netlink-audit, +lint-baseline, quality-report, the nix hygiene checks, `vm-lib-exp` when +an `.exp` file changes, the capture flavor's `NLCAP_DRIVER_PASS`, the +parity flavor's `GOIP_PARITY_DRIVER_PASS`, the conventions review, the +test-shape review, and a mutation check in a scratch worktree. + +## Verification ledger + +| Evidence ID | Date | Tasks / scope | Command or check and environment | Result | Evidence location | +|---|---|---|---|---|---| +| VF001 | 2026-11-04 | VF0-T01 | `nix run .#microvm-x86_64-netlink-dump-capture -- --out /tmp/vf0-cap/dumps` on the host: `NLCAP_DRIVER_PASS`, all nine new rows `datagrams=2 floor=2` — base stdout 12 bytes (`goipvrf 100\n`), mesh and tunnel 0 (`Invalid VRF` is stderr, which read rows drop). `go test ./internal/goip/ -count=1` and `go vet`: green, `TestVrfFamilyFormsRequestIsTheNameGet` 9/9 subtests — each recorded get byte-identical to `req.VrfCheckByName(, goipvrf)` with seq/pid zeroed, the family byte the only delta from the plain form's built request, base's reply `RTM_NEWLINK` and mesh/tunnel's `NLMSG_ERROR` `-ENODEV`. `nix build --no-link` of `.#test-focused-goip`, `.#checks.x86_64-linux.vm-lib-exp`, `.#checks.x86_64-linux.netlink-audit`, `.#checks.x86_64-linux.lint-baseline`, `.#quality-report`, and the nix-fmt, statix and deadnix checks: green. Mutation check in a scratch worktree, removed after — `VrfCheckByName`'s family parameter overwritten to hardcode AF_UNSPEC — killed exactly the nine byte-compare subtests and nothing else (`TestVrfShowNameRequestIsNameIsVrf`, `TestVrfShowName`, `TestVrfShowNameReplay` and `TestCorpus730MatchesSidecarsAt720` stayed green), and was reverted. | Passed: 9/9 rows and 9/9 subtests; the gates green; the mutant caught | the three capio rows in `nix/microvms/scripts/capture-netlink-dumps.exp`; the 18 new files under `pkg/xtcpnl/testdata/7_3_0/dumps/{,mesh/,tunnel/}`; `TestVrfFamilyFormsRequestIsTheNameGet` in `internal/goip/obj_vrf_test.go` | +| VF002 | 2026-11-04 | VF1-T01 | The guard deleted from `vrfShowName` (`obj_vrf.go`), the doc comment amended to name the family pass-through and the dump form's distinct refusal. `go test ./internal/goip/... -count=1` and `go vet`: green — `TestVrfShowName`'s `-4` corner row flipped to a positive row (`goipvrf 100\n`, rc 0, one Talk); three family rows in `TestVrfShowNameReplay` (the planned `TestVrfShowNameFamilyReplay` folded into the existing table instead — one table, one walk), each replaying its own capture through the family-blind dump fallback; three `cleanOnly` corpus rows in `corpus_7_3_0_test.go`, mesh and tunnel refusing with `Invalid VRF` as the plain NAME row does. `nix build --no-link` of `.#test-focused-goip`, `.#checks.x86_64-linux.netlink-audit`, `.#checks.x86_64-linux.lint-baseline`, `.#quality-report`, and the nix-fmt, statix and deadnix checks: green. Mutation check in a scratch worktree, removed after: the guard restored verbatim failed exactly the seven family rows — `TestVrfShowName`'s flipped positive row, the three `TestVrfShowNameReplay` family rows and the three `TestCorpus730MatchesSidecarsAt720` clean-namespace subtests — and nothing else, and was reverted. No VM runs; no `.exp` change. | Passed: the flipped row, 3/3 replay rows, 3/3 corpus rows; the gates green; the mutant caught | `vrfShowName` in `internal/goip/obj_vrf.go`; the flipped row and the family replay rows in `internal/goip/obj_vrf_test.go`; the three corpus rows in `internal/goip/corpus_7_3_0_test.go` | +| VF003 | 2026-11-04 | VF2-T01 | Four live runs of `nix run .#microvm-x86_64-goip-parity` on the host: two with the rows ungated, then, after the gating edits, two back to back with **no edit of any kind between them**. All four read `GOIP_PARITY_DRIVER_PASS` and `GOIP_PARITY_OVERALL_PASS` with **513/513 `GOIP_PARITY_PASS`**, 0 FAIL, `GOIP_PARITY_UNGATED_CLEAN`; each of the three rows `txns: ip=1 goip=1`, `control: nl=0 stdout=0`, the answer and stdout the plain NAME row's own. The three names join `gated_commands` (512 now) and the earned slice; `TestVrfFamilyFormRowsGated` pins the flip (the dump form's `-4 vrf show` is not in the matrix), the three family rows join `TestFamilyTableAndJSONRows`, and `TestUngatedSurfaceIsNotVacuous` is back to exactly `addr show dynamic`. `go test ./internal/goipparity/ ./pkg/nlparity/ -count=1` and `go vet`: green. `nix build --no-link` of `.#test-focused-goip`, `.#checks.x86_64-linux.netlink-audit`, `.#checks.x86_64-linux.lint-baseline`, `.#quality-report`, and the nix-fmt, statix and deadnix checks: green. Mutation check in a scratch worktree, removed after: the `-6 vrf show goipvrf` row dropped from `commands.go` failed exactly the `-6` family row of `TestFamilyTableAndJSONRows`, the `-6` row of `TestVrfFamilyFormRowsGated`, and the gated-command-is-in-the-table corners of `TestCommandsCoverAllowlist` and `TestObjectGatingStatus`, and was reverted. coverage-status's at-a-glance (513 commands / 512 gated, counted from 2026-11-04) and the README counts (513/512) amended in this commit. | Passed: 4/4 live runs clean; the gates green; the mutant caught | the three rows in `internal/goipparity/commands.go`; the family rows and `TestVrfFamilyFormRowsGated` in `internal/goipparity/commands_test.go`; the three names in `pkg/nlparity/goip-parity-allowlist.json` and the earned slice in `pkg/nlparity/nlparity_allowlist_test.go` | + +## Out of scope + +- **`-4/-6/-0 vrf show` (the dump form)** — the family changes the dump's + shape (decision 3). +- **The family forms of `vrf show default` or a non-VRF device** — the + ENODEV arm is family-blind (decision 2). +- **maddress at this pin** — class F until the 7.3.0 pin bump + (`goip-parity-gaps.md`'s corrected class C note). + +## See also + +- [vrf-show-name.md](vrf-show-name.md) — the parked increment this reopens; + VS0–VS2's pins and the decision-8 text this plan deletes. +- [coverage-status.md](coverage-status.md) — the tracker; the + [parity-at-a-glance] table VF2 amends. +- [goip-parity-gaps.md](goip-parity-gaps.md) — the corrected class C note + that closed the maddress door at this pin. diff --git a/internal/goip/corpus_7_3_0_test.go b/internal/goip/corpus_7_3_0_test.go index 60c45581..7d2d7f20 100644 --- a/internal/goip/corpus_7_3_0_test.go +++ b/internal/goip/corpus_7_3_0_test.go @@ -122,6 +122,12 @@ func TestCorpus730MatchesSidecarsAt720(t *testing.T) { args: []string{"vrf", "show"}, pcap: "netlink_route_getvrf", sidecar: "ip_vrf"}, {category: "positive", description: "vrf show goipvrf, ipvrf_get_table's single lookup", expected: "byte-identical", args: []string{"vrf", "show", "goipvrf"}, pcap: "netlink_route_getvrf_name", sidecar: "ip_vrf_name", cleanOnly: true}, + {category: "positive", description: "-4 vrf show goipvrf, the family byte reaching the request", expected: "byte-identical", + args: []string{"-4", "vrf", "show", "goipvrf"}, pcap: "netlink_route_getvrf_name_v4", sidecar: "ip_vrf_name_v4", cleanOnly: true}, + {category: "positive", description: "-6 vrf show goipvrf likewise", expected: "byte-identical", + args: []string{"-6", "vrf", "show", "goipvrf"}, pcap: "netlink_route_getvrf_name_v6", sidecar: "ip_vrf_name_v6", cleanOnly: true}, + {category: "corner", description: "-0 vrf show goipvrf, the AF_PACKET byte no other vrf request carries", expected: "byte-identical", + args: []string{"-0", "vrf", "show", "goipvrf"}, pcap: "netlink_route_getvrf_name_0", sidecar: "ip_vrf_name_0", cleanOnly: true}, {category: "corner", description: "vrf show default, ENODEV's main-table arm", expected: "byte-identical in every namespace", args: []string{"vrf", "show", "default"}, pcap: "netlink_route_getvrf_default", sidecar: "ip_vrf_default"}, {category: "negative", description: "vrf show $dev, an existing non-VRF device", expected: "goip refuses as ip did: non-zero exit, nothing on stdout", diff --git a/internal/goip/obj_vrf.go b/internal/goip/obj_vrf.go index 367d3009..4d08d473 100644 --- a/internal/goip/obj_vrf.go +++ b/internal/goip/obj_vrf.go @@ -77,18 +77,13 @@ func parseVrfShowArgs(args []string) (string, error) { // ipvrf_get_table lookup, its table printed as "NAME table" with the name as // typed. The branch returns before new_json_obj is ever called, so -j, -br and // -o change nothing here — the plain line is printed under every output mode -// (decision 4, docs/netlink/vrf-show-name.md). -// -// The family refusal is the NAME form's own (decision 8): -4/-6 changes only -// the request's ifi_family byte on this form — the kernel resolves the name -// without consulting the family (net/core/rtnetlink.c:4346-4353) — but no -// capture grounds those bytes, so the form stays refused and the message names -// it, keeping the dump form's refusal (the family changes the dump's shape) -// distinct. +// (decision 4, docs/netlink/vrf-show-name.md). The family flag passes through +// to the request's ifi_family byte (decision 1, +// docs/netlink/vrf-family-forms.md): the kernel resolves the name without +// consulting the family (net/core/rtnetlink.c:4346-4355), so -4, -6 and -0 +// print the same line the plain form does, and the dump form's family refusal +// stays distinct (the family changes the dump's shape). func vrfShowName(c *runCtx, name string) error { - if c.family != unix.AF_UNSPEC { - return fmt.Errorf("vrf show NAME with -4/-6: family selection changes only the request's ifi_family byte, a form no capture grounds: %w", ErrNotImplemented) - } svc := service.New(c.src, c.nextSeq) table, err := svc.VrfTableByName(c.family, name) if err != nil { diff --git a/internal/goip/obj_vrf_test.go b/internal/goip/obj_vrf_test.go index 064f6186..aef4600b 100644 --- a/internal/goip/obj_vrf_test.go +++ b/internal/goip/obj_vrf_test.go @@ -435,8 +435,8 @@ func (v *vrfNameSource) Talk(request []byte, _ uint16) ([]byte, error) { // TestVrfShowName runs ipvrf_show's NAME branch (VS2, // docs/netlink/vrf-show-name.md) at the object level over a Talk source: the // `NAME table` line, the Invalid VRF line and rc 1, the usage block and rc -// 255, and the family refusal — each against what the branch's one request -// and zero dumps leave on the wire. +// 255, and the family forms' byte pass-through (VF1) — each against what the +// branch's one request and zero dumps leave on the wire. // // The stdout rows are byte exact because `ip`'s NAME branch is a single // printf, and the -j row pins decision 4: the branch returns before @@ -457,6 +457,11 @@ func TestVrfShowName(t *testing.T) { wantExit int wantTalks int wantDumps int + // wantTalkFamily, when set, is the ifi_family byte the one Talk request + // must carry — the family flag's pass-through asserted on the object's + // own wire, where the replay tests cannot see it (their dump fallback is + // family-blind). + wantTalkFamily uint8 }{ { description: "positive: vrf show goipvrf", @@ -564,15 +569,52 @@ func TestVrfShowName(t *testing.T) { wantExit: ExitMinusOne, }, { - // The family changes only the request's ifi_family byte on this - // form, but no capture grounds those bytes, so the form stays refused - // (decision 8) and nothing is sent. - description: "corner: -4 vrf show goipvrf", - expected: "refused, rc 1, nothing sent", - args: []string{"show", "goipvrf"}, + // The family flag is no longer refused (vrf-family-forms.md VF1): + // the byte reaches the request and the answer is the plain form's + // own, so the fake source answers it the same way. + description: "positive: -4 vrf show goipvrf", + expected: "goipvrf 100\n, rc 0 — the family byte reaches the request, the answer is the plain form's", + args: []string{"show", "goipvrf"}, + links: map[string]vrfNameLink{"goipvrf": {kind: "vrf", table: 100}}, + family: unix.AF_INET, + wantStdout: "goipvrf 100\n", + wantTalks: 1, + wantExit: ExitOK, + wantTalkFamily: unix.AF_INET, + }, + { + description: "positive: -6 vrf show goipvrf, the AF_INET6 byte", + expected: "the same line; the request carries AF_INET6", + args: []string{"show", "goipvrf"}, + links: map[string]vrfNameLink{"goipvrf": {kind: "vrf", table: 100}}, + family: unix.AF_INET6, + wantStdout: "goipvrf 100\n", + wantTalks: 1, + wantExit: ExitOK, + wantTalkFamily: unix.AF_INET6, + }, + { + description: "corner: -0 vrf show goipvrf, the AF_PACKET byte", + expected: "the same line; the request carries AF_PACKET, the byte no other vrf request holds", + args: []string{"show", "goipvrf"}, + links: map[string]vrfNameLink{"goipvrf": {kind: "vrf", table: 100}}, + family: unix.AF_PACKET, + wantStdout: "goipvrf 100\n", + wantTalks: 1, + wantExit: ExitOK, + wantTalkFamily: unix.AF_PACKET, + }, + { + // Decision 6 (VS2) composes with the family flag: a name the + // builder refuses takes the ENODEV arm — Invalid VRF, rc 1 — and + // nothing is sent, the family byte included. + description: "boundary: -4 vrf show of a 16-character name", + expected: "Invalid VRF\n, rc 1 — the ErrBadIfName arm, family flag set, and nothing sent", + args: []string{"show", "aaaaaaaaaaaaaaaa"}, links: map[string]vrfNameLink{"goipvrf": {kind: "vrf", table: 100}}, family: unix.AF_INET, - wantErrSub: "vrf show NAME with -4/-6", + wantStderr: "Invalid VRF\n", + wantErrSub: "vrf invalid", wantExit: ExitUsage, }, } @@ -590,6 +632,16 @@ func TestVrfShowName(t *testing.T) { if len(f.talks) != tc.wantTalks { t.Errorf("Talk calls = %d, want %d", len(f.talks), tc.wantTalks) } + if tc.wantTalkFamily != 0 { + if len(f.talks) != 1 { + t.Fatalf("%d talk requests to read the family byte from, want 1", len(f.talks)) + } + // ifi_family is the first byte of the ifinfomsg, straight after + // the 16-byte nlmsghdr — offset 16, the VF0 pin's byte. + if got := f.talks[0][unix.NLMSG_HDRLEN]; got != tc.wantTalkFamily { + t.Errorf("the request's ifi_family is %d, want %d", got, tc.wantTalkFamily) + } + } if len(f.dumps) != tc.wantDumps { t.Errorf("Dump calls = %d, want %d", len(f.dumps), tc.wantDumps) } @@ -639,6 +691,32 @@ func TestVrfShowNameReplay(t *testing.T) { args: []string{"vrf", "show", "goipvrf"}, wantStdout: "goipvrf 100\n", }, + { + // The family forms (VF1): under replay the dump fallback answers, and + // it is family-blind — the fallback's dump finds each capture's one + // recorded RTM_NEWLINK answer, the get's own, exactly as the nexthop + // row above does. The family byte reaches the request only live; the + // VF0 pin holds it. + description: "positive: -4 vrf show goipvrf over the -4 capture", + expected: "goipvrf 100\n through the family-blind dump fallback", + pcap: corpus730Dir + "netlink_route_getvrf_name_v4.pcap", + args: []string{"-4", "vrf", "show", "goipvrf"}, + wantStdout: "goipvrf 100\n", + }, + { + description: "positive: -6 vrf show goipvrf over the -6 capture", + expected: "the same with AF_INET6", + pcap: corpus730Dir + "netlink_route_getvrf_name_v6.pcap", + args: []string{"-6", "vrf", "show", "goipvrf"}, + wantStdout: "goipvrf 100\n", + }, + { + description: "corner: -0 vrf show goipvrf over the -0 capture", + expected: "the same with AF_PACKET, the byte no other vrf request carries", + pcap: corpus730Dir + "netlink_route_getvrf_name_0.pcap", + args: []string{"-0", "vrf", "show", "goipvrf"}, + wantStdout: "goipvrf 100\n", + }, } for _, tc := range tests { t.Run(tc.description, func(t *testing.T) { @@ -654,3 +732,107 @@ func TestVrfShowNameReplay(t *testing.T) { }) } } + +// TestVrfFamilyFormsRequestIsTheNameGet pins VF0's three family rows: -4/-6/-0 +// changes only the request's ifi_family byte (iplink_vrf.c:110-113), so each +// recorded get must be req.VrfCheckByName's bytes for its family, and the +// plain form's built request in every other byte. The kernel resolves the name +// without consulting the family (rtnetlink.c:4346-4355): base's answer is the +// vrf link itself, mesh's and tunnel's an NLMSG_ERROR ENODEV, and the family +// byte is the whole difference the rows carry. No hand bytes: every row is a +// capture or the builder. +// +// go test ./internal/goip/ -run TestVrfFamilyFormsRequestIsTheNameGet +func TestVrfFamilyFormsRequestIsTheNameGet(t *testing.T) { + tests := []struct { + name, file, category, description, expected string + family uint8 + }{ + {name: "v4", file: "v4", category: "positive", + description: "the -4 get, ifi_family AF_INET", + expected: "byte-identical to req.VrfCheckByName(AF_INET, goipvrf); the plain form's bytes elsewhere", + family: unix.AF_INET}, + {name: "v6", file: "v6", category: "positive", + description: "the -6 get, ifi_family AF_INET6", + expected: "the same with AF_INET6", + family: unix.AF_INET6}, + {name: "af0", file: "0", category: "corner", + // file is the pcap stem's suffix — 0, not af0, because the slug + // netlink_route_getvrf_name_0 spells the flag, not this row's name. + description: "the -0 get, ifi_family AF_PACKET, the byte no other vrf request carries", + expected: "the same with AF_PACKET", + family: unix.AF_PACKET}, + } + for _, tc := range tests { + for _, ns := range corpus730Namespaces { + name := strings.TrimSuffix(ns.subdir, "/") + if name == "" { + name = "clean" + } + t.Run(tc.name+"/"+name, func(t *testing.T) { + t.Logf("%s: %s in the %s namespace; expected: %s", tc.category, tc.description, name, tc.expected) + pcap := corpus730Dir + ns.subdir + "netlink_route_getvrf_name_" + tc.file + ".pcap" + capt, err := nlparity.ParseRouteCaptureFile(pcap) + if err != nil { + t.Fatal(err) + } + msgs := capt.Msgs() + var gets []int + for i, m := range msgs { + if m.IsRequest() && m.Hdr.Type == unix.RTM_GETLINK { + gets = append(gets, i) + } + } + if len(gets) != 1 { + t.Fatalf("%s: %d RTM_GETLINK requests, want the one get", name, len(gets)) + } + captured := capturedRequest(msgs[gets[0]]) + if len(captured) != 44 { + t.Errorf("%s: recorded get is %d bytes, want 44", name, len(captured)) + } + built, err := req.VrfCheckByName(tc.family, "goipvrf", 1) + if err != nil { + t.Fatal(err) + } + if got := zeroSeqPidCopy(built); !bytes.Equal(got, captured) { + t.Errorf("%s: %s get differs\n got %x\nwant %x", name, tc.name, got, captured) + } + if captured[16] != tc.family { + t.Errorf("%s: family byte at offset 16 is %d, want %d", name, captured[16], tc.family) + } + // The family is the only delta: the plain form's built request, + // which VS0 pinned byte for byte, equals this one elsewhere. + plain, err := req.VrfCheckByName(unix.AF_UNSPEC, "goipvrf", 1) + if err != nil { + t.Fatal(err) + } + zeroed := zeroSeqPidCopy(plain) + for i := range captured { + if captured[i] != zeroed[i] && i != 16 { + t.Errorf("%s: byte %d is %d, want the plain form's %d — the family is the only delta", + name, i, captured[i], zeroed[i]) + } + } + // rtnl_talk is synchronous: the reply follows the get. The + // kernel answers the vrf link in the clean namespace and + // ENODEV in mesh and tunnel, which hold no goipvrf. + reply := msgs[gets[0]+1] + if reply.IsRequest() { + t.Fatalf("%s: the datagram after the get is a request", name) + } + if ns.subdir == "" { + if reply.Hdr.Type != unix.RTM_NEWLINK { + t.Errorf("%s: reply type %d, want RTM_NEWLINK", name, reply.Hdr.Type) + } + return + } + if reply.Hdr.Type != unix.NLMSG_ERROR { + t.Fatalf("%s: reply type %d, want NLMSG_ERROR", name, reply.Hdr.Type) + } + if errno := int32(binary.LittleEndian.Uint32(reply.Body[0:4])); errno != -int32(unix.ENODEV) { + t.Errorf("%s: NLMSG_ERROR errno %d, want -ENODEV", name, errno) + } + }) + } + } +} diff --git a/internal/goipparity/commands.go b/internal/goipparity/commands.go index 66508d64..3dc2d97e 100644 --- a/internal/goipparity/commands.go +++ b/internal/goipparity/commands.go @@ -2406,6 +2406,28 @@ var commands = withArgs([]Command{ // ground. Floor: 2, Implemented: true, }, + { + Name: "-4 vrf show goipvrf", Slug: "vrf_show_name_v4", + // The parked NAME get's family forms (vrf-family-forms.md): -4/-6/-0 + // changes only the request's ifi_family byte (iplink_vrf.c:110-113) — + // the kernel resolves the name without consulting the family + // (rtnetlink.c:4346-4355), so the answer and stdout are the plain + // row's own, `goipvrf 100`, and the floor with it. VF0's captures + // ground the bytes; VF1 deleted the guard. + Floor: 2, Implemented: true, + }, + { + Name: "-6 vrf show goipvrf", Slug: "vrf_show_name_v6", + // The AF_INET6 twin: the same get with the v6 family byte, the same + // answer, the same floor. + Floor: 2, Implemented: true, + }, + { + Name: "-0 vrf show goipvrf", Slug: "vrf_show_name_0", + // The AF_PACKET corner: the family byte no other vrf request carries; + // the kernel answers it exactly as the others. + Floor: 2, Implemented: true, + }, // The link write rows (link-write-path.md phase 7): the 21 rows of the // committed corpus in pkg/xtcpnl/testdata/7_1_4/link-write/write/, with // the same preconditions and floors as capture-link-writes.exp. All 21 diff --git a/internal/goipparity/commands_test.go b/internal/goipparity/commands_test.go index b848eaa7..504d4435 100644 --- a/internal/goipparity/commands_test.go +++ b/internal/goipparity/commands_test.go @@ -874,6 +874,27 @@ func TestFamilyTableAndJSONRows(t *testing.T) { floor: 2, implemented: true, }, + { + description: "positive: -4 vrf show goipvrf, the NAME get with the family byte, the kernel family-blind on the lookup", + name: "-4 vrf show goipvrf", + slug: "vrf_show_name_v4", + floor: 2, + implemented: true, + }, + { + description: "positive: -6 vrf show goipvrf, the AF_INET6 twin, the same answer and floor", + name: "-6 vrf show goipvrf", + slug: "vrf_show_name_v6", + floor: 2, + implemented: true, + }, + { + description: "corner: -0 vrf show goipvrf, the AF_PACKET byte no other vrf request carries", + name: "-0 vrf show goipvrf", + slug: "vrf_show_name_0", + floor: 2, + implemented: true, + }, { description: "positive: vrf show default, the ENODEV arm both sides resolve to the main table", name: "vrf show default", @@ -4724,3 +4745,47 @@ func TestVrfShowNameRowsGated(t *testing.T) { }) } } + +// TestVrfFamilyFormRowsGated pins VF2's three family rows: implemented, on no +// Setup (the NAME form resolves nothing up front), each floor 2 — the one +// RTM_GETLINK get by name, family byte and all, and its answer — and gated +// with no allowlist entry after the runs. The plain NAME row they sit beside +// stays gated beside them, and a family form of the dump twin is not in the +// matrix at all. +// +// go test ./internal/goipparity/ -run TestVrfFamilyFormRowsGated +func TestVrfFamilyFormRowsGated(t *testing.T) { + al, err := nlparity.EmbeddedAllowlist() + if err != nil { + t.Fatalf("EmbeddedAllowlist: %v", err) + } + type want struct { + implemented, gated, setup bool + floor, entries int + } + gated := want{implemented: true, gated: true, floor: 2} + rows := []struct { + description string + command string + expected want + }{ + {"positive: -4, the AF_INET family byte", "-4 vrf show goipvrf", gated}, + {"positive: -6, the AF_INET6 twin", "-6 vrf show goipvrf", gated}, + {"corner: -0, the AF_PACKET byte no other vrf request carries", "-0 vrf show goipvrf", gated}, + {"boundary: the plain NAME row stays gated beside them", "vrf show goipvrf", want{implemented: true, gated: true, floor: 2}}, + {"negative: the dump form's family twin is not in the matrix", "-4 vrf show", want{}}, + } + for _, r := range rows { + t.Run(r.description, func(t *testing.T) { + var got want + if c, lookupErr := Lookup(r.command); lookupErr == nil { + got.implemented, got.setup, got.floor = c.Implemented, len(c.Setup) > 0, c.Floor + } + got.gated = al.IsGated(r.command) + got.entries = len(al.EntriesFor(r.command)) + if got != r.expected { + t.Errorf("%q: got %+v, want %+v", r.command, got, r.expected) + } + }) + } +} diff --git a/nix/microvms/netlink-capture.nix b/nix/microvms/netlink-capture.nix index ff1ada6e..1c180d29 100644 --- a/nix/microvms/netlink-capture.nix +++ b/nix/microvms/netlink-capture.nix @@ -166,8 +166,9 @@ let # captures (docs/netlink/addr-write-path.md, table 6). Those are the only # replies that carry IFA_MULTICAST, IFA_ANYCAST and IFA_MC_USERS, and the # pinned `ip` never asks for them: `ip maddr` reads /proc until iproute2 - # b8cdda55, which is in no release, and nothing in iproute2 sends - # RTM_GETANYCAST. So this sends the request itself, the way + # b8cdda55, which is first released in v7.3.0 (after this pin), and + # nothing in iproute2 sends RTM_GETANYCAST. So this sends the request + # itself, the way # rtnl_mcaddrdump_req (lib/libnetlink.c:338) builds it: an nlmsghdr with # NLM_F_REQUEST | NLM_F_DUMP and a zeroed ifaddrmsg naming the family. # diff --git a/nix/microvms/scripts/capture-netlink-dumps.exp b/nix/microvms/scripts/capture-netlink-dumps.exp index b98c416d..5c36cc15 100644 --- a/nix/microvms/scripts/capture-netlink-dumps.exp +++ b/nix/microvms/scripts/capture-netlink-dumps.exp @@ -352,6 +352,15 @@ proc capture_set {ns subdir dev} { # rc and the stderr line stay unit assertions (VS2); these rows ground the # request and answer bytes. capio $ns $subdir netlink_route_getvrf_name 2 "ip vrf show goipvrf" ip_vrf_name + # The family forms of the NAME get (vrf-family-forms.md VF0): -4/-6/-0 + # changes only the request's ifi_family byte (iplink_vrf.c:110-113) — the + # kernel resolves the name without consulting the family + # (rtnetlink.c:4346-4355), so every answer here is the plain row's own: + # goipvrf 100 in base, ENODEV in mesh and tunnel, and the family byte is + # all the rows add. Floor 2, the get and its answer. + capio $ns $subdir netlink_route_getvrf_name_v4 2 "ip -4 vrf show goipvrf" ip_vrf_name_v4 + capio $ns $subdir netlink_route_getvrf_name_v6 2 "ip -6 vrf show goipvrf" ip_vrf_name_v6 + capio $ns $subdir netlink_route_getvrf_name_0 2 "ip -0 vrf show goipvrf" ip_vrf_name_0 capio $ns $subdir netlink_route_getvrf_default 2 "ip vrf show default" ip_vrf_default capio $ns $subdir netlink_route_getvrf_novrf 2 "ip vrf show $dev" ip_vrf_novrf diff --git a/pkg/nlparity/goip-parity-allowlist.json b/pkg/nlparity/goip-parity-allowlist.json index 1d17c42f..9f0515fd 100644 --- a/pkg/nlparity/goip-parity-allowlist.json +++ b/pkg/nlparity/goip-parity-allowlist.json @@ -1251,6 +1251,9 @@ "vrf show goipvrf", "vrf show default", "vrf show goip0", + "-4 vrf show goipvrf", + "-6 vrf show goipvrf", + "-0 vrf show goipvrf", "nexthop show", "-j nexthop show", "nexthop show dev goip0", diff --git a/pkg/nlparity/nlparity_allowlist_test.go b/pkg/nlparity/nlparity_allowlist_test.go index 6d9b0f8e..5813bb63 100644 --- a/pkg/nlparity/nlparity_allowlist_test.go +++ b/pkg/nlparity/nlparity_allowlist_test.go @@ -404,6 +404,17 @@ func TestAllowlistCommitted(t *testing.T) { "vrf show goipvrf", "vrf show default", "vrf show goip0", + // The three `vrf show NAME` family rows (VF2 of + // docs/netlink/vrf-family-forms.md, the parked decision-8 increment) + // - gated on two consecutive clean ungated runs measured 2026-11-04, + // both GOIP_PARITY_PASS 513/513 with GOIP_PARITY_UNGATED_CLEAN, txns + // 1/1, control nl=0 stdout=0, the answer and stdout the plain NAME + // row's own — the family byte is the whole difference the rows carry + // (iplink_vrf.c:110-113; the kernel resolves the name without + // consulting the family, rtnetlink.c:4346-4355). + "-4 vrf show goipvrf", + "-6 vrf show goipvrf", + "-0 vrf show goipvrf", // nexthop and addrlabel, gated on runs 10 and 11 - the first live // grounding of those two objects, which were replay-grounded only. // Both runs GOIP_PARITY_PASS with stdout=0 and no unsuppressed findings, diff --git a/pkg/xtcpnl/testdata/7_3_0/dumps/ip_vrf_name_0 b/pkg/xtcpnl/testdata/7_3_0/dumps/ip_vrf_name_0 new file mode 100644 index 00000000..21ee2858 --- /dev/null +++ b/pkg/xtcpnl/testdata/7_3_0/dumps/ip_vrf_name_0 @@ -0,0 +1 @@ +goipvrf 100 diff --git a/pkg/xtcpnl/testdata/7_3_0/dumps/ip_vrf_name_v4 b/pkg/xtcpnl/testdata/7_3_0/dumps/ip_vrf_name_v4 new file mode 100644 index 00000000..21ee2858 --- /dev/null +++ b/pkg/xtcpnl/testdata/7_3_0/dumps/ip_vrf_name_v4 @@ -0,0 +1 @@ +goipvrf 100 diff --git a/pkg/xtcpnl/testdata/7_3_0/dumps/ip_vrf_name_v6 b/pkg/xtcpnl/testdata/7_3_0/dumps/ip_vrf_name_v6 new file mode 100644 index 00000000..21ee2858 --- /dev/null +++ b/pkg/xtcpnl/testdata/7_3_0/dumps/ip_vrf_name_v6 @@ -0,0 +1 @@ +goipvrf 100 diff --git a/pkg/xtcpnl/testdata/7_3_0/dumps/mesh/ip_vrf_name_0 b/pkg/xtcpnl/testdata/7_3_0/dumps/mesh/ip_vrf_name_0 new file mode 100644 index 00000000..e69de29b diff --git a/pkg/xtcpnl/testdata/7_3_0/dumps/mesh/ip_vrf_name_v4 b/pkg/xtcpnl/testdata/7_3_0/dumps/mesh/ip_vrf_name_v4 new file mode 100644 index 00000000..e69de29b diff --git a/pkg/xtcpnl/testdata/7_3_0/dumps/mesh/ip_vrf_name_v6 b/pkg/xtcpnl/testdata/7_3_0/dumps/mesh/ip_vrf_name_v6 new file mode 100644 index 00000000..e69de29b diff --git a/pkg/xtcpnl/testdata/7_3_0/dumps/mesh/netlink_route_getvrf_name_0.pcap b/pkg/xtcpnl/testdata/7_3_0/dumps/mesh/netlink_route_getvrf_name_0.pcap new file mode 100644 index 00000000..a0a27acb Binary files /dev/null and b/pkg/xtcpnl/testdata/7_3_0/dumps/mesh/netlink_route_getvrf_name_0.pcap differ diff --git a/pkg/xtcpnl/testdata/7_3_0/dumps/mesh/netlink_route_getvrf_name_v4.pcap b/pkg/xtcpnl/testdata/7_3_0/dumps/mesh/netlink_route_getvrf_name_v4.pcap new file mode 100644 index 00000000..2ddd4312 Binary files /dev/null and b/pkg/xtcpnl/testdata/7_3_0/dumps/mesh/netlink_route_getvrf_name_v4.pcap differ diff --git a/pkg/xtcpnl/testdata/7_3_0/dumps/mesh/netlink_route_getvrf_name_v6.pcap b/pkg/xtcpnl/testdata/7_3_0/dumps/mesh/netlink_route_getvrf_name_v6.pcap new file mode 100644 index 00000000..a3a13a42 Binary files /dev/null and b/pkg/xtcpnl/testdata/7_3_0/dumps/mesh/netlink_route_getvrf_name_v6.pcap differ diff --git a/pkg/xtcpnl/testdata/7_3_0/dumps/netlink_route_getvrf_name_0.pcap b/pkg/xtcpnl/testdata/7_3_0/dumps/netlink_route_getvrf_name_0.pcap new file mode 100644 index 00000000..23756373 Binary files /dev/null and b/pkg/xtcpnl/testdata/7_3_0/dumps/netlink_route_getvrf_name_0.pcap differ diff --git a/pkg/xtcpnl/testdata/7_3_0/dumps/netlink_route_getvrf_name_v4.pcap b/pkg/xtcpnl/testdata/7_3_0/dumps/netlink_route_getvrf_name_v4.pcap new file mode 100644 index 00000000..db566667 Binary files /dev/null and b/pkg/xtcpnl/testdata/7_3_0/dumps/netlink_route_getvrf_name_v4.pcap differ diff --git a/pkg/xtcpnl/testdata/7_3_0/dumps/netlink_route_getvrf_name_v6.pcap b/pkg/xtcpnl/testdata/7_3_0/dumps/netlink_route_getvrf_name_v6.pcap new file mode 100644 index 00000000..11f73700 Binary files /dev/null and b/pkg/xtcpnl/testdata/7_3_0/dumps/netlink_route_getvrf_name_v6.pcap differ diff --git a/pkg/xtcpnl/testdata/7_3_0/dumps/tunnel/ip_vrf_name_0 b/pkg/xtcpnl/testdata/7_3_0/dumps/tunnel/ip_vrf_name_0 new file mode 100644 index 00000000..e69de29b diff --git a/pkg/xtcpnl/testdata/7_3_0/dumps/tunnel/ip_vrf_name_v4 b/pkg/xtcpnl/testdata/7_3_0/dumps/tunnel/ip_vrf_name_v4 new file mode 100644 index 00000000..e69de29b diff --git a/pkg/xtcpnl/testdata/7_3_0/dumps/tunnel/ip_vrf_name_v6 b/pkg/xtcpnl/testdata/7_3_0/dumps/tunnel/ip_vrf_name_v6 new file mode 100644 index 00000000..e69de29b diff --git a/pkg/xtcpnl/testdata/7_3_0/dumps/tunnel/netlink_route_getvrf_name_0.pcap b/pkg/xtcpnl/testdata/7_3_0/dumps/tunnel/netlink_route_getvrf_name_0.pcap new file mode 100644 index 00000000..fa57d83c Binary files /dev/null and b/pkg/xtcpnl/testdata/7_3_0/dumps/tunnel/netlink_route_getvrf_name_0.pcap differ diff --git a/pkg/xtcpnl/testdata/7_3_0/dumps/tunnel/netlink_route_getvrf_name_v4.pcap b/pkg/xtcpnl/testdata/7_3_0/dumps/tunnel/netlink_route_getvrf_name_v4.pcap new file mode 100644 index 00000000..190a68f6 Binary files /dev/null and b/pkg/xtcpnl/testdata/7_3_0/dumps/tunnel/netlink_route_getvrf_name_v4.pcap differ diff --git a/pkg/xtcpnl/testdata/7_3_0/dumps/tunnel/netlink_route_getvrf_name_v6.pcap b/pkg/xtcpnl/testdata/7_3_0/dumps/tunnel/netlink_route_getvrf_name_v6.pcap new file mode 100644 index 00000000..c21d60e0 Binary files /dev/null and b/pkg/xtcpnl/testdata/7_3_0/dumps/tunnel/netlink_route_getvrf_name_v6.pcap differ