Files
daniel 3fd3214ff8 Remediate Ε-W3-T1 package-compat-fixtures review findings
Freeze *.rsbank as binary via .gitattributes; add a truncated additive_forward fixture proving the exact-size proof beats TooNew; enumerate the fixture dir to catch orphaned files; make fixture-size checks fatal instead of just logged; pin fixture version asserts as literals, not build-relative.
2026-08-02 17:19:57 -04:00

7.5 KiB

The frozen .rsbank compatibility corpus

Real RSBK bytes, committed. tests/test_package_compat.cpp decodes them; tests/test_package_round_trip.cpp drives them through the import and export verbs.

THE RULE: this corpus is append-only

No file here is ever regenerated or edited. When a future format version ships, add its fixture beside these and leave every existing one alone.

The reason is the whole point of the corpus. These bytes exist to catch a format change that quietly breaks a compatibility direction. A fixture regenerated by the build that broke it agrees with that build by construction and catches nothing — which is exactly the failure mode a version ladder exists to prevent. The same argument forbids a test that builds its own fixture at run time. The repo-root .gitattributes (*.rsbank binary) keeps this mechanical: without it, git's NUL-sniffing heuristic could text-classify a future short/ASCII fixture and CRLF-mangle a line ending on a Windows checkout, silently breaking the frozen-bytes premise.

A fixture's BYTES are frozen forever; a fixture's ASSERTION is not. additive_forward.rsbank and refuse_structural.rsbank carry version pairs one step past THIS build's ladder (2/1 and 2/2). When a future build's own kPackageFormatVersion reaches 2, refuse_structural.rsbank classifies Readable under the new ladder — its bytes never claimed to need more than format 2 — so that build re-aims the assertion (and adds a new synthetic pair one step past the NEW ladder); it never re-cuts the fixture. If a truncation or hostile-name fixture ever changes classification, that is a regression, never a ladder consequence.

Provenance

v1_shipping.rsbank was produced by running this repo's own export verb (exportBank) at version 1.4.0 over a one-sample bank, and copying the emitted file here verbatim. Every other fixture is derived from those bytes: eight of the nine truncations are prefixes of v1_shipping.rsbank (the ninth, trunc_additive_forward.rsbank, is a prefix of additive_forward.rsbank itself — a prefix of a prefix, still frozen bytes, never regenerated), and the synthetic packages reuse their manifest region under different version integers or a hand-written hostile manifest (the encoder refuses to write one, which is why those could not come from the verb).

Payloads are one 300-byte 16-bit mono WAV. The properties under test are structural — version integers, framing arithmetic, name validation — so a larger payload proves nothing extra and costs the repo bytes forever. Whole corpus: ~14 KB.

Adding a fixture for a future version means writing it with that version's shipping build, exactly as this one was, and recording the build's version here.

What each fixture proves

The three version fixtures

File formatVersion / minReaderVersion Verdict Proves
v1_shipping.rsbank 1 / 1 Readable This build reads what it wrote: header, one manifest entry, the entry digest, and every Sample field with every optional present. Writer semver 1.4.0 is asserted literally, not against stampVersion() — comparing against the running build would let a version bump re-anchor the fixture silently.
additive_forward.rsbank 2 / 1 Readable An additive newer writer still reads. Carries three keys this build has never heard of — exportTool at the manifest root, futureEntryKey on the entry, futureSampleKey inside the nested Sample blob — and decodes to exactly the manifest v1_shipping.rsbank decodes to. Writer semver 1.9.0.
refuse_structural.rsbank 2 / 2 TooNew A structural newer writer is refused whole. The header through the writer semver still reads, so the refusal can name all three facts (1.9.0, needs format 2, this build reads 1); no manifest, no layout, no partial success. Its body is v1_shipping.rsbank's own manifest, which parses — so the refusal is a decision, not an inability.

Truncation — one file per distinct decode failure site

The first eight are prefixes of v1_shipping.rsbank (907-byte prefix + 300-byte payload = 1207 bytes), so formatVersion never exceeds this build's on that path. All classify Malformed; none may classify TooNew, since "install a newer build" does not fix a partial download.

File Bytes Site the cut lands in
trunc_magic.rsbank 2 Inside the 4-byte magic.
trunc_version_pair.rsbank 10 Inside the frozen header's minReaderVersion u32.
trunc_writer_semver.rsbank 18 Inside the frozen header's writer semver.
trunc_manifest_length.rsbank 23 Inside the manifest-length u32.
trunc_manifest_body.rsbank 466 Inside the manifest JSON.
trunc_payload_start.rsbank 907 At the payload boundary. RSBK stores no layout section — the layout is derived from the manifest's entries — so this is the cut that exercises "manifest parses, layout computes, exact-size proof fails".
trunc_payload_middle.rsbank 1057 Inside the first payload.
trunc_one_short.rsbank 1206 One byte short of the total.

trunc_additive_forward.rsbank is the ninth: a 983-byte prefix of additive_forward.rsbank (25-byte frozen header/manifest-length region + 958-byte manifest = 983), cut exactly at ITS payload boundary. formatVersion here is 2 — one past this build's — so this is the one truncation that proves the exact-size-proof failure stays Malformed even when formatVersion > kPackageFormatVersion, rather than relabeling to TooNew (the parse branch is the only one that relabels — see src/core/package/CLAUDE.md).

Hostile names — refused at decode, before any planner

The two naming fields carry different rules (src/core/package/CLAUDE.md), so each fixture keeps the other field spelled cleanly (kick.wav) and the refusal is attributable to the field under test.

Entry name — a bare file name, no path expression possible (isValidEntryName):

File Entry name
hostile_name_dotdot.rsbank ..
hostile_name_parent_slash.rsbank ../evil.wav
hostile_name_parent_backslash.rsbank ..\evil.wav
hostile_name_subdir_slash.rsbank sub/evil.wav
hostile_name_drive_absolute.rsbank C:\Windows\evil.wav
hostile_name_unc_absolute.rsbank \\srv\share\evil.wav

Nested Sample::relativePath — a path by design, refused only for traversal and absolute forms (isValidNestedSamplePath):

File relativePath Guard that fires first inside the codec
hostile_path_dotdot_slash.rsbank bank/../../evil.wav isValidNestedSamplePath
hostile_path_dotdot_backslash.rsbank bank\..\evil.wav isValidNestedSamplePath
hostile_path_rooted.rsbank /etc/evil.wav BankModel::add's absolute-path rejection, which drops the record and leaves the nested blob holding zero samples
hostile_path_drive_absolute.rsbank C:\Windows\evil.wav as above
hostile_path_unc_absolute.rsbank \\srv\share\evil.wav as above

Both guards are inside the codec and both refuse the whole package, so the security property is the same either way; the split is recorded because a change to either guard alone would still leave these fixtures passing.

The round-trip anchor

v1_shipping.rsbank doubles as it: the file is a real export, so importing it and exporting the resulting bank closes export → import → export over frozen bytes. Entry names may legally change across the trip (the importer re-spells a bank file, the exporter mints its own transport name); the payload bytes may not.