Land src/core/package: the pure RSBK container — format ladder, JSON manifest, framing/layout codec

Two-integer ladder (formatVersion/minReaderVersion), bare-name-only entries
validated on encode and decode, prefix decode that proves exact file size
without ever reading a payload.
This commit is contained in:
2026-08-02 07:27:43 -04:00
parent 09a9ef838f
commit 043558a54d
12 changed files with 1351 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
#include "core/package/package_manifest.h"
#include <utility>
#include "core/json/json.h"
#include "core/package/package_format.h"
// Duplicate entry names are rejected in both directions: two payloads landing
// on one destination name is incoherent, and on import it would be a silent
// overwrite. Sample-id rules (remap, collision) are deliberately NOT enforced
// here — they are the import plan's decisions, not the codec's.
namespace reasampler::package {
namespace {
using json::numToStr;
using ObjWriter = json::Writer;
bool duplicateName(const std::vector<PackageEntry>& entries) {
for (std::size_t i = 0; i < entries.size(); ++i)
for (std::size_t j = i + 1; j < entries.size(); ++j)
if (entries[i].fileName == entries[j].fileName) return true;
return false;
}
// The one-sample BankModel image of `s` — bank_model's own writer, verbatim, so
// the per-sample shape has exactly one owner. nullopt when add() would reject
// the record (its guards are the format's guards too).
std::optional<std::string> nestSample(const model::Sample& s) {
model::BankModel one;
if (one.add(s) != model::AddResult::Added) return std::nullopt;
return one.serialize();
}
} // namespace
bool PackageEntry::operator==(const PackageEntry& o) const {
return fileName == o.fileName && byteLength == o.byteLength &&
byteHash == o.byteHash && sample == o.sample;
}
bool PackageManifest::operator==(const PackageManifest& o) const {
return bankDisplayName == o.bankDisplayName && exportTimestamp == o.exportTimestamp &&
entries == o.entries && slots == o.slots;
}
std::optional<std::string> serializeManifest(const PackageManifest& m) {
for (const auto& e : m.entries)
if (!isValidEntryName(e.fileName)) return std::nullopt;
if (duplicateName(m.entries)) return std::nullopt;
std::string out;
{
ObjWriter root(out);
root.keyStr("bankName", m.bankDisplayName);
root.keyRaw("exported", numToStr(m.exportTimestamp));
root.keyBegin("entries");
out += '[';
for (std::size_t i = 0; i < m.entries.size(); ++i) {
const auto& e = m.entries[i];
auto nested = nestSample(e.sample);
if (!nested) return std::nullopt;
if (i) out += ',';
ObjWriter w(out);
w.keyStr("name", e.fileName);
// byteLength rides as a signed decimal; 2^63 bytes is beyond any file.
w.keyRaw("length", numToStr(static_cast<std::int64_t>(e.byteLength)));
w.keyStr("hash", e.byteHash);
w.keyRaw("index", *nested);
}
out += ']';
root.keyBegin("slots");
out += m.slots.serialize();
} // root closes here (NRVO note in json::Writer)
return out;
}
namespace {
// Mirrors bank_book_json's private slots parser: [{id, slot}, ...] pairs handed
// to SlotMap::fromEntries, which owns the defensive repair rules.
bool parseSlots(json::Reader& r, model::SlotMap& out) {
std::vector<std::pair<std::string, int>> pairs;
if (!r.consume('[')) return false;
r.skipWs();
if (r.consume(']')) {
out = model::SlotMap::fromEntries(pairs);
return true;
}
do {
if (!r.consume('{')) return false;
std::string id;
int slot = 0;
bool haveId = false, haveSlot = false;
do {
std::string k;
if (!r.parseKey(k)) return false;
if (k == "id") { if (!r.parseString(id)) return false; haveId = true; }
else if (k == "slot") { if (!r.parseInt(slot)) return false; haveSlot = true; }
else { if (!r.skipValue()) return false; }
} while (r.consume(','));
if (!r.consume('}')) return false;
if (!haveId || !haveSlot) return false;
pairs.emplace_back(std::move(id), slot);
} while (r.consume(','));
if (!r.consume(']')) return false;
out = model::SlotMap::fromEntries(pairs);
return true;
}
bool parseEntry(json::Reader& r, PackageEntry& e) {
if (!r.consume('{')) return false;
r.skipWs();
if (r.consume('}')) return false; // an entry needs all four fields
bool haveName = false, haveLength = false, haveHash = false, haveSample = false;
do {
std::string key;
if (!r.parseKey(key)) return false;
if (key == "name") {
if (!r.parseString(e.fileName)) return false;
haveName = true;
} else if (key == "length") {
std::int64_t v = 0;
if (!r.parseInt64(v)) return false;
if (v < 0) return false;
e.byteLength = static_cast<std::uint64_t>(v);
haveLength = true;
} else if (key == "hash") {
if (!r.parseString(e.byteHash)) return false;
haveHash = true;
} else if (key == "index") {
std::string raw;
if (!r.captureValue(raw)) return false;
auto idx = model::BankModel::deserialize(raw);
// Exactly one sample: add()'s silent drop (rejected record) or a
// multi-sample blob both fail the entry rather than half-parse.
if (!idx || idx->size() != 1) return false;
e.sample = idx->all().front();
haveSample = true;
} else {
if (!r.skipValue()) return false; // forward-compat unknown keys
}
} while (r.consume(','));
if (!r.consume('}')) return false;
if (!haveName || !haveLength || !haveHash || !haveSample) return false;
return isValidEntryName(e.fileName);
}
bool parseManifest(json::Reader& r, PackageManifest& m) {
if (!r.consume('{')) return false;
r.skipWs();
if (r.consume('}')) return true; // empty object: a valid empty manifest
do {
std::string key;
if (!r.parseKey(key)) return false;
if (key == "bankName") {
if (!r.parseString(m.bankDisplayName)) return false;
} else if (key == "exported") {
if (!r.parseInt64(m.exportTimestamp)) return false;
} else if (key == "entries") {
if (!r.consume('[')) return false;
r.skipWs();
if (!r.consume(']')) {
do {
PackageEntry e;
if (!parseEntry(r, e)) return false;
m.entries.push_back(std::move(e));
} while (r.consume(','));
if (!r.consume(']')) return false;
}
} else if (key == "slots") {
if (!parseSlots(r, m.slots)) return false;
} else {
if (!r.skipValue()) return false; // forward-compat unknown keys
}
} while (r.consume(','));
if (!r.consume('}')) return false;
r.skipWs();
if (!r.eof()) return false; // trailing garbage
return !duplicateName(m.entries);
}
} // namespace
std::optional<PackageManifest> deserializeManifest(const std::string& json) {
PackageManifest m;
json::Reader r(json);
if (!parseManifest(r, m)) return std::nullopt;
return m;
}
} // namespace reasampler::package