Skip to content

fuzz - #1755

Draft
daniel-noland wants to merge 14 commits into
pr/daniel-noland/fuzz-config-generatorsfrom
pr/daniel-noland/fuzz-nf-probes
Draft

fuzz#1755
daniel-noland wants to merge 14 commits into
pr/daniel-noland/fuzz-config-generatorsfrom
pr/daniel-noland/fuzz-nf-probes

Conversation

@daniel-noland

@daniel-noland daniel-noland commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

@coderabbitai

coderabbitai Bot commented Aug 26, 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.

Comment on lines +414 to +415
source: IpAddr,
destination: IpAddr,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

better to avoid the use of IpAddr if we can just use an IpAddress generic instead. Cleaner

///
/// Panics if the two addresses are of different families. Resolution never mixes them, since an
/// expose is of one family throughout and the peer prefix is chosen to match.
pub(crate) fn build(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this function should likely be rebuilt using the pat.rs / view.rs mechanics we use elsewhere

@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-config-generators branch from 641ac94 to 020c301 Compare August 26, 2026 17:30
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-nf-probes branch from 1017959 to c0bc094 Compare August 26, 2026 17:30
@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-config-generators branch from 020c301 to ec84ef3 Compare August 26, 2026 19:36
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-nf-probes branch 2 times, most recently from caeff74 to fc901ba Compare August 26, 2026 20:41
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-config-generators branch from 23da889 to 8d7de8c Compare August 26, 2026 21:02
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-nf-probes branch from fc901ba to 3d43e5b Compare August 26, 2026 21:02
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-config-generators branch from 8d7de8c to 17e5812 Compare August 26, 2026 21:13
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-nf-probes branch from 3d43e5b to 357270f Compare August 26, 2026 21:13
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-config-generators branch from 17e5812 to 5514278 Compare August 27, 2026 01:29
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-nf-probes branch from 357270f to ffec6ba Compare August 27, 2026 01:29
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-config-generators branch from 5514278 to a1f62fc Compare August 27, 2026 01:41
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-nf-probes branch from ffec6ba to f51c0fc Compare August 27, 2026 01:41
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-config-generators branch from a1f62fc to 99c2ea7 Compare August 27, 2026 04:32
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-nf-probes branch from f51c0fc to 96cbbc7 Compare August 27, 2026 04:32
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-config-generators branch from 99c2ea7 to 3927db1 Compare August 27, 2026 05:10
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-nf-probes branch 2 times, most recently from 0b54681 to 4748ecd Compare August 27, 2026 17:59
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-config-generators branch from 01c6a98 to b137c88 Compare August 27, 2026 18:29
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-nf-probes branch 2 times, most recently from 281d3bc to d61d365 Compare August 27, 2026 19:33
@daniel-noland daniel-noland changed the title test(nat): drive the network functions with configuration-relative packets packet Aug 28, 2026
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-nf-probes branch from 876a902 to 773d31a Compare August 28, 2026 04:22
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-config-generators branch 2 times, most recently from 5f049ba to 8387397 Compare August 28, 2026 05:11
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-nf-probes branch from 773d31a to 95022cf Compare August 28, 2026 05:11
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-nf-probes branch from f759872 to e9fba51 Compare August 28, 2026 05:42
@daniel-noland daniel-noland changed the title packet fuzz Aug 28, 2026
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-config-generators branch from 6fd7f93 to 26db9f0 Compare August 28, 2026 06:40
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-nf-probes branch from 2065c3b to 43a361f Compare August 28, 2026 06:40
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-config-generators branch from 26db9f0 to fbb8ebe Compare August 28, 2026 07:07
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-nf-probes branch from 43a361f to 81f2482 Compare August 28, 2026 07:07
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-config-generators branch from fbb8ebe to adb8eae Compare August 28, 2026 07:31
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-nf-probes branch from 81f2482 to e681b25 Compare August 28, 2026 07:31
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-config-generators branch from adb8eae to 5ebb19b Compare August 28, 2026 07:43
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-nf-probes branch from e681b25 to f44e856 Compare August 28, 2026 07:43
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-config-generators branch from 5ebb19b to 214e324 Compare August 28, 2026 09:14
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-nf-probes branch from f44e856 to 7e7ba95 Compare August 28, 2026 09:14
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-config-generators branch from 214e324 to 71e4ecc Compare August 28, 2026 17:15
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-nf-probes branch from 7e7ba95 to 0472824 Compare August 28, 2026 17:15
daniel-noland and others added 14 commits August 28, 2026 11:32
A design note for testing the config-driven dataplane, plus links to it from the
code guidelines and the property-testing guide.

Nothing in it is implemented. Its value is mostly in the approaches it rejects,
because each of those looks obviously right at first and is a dead end for a
reason worth keeping:

  * Generating configuration values directly. A `TypeGenerator` over the config
    types yields syntactically valid, semantically impossible configurations --
    colliding VNIs, peerings between VPCs that do not exist -- so the validator
    refuses nearly all of them and a coverage-guided fuzzer spends its budget
    exploring rejection paths. Filtering does not help, because the generator
    would then have to encode the validator's rules, leaving two copies to keep
    in agreement. Build configurations from an algebra of valid operations
    instead, and preconditions become unrepresentable rather than checked.

  * A shadow model as the oracle. It grows into a second dataplane, drifts from
    the first, and has to be rewritten whenever the real one is refactored.
    Operations emit claims about observable behaviour instead.

  * Reimplementing rule selection, or discovering precedence by ablation. Both
    are unnecessary: `acl/src/reference/` already answers "which rule should have
    won, and which did it shadow" in one pass, and the vocabulary in
    `match-action` is general enough to serve every function that consults a
    table. That reference scales with the match vocabulary rather than with the
    feature set, which is what keeps it from rotting.

  * Following the algebraic notation toward rigour. There is no inverse for
    "transmit a session", and the nearest thing to one advances the clock until
    transients decay. Chasing that ends in rebuilding a temporal logic. We want
    to find defects, not prove their absence, so the notation is a naming scheme
    for test shapes and nothing more.

  * Putting the oracles at the boundary of the whole pipeline. An ACL that drops
    traffic before the router sees it hides the router completely, and the
    expectation becomes a cross-product over domains. Contracts belong to
    individual network functions; pipeline behaviour is their composition.

The recurring theme is that an oracle derived from the same source as the
implementation cannot see that source being wrong, and that the way out is always
to find something genuinely independent -- a parser, a transport protocol, a
second walk over the same data.

Two constraints are recorded as requirements on work that has not started yet,
because both are cheap to honour in advance and expensive to retrofit: the
generation-propagation logic of the planned network-function DAG has to be a pure
state machine over a small hashable state, and a match-action rule has to name its
action completely enough to serve as the specification for it.

The note is a record under revision rather than settled doctrine; its open
questions are live, and several of them are questions about this repository that
nobody has answered yet.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`StaticNatExpose` draws one expose, and one expose builds a table with one rule in
it. A property about a *lookup* wants several, because one rule gives a
longest-prefix match nothing to choose between.

Repeating the single-expose generator does not work. Two independent draws are
refused by a manifest almost every time, for two separate reasons:

  * **Overlap.** Every expose is laid out from the same two bases -- 10.0.0.0 for
    the private side and 172.16.0.0 for the public one -- so two of them cover the
    same addresses and validation refuses the pair.
  * **Address family.** A peering's manifests must agree on one family, so a v4
    expose beside a v6 one is refused as well.

Both belong in the generator rather than in each caller, since both are facts
about what a manifest accepts. `StaticNatExposes` draws the family once and places
each expose in a block of its own, `BLOCK_STRIDE` apart -- wider than the widest
span one expose can occupy, so distinct blocks cannot collide whatever the draw.

Measured on the static NAT network function properties that motivated this, which
draw between one and three exposes: repeating `StaticNatExpose` validated 33% of
configurations, one block per expose 59%, one block and one family 100%. So two
thirds of the fuzzing budget was being spent building configurations that were
thrown away -- and, worse than the waste, multi-expose configurations were nearly
unreachable. The interesting case was the one being skipped.

`StaticNatExpose` is unchanged and still draws a single expose, so the existing
callers in `nat` and `mgmt` are untouched.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fuzzing so far has been small, focused and intrusive: it reaches into a
structure, exercises it directly, and asserts something about that structure. This
is the first of the other kind -- configure a network function, put generated
packets through it, and assert properties that would hold of any static NAT rather
than of this one.

Packets have to be drawn relative to the configuration. `Packet` has a
`TypeGenerator`, and pointing it at a generated NAT configuration is useless:
every packet misses every table, the fuzzer explores the miss path, and the run is
vacuous while looking enormous. This is the same failure the design note rejects
for configuration values, one level down, so it takes the same answer. The
configuration is a **parameter to resolution**, not a predicate to filter against.
A `ProbeSpec` is drawn with no reference to any configuration -- it is a handful of
indices -- and `resolve` interprets it against the built `Fabric`. Resolution is
total, so no draw is discarded and no rejection loop skews the distribution.
`acl-filter`'s `ProbeSpec` resolves against a built overlay the same way; this
generalises the shape to a stage that takes real packets.

The arrival state is the stage's precondition. `StaticNat` sits mid-pipeline and
assumes its predecessors annotated the packet: two vpc discriminants, the overlay
flag, and the flags saying which directions of translation are wanted. Nothing
says so in the type system -- `process` passes silently over a packet that lacks
them. `Arrival` writes it down once, which is what the design note asks for when it
puts contracts on network functions rather than on the pipeline: the assumption
travels with the stage. `masquerade`'s tests hand-roll the same thing as a mock
stage, and that is the drift this avoids.

`setup::config_driven` already proves the *mapping* right by enumeration; nothing
there touches a packet, its metadata, or `StaticNat`. These cover the half where
the decisions live, and none of them needs an oracle -- each is a metamorphic
relation or an invariant, so nothing here is a second copy of `RangeBuilder`:

  * **round trip** -- a translated source comes back. The outbound packet is
    rewritten by the local vpc's table, built from the local side of the peering;
    the reply is rewritten by the peer's table, built from the remote side, by a
    different code path. Whatever the first did, the second must undo. This is the
    one property that ties the two halves together.
  * **injectivity** -- distinct sources stay distinct, through the stage rather
    than through the table. A collision is a tenant isolation defect.
  * **frame** -- translating the source touches nothing else. The generated
    exposes carry no port ranges, so a rewritten port would be a mapping reaching
    further than it was configured to.
  * **permission** -- nothing is translated that did not ask. Covers every reason:
    not requested, already done, annotations missing or naming something absent,
    source not exposed.
  * **attribution** -- a packet that cannot be looked up is dropped with a
    `DoneReason`, not passed silently. A silent pass forwards untranslated traffic
    under a configuration that never mentioned it.
  * **marking** -- a packet whose address changed carries `src_natted` and
    `checksum_refresh`. Without the second it goes out with a checksum for an
    address it no longer has, and is discarded by the receiver rather than by
    anything that could report it.

`Packet::enforce` removes a dropped packet from the output, so probes carry `keep`:
without it a drop and a pass-through are the same event from outside, and
attribution could not be stated at all.

Every property counts the draws that reached its assertion and fails the run if
too few did, because the failure that matters is an assertion that stops running
rather than one that is wrong. That floor is a **ratio** -- at least one reaching
draw per two configurations built, plus a small absolute minimum -- rather than an
absolute count, because an absolute count measures how fast the machine was. A
property that reaches a couple of thousand draws on its own reaches a few dozen
under coverage instrumentation beside nine hundred other tests, and a floor tuned
to the fast case then fails for a reason that has nothing to do with the code
under test. Both counts scale with the iteration budget, so their ratio does not,
and a property that has genuinely stopped reaching its assertion still collapses
the ratio to zero -- which is the only thing the guard was ever for.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sses

Static NAT permits a port range on a prefix, and a prefix that carries one takes
the mapping down a second path entirely: `NatTableValue::Pat` and
`PortAddrTranslationValue` rather than `NatTableValue::Nat` and
`AddrTranslationValue`. The expose generator produced no port ranges, so that path
had no configuration-driven coverage at all.

The rule makes it the harder path. Validation asks that the two sides cover the
same **total**, counting addresses times ports, so a `/32` carrying 64 ports is a
legal answer to a `/30` carrying 16, and the mapping has to run across both
dimensions at once. That asymmetry is the reason the path exists, so
`StaticNatExposes::with_ports` draws it on purpose: one total per expose, divided
into addresses and ports independently per side, with both port ranges starting at
a drawn offset so a mapping that quietly assumes they begin at the same port fails
here.

Worth noting what is legal for static NAT and not for port forwarding, which
requires the two prefix lengths and the two port counts to match individually. The
two flavours do not share this rule and must not share a generator.

An address on its own is no longer a thing the configuration maps -- the
address-and-port pair is. So `Endpoint` replaces the bare address, carrying the
range its prefix declares, and a probe draws its port from that range rather than
freely, or it would miss. Three consequences:

  * the reply in the round trip must be addressed to the **translated** port, since
    that is the port the peer was contacted from;
  * injectivity sweeps every pair rather than every address -- an address-only
    sweep checks a diagonal of the space and calls it injective; and
  * the frame differs between the paths. With no port range the transport ports are
    part of the frame and must survive untouched; with one they are part of what is
    being translated, and only the destination and protocol remain.

One property per flavour rather than one over a mix, following the same reasoning
as the NAT flavour properties in `mgmt`: a mixed property reaches each path
eventually, one that asks for a path reaches it every time and says in its name
which one failed. The two suites are mutually isolated -- a defect in
`PortAddrTranslationValue::get_entry` is invisible to the address properties and
vice versa -- which is what earns the extra properties their place rather than
re-running the same paths under new names.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The second network function, and the first stateful one. Static NAT's answer for a
packet is fixed by its tables; masquerade's is whatever the allocator handed out
the first time it saw the flow, kept in the flow table and reused after. Every
property here is really about that state being kept consistently, which is a
different subject from anything the allocator's own tests can reach.

A probe is a flow, not a packet. The first packet of a flow allocates and writes a
flow entry; the second finds that entry and reuses it. Different code, and the
interesting properties relate the two. So `Probe::packet` hands back a fresh
packet on every call rather than being consumed once, because sending the same
flow twice is how the hot path is reached at all.

The stage order is load-bearing. `FlowLookup` attaches a flow entry only to a
packet whose `dst_vpcd` is **absent**, and the flow filter that sets `dst_vpcd`
runs *after* it. `Masquerade` then requires `dst_vpcd` to be present. So the
annotation has to arrive between the two stages -- not before both, not after.
Stamping both up front, the way the static NAT harness does, crashes nothing: it
quietly gives no packet any flow state, sends every packet down the allocation
path, and makes a flow look re-allocated on each packet. That is the sharper form
of the arrival-state point from the static NAT work. A network function's
precondition is not always a stamp a test can apply in one go -- here part of it is
supplied by a stage that must run *after* another stage that requires its absence,
and no test that ignores the ordering describes the real thing.

The three prerequisites the design note lists for comparing a stateful stage at
all, handled rather than assumed:

  * **Seeded non-determinism** -- `set_randomize(false)`, or two fabrics built from
    one configuration disagree on every flow.
  * **Timers** -- rather than fake a clock, every property completes inside one flow
    lifetime, so none depends on expiry either happening or not. Expiry is a
    separate subject and wants the explicitly driven clock the note asks for, not a
    wall clock a property happens to outrun.
  * **Projections, not state** -- nothing here inspects the allocator or the flow
    table. Every assertion is over what came out of the pipeline.

The properties also need a tokio runtime, since `FlowTable::insert` spawns a
per-flow expiry timer. The existing tests get one from `#[tokio::test]`; a bolero
body is synchronous, so it enters a runtime instead.

None of the properties predicts which address and port a flow will be given --
that is the allocator's business and predicting it would be a second copy of it:

  * **reversibility** -- the reply comes back to where the flow started. Unlike
    static NAT there is no second table built from the other side of the peering:
    the reverse translation exists only because the forward packet recorded it. A
    forward translation not faithfully recorded is a connection that never gets an
    answer.
  * **stability** -- a flow keeps the translation it was first given. A stage that
    re-allocated would produce a legal-looking packet every time, and the
    connection would break in a way no allocator-level test could see, because the
    two allocations are individually correct.
  * **exclusivity** -- two live flows never share a translation. Distinct source
    ports as well as addresses, since masquerade collapses many private addresses
    onto few public ones and the port is what keeps them apart after.
  * **containment** -- every translation lands inside a range the configuration
    named. The one property that consults the configuration, and legitimately: a
    membership test, not a prediction of which member. An address from outside the
    declared set is unroutable, so the flow is a blackhole that looks like success
    from inside the box.
  * **permission** and **attribution** -- as for static NAT. Permission matters more
    here, because a translation is not merely applied but *recorded*: a packet
    masqueraded without permission leaves an entry behind that keeps translating
    its successors.

Reversibility is deliberately blind to an allocation outside the declared range:
it asserts the reverse undoes the forward, which stays true when both use the same
wrong address. Containment is what covers that.

`MasqueradeExposes` gets the same treatment `StaticNatExposes` did, for the same
two reasons -- `MasqueradeExpose` draws its base index freely, so two exposes
collide whenever their index ranges intersect, and independent draws mix address
families. One slot of four indices per expose, family drawn once.

The vacuity guard is a ratio rather than an absolute count, for the reason
recorded with the static NAT properties.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nction

`acl-filter/src/fuzz.rs` has the strongest oracle in this codebase: it evaluates
the validated configuration directly and compares that against the lowered tables,
so a lowering mistake cannot hide behind the thing it produced. What it never
touches is a packet. Every probe there is a `PacketSummary` handed straight to
`lookup`.

Two pieces of production code sit between a packet and that summary, and neither
had any coverage from a generated configuration:

  * **`PacketSummary::try_from`**, which reads the five-tuple and both
    discriminants out of the headers; and
  * **`AclFilter::process_packet`**, which turns a verdict into a fate --
    `DoneReason::AclDropped`, `invalidate_flows`, and the `is_overlay` gate
    deciding whether any of it happens.

A field misread in the first of those is invisible to every existing property,
because none of them builds the packet that would be misread. This re-points the
existing generators rather than writing new ones. The `OverlaySpec` and
`ProbeSpec` are unchanged; a probe now becomes a packet and the answer is read off
the packet's fate. The oracle is the same `oracle_resolved_action`, asked the same
question, so this is a differential test over the packet path rather than a second
ACL.

  * **stage verdict** -- a packet the configuration denies is dropped with
    `AclDropped`; one it allows survives untouched. This tests the extraction
    implicitly: a field read from the wrong place makes the stage judge a different
    tuple from the one the oracle judged, and they disagree wherever that field
    decides the answer.
  * **summary round trip** -- the five-tuple read back is the one the packet was
    built with. Direct rather than implicit, so it also catches the misread that
    happens to be harmless for the ruleset drawn.
  * **missing discriminant** -- a packet naming no destination vpc is refused as
    `Unroutable`. An ACL is indexed by the vpc pair, so such a packet cannot be
    judged at all, and letting it through applies no policy whatsoever.
  * **underlay gate** -- traffic that is not overlay traffic is left alone. No ACL
    in the configuration describes it.

Only TCP and UDP become packets. A probe drawing ICMP or an arbitrary next header
is counted and skipped rather than approximated, because a packet whose headers did
not match the summary it came from would make every disagreement meaningless. The
same goes for the generator's `CrossVersion` stray, which asks for a v4 source with
a v6 destination -- there is no such packet, and that case stays with the
summary-level properties where it belongs.

The vacuity guard here counts denials as well as arrivals: a run that only ever saw
permits would pass while the drop path -- the only path where the stage does
anything -- went entirely unexercised. Around a quarter of probes are denied in
practice. It is a ratio rather than an absolute count, for the reason recorded with
the static NAT properties.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ting denials

Two defects in one helper, both of which `nat`'s two copies of it already avoid.

`cargo bolero` runs the test binary once with `CARGO_BOLERO_SELECT` set to find
out which targets it holds; `check!()` registers and returns without drawing, so
`report` ran with every count at zero and the vacuity guard refused the
*selection*. These targets could not be fuzzed at all.

The denial ratio is documented as catching a run that only ever saw permits.
Three of the four properties bumped `denied` on the same line as `reached`, which
reduces it to `reached * 20 >= reached`; `underlay_traffic_is_not_judged` did so
having just asserted the packet was *not* denied. They now use a report that
measures arrivals and does not claim to measure anything else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
`Fabric::public` is what `a_translation_stays_inside_the_public_range` tests
membership against, and it was computed from the *unvalidated* exposes.
Validation collapses exclusion prefixes, so the raw `as_range` is a superset of
what the allocator's pool is built from: a translation to an address the operator
explicitly excluded would have passed.

Latent, because the masquerade generator emits no exclusions -- which is the only
reason the two agree, and not something the property should depend on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Daniel Noland <daniel@githedgehog.com>
`fix(acl-filter): Let the acl properties be selected` says `nat`'s two copies of
this helper already avoid the defect. Only one does, and by accident:
`static_nat::fuzz` puts `check!()` at the body scope of `drive_*`, so bolero's
`return` under `CARGO_BOLERO_SELECT` leaves the function before `report` runs.
Here `check!()` is inside a `with_runtime` closure, so the `return` leaves only
the closure and the vacuity guard fires on every count at zero.

All six masquerade properties therefore failed `cargo bolero`'s target
enumeration and could not be fuzzed at all.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
… too

The commit before last made this argument for masquerade and left the identical
code in static NAT: validation collapses exclusion prefixes, so the raw expose
lists are supersets of what the tables were built from.

It is worse on this side. `private` is what `every_source` sweeps, so an excluded
address in it fails `a_translated_source_comes_back` and
`distinct_sources_stay_distinct` against a correct implementation -- a property
that lies rather than one that misses. Latent either way: the generator emits no
exclusions, which is the only reason the two agree.

Both walks now take the offering vpc alone. `overlay_with_exposes` gives the peer
a manifest of its own, and folding it in would have swept its 256 addresses as
sources this configuration maps.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
`packet_for` clamps port 0 to 1, because 0 is not a port either transport can
carry. The oracle was still asked about the drawn summary, so its verdict on port
0 was compared against the stage's verdict on port 1 -- and a ruleset that
distinguishes the two fails a correct implementation.

`expected_summary` already existed for exactly this, and was used only by the
round-trip property. Using it here restates nothing: that the packet carries this
summary is what the round-trip property establishes.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
The same guard as the masquerade one two commits back, and the same reason.
Backported from `fix(nat): Let the nat properties be fuzzed at all` on
pr/daniel-noland/driven-clock, whose other two files do not exist yet here.

Not load-bearing today: `check!()` sits at the body scope of the `drive_*`
helpers, so its `return` leaves the helper before `report` runs. It is here so
that wrapping a property body in a closure later cannot quietly make these
targets unselectable again, which is exactly how masquerade acquired the fault.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
`bolero::check!()` names its target after the function it is written in, so the
six drivers shared by these eleven tests registered six targets named after
functions that are not tests -- listed by `cargo bolero list`, resolvable by
nothing. Expanding the driver at each test site is what makes the name a test's.

The case bodies are unchanged; only where the macro expands moved.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit c00f66d)
The comment three lines above these asserts says an absolute floor measures how
fast the machine was rather than anything about the property. The asserts carried
one anyway, and coverage instrumentation duly failed it:
`a_flow_that_cannot_be_masqueraded_says_so` reached 3 flows across 1
configuration, which satisfies `reached * 2 >= built` and misses `reached >= 8`.

The ratio is the part that means something, and a property that has stopped
reaching its assertion collapses to zero, which `reached > 0` catches.

Signed-off-by: Daniel Noland <daniel@githedgehog.com>
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-config-generators branch from 71e4ecc to 440a6fa Compare August 28, 2026 17:33
@daniel-noland
daniel-noland force-pushed the pr/daniel-noland/fuzz-nf-probes branch from 0472824 to c49f3b8 Compare August 28, 2026 17:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant