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:
2026-08-02 08:44:24 -04:00
parent 1aebf51938
commit 3909b1072c
8 changed files with 442 additions and 74 deletions
+37 -3
View File
@@ -142,9 +142,7 @@ static void testEncodeRefusesWhatManifestRefuses() {
m.entries[0].fileName = "../evil.wav";
CHECK(!encodePackage(m).has_value());
// 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.
// See src/core/package/CLAUDE.md for the shell seam that forces this.
PackageManifest zeroLen = fixture(1, 1);
zeroLen.entries[1].byteLength = 0;
CHECK(!encodePackage(zeroLen).has_value());
@@ -185,6 +183,41 @@ static void testTruncationAtEveryByteOffsetIsMalformed() {
CHECK(decodePackage(file, file.size() - 1).status == PackageReadability::Malformed);
}
// --- header defaults ---------------------------------------------------------
// The 0/0 defaults are not the current ladder pair, so a header that never
// parsed cannot be mistaken for a plausible 1/1. They mean "unset", NOT "the
// decode failed": a decode that got past the header reports the real pair
// alongside its Malformed verdict.
static void testHeaderDefaultsMeanUnparsed() {
CHECK(PackageHeader{}.formatVersion == 0);
CHECK(PackageHeader{}.minReaderVersion == 0);
CHECK(PackageHeader{}.writerVersion.empty());
// Failed before the header: bad magic, and a semver truncated mid-string.
std::vector<std::uint8_t> badMagic = rawHeader(1, 1, "1.0.0");
appendManifest(badMagic, "{}");
badMagic[0] = 'Z';
const DecodedPackage magic = decodePackage(badMagic, badMagic.size());
CHECK(magic.status == PackageReadability::Malformed);
CHECK(magic.header == PackageHeader{});
std::vector<std::uint8_t> cutSemver = rawHeader(1, 1, "1.0.0");
cutSemver.resize(18);
CHECK(decodePackage(cutSemver, cutSemver.size()).header == PackageHeader{});
// Failed after it: a same-version package with a corrupt manifest is
// Malformed, and its header is fully populated.
std::vector<std::uint8_t> corrupt =
rawHeader(kPackageFormatVersion, kPackageMinReaderVersion, "1.0.0");
appendManifest(corrupt, "not json");
const DecodedPackage late = decodePackage(corrupt, corrupt.size());
CHECK(late.status == PackageReadability::Malformed);
CHECK(late.header.formatVersion == kPackageFormatVersion);
CHECK(late.header.minReaderVersion == kPackageMinReaderVersion);
CHECK(late.header.writerVersion == "1.0.0");
}
// --- version ladder: TooNew refuses whole ------------------------------------
static void testTooNewProducesNoManifest() {
@@ -365,6 +398,7 @@ int main() {
testEncodeDecodeRoundTrip();
testEncodeRefusesWhatManifestRefuses();
testTruncationAtEveryByteOffsetIsMalformed();
testHeaderDefaultsMeanUnparsed();
testTooNewProducesNoManifest();
testAdditiveUnparseableManifestIsTooNew();
testNewerAdditiveFormatReads();
+110 -3
View File
@@ -1,7 +1,7 @@
// Standalone tests for reasampler::package's format contract — no REAPER, no
// test framework. Pins the version-ladder classification (both integers, every
// branch) and the entry-name rule that makes path expression structurally
// impossible in a package.
// branch) and the three naming rules: the entry-name rule, the ASCII-folding
// name equivalence, and the nested-path traversal guard.
#include "../src/core/package/package_format.h"
@@ -111,8 +111,110 @@ static void testEntryNameRejectsWindowsHostileNames() {
CHECK(!isValidEntryName("COM1"));
CHECK(!isValidEntryName("com1.txt"));
CHECK(!isValidEntryName("LPT1"));
// Not a device name: a real filename that merely starts with one.
// The superscript device forms (COM¹ COM² COM³ LPT¹ LPT² LPT³ in UTF-8):
// Windows reads those as digits in a device name, so "COM².wav" is COM2.
CHECK(!isValidEntryName("COM\xC2\xB9.wav"));
CHECK(!isValidEntryName("com\xC2\xB2"));
CHECK(!isValidEntryName("COM\xC2\xB3.wav"));
CHECK(!isValidEntryName("LPT\xC2\xB9"));
CHECK(!isValidEntryName("lpt\xC2\xB2.txt"));
CHECK(!isValidEntryName("LPT\xC2\xB3.wav"));
// Not a device name: a real filename that merely starts with one, and the
// zero forms, which Windows does not reserve.
CHECK(isValidEntryName("console.wav"));
CHECK(isValidEntryName("COM0.wav"));
CHECK(isValidEntryName("LPT0"));
// Nor does a superscript past 3 name a device.
CHECK(isValidEntryName("COM\xE2\x81\xB4.wav")); // U+2074 SUPERSCRIPT FOUR
}
// --- isValidEntryName: UTF-8 well-formedness ---------------------------------
static void testEntryNameAcceptsWellFormedUtf8() {
CHECK(isValidEntryName("caf\xC3\xA9.wav")); // 2-byte: é
CHECK(isValidEntryName("\xE2\x99\xAA.wav")); // 3-byte: ♪
CHECK(isValidEntryName("\xF0\x9F\x8E\xB5.wav")); // 4-byte: 🎵
CHECK(isValidEntryName("\xEF\xBB\xBF.wav")); // U+FEFF, ugly but well-formed
CHECK(isValidEntryName("\xF4\x8F\xBF\xBF.wav")); // U+10FFFF, the last code point
}
static void testEntryNameRejectsIllFormedUtf8() {
// Two names differing ONLY in their invalid bytes: a host converting to
// UTF-16 substitutes U+FFFD for both by default, collapsing them onto one
// file — the duplicate-name collision the manifest cannot otherwise see.
CHECK(!isValidEntryName("a\x80.wav")); // stray continuation byte
CHECK(!isValidEntryName("a\x81.wav"));
// Structural: truncated sequences (a lead byte the name ends inside).
CHECK(!isValidEntryName("a\xC3"));
CHECK(!isValidEntryName("a\xE2\x99"));
CHECK(!isValidEntryName("a\xF0\x9F\x8E"));
// A lead byte followed by a non-continuation.
CHECK(!isValidEntryName("a\xC3\x41.wav"));
// Overlong encodings: an alternate spelling of an ASCII byte we ban.
CHECK(!isValidEntryName("a\xC0\xAF.wav")); // overlong '/'
CHECK(!isValidEntryName("a\xC0\x80.wav")); // overlong NUL
CHECK(!isValidEntryName("a\xE0\x80\xAF.wav")); // overlong '/', 3-byte
CHECK(!isValidEntryName("a\xF0\x80\x80\xAF.wav")); // overlong '/', 4-byte
// Surrogate halves: no code point, and unrepresentable in UTF-16.
CHECK(!isValidEntryName("a\xED\xA0\x80.wav")); // U+D800
CHECK(!isValidEntryName("a\xED\xBF\xBF.wav")); // U+DFFF
// Past U+10FFFF, and the 5/6-byte leads that never encode anything.
CHECK(!isValidEntryName("a\xF4\x90\x80\x80.wav")); // U+110000
CHECK(!isValidEntryName("a\xF5\x80\x80\x80.wav"));
CHECK(!isValidEntryName("a\xFC\x80\x80\x80\x80\x80.wav"));
CHECK(!isValidEntryName("a\xFF.wav"));
}
// --- sameEntryName -----------------------------------------------------------
static void testSameEntryNameFoldsAsciiCase() {
// A bank authored on a case-sensitive filesystem produces this pair
// honestly; Windows and default APFS would extract both onto one file.
CHECK(sameEntryName("Kick.wav", "kick.wav"));
CHECK(sameEntryName("KICK.WAV", "kick.wav"));
CHECK(sameEntryName("kick.wav", "kick.wav"));
CHECK(!sameEntryName("kick.wav", "snare.wav"));
CHECK(!sameEntryName("kick.wav", "kick.wave")); // length alone decides
CHECK(!sameEntryName("", "a"));
CHECK(sameEntryName("", ""));
// ASCII only: "é" vs "É" are two names here (the NFC/NFD limitation this
// shares — see this directory's CLAUDE.md).
CHECK(!sameEntryName("caf\xC3\xA9.wav", "caf\xC3\x89.wav"));
// Only the letters fold — the bytes flanking the ASCII range must not.
CHECK(!sameEntryName("a[b", "a{b")); // 0x5B vs 0x7B, 'Z'+1 and 'z'+1
CHECK(!sameEntryName("a@b", "a`b")); // 0x40 vs 0x60, 'A'-1 and 'a'-1
}
// --- isValidNestedSamplePath -------------------------------------------------
static void testNestedSamplePathAcceptsRelative() {
CHECK(isValidNestedSamplePath("a.wav"));
CHECK(isValidNestedSamplePath("reasampler_bank/kick.wav"));
CHECK(isValidNestedSamplePath("reasampler_bank\\kick.wav"));
CHECK(isValidNestedSamplePath("deep/dir/tree/a.wav"));
// A ".." that is not a whole component is an ordinary name.
CHECK(isValidNestedSamplePath("take..final/a.wav"));
CHECK(isValidNestedSamplePath("bank/..hidden"));
CHECK(isValidNestedSamplePath("a..b"));
}
static void testNestedSamplePathRejectsTraversalAndAbsolute() {
// BankModel::add catches only the absolute forms, so traversal reaches the
// format unless this rule stops it.
CHECK(!isValidNestedSamplePath(".."));
CHECK(!isValidNestedSamplePath("../evil.wav"));
CHECK(!isValidNestedSamplePath("..\\evil.wav"));
CHECK(!isValidNestedSamplePath("bank/../../evil.wav"));
CHECK(!isValidNestedSamplePath("bank\\..\\evil.wav"));
CHECK(!isValidNestedSamplePath("bank/.."));
CHECK(!isValidNestedSamplePath("bank/../"));
// Everything util::isAbsolutePath already catches.
CHECK(!isValidNestedSamplePath("/rooted.wav"));
CHECK(!isValidNestedSamplePath("\\rooted.wav"));
CHECK(!isValidNestedSamplePath("C:/abs.wav"));
CHECK(!isValidNestedSamplePath("C:\\abs.wav"));
CHECK(!isValidNestedSamplePath("c:relative-to-drive.wav"));
CHECK(!isValidNestedSamplePath("\\\\server\\share.wav"));
}
int main() {
@@ -123,6 +225,11 @@ int main() {
testEntryNameRejectsSeparatorsAndDots();
testEntryNameRejectsAbsolutePrefixes();
testEntryNameRejectsWindowsHostileNames();
testEntryNameAcceptsWellFormedUtf8();
testEntryNameRejectsIllFormedUtf8();
testSameEntryNameFoldsAsciiCase();
testNestedSamplePathAcceptsRelative();
testNestedSamplePathRejectsTraversalAndAbsolute();
if (g_fail == 0) {
std::printf("package_format_tests: all passed\n");
+118 -21
View File
@@ -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();