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:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user