181b4f2edb
Documents why parseSlots skips repeat-key rejection, corrects two drifted doc lines (naming-rule count, Malformed-after-header header validity), and records two forward obligations for import_plan in CLAUDE.md.
225 lines
8.8 KiB
C++
225 lines
8.8 KiB
C++
#include "core/package/package_manifest.h"
|
|
|
|
#include <utility>
|
|
|
|
#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<PackageEntry>& 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<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 (!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<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. 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<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;
|
|
|
|
// 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<std::uint64_t>(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<PackageManifest> deserializeManifest(const std::string& json) {
|
|
PackageManifest m;
|
|
json::Reader r(json);
|
|
if (!parseManifest(r, m)) return std::nullopt;
|
|
return m;
|
|
}
|
|
|
|
} // namespace reasampler::package
|