#include "core/package/package_manifest.h" #include #include "core/json/json.h" #include "core/package/package_format.h" namespace reasampler::package { namespace { using json::numToStr; using ObjWriter = json::Writer; // Shared by serializeManifest and deserializeManifest — see this directory's // CLAUDE.md for why duplicate names are rejected both ways. Equivalence is the // format's, not std::string's: sameEntryName folds ASCII case. bool duplicateName(const std::vector& entries) { for (std::size_t i = 0; i < entries.size(); ++i) for (std::size_t j = i + 1; j < entries.size(); ++j) if (sameEntryName(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 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 serializeManifest(const PackageManifest& m) { for (const auto& e : m.entries) { if (!isValidEntryName(e.fileName)) return std::nullopt; if (!isValidNestedSamplePath(e.sample.relativePath)) return std::nullopt; // Cross-module contract with src/shell/package — see this directory's // CLAUDE.md. if (e.byteLength == 0) 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(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. Deliberately // does NOT reject a repeated "id"/"slot" key the way the root and entry parsers // below reject theirs — this grammar belongs to core/model's bank_book_json, and // diverging here would give one wire shape two behaviours in two files. The // stakes differ too: a repeated "name" decides which file an entry lands on, // while a repeated "id" here still feeds SlotMap::fromEntries's deterministic // first-wins/never-double-occupy repair, so no ambiguity survives. Do not // "finish" the repeat-key rejection here to match the parsers below. bool parseSlots(json::Reader& r, model::SlotMap& out) { std::vector> 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; // A repeated key is rejected here exactly as at the root — same format // question, one level down. if (key == "name") { if (haveName || !r.parseString(e.fileName)) return false; haveName = true; } else if (key == "length") { std::int64_t v = 0; if (haveLength || !r.parseInt64(v)) return false; if (v < 0) return false; e.byteLength = static_cast(v); haveLength = true; } else if (key == "hash") { if (haveHash || !r.parseString(e.byteHash)) return false; haveHash = true; } else if (key == "index") { if (haveSample) return false; 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) && isValidNestedSamplePath(e.sample.relativePath); } bool parseManifest(json::Reader& r, PackageManifest& m) { if (!r.consume('{')) return false; r.skipWs(); // "Which duplicate keys are legal" is a format contract, so it is answered // for every root key rather than only for the one that would accumulate: // a repeated key is rejected, never last-wins. Unknown keys may repeat — // they are skipped, and a future format must stay free to add them. bool haveBankName = false, haveExported = false, haveEntries = false, haveSlots = false; const auto firstTime = [](bool& seen) { const bool ok = !seen; seen = true; return ok; }; if (!r.consume('}')) { // not the empty-object shortcut: parse the members do { std::string key; if (!r.parseKey(key)) return false; if (key == "bankName") { if (!firstTime(haveBankName)) return false; if (!r.parseString(m.bankDisplayName)) return false; } else if (key == "exported") { if (!firstTime(haveExported)) return false; if (!r.parseInt64(m.exportTimestamp)) return false; } else if (key == "entries") { if (!firstTime(haveEntries)) return false; 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 (!firstTime(haveSlots)) return false; 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 — even after an empty object return !duplicateName(m.entries); } } // namespace std::optional deserializeManifest(const std::string& json) { PackageManifest m; json::Reader r(json); if (!parseManifest(r, m)) return std::nullopt; return m; } } // namespace reasampler::package