268 lines
10 KiB
C++
268 lines
10 KiB
C++
#include "provenance.h"
|
|
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <limits>
|
|
|
|
// 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 <len>':'<bytes>
|
|
//
|
|
// 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: <decimal-len> ':' <bytes>
|
|
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<std::size_t>(c - '0');
|
|
// Overflow guard: if len would exceed SIZE_MAX after multiply+add, fail.
|
|
if (len > (std::numeric_limits<std::size_t>::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<std::size_t>(c - '0');
|
|
if (v > (std::numeric_limits<std::size_t>::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<int>(v);
|
|
return true;
|
|
}
|
|
|
|
const std::string& s_;
|
|
std::size_t pos_ = 0;
|
|
bool ok_ = true;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
std::string fxChainIdentity(const std::vector<FxIdentityEntry>& 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<std::string>& 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<int>(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<CaptureRecipe> 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<int>(ProvenanceScope::Item) &&
|
|
scopeInt != static_cast<int>(ProvenanceScope::Track))
|
|
return std::nullopt;
|
|
r.scope = static_cast<ProvenanceScope>(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<std::string> detectParent(
|
|
const std::vector<std::string>& sourceItemFiles,
|
|
const std::vector<BankFileRef>& bankFiles) {
|
|
if (sourceItemFiles.empty()) return std::nullopt;
|
|
|
|
std::optional<std::string> 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
|