Merge Ε-W1-T1: the pure RSBK package format, ladder, and manifest codec
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
// Standalone tests for reasampler::package::bank_package — no REAPER, no test
|
||||
// framework. Byte-level suites hand-roll RSBK images with wire::putLE rather
|
||||
// than calling encodePackage, so a layout regression in encode cannot hide from
|
||||
// decode (the two sides are pinned against each other AND against raw bytes).
|
||||
|
||||
#include "../src/core/package/bank_package.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../src/core/version/app_version.h"
|
||||
#include "../src/core/wire/bytes.h"
|
||||
|
||||
using namespace reasampler::package;
|
||||
using namespace reasampler::model;
|
||||
namespace wire = reasampler::wire;
|
||||
namespace version = reasampler::version;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// --- fixtures ----------------------------------------------------------------
|
||||
|
||||
// Every Sample field populated, every optional PRESENT.
|
||||
static Sample fullSample() {
|
||||
Sample s;
|
||||
s.id = "smp-full";
|
||||
s.displayName = "Kick (wet)";
|
||||
s.relativePath = "reasampler_bank/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 = 2;
|
||||
s.sampleRate = 48000;
|
||||
s.lengthSeconds = 2.25;
|
||||
s.lengthBeats = 4.5;
|
||||
s.captureTempo = 120.5;
|
||||
s.captureTimeSigNum = 7;
|
||||
s.captureTimeSigDenom = 8;
|
||||
s.key = "F#m";
|
||||
s.rootNote = 60;
|
||||
s.loop = LoopPoints{100, 4800};
|
||||
s.levels = {-0.3, -12.7, -14.0};
|
||||
s.clipped = true;
|
||||
s.tier = Tier::Archive;
|
||||
s.contentHash = "W0123456789abcdef";
|
||||
s.provenance = Provenance{"smp-parent", "fx-snapshot"};
|
||||
s.createdTimestamp = 1754000000;
|
||||
return s;
|
||||
}
|
||||
|
||||
// Every Sample optional ABSENT (key, rootNote, loop, provenance).
|
||||
static Sample bareSample() {
|
||||
Sample s;
|
||||
s.id = "smp-bare";
|
||||
s.displayName = "Snare";
|
||||
s.relativePath = "reasampler_bank/snare.wav";
|
||||
s.sourceMode = SourceMode::Realtime;
|
||||
s.contentHash = "Wfedcba9876543210";
|
||||
s.createdTimestamp = 1754000001;
|
||||
return s;
|
||||
}
|
||||
|
||||
static PackageManifest fixture(std::uint64_t len0, std::uint64_t len1) {
|
||||
PackageManifest m;
|
||||
m.bankDisplayName = "Drums \"live\"";
|
||||
m.exportTimestamp = 1754100000;
|
||||
m.entries.push_back({"kick.wav", len0, "1111222233334444", fullSample()});
|
||||
m.entries.push_back({"snare.wav", len1, "5555666677778888", bareSample()});
|
||||
m.slots.append("smp-full");
|
||||
m.slots.append("smp-bare");
|
||||
return m;
|
||||
}
|
||||
|
||||
// A hand-rolled RSBK image: frozen region + a raw tail (manifest framing or
|
||||
// deliberate garbage), independent of encodePackage.
|
||||
static std::vector<std::uint8_t> rawHeader(std::uint32_t fv, std::uint32_t mv,
|
||||
const std::string& semver) {
|
||||
std::vector<std::uint8_t> out;
|
||||
out.insert(out.end(), kPackageMagic, kPackageMagic + 4);
|
||||
wire::putLE(out, fv);
|
||||
wire::putLE(out, mv);
|
||||
wire::putLE(out, static_cast<std::uint32_t>(semver.size()));
|
||||
out.insert(out.end(), semver.begin(), semver.end());
|
||||
return out;
|
||||
}
|
||||
|
||||
static void appendManifest(std::vector<std::uint8_t>& out, const std::string& json) {
|
||||
wire::putLE(out, static_cast<std::uint32_t>(json.size()));
|
||||
out.insert(out.end(), json.begin(), json.end());
|
||||
}
|
||||
|
||||
// One-entry manifest JSON with `extra` spliced in as additional root content
|
||||
// ("" for none) and `sampleExtra` spliced into the nested Sample object — for
|
||||
// images a current writer would never emit.
|
||||
static std::string handManifest(const std::string& name, int length,
|
||||
const std::string& extra,
|
||||
const std::string& sampleExtra = "") {
|
||||
return std::string("{") + extra +
|
||||
"\"entries\":[{\"name\":\"" + name +
|
||||
"\",\"length\":" + std::to_string(length) + ",\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\"," + sampleExtra +
|
||||
"\"relativePath\":\"bank/a.wav\"}]}}]}";
|
||||
}
|
||||
|
||||
// --- encode / decode round trip ----------------------------------------------
|
||||
|
||||
static void testEncodeDecodeRoundTrip() {
|
||||
const PackageManifest m = fixture(96000, 48000);
|
||||
auto enc = encodePackage(m);
|
||||
CHECK(enc.has_value());
|
||||
|
||||
// Layout arithmetic: payloads start at the prefix end, in manifest order.
|
||||
CHECK(enc->layout.size() == 2);
|
||||
CHECK(enc->layout[0].name == "kick.wav");
|
||||
CHECK(enc->layout[0].offset == enc->prefix.size());
|
||||
CHECK(enc->layout[0].length == 96000);
|
||||
CHECK(enc->layout[1].offset == enc->prefix.size() + 96000);
|
||||
CHECK(enc->layout[1].length == 48000);
|
||||
CHECK(enc->totalSize == enc->prefix.size() + 96000 + 48000);
|
||||
|
||||
// decodePackage(encodePackage(x)) == x — payloads are never read by the
|
||||
// codec, so the prefix plus the true total size is the whole input.
|
||||
const DecodedPackage dec = decodePackage(enc->prefix, enc->totalSize);
|
||||
CHECK(dec.status == PackageReadability::Readable);
|
||||
CHECK(dec.manifest == m);
|
||||
CHECK(dec.layout == enc->layout);
|
||||
CHECK(dec.prefixSize == enc->prefix.size());
|
||||
|
||||
// The header stamps this build's ladder pair and its informational semver.
|
||||
CHECK(dec.header.formatVersion == kPackageFormatVersion);
|
||||
CHECK(dec.header.minReaderVersion == kPackageMinReaderVersion);
|
||||
CHECK(dec.header.writerVersion == version::stampVersion());
|
||||
}
|
||||
|
||||
static void testEncodeRefusesWhatManifestRefuses() {
|
||||
PackageManifest m = fixture(1, 1);
|
||||
m.entries[0].fileName = "../evil.wav";
|
||||
CHECK(!encodePackage(m).has_value());
|
||||
|
||||
// See src/core/package/CLAUDE.md for the shell seam that forces this.
|
||||
PackageManifest zeroLen = fixture(1, 1);
|
||||
zeroLen.entries[1].byteLength = 0;
|
||||
CHECK(!encodePackage(zeroLen).has_value());
|
||||
}
|
||||
|
||||
// --- truncation: every byte offset -------------------------------------------
|
||||
|
||||
static void testTruncationAtEveryByteOffsetIsMalformed() {
|
||||
const PackageManifest m = fixture(3, 5);
|
||||
auto enc = encodePackage(m);
|
||||
CHECK(enc.has_value());
|
||||
|
||||
// The complete on-disk image: prefix + both payloads.
|
||||
std::vector<std::uint8_t> file = enc->prefix;
|
||||
for (std::uint8_t b : {1, 2, 3, 10, 20, 30, 40, 50}) file.push_back(b);
|
||||
CHECK(file.size() == enc->totalSize);
|
||||
CHECK(decodePackage(file, file.size()).status == PackageReadability::Readable);
|
||||
|
||||
for (std::size_t cut = 0; cut < file.size(); ++cut) {
|
||||
const std::vector<std::uint8_t> truncated(file.begin(), file.begin() + cut);
|
||||
const DecodedPackage dec = decodePackage(truncated, truncated.size());
|
||||
if (dec.status != PackageReadability::Malformed) {
|
||||
std::printf("FAIL: truncation at %zu not Malformed\n", cut);
|
||||
++g_fail;
|
||||
break;
|
||||
}
|
||||
// A refused decode must not half-succeed at any cut either.
|
||||
CHECK(dec.manifest.entries.empty());
|
||||
CHECK(dec.layout.empty());
|
||||
}
|
||||
|
||||
// One byte extra (trailing garbage) is as Malformed as one byte missing.
|
||||
std::vector<std::uint8_t> extended = file;
|
||||
extended.push_back(0);
|
||||
CHECK(decodePackage(extended, extended.size()).status == PackageReadability::Malformed);
|
||||
// And a file size that disagrees with the same bytes.
|
||||
CHECK(decodePackage(file, file.size() + 1).status == PackageReadability::Malformed);
|
||||
CHECK(decodePackage(file, file.size() - 1).status == PackageReadability::Malformed);
|
||||
}
|
||||
|
||||
// --- header defaults ---------------------------------------------------------
|
||||
|
||||
// The 0/0 defaults are not the current ladder pair, so a header that never
|
||||
// parsed cannot be mistaken for a plausible 1/1. They mean "unset", NOT "the
|
||||
// decode failed": a decode that got past the header reports the real pair
|
||||
// alongside its Malformed verdict.
|
||||
static void testHeaderDefaultsMeanUnparsed() {
|
||||
CHECK(PackageHeader{}.formatVersion == 0);
|
||||
CHECK(PackageHeader{}.minReaderVersion == 0);
|
||||
CHECK(PackageHeader{}.writerVersion.empty());
|
||||
|
||||
// Failed before the header: bad magic, and a semver truncated mid-string.
|
||||
std::vector<std::uint8_t> badMagic = rawHeader(1, 1, "1.0.0");
|
||||
appendManifest(badMagic, "{}");
|
||||
badMagic[0] = 'Z';
|
||||
const DecodedPackage magic = decodePackage(badMagic, badMagic.size());
|
||||
CHECK(magic.status == PackageReadability::Malformed);
|
||||
CHECK(magic.header == PackageHeader{});
|
||||
|
||||
std::vector<std::uint8_t> cutSemver = rawHeader(1, 1, "1.0.0");
|
||||
cutSemver.resize(18);
|
||||
CHECK(decodePackage(cutSemver, cutSemver.size()).header == PackageHeader{});
|
||||
|
||||
// Failed after it: a same-version package with a corrupt manifest is
|
||||
// Malformed, and its header is fully populated.
|
||||
std::vector<std::uint8_t> corrupt =
|
||||
rawHeader(kPackageFormatVersion, kPackageMinReaderVersion, "1.0.0");
|
||||
appendManifest(corrupt, "not json");
|
||||
const DecodedPackage late = decodePackage(corrupt, corrupt.size());
|
||||
CHECK(late.status == PackageReadability::Malformed);
|
||||
CHECK(late.header.formatVersion == kPackageFormatVersion);
|
||||
CHECK(late.header.minReaderVersion == kPackageMinReaderVersion);
|
||||
CHECK(late.header.writerVersion == "1.0.0");
|
||||
}
|
||||
|
||||
// --- version ladder: TooNew refuses whole ------------------------------------
|
||||
|
||||
static void testTooNewProducesNoManifest() {
|
||||
// A future structural format: only the frozen region is trustworthy, so the
|
||||
// tail is deliberate garbage that would crash a parser that kept reading.
|
||||
std::vector<std::uint8_t> bytes = rawHeader(9, 9, "9.9.9");
|
||||
for (int i = 0; i < 32; ++i) bytes.push_back(0xFF);
|
||||
|
||||
const DecodedPackage dec = decodePackage(bytes, bytes.size());
|
||||
CHECK(dec.status == PackageReadability::TooNew);
|
||||
// The refusal message's three facts survive...
|
||||
CHECK(dec.header.formatVersion == 9);
|
||||
CHECK(dec.header.minReaderVersion == 9);
|
||||
CHECK(dec.header.writerVersion == "9.9.9");
|
||||
// ...and nothing else is produced: no manifest, no layout, no half-success.
|
||||
CHECK(dec.manifest.entries.empty());
|
||||
CHECK(dec.manifest == PackageManifest{});
|
||||
CHECK(dec.layout.empty());
|
||||
CHECK(dec.prefixSize == 0);
|
||||
|
||||
// Boundary: minReader exactly one past this build.
|
||||
auto boundary = rawHeader(kPackageFormatVersion + 1, kPackageFormatVersion + 1, "2.0.0");
|
||||
CHECK(decodePackage(boundary, boundary.size()).status == PackageReadability::TooNew);
|
||||
|
||||
// A TooNew header truncated inside the frozen region cannot name the
|
||||
// writer, so it is Malformed, not an unactionable refusal.
|
||||
std::vector<std::uint8_t> cut = rawHeader(9, 9, "9.9.9");
|
||||
cut.resize(18); // mid-semver
|
||||
CHECK(decodePackage(cut, cut.size()).status == PackageReadability::Malformed);
|
||||
}
|
||||
|
||||
// An additively-tagged package (fv > ours, minReader still within reach) whose
|
||||
// manifest fails to parse: the header classifies Readable, so decode reads
|
||||
// into the manifest and fails there. That failure must still report TooNew —
|
||||
// the header is valid and already carries the writer's semver — not the
|
||||
// unactionable Malformed a genuinely corrupt header produces.
|
||||
static void testAdditiveUnparseableManifestIsTooNew() {
|
||||
std::vector<std::uint8_t> bytes = rawHeader(kPackageFormatVersion + 1, kPackageMinReaderVersion, "1.9.0");
|
||||
appendManifest(bytes, "not json");
|
||||
const DecodedPackage dec = decodePackage(bytes, bytes.size());
|
||||
CHECK(dec.status == PackageReadability::TooNew);
|
||||
CHECK(dec.header.formatVersion == kPackageFormatVersion + 1);
|
||||
CHECK(dec.header.minReaderVersion == kPackageMinReaderVersion);
|
||||
CHECK(dec.header.writerVersion == "1.9.0");
|
||||
CHECK(dec.manifest.entries.empty());
|
||||
CHECK(dec.layout.empty());
|
||||
|
||||
// Same-version unparseable manifest stays Malformed: nothing "newer"
|
||||
// excuses it, so this is not a blanket "unparseable == TooNew" rule.
|
||||
std::vector<std::uint8_t> sameVersion =
|
||||
rawHeader(kPackageFormatVersion, kPackageMinReaderVersion, "1.0.0");
|
||||
appendManifest(sameVersion, "not json");
|
||||
CHECK(decodePackage(sameVersion, sameVersion.size()).status == PackageReadability::Malformed);
|
||||
}
|
||||
|
||||
// --- version ladder: additive forward compatibility --------------------------
|
||||
|
||||
// The reason two integers exist: a NEWER formatVersion whose minReaderVersion
|
||||
// still reaches back to this build must read, with its unknown keys skipped —
|
||||
// at the manifest level AND inside the nested Sample blob, the actual
|
||||
// motivating case for the two-integer ladder (see this directory's CLAUDE.md).
|
||||
static void testNewerAdditiveFormatReads() {
|
||||
const std::string manifest = handManifest(
|
||||
"a.wav", 4,
|
||||
"\"instrumentState\":{\"future\":[1,2,3]},\"anotherNewKey\":\"x\",",
|
||||
"\"someFutureSampleField\":42,");
|
||||
std::vector<std::uint8_t> bytes =
|
||||
rawHeader(kPackageFormatVersion + 1, kPackageMinReaderVersion, "1.9.0");
|
||||
appendManifest(bytes, manifest);
|
||||
const std::uint64_t total = bytes.size() + 4; // the one entry's payload
|
||||
|
||||
const DecodedPackage dec = decodePackage(bytes, total);
|
||||
CHECK(dec.status == PackageReadability::Readable);
|
||||
CHECK(dec.header.formatVersion == kPackageFormatVersion + 1);
|
||||
CHECK(dec.header.writerVersion == "1.9.0");
|
||||
CHECK(dec.manifest.entries.size() == 1);
|
||||
CHECK(dec.manifest.entries[0].fileName == "a.wav");
|
||||
CHECK(dec.manifest.entries[0].sample.id == "s1");
|
||||
CHECK(dec.layout.size() == 1);
|
||||
CHECK(dec.layout[0].offset == bytes.size());
|
||||
CHECK(dec.layout[0].length == 4);
|
||||
}
|
||||
|
||||
// --- hostile headers ---------------------------------------------------------
|
||||
|
||||
static void testHostileHeadersAreMalformed() {
|
||||
// Wrong magic.
|
||||
std::vector<std::uint8_t> bad = rawHeader(1, 1, "1.0.0");
|
||||
appendManifest(bad, "{}");
|
||||
bad[0] = 'Z';
|
||||
CHECK(decodePackage(bad, bad.size()).status == PackageReadability::Malformed);
|
||||
|
||||
// Incoherent version pairs (the classify rules, proven through the framing).
|
||||
for (auto [fv, mv] : {std::pair<std::uint32_t, std::uint32_t>{0, 0}, {0, 1},
|
||||
{1, 0}, {1, 2}}) {
|
||||
std::vector<std::uint8_t> b = rawHeader(fv, mv, "1.0.0");
|
||||
appendManifest(b, "{}");
|
||||
CHECK(decodePackage(b, b.size()).status == PackageReadability::Malformed);
|
||||
}
|
||||
|
||||
// A forged semver length over the cap must not read past the buffer.
|
||||
std::vector<std::uint8_t> overSemver;
|
||||
overSemver.insert(overSemver.end(), kPackageMagic, kPackageMagic + 4);
|
||||
wire::putLE(overSemver, std::uint32_t{1});
|
||||
wire::putLE(overSemver, std::uint32_t{1});
|
||||
wire::putLE(overSemver, kMaxWriterVersionBytes + 1);
|
||||
CHECK(decodePackage(overSemver, overSemver.size()).status ==
|
||||
PackageReadability::Malformed);
|
||||
|
||||
// A forged manifest length over the cap: refused before any allocation.
|
||||
std::vector<std::uint8_t> overManifest = rawHeader(1, 1, "1.0.0");
|
||||
wire::putLE(overManifest, kMaxManifestBytes + 1);
|
||||
CHECK(decodePackage(overManifest, overManifest.size()).status ==
|
||||
PackageReadability::Malformed);
|
||||
|
||||
// A manifest whose entry name expresses a path: rejected on decode even
|
||||
// though no current encoder would write it.
|
||||
std::vector<std::uint8_t> traversal = rawHeader(1, 1, "1.0.0");
|
||||
appendManifest(traversal, handManifest("../evil.wav", 4, ""));
|
||||
CHECK(decodePackage(traversal, traversal.size() + 4).status ==
|
||||
PackageReadability::Malformed);
|
||||
}
|
||||
|
||||
// --- requiredPrefixSize ------------------------------------------------------
|
||||
|
||||
static void testRequiredPrefixSizeGrowsToTheFullPrefix() {
|
||||
auto enc = encodePackage(fixture(3, 5));
|
||||
CHECK(enc.has_value());
|
||||
const auto& prefix = enc->prefix;
|
||||
|
||||
// Empty: the fixed region first.
|
||||
CHECK(requiredPrefixSize({}) == std::uint64_t{16});
|
||||
|
||||
// With the fixed region: asks through the semver + manifest-length field.
|
||||
const std::string& semver = version::stampVersion();
|
||||
std::vector<std::uint8_t> first16(prefix.begin(), prefix.begin() + 16);
|
||||
CHECK(requiredPrefixSize(first16) == std::uint64_t{16 + semver.size() + 4});
|
||||
|
||||
// With that much: the full prefix size. And the answer is a fixpoint.
|
||||
std::vector<std::uint8_t> upToManifestLen(
|
||||
prefix.begin(), prefix.begin() + 20 + static_cast<long>(semver.size()));
|
||||
CHECK(requiredPrefixSize(upToManifestLen) == std::uint64_t{prefix.size()});
|
||||
CHECK(requiredPrefixSize(prefix) == std::uint64_t{prefix.size()});
|
||||
|
||||
// The returned count is exactly enough for decodePackage.
|
||||
CHECK(decodePackage(prefix, enc->totalSize).status == PackageReadability::Readable);
|
||||
}
|
||||
|
||||
static void testRequiredPrefixSizeRefusals() {
|
||||
// Bad magic: stop reading.
|
||||
std::vector<std::uint8_t> bad(16, 0);
|
||||
CHECK(!requiredPrefixSize(bad).has_value());
|
||||
|
||||
// Incoherent versions: stop reading.
|
||||
std::vector<std::uint8_t> zeroed = rawHeader(0, 0, "1.0.0");
|
||||
CHECK(!requiredPrefixSize(zeroed).has_value());
|
||||
|
||||
// TooNew: asks only through the frozen region — the manifest-length field
|
||||
// belongs to the newer format and is never trusted.
|
||||
std::vector<std::uint8_t> tooNew = rawHeader(9, 9, "9.9.9");
|
||||
for (int i = 0; i < 8; ++i) tooNew.push_back(0xFF); // garbage where M would be
|
||||
CHECK(requiredPrefixSize(tooNew) == std::uint64_t{16 + 5});
|
||||
|
||||
// Oversize length fields: stop reading.
|
||||
std::vector<std::uint8_t> overSemver;
|
||||
overSemver.insert(overSemver.end(), kPackageMagic, kPackageMagic + 4);
|
||||
wire::putLE(overSemver, std::uint32_t{1});
|
||||
wire::putLE(overSemver, std::uint32_t{1});
|
||||
wire::putLE(overSemver, kMaxWriterVersionBytes + 1);
|
||||
CHECK(!requiredPrefixSize(overSemver).has_value());
|
||||
|
||||
std::vector<std::uint8_t> overManifest = rawHeader(1, 1, "1.0.0");
|
||||
wire::putLE(overManifest, kMaxManifestBytes + 1);
|
||||
CHECK(!requiredPrefixSize(overManifest).has_value());
|
||||
}
|
||||
|
||||
int main() {
|
||||
testEncodeDecodeRoundTrip();
|
||||
testEncodeRefusesWhatManifestRefuses();
|
||||
testTruncationAtEveryByteOffsetIsMalformed();
|
||||
testHeaderDefaultsMeanUnparsed();
|
||||
testTooNewProducesNoManifest();
|
||||
testAdditiveUnparseableManifestIsTooNew();
|
||||
testNewerAdditiveFormatReads();
|
||||
testHostileHeadersAreMalformed();
|
||||
testRequiredPrefixSizeRefusals();
|
||||
testRequiredPrefixSizeGrowsToTheFullPrefix();
|
||||
|
||||
if (g_fail == 0) {
|
||||
std::printf("bank_package_tests: all passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("bank_package_tests: %d failure(s)\n", g_fail);
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// Standalone tests for reasampler::package's format contract — no REAPER, no
|
||||
// test framework. Pins the version-ladder classification (both integers, every
|
||||
// branch) and the three naming rules: the entry-name rule, the ASCII-folding
|
||||
// name equivalence, and the nested-path traversal guard.
|
||||
|
||||
#include "../src/core/package/package_format.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
using namespace reasampler::package;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// --- classifyPackageVersion --------------------------------------------------
|
||||
|
||||
static void testClassifyReadable() {
|
||||
CHECK(classifyPackageVersion(kPackageFormatVersion, kPackageMinReaderVersion) ==
|
||||
PackageReadability::Readable);
|
||||
// The additive-forward-compat direction: a newer writer whose minReader
|
||||
// still reaches back to this build reads fine.
|
||||
CHECK(classifyPackageVersion(kPackageFormatVersion + 5, kPackageMinReaderVersion) ==
|
||||
PackageReadability::Readable);
|
||||
// Boundary: minReader exactly this build's format version.
|
||||
CHECK(classifyPackageVersion(kPackageFormatVersion + 1, kPackageFormatVersion) ==
|
||||
PackageReadability::Readable);
|
||||
}
|
||||
|
||||
static void testClassifyTooNew() {
|
||||
// Boundary: one past this build's format version refuses.
|
||||
CHECK(classifyPackageVersion(kPackageFormatVersion + 1, kPackageFormatVersion + 1) ==
|
||||
PackageReadability::TooNew);
|
||||
CHECK(classifyPackageVersion(99, 42) == PackageReadability::TooNew);
|
||||
}
|
||||
|
||||
static void testClassifyMalformed() {
|
||||
// Zero versions: no honest writer emits them (the ladder starts at 1).
|
||||
CHECK(classifyPackageVersion(0, 0) == PackageReadability::Malformed);
|
||||
CHECK(classifyPackageVersion(1, 0) == PackageReadability::Malformed);
|
||||
CHECK(classifyPackageVersion(0, 1) == PackageReadability::Malformed);
|
||||
// A writer cannot require a reader newer than what it wrote.
|
||||
CHECK(classifyPackageVersion(1, 2) == PackageReadability::Malformed);
|
||||
// Incoherence outranks TooNew: even with both above this build, minReader >
|
||||
// formatVersion is Malformed, not a refusal message.
|
||||
CHECK(classifyPackageVersion(5, 9) == PackageReadability::Malformed);
|
||||
}
|
||||
|
||||
// --- isValidEntryName --------------------------------------------------------
|
||||
|
||||
static void testEntryNameAccepts() {
|
||||
CHECK(isValidEntryName("kick.wav"));
|
||||
CHECK(isValidEntryName("Snare 03 (wet).wav"));
|
||||
CHECK(isValidEntryName("no-extension"));
|
||||
CHECK(isValidEntryName(".hidden")); // a leading dot is a bare name
|
||||
CHECK(isValidEntryName("a.b.c.wav")); // single dots are fine
|
||||
CHECK(isValidEntryName(std::string(kMaxEntryNameBytes, 'x'))); // at the cap
|
||||
// Legal names containing a ".." substring that is not the whole name: a
|
||||
// name can only ever be one path component (separators are banned), so
|
||||
// ".." as a component is the only expressible traversal.
|
||||
CHECK(isValidEntryName("take..final.wav"));
|
||||
CHECK(isValidEntryName("loop...wav"));
|
||||
CHECK(isValidEntryName("a..b"));
|
||||
}
|
||||
|
||||
static void testEntryNameRejectsSeparatorsAndDots() {
|
||||
CHECK(!isValidEntryName(""));
|
||||
CHECK(!isValidEntryName("."));
|
||||
CHECK(!isValidEntryName(".."));
|
||||
CHECK(!isValidEntryName("..\\evil.wav"));
|
||||
CHECK(!isValidEntryName("../evil.wav"));
|
||||
CHECK(!isValidEntryName("dir/inner.wav"));
|
||||
CHECK(!isValidEntryName("dir\\inner.wav"));
|
||||
CHECK(!isValidEntryName("/rooted.wav"));
|
||||
CHECK(!isValidEntryName("\\rooted.wav"));
|
||||
// Embedded NUL: every plausible filesystem call (ofstream, fopen,
|
||||
// CreateFileW off .c_str()) truncates at it, so two names differing only
|
||||
// after the NUL would collide on one file.
|
||||
CHECK(!isValidEntryName(std::string("a\0b.wav", 7)));
|
||||
// Other control bytes (newline here) are equally hostile to logs/UI.
|
||||
CHECK(!isValidEntryName("a\nb.wav"));
|
||||
}
|
||||
|
||||
static void testEntryNameRejectsAbsolutePrefixes() {
|
||||
CHECK(!isValidEntryName("C:\\abs.wav"));
|
||||
CHECK(!isValidEntryName("C:/abs.wav"));
|
||||
CHECK(!isValidEntryName("c:relative-to-drive.wav")); // ':' bans drive forms
|
||||
CHECK(!isValidEntryName("\\\\server\\share.wav")); // UNC
|
||||
CHECK(!isValidEntryName(std::string(kMaxEntryNameBytes + 1, 'x'))); // over cap
|
||||
}
|
||||
|
||||
static void testEntryNameRejectsWindowsHostileNames() {
|
||||
// Reserved characters.
|
||||
CHECK(!isValidEntryName("a*b.wav"));
|
||||
CHECK(!isValidEntryName("a?b.wav"));
|
||||
CHECK(!isValidEntryName("a|b.wav"));
|
||||
CHECK(!isValidEntryName("a<b>.wav"));
|
||||
CHECK(!isValidEntryName("\"q\".wav"));
|
||||
// Trailing dot or space (silently stripped at creation on Windows).
|
||||
CHECK(!isValidEntryName("trailing "));
|
||||
CHECK(!isValidEntryName("trailing."));
|
||||
CHECK(!isValidEntryName(" "));
|
||||
CHECK(!isValidEntryName(" "));
|
||||
// DOS device names, case-insensitive, with and without an extension.
|
||||
CHECK(!isValidEntryName("NUL"));
|
||||
CHECK(!isValidEntryName("CON"));
|
||||
CHECK(!isValidEntryName("con.wav"));
|
||||
CHECK(!isValidEntryName("PRN"));
|
||||
CHECK(!isValidEntryName("AUX"));
|
||||
CHECK(!isValidEntryName("COM1"));
|
||||
CHECK(!isValidEntryName("com1.txt"));
|
||||
CHECK(!isValidEntryName("LPT1"));
|
||||
// The superscript device forms (COM¹ COM² COM³ LPT¹ LPT² LPT³ in UTF-8):
|
||||
// Windows reads those as digits in a device name, so "COM².wav" is COM2.
|
||||
CHECK(!isValidEntryName("COM\xC2\xB9.wav"));
|
||||
CHECK(!isValidEntryName("com\xC2\xB2"));
|
||||
CHECK(!isValidEntryName("COM\xC2\xB3.wav"));
|
||||
CHECK(!isValidEntryName("LPT\xC2\xB9"));
|
||||
CHECK(!isValidEntryName("lpt\xC2\xB2.txt"));
|
||||
CHECK(!isValidEntryName("LPT\xC2\xB3.wav"));
|
||||
// Not a device name: a real filename that merely starts with one, and the
|
||||
// zero forms, which Windows does not reserve.
|
||||
CHECK(isValidEntryName("console.wav"));
|
||||
CHECK(isValidEntryName("COM0.wav"));
|
||||
CHECK(isValidEntryName("LPT0"));
|
||||
// Nor does a superscript past 3 name a device.
|
||||
CHECK(isValidEntryName("COM\xE2\x81\xB4.wav")); // U+2074 SUPERSCRIPT FOUR
|
||||
}
|
||||
|
||||
// --- isValidEntryName: UTF-8 well-formedness ---------------------------------
|
||||
|
||||
static void testEntryNameAcceptsWellFormedUtf8() {
|
||||
CHECK(isValidEntryName("caf\xC3\xA9.wav")); // 2-byte: é
|
||||
CHECK(isValidEntryName("\xE2\x99\xAA.wav")); // 3-byte: ♪
|
||||
CHECK(isValidEntryName("\xF0\x9F\x8E\xB5.wav")); // 4-byte: 🎵
|
||||
CHECK(isValidEntryName("\xEF\xBB\xBF.wav")); // U+FEFF, ugly but well-formed
|
||||
CHECK(isValidEntryName("\xF4\x8F\xBF\xBF.wav")); // U+10FFFF, the last code point
|
||||
}
|
||||
|
||||
static void testEntryNameRejectsIllFormedUtf8() {
|
||||
// Two names differing ONLY in their invalid bytes: a host converting to
|
||||
// UTF-16 substitutes U+FFFD for both by default, collapsing them onto one
|
||||
// file — the duplicate-name collision the manifest cannot otherwise see.
|
||||
CHECK(!isValidEntryName("a\x80.wav")); // stray continuation byte
|
||||
CHECK(!isValidEntryName("a\x81.wav"));
|
||||
// Structural: truncated sequences (a lead byte the name ends inside).
|
||||
CHECK(!isValidEntryName("a\xC3"));
|
||||
CHECK(!isValidEntryName("a\xE2\x99"));
|
||||
CHECK(!isValidEntryName("a\xF0\x9F\x8E"));
|
||||
// A lead byte followed by a non-continuation.
|
||||
CHECK(!isValidEntryName("a\xC3\x41.wav"));
|
||||
// Overlong encodings: an alternate spelling of an ASCII byte we ban.
|
||||
CHECK(!isValidEntryName("a\xC0\xAF.wav")); // overlong '/'
|
||||
CHECK(!isValidEntryName("a\xC0\x80.wav")); // overlong NUL
|
||||
CHECK(!isValidEntryName("a\xE0\x80\xAF.wav")); // overlong '/', 3-byte
|
||||
CHECK(!isValidEntryName("a\xF0\x80\x80\xAF.wav")); // overlong '/', 4-byte
|
||||
// Surrogate halves: no code point, and unrepresentable in UTF-16.
|
||||
CHECK(!isValidEntryName("a\xED\xA0\x80.wav")); // U+D800
|
||||
CHECK(!isValidEntryName("a\xED\xBF\xBF.wav")); // U+DFFF
|
||||
// Past U+10FFFF, and the 5/6-byte leads that never encode anything.
|
||||
CHECK(!isValidEntryName("a\xF4\x90\x80\x80.wav")); // U+110000
|
||||
CHECK(!isValidEntryName("a\xF5\x80\x80\x80.wav"));
|
||||
CHECK(!isValidEntryName("a\xFC\x80\x80\x80\x80\x80.wav"));
|
||||
CHECK(!isValidEntryName("a\xFF.wav"));
|
||||
}
|
||||
|
||||
// --- sameEntryName -----------------------------------------------------------
|
||||
|
||||
static void testSameEntryNameFoldsAsciiCase() {
|
||||
// A bank authored on a case-sensitive filesystem produces this pair
|
||||
// honestly; Windows and default APFS would extract both onto one file.
|
||||
CHECK(sameEntryName("Kick.wav", "kick.wav"));
|
||||
CHECK(sameEntryName("KICK.WAV", "kick.wav"));
|
||||
CHECK(sameEntryName("kick.wav", "kick.wav"));
|
||||
CHECK(!sameEntryName("kick.wav", "snare.wav"));
|
||||
CHECK(!sameEntryName("kick.wav", "kick.wave")); // length alone decides
|
||||
CHECK(!sameEntryName("", "a"));
|
||||
CHECK(sameEntryName("", ""));
|
||||
// ASCII only: "é" vs "É" are two names here (the NFC/NFD limitation this
|
||||
// shares — see this directory's CLAUDE.md).
|
||||
CHECK(!sameEntryName("caf\xC3\xA9.wav", "caf\xC3\x89.wav"));
|
||||
// Only the letters fold — the bytes flanking the ASCII range must not.
|
||||
CHECK(!sameEntryName("a[b", "a{b")); // 0x5B vs 0x7B, 'Z'+1 and 'z'+1
|
||||
CHECK(!sameEntryName("a@b", "a`b")); // 0x40 vs 0x60, 'A'-1 and 'a'-1
|
||||
}
|
||||
|
||||
// --- isValidNestedSamplePath -------------------------------------------------
|
||||
|
||||
static void testNestedSamplePathAcceptsRelative() {
|
||||
CHECK(isValidNestedSamplePath("a.wav"));
|
||||
CHECK(isValidNestedSamplePath("reasampler_bank/kick.wav"));
|
||||
CHECK(isValidNestedSamplePath("reasampler_bank\\kick.wav"));
|
||||
CHECK(isValidNestedSamplePath("deep/dir/tree/a.wav"));
|
||||
// A ".." that is not a whole component is an ordinary name.
|
||||
CHECK(isValidNestedSamplePath("take..final/a.wav"));
|
||||
CHECK(isValidNestedSamplePath("bank/..hidden"));
|
||||
CHECK(isValidNestedSamplePath("a..b"));
|
||||
}
|
||||
|
||||
static void testNestedSamplePathRejectsTraversalAndAbsolute() {
|
||||
// BankModel::add catches only the absolute forms, so traversal reaches the
|
||||
// format unless this rule stops it.
|
||||
CHECK(!isValidNestedSamplePath(".."));
|
||||
CHECK(!isValidNestedSamplePath("../evil.wav"));
|
||||
CHECK(!isValidNestedSamplePath("..\\evil.wav"));
|
||||
CHECK(!isValidNestedSamplePath("bank/../../evil.wav"));
|
||||
CHECK(!isValidNestedSamplePath("bank\\..\\evil.wav"));
|
||||
CHECK(!isValidNestedSamplePath("bank/.."));
|
||||
CHECK(!isValidNestedSamplePath("bank/../"));
|
||||
// Everything util::isAbsolutePath already catches.
|
||||
CHECK(!isValidNestedSamplePath("/rooted.wav"));
|
||||
CHECK(!isValidNestedSamplePath("\\rooted.wav"));
|
||||
CHECK(!isValidNestedSamplePath("C:/abs.wav"));
|
||||
CHECK(!isValidNestedSamplePath("C:\\abs.wav"));
|
||||
CHECK(!isValidNestedSamplePath("c:relative-to-drive.wav"));
|
||||
CHECK(!isValidNestedSamplePath("\\\\server\\share.wav"));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testClassifyReadable();
|
||||
testClassifyTooNew();
|
||||
testClassifyMalformed();
|
||||
testEntryNameAccepts();
|
||||
testEntryNameRejectsSeparatorsAndDots();
|
||||
testEntryNameRejectsAbsolutePrefixes();
|
||||
testEntryNameRejectsWindowsHostileNames();
|
||||
testEntryNameAcceptsWellFormedUtf8();
|
||||
testEntryNameRejectsIllFormedUtf8();
|
||||
testSameEntryNameFoldsAsciiCase();
|
||||
testNestedSamplePathAcceptsRelative();
|
||||
testNestedSamplePathRejectsTraversalAndAbsolute();
|
||||
|
||||
if (g_fail == 0) {
|
||||
std::printf("package_format_tests: all passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("package_format_tests: %d failure(s)\n", g_fail);
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
// Standalone tests for reasampler::package's manifest codec — no REAPER, no
|
||||
// test framework. The round-trip fixture exercises every manifest field and
|
||||
// every Sample optional in both present and absent states; the rejection suite
|
||||
// pins the naming, case-folding and traversal rules on encode AND decode.
|
||||
|
||||
#include "../src/core/package/package_manifest.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
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)
|
||||
|
||||
// --- fixtures ----------------------------------------------------------------
|
||||
|
||||
// Every Sample field populated, every optional PRESENT.
|
||||
static Sample fullSample() {
|
||||
Sample s;
|
||||
s.id = "smp-full";
|
||||
s.displayName = "Kick (wet)";
|
||||
s.relativePath = "reasampler_bank/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 = 2;
|
||||
s.sampleRate = 48000;
|
||||
s.lengthSeconds = 2.25;
|
||||
s.lengthBeats = 4.5;
|
||||
s.captureTempo = 120.5;
|
||||
s.captureTimeSigNum = 7;
|
||||
s.captureTimeSigDenom = 8;
|
||||
s.key = "F#m";
|
||||
s.rootNote = 60;
|
||||
s.loop = LoopPoints{100, 4800};
|
||||
s.levels = {-0.3, -12.7, -14.0};
|
||||
s.clipped = true;
|
||||
s.tier = Tier::Archive;
|
||||
s.contentHash = "W0123456789abcdef";
|
||||
s.provenance = Provenance{"smp-parent", "fx-snapshot"};
|
||||
s.createdTimestamp = 1754000000;
|
||||
return s;
|
||||
}
|
||||
|
||||
// Every Sample optional ABSENT (key, rootNote, loop, provenance).
|
||||
static Sample bareSample() {
|
||||
Sample s;
|
||||
s.id = "smp-bare";
|
||||
s.displayName = "Snare";
|
||||
s.relativePath = "reasampler_bank/snare.wav";
|
||||
s.sourceMode = SourceMode::Realtime;
|
||||
s.contentHash = "Wfedcba9876543210";
|
||||
s.createdTimestamp = 1754000001;
|
||||
return s;
|
||||
}
|
||||
|
||||
static PackageManifest fixture() {
|
||||
PackageManifest m;
|
||||
m.bankDisplayName = "Drums \"live\""; // escaping exercised
|
||||
m.exportTimestamp = 1754100000;
|
||||
m.entries.push_back({"kick.wav", 96000, "1111222233334444", fullSample()});
|
||||
m.entries.push_back({"snare.wav", 48000, "5555666677778888", bareSample()});
|
||||
m.slots.append("smp-full");
|
||||
m.slots.append("smp-bare");
|
||||
m.slots.remove("smp-full"); // leaves a gap: slots round-trip must keep it
|
||||
return m;
|
||||
}
|
||||
|
||||
// --- round trip --------------------------------------------------------------
|
||||
|
||||
static void testRoundTripEveryField() {
|
||||
const PackageManifest m = fixture();
|
||||
auto json = serializeManifest(m);
|
||||
CHECK(json.has_value());
|
||||
auto back = deserializeManifest(*json);
|
||||
CHECK(back.has_value());
|
||||
CHECK(*back == m);
|
||||
// Spot-check both optional states survived (== above proves it; these name
|
||||
// the claim so a failure reads directly).
|
||||
CHECK(back->entries[0].sample.loop.has_value());
|
||||
CHECK(back->entries[0].sample.provenance.has_value());
|
||||
CHECK(!back->entries[1].sample.key.has_value());
|
||||
CHECK(!back->entries[1].sample.rootNote.has_value());
|
||||
CHECK(back->slots.idAt(0).empty()); // the slot gap survived
|
||||
CHECK(back->slots.slotOf("smp-bare") == 1);
|
||||
}
|
||||
|
||||
static void testEmptyManifestRoundTrips() {
|
||||
PackageManifest m;
|
||||
auto json = serializeManifest(m);
|
||||
CHECK(json.has_value());
|
||||
auto back = deserializeManifest(*json);
|
||||
CHECK(back.has_value());
|
||||
CHECK(*back == m);
|
||||
}
|
||||
|
||||
// --- forward compatibility ---------------------------------------------------
|
||||
|
||||
static void testUnknownKeysSkippedAtEveryLevel() {
|
||||
// A future additive manifest: unknown keys at the root, inside an entry,
|
||||
// and inside the nested index blob itself.
|
||||
const std::string json =
|
||||
"{\"bankName\":\"B\",\"exported\":7,"
|
||||
"\"instrumentState\":{\"nested\":[1,2,{\"x\":\"y\"}]},"
|
||||
"\"entries\":[{\"name\":\"a.wav\",\"length\":10,\"hash\":\"h\","
|
||||
"\"futureField\":\"ignored\","
|
||||
"\"index\":{\"version\":1,\"futureIndexField\":42,\"samples\":[{\"id\":\"s1\","
|
||||
"\"relativePath\":\"bank/a.wav\"}]}}],"
|
||||
"\"slots\":[],\"trailingUnknown\":null}";
|
||||
auto m = deserializeManifest(json);
|
||||
CHECK(m.has_value());
|
||||
CHECK(m->bankDisplayName == "B");
|
||||
CHECK(m->exportTimestamp == 7);
|
||||
CHECK(m->entries.size() == 1);
|
||||
CHECK(m->entries[0].fileName == "a.wav");
|
||||
CHECK(m->entries[0].byteLength == 10);
|
||||
CHECK(m->entries[0].sample.id == "s1");
|
||||
}
|
||||
|
||||
// --- rejection: entry names, both directions ---------------------------------
|
||||
|
||||
static void testEncodeRejectsBadEntryName() {
|
||||
for (const char* bad : {"..\\evil.wav", "../evil.wav", "dir/a.wav", "C:\\a.wav", "", ".."}) {
|
||||
PackageManifest m = fixture();
|
||||
m.entries[0].fileName = bad;
|
||||
CHECK(!serializeManifest(m).has_value());
|
||||
}
|
||||
}
|
||||
|
||||
// One entry, `name` spliced in as raw manifest text so a hostile spelling
|
||||
// (escapes included) is expressible — a package is not limited to what encode
|
||||
// emits.
|
||||
static std::string oneEntryJson(const std::string& name,
|
||||
const std::string& relativePath = "bank/a.wav") {
|
||||
return "{\"entries\":[{\"name\":\"" + name +
|
||||
"\",\"length\":1,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\","
|
||||
"\"relativePath\":\"" + relativePath + "\"}]}}]}";
|
||||
}
|
||||
|
||||
static void testDecodeRejectsBadEntryName() {
|
||||
for (const char* bad : {"..\\\\evil.wav", "../evil.wav", "dir/a.wav", "C:\\\\a.wav", ".."})
|
||||
CHECK(!deserializeManifest(oneEntryJson(bad)).has_value());
|
||||
|
||||
// The rest of the rule set through decode — the direction that matters,
|
||||
// since a package can arrive from anywhere.
|
||||
CHECK(!deserializeManifest(oneEntryJson("CON.wav")).has_value()); // DOS device
|
||||
CHECK(!deserializeManifest(oneEntryJson("COM\xC2\xB2.wav")).has_value()); // COM²
|
||||
CHECK(!deserializeManifest(oneEntryJson("a.wav ")).has_value()); // trailing space
|
||||
CHECK(!deserializeManifest(oneEntryJson("a.wav.")).has_value()); // trailing dot
|
||||
CHECK(!deserializeManifest(oneEntryJson("a*b.wav")).has_value()); // reserved char
|
||||
// A NUL smuggled in as a JSON escape: the manifest text is legal, the
|
||||
// decoded name is not.
|
||||
CHECK(!deserializeManifest(oneEntryJson("a\\u0000b.wav")).has_value());
|
||||
// Ill-formed UTF-8 as raw bytes.
|
||||
CHECK(!deserializeManifest(oneEntryJson("a\xC3.wav")).has_value());
|
||||
// A lone surrogate never reaches the name rule — the JSON reader refuses
|
||||
// the unpaired \uD800 first. Pinned so that refusal cannot silently become
|
||||
// "decoded to U+FFFD and accepted".
|
||||
CHECK(!deserializeManifest(oneEntryJson("a\\ud800b.wav")).has_value());
|
||||
}
|
||||
|
||||
static void testDecodeRejectsTraversalInNestedPath() {
|
||||
// The one field in the format that CAN express a path. BankModel::add
|
||||
// catches the absolute forms only, so ".." arrives unless the package layer
|
||||
// refuses it.
|
||||
CHECK(!deserializeManifest(oneEntryJson("a.wav", "../../evil.wav")).has_value());
|
||||
CHECK(!deserializeManifest(oneEntryJson("a.wav", "bank/../evil.wav")).has_value());
|
||||
CHECK(!deserializeManifest(oneEntryJson("a.wav", "..")).has_value());
|
||||
// A ".." that is not a whole component still reads.
|
||||
CHECK(deserializeManifest(oneEntryJson("a.wav", "take..final/a.wav")).has_value());
|
||||
}
|
||||
|
||||
static void testEncodeRejectsTraversalInNestedPath() {
|
||||
PackageManifest m = fixture();
|
||||
m.entries[0].sample.relativePath = "../../evil.wav";
|
||||
CHECK(!serializeManifest(m).has_value());
|
||||
|
||||
PackageManifest m2 = fixture();
|
||||
m2.entries[0].sample.relativePath = "bank/../evil.wav";
|
||||
CHECK(!serializeManifest(m2).has_value());
|
||||
}
|
||||
|
||||
static void testDuplicateEntryNamesRejectedBothWays() {
|
||||
PackageManifest m = fixture();
|
||||
m.entries[1].fileName = m.entries[0].fileName;
|
||||
CHECK(!serializeManifest(m).has_value());
|
||||
|
||||
// Case-folded: two names one case-insensitive filesystem extracts onto a
|
||||
// single file are one name here too, in both directions.
|
||||
PackageManifest folded = fixture();
|
||||
folded.entries[1].fileName = "KICK.WAV"; // entries[0] is "kick.wav"
|
||||
CHECK(!serializeManifest(folded).has_value());
|
||||
|
||||
// Decode side, from a hand-built duplicate (a hostile package is not
|
||||
// limited to what encode emits).
|
||||
const std::string dup =
|
||||
"{\"entries\":["
|
||||
"{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}},"
|
||||
"{\"name\":\"A.WAV\",\"length\":2,\"hash\":\"i\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s2\",\"relativePath\":\"q\"}]}}]}";
|
||||
CHECK(!deserializeManifest(dup).has_value());
|
||||
|
||||
// Two names that differ outside the ASCII letters are still two names.
|
||||
const std::string distinct =
|
||||
"{\"entries\":["
|
||||
"{\"name\":\"a1.wav\",\"length\":1,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}},"
|
||||
"{\"name\":\"a2.wav\",\"length\":2,\"hash\":\"i\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s2\",\"relativePath\":\"q\"}]}}]}";
|
||||
CHECK(deserializeManifest(distinct).has_value());
|
||||
}
|
||||
|
||||
static void testRepeatedRootKeyRejected() {
|
||||
// A repeated "entries" must not accumulate into two arrays' worth of
|
||||
// entries; the other three assign rather than append, but "which duplicate
|
||||
// keys are legal" is one format answer, not four.
|
||||
const std::string entriesTwice =
|
||||
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}}],"
|
||||
"\"entries\":[{\"name\":\"b.wav\",\"length\":1,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s2\",\"relativePath\":\"q\"}]}}]}";
|
||||
CHECK(!deserializeManifest(entriesTwice).has_value());
|
||||
|
||||
CHECK(!deserializeManifest("{\"bankName\":\"A\",\"bankName\":\"B\"}").has_value());
|
||||
CHECK(!deserializeManifest("{\"exported\":1,\"exported\":2}").has_value());
|
||||
CHECK(!deserializeManifest("{\"slots\":[],\"slots\":[]}").has_value());
|
||||
// Unknown keys stay repeatable: they are skipped, and a future format must
|
||||
// be free to add them.
|
||||
CHECK(deserializeManifest("{\"future\":1,\"future\":2}").has_value());
|
||||
|
||||
// The same answer one level down, so a hostile manifest cannot make two
|
||||
// readers disagree about which spelling of an entry field is the real one.
|
||||
CHECK(!deserializeManifest(
|
||||
"{\"entries\":[{\"name\":\"a.wav\",\"name\":\"b.wav\",\"length\":1,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}}]}")
|
||||
.has_value());
|
||||
CHECK(!deserializeManifest(
|
||||
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"length\":2,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}}]}")
|
||||
.has_value());
|
||||
CHECK(!deserializeManifest(
|
||||
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\",\"hash\":\"i\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}}]}")
|
||||
.has_value());
|
||||
CHECK(!deserializeManifest(
|
||||
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]},"
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s2\",\"relativePath\":\"q\"}]}}]}")
|
||||
.has_value());
|
||||
}
|
||||
|
||||
// --- rejection: structural ---------------------------------------------------
|
||||
|
||||
// See src/core/package/CLAUDE.md for the shell seam that forces this.
|
||||
static void testEncodeRejectsZeroLengthEntry() {
|
||||
PackageManifest m = fixture();
|
||||
m.entries[0].byteLength = 0;
|
||||
CHECK(!serializeManifest(m).has_value());
|
||||
}
|
||||
|
||||
// The asymmetry is deliberate: refusing a zero-length entry is an obligation on
|
||||
// what this layer WRITES, not a claim about what a package may declare. Pinned
|
||||
// so it is not "fixed" into a decode-side rejection.
|
||||
static void testDecodeAcceptsZeroLengthEntry() {
|
||||
auto m = deserializeManifest(
|
||||
"{\"entries\":[{\"name\":\"a.wav\",\"length\":0,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}}]}");
|
||||
CHECK(m.has_value());
|
||||
CHECK(m->entries.size() == 1);
|
||||
CHECK(m->entries[0].byteLength == 0);
|
||||
}
|
||||
|
||||
static void testEncodeRejectsUnrepresentableSample() {
|
||||
PackageManifest m = fixture();
|
||||
m.entries[0].sample.id.clear(); // BankModel::add rejects an empty id
|
||||
CHECK(!serializeManifest(m).has_value());
|
||||
|
||||
PackageManifest m2 = fixture();
|
||||
m2.entries[0].sample.relativePath = "C:/abs/kick.wav"; // and an absolute path
|
||||
CHECK(!serializeManifest(m2).has_value());
|
||||
}
|
||||
|
||||
static void testDecodeRejectsMalformedShapes() {
|
||||
CHECK(!deserializeManifest("").has_value());
|
||||
CHECK(!deserializeManifest("not json").has_value());
|
||||
CHECK(!deserializeManifest("[]").has_value());
|
||||
CHECK(!deserializeManifest("{\"entries\":[{}]}").has_value()); // entry missing fields
|
||||
// Missing one required entry field apiece.
|
||||
CHECK(!deserializeManifest(
|
||||
"{\"entries\":[{\"length\":1,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\",\"relativePath\":\"p\"}]}}]}")
|
||||
.has_value());
|
||||
CHECK(!deserializeManifest(
|
||||
"{\"entries\":[{\"name\":\"a.wav\",\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\",\"relativePath\":\"p\"}]}}]}")
|
||||
.has_value());
|
||||
CHECK(!deserializeManifest(
|
||||
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,"
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\",\"relativePath\":\"p\"}]}}]}")
|
||||
.has_value());
|
||||
CHECK(!deserializeManifest(
|
||||
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\"}]}").has_value());
|
||||
// Negative length.
|
||||
CHECK(!deserializeManifest(
|
||||
"{\"entries\":[{\"name\":\"a.wav\",\"length\":-1,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\",\"relativePath\":\"p\"}]}}]}")
|
||||
.has_value());
|
||||
// A nested index that is not exactly one sample (zero and two).
|
||||
CHECK(!deserializeManifest(
|
||||
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[]}}]}").has_value());
|
||||
CHECK(!deserializeManifest(
|
||||
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"},"
|
||||
"{\"id\":\"s2\",\"relativePath\":\"q\"}]}}]}").has_value());
|
||||
// A nested sample BankModel::add drops (absolute path) fails the entry —
|
||||
// the silent drop must not half-parse into an empty index.
|
||||
CHECK(!deserializeManifest(
|
||||
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\","
|
||||
"\"relativePath\":\"C:/abs.wav\"}]}}]}").has_value());
|
||||
// An out-of-range enum inside the nested Sample blob fails the entry: the
|
||||
// manifest defines no enum of its own, and bank_model's codec REJECTS an
|
||||
// unknown sourceMode/tier rather than degrading — so growing one of those
|
||||
// vocabularies is a minReaderVersion bump, not an additive change (see this
|
||||
// directory's CLAUDE.md).
|
||||
CHECK(!deserializeManifest(
|
||||
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\",\"relativePath\":\"p\","
|
||||
"\"sourceMode\":99}]}}]}").has_value());
|
||||
// Trailing garbage after the root object.
|
||||
auto json = serializeManifest(fixture());
|
||||
CHECK(json.has_value());
|
||||
CHECK(!deserializeManifest(*json + "x").has_value());
|
||||
// Trailing garbage after the EMPTY-object shortcut specifically: this path
|
||||
// returned early before reaching the eof check, so "{}JUNK" parsed valid.
|
||||
CHECK(!deserializeManifest("{}JUNK").has_value());
|
||||
CHECK(deserializeManifest("{}").has_value());
|
||||
// Truncation at a few JSON-level offsets (byte-level truncation of the whole
|
||||
// package is bank_package's suite).
|
||||
CHECK(!deserializeManifest(json->substr(0, json->size() / 2)).has_value());
|
||||
CHECK(!deserializeManifest(json->substr(0, 1)).has_value());
|
||||
}
|
||||
|
||||
int main() {
|
||||
testRoundTripEveryField();
|
||||
testEmptyManifestRoundTrips();
|
||||
testUnknownKeysSkippedAtEveryLevel();
|
||||
testEncodeRejectsBadEntryName();
|
||||
testDecodeRejectsBadEntryName();
|
||||
testDecodeRejectsTraversalInNestedPath();
|
||||
testEncodeRejectsTraversalInNestedPath();
|
||||
testDuplicateEntryNamesRejectedBothWays();
|
||||
testRepeatedRootKeyRejected();
|
||||
testEncodeRejectsZeroLengthEntry();
|
||||
testDecodeAcceptsZeroLengthEntry();
|
||||
testEncodeRejectsUnrepresentableSample();
|
||||
testDecodeRejectsMalformedShapes();
|
||||
|
||||
if (g_fail == 0) {
|
||||
std::printf("package_manifest_tests: all passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("package_manifest_tests: %d failure(s)\n", g_fail);
|
||||
return 1;
|
||||
}
|
||||
Reference in New Issue
Block a user