#include "provenance.h" #include #include #include // provenance implementation — pure, self-contained (no third-party lib, mirror of // bank_model's hand-rolled encoding discipline). // // ENCODING (the fingerprint string): a length-prefixed, field-ordered format so it // is unambiguous and forge-proof (a value containing the separator cannot shift // the parse). Grammar: // // "rsprov1" -- magic + version tag // then, in fixed order, each field as ':' // // Every field — including numbers — is emitted as its decimal / %.17g text then // length-prefixed, so the parser never has to guess a field boundary. A trailing // field is the track-GUID count followed by that many length-prefixed GUIDs, then // the folded fxChainIdentity. Numbers use the SAME %.17g the bank model uses so a // double round-trips bit-for-bit. Any deviation (wrong magic, short read, bad // number) -> parseFingerprint returns nullopt. // // The fxChainIdentity fold is itself length-prefixed per entry field, so it is // injection-proof on its own and can be embedded whole as one more length-prefixed // field of the fingerprint. namespace reasampler { bool CaptureRecipe::operator==(const CaptureRecipe& o) const { return scope == o.scope && sourceMode == o.sourceMode && startSeconds == o.startSeconds && endSeconds == o.endSeconds && tailMode == o.tailMode && tailMs == o.tailMs && sampleRate == o.sampleRate && channelCount == o.channelCount && trackGuids == o.trackGuids && fxChainIdentity == o.fxChainIdentity; } namespace { constexpr const char* kMagic = "rsprov1"; // Append one length-prefixed field: ':' void putField(std::string& out, const std::string& field) { out += std::to_string(field.size()); out += ':'; out += field; } std::string dblToStr(double v) { char buf[32]; std::snprintf(buf, sizeof(buf), "%.17g", v); return buf; } // Cursor over the encoded string. All reads are bounds-checked; any short read // fails the whole parse (ok_ latches false). class Cursor { public: explicit Cursor(const std::string& s) : s_(s) {} bool ok() const { return ok_; } bool atEnd() const { return pos_ >= s_.size(); } // Reads one length-prefixed field into `out`. Fails on a missing ':', an empty or // non-numeric length, a length that overflows SIZE_MAX, or a length that runs past // the end. Hardened form backported from the assignment_request / sample_usage // siblings (Q-W0 T2-01a): the digit count is capped at 20 (the decimal width of // SIZE_MAX on a 64-bit host) so a crafted 200-digit length cannot accumulate past // SIZE_MAX via repeated multiply, and the bounds check is subtraction-first so a // huge `len` cannot wrap `start + len` past the end test. bool field(std::string& out) { if (!ok_) return false; const std::size_t colon = s_.find(':', pos_); if (colon == std::string::npos) return fail(); if (colon == pos_) return fail(); // empty length token // Cap: SIZE_MAX fits in at most 20 decimal digits; a longer run is bogus. if (colon - pos_ > 20u) return fail(); std::size_t len = 0; for (std::size_t i = pos_; i < colon; ++i) { const char c = s_[i]; if (c < '0' || c > '9') return fail(); const std::size_t digit = static_cast(c - '0'); // Overflow guard: if len would exceed SIZE_MAX after multiply+add, fail. if (len > (std::numeric_limits::max() - digit) / 10u) return fail(); len = len * 10u + digit; } const std::size_t start = colon + 1; // Subtraction-first form: start + len cannot wrap on a huge len. if (start > s_.size() || len > s_.size() - start) return fail(); out.assign(s_, start, len); pos_ = start + len; return true; } bool fieldInt(int& out) { std::string f; if (!field(f)) return false; return toInt(f, out); } // A length-prefixed unsigned decimal (the GUID count). Hardened (Q-W0 T2-01a, the // sample_usage fieldCount pattern): fails on empty, non-digit, a digit run past 20 // (SIZE_MAX's decimal width), or an accumulate that would overflow SIZE_MAX. bool fieldSizeT(std::size_t& out) { std::string f; if (!field(f)) return false; if (f.empty() || f.size() > 20u) return fail(); std::size_t v = 0; for (const char c : f) { if (c < '0' || c > '9') return fail(); const std::size_t digit = static_cast(c - '0'); if (v > (std::numeric_limits::max() - digit) / 10u) return fail(); v = v * 10u + digit; } out = v; return true; } bool fieldDouble(double& out) { std::string f; if (!field(f)) return false; const char* b = f.c_str(); char* end = nullptr; double v = std::strtod(b, &end); if (end != b + f.size()) return fail(); out = v; return true; } // Consumes an exact literal at the cursor (the magic tag). Fails if absent. bool literal(const char* lit) { if (!ok_) return false; const std::string l(lit); if (s_.compare(pos_, l.size(), l) != 0) return fail(); pos_ += l.size(); return true; } private: bool fail() { ok_ = false; return false; } // TODO(Q-W1): strtol does not check errno/range here, so an out-of-range field narrows // silently to LONG_MAX (then truncates into `int`) instead of failing parse. Flagged for // the Q-W1 wire-codec collapse rather than fixed in place. static bool toInt(const std::string& f, int& out) { const char* b = f.c_str(); char* end = nullptr; long v = std::strtol(b, &end, 10); if (end != b + f.size() || f.empty()) return false; out = static_cast(v); return true; } const std::string& s_; std::size_t pos_ = 0; bool ok_ = true; }; } // namespace std::string fxChainIdentity(const std::vector& entries) { std::string out; // Count first, then each entry's three fields length-prefixed. Order is part of // identity (chain order matters), so we emit in the given vector order. putField(out, std::to_string(entries.size())); for (const FxIdentityEntry& e : entries) { putField(out, e.name); putField(out, e.guid); putField(out, e.enabled ? "1" : "0"); } return out; } std::string combineChainIdentities(const std::vector& perTrack) { std::string out; putField(out, std::to_string(perTrack.size())); for (const std::string& id : perTrack) putField(out, id); return out; } std::string buildFingerprint(const CaptureRecipe& r) { std::string out(kMagic); putField(out, std::to_string(static_cast(r.scope))); putField(out, std::to_string(r.sourceMode)); putField(out, dblToStr(r.startSeconds)); putField(out, dblToStr(r.endSeconds)); putField(out, std::to_string(r.tailMode)); putField(out, dblToStr(r.tailMs)); putField(out, std::to_string(r.sampleRate)); putField(out, std::to_string(r.channelCount)); putField(out, std::to_string(r.trackGuids.size())); for (const std::string& g : r.trackGuids) putField(out, g); putField(out, r.fxChainIdentity); return out; } std::optional parseFingerprint(const std::string& fingerprint) { Cursor c(fingerprint); if (!c.literal(kMagic)) return std::nullopt; CaptureRecipe r; int scopeInt = 0; if (!c.fieldInt(scopeInt)) return std::nullopt; if (scopeInt != static_cast(ProvenanceScope::Item) && scopeInt != static_cast(ProvenanceScope::Track)) return std::nullopt; r.scope = static_cast(scopeInt); if (!c.fieldInt(r.sourceMode)) return std::nullopt; if (!c.fieldDouble(r.startSeconds)) return std::nullopt; if (!c.fieldDouble(r.endSeconds)) return std::nullopt; if (!c.fieldInt(r.tailMode)) return std::nullopt; if (!c.fieldDouble(r.tailMs)) return std::nullopt; if (!c.fieldInt(r.sampleRate)) return std::nullopt; if (!c.fieldInt(r.channelCount)) return std::nullopt; std::size_t guidCount = 0; if (!c.fieldSizeT(guidCount)) return std::nullopt; // Q-W0 T2-01a (the sample_usage count-sanity pattern): each GUID field costs at least // 2 wire bytes ("0:"), so a count past size/2 is provably bogus — reject BEFORE the // reserve, so a corrupt/crafted persisted fingerprint can never drive reserve(huge) // into std::length_error / bad_alloc through the shell. if (guidCount > fingerprint.size() / 2u + 1u) return std::nullopt; r.trackGuids.reserve(guidCount); for (std::size_t i = 0; i < guidCount; ++i) { std::string g; if (!c.field(g)) return std::nullopt; r.trackGuids.push_back(std::move(g)); } if (!c.field(r.fxChainIdentity)) return std::nullopt; // Trailing garbage means the string was not produced by our writer -> reject, // so a corrupt/extended blob never silently drives a partial re-capture. if (!c.ok() || !c.atEnd()) return std::nullopt; return r; } std::optional detectParent( const std::vector& sourceItemFiles, const std::vector& bankFiles) { if (sourceItemFiles.empty()) return std::nullopt; std::optional parent; // the single bank sample all sources point at for (const std::string& src : sourceItemFiles) { // Resolve this source file against the bank by exact normalized path. const std::string* matchedId = nullptr; for (const BankFileRef& ref : bankFiles) { if (!ref.absolutePath.empty() && ref.absolutePath == src) { matchedId = &ref.sampleId; break; } } if (matchedId == nullptr) return std::nullopt; // a source item is NOT a bank file -> not a resample if (!parent) { parent = *matchedId; } else if (*parent != *matchedId) { return std::nullopt; // sources span >1 bank sample -> ambiguous, no parent } } return parent; } } // namespace reasampler