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:
2026-08-02 07:56:45 -04:00
parent 043558a54d
commit e0b4ec2e21
10 changed files with 161 additions and 56 deletions
+1
View File
@@ -13,6 +13,7 @@ add_subdirectory(capture)
add_subdirectory(tracking)
add_subdirectory(reclaim)
add_subdirectory(version)
add_subdirectory(package)
add_subdirectory(view)
add_subdirectory(ui)
add_subdirectory(instrument)
+11 -3
View File
@@ -26,9 +26,12 @@ landing after the format.
else — no manifest, no layout, no half-success. The fields through the writer
semver are FROZEN for all future versions to keep that refusal producible.
- **Path expression is structurally impossible.** Entry names are bare file
names (`isValidEntryName`: no separators, no `..`, no drive/UNC/rooted form),
enforced on encode AND decode because a package can arrive from anywhere.
There is no field in the format capable of expressing a path.
names (`isValidEntryName`: no separators, no `..` component, no
drive/UNC/rooted form, no control bytes, no Windows-reserved character, no
trailing dot/space, no DOS device name), enforced on encode AND decode
because a package can arrive from anywhere. There is no field in the format
capable of expressing a path. The rule set is the full authority; see
`package_format.h`'s doc comment for the itemized list.
- **Framing only, never a payload.** `bank_package` produces header bytes and
an ordered `{name, offset, length}` layout; it never holds, copies, or hashes
an entry's audio. `decodePackage` proves prefix + payload lengths equal the
@@ -76,3 +79,8 @@ landing after the format.
version pair classifies `Readable`; for `TooNew` it stops at the semver —
don't "fix" it to read the manifest length there, a future structural format
may have moved it.
- The format carries no algorithm tag for `byteHash` — it is FNV-1a
(`capture::hashBytes`) implicitly. Changing the digest algorithm is a
`minReaderVersion` bump, not additive: an old reader would otherwise compare
a stored digest against bytes hashed the new way and silently misjudge
corruption.
+2 -3
View File
@@ -1,8 +1,7 @@
#pragma once
// bank_package — RSBK framing and layout arithmetic: header encode, prefix
// decode, and the ordered {name, offset, length} entry layout. Framing only,
// never a payload: this module never holds, copies, or hashes an entry's audio
// — the shell streams payloads one at a time against the layout produced here.
// decode, and the ordered {name, offset, length} entry layout. See this
// directory's CLAUDE.md for the framing-only invariant. Pure: no filesystem.
#include <cstdint>
#include <optional>
+32 -3
View File
@@ -1,5 +1,7 @@
#include "core/package/package_format.h"
#include <cctype>
namespace reasampler::package {
PackageReadability classifyPackageVersion(std::uint32_t formatVersion,
@@ -10,13 +12,40 @@ PackageReadability classifyPackageVersion(std::uint32_t formatVersion,
return PackageReadability::Readable;
}
namespace {
// Windows device names claim the whole entry regardless of extension
// (CON, CON.wav, con.WAV are all the same reserved device) — checked against
// the portion before the first dot only.
bool isDosDeviceName(const std::string& name) {
std::string base = name.substr(0, name.find('.'));
for (char& c : base) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
static const std::string kReserved[] = {
"CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
};
for (const auto& r : kReserved) if (base == r) return true;
return false;
}
} // namespace
bool isValidEntryName(const std::string& name) {
if (name.empty() || name.size() > kMaxEntryNameBytes) return false;
if (name == ".") return false;
if (name.find("..") != std::string::npos) return false;
for (char c : name) {
if (name == "." || name == "..") return false;
// fopen/CreateFileW both silently strip a trailing dot or space at
// creation, so "a.wav " and "a.wav" would collide on one file.
if (name.back() == '.' || name.back() == ' ') return false;
for (unsigned char c : name) {
// NUL and other control bytes truncate at the first filesystem call
// (std::ofstream, fopen, CreateFileW off .c_str()) — two names that
// differ only after the NUL land on the same file.
if (c < 0x20 || c == 0x7F) return false;
if (c == '/' || c == '\\' || c == ':') return false;
if (c == '*' || c == '?' || c == '|' || c == '<' || c == '>' || c == '"') return false;
}
if (isDosDeviceName(name)) return false;
return true;
}
+16 -6
View File
@@ -57,17 +57,27 @@ PackageReadability classifyPackageVersion(std::uint32_t formatVersion,
std::uint32_t minReaderVersion);
// The entry-name rule that makes path expression structurally impossible: a
// bare file name only. Rejects empty, ".", any ".." occurrence, any '/', '\\'
// or ':' (which also bans every absolute form — drive, UNC, rooted), and names
// over kMaxEntryNameBytes. Enforced on encode AND decode by package_manifest.
// bare file name only. Rejects empty, ".", the exact ".." component (a name
// can only ever be one component, since separators are banned below — a
// substring scan would over-reject legal names like "take..final.wav"), any
// control byte (NUL included — truncates at the first filesystem call and
// collides two distinct manifest entries onto one file) or 0x7F, any '/',
// '\\' or ':' (which also bans every absolute form — drive, UNC, rooted), any
// Windows-reserved character (`*?|<>"`), a trailing dot or space (silently
// stripped at file creation, so "a.wav " and "a.wav" would collide), a DOS
// device name (CON/PRN/AUX/NUL/COM1-9/LPT1-9, case-insensitive, with or
// without an extension), and names over kMaxEntryNameBytes. Enforced on
// encode AND decode by package_manifest.
bool isValidEntryName(const std::string& name);
// The fixed header, informational semver included. writerVersion is
// version::stampVersion() on the write side — it exists so a TooNew refusal can
// tell the user which build to install; it never gates.
// tell the user which build to install; it never gates. Defaults are 0/0, NOT
// the current ladder pair — a caller reading `header` after a Malformed
// verdict must see an obviously-unset value, not a plausible-looking 1/1.
struct PackageHeader {
std::uint32_t formatVersion = kPackageFormatVersion;
std::uint32_t minReaderVersion = kPackageMinReaderVersion;
std::uint32_t formatVersion = 0;
std::uint32_t minReaderVersion = 0;
std::string writerVersion;
bool operator==(const PackageHeader& o) const {
+32 -32
View File
@@ -5,11 +5,6 @@
#include "core/json/json.h"
#include "core/package/package_format.h"
// Duplicate entry names are rejected in both directions: two payloads landing
// on one destination name is incoherent, and on import it would be a silent
// overwrite. Sample-id rules (remap, collision) are deliberately NOT enforced
// here — they are the import plan's decisions, not the codec's.
namespace reasampler::package {
namespace {
@@ -17,6 +12,8 @@ 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.
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)
@@ -155,37 +152,40 @@ bool parseEntry(json::Reader& r, PackageEntry& e) {
bool parseManifest(json::Reader& r, PackageManifest& m) {
if (!r.consume('{')) return false;
r.skipWs();
if (r.consume('}')) return true; // empty object: a valid empty manifest
bool haveEntries = false;
if (!r.consume('}')) { // not the empty-object shortcut: parse the members
do {
std::string key;
if (!r.parseKey(key)) return false;
do {
std::string key;
if (!r.parseKey(key)) return false;
if (key == "bankName") {
if (!r.parseString(m.bankDisplayName)) return false;
} else if (key == "exported") {
if (!r.parseInt64(m.exportTimestamp)) return false;
} else if (key == "entries") {
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;
if (key == "bankName") {
if (!r.parseString(m.bankDisplayName)) return false;
} else if (key == "exported") {
if (!r.parseInt64(m.exportTimestamp)) return false;
} else if (key == "entries") {
if (haveEntries) return false; // a repeated key must not accumulate
haveEntries = true;
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 (!parseSlots(r, m.slots)) return false;
} else {
if (!r.skipValue()) return false; // forward-compat unknown keys
}
} else if (key == "slots") {
if (!parseSlots(r, m.slots)) return false;
} else {
if (!r.skipValue()) return false; // forward-compat unknown keys
}
} while (r.consume(','));
} while (r.consume(','));
if (!r.consume('}')) return false;
if (!r.consume('}')) return false;
}
r.skipWs();
if (!r.eof()) return false; // trailing garbage
if (!r.eof()) return false; // trailing garbage — even after an empty object
return !duplicateName(m.entries);
}