// Standalone tests for the pure provenance core (M10) — no REAPER, no framework. // // Covers (brief-named test categories): // * fingerprint build -> encode -> parse round-trip (lossless). // * identical inputs -> equal fingerprints (byte-identical string). // * any single component change (scope, sourceMode, range, tail, rate, channels, // track GUIDs, FX-chain identity) -> a MISMATCH (different string / recipe). // * fxChainIdentity fold: order-sensitive, field-injection-proof, empty-stable. // * parse of malformed / wrong-version / truncated input -> nullopt (graceful). // * hardened wire cursor (Q-W0 T2-01a): hostile digit-run lengths, wrap-magnitude // lengths, and huge GUID counts -> nullopt with no overflow and no over-allocation. // * parent-detection decision: positive, negative, ambiguous, empty, and the // edge where a source file is not in the bank (missing-from-bank). // // The Sample-JSON round-trip of the fingerprint (leveraging M1's existing provenance // round-trip) is exercised in test_bank_model.cpp — see the fingerprint case there. #include "../src/provenance.h" #include "../src/bank_model.h" // recipe-through-Sample-JSON round-trip (M1 seam) #include #include #include using namespace reasampler; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) // A representative, fully-populated recipe used as the baseline for mutation tests. static CaptureRecipe baseRecipe() { CaptureRecipe r; r.scope = ProvenanceScope::Track; r.sourceMode = 1; // SelectedTracks r.startSeconds = 12.3456789012345; // non-trivial doubles to exercise %.17g r.endSeconds = 45.6789012345678; r.tailMode = 2; // Manual r.tailMs = 1234.5; r.sampleRate = 48000; r.channelCount = 2; r.trackGuids = {"{11111111-1111-1111-1111-111111111111}", "{22222222-2222-2222-2222-222222222222}"}; r.fxChainIdentity = fxChainIdentity({ {"ReaEQ", "{AAAA-1}", true}, {"ReaComp", "{BBBB-2}", false}, }); return r; } // --- fingerprint round-trip -------------------------------------------------- static void testFingerprintRoundTrip() { const CaptureRecipe r = baseRecipe(); const std::string fp = buildFingerprint(r); auto back = parseFingerprint(fp); CHECK(back.has_value()); CHECK(*back == r); // Re-encode is byte-stable. CHECK(buildFingerprint(*back) == fp); } // A recipe with empty GUID list + empty FX identity (a no-FX, no-track-guid capture) // still round-trips — the degenerate case must not corrupt the parse. static void testFingerprintRoundTripEmptyFields() { CaptureRecipe r; r.scope = ProvenanceScope::Item; r.trackGuids.clear(); r.fxChainIdentity = fxChainIdentity({}); const std::string fp = buildFingerprint(r); auto back = parseFingerprint(fp); CHECK(back.has_value()); CHECK(*back == r); CHECK(back->trackGuids.empty()); } // A GUID or FX-name carrying the field separators (':' and digits) must survive — // length-prefixing makes the encoding injection-proof. static void testFingerprintRoundTripHostileStrings() { CaptureRecipe r = baseRecipe(); r.trackGuids = {"7:not-a-real-guid", "12:another:evil:one"}; r.fxChainIdentity = fxChainIdentity({ {"FX with 3:colons: and stuff", "{gu:id}", true}, }); auto back = parseFingerprint(buildFingerprint(r)); CHECK(back.has_value()); CHECK(*back == r); } // --- identical inputs -> equal fingerprints ---------------------------------- static void testIdenticalInputsEqualFingerprints() { CHECK(buildFingerprint(baseRecipe()) == buildFingerprint(baseRecipe())); CHECK(baseRecipe() == baseRecipe()); } // --- any single component change -> mismatch --------------------------------- static void testSingleComponentChangesMismatch() { const std::string base = buildFingerprint(baseRecipe()); { auto r = baseRecipe(); r.scope = ProvenanceScope::Item; CHECK(buildFingerprint(r) != base); CHECK(r != baseRecipe()); } { auto r = baseRecipe(); r.sourceMode = 3; CHECK(buildFingerprint(r) != base); } { auto r = baseRecipe(); r.startSeconds += 0.0000001; CHECK(buildFingerprint(r) != base); } { auto r = baseRecipe(); r.endSeconds += 0.0000001; CHECK(buildFingerprint(r) != base); } { auto r = baseRecipe(); r.tailMode = 0; CHECK(buildFingerprint(r) != base); } { auto r = baseRecipe(); r.tailMs += 1.0; CHECK(buildFingerprint(r) != base); } { auto r = baseRecipe(); r.sampleRate = 44100; CHECK(buildFingerprint(r) != base); } { auto r = baseRecipe(); r.channelCount = 1; CHECK(buildFingerprint(r) != base); } { auto r = baseRecipe(); r.trackGuids.pop_back(); CHECK(buildFingerprint(r) != base); } { auto r = baseRecipe(); r.trackGuids[0] = "{99999999-9999-9999-9999-999999999999}"; CHECK(buildFingerprint(r) != base); } // The drift component: a changed FX chain identity mismatches (this is exactly // the "source changed since capture" signal re-capture reports). { auto r = baseRecipe(); r.fxChainIdentity = fxChainIdentity({{"ReaEQ", "{AAAA-1}", true}}); CHECK(buildFingerprint(r) != base); CHECK(r != baseRecipe()); } } // --- fxChainIdentity fold ---------------------------------------------------- static void testFxChainIdentityOrderSensitive() { const std::string a = fxChainIdentity({ {"ReaEQ", "{A}", true}, {"ReaComp", "{B}", true}}); const std::string b = fxChainIdentity({ {"ReaComp", "{B}", true}, {"ReaEQ", "{A}", true}}); CHECK(a != b); // chain order is part of identity } static void testFxChainIdentityFieldsMatter() { const std::string base = fxChainIdentity({{"ReaEQ", "{A}", true}}); CHECK(fxChainIdentity({{"ReaEQ2", "{A}", true}}) != base); // name CHECK(fxChainIdentity({{"ReaEQ", "{B}", true}}) != base); // guid (instance) CHECK(fxChainIdentity({{"ReaEQ", "{A}", false}}) != base); // enabled flag } static void testFxChainIdentityEmptyStable() { CHECK(fxChainIdentity({}) == fxChainIdentity({})); // Empty chain differs from a one-FX chain. CHECK(fxChainIdentity({}) != fxChainIdentity({{"X", "{Y}", true}})); } // Concatenation cannot forge equality: {"AB",""} vs {"A","B"} must differ despite // sharing raw bytes — length-prefixing keeps boundaries honest. static void testFxChainIdentityInjectionProof() { const std::string x = fxChainIdentity({{"AB", "", true}}); const std::string y = fxChainIdentity({{"A", "B", true}}); CHECK(x != y); } // --- combineChainIdentities (multi-track Track-scope fold) ------------------- static void testCombineChainIdentities() { const std::string idA = fxChainIdentity({{"ReaEQ", "{A}", true}}); const std::string idB = fxChainIdentity({{"ReaComp", "{B}", true}}); // Order of tracks matters, and distinct partitions cannot collide by concatenation. CHECK(combineChainIdentities({idA, idB}) != combineChainIdentities({idB, idA})); CHECK(combineChainIdentities({idA, ""}) != combineChainIdentities({"", idA})); // Empty vs single-track vs two-track are all distinct. CHECK(combineChainIdentities({}) != combineChainIdentities({idA})); CHECK(combineChainIdentities({idA}) != combineChainIdentities({idA, idB})); // Deterministic. CHECK(combineChainIdentities({idA, idB}) == combineChainIdentities({idA, idB})); } // --- malformed parse --------------------------------------------------------- static void testMalformedFingerprint() { CHECK(!parseFingerprint("").has_value()); // empty CHECK(!parseFingerprint("garbage").has_value()); // wrong magic CHECK(!parseFingerprint("rsprov0...").has_value()); // wrong version tag // Right magic, truncated body (no fields). CHECK(!parseFingerprint("rsprov1").has_value()); // A length prefix that runs past the end. CHECK(!parseFingerprint("rsprov199:short").has_value()); // A valid fingerprint with trailing garbage appended is rejected. const std::string good = buildFingerprint(baseRecipe()); CHECK(!parseFingerprint(good + "TRAILING").has_value()); // An out-of-range scope value is rejected. CHECK(!parseFingerprint("rsprov11:9" "1:0" "1:0" "1:0" "1:0" "1:0" "1:0" "1:0" "1:0" "0:").has_value()); } // --- Q-W0 T2-01a: hardened wire cursor (backported from assignment_request / // sample_usage) — corrupt or crafted persisted fingerprints must fail the parse // cleanly (nullopt), never wrap an integer, never throw, never over-allocate. ---- // Mirrors buildFingerprint's field order with benign values, except the GUID-count // field carries caller-supplied raw text — the attack surface under test. static void putF(std::string& out, const std::string& f) { out += std::to_string(f.size()); out += ':'; out += f; } static std::string forgedFingerprint(const std::string& guidCountText) { std::string out = "rsprov1"; putF(out, "0"); // scope = Item putF(out, "0"); // sourceMode putF(out, "0"); // startSeconds putF(out, "1"); // endSeconds putF(out, "0"); // tailMode putF(out, "0"); // tailMs putF(out, "48000"); // sampleRate putF(out, "2"); // channelCount putF(out, guidCountText); // GUID count (no GUID fields follow) putF(out, ""); // fxChainIdentity (empty) return out; } static void testHardenedCursorRejectsHostileLengths() { // A 200-digit length run: pre-hardening the accumulate wrapped std::size_t silently // (the digit cap + overflow guard now reject it outright). CHECK(!parseFingerprint("rsprov1" + std::string(200, '9') + ":x").has_value()); // A SIZE_MAX-magnitude length: the additive bounds check `start + len > size` could // itself wrap and pass; the subtraction-first form rejects. CHECK(!parseFingerprint("rsprov118446744073709551615:x").has_value()); // One past SIZE_MAX: the per-digit overflow guard fires during the accumulate. CHECK(!parseFingerprint("rsprov118446744073709551616:x").has_value()); } static void testHugeGuidCountRejectedBeforeReserve() { // A GUID count astronomically larger than the wire could hold must return nullopt // WITHOUT reaching trackGuids.reserve(count) — pre-fix this drove reserve(10^16) // into std::length_error / bad_alloc thrown through the shell. CHECK(!parseFingerprint(forgedFingerprint("9999999999999999")).has_value()); // A count merely past the wire-size sanity bound (each GUID field needs >= 2 wire // bytes) is provably bogus and rejected before the field loop. CHECK(!parseFingerprint(forgedFingerprint("1000")).has_value()); // A digit run past 20 fails the count parser's cap. CHECK(!parseFingerprint(forgedFingerprint(std::string(25, '9'))).has_value()); // Sanity (non-vacuous forgery): the honest zero-count version of the same forged // shape parses fine — the rejections above are the count's doing, not the shape's. CHECK(parseFingerprint(forgedFingerprint("0")).has_value()); } // --- recorded-recipe model round-trips through the Sample JSON ---------------- // The fingerprint rides in Provenance.fxChainSnapshot (one string), which M1's // BankIndex JSON already round-trips. Prove a real recipe survives that path intact. static void testRecipeThroughSampleJson() { const CaptureRecipe r = baseRecipe(); Sample s; s.id = "child-1"; s.relativePath = "reasampler_bank/child.wav"; s.contentHash = "hash-child"; Provenance prov; prov.parentSampleId = "sample-A"; prov.fxChainSnapshot = buildFingerprint(r); s.provenance = prov; BankIndex idx; CHECK(idx.add(s) == AddResult::Added); auto back = BankIndex::deserialize(idx.serialize()); CHECK(back.has_value()); const Sample* child = back ? back->query("child-1") : nullptr; CHECK(child != nullptr); CHECK(child && child->provenance.has_value()); CHECK(child && child->provenance->parentSampleId == "sample-A"); // The fingerprint string survived byte-for-byte AND re-parses to the recipe. if (child && child->provenance) { auto recovered = parseFingerprint(child->provenance->fxChainSnapshot); CHECK(recovered.has_value()); CHECK(recovered && *recovered == r); } } // --- parent detection -------------------------------------------------------- static std::vector bank() { return { {"sample-A", "c:/proj/reasampler_bank/a.wav"}, {"sample-B", "c:/proj/reasampler_bank/b.wav"}, }; } static void testDetectParentPositive() { // A single source item resolving to a bank file -> that sample is the parent. auto p = detectParent({"c:/proj/reasampler_bank/a.wav"}, bank()); CHECK(p.has_value()); CHECK(*p == "sample-A"); } static void testDetectParentMultipleSameParent() { // Two source items both from the SAME bank sample -> still that parent (a track // capture whose items all came from one bank file). auto p = detectParent( {"c:/proj/reasampler_bank/b.wav", "c:/proj/reasampler_bank/b.wav"}, bank()); CHECK(p.has_value()); CHECK(*p == "sample-B"); } static void testDetectParentNegativeNotInBank() { // A source file that is not a bank file -> no parent (a fresh, non-resample capture). auto p = detectParent({"c:/proj/audio/live-recording.wav"}, bank()); CHECK(!p.has_value()); } static void testDetectParentAmbiguous() { // Sources spanning two DIFFERENT bank samples -> ambiguous, record no parent // (honest: we will not guess which one is "the" parent). auto p = detectParent( {"c:/proj/reasampler_bank/a.wav", "c:/proj/reasampler_bank/b.wav"}, bank()); CHECK(!p.has_value()); } static void testDetectParentMixedBankAndNonBank() { // One source is a bank file, another is not -> not a clean resample -> no parent. auto p = detectParent( {"c:/proj/reasampler_bank/a.wav", "c:/proj/audio/other.wav"}, bank()); CHECK(!p.has_value()); } static void testDetectParentEmptySources() { CHECK(!detectParent({}, bank()).has_value()); } static void testDetectParentEmptyBank() { // Edge: the bank has no files (e.g. the sample's file record is missing / bank // empty) -> nothing matches -> no parent. CHECK(!detectParent({"c:/proj/reasampler_bank/a.wav"}, {}).has_value()); // A bank ref with an empty path never matches (guards against a null resolve). std::vector holey = {{"sample-X", ""}}; CHECK(!detectParent({""}, holey).has_value()); CHECK(!detectParent({"c:/proj/reasampler_bank/a.wav"}, holey).has_value()); } int main() { testFingerprintRoundTrip(); testFingerprintRoundTripEmptyFields(); testFingerprintRoundTripHostileStrings(); testIdenticalInputsEqualFingerprints(); testSingleComponentChangesMismatch(); testFxChainIdentityOrderSensitive(); testFxChainIdentityFieldsMatter(); testFxChainIdentityEmptyStable(); testFxChainIdentityInjectionProof(); testCombineChainIdentities(); testMalformedFingerprint(); testHardenedCursorRejectsHostileLengths(); testHugeGuidCountRejectedBeforeReserve(); testRecipeThroughSampleJson(); testDetectParentPositive(); testDetectParentMultipleSameParent(); testDetectParentNegativeNotInBank(); testDetectParentAmbiguous(); testDetectParentMixedBankAndNonBank(); testDetectParentEmptySources(); testDetectParentEmptyBank(); if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; }