diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6974000 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.rsbank binary diff --git a/CMakeLists.txt b/CMakeLists.txt index 431b569..07354b0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -85,6 +85,7 @@ set(LICE_SRC # --------------------------------------------------------------------------- set(REASAMPLER_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src) set(REASAMPLER_TESTS_DIR ${CMAKE_CURRENT_SOURCE_DIR}/tests) +set(REASAMPLER_PACKAGE_FIXTURE_DIR ${REASAMPLER_TESTS_DIR}/fixtures/package_compat) include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/reasampler_targets.cmake) enable_testing() diff --git a/docs/verify-package-transfer.md b/docs/verify-package-transfer.md new file mode 100644 index 0000000..d701cc9 --- /dev/null +++ b/docs/verify-package-transfer.md @@ -0,0 +1,172 @@ +# DAW verification — bank-package transfer across machines + +What a DAW pass must establish for `.rsbank` export and import, and the exact strings or +counts to read off. The unit corpus (`tests/fixtures/package_compat/`) already proves the +version ladder, the truncation verdicts and the hostile-name refusals against frozen +bytes. **Nothing below is covered by it**: every cell here depends on a real REAPER +session, a real file dialog, or a genuine second machine. + +**Build to use.** Release, installed into `UserPlugins/`, REAPER restarted — extensions +load at startup only. Note the version the *About*/version action reports; §5 needs it. + +**Machines to use.** Two: **A** (the source) and **B** (the destination). B must be a +different machine, or at minimum a different user account with its own REAPER resource +path and its own projects folder — the point is that no absolute path from A can resolve +on B. A USB stick, a network share, or a cloud folder are all acceptable transports. + +**Projects to use.** On A: one **saved** project with a bank holding at least **three** +samples, at least one of them audibly distinct from the others, and at least one whose +display name carries a non-ASCII character (e.g. `Café hit`). On B: one **saved**, +otherwise empty project. + +--- + +## 1. Export writes one file and touches nothing else + +On A, right-click the bank's header in the docked panel → **Export as package...** (or +run *ReaSampler: export active bank as package*). Accept the suggested file name. + +Read off: + +- The console shows `ReaSampler export: wrote 3 entry/entries (N bytes) to `, with + the entry count matching the bank. +- A single `.rsbank` file exists at that path. **No `.rsbanktmp` sibling remains** — a + leftover temp file means the atomic rename did not complete. +- The bank's card count, the bank folder's file count, and the project's dirty flag are + all **unchanged**. An export writes no ext state and opens no undo point, so REAPER + must not consider the project modified by it alone. +- Nothing was added to the arrange view. + +## 2. The transfer itself — the claim no unit test can make + +Copy the `.rsbank` to B by whatever transport you chose. Do **not** copy the project, the +bank folder, or anything else. + +On B, open the empty saved project. Panel bank menu → **Import bank package...** (or run +*ReaSampler: import bank package (.rsbank)*), and choose the transferred file. + +Read off: + +- A message box: `Imported 3 sample(s) into a new bank: "".` +- The console block repeats that line and ends with `One undo removes the imported bank + and its entries. It does NOT delete the imported files ...`. +- The panel shows a **new** bank with the same display name and the same number of cards, + **in the same order** as on A. +- B's bank folder holds three new files. The non-ASCII display name from A renders + correctly on the card — a mangled name here means the UTF-8 path/name conversion broke + in transit. +- **Audition each card.** They must sound like their counterparts on A. This is the whole + claim: the audio survived a machine boundary with no shared path. +- Press **Ctrl-Z once**. The imported bank and its entries disappear in one step. The + three files remain in B's bank folder (that is stated in the console block above, and is + the designed behaviour — a prune reclaims them). Redo to continue. + +## 3. Re-importing the same package never overwrites + +Still on B, import the **same** file a second time. + +Read off: + +- A second new bank appears, named with a suffix (` 2`), and the box's + `(a bank named "" already exists in this project)` clause appears in the + console block. +- B's bank folder now holds **six** files, not three. The console reports + `3 file(s) landed under a freshly minted name (the package's own name was already taken + in the bank folder). An existing bank file is never overwritten.` +- The first imported bank's cards still audition correctly — nothing was replaced under it. + +## 4. Round trip back to the source + +On B, export the imported bank (§1) to a second `.rsbank`. Carry it back to A and import +it into A's original project. + +Read off: + +- The import succeeds and lands as a new bank beside the original. +- The original bank on A is untouched: same card count, same names, same audio. +- Compare the two `.rsbank` files' **sizes**. They will usually differ — entry names, + sample ids and the export timestamp are all legitimately re-minted across a trip. The + payload bytes are what must survive, and that half is closed by + `tests/test_package_round_trip.cpp` against frozen bytes; do **not** treat a size + difference here as a defect. + +## 5. The too-new refusal, with the message read verbatim + +This is the direction a user hits when a collaborator is ahead of them, and the message is +the only actionable output. Produce it by hand: + +1. Copy the `.rsbank` from §1 to a scratch name. +2. Open the copy in a hex editor. Bytes 0–3 are `RSBK`; bytes 4–7 are `formatVersion` + little-endian; bytes **8–11** are `minReaderVersion` little-endian. +3. Change byte **8** from `01` to `02`, and byte **4** from `01` to `02` (a writer cannot + require a reader newer than the format it wrote — leaving `formatVersion` at 1 makes + the file incoherent and it will be refused as malformed instead, which is a different + cell). Save. +4. Import the edited copy. + +Read off — the message box, all four lines: + +``` +Cannot import this bank package. +It was written by ReaSampler and needs package format 2 or newer. +This build () reads package format 1. +Nothing was imported. Install ReaSampler or newer and try again. +``` + +- The writer version named is the one **this** build stamped in §1 (the hex edit does not + touch the semver), so the second and fourth lines will name your own version. That is + expected — what is being verified is that all three facts are present and the box + appears at all. +- **No** new bank, **no** new files in the bank folder, **no** undo point. + +## 6. The truncated-download refusal is a different message + +Copy the §1 package again and delete the last few hundred bytes (any hex editor, or +`head -c` / `fsutil` — the exact count does not matter as long as the file is shorter). +Import it. + +Read off: + +- The message box reads exactly: `This file is not a readable bank package (corrupt or + truncated). Nothing was imported.` +- It is **not** the §5 message. Crossing these two is the failure this cell exists to + catch — "install a newer build" does not fix a partial download. +- No new bank, no new files. + +## 7. Corruption in the middle is caught before anything lands + +Copy the §1 package again and flip a single byte **well past the halfway point** (inside a +payload, not the header). Import it. + +Read off: + +- The message box names the offending entry: + `This bank package is damaged (entry "" failed its integrity check). Nothing was + imported.` +- The bank folder gained **no** files at all — not even the entries before the damaged + one. Verification runs to completion before the first write, so a damaged package costs + no rollback. + +## 8. The unsaved-project refusals + +- On B, File → New Project (do not save). Try to import. Read off: + `Save the project before importing a bank package -- an unsaved project has no bank + folder to import into.` The file picker must **not** have opened first. +- On A, in an unsaved project with no bank, try to export. Read off the console: + `ReaSampler export: save the project first -- an unsaved project has no bank folder to + read from.` + +## 9. Drag-and-drop reaches the same verb + +On B, drag a `.rsbank` from the file manager onto the docked ReaSampler panel. + +Read off: the same import box as §2, and the same new bank. A `.rsbank` is a whole bank, +not audio — it must never land as an item in the arrange view. + +--- + +## Recording the result + +For each section, record **pass**, **fail with the string actually seen**, or **not +exercised**. §2 and §4 are the load-bearing ones: they are the only cells in this document +that involve a real machine boundary, and no unit test can stand in for them. diff --git a/src/core/package/CLAUDE.md b/src/core/package/CLAUDE.md index c24ffcb..4733c76 100644 --- a/src/core/package/CLAUDE.md +++ b/src/core/package/CLAUDE.md @@ -93,6 +93,10 @@ both have landed. file size in; header/manifest/layout out), and `requiredPrefixSize` (the incremental-read seam for the shell). Framing rides `core/wire/bytes.h`. +`package_compat_tests` is declared here with no library of its own: it decodes the +frozen `.rsbank` corpus at `tests/fixtures/package_compat/`, whose README owns the +append-only rule and the per-fixture inventory. + ## Gotchas - Enums nested inside the `BankModel` blob follow `bank_model`'s own rule — an diff --git a/src/core/package/CMakeLists.txt b/src/core/package/CMakeLists.txt index 3cdc30e..8102725 100644 --- a/src/core/package/CMakeLists.txt +++ b/src/core/package/CMakeLists.txt @@ -18,6 +18,12 @@ reasampler_pure_library(bank_package # app_version: the tests pin the stamped writer semver against stampVersion(). reasampler_test(bank_package LINK bank_package app_version) +# The frozen compatibility corpus, decoded rather than regenerated. Fixture path: see +# tests/package_fixtures.h. +reasampler_test(package_compat LINK bank_package) +target_compile_definitions(package_compat_tests PRIVATE + REASAMPLER_PACKAGE_FIXTURE_DIR="${REASAMPLER_PACKAGE_FIXTURE_DIR}") + reasampler_pure_library(import_plan SOURCES import_plan.cpp LINK PUBLIC package_manifest bank_book origin_ledger PRIVATE package_format capture_paths) diff --git a/src/shell/package/CLAUDE.md b/src/shell/package/CLAUDE.md index bed91ee..dede777 100644 --- a/src/shell/package/CLAUDE.md +++ b/src/shell/package/CLAUDE.md @@ -94,6 +94,10 @@ belongs to its skin (`shell/actions/package_export_action`), not this seam. - `import_landing` — the import's two halves that decide anything: `landPackage` (decode, plan, verify EVERY payload's digest, then land through the journal) and `applyImportedBank` (the new bank's entries plus a birth record per landed file, in one straight-line block). REAPER-free deliberately — all-or-nothing, integrity and birth-record behaviour are assertable without a DAW. - `import_bank` — the promptless import verb over a live `ReaSamplerSession`: the project directory, the minted bank id, the `recordCreated` writer, and the one undo-batched persist. REAPER-facing, so it compiles into the extension module rather than into a library with a test target. +`package_round_trip_tests` is declared here with no library of its own: it drives the +same frozen corpus (`tests/fixtures/package_compat/`) through both verbs, which is where +export → import → export payload identity is proven. + ## Gotchas - A crash mid-export strands the `.rsbanktmp` sibling. It is not a `.rsbank` (no diff --git a/src/shell/package/CMakeLists.txt b/src/shell/package/CMakeLists.txt index 399b32d..497f8e5 100644 --- a/src/shell/package/CMakeLists.txt +++ b/src/shell/package/CMakeLists.txt @@ -33,6 +33,16 @@ reasampler_pure_library(import_landing LINK PUBLIC import_plan package_rollback bank_book PRIVATE bank_package wav_codec) reasampler_test(import_landing LINK import_landing bank_package app_version wav_codec origin_ledger) +# The frozen compatibility corpus driven through both verbs in one process — the round +# trip is export -> import -> export, so its link set is export_bank's plus the import +# half. Fixture path: see tests/package_fixtures.h. +reasampler_test(package_round_trip + LINK import_landing export_bank bank_package bank_book slot_map view_mode_model + tail_control origin_ledger tracking_authority prune_reconcile app_version + capture_paths wav_codec) +target_compile_definitions(package_round_trip_tests PRIVATE + REASAMPLER_PACKAGE_FIXTURE_DIR="${REASAMPLER_PACKAGE_FIXTURE_DIR}") + # The pickers call the REAPER API, so no test target can exercise them; declared as a # library so the TU stays compiled. reaper_plugin.h pulls SWELL in on non-Windows. add_library(package_pickers STATIC package_pickers.cpp) diff --git a/tests/fixtures/package_compat/README.md b/tests/fixtures/package_compat/README.md new file mode 100644 index 0000000..837d7ec --- /dev/null +++ b/tests/fixtures/package_compat/README.md @@ -0,0 +1,118 @@ +# 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. diff --git a/tests/fixtures/package_compat/additive_forward.rsbank b/tests/fixtures/package_compat/additive_forward.rsbank new file mode 100644 index 0000000..656d9dd Binary files /dev/null and b/tests/fixtures/package_compat/additive_forward.rsbank differ diff --git a/tests/fixtures/package_compat/hostile_name_dotdot.rsbank b/tests/fixtures/package_compat/hostile_name_dotdot.rsbank new file mode 100644 index 0000000..30af026 Binary files /dev/null and b/tests/fixtures/package_compat/hostile_name_dotdot.rsbank differ diff --git a/tests/fixtures/package_compat/hostile_name_drive_absolute.rsbank b/tests/fixtures/package_compat/hostile_name_drive_absolute.rsbank new file mode 100644 index 0000000..6ef9963 Binary files /dev/null and b/tests/fixtures/package_compat/hostile_name_drive_absolute.rsbank differ diff --git a/tests/fixtures/package_compat/hostile_name_parent_backslash.rsbank b/tests/fixtures/package_compat/hostile_name_parent_backslash.rsbank new file mode 100644 index 0000000..bf100cb Binary files /dev/null and b/tests/fixtures/package_compat/hostile_name_parent_backslash.rsbank differ diff --git a/tests/fixtures/package_compat/hostile_name_parent_slash.rsbank b/tests/fixtures/package_compat/hostile_name_parent_slash.rsbank new file mode 100644 index 0000000..c8145cb Binary files /dev/null and b/tests/fixtures/package_compat/hostile_name_parent_slash.rsbank differ diff --git a/tests/fixtures/package_compat/hostile_name_subdir_slash.rsbank b/tests/fixtures/package_compat/hostile_name_subdir_slash.rsbank new file mode 100644 index 0000000..d473a8e Binary files /dev/null and b/tests/fixtures/package_compat/hostile_name_subdir_slash.rsbank differ diff --git a/tests/fixtures/package_compat/hostile_name_unc_absolute.rsbank b/tests/fixtures/package_compat/hostile_name_unc_absolute.rsbank new file mode 100644 index 0000000..d50717c Binary files /dev/null and b/tests/fixtures/package_compat/hostile_name_unc_absolute.rsbank differ diff --git a/tests/fixtures/package_compat/hostile_path_dotdot_backslash.rsbank b/tests/fixtures/package_compat/hostile_path_dotdot_backslash.rsbank new file mode 100644 index 0000000..700a42c Binary files /dev/null and b/tests/fixtures/package_compat/hostile_path_dotdot_backslash.rsbank differ diff --git a/tests/fixtures/package_compat/hostile_path_dotdot_slash.rsbank b/tests/fixtures/package_compat/hostile_path_dotdot_slash.rsbank new file mode 100644 index 0000000..b203f8e Binary files /dev/null and b/tests/fixtures/package_compat/hostile_path_dotdot_slash.rsbank differ diff --git a/tests/fixtures/package_compat/hostile_path_drive_absolute.rsbank b/tests/fixtures/package_compat/hostile_path_drive_absolute.rsbank new file mode 100644 index 0000000..08fead8 Binary files /dev/null and b/tests/fixtures/package_compat/hostile_path_drive_absolute.rsbank differ diff --git a/tests/fixtures/package_compat/hostile_path_rooted.rsbank b/tests/fixtures/package_compat/hostile_path_rooted.rsbank new file mode 100644 index 0000000..359c3d1 Binary files /dev/null and b/tests/fixtures/package_compat/hostile_path_rooted.rsbank differ diff --git a/tests/fixtures/package_compat/hostile_path_unc_absolute.rsbank b/tests/fixtures/package_compat/hostile_path_unc_absolute.rsbank new file mode 100644 index 0000000..8e482b1 Binary files /dev/null and b/tests/fixtures/package_compat/hostile_path_unc_absolute.rsbank differ diff --git a/tests/fixtures/package_compat/refuse_structural.rsbank b/tests/fixtures/package_compat/refuse_structural.rsbank new file mode 100644 index 0000000..5e751b3 Binary files /dev/null and b/tests/fixtures/package_compat/refuse_structural.rsbank differ diff --git a/tests/fixtures/package_compat/trunc_additive_forward.rsbank b/tests/fixtures/package_compat/trunc_additive_forward.rsbank new file mode 100644 index 0000000..7e5e50d Binary files /dev/null and b/tests/fixtures/package_compat/trunc_additive_forward.rsbank differ diff --git a/tests/fixtures/package_compat/trunc_magic.rsbank b/tests/fixtures/package_compat/trunc_magic.rsbank new file mode 100644 index 0000000..0ea8a97 --- /dev/null +++ b/tests/fixtures/package_compat/trunc_magic.rsbank @@ -0,0 +1 @@ +RS \ No newline at end of file diff --git a/tests/fixtures/package_compat/trunc_manifest_body.rsbank b/tests/fixtures/package_compat/trunc_manifest_body.rsbank new file mode 100644 index 0000000..05d37e4 Binary files /dev/null and b/tests/fixtures/package_compat/trunc_manifest_body.rsbank differ diff --git a/tests/fixtures/package_compat/trunc_manifest_length.rsbank b/tests/fixtures/package_compat/trunc_manifest_length.rsbank new file mode 100644 index 0000000..3ab399a Binary files /dev/null and b/tests/fixtures/package_compat/trunc_manifest_length.rsbank differ diff --git a/tests/fixtures/package_compat/trunc_one_short.rsbank b/tests/fixtures/package_compat/trunc_one_short.rsbank new file mode 100644 index 0000000..79b4f3b Binary files /dev/null and b/tests/fixtures/package_compat/trunc_one_short.rsbank differ diff --git a/tests/fixtures/package_compat/trunc_payload_middle.rsbank b/tests/fixtures/package_compat/trunc_payload_middle.rsbank new file mode 100644 index 0000000..ac9ddd2 Binary files /dev/null and b/tests/fixtures/package_compat/trunc_payload_middle.rsbank differ diff --git a/tests/fixtures/package_compat/trunc_payload_start.rsbank b/tests/fixtures/package_compat/trunc_payload_start.rsbank new file mode 100644 index 0000000..c7a3a72 Binary files /dev/null and b/tests/fixtures/package_compat/trunc_payload_start.rsbank differ diff --git a/tests/fixtures/package_compat/trunc_version_pair.rsbank b/tests/fixtures/package_compat/trunc_version_pair.rsbank new file mode 100644 index 0000000..c66f6db Binary files /dev/null and b/tests/fixtures/package_compat/trunc_version_pair.rsbank differ diff --git a/tests/fixtures/package_compat/trunc_writer_semver.rsbank b/tests/fixtures/package_compat/trunc_writer_semver.rsbank new file mode 100644 index 0000000..430300e Binary files /dev/null and b/tests/fixtures/package_compat/trunc_writer_semver.rsbank differ diff --git a/tests/fixtures/package_compat/v1_shipping.rsbank b/tests/fixtures/package_compat/v1_shipping.rsbank new file mode 100644 index 0000000..8c85831 Binary files /dev/null and b/tests/fixtures/package_compat/v1_shipping.rsbank differ diff --git a/tests/package_fixtures.h b/tests/package_fixtures.h new file mode 100644 index 0000000..b7cab67 --- /dev/null +++ b/tests/package_fixtures.h @@ -0,0 +1,25 @@ +#pragma once +// The one accessor over the frozen package-compat corpus (tests/fixtures/package_compat). +// REASAMPLER_PACKAGE_FIXTURE_DIR is a compile-time absolute path defined by each +// consuming test target: the corpus is source-tree data, and a test's working directory +// under ctest differs between single- and multi-config generators, so no relative +// spelling reaches it from both. +// +// Every caller must check the returned size: a fixture that failed to open reads as an +// empty buffer, which a "this must be Malformed" assertion would otherwise pass. + +#include +#include +#include +#include +#include + +inline std::string packageFixturePath(const std::string& name) { + return std::string(REASAMPLER_PACKAGE_FIXTURE_DIR) + "/" + name; +} + +inline std::vector packageFixtureBytes(const std::string& name) { + std::ifstream f(packageFixturePath(name), std::ios::binary); + return std::vector(std::istreambuf_iterator(f), + std::istreambuf_iterator()); +} diff --git a/tests/test_package_compat.cpp b/tests/test_package_compat.cpp new file mode 100644 index 0000000..38cd5b7 --- /dev/null +++ b/tests/test_package_compat.cpp @@ -0,0 +1,402 @@ +// Standalone tests over the FROZEN package-compat corpus — no REAPER, no framework. +// Nothing here builds a package: every byte comes off disk exactly as committed, which +// is the only shape in which a later format change can be caught breaking a +// compatibility direction. A test that re-derived its own fixture would prove only that +// the codec agrees with itself. See tests/fixtures/package_compat/README.md. +// +// The values pinned below are the FIXTURE's facts, not this build's — the writer semver +// especially must never be compared against version::stampVersion(), or a version bump +// would silently re-anchor the corpus. + +#include "../src/core/package/bank_package.h" + +#include +#include +#include +#include +#include +#include + +#include "../src/core/package/package_format.h" +#include "../src/core/package/package_manifest.h" +#include "package_fixtures.h" + +using namespace reasampler::package; +using namespace reasampler::model; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- what the v1 fixture's bytes say ----------------------------------------- + +static constexpr const char* kV1File = "v1_shipping.rsbank"; +static constexpr const char* kV1WriterSemver = "1.4.0"; +static constexpr const char* kV1EntryName = "kick.wav"; +static constexpr const char* kV1EntryHash = "8df36e805531e4af"; +static constexpr std::uint64_t kV1PrefixSize = 907; +static constexpr std::uint64_t kV1PayloadLength = 300; +static constexpr std::uint64_t kV1TotalSize = 1207; + +// Every Sample field the shipping build wrote, with every optional PRESENT — so +// "every known field intact" is a claim about the whole record, not a sampled few. +// relativePath is the BARE transport name: export_plan normalizes it (see +// src/core/package/CLAUDE.md), so a package carrying a directory component here would +// mean the fixture predates that rule. +static Sample expectedV1Sample() { + Sample s; + s.id = "cap-kick"; + s.displayName = "Kick (wet)"; + s.relativePath = "kick.wav"; + s.sourceMode = SourceMode::RazorArea; + s.sourceRange = {1.25, 3.5, 480.0, 1920.0}; + s.trackGuids = {"{AAA}", "{BBB}"}; + s.wetDry = 0.75; + s.channelCount = 1; + s.sampleRate = 48000; + s.lengthSeconds = 0.0026666666666666666; + s.lengthBeats = 0.00533; + s.captureTempo = 120.5; + s.captureTimeSigNum = 7; + s.captureTimeSigDenom = 8; + s.key = "F#m"; + s.rootNote = 60; + s.loop = LoopPoints{8, 120}; + s.levels = {-0.3, -12.7, -14.0}; + s.clipped = true; + s.tier = Tier::Archive; + s.contentHash = "Wcompatcorpus0001"; + s.provenance = Provenance{"cap-parent", "fx-snapshot"}; + s.createdTimestamp = 1754000000; + return s; +} + +static PackageManifest expectedV1Manifest() { + PackageManifest m; + m.bankDisplayName = "Compat Corpus"; + m.exportTimestamp = 1754100000; + m.entries.push_back({kV1EntryName, kV1PayloadLength, kV1EntryHash, expectedV1Sample()}); + m.slots.append("cap-kick"); + return m; +} + +// A fixture that failed to open reads as an empty buffer, and an empty buffer decodes +// Malformed — which would let half this file pass vacuously. Every suite loads through +// here. A size mismatch is fatal to the caller (false), not just recorded: proceeding +// with a short or empty buffer would let classifyFixture/le32At index out of range and +// the TooNew test's hand-lifted manifest slice overread the heap, rather than fail clean. +static bool load(const char* name, std::size_t expectedSize, std::vector& out) { + out = packageFixtureBytes(name); + if (out.size() != expectedSize) { + std::printf("FAIL: fixture %s is %zu bytes, expected %zu (path: %s)\n", name, + out.size(), expectedSize, packageFixturePath(name).c_str()); + ++g_fail; + return false; + } + return true; +} + +static bool containsToken(const std::vector& bytes, const std::string& token) { + const std::string text(reinterpret_cast(bytes.data()), bytes.size()); + return text.find(token) != std::string::npos; +} + +static std::uint32_t le32At(const std::vector& bytes, std::size_t at) { + return static_cast(bytes[at]) | + (static_cast(bytes[at + 1]) << 8) | + (static_cast(bytes[at + 2]) << 16) | + (static_cast(bytes[at + 3]) << 24); +} + +// --- direction 1: the shipping build reads what it wrote --------------------- + +// The header pair as the bytes carry it, classified without going through decode — the +// ladder rule stated against the file rather than against the decoder's reading of it. +static PackageReadability classifyFixture(const std::vector& bytes) { + return classifyPackageVersion(le32At(bytes, 4), le32At(bytes, 8)); +} + +static void testV1FixtureDecodesToTheRecordTheShippingBuildWrote() { + std::vector bytes; + if (!load(kV1File, kV1TotalSize, bytes)) return; + CHECK(classifyFixture(bytes) == PackageReadability::Readable); + + const DecodedPackage dec = decodePackage(bytes, bytes.size()); + CHECK(dec.status == PackageReadability::Readable); + CHECK(dec.header.formatVersion == 1); + CHECK(dec.header.minReaderVersion == 1); + CHECK(dec.header.writerVersion == kV1WriterSemver); + + CHECK(dec.manifest.entries.size() == 1); + CHECK(dec.manifest.bankDisplayName == "Compat Corpus"); + if (dec.manifest.entries.size() == 1) { + CHECK(dec.manifest.entries[0].fileName == kV1EntryName); + CHECK(dec.manifest.entries[0].byteLength == kV1PayloadLength); + CHECK(dec.manifest.entries[0].byteHash == kV1EntryHash); + CHECK(dec.manifest.entries[0].sample == expectedV1Sample()); + } + CHECK(dec.manifest == expectedV1Manifest()); + + CHECK(dec.prefixSize == kV1PrefixSize); + CHECK(dec.layout.size() == 1); + if (dec.layout.size() == 1) { + CHECK(dec.layout[0].name == kV1EntryName); + CHECK(dec.layout[0].offset == kV1PrefixSize); + CHECK(dec.layout[0].length == kV1PayloadLength); + } +} + +// --- direction 1: a NEWER additive writer still reads ------------------------ + +// formatVersion N+1, minReaderVersion unchanged: the whole reason two integers exist. +// The fixture carries three keys this build has never heard of — one at the manifest +// root, one on the entry, one inside the nested Sample blob — and must still decode to +// exactly what the v1 fixture decodes to. +static void testAdditiveForwardFixtureReadsWithEveryKnownFieldIntact() { + std::vector bytes; + if (!load("additive_forward.rsbank", 1283, bytes)) return; + + // Non-vacuity: the unknown keys are genuinely in the bytes, so the equality below + // is "skipped without error", not "there was nothing to skip". + CHECK(containsToken(bytes, "\"exportTool\"")); + CHECK(containsToken(bytes, "\"futureEntryKey\"")); + CHECK(containsToken(bytes, "\"futureSampleKey\"")); + + CHECK(classifyFixture(bytes) == PackageReadability::Readable); + + const DecodedPackage dec = decodePackage(bytes, bytes.size()); + CHECK(dec.status == PackageReadability::Readable); + // Literal, not kPackageFormatVersion-relative: these are the FIXTURE's frozen + // version pair (2/1), not this build's — see the file header note. + CHECK(dec.header.formatVersion == 2); + CHECK(dec.header.minReaderVersion == 1); + CHECK(dec.header.writerVersion == "1.9.0"); + + // Every known field, end to end: same manifest the v1 fixture yields. + CHECK(dec.manifest == expectedV1Manifest()); + CHECK(dec.layout.size() == 1); + if (dec.layout.size() == 1) CHECK(dec.layout[0].length == kV1PayloadLength); +} + +// --- direction 2: a structural newer writer is refused whole ----------------- + +static void testRefuseFixtureIsTooNewAndStillNamesTheWriter() { + std::vector bytes; + if (!load("refuse_structural.rsbank", kV1TotalSize, bytes)) return; + CHECK(classifyFixture(bytes) == PackageReadability::TooNew); + + const DecodedPackage dec = decodePackage(bytes, bytes.size()); + CHECK(dec.status == PackageReadability::TooNew); + // The three facts the refusal message owes the user. Literal, not + // kPackageFormatVersion-relative — this is the FIXTURE's frozen pair (2/2). + CHECK(dec.header.formatVersion == 2); + CHECK(dec.header.minReaderVersion == 2); + CHECK(dec.header.writerVersion == "1.9.0"); + + // Nothing else: no manifest, no layout, no partial success. + CHECK(dec.manifest == PackageManifest{}); + CHECK(dec.layout.empty()); + CHECK(dec.prefixSize == 0); + + // The refusal is a DECISION, not an inability: this fixture's body is the v1 + // fixture's own manifest, which parses. Walk the frozen header by hand to lift it + // out — a reader at this version is forbidden from doing so, which is the point. + const std::size_t manifestLenAt = 16 + le32At(bytes, 12); + const std::uint32_t manifestLen = le32At(bytes, manifestLenAt); + const std::string body(reinterpret_cast(bytes.data() + manifestLenAt + 4), + manifestLen); + CHECK(deserializeManifest(body).has_value()); +} + +// --- truncation: Malformed at every site, never TooNew ----------------------- + +// One fixture per DISTINCT decode failure site rather than an arithmetic spread. The +// RSBK layout is derived from the manifest's entries, not stored as its own section, so +// the site a "mid-layout" cut maps to is the payload boundary: the manifest parses, the +// layout computes, and the exact-size proof is what fails. +struct Truncation { + const char* file; + std::size_t size; // the cut offset — the committed file IS this many bytes + const char* site; +}; + +static const Truncation kTruncations[] = { + {"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 — 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"}, +}; + +static void testEveryTruncationIsMalformedNeverTooNew() { + for (const Truncation& t : kTruncations) { + std::vector bytes; + if (!load(t.file, t.size, bytes)) continue; + const DecodedPackage dec = decodePackage(bytes, bytes.size()); + if (dec.status != PackageReadability::Malformed) { + std::printf("FAIL: %s (%s) classified %s, expected Malformed\n", t.file, t.site, + dec.status == PackageReadability::TooNew ? "TooNew" : "Readable"); + ++g_fail; + } + // A refusal never half-succeeds, at any site. + CHECK(dec.manifest.entries.empty()); + CHECK(dec.layout.empty()); + CHECK(dec.prefixSize == 0); + + // The shell hands decode a PREFIX plus the observed file size, not the whole + // file — so the verdict has to survive that call shape too, at every cut the + // incremental seam can actually satisfy. + const auto need = requiredPrefixSize(bytes); + if (need && *need <= bytes.size()) { + const std::vector head(bytes.begin(), + bytes.begin() + static_cast(*need)); + CHECK(decodePackage(head, bytes.size()).status == PackageReadability::Malformed); + } + } +} + +// --- truncation: the additive-forward ladder direction, not just v1 --------- + +// Every truncation above is a prefix of v1_shipping.rsbank (formatVersion == ours), so +// none of them ever puts formatVersion > kPackageFormatVersion on the exact-size-proof +// failure path — the one relabeling branch (src/core/package/CLAUDE.md: "the parse +// branch is the ONLY one that relabels") never gets exercised from the TooNew side. +// This fixture is additive_forward.rsbank (formatVersion 2, one past ours) cut exactly +// at its manifest/payload boundary: the manifest parses whole, so the failure is the +// exact-size proof, not a parse failure — it must stay Malformed, not relabel to TooNew. +static void testAdditiveForwardTruncatedAtPayloadBoundaryStaysMalformed() { + std::vector bytes; + if (!load("trunc_additive_forward.rsbank", 983, bytes)) return; + CHECK(classifyFixture(bytes) == PackageReadability::Readable); + + const DecodedPackage dec = decodePackage(bytes, bytes.size()); + CHECK(dec.status == PackageReadability::Malformed); + CHECK(dec.manifest.entries.empty()); + CHECK(dec.layout.empty()); + CHECK(dec.prefixSize == 0); +} + +// --- hostile names: refused at decode, before any planner exists ------------- + +// The two fields carry DIFFERENT rules (src/core/package/CLAUDE.md): the entry name may +// not express a path at all, while the nested relativePath is a path and is refused only +// for traversal and absolute forms. Each fixture keeps the other field clean, so the +// refusal is attributable to the field under test. +struct HostileFixture { + const char* file; + std::size_t size; + const char* offending; // the form as it reads once JSON-unescaped + bool inEntryName; // false: the nested Sample's relativePath +}; + +static const HostileFixture kHostiles[] = { + {"hostile_name_dotdot.rsbank", 624, "..", true}, + {"hostile_name_parent_slash.rsbank", 633, "../evil.wav", true}, + {"hostile_name_parent_backslash.rsbank", 634, "..\\evil.wav", true}, + {"hostile_name_subdir_slash.rsbank", 634, "sub/evil.wav", true}, + {"hostile_name_drive_absolute.rsbank", 643, "C:\\Windows\\evil.wav", true}, + {"hostile_name_unc_absolute.rsbank", 646, "\\\\srv\\share\\evil.wav", true}, + {"hostile_path_dotdot_slash.rsbank", 641, "bank/../../evil.wav", false}, + {"hostile_path_dotdot_backslash.rsbank", 640, "bank\\..\\evil.wav", false}, + {"hostile_path_rooted.rsbank", 635, "/etc/evil.wav", false}, + {"hostile_path_drive_absolute.rsbank", 643, "C:\\Windows\\evil.wav", false}, + {"hostile_path_unc_absolute.rsbank", 646, "\\\\srv\\share\\evil.wav", false}, +}; + +// A backslash rides the manifest JSON doubled; the corpus table above spells the +// unescaped form, since that is what the naming rules are asked about. +static std::string jsonEscaped(const std::string& s) { + std::string out; + for (char c : s) { + if (c == '\\') out += '\\'; + out += c; + } + return out; +} + +static void testEveryHostileNameIsRefusedAtDecode() { + // The clean spelling both fields hold in the fixture they are NOT under test in — + // without this, a refusal could be coming from the wrong field. + CHECK(isValidEntryName("kick.wav")); + CHECK(isValidNestedSamplePath("kick.wav")); + + for (const HostileFixture& h : kHostiles) { + std::vector bytes; + if (!load(h.file, h.size, bytes)) continue; + if (!containsToken(bytes, jsonEscaped(h.offending))) { + std::printf("FAIL: %s does not carry the form it is named for (%s)\n", h.file, + h.offending); + ++g_fail; + } + + // The format's own rule on the offending field, stated directly. + if (h.inEntryName) CHECK(!isValidEntryName(h.offending)); + else CHECK(!isValidNestedSamplePath(h.offending)); + + // Refused by the CODEC: decode yields no manifest at all, so there is nothing + // for a planner to have been handed. planImport takes a PackageManifest, and + // decode produced none. + const DecodedPackage dec = decodePackage(bytes, bytes.size()); + if (dec.status != PackageReadability::Malformed) { + std::printf("FAIL: %s classified %d, expected Malformed\n", h.file, + static_cast(dec.status)); + ++g_fail; + } + CHECK(dec.manifest == PackageManifest{}); + CHECK(dec.layout.empty()); + CHECK(dec.prefixSize == 0); + } +} + +// --- inventory: every fixture on disk is exercised by SOME test above -------- + +// This TU's own tables (the three named fixtures plus kTruncations and kHostiles) are +// the fullest account of the corpus in the tree — every other consumer (the round-trip +// harness, the README) tests a subset of these same files. Enumerating the fixture +// directory here and failing on anything absent from this list is the one check that +// catches a fixture added to disk but never wired into a table: a silent coverage drop +// that would otherwise leave a green suite. +static void testEveryFixtureOnDiskIsInSomeTable() { + std::vector known = {kV1File, "additive_forward.rsbank", + "refuse_structural.rsbank", + "trunc_additive_forward.rsbank"}; + for (const Truncation& t : kTruncations) known.push_back(t.file); + for (const HostileFixture& h : kHostiles) known.push_back(h.file); + + std::error_code ec; + for (const auto& entry : + std::filesystem::directory_iterator(REASAMPLER_PACKAGE_FIXTURE_DIR, ec)) { + if (entry.path().extension() != ".rsbank") continue; + const std::string name = entry.path().filename().string(); + if (std::find(known.begin(), known.end(), name) == known.end()) { + std::printf("FAIL: fixture %s exists on disk but is in no table in this file\n", + name.c_str()); + ++g_fail; + } + } + if (ec) { + std::printf("FAIL: could not list fixture directory: %s\n", ec.message().c_str()); + ++g_fail; + } +} + +int main() { + testV1FixtureDecodesToTheRecordTheShippingBuildWrote(); + testAdditiveForwardFixtureReadsWithEveryKnownFieldIntact(); + testRefuseFixtureIsTooNewAndStillNamesTheWriter(); + testEveryTruncationIsMalformedNeverTooNew(); + testAdditiveForwardTruncatedAtPayloadBoundaryStaysMalformed(); + testEveryHostileNameIsRefusedAtDecode(); + testEveryFixtureOnDiskIsInSomeTable(); + + if (g_fail == 0) { + std::printf("package_compat_tests: all passed\n"); + return 0; + } + std::printf("package_compat_tests: %d failure(s)\n", g_fail); + return 1; +} diff --git a/tests/test_package_round_trip.cpp b/tests/test_package_round_trip.cpp new file mode 100644 index 0000000..79f365b --- /dev/null +++ b/tests/test_package_round_trip.cpp @@ -0,0 +1,275 @@ +// The corpus driven through both verbs — no REAPER, no framework. Where +// test_package_compat asserts what the frozen bytes DECODE to, this file asserts what +// the import and export verbs DO with them: the payload bytes survive a full +// export -> import -> export, and every refusal in the corpus refuses before a planner +// or a filesystem write is reached. + +#include "../src/shell/package/import_landing.h" + +#include +#include +#include +#include +#include +#include + +#include "../src/core/package/bank_package.h" +#include "../src/shell/package/export_bank.h" +#include "../src/shell/package/package_path.h" +#include "../src/shell/persist/session.h" +#include "package_fixtures.h" + +using namespace reasampler; +namespace fs = std::filesystem; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static const char* kTag = "1754000000"; +static const char* kImportedBankId = "bank-imported"; + +// --- scratch project --------------------------------------------------------- + +// One project directory per suite, torn down after, so no suite observes another's +// bank folder. +class Scratch { +public: + explicit Scratch(const std::string& name) { + std::error_code ec; + dir_ = pathToUtf8(fs::temp_directory_path(ec) / + utf8Path("reasampler_compat_" + name)); + fs::remove_all(utf8Path(dir_), ec); + fs::create_directories(utf8Path(dir_), ec); + } + ~Scratch() { + std::error_code ec; + fs::remove_all(utf8Path(dir_), ec); + } + const std::string& projectDir() const { return dir_; } + std::string bankDir() const { return package::bankFolderDir(dir_); } + std::string importPath() const { return dir_ + "/in.rsbank"; } + std::string exportPath() const { return dir_ + "/out.rsbank"; } + +private: + std::string dir_; +}; + +static std::vector readBytes(const std::string& path) { + std::ifstream f(utf8Path(path), std::ios::binary); + return std::vector(std::istreambuf_iterator(f), + std::istreambuf_iterator()); +} + +static void writeBytes(const std::string& path, const std::vector& bytes) { + std::ofstream f(utf8Path(path), std::ios::binary | std::ios::trunc); + f.write(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); +} + +// Copies a committed fixture to the path the verb will be pointed at. Fails loudly on a +// size mismatch (empty included): an unreadable OR mangled corpus would otherwise let +// every "must refuse" suite pass, since a garbled fixture still refuses, just not for +// the reason under test. +static bool stageFixture(const Scratch& scratch, const char* fixture, + std::size_t expectedSize) { + const std::vector bytes = packageFixtureBytes(fixture); + if (bytes.size() != expectedSize) { + std::printf("FAIL: fixture %s is %zu bytes, expected %zu (path: %s)\n", fixture, + bytes.size(), expectedSize, packageFixturePath(fixture).c_str()); + ++g_fail; + return false; + } + writeBytes(scratch.importPath(), bytes); + return true; +} + +// Each entry's payload bytes, sliced out of a whole package file by its own layout. +static std::vector> payloadsOf(const std::vector& file) { + std::vector> out; + const package::DecodedPackage dec = package::decodePackage(file, file.size()); + if (dec.status != package::PackageReadability::Readable) return out; + for (const package::PackageEntrySpan& span : dec.layout) { + const auto begin = file.begin() + static_cast(span.offset); + out.emplace_back(begin, begin + static_cast(span.length)); + } + return out; +} + +// --- the sequence the import verb runs, minus REAPER ------------------------- + +static ImportLanding runImport(const Scratch& scratch, ReaSamplerSession& session, + int* outBirths = nullptr) { + LandedFileJournal journal; + ImportLanding landing = landPackage(scratch.importPath(), scratch.projectDir(), + session.book(), kTag, journal); + if (landing.outcome != ImportOutcome::Landed) return landing; + int births = 0; + const bool applied = applyImportedBank(session.book(), kImportedBankId, landing.plan, + [&births](const model::Sample&) { ++births; }); + CHECK(applied); + if (applied) journal.markIndexCommitted(); + if (outBirths) *outBirths = births; + return landing; +} + +// --- the round-trip anchor --------------------------------------------------- + +// The fixture IS the first export (written by the shipping build's own export verb), so +// importing and re-exporting it closes export -> import -> export over frozen bytes. +static void testV1FixtureReExportsByteIdenticalPayloads() { + Scratch scratch("roundtrip"); + if (!stageFixture(scratch, "v1_shipping.rsbank", 1207)) return; + + ReaSamplerSession session; + int births = 0; + const ImportLanding landing = runImport(scratch, session, &births); + CHECK(landing.outcome == ImportOutcome::Landed); + CHECK(births == 1); + CHECK(landing.plan.landCount == 1); + if (landing.plan.entries.size() != 1) { CHECK(false); return; } + + const std::vector> sourcePayloads = + payloadsOf(packageFixtureBytes("v1_shipping.rsbank")); + if (sourcePayloads.size() != 1) { CHECK(false); return; } + + // The landed file is the package's payload verbatim — the first half of the claim. + const std::string landed = scratch.bankDir() + "/" + landing.plan.entries[0].destFileName; + CHECK(readBytes(landed) == sourcePayloads[0]); + + ExportRequest req; + req.projectDir = scratch.projectDir(); + req.bankId = kImportedBankId; + req.destAbsPath = scratch.exportPath(); + req.exportTimestamp = 1754200000; + const ExportOutcome out = exportBank(session, req); + CHECK(out.status == ExportStatus::Written); + CHECK(out.entriesWritten == 1); + + // The second half: the re-export's payloads, byte for byte. Entry NAMES may legally + // differ across the trip — the importer re-spells a bank file and the exporter mints + // its own transport name (src/core/package/CLAUDE.md) — the payload bytes may not. + CHECK(payloadsOf(readBytes(scratch.exportPath())) == sourcePayloads); +} + +// --- the refusals, at the verb rather than the codec ------------------------- + +static void testRefuseFixtureRefusesTheWholeImportAndNamesTheWriter() { + Scratch scratch("refuse"); + if (!stageFixture(scratch, "refuse_structural.rsbank", 1207)) return; + + ReaSamplerSession session; + const BankBook before = session.book(); + LandedFileJournal journal; + const ImportLanding landing = landPackage(scratch.importPath(), scratch.projectDir(), + session.book(), kTag, journal); + + CHECK(landing.outcome == ImportOutcome::TooNew); + // Literal, not kPackageFormatVersion-relative: this is the FIXTURE's frozen pair + // (2/2), not this build's (see test_package_compat.cpp's file header note). + CHECK(landing.header.formatVersion == 2); + CHECK(landing.header.minReaderVersion == 2); + CHECK(landing.header.writerVersion == "1.9.0"); + // Nothing planned, nothing on disk, nothing in the index. + CHECK(landing.plan.entries.empty()); + CHECK(!fs::exists(utf8Path(scratch.bankDir()))); + CHECK(session.book() == before); +} + +// The same eight cuts test_package_compat classifies, driven through the verb's +// incremental prefix reader — the one caller that can ask requiredPrefixSize for more +// bytes than the file holds. Sizes match test_package_compat.cpp's kTruncations. +struct TruncationFixture { + const char* file; + std::size_t size; +}; + +static const TruncationFixture kTruncationFixtures[] = { + {"trunc_magic.rsbank", 2}, + {"trunc_version_pair.rsbank", 10}, + {"trunc_writer_semver.rsbank", 18}, + {"trunc_manifest_length.rsbank", 23}, + {"trunc_manifest_body.rsbank", 466}, + {"trunc_payload_start.rsbank", 907}, + {"trunc_payload_middle.rsbank", 1057}, + {"trunc_one_short.rsbank", 1206}, +}; + +static void testEveryTruncationRefusesTheImportAsMalformed() { + for (const TruncationFixture& fixture : kTruncationFixtures) { + Scratch scratch(std::string("trunc_") + fixture.file); + if (!stageFixture(scratch, fixture.file, fixture.size)) continue; + + ReaSamplerSession session; + const BankBook before = session.book(); + LandedFileJournal journal; + const ImportLanding landing = landPackage(scratch.importPath(), scratch.projectDir(), + session.book(), kTag, journal); + if (landing.outcome != ImportOutcome::Malformed) { + std::printf("FAIL: %s imported as outcome %d, expected Malformed\n", fixture.file, + static_cast(landing.outcome)); + ++g_fail; + } + CHECK(landing.plan.entries.empty()); + CHECK(!fs::exists(utf8Path(scratch.bankDir()))); + CHECK(session.book() == before); + } +} + +// Sizes match test_package_compat.cpp's kHostiles. +struct HostileFixtureFile { + const char* file; + std::size_t size; +}; + +static const HostileFixtureFile kHostileFixtures[] = { + {"hostile_name_dotdot.rsbank", 624}, + {"hostile_name_parent_slash.rsbank", 633}, + {"hostile_name_parent_backslash.rsbank", 634}, + {"hostile_name_subdir_slash.rsbank", 634}, + {"hostile_name_drive_absolute.rsbank", 643}, + {"hostile_name_unc_absolute.rsbank", 646}, + {"hostile_path_dotdot_slash.rsbank", 641}, + {"hostile_path_dotdot_backslash.rsbank", 640}, + {"hostile_path_rooted.rsbank", 635}, + {"hostile_path_drive_absolute.rsbank", 643}, + {"hostile_path_unc_absolute.rsbank", 646}, +}; + +static void testEveryHostileNameIsRefusedBeforeThePlannerRuns() { + for (const HostileFixtureFile& fixture : kHostileFixtures) { + Scratch scratch(std::string("hostile_") + fixture.file); + if (!stageFixture(scratch, fixture.file, fixture.size)) continue; + + ReaSamplerSession session; + const BankBook before = session.book(); + LandedFileJournal journal; + const ImportLanding landing = landPackage(scratch.importPath(), scratch.projectDir(), + session.book(), kTag, journal); + if (landing.outcome != ImportOutcome::Malformed) { + std::printf("FAIL: %s imported as outcome %d, expected Malformed\n", fixture.file, + static_cast(landing.outcome)); + ++g_fail; + } + // planImport is the ONLY producer of a non-empty plan and it runs after the + // decode — an empty one is how "refused before any planner" is observed here. + CHECK(landing.plan.entries.empty()); + CHECK(landing.plan.bankDisplayName.empty()); + CHECK(!fs::exists(utf8Path(scratch.bankDir()))); + CHECK(session.book() == before); + } +} + +int main() { + testV1FixtureReExportsByteIdenticalPayloads(); + testRefuseFixtureRefusesTheWholeImportAndNamesTheWriter(); + testEveryTruncationRefusesTheImportAsMalformed(); + testEveryHostileNameIsRefusedBeforeThePlannerRuns(); + + if (g_fail == 0) { + std::printf("package_round_trip_tests: all passed\n"); + return 0; + } + std::printf("package_round_trip_tests: %d failure(s)\n", g_fail); + return 1; +}