Skip to content

test(net): property-test the headers view, matcher, and builder - #1734

Closed
daniel-noland wants to merge 30 commits into
pr/daniel-noland/fuzz-net-checksumfrom
pr/daniel-noland/fuzz-net-headers
Closed

test(net): property-test the headers view, matcher, and builder#1734
daniel-noland wants to merge 30 commits into
pr/daniel-noland/fuzz-net-checksumfrom
pr/daniel-noland/fuzz-net-headers

Conversation

@daniel-noland

Copy link
Copy Markdown
Collaborator

Second of five, stacked on #1733.

Property tests for net::headers — the HeadersView unsafe boundary, the
pattern matchers, the builder's Within::conform, and the embedded (ICMP-quoted)
header views. Coverage of the module went from roughly a third to the high
nineties; the lines still uncovered are the unreachable_unchecked arm of each
arity, which must stay uncovered because reaching one is the undefined behaviour
the whole HeadersView invariant exists to prevent.

The recurring finding, across five separate instances, was code that no
generator could reach rather than code that was wrong: embedded_ip was never
set by any generator, the absent-layer arm of every opt_* method was
unconstructible, no shape ever named Vxlan, the first-step-refusal branch was
unreachable at every arity, and conform was only reachable through a builder
no test used. In each case "we have tests for that" was false and the tests
looked fine.

Two things the tests pin rather than fix, both for the owner to decide:

  • when/inspect/otherwise on the embedded matchers read only the inner
    accumulator, while done() requires both. A packet whose outer chain fails
    but whose quoted packet matches runs inspect, skips otherwise, and then
    returns None — 4,974 packets in 25,000. Documented behaviour, but
    otherwise is the error hook and stays silent for a whole class of failure.
  • All twelve Within<Icmp4|Icmp6> for <subtype> conform bodies are dead.
    Install runs afterwards from build_headers and overwrites whatever they
    wrote, and nothing can be stacked on a subtype, so there is no arrangement in
    which conform gets the last word.

The last commit is new, not extracted: the embedded vacuity guard only asserts
once a run is long enough for a miss to mean something, and its threshold was
set at the case count a default run managed when it was written. Coverage
instrumentation costs about a fifth of the throughput here — measured at 17 hits
in 20,206 cases against 21 in 25,857 — which left the threshold sitting a couple
of hundred cases below what CI actually draws, so a busier runner would drop
under it and the check would disappear silently. Lowered to ten thousand.

Worth recording since #1714 adds --cfg=instrumented for this purpose: these
properties are bound by the generator rather than by a counter loop, so unlike
the fib test that motivated the cfg, no iteration counts need cutting here.

Verified locally: dataplane-net 473/473, fmt --check and
clippy -D warnings clean.

🤖 Generated with Claude Code

@daniel-noland daniel-noland added the dont-merge Do not merge this Pull Request label Aug 17, 2026
@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.83973% with 28 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
net/src/headers/embedded.rs 91.20% 6 Missing and 2 partials ⚠️
net/src/headers/view.rs 94.21% 4 Missing and 3 partials ⚠️
net/src/headers/embedded_view.rs 96.53% 4 Missing and 2 partials ⚠️
net/src/headers/within.rs 93.22% 3 Missing and 1 partial ⚠️
net/src/headers/mod.rs 98.78% 1 Missing and 1 partial ⚠️
net/src/headers/pat.rs 99.63% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-net-headers branch from 8f36183 to 61cd021 Compare August 18, 2026 03:07
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-net-checksum branch from 56e8187 to 61049ae Compare August 18, 2026 20:23
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-net-headers branch from 61cd021 to e2c650c Compare August 18, 2026 20:23
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-net-checksum branch from 61049ae to dd6e234 Compare August 18, 2026 20:31
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-net-headers branch from e2c650c to 90b4298 Compare August 18, 2026 20:31
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-net-checksum branch from dd6e234 to 1ba8747 Compare August 18, 2026 20:38
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-net-headers branch from 90b4298 to 3ea0473 Compare August 18, 2026 20:38
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-net-checksum branch from 1ba8747 to 94cdc87 Compare August 18, 2026 23:17
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-net-headers branch from 3ea0473 to 7954def Compare August 18, 2026 23:17
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-net-checksum branch from 94cdc87 to a9d9273 Compare August 19, 2026 06:33
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-net-headers branch from 7954def to aaa46d5 Compare August 19, 2026 06:33
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-net-checksum branch from a9d9273 to 3268b44 Compare August 20, 2026 01:58
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-net-headers branch from aaa46d5 to 41a96c0 Compare August 20, 2026 01:58
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-net-checksum branch from 3268b44 to d6c577e Compare August 20, 2026 02:32
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-net-headers branch from 41a96c0 to 8f67fe3 Compare August 20, 2026 02:32
@mvachhar
mvachhar force-pushed the pr/daniel-noland/fuzz-net-checksum branch from d6c577e to f97fb60 Compare August 21, 2026 15:13
@mvachhar
mvachhar force-pushed the pr/daniel-noland/fuzz-net-headers branch from 8f67fe3 to 332eaf2 Compare August 21, 2026 15:13
daniel-noland and others added 9 commits August 25, 2026 20:48
Debug binaries retained the complete Rust toolchain through their
standard-library source paths, adding roughly 2.4 GB to the closure.
They also carried a sizable DWARF index that neither packaged debugger
consumes.

Point those paths at the much smaller rust-src component, omit unused
prebuilt documentation, and remove .debug_names. Source browsing and
symbols remain available while the resulting diagnostic images become
practical to store and transfer.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
(cherry picked from commit 7b2f63f)
A core collected from the lab is useful only with the exact unstripped
binaries and sources that produced it. A general debugging toolbox
cannot reconstruct that relationship after the release has moved on.

Provide a purpose-built gdb image alongside each build and teach it
Rust's standard-library types without retaining rustc. This keeps
post-mortem debugging reproducible while avoiding unrelated
live-debugging tools.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
(cherry picked from commit 0a97d9f)
Post-mortem inspection and live debugging need different tools. The core
viewer cannot offer an editor-driven session, while bugstalker
understands Rust layouts and can expose the running dataplane through
the Debug Adapter Protocol.

Track bugstalker upstream for its current remote DAP support and package
it separately with the matching binaries and sources. Keeping the image
single-purpose avoids making every diagnostic artifact carry every
debugger.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
(cherry picked from commit 0000171)
Some failures need a record of the dataplane's kernel interactions
rather than an interactive debugger. A small, repeatable tracing
environment is easier to deploy and feed into existing log analysis than
a general-purpose toolbox.

Package lurk around the matching release binaries and follow the worker
threads where the dataplane does its work. Because syscall tracing needs
no symbols, this image can stay much smaller than the debugger images.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
(cherry picked from commit c00aff1)
The diagnostic images are useful only when they match the build being
investigated, but building roughly 850 MB of extra images for every pull
request would undermine the runner-load reduction this CI rework is
meant to achieve.

Build them automatically for pushes, the merge queue, and manual runs,
with an explicit label available for debugging a pull request. Publish
all three beside tagged releases so the matching tools remain available
when a deployed build needs investigation.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
(cherry picked from commit 9621607)
All three images built green while two of them could not do what the
README documented.  Building proves an image links; it says nothing
about whether its entrypoint runs.

`smoke-container` runs each one the way the README tells a user to:

  - the tracer under a plain `docker run`, with no seccomp relaxation,
    and requires an `execve` in the trace.  Its failure mode is the
    reason this exists: lurk emitted eight well-formed JSON lines
    recording its own child failing to start, then exited 0, which the
    `jq -R 'fromjson? // empty'` filter we document accepts without
    complaint.  Both guards fire against the pre-fix image.
  - the core viewer through its own entrypoint rather than by invoking
    gdb directly, since the `--directory` and `source` flags that
    register the printers live in that entrypoint.  Checks that the
    printer set is registered, not merely that gdb started.
  - the debugger only for coming up and listening.  Driving a real DAP
    session from CI means carrying a protocol client in-tree, and the
    contract it would pin is exercised better by pointing an editor at
    the image.  It also would not have caught the defect on that image,
    which was in the documentation rather than the runtime.

The trace goes to a file rather than a shell variable: at a few
megabytes it overruns the here-string limit, and every grep against it
then fails with E2BIG, which reads exactly like a failed trace.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
(cherry picked from commit 3c76aec)
The published images debug what CI built.  Debugging what you are
building meant either rebuilding an image by hand or falling back to a
system gdb, which is exactly the case where symbols do not line up.

`just debug <tool> <target>` builds the image that carries the tool at
the current profile, platform, instrumentation, and sanitizer, and runs
the target inside it:

  - `lurk` traces syscalls and streams JSON until the program exits.
  - `gdb` runs gdbserver and waits, printing the `target remote` line.
  - `bugstalker` waits for a DAP client, printing the `program` and
    `args` for the launch request -- in remote-DAP mode it takes those
    from the client rather than from its own command line.

`target` is either one of the binaries the images already carry, in
which case nothing needs mounting, or a nextest filter.  For a test the
archive is built and unpacked, and that binary was built outside the
image, so the store comes along read-only and it runs with its package
directory as the working directory the way nextest runs it.

Naming a target exactly is the tedious part -- test paths are long and
nobody remembers them -- so leaving it out offers everything through
skim, and a filter matching several offers those.  Resolution stays
unambiguous: exactly one match runs without asking, no match is an
error, and several with no terminal to ask at is an error listing them
rather than a guess.  That last case is what keeps this safe to call
from a script.

`just debug-list` prints the same list without running anything, and
`just inspect-core` opens a core file in a gdb built from the same
settings.

Randomization stays enabled under gdbserver.  Docker's default seccomp
answers personality(ADDR_NO_RANDOMIZE) with EPERM, and while gdbserver
treats that as non-fatal -- unlike lurk, which is why lurk is patched --
it otherwise opens with a warning that reads like a real failure.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
(cherry picked from commit 057b27d)
Written down because the convention is not self-evident from `git log` alone: a
reviewer can see that messages are short without seeing that the brevity is deliberate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The html report is for reading; lcov is what external coverage tooling ingests, and
regenerating it separately means running the whole instrumented suite again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
daniel-noland and others added 21 commits August 25, 2026 20:50
… dev shell

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both are meant to be copied into a VM or an image that has no store to resolve an
interpreter against. Why configure flags cannot get you there, and why perf needs a
different route from gdb, is recorded at each derivation -- the reasoning constrains the
argument lists, so it lives beside them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
duvet parses a specification into its individual requirements and matches
them against citations left in the source -- `//= <url>` followed by the
requirement's text -- so a requirement with no implementation, or an
implementation with no test, becomes visible.

It is the third leg. Bolero says a property holds. cargo-mutants says enough
properties exist that nothing goes unasserted. Neither can say the
properties are the ones the specification asked for, and the RFC 4884 defect
fixed in the previous commit is what that gap looks like: a length check
expressed in bits where the values were octets, refusing seven of every
eight conforming messages, with a test that had been written to make the
deviation pass. Mutation testing would have rewarded closing those mutants
against the code, cementing it.

The citations that fix already carries are in duvet's format, so a report
run has something to find on day one.

## Taken from the crate, not the tag

duvet embeds `www/public/script.js` with `include_str!`, and that file is a
JavaScript build product: absent from the git tree, present in the published
crate. Building from the tag would mean carrying a node toolchain and a
second lockfile to produce a file the crate already ships.

The crate also carries the `Cargo.lock` the git tree omits. Building from
the tag first, I had to generate a lockfile and keep it beside the
expression -- ours to maintain, and drifting from whatever upstream tested
against. Taking the crate hands that back to upstream.

The cost is that the pin is a plain URL rather than a GitHub release, so
npins cannot discover new versions: bumping duvet means editing the version
in npins/sources.json by hand. That is written down in the package
expression next to the pin it constrains.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
(cherry picked from commit e274d99)
For `scripts/spec-interlock.ts`. TypeScript over Python because more of the
team reads it.

(cherry picked from commit 0b1f665)
…tation

duvet checks that a `type=test` citation exists, not that it tests anything.
Both citations are comments, so a refactor separates them silently and the
requirement still reports green.

Two things the tool has to work around are not visible in the diff:

- cargo-mutants applies neither `--re` nor `--exclude-re` nor
  `.cargo/mutants.toml` to StructField-genre mutants, so its own filters
  cannot be trusted as the selector; the results are filtered here instead.
- A cited test that has been renamed makes the nextest filter match nothing,
  which would report every mutant as surviving. Hence the `stale` outcome and
  the `cargo nextest list` check before any mutant runs.

(cherry picked from commit 347c260)
`duvet-check` is a gate; the other three are not. duvet report is 76ms and
bit-for-bit deterministic, which is what makes gating it affordable, and the
snapshot having already drifted is what makes it necessary.

(cherry picked from commit c787026)
…ng it

RFC 4787 REQ-2's surviving mutant is equivalent: `reuse_allocated_ip` can
only return `NoFreeIp` from a well-formed pool, so forcing the exhaustion
guard to `true` changes nothing. The whole nat suite passes with it applied.

Without somewhere to record that, the tool reports it as decorative forever
and the cheapest way to clear it is a test asserting whatever the code
already does -- the entrenchment `development/code/mutation-testing.md`
warns about, which is the failure this whole procedure exists to avoid.

An accept is printed on every run and must still match a live mutant, so the
list cannot quietly become a way of not looking.

(cherry picked from commit 54a842b)
A surviving mutant has two possible causes needing opposite fixes, and the
mutation run cannot tell them apart: the test never reached the line, or it
ran straight through and did not care. Splitting the ten survivors on RFC
4787 REQ-3 by hand took longer than the run that found them.

Coverage answers it directly, and is cheap enough to run first: a cited test
that executes none of the cited region fails outright, with no mutants built
at all.

It must not become a threshold. A caught mutant was necessarily executed, so
coverage adds nothing wherever mutation already succeeds -- only zero is
decisive, and only as an error. Failure to collect it returns null rather
than an empty map, because an empty map reads as "nothing was executed" and
would relabel every survivor as unreached.

(cherry picked from commit dc24092)
…achable

The tenth survivor. `allocate_port` enters the bitmap only when the block is
not full, so on the second-half branch the first half is already full and a
non-full block must leave a zero in the second -- `ones == 128`, the only
value `<` and `<=` disagree on, cannot occur.

The same mutant on the first half is caught, and should be: a block with the
first half full and the second free is ordinary, so there `ones == 128` is
reachable and the shift overflows. The asymmetry is the argument that this is
an invariant rather than a gap.

(cherry picked from commit 47f4b2e)
`Checksum::increment_update_checksum` works out the new checksum and
hands it back. It does not store it, despite taking `&mut self`.
`EmbeddedTransport::update_checksum` called it and dropped the answer on
the floor, under a comment about ignoring errors on a truncated header
-- but there is no error to ignore; the return value is the checksum.

So no quoted transport header has ever had its checksum updated. NAT
rewrites the ports of the packet quoted inside an ICMP error and calls
this to keep the checksum in step, and the call has been doing nothing.
The only correct uses of the trait method in the tree are in a test,
which is presumably how the shape survived.

Setting it can fail, on a header too truncated to hold a checksum -- but
that is a header the caller could not have read a checksum out of
either, and every caller reads one first. Hence discarding that error
rather than the value, which is what the comment was reaching for.

update_checksum_for_address is new, and folds a change of one of the
quoted packet's addresses in. TCP, UDP and ICMPv6 are checksummed over a
pseudo-header built from the source and destination addresses, so
rewriting one leaves the quoted checksum describing an address that is
no longer there. ICMPv4 has no pseudo-header and is left alone.
Incremental for the same reason as the rest: a quote is usually
truncated, so there is no payload to compute over from scratch.

The tests build the one case where both routes are open -- a full header
over a known payload -- and hold the fold against a computation from
scratch, over two words for v4 and eight for v6. The checksum starts out
correct on purpose: an RFC 1624 update is exact given a correct starting
value and says nothing given a wrong one. Both fail against the
discarded update; the ICMPv4 one passes either way, which is the point
of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
(cherry picked from commit 858897f)
…cher

`view.rs` was the most sensitive uncovered code in the tree, and its coverage
number understated the risk.

[`HeadersView`] buys zero-cost extraction with `unwrap_unchecked`: `Look::look`
repeats the `ViewStep::step` chain that `sealed::Sealed::matches` already ran, and
tells the compiler the `None` arms cannot happen. **Soundness rests entirely on
those two chains agreeing** -- and the macro writes them out separately for every
arity, eight-odd hand-written pairs, each threading the VLAN and extension cursors
through by hand.

That is the same shape as all six defects this campaign found in `routing`: an
invariant enforced at a distance by a different function from the one relying on
it. Only here a transposed cursor is not a wrong answer, it is undefined
behaviour. `view.rs` sat at 42% line coverage.

## The generator was the reason, not missing tests

`CommonHeaders` -- the sunny-day generator every packet test reaches for -- has six
construction sites and **all six** set `vlan: ArrayVec::default()` and
`net_ext: ArrayVec::default()`. It never produces a VLAN tag or an IPv6 extension
header.

Those are exactly the two things the view and matcher semantics are *about*: a tag
the shape does not mention is a miss, extension headers are skipped silently until
the shape enters the extension region and then `ExtGapCheck` turns strict. No
existing generator could reach either. The tests were there; the inputs were not.

Hence `ShapedHeaders`, which varies the structure: 0..=MAX_VLANS tags, and
0..=MAX_NET_EXTENSIONS extension headers of the variants that belong to the address
family. Structural on purpose -- `step` walks in-memory layers with cursors, so
whether `next_header` agrees with what follows it is a different property's
business, and coupling the two would shrink the space this explores.

Measured: **80% of generated packets carry a VLAN tag, 75% an extension header, and
20% match the shape under test.** Not vacuous.

## The oracle is the other implementation

`Matcher` decides the same question safely and returns an `Option`, over the same
`Within` graph and the same `ExtGapCheck`. Comparing the two is a differential test
between implementations that both already exist, rather than against a third
transcription of the rules. Layers are compared by **address**: two VLAN tags with
equal contents pass an `assert_eq!` and are a bug if the two sides chose different
ones.

A shape starting at `Net` was tried and does not compile -- `Net` has no
`Within<()>` -- so that half of the contract is enforced at compile time and needs
no property. Left as a comment so the next person does not retry it.

## Verified, including what the verification cannot see

The break test needed two attempts, which is the argument for always running it:
making `matches` stricter in the **arity-1** arm changed nothing, because these
shapes are arity 3 and 4. Patching the arity-3 arm fails in half a second with a
shrunk packet.

Both properties also run clean under miri, which is the only thing that can see the
*unsound* direction -- a test that has already reached `unwrap_unchecked` on a
`None` cannot report it. Recorded honestly at the property: bolero manages 5 cases
a second under miri against ~35,000 native, and the miri recipe spawns its own
`nix-shell` so the caller's `BOLERO_RANDOM_TEST_TIME_MS` never arrives, capping the
run at 25 cases per property. A smoke test of the unsafe path, not a proof.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
(cherry picked from commit c3ce02d)
The previous commit claimed the unsound direction of a `matches`/`look` divergence
could only be seen under miri, on the reasoning that a test which has already
reached `unwrap_unchecked` on a `None` is in no position to report it.

That is wrong, and Daniel caught it. `unwrap_unchecked` bottoms out in
`hint::unreachable_unchecked`, whose `assert_unsafe_precondition!` is gated on
`ub_checks`, which follows `-Cdebug-assertions` -- and `profile.fuzz` sets that
**on**. Measured rather than reasoned this time:

    unsafe precondition(s) violated: hint::unreachable_unchecked must never be reached
    thread caused non-unwinding panic. aborting.
    ... (signal: 6, SIGABRT: process abort signal)

So `profile=fuzz` already detects it, and that is where most of the assurance
comes from. Miri is still worth having for what `ub_checks` does not model --
aliasing and provenance across the `as_ref_unchecked` boundary -- but it is not the
only thing standing between this and undefined behaviour.

Both guards are now documented in the order they fire, each demonstrated by
breaking the arity-3 arm deliberately:

  1. **the differential, first.** The `Matcher` comparison runs *before* `look`, so
     a divergence in either direction fails with a shrunk counterexample instead of
     invoking undefined behaviour. Over-strict and accept-everything both fail here
     in under a second.
  2. **the standard library's check, as a backstop**, for a divergence that slipped
     past guard 1 -- if `Matcher` carried the same bug. Verified by calling `look`
     on an over-permissive `matches` with the differential removed: `SIGABRT`, fuzz
     profile, no miri.

Worth recording that guard 2 is a *non-unwinding* panic, so bolero cannot catch it
and the process dies. Under libfuzzer that is the right outcome -- a saved
`crash-*` artifact rather than a silent pass -- but it does mean the failure
surfaces as an abort rather than a counterexample, which is why guard 1 running
first is a design choice and not an accident.

No behaviour change; comments only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
(cherry picked from commit 2176641)
…ke miri usable

The read path was the easy half. `Look::look` and `sealed::Sealed::matches` at
least walk the stack the same way -- both chain `ViewStep::step`. `look_mut` does
not: it builds a `MatcherMut` from `pat_mut()` and chains `ViewStepMut::chain` over
it, then calls `unreachable_unchecked` if that returns `None`.

**So the invariant is established by one traversal and consumed by a different
one.** Nothing makes `ViewStep::step` and `ViewStepMut::chain` agree except that
they were written to. Where the read path risks a mis-threaded cursor between two
copies of one walk, this risks two walks disagreeing outright.

Worth saying what is *not* tested: `look_mut` against `MatcherMut`. `look_mut`
**is** `MatcherMut` plus an `unreachable_unchecked`, so comparing them is the
implementation against itself. The question worth asking is whether `matches` --
which licensed the unchecked call -- agrees with the walk that has to deliver on
it, and that is checked without calling `look_mut` at all, so a divergence is a
counterexample rather than undefined behaviour.

`look_mut` hands back several `&mut` into one `Headers`, pre-split through `Fields`.
If that split ever aliased, two references would point at the same layer -- and the
`ub_checks` backstop cannot see it. It checks the `unreachable_unchecked`
precondition and nothing about aliasing. Only miri sees that, and only with stacked
borrows on.

Two obstacles, both now fixed in `miri.just`:

  - **the budget.** The recipe launches its own `nix-shell`, which does not inherit
    the caller's environment, so `BOLERO_RANDOM_TEST_TIME_MS` never arrived and
    every property stopped at bolero's one-second default -- about 25 cases under
    miri. Enough to prove the harness runs and nothing else.
  - **`stacked_borrow_check` was unreachable.** `just` will not override a
    *module's* variables from the command line: `just miri stacked_borrow_check=... test`
    parses as a recipe name, and `--set` is refused as "not present in justfile". The
    knob existed and could only be changed by editing the file. Both now read
    `env()`, so `STACKED_BORROW_CHECK=enabled just miri test ...` works.

Under miri bolero manages about five cases a second, and the cost is wall-clock.
Raising the budget buys cases linearly on one core; sharding buys them across cores
for free. The aliasing property -- the expensive one and the one that matters most
-- is instantiated as sixteen shards, each seeded from the OS so they explore
independent streams, and nextest runs them concurrently. Sixteen rather than sixty
because miri's per-process memory footprint is large and the other properties want
cores too.

Result: **963 cases across 24 properties under miri with stacked borrows enabled,
no undefined behaviour** -- against 25 per property with stacked borrows off before
this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
(cherry picked from commit 0b8372b)
…cle reaches

`matches`, `look` and `look_mut` are generated separately for each arity by a
macro, with the VLAN and extension cursors threaded through by hand every time.
Testing two arities tested two of eight copies, which is why the last commit moved
`view.rs` coverage by two points and no more.

Both walks are now checked at arities one through seven, read and mutable, from one
macro so a new arity costs one line.

## Seven, not eight, and the reason is the oracle

`Matcher`'s vocabulary is `eth`, `vlan`, `net`, `transport`, `vxlan`, `embedded`.
The last two cannot be reached in a builder chain: `Vxlan: Within<Udp>` and the
embedded header sits under `Icmp4`/`Icmp6` -- both *concrete* layers -- and
`Matcher` has no concrete-layer methods. No `.udp()`, no `.tcp()`. So its longest
expressible chain is `Eth`, four VLAN tags (`MAX_VLANS`), `Net`, `Transport`.

Two gaps follow, and both belong to the oracle rather than the code:

  - the **arity-8** arm is generated and stays unchecked, because nothing `Matcher`
    can say is eight elements long;
  - shapes entering the **IPv6 extension region** cannot be expressed at all, and
    that is the more interesting loss. `ExtGapCheck` is the subtlest part of the
    contract and the part the module documentation spends most of its words on, and
    it has no oracle. Closing it needs extension-header methods on `Matcher`, or a
    different oracle.

Recorded at the call site so the next person does not have to rediscover why the
list stops where it does.

## Every arity proves it is not vacuous

A shape the generator never produces makes its property pass for the wrong reason,
and the higher arities are exactly where that would happen quietly: arity 7 needs a
packet carrying *exactly* four VLAN tags, since a tag the shape does not name is a
miss. So each property reports its hit rate and fails if it never matched.

Measured, and pleasingly uniform: arity 1 matches everything, and arities 2 through
7 each match about 20% -- which is `P(exactly N tags)` for a uniform 0..=4 draw.
Every arity is exercised at roughly the same rate rather than the long shapes being
starved.

## Verification

1,386 tests green. Under miri with stacked borrows enabled -- the configuration that
can see an aliasing fault in the `Fields` split, which the `ub_checks` backstop
cannot -- **4,560 cases across 31 properties, no undefined behaviour.** That is up
from 963 before the arity work and from 25 per property before the miri budget was
reachable at all.

Coverage-guided runs on the two differentials, 60 workers: roughly 1.7 billion
executions each, no crashes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
(cherry picked from commit edf8bb3)
…nsion region

`embedded_view.rs` sat at 33.9% line coverage, and the reason was not that
nobody had written tests for it. Its hand-written tests are careful and
thorough. Every one of them builds its packet through `HeaderStack` or
`header_chain`, which pin the shape at `(Eth, Ipv4, Icmp4)` outside and
`(Ipv4, Tcp)` inside and offer no way to attach an extension header to
either side. Six of the eight `as_embedded` arities and the whole embedded
extension region were unreachable by construction.

The generators were the binding constraint, again. All six construction
sites of `CommonHeaders` set `embedded_ip: None`, so neither it nor
`ShapedHeaders`, which builds on it, can produce an ICMP error carrying a
quoted packet at all. `ShapedIcmpError` does: it varies the outer VLAN tags
and extension headers so the outer arity spans the range of `as_embedded`
impls, varies whether the quote is present, and varies the quoted packet's
network layer, extensions and truncated transport. The quoted family usually
follows the quoting family, because that is what a real ICMP error looks
like, but not always -- a mismatch is where two independent structural walks
are most likely to disagree.

What the new differential properties compare is the pairing soundness rests
on, which is not the obvious one. `as_embedded_mut` *decides* with
`Sealed::matches`, walking `EmbeddedHeaders` through `EmbeddedStep`.
`look_mut` then *delivers* through `EmbeddedMatcherMut`, a separate
implementation with its own pre-split fields and its own gap check, and
unwraps that chain with `unreachable_unchecked` on the strength of the first
one's answer. If they disagree the result is undefined behaviour, not a
wrong answer. Every arity of both is now checked against the `pat()` oracle,
read path and mutable path.

Two claims in `view.rs` were wrong and are corrected here. `Matcher` does
have concrete-layer and extension-header methods -- `matcher_net!`,
`matcher_ext!` and `matcher_transport!` give it `.ipv4()`, `.hop_by_hop()`,
`.tcp()` and the rest -- so neither gap that comment recorded was real. The
arity-8 arm is now checked, and so is the extension region, where
`ExtGapCheck` switches from skipping extensions silently to requiring all of
them consumed. That is the subtlest part of the contract and the part the
module documentation spends most of its words on, and it had no oracle at
all.

Hit rates are measured and asserted rather than hoped for, which mattered
twice. A shape naming three extensions in sequence matched 2 packets in
36,000 -- honest, and useless -- until `ext_run` learned to follow RFC 8200's
recommended order a quarter of the time; it now matches about 200. And
naming one extension inside a quoted packet compounds six conditions,
including `P(no VLAN tags) = 1/5`, which put `(&Ipv6, &DestOpts,
&TruncatedTcp)` at 6 hits in 23,910. `ShapedQuote` produces that shape every
time and fuzzes the contents instead: 32,000 hits. The division is
deliberate -- a property comparing hit against miss needs both, a property
asking only which layer was selected gets nothing from a packet it skips.

Verified by breaking the code three ways. An off-by-one on the embedded
extension cursor fails exactly the four extension-region differentials, in
one second, with a counterexample carrying exactly one extension -- the case
where a stalled cursor makes the gap check see `len 1 != ec 0`. Dropping the
shape check from arity 7's `as_embedded` fails exactly `read_outer_7`, in
87ms, while `mutable_outer_7` correctly stays green. Reading one extension
slot too far fails the six `same_layers_ext_*` properties through the
vacuity guard, which reports that the shape was never produced -- the same
class of defect this campaign has now found five times.

Coverage: `embedded_view.rs` 33.9% -> 86.0%, `pat.rs` 55.4% -> 63.2% without
a test written for it, `view.rs` 50.1% -> 53.8%, `net/` 66.8% -> 69.8%. The
49 lines still uncovered are all unreachable: three `unreachable_unchecked`
arms, which is the point of them, and 44 in the arity-1 and arity-2
`as_embedded` arms, which cannot be instantiated -- asking for either is a
compile error, so they could be deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
(cherry picked from commit fb394a8)
…rs watch

`pat.rs` had 296 unreached lines and 250 of them were one thing: every
`opt_*` method on all four matchers, plus `when`, `inspect` and `otherwise`
on each. The optional half of the pattern-matching API had never been run.

That half is not a thin wrapper over the strict half. Three families live
under one naming convention. `opt_eth` cannot miss. `opt_vlan` and the
optional extension methods cannot miss either, but advance their cursor only
when they matched, so they skip rather than refuse -- and an optional
extension still moves `Pos` into the extension region, which makes a later,
unrelated transport step strict. `opt_net`, the optional transports and
`opt_vxlan` are three-way: present and right is a hit, absent is a hit
carrying `None`, present and wrong is a miss. Conflating the middle case with
either neighbour is the mistake the design invites.

Nothing could draw the middle case. `CommonHeaders` sets every layer on every
path, so `net` and `transport` are always `Some` in anything it or
`ShapedHeaders` produces, and the absent-layer arm of every optional method
was unreachable by construction. `ThinHeaders` truncates the stack by suffix,
which is the only shape a real short packet takes. `ShapedIcmpError` gained
the quote too short to hold a network header, which RFC 792's header-plus-
eight-bytes makes an ordinary thing rather than an exotic one.

One invariant covers all three families and every layer: weakening a
requirement cannot turn a match into a miss. It is worth stating because
`map` and `and_then` differ by exactly that, and it is the direction a
mis-wiring inverts. The guard on it counts both outcomes -- the strict form
must sometimes match, and the optional form must sometimes accept what the
strict form refused. An implication passes for free when its antecedent never
holds, and just as quietly when the two sides never differ, which is the more
likely failure and would leave the optional method's whole reason for
existing untested.

The property found something. `EmbeddedMatcher` and `EmbeddedMatcherMut`
carry two accumulators and `done()` requires both, but `when`, `inspect` and
`otherwise` all read the inner one alone. A packet whose outer chain fails --
an unconsumed VLAN tag will do it -- but whose quoted packet matches will run
`inspect`, skip `otherwise`, and then return `None`. It happens to 4,974
packets in 25,000, so it is the common case rather than a corner. The doc
comments say "inner accumulator" and "inner match", so this is documented
rather than broken, but `otherwise` is the error-handling hook and there is a
class of failure it stays silent for. `the_embedded_combinators_track_the_
inner_match_only` pins the behaviour as it stands and will fail if it is ever
changed, so that becomes a decision rather than a discovery.

A second finding, this one about what cannot be written down. The enum-level
vocabulary is complete on the outer matchers and mostly missing on the
embedded ones: `EmbeddedMatcher` has `net` but not `opt_net`, `transport` or
`opt_transport`; `EmbeddedMatcherMut` has `net` and `transport` but neither
optional form. All five missing methods are hand-written rather than
macro-generated, which is likely how they came to be missing, since every
per-variant method is present. The consequence is that a shape naming `Net`
or `EmbeddedTransport` inside a quoted packet cannot be expressed as a
matcher chain at all -- which is also why `embedded_view`'s differential
properties have no read-side oracle for the enum forms. The table is in the
source next to the tests that would use them.

Verified by breaking the code twice. Making an optional extension advance its
cursor unconditionally fails exactly the gap-check property, and nothing
else. Making `opt_eth` refuse an absent Ethernet header fails exactly
`read_opt_eth`, through the both-outcomes guard, reporting that the optional
form never accepted anything the strict form refused.

Coverage: `pat.rs` 63.2% -> 98.8%, 296 uncovered lines down to 10. `net/`
69.8% -> 73.1%. Of the 10 left, three are defensive `unreachable!()` and the
rest are gap-fail arms on the mutable optional paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
(cherry picked from commit 31ee5a6)
`view.rs` sat at 53.8% with 224 production lines unreached, and almost all of
them were `look` and `look_mut`: the bodies at arities five through eight had
never executed, nor had `look_mut` at one and two. The properties compared
`matches` against the matcher chain -- the decision -- and stopped there. The
delivery half was checked at arities three and four only, in the split test,
because comparing two tuples means destructuring them into a fixed number of
bindings and that has to be written out per arity.

`Addrs` removes that constraint. Reducing a tuple of references to an array of
addresses is arity-generic at the call site even though the impls are not, so
the delivery check now runs wherever the decision check does. Addresses rather
than values, because two VLAN tags can hold equal bytes without being the same
tag, and picking the wrong one out of four is exactly the cursor bug this is
looking for: `matches`, `look` and `look_mut` are generated separately at each
arity, threading `vc` and `ec` through by hand every time, so a shape naming
four tags has four chances to be off by one and the decision check cannot see
any of them. Breaking arity six's `look` to read one tag early fails `read_6`
and nothing else -- not even `read_ext_v6_three`, the other arity-six shape,
whose third layer is an extension header and reads the other cursor.

Two more gaps, both of the kind that hides behind a passing suite:

`Vxlan` had no `ViewStep` coverage at all, not because the generators could not
draw a VXLAN packet -- `CommonHeaders` has been drawing them all along -- but
because no shape ever named the layer. It is the one step that runs no gap
check and the one layer outside the linear stack, so nothing about it follows
from the other arities. Two chains now name it; making the step refuse fails
exactly those four tests and nothing else.

The branch where the *first* step refuses is generated once per arity and was
unreachable at every one of them, since `CommonHeaders` sets `eth` on all six
of its paths. `ThinHeaders` can drop it but truncates four packets in five,
which would cost the deep shapes most of their hit rate; `SometimesHeadless`
drops it one packet in eight, enough for the branch and cheap enough that arity
seven still matches six thousand times in thirty-five.

The shapes are now derived rather than written. A shape and the chain matching
it are one statement said twice, and every instantiation said it twice by hand:
`(&Eth, &Ipv6, &HopByHop, &Transport)` next to `.eth().ipv6().hop_by_hop()
.transport()`. A pair that disagrees compiles and passes and silently tests
something else. `layer_ty!` holds the correspondence once, `shape_of!` builds
the tuple from the chain, and the instantiations shrank to the chain alone.

That is as far as generating tests from the macro tables usefully goes here.
Enumerating the `Within` graph gives 4,755 legal chains up to arity eight --
2,614 at arity eight alone -- so one property per chain is not a suite anyone
would run, and the exhaustive version would have to be a shallow sweep over a
fixed corpus rather than a fuzz run. It would also be worth less than it looks:
an oracle enumerated from the same table the implementation is generated from
cannot notice a wrong table entry. What survives the objection is the
differential, since `matches` and the matcher chain are independent
implementations and the table only chooses which chains to test, not the
verdict.

Coverage: `view.rs` 53.8% -> 98.4%, 224 uncovered lines to eight. The eight are
the `unreachable_unchecked` arm of each arity, which must stay uncovered --
reaching one is the undefined behaviour the whole `HeadersView` invariant
exists to prevent. `net` overall 74.2% -> 76.8%.

Also fixes two lints in the previous commit that only appear under
`--features bolero,test_buffer,builder` rather than `--all-features`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
(cherry picked from commit a17ff2e)
`within.rs` sat at 32.7%, and the shape of the gap was unusually clean: thirty-two
of the thirty-three `conform` bodies that do any work had never run, and the one
that had was `DestOpts` inside `HopByHop`. Every unreached one was an IPv6
extension header transition, plus `Vlan` inside `Vlan`.

The cause is a seam rather than an oversight. `conform` runs only from
`HeaderStack::stack`, and the builder has had `.hop_by_hop()`, `.dest_opts()`,
`.routing()`, `.fragment()`, `.ipv4_auth()` and `.ipv6_auth()` all along -- no
test ever called one. The generators reach the extension region constantly, but
they assemble `Headers` field by field and never go near the builder, so they
never conform anything. Two ways to build a packet, and the fuzzing all went
down the one that skips this trait.

Seventeen chains cover the thirty-two transitions between them. Three extension
headers is the ceiling, `MAX_NET_EXTENSIONS`, so the deeper corners of the graph
need several short chains rather than one long one.

The oracle is deparse-then-parse. Reading back the field `conform` just wrote
would check the implementation against itself; the parser decides what follows an
IPv6 header by reading that same field, so a `conform` naming the wrong protocol
produces bytes that parse as a different packet, or as no packet at all. Naming
TCP where the fragment header goes fails exactly the two chains carrying
`routing -> fragment`.

Each layer's protocol field is scrambled to a fuzzed byte before the next layer
is stacked, so `conform` always overwrites a wrong value instead of filling in a
blank one. That turns out to be load-bearing rather than cautious, and `Ipv4`
inside `Eth` is the proof: `Blank for Eth` already produces `EthType::IPV4`, so
on a blank header the conform setting `EthType::IPV4` has nothing to do. Deleting
its body passes all seventeen chains unscrambled and fails two of them scrambled.
`Vlan` has the same blank and the same exposure.

Coverage: `within.rs` 32.7% -> 88.9%. `net` overall 76.8% -> 79.9%.

The nineteen lines left are the no-op bodies -- the enum-level impls, the
`EmbeddedStart` impls, and everything `impl_truncated_within!` generates. Those
are unreachable through the builder, and the compiler says so twice over:
`stack::<Net>` fails on both `Net: Blank` and `Headers: Install<Net>`, either of
which would be enough on its own. They exist to give the pattern matcher its
`Within` edges, which need the trait but not the method. Documented rather than
deleted; whether to keep nineteen uncallable bodies belongs to whoever owns the
trait.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
(cherry picked from commit f9607e9)
The last of `net/headers`. `builder.rs` had two untouched regions, both the same
shape as everything else this campaign has turned up: methods the builder has
always offered that no test ever called.

The twelve ICMP message subtypes are one. `.dest_unreachable()`, `.redirect()`,
`.time_exceeded()`, `.param_problem()`, `.echo_request()`, `.echo_reply()` and
their v6 twins were unused, and ten of the twelve `Blank` impls behind them had
never been called. The two that had are why the shared macro bodies looked
covered -- a `macro_rules!` line counts as run once any one of its expansions
runs, so a table of twelve generated impls reports green when one of the twelve
is exercised, and only the hand-written part of each shows the difference.

Scrambling had to change for these. `conform` writes a message type here rather
than a protocol number, and the first attempt scrambled it to `Unknown` with a
fuzzed type byte -- which fails six chains for a reason that has nothing to do
with `conform`: `Unknown { type_u8: 3 }` deparses to the bytes of a
destination-unreachable message and parses back as one. The scramble now uses
253 and 200, reserved for experimentation, which belong to no variant and
survive the round trip as themselves.

The round trip alone cannot check these. The scrambled type is a well-formed
ICMP message, so a packet that never got specialized still deparses and parses
back perfectly. The subtype chains assert separately that the scramble did not
survive the build.

That check is what exposes the finding: all twelve `Within<Icmp4|Icmp6> for
<subtype>` conform bodies are dead. Empty them and every test still passes.
`Install` runs unconditionally from `build_headers`, after `conform`, and
overwrites whatever `conform` wrote; nothing can be stacked on a subtype, so
there is no arrangement in which `conform` gets the last word. The two are
indistinguishable until the caller customizes the subtype, because until then
both write the same value -- `a_customized_subtype_survives_the_build` is the one
test that separates them, and it fails on `Install` and not on `conform`.
Pinned, not acted on; emptying them is a call for whoever owns the builder.

The other region is an ICMP error quoting an ICMP packet.
`EmbeddedAssembler::icmp4` and `::icmp6` were the two inner-transport methods
nothing called, and with them the arm of `fixup_embedded` that writes
`NextHeader::ICMP` onto the quoted IP header. A ping drawing a
destination-unreachable is the ordinary way to produce one. The protocol number
is asserted directly rather than left to the shape match, which would pass
without it; naming TCP there fails exactly the one test.

Coverage: `builder.rs` 79.1% -> 97.7%, `within.rs` unchanged at 88.9%, `net`
79.9% -> 81.1%.

Eight lines left in `builder.rs`: two defensive `unreachable!` arms, two absent-
layer arms, `Blank for ()` which `stack` never instantiates, and `Default for
HeaderStack`. A test written to touch the last of those would be a test that
exists to move a number, which is the failure mode this campaign has been
finding, not one to add.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
(cherry picked from commit 52fd730)
The guard only asserts once a run is long enough for a miss to mean
something, and the threshold was set at the twenty-five thousand cases a
default one-second run managed when it was written. That leaves no room:
coverage instrumentation costs about a fifth of the throughput here --
17 hits in 20,206 cases against 21 in 25,857 without it -- so the
threshold now sits a couple of hundred cases below what CI actually
draws. A busier runner drops under it and the check disappears without
saying so, which is the failure mode the guard exists to prevent.

Ten thousand instead. The thinnest shape in this module draws about
eight hits per ten thousand, so at that many draws a shape that really
is reachable comes up empty about three times in ten thousand runs.

The instrumentation cost is small because these properties are bound by
the generator rather than by a counter loop, which is the opposite of
the fib test that `--cfg=instrumented` was added for. No iteration
counts need cutting here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
(cherry picked from commit b455ebd)
@daniel-noland

Copy link
Copy Markdown
Collaborator Author

Recreated with a corrected base after the stack was reordered into chapters. GitHub will not re-base a PR that is part of a stack, and these were never out of draft.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dont-merge Do not merge this Pull Request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant