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:
+118
-21
@@ -1,7 +1,7 @@
|
||||
// Standalone tests for reasampler::package's manifest codec — no REAPER, no
|
||||
// test framework. The round-trip fixture exercises every manifest field and
|
||||
// every Sample optional in both present and absent states; the rejection suite
|
||||
// pins the entry-name rule on encode AND decode.
|
||||
// pins the naming, case-folding and traversal rules on encode AND decode.
|
||||
|
||||
#include "../src/core/package/package_manifest.h"
|
||||
|
||||
@@ -132,16 +132,58 @@ static void testEncodeRejectsBadEntryName() {
|
||||
}
|
||||
}
|
||||
|
||||
// One entry, `name` spliced in as raw manifest text so a hostile spelling
|
||||
// (escapes included) is expressible — a package is not limited to what encode
|
||||
// emits.
|
||||
static std::string oneEntryJson(const std::string& name,
|
||||
const std::string& relativePath = "bank/a.wav") {
|
||||
return "{\"entries\":[{\"name\":\"" + name +
|
||||
"\",\"length\":1,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\","
|
||||
"\"relativePath\":\"" + relativePath + "\"}]}}]}";
|
||||
}
|
||||
|
||||
static void testDecodeRejectsBadEntryName() {
|
||||
for (const char* bad : {"..\\\\evil.wav", "../evil.wav", "dir/a.wav", "C:\\\\a.wav", ".."}) {
|
||||
// Hand-rolled JSON: a hostile package is not limited to what encode emits.
|
||||
std::string json =
|
||||
std::string("{\"entries\":[{\"name\":\"") + bad +
|
||||
"\",\"length\":1,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\","
|
||||
"\"relativePath\":\"bank/a.wav\"}]}}]}";
|
||||
CHECK(!deserializeManifest(json).has_value());
|
||||
}
|
||||
for (const char* bad : {"..\\\\evil.wav", "../evil.wav", "dir/a.wav", "C:\\\\a.wav", ".."})
|
||||
CHECK(!deserializeManifest(oneEntryJson(bad)).has_value());
|
||||
|
||||
// The rest of the rule set through decode — the direction that matters,
|
||||
// since a package can arrive from anywhere.
|
||||
CHECK(!deserializeManifest(oneEntryJson("CON.wav")).has_value()); // DOS device
|
||||
CHECK(!deserializeManifest(oneEntryJson("COM\xC2\xB2.wav")).has_value()); // COM²
|
||||
CHECK(!deserializeManifest(oneEntryJson("a.wav ")).has_value()); // trailing space
|
||||
CHECK(!deserializeManifest(oneEntryJson("a.wav.")).has_value()); // trailing dot
|
||||
CHECK(!deserializeManifest(oneEntryJson("a*b.wav")).has_value()); // reserved char
|
||||
// A NUL smuggled in as a JSON escape: the manifest text is legal, the
|
||||
// decoded name is not.
|
||||
CHECK(!deserializeManifest(oneEntryJson("a\\u0000b.wav")).has_value());
|
||||
// Ill-formed UTF-8 as raw bytes.
|
||||
CHECK(!deserializeManifest(oneEntryJson("a\xC3.wav")).has_value());
|
||||
// A lone surrogate never reaches the name rule — the JSON reader refuses
|
||||
// the unpaired \uD800 first. Pinned so that refusal cannot silently become
|
||||
// "decoded to U+FFFD and accepted".
|
||||
CHECK(!deserializeManifest(oneEntryJson("a\\ud800b.wav")).has_value());
|
||||
}
|
||||
|
||||
static void testDecodeRejectsTraversalInNestedPath() {
|
||||
// The one field in the format that CAN express a path. BankModel::add
|
||||
// catches the absolute forms only, so ".." arrives unless the package layer
|
||||
// refuses it.
|
||||
CHECK(!deserializeManifest(oneEntryJson("a.wav", "../../evil.wav")).has_value());
|
||||
CHECK(!deserializeManifest(oneEntryJson("a.wav", "bank/../evil.wav")).has_value());
|
||||
CHECK(!deserializeManifest(oneEntryJson("a.wav", "..")).has_value());
|
||||
// A ".." that is not a whole component still reads.
|
||||
CHECK(deserializeManifest(oneEntryJson("a.wav", "take..final/a.wav")).has_value());
|
||||
}
|
||||
|
||||
static void testEncodeRejectsTraversalInNestedPath() {
|
||||
PackageManifest m = fixture();
|
||||
m.entries[0].sample.relativePath = "../../evil.wav";
|
||||
CHECK(!serializeManifest(m).has_value());
|
||||
|
||||
PackageManifest m2 = fixture();
|
||||
m2.entries[0].sample.relativePath = "bank/../evil.wav";
|
||||
CHECK(!serializeManifest(m2).has_value());
|
||||
}
|
||||
|
||||
static void testDuplicateEntryNamesRejectedBothWays() {
|
||||
@@ -149,40 +191,92 @@ static void testDuplicateEntryNamesRejectedBothWays() {
|
||||
m.entries[1].fileName = m.entries[0].fileName;
|
||||
CHECK(!serializeManifest(m).has_value());
|
||||
|
||||
// Case-folded: two names one case-insensitive filesystem extracts onto a
|
||||
// single file are one name here too, in both directions.
|
||||
PackageManifest folded = fixture();
|
||||
folded.entries[1].fileName = "KICK.WAV"; // entries[0] is "kick.wav"
|
||||
CHECK(!serializeManifest(folded).has_value());
|
||||
|
||||
// Decode side, from a hand-built duplicate (a hostile package is not
|
||||
// limited to what encode emits).
|
||||
const std::string dup =
|
||||
"{\"entries\":["
|
||||
"{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}},"
|
||||
"{\"name\":\"a.wav\",\"length\":2,\"hash\":\"i\","
|
||||
"{\"name\":\"A.WAV\",\"length\":2,\"hash\":\"i\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s2\",\"relativePath\":\"q\"}]}}]}";
|
||||
CHECK(!deserializeManifest(dup).has_value());
|
||||
|
||||
// Two names that differ outside the ASCII letters are still two names.
|
||||
const std::string distinct =
|
||||
"{\"entries\":["
|
||||
"{\"name\":\"a1.wav\",\"length\":1,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}},"
|
||||
"{\"name\":\"a2.wav\",\"length\":2,\"hash\":\"i\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s2\",\"relativePath\":\"q\"}]}}]}";
|
||||
CHECK(deserializeManifest(distinct).has_value());
|
||||
}
|
||||
|
||||
static void testDuplicateEntriesKeyRejected() {
|
||||
// A repeated "entries" key must not accumulate into two arrays' worth of
|
||||
// entries — reject rather than silently union them.
|
||||
const std::string json =
|
||||
static void testRepeatedRootKeyRejected() {
|
||||
// A repeated "entries" must not accumulate into two arrays' worth of
|
||||
// entries; the other three assign rather than append, but "which duplicate
|
||||
// keys are legal" is one format answer, not four.
|
||||
const std::string entriesTwice =
|
||||
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}}],"
|
||||
"\"entries\":[{\"name\":\"b.wav\",\"length\":1,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s2\",\"relativePath\":\"q\"}]}}]}";
|
||||
CHECK(!deserializeManifest(json).has_value());
|
||||
CHECK(!deserializeManifest(entriesTwice).has_value());
|
||||
|
||||
CHECK(!deserializeManifest("{\"bankName\":\"A\",\"bankName\":\"B\"}").has_value());
|
||||
CHECK(!deserializeManifest("{\"exported\":1,\"exported\":2}").has_value());
|
||||
CHECK(!deserializeManifest("{\"slots\":[],\"slots\":[]}").has_value());
|
||||
// Unknown keys stay repeatable: they are skipped, and a future format must
|
||||
// be free to add them.
|
||||
CHECK(deserializeManifest("{\"future\":1,\"future\":2}").has_value());
|
||||
|
||||
// The same answer one level down, so a hostile manifest cannot make two
|
||||
// readers disagree about which spelling of an entry field is the real one.
|
||||
CHECK(!deserializeManifest(
|
||||
"{\"entries\":[{\"name\":\"a.wav\",\"name\":\"b.wav\",\"length\":1,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}}]}")
|
||||
.has_value());
|
||||
CHECK(!deserializeManifest(
|
||||
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"length\":2,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}}]}")
|
||||
.has_value());
|
||||
CHECK(!deserializeManifest(
|
||||
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\",\"hash\":\"i\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}}]}")
|
||||
.has_value());
|
||||
CHECK(!deserializeManifest(
|
||||
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]},"
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s2\",\"relativePath\":\"q\"}]}}]}")
|
||||
.has_value());
|
||||
}
|
||||
|
||||
// --- rejection: structural ---------------------------------------------------
|
||||
|
||||
// A zero-length entry cannot round-trip through the shell's filesystem seam
|
||||
// (src/shell/package's appendPayload refuses an empty payload) — the format
|
||||
// layer must never produce one, so encode refuses it. Decode does not enforce
|
||||
// this (a hostile/older package declaring one is not this codec's concern).
|
||||
// See src/core/package/CLAUDE.md for the shell seam that forces this.
|
||||
static void testEncodeRejectsZeroLengthEntry() {
|
||||
PackageManifest m = fixture();
|
||||
m.entries[0].byteLength = 0;
|
||||
CHECK(!serializeManifest(m).has_value());
|
||||
}
|
||||
|
||||
// The asymmetry is deliberate: refusing a zero-length entry is an obligation on
|
||||
// what this layer WRITES, not a claim about what a package may declare. Pinned
|
||||
// so it is not "fixed" into a decode-side rejection.
|
||||
static void testDecodeAcceptsZeroLengthEntry() {
|
||||
auto m = deserializeManifest(
|
||||
"{\"entries\":[{\"name\":\"a.wav\",\"length\":0,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}}]}");
|
||||
CHECK(m.has_value());
|
||||
CHECK(m->entries.size() == 1);
|
||||
CHECK(m->entries[0].byteLength == 0);
|
||||
}
|
||||
|
||||
static void testEncodeRejectsUnrepresentableSample() {
|
||||
PackageManifest m = fixture();
|
||||
m.entries[0].sample.id.clear(); // BankModel::add rejects an empty id
|
||||
@@ -261,9 +355,12 @@ int main() {
|
||||
testUnknownKeysSkippedAtEveryLevel();
|
||||
testEncodeRejectsBadEntryName();
|
||||
testDecodeRejectsBadEntryName();
|
||||
testDecodeRejectsTraversalInNestedPath();
|
||||
testEncodeRejectsTraversalInNestedPath();
|
||||
testDuplicateEntryNamesRejectedBothWays();
|
||||
testDuplicateEntriesKeyRejected();
|
||||
testRepeatedRootKeyRejected();
|
||||
testEncodeRejectsZeroLengthEntry();
|
||||
testDecodeAcceptsZeroLengthEntry();
|
||||
testEncodeRejectsUnrepresentableSample();
|
||||
testDecodeRejectsMalformedShapes();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user