3fd3214ff8
Freeze *.rsbank as binary via .gitattributes; add a truncated additive_forward fixture proving the exact-size proof beats TooNew; enumerate the fixture dir to catch orphaned files; make fixture-size checks fatal instead of just logged; pin fixture version asserts as literals, not build-relative.
403 lines
18 KiB
C++
403 lines
18 KiB
C++
// 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;
|
|
}
|