Skip to content

test: port the property and fuzz suites from #1 to the current API - #16

Merged
JustinKovacich merged 1 commit into
ci/adopt-org-rust-workflowfrom
test/port-property-and-fuzz-suites
Sep 10, 2026
Merged

test: port the property and fuzz suites from #1 to the current API#16
JustinKovacich merged 1 commit into
ci/adopt-org-rust-workflowfrom
test/port-property-and-fuzz-suites

Conversation

@JustinKovacich

Copy link
Copy Markdown
Contributor

Stacked on #13 — merge order: #11#12#13 → this.

Issue URL

Closes #1 (supersedes it — see below). Files #15.

What

Brings simple_doip#1's test suites forward instead of rebasing that branch, and
turns on the two CI gates that were off for want of them.

#1's merge base is 2026-04-03, before the no_std migration, the
error-taxonomy refactor and the 0.4.0 server break. Its 26 property tests and 3
of its 4 fuzz targets drive Message::read, Message::write and
Payload::readnone of which exist on main, all removed in 0.2.0. A
rebase resolves textually (12 conflicts on the first of 7 commits) and then
fails to compile. The properties were sound; only the calls were stale.

tests/property.rs 24 properties ported to Encode/Decode, all passing on the first run
fuzz/ 4 cargo-fuzz targets, 3 rewritten and 1 unchanged
.github/ templates PR + bug/epic/task, verbatim from #1 — no API coupling, no porting needed
main.yml run-property-tests and run-fuzz-tests both on

24 of 26 properties land. The two that don't are byte round trips through
serde, and this crate has no serde dependency.

They live in tests/property.rs rather than #[cfg(test)] modules inside
src/ as the original did: proptest needs std, the library is no_std, and
an integration target gets std with no conditional-compilation gymnastics.
The cost is only reaching the public API, which is all these properties touch.

What they add over golden_vectors.rs

The golden fixtures pin the exact bytes the crate emits, so they catch the wire
format changing. These check encode and decode agree with each other
across the whole input space, which catches a field written in one order and
read in another. Neither finds a misreading of the standard that both
directions share symmetrically — that's what the fixtures are for.

fuzz_roundtrip found a real bug in under a second

#15: Message::encode can emit a frame that Message::decode rejects.
decode takes exactly header.payload_length bytes and lets Payload::decode
consume fewer without complaint; the decoded Message keeps the declared
length; encode then writes that stale length beside a payload of its real
size. A NACK frame declaring 5 body bytes and carrying 1 decodes fine,
re-encodes to 9 bytes with the header still claiming 5, and fails to re-decode
with Incomplete { needed: 5, available: 1 }.

The target skips that specific shape, with #15 referenced at the check, so
it keeps hunting field-order asymmetries without asserting a property the crate
violates today. The skip comes out with the fix, which is stacked on this PR.

Testing

cargo test --all-features / --no-default-features pass
the 24 properties pass
cargo nextest run -E 'test(~prop_)' selects exactly 24, not zero — the exit 4 failure mode
cargo fuzz build (real cargo-fuzz, nightly) pass
8s per fuzz target 5.4M / 6.1M / 1.6M / 4.1M execs, all clean
clippy --all-targets --all-features -Dclippy::pedantic clean
clippy --no-default-features -Dclippy::pedantic clean
cargo fmt --all --check, pre-commit run --all-files clean
cargo publish --dry-run pass — fuzz/ does not enter the packaged crate

unit-test-filter stays all() rather than excluding prop_: the unit job
measures coverage, and coverage should describe the whole suite. The property
job re-runs the same 24 under their own name for a readable signal.

Not included

The PR description lint the sibling repos pair with the templates. It
requires ## Issue URL and ## Testing sections and would fail every
currently open PR, including this one's stack-mates. A No Issue label now
exists as its escape hatch; the job belongs in a follow-up once bodies conform.

On #1

Its main.yml predates rust_workflow@v1 by six weeks, so it wasn't a
misjudgment — the better option didn't exist. Its scaffolding was
independently written in #13 before I'd read his closely enough, which is on
me. What was uniquely valuable was the tests, and they're here.

Recommend closing #1 with a pointer to this PR rather than leaving it to rot;
@gavin-dunlap-luminar is credited in the commit and in tests/property.rs.

Note on the commit

This is one commit, not the four its message describes — everything was already
staged when I wrote the first one, so it swept the lot. Since the repo
squash-merges with the body taken from the PR description, the rationale that
would have been in those messages is above instead.

Review status

Not reviewed by anyone yet. Draft.

simple_doip#1 carries 26 property tests written against the pre-`no_std`
`write`/`read` API that 0.2.0 removed, on a branch whose merge base predates
that migration, the error-taxonomy refactor and the 0.4.0 server break. The
properties were sound; only the calls were stale. Rebasing that branch would
have resolved textually and then failed to compile, so the cases are ported
instead.

24 of the 26 land, all passing on the first run. The two that do not are byte
roundtrips through serde, and this crate has no serde dependency to roundtrip
through.

They live in `tests/property.rs` rather than in `#[cfg(test)]` modules inside
`src/` as the original did. proptest needs `std`, the library is `no_std`, and
an integration test target gets `std` without any conditional-compilation
gymnastics -- at the cost of only exercising the public API, which is all these
properties touch anyway.

What they check that `golden_vectors.rs` does not: the golden fixtures pin the
exact bytes the crate emits, so they catch the wire format changing. These
check that `encode` and `decode` agree with each other across the whole input
space, which catches a field written in one order and read in another. Neither
finds a misreading of the standard that both directions share symmetrically.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JustinKovacich
JustinKovacich force-pushed the test/port-property-and-fuzz-suites branch from 4564ace to befec53 Compare September 10, 2026 13:54
@JustinKovacich
JustinKovacich merged commit 761218b into main Sep 10, 2026
18 checks passed
@JustinKovacich
JustinKovacich deleted the test/port-property-and-fuzz-suites branch September 10, 2026 16:50
JustinKovacich added a commit that referenced this pull request Sep 10, 2026
…sage (#17)

**Stacked on #16 — merge order: #11#12#13#16 → this.**

## Issue URL

Closes #15.

## What

`Message::encode` could emit a frame that `Message::decode` rejects.

`decode` takes exactly `header.payload_length` bytes and hands them to
`Payload::decode`, which is not required to consume all of them. The
decoded
`Message` keeps the header verbatim, declared length included, and
`encode`
wrote that stale field beside a payload of its real size. Note that
`encoded_size()` already disagreed with the header being written — it
returns
`Header::SIZE + payload.encoded_size()`, not the declared length.

```rust
// A NACK body is one byte. This header claims five.
let framed = [0x02, 0xFD, 0, 0, 0, 0, 0, 0x05, 0x03, 0, 0, 0, 0];
let (msg, _) = Message::decode(&framed).unwrap();   // accepted
assert_eq!(msg.header.payload_length, 5);           // preserved
// re-encode -> 9 bytes, header still claims 5
Message::decode(&encode(&msg));  // Err(Incomplete { needed: 5, available: 1 })
```

Anything that decodes a frame and re-emits it — a proxy, a replay tool,
a
logging fake, a test harness echoing what it received — was turning a
malformed-but-accepted frame into a corrupt one on the wire. This
crate's own
`MessageCodec` encoder is on that path.

## The fix

`encode` builds its header from `payload.encoded_size()` rather than
trusting
`self.header.payload_length`, so an encoded frame is always
self-consistent
regardless of how lenient `decode` is. **No input that is accepted today
starts
being rejected.**

The consequence, documented on the method: for a frame that arrived with
a
mismatched declared length, `decode(encode(m)).header.payload_length` is
the
payload's real size rather than the length it arrived with. That is the
point —
but it is a visible behavior change, so it's in the changelog.

A **well-formed frame is byte-identical**, which is why all 11 golden
vectors
still pass unchanged. That's also pinned as a test.

`MessageError::PayloadTooLarge` covers the one fallible step — a payload
too
large for the `u32` length field, unreachable for a frame off the wire
whose
length was itself a `u32`. `MessageError` is `#[non_exhaustive]`, so
adding it
breaks nothing.

## Why not make `decode` strict instead

That is the standards-correct complement — ISO 13400-2 has an entity
answer an
invalid payload length with NACK `0x04`, and
`MessageError::PayloadLengthTooShort`
sits unused for exactly this. But it's a redesign, not a fix:
`Payload::decode`
would have to report unconsumed bytes, and the identification requests
**deliberately** discard their EID/VIN body (`ARCHITECTURE.md` §7.6), so
a
`0x0002` request carrying its six EID bytes would start being rejected
outright
rather than declined — breaking the UDP responder path.

Recorded in `ARCHITECTURE.md` §7.6 as deferred rather than dropped.

## Testing

| | |
|---|---|
| `tests/encode_consistency.rs` | 3 regression cases: overlong length on
a fixed payload, nonzero length on a unit payload, and a well-formed
frame encoding to the bytes it came from |
| golden vectors | 11/11 pass **unchanged** — the fix cannot touch a
frame whose declared length was already right |
| `fuzz_roundtrip`, skip removed, idempotence asserted | **21,033,263
executions clean** |
| other three fuzz targets | 6.4M / 8.1M / 1.7M clean |
| full test suite, all features and none | pass |
| clippy `--all-targets --all-features -Dclippy::pedantic`, and
`--no-default-features` | clean |
| `cargo doc` with `-D warnings`, `cargo fmt`, `pre-commit`, `cargo
publish --dry-run`, MSRV 1.88 | pass |

The fuzz target now asserts payload and payload-type equality plus
**idempotence** (encode, decode, encode again → identical bytes) rather
than
whole-`Message` equality. Asserting full equality would assert the bug
back
into existence, since the declared length legitimately normalizes;
idempotence
buys back the field-order asymmetry detection that equality was
providing.

## Also

Corrects `PayloadLengthTooShort`'s message, which read "does match"
where it
meant "does not match" — a user-visible error string.

## Release bump: 0.6.0

This PR sits at the top of the stack, so it also carries
`chore(release): v0.6.0` — the version the whole stack (#11#17)
publishes
as. It follows the same pattern as 0.5.2, whose bump was made inside
#10's
branch rather than by `cargo release` on main.

Nothing in the stack breaks a signature: `MessageError` is
`#[non_exhaustive]`, so `PayloadTooLarge` is additive, and
`ClientConnectionInfo::logical_address` keeps its type and only starts
carrying a real value. The bump is for the encode change in this PR — a
caller that set a mismatched `payload_length` deliberately (a
negative-test
fake, a corpus generator, a proxy replaying what it saw) stops being
able to
emit that frame, with no compiler diagnostic anywhere. The CHANGELOG
entry is
marked **Breaking:** so the version and the section header tell the same
story.

`v0.5.2` is now tagged at the #10 merge on `main` (`304d014`), so that
section has a comparison range and the release links run
`v0.5.2...v0.6.0`.

## Review status

Not reviewed by anyone yet. Draft.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants