# 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.
