Close the RSBK name-collision class: ASCII case folding, UTF-8 well-formedness, nested-path traversal
All three are format-locked and validated on encode and decode. Repeated known keys now reject at the root and inside an entry rather than last-wins.
This commit is contained in:
@@ -13,11 +13,12 @@ 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.
|
||||
// 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 (entries[i].fileName == entries[j].fileName) return true;
|
||||
if (sameEntryName(entries[i].fileName, entries[j].fileName)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -45,6 +46,7 @@ bool PackageManifest::operator==(const PackageManifest& o) const {
|
||||
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;
|
||||
@@ -122,19 +124,22 @@ bool parseEntry(json::Reader& r, PackageEntry& e) {
|
||||
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 (!r.parseString(e.fileName)) return false;
|
||||
if (haveName || !r.parseString(e.fileName)) return false;
|
||||
haveName = true;
|
||||
} else if (key == "length") {
|
||||
std::int64_t v = 0;
|
||||
if (!r.parseInt64(v)) return false;
|
||||
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 (!r.parseString(e.byteHash)) return false;
|
||||
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);
|
||||
@@ -150,25 +155,31 @@ bool parseEntry(json::Reader& r, PackageEntry& e) {
|
||||
|
||||
if (!r.consume('}')) return false;
|
||||
if (!haveName || !haveLength || !haveHash || !haveSample) return false;
|
||||
return isValidEntryName(e.fileName);
|
||||
return isValidEntryName(e.fileName) && isValidNestedSamplePath(e.sample.relativePath);
|
||||
}
|
||||
|
||||
bool parseManifest(json::Reader& r, PackageManifest& m) {
|
||||
if (!r.consume('{')) return false;
|
||||
r.skipWs();
|
||||
bool haveEntries = false;
|
||||
// "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 (haveEntries) return false; // a repeated key must not accumulate
|
||||
haveEntries = true;
|
||||
if (!firstTime(haveEntries)) return false;
|
||||
if (!r.consume('[')) return false;
|
||||
r.skipWs();
|
||||
if (!r.consume(']')) {
|
||||
@@ -180,6 +191,7 @@ bool parseManifest(json::Reader& r, PackageManifest& m) {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user