Files
reasampler/tests/test_provenance.cpp
T

368 lines
16 KiB
C++

// 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/core/model/provenance.h"
#include "../src/core/model/bank_model.h" // recipe-through-Sample-JSON round-trip (M1 seam)
#include <cstdio>
#include <string>
#include <vector>
using namespace reasampler;
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)
// 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
// BankModel 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;
BankModel idx;
CHECK(idx.add(s) == AddResult::Added);
auto back = BankModel::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<BankFileRef> 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<BankFileRef> 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;
}