Freeze the package compatibility corpus: real .rsbank bytes proving both ladder directions, every truncation site, and the round trip
This commit is contained in:
+101
@@ -0,0 +1,101 @@
|
||||
# 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.
|
||||
|
||||
If a fixture stops decoding, the answer is never to re-cut the fixture. Either the format
|
||||
change was structural (bump `minReaderVersion`, add a new fixture, and leave the old one
|
||||
asserting the refusal) or it is a regression.
|
||||
|
||||
## 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: the truncations are prefixes of them,
|
||||
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
|
||||
|
||||
Each is a prefix of `v1_shipping.rsbank` (907-byte prefix + 300-byte payload = 1207
|
||||
bytes). 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. |
|
||||
|
||||
### 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.
|
||||
Reference in New Issue
Block a user