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
+6 -4
View File
@@ -2704,10 +2704,12 @@ on the import side, `bank_ops`, or `persist`.
- Exporting an empty bank produces a valid, importable package with zero entries rather than - Exporting an empty bank produces a valid, importable package with zero entries rather than
refusing. An empty bank is a legitimate thing to carry. refusing. An empty bank is a legitimate thing to carry.
**Open questions.** **[propose at review]** whether the export affordance is action-only, **Open questions — both answered at implementation review, recorded at
panel-only, or both at ship. **[propose at review]** whether the default file name is derived `docs/product/bank-package.md` §"Implementation decisions — Ε-W2-T1".** Affordance:
from the bank's display name (recommended, sanitized through **both** the action and the panel row (the action is the only spelling that can reach
`capture_paths::sanitizeStem`) or from the project name. the pool; the panel row is the direct gesture on a named bank). Default file name:
the bank's **display name**, sanitized through `capture_paths::sanitizeStem`, as
recommended.
#### Ε-W2-T2 — `bank-import` #### Ε-W2-T2 — `bank-import`
+24
View File
@@ -725,6 +725,30 @@ contemplates one untracked capture, an import strands hundreds).
--- ---
## Implementation decisions — Ε-W2-T1
Not [Daniel]-class forks — both were `[propose at review]` calls in `docs/PLAN.md`'s
Ε-W2-T1 track, answered at implementation review rather than by Daniel, and recorded
here per this phase's own convention for keeping such answers where the design lives
rather than only in the track's own now-stale open-questions line.
- **Affordance: both the bindable action and the panel row.** The action targets the
**active** bank and is the only spelling that can reach the **pool** (the panel's
`showTabMenu` returns early on `isPool()` — a named-bank-tab context menu has no tab
to right-click for the pool), while the exported unit's own definition above includes
the pool. The panel row is the direct gesture on a specific named bank. Neither
subsumes the other.
- **Default file name: the bank's display name**, sanitized through
`capture_paths::sanitizeStem`, seeded into `<projectDir>/<stem>.rsbank`. A
project-derived name was the rejected alternative: three banks exported from one
project must produce three distinguishable files, and a project-derived name
collides on the second export. Known wart, worth recording rather than hiding:
`sanitizeStem` collapses an all-non-ASCII display name to the literal `capture`, so
two such banks still collide — the existing rename verb is the recovery, same as the
import-side auto-suffix collisions above.
---
## Non-goals and guardrails ## Non-goals and guardrails
- **No auto-insertion of imported audio into the arrange.** Same rule as capture. - **No auto-insertion of imported audio into the arrange.** Same rule as capture.
+1 -3
View File
@@ -92,9 +92,7 @@ static void RunCancelRealtime(int) { capture::RunCancelRealtime(g_session); }
static void RunRecaptureFromSource(int) { capture::RunRecaptureFromSource(g_session); } static void RunRecaptureFromSource(int) { capture::RunRecaptureFromSource(g_session); }
static void RunRenderTrackInPlace(int) { capture::RunRenderTrackInPlace(g_session); } static void RunRenderTrackInPlace(int) { capture::RunRenderTrackInPlace(g_session); }
static void RunResampleBake(int) { capture::RunResampleBake(g_session); } static void RunResampleBake(int) { capture::RunResampleBake(g_session); }
static void RunExportBankPackage(int) { static void RunExportBankPackage(int) { reasampler::doBankPackageExport(g_session, g_session.book().activeBankId()); }
reasampler::doBankPackageExport(g_session, g_session.book().activeBankId());
}
static void RunShowVersion(int) { static void RunShowVersion(int) {
// On-demand only — no unconditional startup print (routine console chatter pops // On-demand only — no unconditional startup print (routine console chatter pops
// the console window). // the console window).
+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 unit-tested outside the DAW): the format contract and version ladder, the JSON
manifest, and the framing/layout codec. No filesystem — the shell manifest, and the framing/layout codec. No filesystem — the shell
(`src/shell/package`) streams bytes against the layouts produced here. The (`src/shell/package`) streams bytes against the layouts produced here. The
export/import *decisions* (`export_plan` / `import_plan`) are separate modules export/import *decisions* (`export_plan` / `import_plan`) are separate modules;
landing after the format. `export_plan` has landed, `import_plan` has not.
## Invariants ## Invariants
@@ -123,12 +123,20 @@ landing after the format.
corruption. corruption.
- **A written package carries no path in ANY field.** `isValidNestedSamplePath` - **A written package carries no path in ANY field.** `isValidNestedSamplePath`
permits a relative `relativePath` because a *record* may hold one, but permits a relative `relativePath` because a *record* may hold one, but
`export_plan` writes each shipping entry's `relativePath` as its bare package `export_plan` writes each shipping entry's `relativePath` as its bare, sanitized
name, so an emitted manifest has no separator anywhere and the entry name is the and disambiguated transport name (`export_plan.cpp`'s `e.fileName`), so an
single naming authority on both sides. The directory component it drops carries emitted manifest has no separator anywhere and the entry name is the single
no information — the bank subfolder is a fixed `capture_paths` constant the naming authority on both sides. The directory component it drops carries no
importer re-spells. The nested-path rule stays as the decode-side backstop for a information — the bank subfolder is a fixed `capture_paths` constant
package this build did not write. (`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.** - **Obligation on the export track: sanitize, don't relay the refusal.**
`serializeManifest` returns one indistinguishable `nullopt` for every rejection `serializeManifest` returns one indistinguishable `nullopt` for every rejection
— an unrepresentable name, a case-folded collision, a traversing nested path, a — 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); return sep == std::string::npos ? path : path.substr(sep + 1);
} }
// Trailing dots and spaces are stripped at file creation on Windows, so a name // Mirrors package_format.cpp's trailing-dot/space rule so a truncated stem never
// carrying them would collide with its stripped twin (isValidEntryName refuses them // reintroduces the collision isValidEntryName exists to prevent.
// for that reason).
std::string stripTrailingDotsAndSpaces(std::string s) { std::string stripTrailingDotsAndSpaces(std::string s) {
while (!s.empty() && (s.back() == '.' || s.back() == ' ')) s.pop_back(); while (!s.empty() && (s.back() == '.' || s.back() == ' ')) s.pop_back();
return s; return s;
} }
// Truncates to at most `max` bytes without splitting a UTF-8 sequence — a split one // Truncates to at most `max` bytes without splitting a UTF-8 sequence (the rule
// would leave the name ill-formed, which isValidEntryName refuses outright. // 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) { std::string truncateUtf8(std::string s, std::size_t max) {
if (s.size() <= max) return s; if (s.size() <= max) return s;
s.resize(max); 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 // `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) { std::string insertSuffix(const std::string& name, const std::string& suffix) {
const std::size_t dot = name.find_last_of('.'); const std::size_t dot = name.find_last_of('.');
const bool hasExt = dot != std::string::npos && dot > 0; const bool hasExt = dot != std::string::npos && dot > 0;
std::string stem = hasExt ? name.substr(0, dot) : name; 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(); const std::size_t room = kMaxEntryNameBytes - suffix.size() - ext.size();
stem = truncateUtf8(std::move(stem), room); stem = truncateUtf8(std::move(stem), room);
return stem + suffix + ext; 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 // 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 // case-folding equivalence (package_format.h's sameEntryName).
// one file on Windows and default APFS).
std::string uniqueEntryName(const std::string& base, const std::vector<std::string>& taken) { std::string uniqueEntryName(const std::string& base, const std::vector<std::string>& taken) {
if (!nameTaken(base, taken)) return base; if (!nameTaken(base, taken)) return base;
// Bounded by construction: each iteration either returns or collides with a // Each iteration either returns a name both valid and distinct from `taken`, or
// distinct member of `taken`, and the suffixed names are pairwise distinct. // advances to the next suffix; taken.size() + 2 attempts is enough by pigeonhole
std::string candidate = base; // 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) { 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; 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 // 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 // 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 // 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 DOS device name). Never returns a name isValidEntryName refuses.
// // Why sanitize rather than relay the codec's refusal: this directory's own
// A bank ingested on macOS/Linux legitimately holds names Windows cannot spell, and // CLAUDE.md, "Obligation on the export track."
// relaying the codec's one indistinguishable refusal would make a single such file
// an unactionable total failure of the whole export.
std::string sanitizeEntryName(const std::string& rawFileName); std::string sanitizeEntryName(const std::string& rawFileName);
} // namespace reasampler::package } // namespace reasampler::package
+7 -1
View File
@@ -164,7 +164,8 @@ void doBankPackageExport(const ReaSamplerSession& session, const std::string& ba
const std::string suggested = const std::string suggested =
projectDir + "/" + capture::sanitizeStem(bankName) + ".rsbank"; projectDir + "/" + capture::sanitizeStem(bankName) + ".rsbank";
std::string dest; std::string dest;
if (!pickPackageSavePath(suggested, dest)) return; // user cancelled the picker bool appended = false;
if (!pickPackageSavePath(suggested, dest, &appended)) return; // user cancelled the picker
ExportRequest req; ExportRequest req;
req.projectDir = projectDir; req.projectDir = projectDir;
@@ -172,6 +173,11 @@ void doBankPackageExport(const ReaSamplerSession& session, const std::string& ba
req.destAbsPath = dest; req.destAbsPath = dest;
req.exportTimestamp = static_cast<std::int64_t>(std::time(nullptr)); req.exportTimestamp = static_cast<std::int64_t>(std::time(nullptr));
req.allowIncomplete = allowIncomplete; req.allowIncomplete = allowIncomplete;
// The dialog's own overwrite confirm covered exactly this path when the picker
// did not need to append `.rsbank` to reach it — asking again would be a second
// prompt for the same consent. An appended path is one the dialog never saw, so
// that case still falls through to exportBank's own refusal and the confirm below.
req.allowOverwrite = !appended;
ExportOutcome out = exportBank(session, req); ExportOutcome out = exportBank(session, req);
if (out.status == ExportStatus::RefusedDestinationExists) { if (out.status == ExportStatus::RefusedDestinationExists) {
+19 -19
View File
@@ -5,12 +5,12 @@
The filesystem and dialog acts behind bank-package export/import: streaming package The filesystem and dialog acts behind bank-package export/import: streaming package
file I/O plus the file-status and exclusive-create acts (`package_io`), the file I/O plus the file-status and exclusive-create acts (`package_io`), the
UTF-8 path conversion every one of them goes through (`package_path`), the landed-file UTF-8 path conversion every one of them goes through (`package_path`), the landed-file
journal and its rollback delete (`package_rollback`), and the two file pickers journal and its rollback delete (`package_rollback`), the two file pickers
(`package_pickers`). This seam is bytes-only — the package format (magic, manifest, (`package_pickers`), and the promptless export verb (`export_bank`) — the import verb
entry layout) is `core/package`'s business, and the export/import verbs that does not live here yet. The package format itself (magic, manifest, entry layout)
orchestrate both do not live here yet. No REAPER project state is touched in this stays `core/package`'s business. No REAPER project state is touched in this directory:
directory: no ext-state read or write, no undo block, no generation bump — those no ext-state read or write, no undo block, no generation bump — those belong to the
belong to the verbs. prompting skin (`shell/actions/package_export_action`), not this seam.
## Invariants ## Invariants
@@ -47,17 +47,17 @@ belong to the verbs.
writer could win the race against. Collision handling (auto-rename) remains the writer could win the race against. Collision handling (auto-rename) remains the
import plan's job upstream. The package writer itself DOES replace an existing import plan's job upstream. The package writer itself DOES replace an existing
destination — the export save dialog's own overwrite confirm is the consent — and destination — the export save dialog's own overwrite confirm is the consent — and
that asymmetry is deliberate. **Known gap, obligation on the export verb:** that asymmetry is deliberate. **Closed, both halves.** `pickPackageSavePath`'s own
`pickPackageSavePath`'s own `.rsbank` re-append (see its Gotcha below) can turn a `.rsbank` re-append (see its Gotcha below) can turn a confirmed path `X` into a write
confirmed path `X` into a write target `X.rsbank` that the dialog never asked about. target `X.rsbank` that the dialog never asked about, so `exportBank` re-checks
The export verb MUST re-check `fileStatus()` on the path actually handed to `fileStatus()` on the path actually handed to `PackageFileWriter` — after any
`PackageFileWriter` — after any extension append — and get its own consent if that extension append — and refuses `RefusedDestinationExists` until the caller sets
re-checked path is `Present`; the dialog's confirm only ever covered the pre-append `allowOverwrite`. The caller does not always re-prompt to get there:
path. Not fixed at this seam: prompting is verb-level UX, and `pickPackageSavePath` `pickPackageSavePath` reports whether it appended (`outAppended`), and
has no caller yet, so the gap is latent, not live. **Closed on the export side:** `package_export_action` pre-grants `allowOverwrite` whenever it did NOT — an
`exportBank` re-checks `fileStatus()` on the post-append path and refuses unappended path is exactly what the dialog's own confirm already covered, so asking
`RefusedDestinationExists` until the caller sets `allowOverwrite`, which again would be a second prompt for the same consent. Only an appended path, one the
`package_export_action` does only after its own confirm naming that exact path. dialog never saw, still costs the verb's own confirm naming that exact path.
- **The rollback delete is prune's ONE carve-out, and only HALF of it is structural.** - **The rollback delete is prune's ONE carve-out, and only HALF of it is structural.**
The citation and the full discriminator live at `package_rollback.cpp`'s header. The citation and the full discriminator live at `package_rollback.cpp`'s header.
"Did this call create it" is structural: only exclusively-created paths are "Did this call create it" is structural: only exclusively-created paths are
@@ -79,8 +79,8 @@ belong to the verbs.
- `package_path` — header-only; the ONE UTF-8-narrow → `fs::path` conversion, so the encoding contract has a single enforcement point. - `package_path` — header-only; the ONE UTF-8-narrow → `fs::path` conversion, so the encoding contract has a single enforcement point.
- `package_io` — every filesystem act the verbs need: `PayloadBuffer` (move-only payload + the `alive()` seam counter), `PackageFileWriter` (append-only temp+atomic-rename writer), `PackageFileReader` (seek-and-read one range per call, range-checked against the real file size), `readFilePayload` (one source file as one entry's payload), `fileStatus` (Present/Absent/Unreadable — export's refusal message must distinguish the last two, and an empty payload cannot), `writeFileExclusive` (exclusive create + write, self-cleaning on a partial write), and `listFolderFileNames` (bare UTF-8 names, sorted, non-recursive, non-throwing). REAPER-free; tested without a DAW. - `package_io` — every filesystem act the verbs need: `PayloadBuffer` (move-only payload + the `alive()` seam counter), `PackageFileWriter` (append-only temp+atomic-rename writer), `PackageFileReader` (seek-and-read one range per call, range-checked against the real file size), `readFilePayload` (one source file as one entry's payload), `fileStatus` (Present/Absent/Unreadable — export's refusal message must distinguish the last two, and an empty payload cannot), `writeFileExclusive` (exclusive create + write, self-cleaning on a partial write), and `listFolderFileNames` (bare UTF-8 names, sorted, non-recursive, non-throwing). REAPER-free; tested without a DAW.
- `package_rollback``LandedFileJournal`: `writeLandedFile` (exclusive-create land, path resolved absolute, recorded on success only), `markIndexCommitted` (disarms the journal), and `rollback` (deletes exactly the recorded set, hard unlink, tolerating a vanished file; refuses once disarmed). REAPER-free; tested without a DAW. - `package_rollback``LandedFileJournal`: `writeLandedFile` (exclusive-create land, path resolved absolute, recorded on success only), `markIndexCommitted` (disarms the journal), and `rollback` (deletes exactly the recorded set, hard unlink, tolerating a vanished file; refuses once disarmed). REAPER-free; tested without a DAW.
- `package_pickers``pickPackageForImport` and `pickPackageSavePath`, both `GetUserFileName`. Compile-only until the verbs land; nothing here can be exercised in a unit test. - `package_pickers``pickPackageForImport` and `pickPackageSavePath`, both `GetUserFileName`; `pickPackageSavePath` also reports whether it appended `.rsbank` (`outAppended`), the signal `package_export_action` uses to skip a redundant overwrite confirm. `pickPackageForImport` stays compile-only until the import verb lands; neither picker can be exercised in a unit test.
- `export_bank` — the promptless export verb, in three composable public steps: `surveyBankExport` (the read-only plan, report-before-acting), `digestSources` (measures each entry's length + `hashBytes` digest, one payload at a time), and `writePackageFile` (prefix, then each payload re-read and re-verified against that digest before it is appended, then commit). `exportBank` composes the three and gates on the plan verdict, the incomplete confirm and the destination confirm. The session arrives **const** — every mutator on it is non-const, so "an export writes no ext state, opens no undo point and never bumps the generation" is enforced by the type rather than remembered. Reads the session through inline accessors only, which is why its tests link and run without a DAW. - `export_bank` — the promptless export verb, in three composable public steps: `surveyBankExport` (the read-only plan, report-before-acting), `digestSources` (measures each entry's length + `hashBytes` digest, one payload at a time), and `writePackageFile` (prefix, then each payload re-read and re-verified against that digest before it is appended, then commit). `exportBank` composes the three and gates on the plan verdict, the incomplete confirm and the destination confirm. The session arrives **const**: `saveToActiveProject`, `bumpBankGeneration` and `writeAssignmentRequest` are the session's only non-const acts, so a const session cannot reach them and "an export writes no ext state, opens no undo point and never bumps the generation" holds by the type rather than by memory (`pruneReclaim`, the sole file-deletion path, is const too and sits outside this claim). Reads the session through inline accessors only, which is why its tests link and run without a DAW.
## Gotchas ## Gotchas
+10 -3
View File
@@ -1,7 +1,8 @@
# The filesystem + dialog seam for bank packages. package_io / package_rollback are # The filesystem + dialog seam for bank packages. package_io / package_rollback are
# REAPER-free (standard filesystem only), so the pure-library/test helpers fit and # REAPER-free (standard filesystem only), so the pure-library/test helpers fit and
# their tests run without a DAW. The export/import verbs that drive all three targets # their tests run without a DAW. export_bank, the promptless export verb, lives here
# are not in this directory yet. # too for the same reason (ReaSamplerSession's inline accessors keep it REAPER-free);
# the import verb does not live here yet.
reasampler_pure_library(package_io SOURCES package_io.cpp) reasampler_pure_library(package_io SOURCES package_io.cpp)
reasampler_test(package_io LINK package_io) reasampler_test(package_io LINK package_io)
@@ -11,9 +12,15 @@ reasampler_test(package_rollback LINK package_rollback)
# export_bank reads the live session through ReaSamplerSession's INLINE accessors only, # export_bank reads the live session through ReaSamplerSession's INLINE accessors only,
# so it pulls in no REAPER-facing TU and its tests link (and run) without a DAW. # so it pulls in no REAPER-facing TU and its tests link (and run) without a DAW.
# bank_book / tail_control / origin_ledger / tracking_authority / prune_reconcile /
# app_version / view_mode_model are session.h's own transitive includes (BankBook::bank()
# in particular is out-of-line, in bank_book.cpp) declared here, on the library that
# actually needs them, rather than left for every consumer to enumerate.
reasampler_pure_library(export_bank reasampler_pure_library(export_bank
SOURCES export_bank.cpp SOURCES export_bank.cpp
LINK PUBLIC export_plan bank_package package_io PRIVATE capture_paths wav_codec) LINK PUBLIC export_plan bank_package package_io
PRIVATE capture_paths wav_codec bank_book tail_control origin_ledger
tracking_authority prune_reconcile app_version view_mode_model)
reasampler_test(export_bank reasampler_test(export_bank
LINK export_bank bank_book slot_map view_mode_model tail_control origin_ledger LINK export_bank bank_book slot_map view_mode_model tail_control origin_ledger
tracking_authority prune_reconcile app_version capture_paths) tracking_authority prune_reconcile app_version capture_paths)
+6 -2
View File
@@ -2,8 +2,12 @@
// stream, commit. No prompts and no message boxes (shell/actions/ // stream, commit. No prompts and no message boxes (shell/actions/
// package_export_action is the skin). The session arrives CONST, which is how "an // package_export_action is the skin). The session arrives CONST, which is how "an
// export writes no ext state, opens no undo point and never bumps the bank // export writes no ext state, opens no undo point and never bumps the bank
// generation" is enforced rather than remembered — every mutator on the session is // generation" is enforced rather than remembered — those three acts
// non-const. Blocking I/O: UI-thread actions only. // (saveToActiveProject, bumpBankGeneration, writeAssignmentRequest) are exactly the
// session members that are non-const (session.h:113,108,147). Constness does not
// block every mutation, though: pruneReclaim (session.h:140-141) is const and is
// the system's sole file-deletion path — irrelevant to export, but not something a
// const session forbids in general. Blocking I/O: UI-thread actions only.
#pragma once #pragma once
+9 -1
View File
@@ -29,6 +29,7 @@ namespace fs = std::filesystem;
namespace { namespace {
std::atomic<int> g_alivePayloads{0}; std::atomic<int> g_alivePayloads{0};
std::atomic<int> g_peakAlivePayloads{0};
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -36,7 +37,13 @@ std::atomic<int> g_alivePayloads{0};
PayloadBuffer::PayloadBuffer(std::vector<std::uint8_t> bytes) PayloadBuffer::PayloadBuffer(std::vector<std::uint8_t> bytes)
: bytes_(std::move(bytes)), counted_(!bytes_.empty()) { : bytes_(std::move(bytes)), counted_(!bytes_.empty()) {
if (counted_) g_alivePayloads.fetch_add(1, std::memory_order_relaxed); if (counted_) {
const int now = g_alivePayloads.fetch_add(1, std::memory_order_relaxed) + 1;
int peak = g_peakAlivePayloads.load(std::memory_order_relaxed);
while (now > peak && !g_peakAlivePayloads.compare_exchange_weak(
peak, now, std::memory_order_relaxed)) {
}
}
} }
PayloadBuffer::~PayloadBuffer() { release(); } PayloadBuffer::~PayloadBuffer() { release(); }
@@ -60,6 +67,7 @@ PayloadBuffer& PayloadBuffer::operator=(PayloadBuffer&& other) noexcept {
} }
int PayloadBuffer::alive() { return g_alivePayloads.load(std::memory_order_relaxed); } int PayloadBuffer::alive() { return g_alivePayloads.load(std::memory_order_relaxed); }
int PayloadBuffer::highWaterMark() { return g_peakAlivePayloads.load(std::memory_order_relaxed); }
void PayloadBuffer::release() { void PayloadBuffer::release() {
if (counted_) g_alivePayloads.fetch_sub(1, std::memory_order_relaxed); if (counted_) g_alivePayloads.fetch_sub(1, std::memory_order_relaxed);
+5
View File
@@ -33,6 +33,11 @@ public:
// Buffers currently holding at least one byte, process-wide. // Buffers currently holding at least one byte, process-wide.
static int alive(); static int alive();
// The largest alive() has ever been, process-wide. A point-in-time alive() == 0
// check after a call returns cannot fail on a whole-package-in-memory shape that
// allocated N buffers and freed them all one at a time — highWaterMark() can,
// since it is never reset.
static int highWaterMark();
private: private:
void release(); void release();
+5 -2
View File
@@ -47,7 +47,8 @@ bool pickPackageForImport(std::string& outAbsPath) {
return runPicker(1, "Import bank package", "", outAbsPath); return runPicker(1, "Import bank package", "", outAbsPath);
} }
bool pickPackageSavePath(const std::string& suggestedPath, std::string& outAbsPath) { bool pickPackageSavePath(const std::string& suggestedPath, std::string& outAbsPath,
bool* outAppended) {
// [verify — DAW] GetUserFileName takes no owner window, so the dialog's parenting // [verify — DAW] GetUserFileName takes no owner window, so the dialog's parenting
// is REAPER's to do; the previous Win32 path passed GetMainHwnd() explicitly. // is REAPER's to do; the previous Win32 path passed GetMainHwnd() explicitly.
if (!runPicker(0, "Export bank package", suggestedPath.c_str(), outAbsPath)) { if (!runPicker(0, "Export bank package", suggestedPath.c_str(), outAbsPath)) {
@@ -56,7 +57,9 @@ bool pickPackageSavePath(const std::string& suggestedPath, std::string& outAbsPa
// GetUserFileName has no lpstrDefExt equivalent (the old Win32 picker's // GetUserFileName has no lpstrDefExt equivalent (the old Win32 picker's
// ofn.lpstrDefExt = L"rsbank"); whether mode 0 appends one itself from // ofn.lpstrDefExt = L"rsbank"); whether mode 0 appends one itself from
// kExtList is [verify — DAW], so append it ourselves whenever it's missing. // kExtList is [verify — DAW], so append it ourselves whenever it's missing.
if (!hasCaseInsensitiveSuffix(outAbsPath, ".rsbank")) outAbsPath += ".rsbank"; const bool appended = !hasCaseInsensitiveSuffix(outAbsPath, ".rsbank");
if (appended) outAbsPath += ".rsbank";
if (outAppended) *outAppended = appended;
return true; return true;
} }
+7 -2
View File
@@ -18,7 +18,12 @@ bool pickPackageForImport(std::string& outAbsPath);
// suggestedPath is a bare file name ("MyBank.rsbank") or a full path — a full one // suggestedPath is a bare file name ("MyBank.rsbank") or a full path — a full one
// also seeds the dialog's starting directory, which is how a caller keeps the picker // also seeds the dialog's starting directory, which is how a caller keeps the picker
// off REAPER's process working directory. True with outAbsPath set iff the user chose // off REAPER's process working directory. True with outAbsPath set iff the user chose
// a destination; the dialog's own overwrite confirm has already run by then. // a destination; the dialog's own overwrite confirm has already run by then, against
bool pickPackageSavePath(const std::string& suggestedPath, std::string& outAbsPath); // the path the user actually chose — NOT necessarily outAbsPath, if the `.rsbank`
// re-append below fires. outAppended, when non-null, is set to whether it fired: the
// caller's signal that its own overwrite consent may not cover the returned path
// (see this directory's CLAUDE.md).
bool pickPackageSavePath(const std::string& suggestedPath, std::string& outAbsPath,
bool* outAppended = nullptr);
} // namespace reasampler } // namespace reasampler
+1 -1
View File
@@ -251,7 +251,7 @@ enum : unsigned int {
} // namespace } // namespace
// Shows the right-click context menu for a named-bank TAB: activate / rename / delete // Shows the right-click context menu for a named-bank TAB: activate / rename / delete
// / evacuate that bank, plus a create entry. Drives the id-keyed ops. // / evacuate / export that bank, plus a create entry. Drives the id-keyed ops.
void showTabMenu(int screenX, int screenY, const std::string& bankId) { void showTabMenu(int screenX, int screenY, const std::string& bankId) {
if (!book()) return; if (!book()) return;
const Bank* bk = book()->bank(bankId); const Bank* bk = book()->bank(bankId);
+122 -26
View File
@@ -86,10 +86,11 @@ struct Fixture {
} }
void addSample(const std::string& id, const std::string& fileName, void addSample(const std::string& id, const std::string& fileName,
std::size_t bytes, std::uint8_t seed, bool writeToDisk = true) { std::size_t bytes, std::uint8_t seed, bool writeToDisk = true,
const std::string& displayName = "") {
model::Sample s; model::Sample s;
s.id = id; s.id = id;
s.displayName = id; s.displayName = displayName.empty() ? id : displayName;
s.relativePath = std::string("reasampler_bank/") + fileName; s.relativePath = std::string("reasampler_bank/") + fileName;
s.sampleRate = 48000; s.sampleRate = 48000;
s.channelCount = 2; s.channelCount = 2;
@@ -146,6 +147,11 @@ static void testHealthyExportCarriesEveryPayloadByteExact() {
CHECK(out.entriesWritten == 3); CHECK(out.entriesWritten == 3);
CHECK(out.excluded.empty()); CHECK(out.excluded.empty());
CHECK(PayloadBuffer::alive() == 0); CHECK(PayloadBuffer::alive() == 0);
// Point-in-time alive() == 0 alone cannot fail on a whole-package-in-memory
// shape (N buffers allocated and freed one at a time still ends at 0); the
// high-water mark can, across this three-entry export and everything the test
// binary ran before it — it must never exceed the "at most one payload" claim.
CHECK(PayloadBuffer::highWaterMark() == 1);
const package::DecodedPackage decoded = decodeFromDisk(fx.destPath()); const package::DecodedPackage decoded = decodeFromDisk(fx.destPath());
CHECK(decoded.status == package::PackageReadability::Readable); CHECK(decoded.status == package::PackageReadability::Readable);
@@ -176,17 +182,51 @@ static void testHealthyExportCarriesEveryPayloadByteExact() {
CHECK(PayloadBuffer::alive() == 0); CHECK(PayloadBuffer::alive() == 0);
} }
// Every occurrence of `"key":"value"` in `json`, value returned raw (escape-aware
// only enough to not stop early on an escaped quote — this file's own writer output
// never nests an unescaped quote, so that is sufficient here).
static std::vector<std::string> jsonStringValuesForKey(const std::string& json,
const std::string& key) {
std::vector<std::string> values;
const std::string marker = "\"" + key + "\":\"";
std::size_t pos = 0;
while ((pos = json.find(marker, pos)) != std::string::npos) {
std::size_t i = pos + marker.size();
while (i < json.size() && json[i] != '"') {
if (json[i] == '\\') ++i; // skip the escaped char too
++i;
}
values.push_back(json.substr(pos + marker.size(), i - (pos + marker.size())));
pos = i;
}
return values;
}
// True for a value opening with an <alpha>':' drive-relative prefix (":" alone is
// JSON's own key separator, so this is checked on isolated VALUES, never on raw text).
static bool looksLikeDriveForm(const std::string& value) {
return value.size() >= 2 && std::isalpha(static_cast<unsigned char>(value[0])) &&
value[1] == ':';
}
static void testEmittedManifestBytesCarryNoPath() { static void testEmittedManifestBytesCarryNoPath() {
Fixture fx("nopath"); Fixture fx("nopath");
// The bank's own display name AND a sample's displayName each carry a literal
// '/' — free text, unlike the entry `name` / nested `relativePath` fields this
// test actually polices (docs/product/bank-package.md:282-289: the destination is
// derived from the entry name, never from free text). Present in the fixture so
// the scan below proves it is scoped correctly rather than merely holding by
// accident on names that happen not to collide with the rule.
CHECK(fx.session.book().renameBank(fx.bankId, "Drums/Bus"));
// Both source records carry a directory component; neither may reach the file. // Both source records carry a directory component; neither may reach the file.
fx.addSample("s1", "kick.wav", 200, 3); fx.addSample("s1", "kick.wav", 200, 3, /*writeToDisk=*/true, "Kick / alt take");
fx.addSample("s2", "snare take 2.wav", 200, 9); fx.addSample("s2", "snare take 2.wav", 200, 9);
CHECK(exportBank(fx.session, fx.request()).status == ExportStatus::Written); CHECK(exportBank(fx.session, fx.request()).status == ExportStatus::Written);
// Scan the MANIFEST REGION of the emitted file, located by walking the frozen // Locate the MANIFEST REGION of the emitted file by walking the frozen header the
// header the way a reader does: magic | fv | minReader | len+semver | len+JSON. // way a reader does: magic | fv | minReader | len+semver | len+JSON. The binary
// The binary length fields are deliberately excluded — a length whose byte // length fields are deliberately excluded — a length whose byte happens to be
// happens to be 0x2F is not a separator. // 0x2F is not a separator.
const std::vector<std::uint8_t> file = readFile(fx.destPath()); const std::vector<std::uint8_t> file = readFile(fx.destPath());
CHECK(file.size() > 20); CHECK(file.size() > 20);
auto le32 = [&](std::size_t at) { auto le32 = [&](std::size_t at) {
@@ -202,22 +242,31 @@ static void testEmittedManifestBytesCarryNoPath() {
CHECK(manifestAt + manifestLen <= file.size()); CHECK(manifestAt + manifestLen <= file.size());
const std::string manifest(reinterpret_cast<const char*>(file.data() + manifestAt), const std::string manifest(reinterpret_cast<const char*>(file.data() + manifestAt),
manifestLen); manifestLen);
CHECK(manifest.find("kick.wav") != std::string::npos); // the scan is looking at the manifest CHECK(manifest.find("kick.wav") != std::string::npos); // the scan is looking at the manifest
CHECK(manifest.find('/') == std::string::npos);
CHECK(manifest.find('\\') == std::string::npos); // The boundary, pinned rather than assumed: free text legitimately carries '/'.
CHECK(manifest.find("..") == std::string::npos); CHECK(manifest.find("Drums/Bus") != std::string::npos);
CHECK(manifest.find("reasampler_bank") == std::string::npos); CHECK(manifest.find("Kick / alt take") != std::string::npos);
// ':' cannot be banned outright — it is JSON's own key separator — so the check
// is for the drive form specifically: a string value opening with <alpha>':'. // The rule itself: scoped to the two fields the importer derives a destination
// With '/' and '\\' already absent, that covers the drive-relative spelling too. // from — the entry `name` and the nested Sample's own `relativePath` — never to
bool driveForm = false; // `displayName` or the manifest's `bankDisplayName`.
for (std::size_t i = 0; i + 2 < manifest.size(); ++i) const std::vector<std::string> names = jsonStringValuesForKey(manifest, "name");
if (manifest[i] == '"' && const std::vector<std::string> relPaths = jsonStringValuesForKey(manifest, "relativePath");
std::isalpha(static_cast<unsigned char>(manifest[i + 1])) && CHECK(!names.empty());
manifest[i + 2] == ':') CHECK(!relPaths.empty());
driveForm = true; for (const std::string& v : names) {
CHECK(!driveForm); CHECK(v.find('/') == std::string::npos);
CHECK(v.find('\\') == std::string::npos);
CHECK(v.find("..") == std::string::npos);
CHECK(!looksLikeDriveForm(v));
}
for (const std::string& v : relPaths) {
CHECK(v.find('/') == std::string::npos);
CHECK(v.find('\\') == std::string::npos);
CHECK(v.find("..") == std::string::npos);
CHECK(!looksLikeDriveForm(v));
}
} }
static void testFailureInjectedMidWriteLeavesNoPackageAndSparesThePriorFile() { static void testFailureInjectedMidWriteLeavesNoPackageAndSparesThePriorFile() {
@@ -252,6 +301,39 @@ static void testFailureInjectedMidWriteLeavesNoPackageAndSparesThePriorFile() {
CHECK(PayloadBuffer::alive() == 0); CHECK(PayloadBuffer::alive() == 0);
} }
static void testCommitFailureIsAGenuineMidWriteAbandon() {
// The mid-READ injection above (a source vanishing between digest and stream) is
// not what "mid-write" names in export_bank.cpp:115-118/122-125 — those guard a
// failure IN the write itself: appendPayload's stream going bad, or commit's
// rename failing. A directory squatting on the destination (test_package_io.cpp's
// own precedent for a real, not simulated, commit failure) makes every payload
// stream fine and only the final rename fail.
Fixture fx("commitfail");
fx.addSample("s1", "kick.wav", 300, 1);
fx.addSample("s2", "snare.wav", 300, 2);
std::error_code ec;
fs::create_directory(utf8Path(fx.destPath()), ec);
CHECK(!ec);
ExportSurvey survey = surveyBankExport(fx.session, fx.projectDir, fx.bankId);
CHECK(survey.plan.verdict == package::ExportVerdict::Ready);
package::PackageManifest manifest = survey.plan.manifest;
const std::vector<std::string> sources = {fx.absPathOf("kick.wav"), fx.absPathOf("snare.wav")};
std::string failed;
CHECK(digestSources(manifest, sources, failed));
const std::optional<package::EncodedPackage> encoded = package::encodePackage(manifest);
CHECK(encoded.has_value());
const ExportOutcome out = writePackageFile(*encoded, manifest, sources, fx.destPath());
CHECK(out.status == ExportStatus::WriteFailed);
CHECK(fs::is_directory(utf8Path(fx.destPath()))); // the squatting dir is untouched
CHECK(!exists(fx.destPath() + ".rsbanktmp")); // commit()'s own self-clean ran
CHECK(PayloadBuffer::alive() == 0);
fs::remove(utf8Path(fx.destPath()), ec);
}
static void testPayloadChangedBetweenDigestAndStreamAborts() { static void testPayloadChangedBetweenDigestAndStreamAborts() {
Fixture fx("changed"); Fixture fx("changed");
fx.addSample("s1", "kick.wav", 500, 1); fx.addSample("s1", "kick.wav", 500, 1);
@@ -280,14 +362,27 @@ static void testExportTouchesNoProjectState() {
fx.addSample("s1", "kick.wav", 400, 5); fx.addSample("s1", "kick.wav", 400, 5);
fx.addSample("s2", "snare.wav", 400, 6); fx.addSample("s2", "snare.wav", 400, 6);
// The ext-state blob IS the serialized book (shell/persist/ext_state_io), so // saveToActiveProject writes seven keys (shell/persist/ext_state_io.cpp): `banks`,
// byte-identity of that string is byte-identity of what a persist would write. // the legacy-key clear, `view_state`, the tail setting, the tracking ledger, the
const std::string extStateBefore = fx.session.book().serialize(); // version stamp, and the bank-generation counter. This asserts byte-identity of
// the three that have an in-memory string to diff (`banks`, `view_state`, the tail
// setting) plus bankGeneration() (the bank-generation-counter key IS its
// serialization). The legacy-key clear and the version stamp are session-external,
// nothing here to diff against. The tracking ledger has no public accessor to diff
// either, but needs none: exportBank/digestSources/writePackageFile all take the
// session by `const&`, and ReaSamplerSession::recordCreated — the ledger's one
// writer (session.h) — is non-const, so it is not reachable through this call at
// all; the compiler enforces "untouched" here rather than a runtime check proving it.
const std::string bankBookBefore = fx.session.book().serialize();
const std::string viewBefore = fx.session.view().serialize();
const std::string tailBefore = capture::serializeTailSetting(fx.session.tail());
const std::int64_t generationBefore = fx.session.bankGeneration(); const std::int64_t generationBefore = fx.session.bankGeneration();
CHECK(exportBank(fx.session, fx.request()).status == ExportStatus::Written); CHECK(exportBank(fx.session, fx.request()).status == ExportStatus::Written);
CHECK(fx.session.book().serialize() == extStateBefore); CHECK(fx.session.book().serialize() == bankBookBefore);
CHECK(fx.session.view().serialize() == viewBefore);
CHECK(capture::serializeTailSetting(fx.session.tail()) == tailBefore);
CHECK(fx.session.bankGeneration() == generationBefore); CHECK(fx.session.bankGeneration() == generationBefore);
} }
@@ -359,6 +454,7 @@ int main() {
testHealthyExportCarriesEveryPayloadByteExact(); testHealthyExportCarriesEveryPayloadByteExact();
testEmittedManifestBytesCarryNoPath(); testEmittedManifestBytesCarryNoPath();
testFailureInjectedMidWriteLeavesNoPackageAndSparesThePriorFile(); testFailureInjectedMidWriteLeavesNoPackageAndSparesThePriorFile();
testCommitFailureIsAGenuineMidWriteAbandon();
testPayloadChangedBetweenDigestAndStreamAborts(); testPayloadChangedBetweenDigestAndStreamAborts();
testExportTouchesNoProjectState(); testExportTouchesNoProjectState();
testEmptyBankExportsAsAValidZeroEntryPackage(); testEmptyBankExportsAsAValidZeroEntryPackage();
+22
View File
@@ -167,6 +167,27 @@ static void testHostileNamesAreRepairedNotRelayed() {
} }
} }
static void testUniqueNameSurvivesLongExtensionUnderflow() {
// insertSuffix computes room = kMaxEntryNameBytes - suffix.size() - ext.size() in
// size_t; an extension long enough that even a two-digit "_10" suffix pushes the
// sum past the cap must not wrap that subtraction. Ten same-named entries force
// the tenth collision into double digits against a 253-byte extension (253 + 3 =
// 256, one over kMaxEntryNameBytes).
const std::string hostileName = "a." + std::string(252, 'x'); // 254 bytes, otherwise valid
std::vector<ExportCandidate> candidates;
for (int i = 0; i < 10; ++i)
candidates.push_back(present("s" + std::to_string(i), "reasampler_bank/" + hostileName));
const ExportPlan p = planExport(bankOf(candidates));
CHECK(p.verdict == ExportVerdict::Ready);
CHECK(p.manifest.entries.size() == 10);
for (const PackageEntry& e : p.manifest.entries) CHECK(isValidEntryName(e.fileName));
for (std::size_t i = 0; i < p.manifest.entries.size(); ++i)
for (std::size_t j = i + 1; j < p.manifest.entries.size(); ++j)
CHECK(!sameEntryName(p.manifest.entries[i].fileName,
p.manifest.entries[j].fileName));
}
static void testCaseFoldedCollisionsAreDisambiguated() { static void testCaseFoldedCollisionsAreDisambiguated() {
const ExportPlan p = planExport(bankOf({ const ExportPlan p = planExport(bankOf({
present("s1", "reasampler_bank/Kick.wav"), present("s1", "reasampler_bank/Kick.wav"),
@@ -253,6 +274,7 @@ int main() {
testUnreadableFileStaysDistinctFromMissing(); testUnreadableFileStaysDistinctFromMissing();
testUnrepresentableRecordRefusesWholeExport(); testUnrepresentableRecordRefusesWholeExport();
testHostileNamesAreRepairedNotRelayed(); testHostileNamesAreRepairedNotRelayed();
testUniqueNameSurvivesLongExtensionUnderflow();
testCaseFoldedCollisionsAreDisambiguated(); testCaseFoldedCollisionsAreDisambiguated();
testSanitizeNeverReturnsANameTheCodecRefuses(); testSanitizeNeverReturnsANameTheCodecRefuses();
testPlannedManifestSatisfiesTheCodec(); testPlannedManifestSatisfiesTheCodec();
+3 -1
View File
@@ -72,7 +72,9 @@ static void testPayloadCounterTracksMovesNotCopies() {
} }
static void testStreamingRoundTripHoldsOnePayload() { static void testStreamingRoundTripHoldsOnePayload() {
const std::string dest = "pkg_io_scratch.rsbank"; std::error_code destEc;
const std::string dest =
(fs::temp_directory_path(destEc) / "pkg_io_scratch.rsbank").generic_string();
const std::vector<std::uint8_t> header = patternBytes(16, 0xA0); const std::vector<std::uint8_t> header = patternBytes(16, 0xA0);
const std::vector<std::vector<std::uint8_t>> entries = { const std::vector<std::vector<std::uint8_t>> entries = {
patternBytes(1000, 1), patternBytes(500, 2), patternBytes(1, 3)}; patternBytes(1000, 1), patternBytes(500, 2), patternBytes(1, 3)};