Close bank-export review findings: name-cap underflow, double overwrite prompt, test scope

Clamps insertSuffix's underflow, floors uniqueEntryName's validity guard, suppresses
the redundant overwrite confirm via a picker out-param, adds a PayloadBuffer
high-water mark, and corrects stale CLAUDE.md/CMake claims.
This commit is contained in:
2026-08-02 14:02:01 -04:00
parent 081b6f1028
commit 454f67b3bc
18 changed files with 291 additions and 91 deletions
+16 -8
View File
@@ -6,8 +6,8 @@ The hand-rolled `RSBK` bank-package container, entirely pure (REAPER-free,
unit-tested outside the DAW): the format contract and version ladder, the JSON
manifest, and the framing/layout codec. No filesystem — the shell
(`src/shell/package`) streams bytes against the layouts produced here. The
export/import *decisions* (`export_plan` / `import_plan`) are separate modules
landing after the format.
export/import *decisions* (`export_plan` / `import_plan`) are separate modules;
`export_plan` has landed, `import_plan` has not.
## Invariants
@@ -123,12 +123,20 @@ landing after the format.
corruption.
- **A written package carries no path in ANY field.** `isValidNestedSamplePath`
permits a relative `relativePath` because a *record* may hold one, but
`export_plan` writes each shipping entry's `relativePath` as its bare package
name, so an emitted manifest has no separator anywhere and the entry name is the
single naming authority on both sides. The directory component it drops carries
no information — the bank subfolder is a fixed `capture_paths` constant the
importer re-spells. The nested-path rule stays as the decode-side backstop for a
package this build did not write.
`export_plan` writes each shipping entry's `relativePath` as its bare, sanitized
and disambiguated transport name (`export_plan.cpp`'s `e.fileName`), so an
emitted manifest has no separator anywhere and the entry name is the single
naming authority on both sides. The directory component it drops carries no
information — the bank subfolder is a fixed `capture_paths` constant
(`capture_paths.cpp`'s `deriveBankPaths`) the importer re-spells. **The
basename spelling is dropped too, not just the directory**: `e.fileName` is
`uniqueEntryName(sanitizeEntryName(...))`, not the source basename, so a
macOS-authored `Hit?.wav` survives only in `displayName` — the transport name
itself may differ. Accepted for the same reason the directory drop is: the
transport name exists to be a valid, collision-free package entry, not a
faithful copy of the source spelling, and `displayName` is the field that
carries the original for display. The nested-path rule stays as the decode-side
backstop for a package this build did not write.
- **Obligation on the export track: sanitize, don't relay the refusal.**
`serializeManifest` returns one indistinguishable `nullopt` for every rejection
— an unrepresentable name, a case-folded collision, a traversing nested path, a
+26 -14
View File
@@ -17,16 +17,18 @@ std::string baseNameOf(const std::string& path) {
return sep == std::string::npos ? path : path.substr(sep + 1);
}
// Trailing dots and spaces are stripped at file creation on Windows, so a name
// carrying them would collide with its stripped twin (isValidEntryName refuses them
// for that reason).
// Mirrors package_format.cpp's trailing-dot/space rule so a truncated stem never
// reintroduces the collision isValidEntryName exists to prevent.
std::string stripTrailingDotsAndSpaces(std::string s) {
while (!s.empty() && (s.back() == '.' || s.back() == ' ')) s.pop_back();
return s;
}
// Truncates to at most `max` bytes without splitting a UTF-8 sequence — a split one
// would leave the name ill-formed, which isValidEntryName refuses outright.
// Truncates to at most `max` bytes without splitting a UTF-8 sequence (the rule
// itself is package_format.cpp's isWellFormedUtf8). A sequence landing exactly on
// the cut is dropped whole, one character short of `max`, rather than checked for
// cleanliness — over-truncating by one character is cheap insurance against a
// subtly wrong boundary check.
std::string truncateUtf8(std::string s, std::size_t max) {
if (s.size() <= max) return s;
s.resize(max);
@@ -36,12 +38,19 @@ std::string truncateUtf8(std::string s, std::size_t max) {
}
// `name` with `suffix` inserted before its extension, trimmed so the result still
// fits the entry-name cap.
// fits the entry-name cap. `suffix.size() + ext.size()` can exceed the cap on its
// own (a long extension, a two-digit disambiguation suffix) — clamped rather than
// subtracted unchecked, which would underflow the size_t `room` below and turn
// truncateUtf8 into a silent no-op. An extension that alone leaves no room even
// after the whole stem is dropped is dropped too; uniqueEntryName's own floor
// covers what even that cannot fix.
std::string insertSuffix(const std::string& name, const std::string& suffix) {
const std::size_t dot = name.find_last_of('.');
const bool hasExt = dot != std::string::npos && dot > 0;
std::string stem = hasExt ? name.substr(0, dot) : name;
const std::string ext = hasExt ? name.substr(dot) : std::string();
std::string ext = hasExt ? name.substr(dot) : std::string();
if (suffix.size() >= kMaxEntryNameBytes) return std::string();
if (ext.size() > kMaxEntryNameBytes - suffix.size()) ext.clear();
const std::size_t room = kMaxEntryNameBytes - suffix.size() - ext.size();
stem = truncateUtf8(std::move(stem), room);
return stem + suffix + ext;
@@ -54,18 +63,21 @@ bool nameTaken(const std::string& candidate, const std::vector<std::string>& tak
}
// A transport name distinct from every name already claimed, under the format's own
// case-folding equivalence (two names differing only by ASCII case would extract onto
// one file on Windows and default APFS).
// case-folding equivalence (package_format.h's sameEntryName).
std::string uniqueEntryName(const std::string& base, const std::vector<std::string>& taken) {
if (!nameTaken(base, taken)) return base;
// Bounded by construction: each iteration either returns or collides with a
// distinct member of `taken`, and the suffixed names are pairwise distinct.
std::string candidate = base;
// Each iteration either returns a name both valid and distinct from `taken`, or
// advances to the next suffix; taken.size() + 2 attempts is enough by pigeonhole
// now that insertSuffix cannot underflow. The floor below is the residual case
// validity alone can still fail — an extension so long insertSuffix must drop it
// on every attempt tried here.
for (std::size_t n = 2; n <= taken.size() + 2; ++n) {
candidate = insertSuffix(base, "_" + std::to_string(n));
const std::string candidate = insertSuffix(base, "_" + std::to_string(n));
if (!nameTaken(candidate, taken) && isValidEntryName(candidate)) return candidate;
}
return candidate;
// Floors like sanitizeEntryName's own "entry" floor: always valid, regardless of
// how base's own extension behaved.
return sanitizeEntryName("entry_" + std::to_string(taken.size() + 2));
}
// What BankModel::add and the manifest's nested-path rule together accept — the pair
+2 -4
View File
@@ -80,10 +80,8 @@ ExportPlan planExport(const ExportInputs& in);
// separators, reserved characters and control bytes to '_', an over-long name
// truncated on a UTF-8 boundary, and an underscore prefix for the reserved forms
// ("." / ".." / a DOS device name). Never returns a name isValidEntryName refuses.
//
// A bank ingested on macOS/Linux legitimately holds names Windows cannot spell, and
// relaying the codec's one indistinguishable refusal would make a single such file
// an unactionable total failure of the whole export.
// Why sanitize rather than relay the codec's refusal: this directory's own
// CLAUDE.md, "Obligation on the export track."
std::string sanitizeEntryName(const std::string& rawFileName);
} // namespace reasampler::package