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.
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.