Freeze the package compatibility corpus: real .rsbank bytes proving both ladder directions, every truncation site, and the round trip
This commit is contained in:
+101
@@ -0,0 +1,101 @@
|
||||
# The frozen `.rsbank` compatibility corpus
|
||||
|
||||
Real RSBK bytes, committed. `tests/test_package_compat.cpp` decodes them;
|
||||
`tests/test_package_round_trip.cpp` drives them through the import and export verbs.
|
||||
|
||||
## THE RULE: this corpus is append-only
|
||||
|
||||
**No file here is ever regenerated or edited.** When a future format version ships, add
|
||||
its fixture beside these and leave every existing one alone.
|
||||
|
||||
The reason is the whole point of the corpus. These bytes exist to catch a format change
|
||||
that quietly breaks a compatibility direction. A fixture regenerated by the build that
|
||||
broke it agrees with that build by construction and catches nothing — which is exactly
|
||||
the failure mode a version ladder exists to prevent. The same argument forbids a test
|
||||
that builds its own fixture at run time.
|
||||
|
||||
If a fixture stops decoding, the answer is never to re-cut the fixture. Either the format
|
||||
change was structural (bump `minReaderVersion`, add a new fixture, and leave the old one
|
||||
asserting the refusal) or it is a regression.
|
||||
|
||||
## Provenance
|
||||
|
||||
`v1_shipping.rsbank` was produced by running this repo's own export verb (`exportBank`)
|
||||
at version **1.4.0** over a one-sample bank, and copying the emitted file here verbatim.
|
||||
Every other fixture is derived from those bytes: the truncations are prefixes of them,
|
||||
and the synthetic packages reuse their manifest region under different version integers
|
||||
or a hand-written hostile manifest (the encoder refuses to write one, which is why those
|
||||
could not come from the verb).
|
||||
|
||||
Payloads are one 300-byte 16-bit mono WAV. The properties under test are structural —
|
||||
version integers, framing arithmetic, name validation — so a larger payload proves
|
||||
nothing extra and costs the repo bytes forever. Whole corpus: ~14 KB.
|
||||
|
||||
Adding a fixture for a future version means writing it with **that** version's shipping
|
||||
build, exactly as this one was, and recording the build's version here.
|
||||
|
||||
## What each fixture proves
|
||||
|
||||
### The three version fixtures
|
||||
|
||||
| File | `formatVersion` / `minReaderVersion` | Verdict | Proves |
|
||||
|---|---|---|---|
|
||||
| `v1_shipping.rsbank` | 1 / 1 | `Readable` | This build reads what it wrote: header, one manifest entry, the entry digest, and every `Sample` field with every optional present. Writer semver `1.4.0` is asserted **literally**, not against `stampVersion()` — comparing against the running build would let a version bump re-anchor the fixture silently. |
|
||||
| `additive_forward.rsbank` | 2 / 1 | `Readable` | An additive newer writer still reads. Carries three keys this build has never heard of — `exportTool` at the manifest root, `futureEntryKey` on the entry, `futureSampleKey` inside the nested `Sample` blob — and decodes to *exactly* the manifest `v1_shipping.rsbank` decodes to. Writer semver `1.9.0`. |
|
||||
| `refuse_structural.rsbank` | 2 / 2 | `TooNew` | A structural newer writer is refused whole. The header through the writer semver still reads, so the refusal can name all three facts (`1.9.0`, needs format 2, this build reads 1); no manifest, no layout, no partial success. Its body is `v1_shipping.rsbank`'s own manifest, which parses — so the refusal is a **decision**, not an inability. |
|
||||
|
||||
### Truncation — one file per distinct decode failure site
|
||||
|
||||
Each is a prefix of `v1_shipping.rsbank` (907-byte prefix + 300-byte payload = 1207
|
||||
bytes). All classify `Malformed`; none may classify `TooNew`, since "install a newer
|
||||
build" does not fix a partial download.
|
||||
|
||||
| File | Bytes | Site the cut lands in |
|
||||
|---|---|---|
|
||||
| `trunc_magic.rsbank` | 2 | Inside the 4-byte magic. |
|
||||
| `trunc_version_pair.rsbank` | 10 | Inside the frozen header's `minReaderVersion` u32. |
|
||||
| `trunc_writer_semver.rsbank` | 18 | Inside the frozen header's writer semver. |
|
||||
| `trunc_manifest_length.rsbank` | 23 | Inside the manifest-length u32. |
|
||||
| `trunc_manifest_body.rsbank` | 466 | Inside the manifest JSON. |
|
||||
| `trunc_payload_start.rsbank` | 907 | At the payload boundary. RSBK stores no layout section — the layout is derived from the manifest's entries — so this is the cut that exercises "manifest parses, layout computes, exact-size proof fails". |
|
||||
| `trunc_payload_middle.rsbank` | 1057 | Inside the first payload. |
|
||||
| `trunc_one_short.rsbank` | 1206 | One byte short of the total. |
|
||||
|
||||
### Hostile names — refused at decode, before any planner
|
||||
|
||||
The two naming fields carry different rules (`src/core/package/CLAUDE.md`), so each
|
||||
fixture keeps the other field spelled cleanly (`kick.wav`) and the refusal is
|
||||
attributable to the field under test.
|
||||
|
||||
Entry name — a bare file name, no path expression possible (`isValidEntryName`):
|
||||
|
||||
| File | Entry name |
|
||||
|---|---|
|
||||
| `hostile_name_dotdot.rsbank` | `..` |
|
||||
| `hostile_name_parent_slash.rsbank` | `../evil.wav` |
|
||||
| `hostile_name_parent_backslash.rsbank` | `..\evil.wav` |
|
||||
| `hostile_name_subdir_slash.rsbank` | `sub/evil.wav` |
|
||||
| `hostile_name_drive_absolute.rsbank` | `C:\Windows\evil.wav` |
|
||||
| `hostile_name_unc_absolute.rsbank` | `\\srv\share\evil.wav` |
|
||||
|
||||
Nested `Sample::relativePath` — a path by design, refused only for traversal and
|
||||
absolute forms (`isValidNestedSamplePath`):
|
||||
|
||||
| File | `relativePath` | Guard that fires first inside the codec |
|
||||
|---|---|---|
|
||||
| `hostile_path_dotdot_slash.rsbank` | `bank/../../evil.wav` | `isValidNestedSamplePath` |
|
||||
| `hostile_path_dotdot_backslash.rsbank` | `bank\..\evil.wav` | `isValidNestedSamplePath` |
|
||||
| `hostile_path_rooted.rsbank` | `/etc/evil.wav` | `BankModel::add`'s absolute-path rejection, which drops the record and leaves the nested blob holding zero samples |
|
||||
| `hostile_path_drive_absolute.rsbank` | `C:\Windows\evil.wav` | as above |
|
||||
| `hostile_path_unc_absolute.rsbank` | `\\srv\share\evil.wav` | as above |
|
||||
|
||||
Both guards are inside the codec and both refuse the whole package, so the security
|
||||
property is the same either way; the split is recorded because a change to either guard
|
||||
alone would still leave these fixtures passing.
|
||||
|
||||
### The round-trip anchor
|
||||
|
||||
`v1_shipping.rsbank` doubles as it: the file **is** a real export, so importing it and
|
||||
exporting the resulting bank closes export → import → export over frozen bytes. Entry
|
||||
names may legally change across the trip (the importer re-spells a bank file, the
|
||||
exporter mints its own transport name); the payload bytes may not.
|
||||
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.
Binary file not shown.
@@ -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.
@@ -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>());
|
||||
}
|
||||
@@ -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 <cstdint>
|
||||
#include <cstdio>
|
||||
#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.
|
||||
static std::vector<std::uint8_t> load(const char* name, std::size_t expectedSize) {
|
||||
std::vector<std::uint8_t> 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<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() {
|
||||
const std::vector<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> 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<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) {
|
||||
const std::vector<std::uint8_t> 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<std::uint8_t> head(bytes.begin(),
|
||||
bytes.begin() + static_cast<std::ptrdiff_t>(*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<std::uint8_t> 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<int>(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;
|
||||
}
|
||||
@@ -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 <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 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<std::uint8_t> 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<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")) 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"));
|
||||
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<int>(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<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;
|
||||
}
|
||||
Reference in New Issue
Block a user