Merge Ε-W3-T1: the frozen package compatibility corpus and the harness over it

This commit is contained in:
2026-08-02 15:45:11 -04:00
34 changed files with 1019 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
*.rsbank binary
+1
View File
@@ -85,6 +85,7 @@ set(LICE_SRC
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
set(REASAMPLER_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src) set(REASAMPLER_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src)
set(REASAMPLER_TESTS_DIR ${CMAKE_CURRENT_SOURCE_DIR}/tests) 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) include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/reasampler_targets.cmake)
enable_testing() enable_testing()
+172
View File
@@ -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 <path>`, 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: "<bank name>".`
- 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 (`<bank name> 2`), and the box's
`(a bank named "<bank name>" 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 03 are `RSBK`; bytes 47 are `formatVersion`
little-endian; bytes **811** 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 <the version noted at the top> and needs package format 2 or newer.
This build (<the same version>) reads package format 1.
Nothing was imported. Install ReaSampler <the same version> 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 "<name>" 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.
+4
View File
@@ -93,6 +93,10 @@ both have landed.
file size in; header/manifest/layout out), and `requiredPrefixSize` (the file size in; header/manifest/layout out), and `requiredPrefixSize` (the
incremental-read seam for the shell). Framing rides `core/wire/bytes.h`. 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 ## Gotchas
- Enums nested inside the `BankModel` blob follow `bank_model`'s own rule — an - Enums nested inside the `BankModel` blob follow `bank_model`'s own rule — an
+6
View File
@@ -18,6 +18,12 @@ reasampler_pure_library(bank_package
# app_version: the tests pin the stamped writer semver against stampVersion(). # app_version: the tests pin the stamped writer semver against stampVersion().
reasampler_test(bank_package LINK bank_package app_version) 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 reasampler_pure_library(import_plan
SOURCES import_plan.cpp SOURCES import_plan.cpp
LINK PUBLIC package_manifest bank_book origin_ledger PRIVATE package_format capture_paths) LINK PUBLIC package_manifest bank_book origin_ledger PRIVATE package_format capture_paths)
+4
View File
@@ -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_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. - `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 ## Gotchas
- A crash mid-export strands the `.rsbanktmp` sibling. It is not a `.rsbank` (no - A crash mid-export strands the `.rsbanktmp` sibling. It is not a `.rsbank` (no
+10
View File
@@ -33,6 +33,16 @@ reasampler_pure_library(import_landing
LINK PUBLIC import_plan package_rollback bank_book PRIVATE bank_package wav_codec) 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) 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 # 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. # library so the TU stays compiled. reaper_plugin.h pulls SWELL in on non-Windows.
add_library(package_pickers STATIC package_pickers.cpp) add_library(package_pickers STATIC package_pickers.cpp)
+118
View File
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
RS
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+25
View File
@@ -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 <cstdint>
#include <fstream>
#include <iterator>
#include <string>
#include <vector>
inline std::string packageFixturePath(const std::string& name) {
return std::string(REASAMPLER_PACKAGE_FIXTURE_DIR) + "/" + name;
}
inline std::vector<std::uint8_t> packageFixtureBytes(const std::string& name) {
std::ifstream f(packageFixturePath(name), std::ios::binary);
return std::vector<std::uint8_t>(std::istreambuf_iterator<char>(f),
std::istreambuf_iterator<char>());
}
+402
View File
@@ -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 <algorithm>
#include <cstdint>
#include <cstdio>
#include <filesystem>
#include <string>
#include <vector>
#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<std::uint8_t>& 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<std::uint8_t>& bytes, const std::string& token) {
const std::string text(reinterpret_cast<const char*>(bytes.data()), bytes.size());
return text.find(token) != std::string::npos;
}
static std::uint32_t le32At(const std::vector<std::uint8_t>& bytes, std::size_t at) {
return static_cast<std::uint32_t>(bytes[at]) |
(static_cast<std::uint32_t>(bytes[at + 1]) << 8) |
(static_cast<std::uint32_t>(bytes[at + 2]) << 16) |
(static_cast<std::uint32_t>(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<std::uint8_t>& bytes) {
return classifyPackageVersion(le32At(bytes, 4), le32At(bytes, 8));
}
static void testV1FixtureDecodesToTheRecordTheShippingBuildWrote() {
std::vector<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> 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<const char*>(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<std::uint8_t> 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<std::uint8_t> head(bytes.begin(),
bytes.begin() + static_cast<std::ptrdiff_t>(*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<std::uint8_t> 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<std::uint8_t> 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<int>(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<std::string> 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;
}
+275
View File
@@ -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 <cstdint>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
#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<std::uint8_t> readBytes(const std::string& path) {
std::ifstream f(utf8Path(path), std::ios::binary);
return std::vector<std::uint8_t>(std::istreambuf_iterator<char>(f),
std::istreambuf_iterator<char>());
}
static void writeBytes(const std::string& path, const std::vector<std::uint8_t>& bytes) {
std::ofstream f(utf8Path(path), std::ios::binary | std::ios::trunc);
f.write(reinterpret_cast<const char*>(bytes.data()),
static_cast<std::streamsize>(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<std::uint8_t> 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<std::vector<std::uint8_t>> payloadsOf(const std::vector<std::uint8_t>& file) {
std::vector<std::vector<std::uint8_t>> 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<std::ptrdiff_t>(span.offset);
out.emplace_back(begin, begin + static_cast<std::ptrdiff_t>(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<std::vector<std::uint8_t>> 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<int>(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<int>(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;
}