From 9521b5339fa5c043e4a474af3264dc624f61d7a8 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 15:16:55 -0400 Subject: [PATCH] Freeze the package compatibility corpus: real .rsbank bytes proving both ladder directions, every truncation site, and the round trip --- docs/verify-package-transfer.md | 172 +++++++++ src/core/package/CLAUDE.md | 4 + src/core/package/CMakeLists.txt | 6 + src/shell/package/CLAUDE.md | 4 + src/shell/package/CMakeLists.txt | 10 + tests/fixtures/package_compat/README.md | 101 ++++++ .../package_compat/additive_forward.rsbank | Bin 0 -> 1283 bytes .../package_compat/hostile_name_dotdot.rsbank | Bin 0 -> 624 bytes .../hostile_name_drive_absolute.rsbank | Bin 0 -> 643 bytes .../hostile_name_parent_backslash.rsbank | Bin 0 -> 634 bytes .../hostile_name_parent_slash.rsbank | Bin 0 -> 633 bytes .../hostile_name_subdir_slash.rsbank | Bin 0 -> 634 bytes .../hostile_name_unc_absolute.rsbank | Bin 0 -> 646 bytes .../hostile_path_dotdot_backslash.rsbank | Bin 0 -> 640 bytes .../hostile_path_dotdot_slash.rsbank | Bin 0 -> 641 bytes .../hostile_path_drive_absolute.rsbank | Bin 0 -> 643 bytes .../package_compat/hostile_path_rooted.rsbank | Bin 0 -> 635 bytes .../hostile_path_unc_absolute.rsbank | Bin 0 -> 646 bytes .../package_compat/refuse_structural.rsbank | Bin 0 -> 1207 bytes .../package_compat/trunc_magic.rsbank | 1 + .../package_compat/trunc_manifest_body.rsbank | Bin 0 -> 466 bytes .../trunc_manifest_length.rsbank | Bin 0 -> 23 bytes .../package_compat/trunc_one_short.rsbank | Bin 0 -> 1206 bytes .../trunc_payload_middle.rsbank | Bin 0 -> 1057 bytes .../package_compat/trunc_payload_start.rsbank | Bin 0 -> 907 bytes .../package_compat/trunc_version_pair.rsbank | Bin 0 -> 10 bytes .../package_compat/trunc_writer_semver.rsbank | Bin 0 -> 18 bytes .../package_compat/v1_shipping.rsbank | Bin 0 -> 1207 bytes tests/package_fixtures.h | 25 ++ tests/test_package_compat.cpp | 334 ++++++++++++++++++ tests/test_package_round_trip.cpp | 250 +++++++++++++ 31 files changed, 907 insertions(+) create mode 100644 docs/verify-package-transfer.md create mode 100644 tests/fixtures/package_compat/README.md create mode 100644 tests/fixtures/package_compat/additive_forward.rsbank create mode 100644 tests/fixtures/package_compat/hostile_name_dotdot.rsbank create mode 100644 tests/fixtures/package_compat/hostile_name_drive_absolute.rsbank create mode 100644 tests/fixtures/package_compat/hostile_name_parent_backslash.rsbank create mode 100644 tests/fixtures/package_compat/hostile_name_parent_slash.rsbank create mode 100644 tests/fixtures/package_compat/hostile_name_subdir_slash.rsbank create mode 100644 tests/fixtures/package_compat/hostile_name_unc_absolute.rsbank create mode 100644 tests/fixtures/package_compat/hostile_path_dotdot_backslash.rsbank create mode 100644 tests/fixtures/package_compat/hostile_path_dotdot_slash.rsbank create mode 100644 tests/fixtures/package_compat/hostile_path_drive_absolute.rsbank create mode 100644 tests/fixtures/package_compat/hostile_path_rooted.rsbank create mode 100644 tests/fixtures/package_compat/hostile_path_unc_absolute.rsbank create mode 100644 tests/fixtures/package_compat/refuse_structural.rsbank create mode 100644 tests/fixtures/package_compat/trunc_magic.rsbank create mode 100644 tests/fixtures/package_compat/trunc_manifest_body.rsbank create mode 100644 tests/fixtures/package_compat/trunc_manifest_length.rsbank create mode 100644 tests/fixtures/package_compat/trunc_one_short.rsbank create mode 100644 tests/fixtures/package_compat/trunc_payload_middle.rsbank create mode 100644 tests/fixtures/package_compat/trunc_payload_start.rsbank create mode 100644 tests/fixtures/package_compat/trunc_version_pair.rsbank create mode 100644 tests/fixtures/package_compat/trunc_writer_semver.rsbank create mode 100644 tests/fixtures/package_compat/v1_shipping.rsbank create mode 100644 tests/package_fixtures.h create mode 100644 tests/test_package_compat.cpp create mode 100644 tests/test_package_round_trip.cpp 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..0af4166 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_TESTS_DIR}/fixtures/package_compat") + 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..8ea6b60 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_TESTS_DIR}/fixtures/package_compat") + # 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..c63252d --- /dev/null +++ b/tests/fixtures/package_compat/README.md @@ -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. diff --git a/tests/fixtures/package_compat/additive_forward.rsbank b/tests/fixtures/package_compat/additive_forward.rsbank new file mode 100644 index 0000000000000000000000000000000000000000..656d9dd16efdb3e689eee997b95c6e23445c68cd GIT binary patch literal 1283 zcmc&z-%Aux6dtuGBEq1+2O+siSnLin`$N|)DAyk;rii;_U`E%OyY8?v_ugjitjf0F zLk~R!1w}*@6!;(%6cJJM10RHfA|eV3d=LtXh$txV!H3SBRayN9x-c;JyXSuAyWcry zD4pu7RurWQzRmFIYMZJ(uThjDB9olCBEy)Ck|^s7-z6x7#xTtfV21$K!5of7LkDxY z@4->|0x?liZ;i)vO)fyGa4q7Y2-9)c7m+8hE7HVZ)C3Q7H4>MWm}^K>1t16-+3A?28O>ID>2vO}=Iov>cMQ#s_={B`ZS9XeXfz@I(mZ zWh0>OMn{^=m>6JEX$#n3Gsf2}3}V5!APbmclLE0pSP^l8^POWT8P?SG`ammCI$m(7 zN7R-|@$$^}v%nD6W58$F7U!~eh3gaW+#+sZ9~4eN3-^>eaM%cX-UwPAywpK7cUgf@ zOpT!49J>%+?zi66_9}xAo1N^)VT-0I=3b6LfdC16t2-&f1;2@z0hgdD9C*K+B{@l& zRvImppx&O%WJO?{Hny)kfl@)-P;Ym46U_8*`?0R9BN}Rh5wB9FpTN&Vl~S$LDkc_K z&i(X-O_$DH*?#Tfja|2{&eYG|yxnwn_I~K$y~nXe+ksl}d*dH=!E!+P)|37{Iwz2>K literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..30af026d053a84217514e7c0e7caa7fa32cbb20c GIT binary patch literal 624 zcmWFvcJgLqU|;}YRv{StFim8_IJ@{3C{b5fOblu|1S@{3AR zQ%}q@V4L|@WnpaYknOdx56} zrCJ!6ni?CXnk1$Hm1X9oq*f?dRV$UH78Pga=K&4ZQ7TT%Ey#hInF+K{DLJt~Hv{gF zl+5CSoWx2D2NtE~B$i~Br3NH|ELO_SOwQITPb>r4Qk-8}l$`3Dp91s|&^gKZc_l!1 zdq7+j4mBC*9Ydhnpf5u*b5n~;fDVCp7wlVu+SuCKSfFt^`6VD9$JPdUy1A(U zgDl)J%rz~yL_q)?o{S6)yMc^yMg}GZ0fv;sl0;A-H}tbi?wH0iYrnvG}*i3fX$Hu$6ZdJIOlWe!qt$QH}1qdc>Ai`TFBW%^wDa z7G@6i9xegCDS{HBb0idGmdI-;uTeA5+M;V=xW~l7;)u0}{TZhKw=13zzIOr=LY{{StFim8_IJ@{3C{b5fOblu|1S@{3AR zQ%}q@V4L|@WnpaYknOdx56H8xB&NlXJO1L{nzP_n94DoZUY&dkpP+NYyb zoS0jX1GOd-=me$Y!~)$6xZ6@PiwklRD>2+$l$w)Rl3A7-kO;C^DLXSc8|s+i{L-T2 zRNwp*peKRONzTtJ0eZy);;L|{$v__(0$r3`l$r?ict~b$YHOefU`N^~aBzKMV{l%pB}JTmpPk1SLf0NGQlGk=IaOqh_GBMc2Y`kBNiD5o-_o iGfn|+S3Dzp?*t@-JPFT;eiK)a^d+?-^H0t|+YbQC&hE_s literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..bf100cb66f871076dae8c3fbe8f37363cde773e1 GIT binary patch literal 634 zcmWFvcJgLqU|;}YRv{StFim8_IJ@{3C{b5fOblu|1S@{3AR zQ%}q@V4L|@WnpaYknOdx56&Idmy?>8UXr0?WeifD zkys35Dp{nY8JneA7?_$G8>X5hrU8{@=B1=oC|Ol2m8BLHXXfVtti_@% literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c8145cb391f0f54b0f35189f0f7aacaaf6f36d1d GIT binary patch literal 633 zcmWFvcJgLqU|;}YRv{StFim8_IJ@{3C{b5fOblu|1S@{3AR zQ%}q@V4L|@WnpaYknOdx56jRI;J?kv?w{%H$MgFHK22n z^Yco89`Jy;DjaGu&|ii?7bO>^CIY=1l9`)YTmp0m#NS|V8`Q?u*2V(emy=%t@_KA- zkf)oQ3NY-#9m8DHa!V8hzyZt1(6AfGC}(6~Vh~_RNi0bOg?2+f%jAw}JhLXu6{StFim8_IJ@{3C{b5fOblu|1S@{3AR zQ%}q@V4L|@WnpaYknOdx56&Idmy?>8UXr0?WeifD zkys35Dp{nY8JneA7?_$G8>X5hrU8{@=B1=oC|Ol2m8BLHXXfVtttz4{ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d50717ce47a2286bf550d7728359b062f84750ce GIT binary patch literal 646 zcmWFvcJgLqU|;}YRv{StFim8_IJ@{3C{b5fOblu|1S@{3AR zQ%}q@V4L|@WnpaYknOdx56sWAO~twCeRg1$%zHJ8F1&NWEL0XBvxWLyC^j$u_UuBH6RgWu~K$sayHa4#rdU0 z$*I2iDL{V$os*oOR|52n2gFt3P?Le4Gz7XRxhORe==YG!+|=R{phF;m01g9#+SuCK zSfKlI@=HMB5L+AM>E@;a49IZDFxRx)5(NQpC^IrN>;^K*85x)u1Q=2hOA1^Au(`4_C12#tv9CtZ=;+)T=3s*yK-nbL<;KAdR z=PzF6y!-I6|+^%>=_}&Rf2ze5o5&b5vAn8kLMdqKJfwmt2LS*np literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..700a42cad9f98a7a1d38ad5919b81bd87ec2121c GIT binary patch literal 640 zcmWFvcJgLqU|;}YRv{StFim8_IJ@{3C{b5fOblu|1S@{3AR zQ%}q@V4L|@WnpaYknOdx56&Idlard4UXr0?Weif9kys35 zDp{nY8JneA7?_$G8>X5hrU8{@=B1=oC|Ol2m8BLHXXfVtta61Y(xT*4-~1Gy z4}lI#&d)0W`oja_zHq1=K<^m>-IiRGnh5lDNM>$oaS6~#5buM1Z%`XsTN?{>X-<9# zCt#U`C?8+MxP-EqL?$bsW7r%#;oxpd)b$juvfVjetrobvp|tDJWq zK9+p_@uTJs149cl2YU~f0N)fr3DG$c3NlONHI&z=8E9?MwJ_Xc;$U&a+Qa^gQ-IqQ d&j{Z;0SO^b!ZV`Z#1$lcNv+8IlQYov0{}{StFim8_IJ@{3C{b5fOblu|1S@{3AR zQ%}q@V4L|@WnpaYknOdx56&Idlard4UXr0?Weif9kys35 zDp{nY8JneA7?_$G8>X5hrU8{@=B1=oC|Ol2m8BLHXXfVtt267PXT%n z=(6Peyb_>CJRlAXhgt&kpCQn3$wjG&KyQa+=B5^x0Nn)fKiK;QwXwCeu|TKh41FVXkSpB?q2DSrg`p zES#}KX2pWlD(hEl(%H6Qr^((O2W*ZUIPP-##5tc!7p{ifym2Sy!Gp&s&tJUCdH3OC z$=4r0YW^@Vv@mn9_izdDO%aq3og<+jvqWA)d5xNZ))rk0!#ySr7Duc-?9Vs_xLxs# b@Vyg|5b`8EBl=BTLDHAhip)Pb18qM5yF%>L literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..08fead86286e59e252fa218c12783f1ad0051b62 GIT binary patch literal 643 zcmWFvcJgLqU|;}YRv{StFim8_IJ@{3C{b5fOblu|1S@{3AR zQ%}q@V4L|@WnpaYknOdx56&Idlard4UXr0?Weif9kys35 zDp{nY8JneA7?_$G8>X5hrU8{@=B1=oC|Ol2m8BLHXXfVttOz-)&_aHxv2m{G2AiCH7&P9K>!@Yj0_FCfsArS1||jphLpsTL{PXl^s`Lv zn8q_}!d#JsGnUA#Sg=}U{fbRG+cxYp*}LO_&5;AgT~41k=X2@8)sUMv?!-KJ@Hpl9 zi&r`CK71_s`r}8<9|ndNW)AiqE&;wNf)b*0Bot(p$ZIICQ8UomqHAHe$Hc+nh_#3P i8K(fZE1nU)cLEYZo`h#azlkeI`jT3a`6p+f?FRs4`0mXB literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..359c3d1f4bab8c162de0bbd3130cc2fcacdcd457 GIT binary patch literal 635 zcmWFvcJgLqU|;}YRv{StFim8_IJ@{3C{b5fOblu|1S@{3AR zQ%}q@V4L|@WnpaYknOdx56&Idlard4UXr0?Weif9kys35 zDp{nY8JneA7?_$G8>X5hrU8{@=B1=oC|Ol2m8BLHXXfVttNr#@5!x0v(x?Ujp)c zY;BOIo0|$S^uis(T+?z(6a>IQ%gE5M8^|bUWME@<+~xF%b3T_YTn)K-<4(+j2ai*pzj&4N?!(8D zuRng&{9#~dVdh}(;S%7RA}Ap`M?yhniM)pL8Z`s0ExHzldrTZGj#zuxpK%IsyW$z) adnX_v{StFim8_IJ@{3C{b5fOblu|1S@{3AR zQ%}q@V4L|@WnpaYknOdx56&Idlard4UXr0?Weif9kys35 zDp{nY8JneA7?_$G8>X5hrU8{@=B1=oC|Ol2m8BLHXXfVttmL?0dZwG)Fz-O4S_C9E=o-V`aL8wH?_C~=rBkifWyF`Hnz4l z7UnvG}*i3fX$Hu$6ZdJIOlWe!qt$QH}1qdc>Ai`TFBW%^wDa7G@6i9xegCDS{HBb0idGmdI-;uTeA5+M;V=xW~l7;)u0} j{TZhKw=13zzIOr=LY{ literal 0 HcmV?d00001 diff --git a/tests/fixtures/package_compat/refuse_structural.rsbank b/tests/fixtures/package_compat/refuse_structural.rsbank new file mode 100644 index 0000000000000000000000000000000000000000..5e751b34975ac8bac171236b4a1108d5f936638e GIT binary patch literal 1207 zcmc&z&ubGw6y91z5s?ZCJrpd{B2|)QH@})jL7T+bQd>$=tWYaXcPGt~-JNlElD5P^ z4<0-S3W|s*DD+TJP((ygp@)KkA|eV3Jrooa5m8X+p$Fe=+7kN@ILpG!H*eni-uK?i z=8}CI6h)E$Cir!=OVxOzqEyfrc8depAt-@T)L~e(rYL764+RjJWRweHqJ-WVjp&+O zfVzTP#6yXZ3UX@?iC9TPEZvJb}5$5reuWWTUI0s7%gWL&7QmzR@5%4G!Xd@}_^# zyFywufCR@z@kq&%4n|O=ySuvzgeu8ovWi9n2neS66iR4nC%7^S*ma4Wq9s?<<(tJq z(u@1z*5q5GpsAV`imh9#A}0wJ5Lenn!=bMF)d|rs6D3ZDh{GsE3u!W&RnT%s&Ke&m zIVe$QT0_&srGUo+s7Qc2%CtFBWJ<*Vl}uwGgH0)0lhC&X;ru8dicQMI24WS&2rj0_ zP$HdDJHyOakHK|KQg4BKK%cA?-U0-9Td`|?5I1TR1Wn;U{&FhhOlVqlv|5FFdp4C7fx%eczVZf21$NoqOs2g-QHHyZ9nL$VwF$4t249yt(A4R7PvfwmHo8{JYutV7>?Xm2j#cpeoI+^YCgV6szIW473Ah4dL zr}wm2=CcRS^ISerhCv)K{pfie{Gckoa3>&xX}xRE4yLtkS_3|aV^eD@IAq~Ci9_as zP-V3g2C`KL>M~f$q6`kWr4nP+;@wxM_e((^Po6DxDEHcs>aGLSI@`Xysna(Jh8MF{W;HAwFwP z?qcHa%LS?p)g2g%+Ac*=D^700V3;^_{DwpU0e#cF`6Yt1H_!Vbd>gW}4_0GQezejN zte~4rCT9}V<#~RFZy!kDyFNk2f-zAQTU1I^)4Emm&hG-Plb80#y`1kRfdz~WNB^Qf F@*hUkj6(nb literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3ab399a432a65b3efd737e41450bec688900edd4 GIT binary patch literal 23 acmWFvcJgLqU|;}YRv*o)B>OY literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..79b4f3bdd40d5db188265100388d556239086724 GIT binary patch literal 1206 zcmc&z-%C_M6uxFrM1(4?c9}uFB>=(7?dVnKS2n=R4oY zWK;chilWH93AV1rRgKpxN*Rq|uP}&Rf(+C}U4})di*i=O#iBO@kV^Vo+YN?N&C8%b)3L`)}foC7!8v&bYe z%BV!RZ&MFagprS3=Bz}vAuBR5i^$mU!Lohk;Hed){a_5WP7tvdo;Y!^uuCM3WnxqV z#-~MYk^yQ#b#aIvTr=1khkF_M0&|fiCiN_+MpvT=S)8?IiK$=+oYL$x1V{$OTfs%| zh-#HE5*(X_BSl*V7(wOE&dv$|DyLGZ3K|U~0NBl^kfEvV5X#JB&m&G3EqbD+-V7EJ zUNRuJO5Yj;P1Uq$+q$(%a*AL9d8JPx7VW5A?T`&KQRHNZxQs%!s3xmfHQFx8+T(*o z7a29KRckl#DAR3LyI^)$PZWJ<*#l}Ouw28U9%Mq%I!#06P^6o-_E1Hj6N5nSjV zLq?XvS%Yc-S+8elyI$JsXIuZ&hAZBlzYfyYkrfvL0rBo1lyE9PT`JIOmE`Lok+g z%JdUBOw=hGlm^AZ0?S#QzOeDqxhvbQUA(dL*43G&*_*dp?#|v1KfL!i{_N4*f%&=T z-7jCfKKk~}`=O5?7EUZKezLxN{pS4m`Ahs+S~|0=C}-=pY(BrWVf*DBjk~TlhMHz} zw>IC|qwRYTN$OAIhm!OAdk?-!rF-7>9UoXopBnl+oFDmql8yZ)llHRnpY#6($oHG0 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ac9ddd28189fbb8eb5b3f465871057acbef8f7bd GIT binary patch literal 1057 zcmZ8gO=uHA6y91z5s?ZCJroSnB2|)QH-Al6LGxp5sg}|dd#F8(yOUr|9FI*Vy_6uSf&D9>DuMI_HW9{4DV$Q);$AQm$8fmBl0eyrMNRh`8(_~Q^dBPz&9NY@JX;fOL;IgutAEtN{>B#F!5W>bsIA)}6J z#Pe-NAw?AV*yYZ8WE-*~6Z4pi4IeDq=MJ7+z=NdGJm2jR&h4ip#1F6In9jhxj(mYVQ6eUz7F46F@supi+OQ;4FoaHH{2T`gTl}khzqj-DGsR-2Y}TPC%9TTi;S42 z{*Mo^LP*ySAN9DJZU-;loS+O5VtNwdnU2l5RH5(!0-W2#3+aQ#2@vB@Q)H87s9rM^ zFL0>?XzsBZp_rOszd826y%dUUxFxTq5q!&Y`3kmaiDK?o7&HhhF~4<_Fg)O!SQEe! zh{A>X|`=y;-yRYr(-gm1z(zCd~zxVzD W?a<>`Mt_k!mRUJGbo6a*toRRd_EEtA literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c7a3a720e6fa7d7b666736500457c104d8832f5f GIT binary patch literal 907 zcmZ8g+iK%55Y4jef0WsmT}X_bd;OLRd!aOxq_7XA52`&$L~Kbb%V~*#3a%$d=0HNE@PYPJ0L1z!^Nqu73KwHkU6YPS$2Q%W;k8sTnc+M2qeHu1q|>o})0 z84dbL>=#hwtmKN)r-sz&;!b9}=vC}-r(m^lTS|MLx)l|EX`HWnLmtP2K`-IHSc4{2 z&Id{x+Ow^sR!GsNN|dH}k1UauGGRJCHZJ6{GKF}1S9%BV=G!Z~?>Mqt2q*XaAbeuF z5>x5gW_+)6RM$uRuw4qZ!M>r&3F}szX_cdzBt4bGnq_|5Y$Yk(<42%2?FQ#jnX(8Ayl>%N^vpMwQ^VWE`{^*#=+c^ zeHRIF6vy4+hjli2%EBS9=L~w?+v_z#Hep_xa-4gE2LT}VXCc0gMf6Z^Ulk#@SF2TxP+l7QNMo`{!PU3 X@%eZ}zpFy~j^JT@9AAHeR)O~qU_lnw literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c66f6dba61f1ff8d2b822b5776d6a12453a09422 GIT binary patch literal 10 RcmWFvcJgLqU|?Wm000V30W|;s literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..430300ebb7bc08fe2ec682b0422f90f8e45637ef GIT binary patch literal 18 VcmWFvcJgLqU|;}YRv4?c9}uFB>=(7?dVnKS2n=R4oY zWK;chilWH93AV1rRgKpxN*Rq|uP}&Rf(+C}U4})di*i=O#iBO@kV^Vo+YN?N&C8%b)3L`)}foC7!8v&bYe z%BV!RZ&MFagprS3=Bz}vAuBR5i^$mU!Lohk;Hed){a_5WP7tvdo;Y!^uuCM3WnxqV z#-~MYk^yQ#b#aIvTr=1khkF_M0&|fiCiN_+MpvT=S)8?IiK$=+oYL$x1V{$OTfs%| zh-#HE5*(X_BSl*V7(wOE&dv$|DyLGZ3K|U~0NBl^kfEvV5X#JB&m&G3EqbD+-V7EJ zUNRuJO5Yj;P1Uq$+q$(%a*AL9d8JPx7VW5A?T`&KQRHNZxQs%!s3xmfHQFx8+T(*o z7a29KRckl#DAR3LyI^)$PZWJ<*#l}Ouw28U9%Mq%I!#06P^6o-_E1Hj6N5nSjV zLq?XvS%Yc-S+8elyI$JsXIuZ&hAZBlzYfyYkrfvL0rBo1lyE9PT`JIOmE`Lok+g z%JdUBOw=hGlm^AZ0?S#QzOeDqxhvbQUA(dL*43G&*_*dp?#|v1KfL!i{_N4*f%&=T z-7jCfKKk~}`=O5?7EUZKezLxN{pS4m`Ahs+S~|0=C}-=pY(BrWVf*DBjk~TlhMHz} zw>IC|qwRYTN$OAIhm!OAdk?-!rF-7>9UoXopBnl+oFDmql8yZ)llHRnzw`eEWfhzc literal 0 HcmV?d00001 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..c262a06 --- /dev/null +++ b/tests/test_package_compat.cpp @@ -0,0 +1,334 @@ +// 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 "../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. +static std::vector load(const char* name, std::size_t expectedSize) { + std::vector bytes = packageFixtureBytes(name); + if (bytes.size() != expectedSize) { + std::printf("FAIL: fixture %s is %zu bytes, expected %zu (path: %s)\n", name, + bytes.size(), expectedSize, packageFixturePath(name).c_str()); + ++g_fail; + } + return bytes; +} + +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() { + const std::vector bytes = load(kV1File, kV1TotalSize); + 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() { + const std::vector bytes = load("additive_forward.rsbank", 1283); + + // 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); + CHECK(dec.header.formatVersion == kPackageFormatVersion + 1); + CHECK(dec.header.minReaderVersion == kPackageMinReaderVersion); + 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() { + const std::vector bytes = load("refuse_structural.rsbank", kV1TotalSize); + 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. + CHECK(dec.header.formatVersion == kPackageFormatVersion + 1); + CHECK(dec.header.minReaderVersion == kPackageFormatVersion + 1); + 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) { + const std::vector bytes = load(t.file, t.size); + 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); + } + } +} + +// --- 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) { + const std::vector bytes = load(h.file, h.size); + 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); + } +} + +int main() { + testV1FixtureDecodesToTheRecordTheShippingBuildWrote(); + testAdditiveForwardFixtureReadsWithEveryKnownFieldIntact(); + testRefuseFixtureIsTooNewAndStillNamesTheWriter(); + testEveryTruncationIsMalformedNeverTooNew(); + testEveryHostileNameIsRefusedAtDecode(); + + 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..3143a46 --- /dev/null +++ b/tests/test_package_round_trip.cpp @@ -0,0 +1,250 @@ +// 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 an +// empty read: an unreadable corpus would otherwise let every "must refuse" suite pass. +static bool stageFixture(const Scratch& scratch, const char* fixture) { + const std::vector bytes = packageFixtureBytes(fixture); + if (bytes.empty()) { + std::printf("FAIL: fixture %s read as 0 bytes (path: %s)\n", fixture, + 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")) 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")); + CHECK(sourcePayloads.size() == 1); + + // 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")) 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); + CHECK(landing.header.formatVersion == package::kPackageFormatVersion + 1); + CHECK(landing.header.minReaderVersion == package::kPackageFormatVersion + 1); + 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. +static const char* kTruncationFixtures[] = { + "trunc_magic.rsbank", "trunc_version_pair.rsbank", + "trunc_writer_semver.rsbank", "trunc_manifest_length.rsbank", + "trunc_manifest_body.rsbank", "trunc_payload_start.rsbank", + "trunc_payload_middle.rsbank", "trunc_one_short.rsbank", +}; + +static void testEveryTruncationRefusesTheImportAsMalformed() { + for (const char* fixture : kTruncationFixtures) { + Scratch scratch(std::string("trunc_") + fixture); + if (!stageFixture(scratch, fixture)) 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, + static_cast(landing.outcome)); + ++g_fail; + } + CHECK(landing.plan.entries.empty()); + CHECK(!fs::exists(utf8Path(scratch.bankDir()))); + CHECK(session.book() == before); + } +} + +static const char* kHostileFixtures[] = { + "hostile_name_dotdot.rsbank", "hostile_name_parent_slash.rsbank", + "hostile_name_parent_backslash.rsbank", "hostile_name_subdir_slash.rsbank", + "hostile_name_drive_absolute.rsbank", "hostile_name_unc_absolute.rsbank", + "hostile_path_dotdot_slash.rsbank", "hostile_path_dotdot_backslash.rsbank", + "hostile_path_rooted.rsbank", "hostile_path_drive_absolute.rsbank", + "hostile_path_unc_absolute.rsbank", +}; + +static void testEveryHostileNameIsRefusedBeforeThePlannerRuns() { + for (const char* fixture : kHostileFixtures) { + Scratch scratch(std::string("hostile_") + fixture); + if (!stageFixture(scratch, fixture)) 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, + 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; +}