Tighten RSBK package-format validation for review remediation
Reject NUL/control bytes and Windows-hostile names in entry names, relax the over-broad ".." substring ban to component-only, close the trailing-garbage gap on empty manifests, and relocate the package CMake subdirectory to its ladder home.
This commit is contained in:
@@ -95,13 +95,15 @@ static void appendManifest(std::vector<std::uint8_t>& out, const std::string& js
|
||||
}
|
||||
|
||||
// One-entry manifest JSON with `extra` spliced in as additional root content
|
||||
// ("" for none) — for images a current writer would never emit.
|
||||
// ("" for none) and `sampleExtra` spliced into the nested Sample object — for
|
||||
// images a current writer would never emit.
|
||||
static std::string handManifest(const std::string& name, int length,
|
||||
const std::string& extra) {
|
||||
const std::string& extra,
|
||||
const std::string& sampleExtra = "") {
|
||||
return std::string("{") + extra +
|
||||
"\"entries\":[{\"name\":\"" + name +
|
||||
"\",\"length\":" + std::to_string(length) + ",\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\"," + sampleExtra +
|
||||
"\"relativePath\":\"bank/a.wav\"}]}}]}";
|
||||
}
|
||||
|
||||
@@ -210,11 +212,14 @@ static void testTooNewProducesNoManifest() {
|
||||
// --- version ladder: additive forward compatibility --------------------------
|
||||
|
||||
// The reason two integers exist: a NEWER formatVersion whose minReaderVersion
|
||||
// still reaches back to this build must read, with its unknown keys skipped.
|
||||
// still reaches back to this build must read, with its unknown keys skipped —
|
||||
// at the manifest level AND inside the nested Sample blob, the actual
|
||||
// motivating case for the two-integer ladder (see this directory's CLAUDE.md).
|
||||
static void testNewerAdditiveFormatReads() {
|
||||
const std::string manifest = handManifest(
|
||||
"a.wav", 4,
|
||||
"\"instrumentState\":{\"future\":[1,2,3]},\"anotherNewKey\":\"x\",");
|
||||
"\"instrumentState\":{\"future\":[1,2,3]},\"anotherNewKey\":\"x\",",
|
||||
"\"someFutureSampleField\":42,");
|
||||
std::vector<std::uint8_t> bytes =
|
||||
rawHeader(kPackageFormatVersion + 1, kPackageMinReaderVersion, "1.9.0");
|
||||
appendManifest(bytes, manifest);
|
||||
|
||||
@@ -56,6 +56,12 @@ static void testEntryNameAccepts() {
|
||||
CHECK(isValidEntryName(".hidden")); // a leading dot is a bare name
|
||||
CHECK(isValidEntryName("a.b.c.wav")); // single dots are fine
|
||||
CHECK(isValidEntryName(std::string(kMaxEntryNameBytes, 'x'))); // at the cap
|
||||
// Legal names containing a ".." substring that is not the whole name: a
|
||||
// name can only ever be one path component (separators are banned), so
|
||||
// ".." as a component is the only expressible traversal.
|
||||
CHECK(isValidEntryName("take..final.wav"));
|
||||
CHECK(isValidEntryName("loop...wav"));
|
||||
CHECK(isValidEntryName("a..b"));
|
||||
}
|
||||
|
||||
static void testEntryNameRejectsSeparatorsAndDots() {
|
||||
@@ -64,11 +70,16 @@ static void testEntryNameRejectsSeparatorsAndDots() {
|
||||
CHECK(!isValidEntryName(".."));
|
||||
CHECK(!isValidEntryName("..\\evil.wav"));
|
||||
CHECK(!isValidEntryName("../evil.wav"));
|
||||
CHECK(!isValidEntryName("a..b.wav")); // any ".." occurrence rejects
|
||||
CHECK(!isValidEntryName("dir/inner.wav"));
|
||||
CHECK(!isValidEntryName("dir\\inner.wav"));
|
||||
CHECK(!isValidEntryName("/rooted.wav"));
|
||||
CHECK(!isValidEntryName("\\rooted.wav"));
|
||||
// Embedded NUL: every plausible filesystem call (ofstream, fopen,
|
||||
// CreateFileW off .c_str()) truncates at it, so two names differing only
|
||||
// after the NUL would collide on one file.
|
||||
CHECK(!isValidEntryName(std::string("a\0b.wav", 7)));
|
||||
// Other control bytes (newline here) are equally hostile to logs/UI.
|
||||
CHECK(!isValidEntryName("a\nb.wav"));
|
||||
}
|
||||
|
||||
static void testEntryNameRejectsAbsolutePrefixes() {
|
||||
@@ -79,6 +90,31 @@ static void testEntryNameRejectsAbsolutePrefixes() {
|
||||
CHECK(!isValidEntryName(std::string(kMaxEntryNameBytes + 1, 'x'))); // over cap
|
||||
}
|
||||
|
||||
static void testEntryNameRejectsWindowsHostileNames() {
|
||||
// Reserved characters.
|
||||
CHECK(!isValidEntryName("a*b.wav"));
|
||||
CHECK(!isValidEntryName("a?b.wav"));
|
||||
CHECK(!isValidEntryName("a|b.wav"));
|
||||
CHECK(!isValidEntryName("a<b>.wav"));
|
||||
CHECK(!isValidEntryName("\"q\".wav"));
|
||||
// Trailing dot or space (silently stripped at creation on Windows).
|
||||
CHECK(!isValidEntryName("trailing "));
|
||||
CHECK(!isValidEntryName("trailing."));
|
||||
CHECK(!isValidEntryName(" "));
|
||||
CHECK(!isValidEntryName(" "));
|
||||
// DOS device names, case-insensitive, with and without an extension.
|
||||
CHECK(!isValidEntryName("NUL"));
|
||||
CHECK(!isValidEntryName("CON"));
|
||||
CHECK(!isValidEntryName("con.wav"));
|
||||
CHECK(!isValidEntryName("PRN"));
|
||||
CHECK(!isValidEntryName("AUX"));
|
||||
CHECK(!isValidEntryName("COM1"));
|
||||
CHECK(!isValidEntryName("com1.txt"));
|
||||
CHECK(!isValidEntryName("LPT1"));
|
||||
// Not a device name: a real filename that merely starts with one.
|
||||
CHECK(isValidEntryName("console.wav"));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testClassifyReadable();
|
||||
testClassifyTooNew();
|
||||
@@ -86,6 +122,7 @@ int main() {
|
||||
testEntryNameAccepts();
|
||||
testEntryNameRejectsSeparatorsAndDots();
|
||||
testEntryNameRejectsAbsolutePrefixes();
|
||||
testEntryNameRejectsWindowsHostileNames();
|
||||
|
||||
if (g_fail == 0) {
|
||||
std::printf("package_format_tests: all passed\n");
|
||||
|
||||
@@ -102,13 +102,14 @@ static void testEmptyManifestRoundTrips() {
|
||||
// --- forward compatibility ---------------------------------------------------
|
||||
|
||||
static void testUnknownKeysSkippedAtEveryLevel() {
|
||||
// A future additive manifest: unknown keys at the root and inside an entry.
|
||||
// A future additive manifest: unknown keys at the root, inside an entry,
|
||||
// and inside the nested index blob itself.
|
||||
const std::string json =
|
||||
"{\"bankName\":\"B\",\"exported\":7,"
|
||||
"\"instrumentState\":{\"nested\":[1,2,{\"x\":\"y\"}]},"
|
||||
"\"entries\":[{\"name\":\"a.wav\",\"length\":10,\"hash\":\"h\","
|
||||
"\"futureField\":\"ignored\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\","
|
||||
"\"index\":{\"version\":1,\"futureIndexField\":42,\"samples\":[{\"id\":\"s1\","
|
||||
"\"relativePath\":\"bank/a.wav\"}]}}],"
|
||||
"\"slots\":[],\"trailingUnknown\":null}";
|
||||
auto m = deserializeManifest(json);
|
||||
@@ -159,6 +160,17 @@ static void testDuplicateEntryNamesRejectedBothWays() {
|
||||
CHECK(!deserializeManifest(dup).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 =
|
||||
"{\"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());
|
||||
}
|
||||
|
||||
// --- rejection: structural ---------------------------------------------------
|
||||
|
||||
static void testEncodeRejectsUnrepresentableSample() {
|
||||
@@ -223,6 +235,10 @@ static void testDecodeRejectsMalformedShapes() {
|
||||
auto json = serializeManifest(fixture());
|
||||
CHECK(json.has_value());
|
||||
CHECK(!deserializeManifest(*json + "x").has_value());
|
||||
// Trailing garbage after the EMPTY-object shortcut specifically: this path
|
||||
// returned early before reaching the eof check, so "{}JUNK" parsed valid.
|
||||
CHECK(!deserializeManifest("{}JUNK").has_value());
|
||||
CHECK(deserializeManifest("{}").has_value());
|
||||
// Truncation at a few JSON-level offsets (byte-level truncation of the whole
|
||||
// package is bank_package's suite).
|
||||
CHECK(!deserializeManifest(json->substr(0, json->size() / 2)).has_value());
|
||||
@@ -236,6 +252,7 @@ int main() {
|
||||
testEncodeRejectsBadEntryName();
|
||||
testDecodeRejectsBadEntryName();
|
||||
testDuplicateEntryNamesRejectedBothWays();
|
||||
testDuplicateEntriesKeyRejected();
|
||||
testEncodeRejectsUnrepresentableSample();
|
||||
testDecodeRejectsMalformedShapes();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user