# src/shell/package — package filesystem + dialog seam ## Scope 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 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 (`package_pickers`). Those are bytes-only — the package format (magic, manifest, entry layout) is `core/package`'s business. Beside them sit both promptless verbs: `export_bank` whole, and the import split so its decisions stay testable — `import_landing` (REAPER-free) decides and writes, while `import_bank` owns the only REAPER project state this directory touches (the ext-state persist, the undo block, the generation bump). The export direction touches none of it: an export writes no ext state, opens no undo point and never bumps the generation, and what prompting it needs belongs to its skin (`shell/actions/package_export_action`), not this seam. ## Invariants - **Paths cross this seam as UTF-8 narrow strings and are converted through `utf8Path()` before ANY filesystem call.** This is not decoration: on Windows `std::filesystem` decodes a narrow path through the runtime ANSI code page (measured `GetACP() == 1252`), so a bare `fs::path(std::string)` turns `café.rsbank` into `café.rsbank` or fails to open it. Every path a verb hands in or gets back — including `listFolderFileNames`' results, which go through `pathToUtf8()` and never `string()` — is UTF-8. `core/util/file_bytes` has the un-converted shape, which is why `readFilePayload` reads through this module's own `PackageFileReader` instead. - **Atomic package write, to the limit of a rename.** A package accumulates in a `.rsbanktmp` sibling in the destination directory and reaches the destination only through `commit()`'s rename (the mono-collapse temp+rename precedent). A failed, aborted, or abandoned write leaves the destination absent or holding its prior contents. This is process-crash atomic, NOT power-loss atomic: `commit()` flushes and closes but does not `fsync`/`FlushFileBuffers`, so a power cut can still leave a renamed-but-unflushed file. Deliberate — an fsync over a whole sample bank is a real stall, and the failure this design targets is a refused or interrupted export. - **Streaming, both ways — at most ONE entry's payload in memory.** Writes append one payload at a time; reads seek and materialize one range at a time. The claim is structural, not aspirational: every payload crosses this seam as a move-only `PayloadBuffer`, and `PayloadBuffer::alive()` is the seam counter the tests assert against. There is no read-whole-package or write-whole-package entry point; do not add one. - **An empty `PayloadBuffer` is a failure signal, never an entry.** It is the seam's one "nothing to work with" branch, so both `PackageFileWriter::appendPayload` and `writeFileExclusive` refuse it — appending it would let a verb commit framing that claims bytes nobody wrote. `appendRaw(ptr, 0)` stays tolerated: framing has legitimate zero-length edges. - **No overwrite of a bank-folder file, ever — and the create is the check.** `writeLandedFile` lands through `writeFileExclusive` (`O_EXCL` / `_O_EXCL`), so the refusal of an occupied path is one atomic act rather than an `exists()` a concurrent 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 destination — the export save dialog's own overwrite confirm is the consent — and that asymmetry is deliberate. **Closed, both halves.** `pickPackageSavePath`'s own `.rsbank` re-append (see its Gotcha below) can turn a confirmed path `X` into a write target `X.rsbank` that the dialog never asked about, so `exportBank` re-checks `fileStatus()` on the path actually handed to `PackageFileWriter` — after any extension append — and refuses `RefusedDestinationExists` until the caller sets `allowOverwrite`. The caller does not always re-prompt to get there: `pickPackageSavePath` reports whether it appended (`outAppended`), and `package_export_action` pre-grants `allowOverwrite` whenever it did NOT — an unappended path is exactly what the dialog's own confirm already covered, so asking again would be a second prompt for the same consent. Only an appended path, one the 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 citation and the full discriminator live at `package_rollback.cpp`'s header. "Did this call create it" is structural: only exclusively-created paths are recorded, resolved absolute at record time so a later CWD change cannot re-aim the delete. "Did anything ever reference it" is a **contract the import verb must honour**: it MUST call `markIndexCommitted()` only AFTER the index write has returned success — calling it before, then having that write fail, strands the landed files with no index entry and a journal that now refuses to roll them back — after which `rollback()` refuses and `writeLandedFile` refuses. (Destroying an armed journal without calling either does NOT roll it back — see `LandedFileJournal`'s own doc comment.) `import_bank` honours it: it calls `markIndexCommitted()` only after `persistBankOp` has returned. - **Integrity is proven before the first byte lands, not undone after.** `landPackage` hashes every declared payload against the manifest and only then creates the bank folder, so a damaged package costs no rollback at all and cannot leave debris behind a rollback that itself failed. The second read of each payload is deliberate on a once-per-gesture path — do not fold it into one hash-and-write pass. - **Both pickers ride `GetUserFileName`** — mode 1 for import, mode 0 for export. There is no platform split and no fallback: `main.cpp` defines `REAPERAPI_IMPLEMENT` without `REAPERAPI_MINIMAL` and aborts the extension load if any single name fails to resolve, so a build that can load us cannot lack it. ## Modules - `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_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`; `pickPackageSavePath` also reports whether it appended `.rsbank` (`outAppended`), the signal `package_export_action` uses to skip a redundant overwrite confirm. 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**: `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. - `import_landing` — the import's two halves that decide anything: `landPackage` (decode, plan, verify EVERY payload's digest, then land through the journal) and `applyImportedBank` (the new bank's entries plus a birth record per landed file, in one straight-line block). REAPER-free deliberately — all-or-nothing, integrity and birth-record behaviour are assertable without a DAW. - `import_bank` — the promptless import verb over a live `ReaSamplerSession`: the project directory, the minted bank id, the `recordCreated` writer, and the one undo-batched persist. REAPER-facing, so it compiles into the extension module rather than into a library with a test target. `package_round_trip_tests` is declared here with no library of its own: it drives the same frozen corpus (`tests/fixtures/package_compat/`) through both verbs, which is where export → import → export payload identity is proven. ## Gotchas - A crash mid-export strands the `.rsbanktmp` sibling. It is not a `.rsbank` (no picker filter matches it), and a later export to the same destination truncates it. A crash mid-import strands a partial bank file under its real name instead — the land is a direct exclusive create, not temp+rename. Either way the debris was never recorded in the tracking ledger, so prune sees a foreign file (not owned, never an orphan) and will not touch it; removal is by hand. `[verify — DAW]` whether the import verb should pre-clean stale debris when it lands. - The picker filter and mode arguments are spelled to `GetUserFileName`'s documented pair format but are `[verify — DAW]` on all three platforms — neither picker is exercised outside a live REAPER session. `GetUserFileName` also takes no owner window, so dialog parenting is REAPER's to do; the superseded Win32 path passed `GetMainHwnd()` explicitly. Also `[verify — DAW]`: whether mode 0's picker appends an extension from `extension_list` when the user omits one — `pickPackageSavePath` re-appends `.rsbank` itself so the returned path is correct regardless of how that lands (the superseded Win32 path had `ofn.lpstrDefExt` for this; `GetUserFileName` has no equivalent parameter). The re-append is suffix-blind: it only skips when the path already ends in `.rsbank`, so a path carrying a DIFFERENT extension gets `.rsbank` appended after it (`mybank.bak` → `mybank.bak.rsbank`), unlike the superseded `ofn.lpstrDefExt`, which appended only when the path had no extension at all. Defensible for a format-locked export, but a real divergence from the old picker's behavior — whoever tests the picker under `[verify — DAW]` should expect the double-extension result on a path that already has one. - `pickPackageSavePath`'s `suggestedPath` doubles as the dialog's starting directory when it is a full path. The verbs should seed it from the project directory — passing a bare name leaves the dialog on REAPER's process working directory, which is its install or resource path. - `readRange(_, 0)` returns an empty buffer — indistinguishable from failure, by design (the one "nothing to work with" branch). **Cross-track contract, not a local rule:** a genuinely zero-length entry cannot round-trip through this seam, so `core/package`'s format layer must not emit one.