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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user