// 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 #include #include 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; }