# src/core/package — the pure RSBK bank-package codec ## Scope 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; both have landed. ## Invariants - **The container is the proprietary `RSBK` — ruled, not revisitable here.** No ZIP, no compressor, no link edge to `vendor/WDL/WDL/zlib/`. The version ladder, not a format swap, is how the format moves. - **Two version integers, two jobs.** `formatVersion` = what the writer emitted; `minReaderVersion` = the oldest reader that can read it safely. The reader's whole rule is `minReaderVersion <= kPackageFormatVersion`. Additive changes (a new optional manifest key, a new enum value with a defined degrade) bump `formatVersion` only; structural changes bump both. The full ladder lives as a comment in `package_format.h` and is READ and validated, never merely written. - **TooNew refuses whole.** A `minReaderVersion` above this build yields the header (so the refusal can name the writer's semver and version) and nothing else — no manifest, no layout, no half-success. The fields through the writer semver are FROZEN for all future versions to keep that refusal producible. - **Every name and path in the format is validated on encode AND decode, to the extent stated below**, because a package can arrive from anywhere. Three rules, all in `package_format`, whose doc comments are the itemized authority: - `isValidEntryName` — a payload's name is a bare file name (no separators, no `..` component, no drive/UNC/rooted form, no control bytes, no Windows-reserved character, no trailing dot/space, no DOS device name, well-formed UTF-8 only). Path expression is impossible in this field. - `sameEntryName` — two entry names differing only by ASCII case are ONE name. Windows and macOS's default APFS would extract them onto a single file, and a bank authored on a case-sensitive filesystem produces the pair honestly. - `isValidNestedSamplePath` — the nested `Sample::relativePath` IS a path by design, and is the one field here that can express one. It refuses a `..` component and every absolute form; `BankModel::add` checks only the latter, so traversal would otherwise reach a future `import_plan` inside a record the format vouched for. **Scope is traversal and absolute-form only** — no UTF-8 well-formedness check (unlike `isValidEntryName`), no device-name check, no case-fold dedup on `relativePath` (unlike `sameEntryName` on the entry name). Correct for what this field is — a *record* field, not a filesystem destination; `BankModel::add` owns the rest. Forward contract for `import_plan`: **the destination file is derived from the entry name, never from `relativePath`.** - **Framing only, never a payload.** `bank_package` produces header bytes and an ordered `{name, offset, length}` layout; it never holds, copies, or hashes an entry's audio. `decodePackage` proves prefix + payload lengths equal the observed file size exactly, so truncation and trailing garbage are Malformed without any payload being read. - **Per-sample shape has one owner.** Each entry nests a one-sample `BankModel` blob emitted/parsed by `bank_model`'s own codec (the `bank_book_json` precedent), so a future `Sample` field reaches packages with no change here. - **Hostile input: error signaled, never UB** — the `bank_model.h` deserialize standard, plus allocation caps on every length field so a forged header cannot demand gigabytes. ## Modules - `package_format` — the contract: magic, `kPackageFormatVersion` / `kPackageMinReaderVersion`, the ladder comment, the three-way `classifyPackageVersion` (`Readable` / `TooNew` / `Malformed`), the three naming rules above, and `PackageHeader`. - `package_manifest` — the manifest model (`PackageEntry` / `PackageManifest`) and its JSON codec. Per entry: bare name, byte length, and a whole-file `capture::hashBytes` digest (deliberately NOT `hashWavContent`, which skips chunks and cannot answer "did these bytes survive") — the digest is carried here, computed where payloads are streamed (shell). The bank's `slot_map` rides along. Unknown keys skip at every level; duplicate entry names are rejected both ways. - `export_plan` — the pure export decision over value inputs (the bank's members plus the shell's per-file probe result): the verdict (`Ready` / `Incomplete` / `Refused`), the transport name per shipping entry, and what is excluded and why (missing / unreadable / an index record the format cannot represent). Owns the name repair the codec's refusal backstops, and normalizes each shipping record's `relativePath` to the bare package name — see the transport-name gotcha below. - `import_plan` — the pure import decision, and the reason the whole feature is testable without a DAW: the destination bank's display name after `BankBook`'s own fold, the reminted sample ids and remapped parents, and the per-entry land / collapse / rename disposition. Also `importLedgerRefusal` (the import's ledger gate, delegating entirely to `tracking::ledgerDegraded`) and `ledgerRefusalMessage` (the gate's console-block body, a pure `(LedgerRefusal, namespace) -> string` fold the shell only supplies the channel-correct namespace to). - `bank_package` — framing and arithmetic composing the four above: `encodePackage` (prefix bytes + layout + total size, stamping this build's ladder pair and `version::stampVersion()`), `decodePackage` (prefix + observed file size in; header/manifest/layout out), and `requiredPrefixSize` (the incremental-read seam for the shell). Framing rides `core/wire/bytes.h`. `package_compat_tests` is declared here with no library of its own: it decodes the frozen `.rsbank` corpus at `tests/fixtures/package_compat/`, whose README owns the append-only rule and the per-fixture inventory. ## Gotchas - Enums nested inside the `BankModel` blob follow `bank_model`'s own rule — an out-of-range `sourceMode`/`tier` REJECTS the parse — so growing one of those vocabularies is a `minReaderVersion` bump for packages, not an additive change. Any enum integer the manifest itself ever adds must instead follow the degrade-to-`Unknown` rule (`core/wire`'s `BakeStatus` precedent) to stay additive. The manifest carries no enum of its own today. - Sample-id rules (remap, collision, dedup across the destination) are deliberately NOT enforced by the codec — they are `import_plan` decisions. The codec rejects only what makes the container itself incoherent (duplicate entry names, invalid names, a non-single-sample nested index). - **`import_plan` consults no other bank's hashes, and that is the ruling, not an omission.** An import always creates a NEW bank, so "already present in the destination bank by content" is exactly "already landed by this same plan". Cross-bank dedup is not enforced anywhere (`core/model/CLAUDE.md`), so a hash the pool already holds still lands its own file here. - `requiredPrefixSize` trusts fields beyond the frozen region only when the version pair classifies `Readable`; for `TooNew` it stops at the semver — don't "fix" it to read the manifest length there, a future structural format may have moved it. - A package whose header classifies `Readable` (fv > ours, minReader still within reach — the additive case) but whose manifest fails to parse is reported `TooNew`, not `Malformed`: the header is valid and already carries the writer's semver, so the refusal can still name what to install. This widens `TooNew` to cover "read and failed" as well as "stopped at the frozen region" — both refuse whole and write nothing, so the safety property is unchanged, only the message. `classifyPackageVersion` and the frozen-region `TooNew` path are unaffected; this is the post-manifest-parse branch only. - **The parse branch is the ONLY one that relabels**, deliberately: a newer package that trips the manifest cap, a short manifest read, the layout overflow, or the exact-size proof still reports `Malformed` even with `formatVersion` above ours. The size proof clearly should — "install 1.9.0" does not fix a truncated download — and the other three are indistinguishable from ordinary corruption at the point they fail. Don't "complete" the relabel across them for symmetry; the split is the answer, not an omission. - The format carries no algorithm tag for `byteHash` — it is FNV-1a (`capture::hashBytes`) implicitly. Changing the digest algorithm is a `minReaderVersion` bump, not additive: an old reader would otherwise compare a stored digest against bytes hashed the new way and silently misjudge 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, 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 zero-length entry, a record `BankModel::add` refuses — and most of the naming rules are Windows'. A bank ingested on macOS/Linux legitimately holds `Hit?.wav`, `snare .wav`, or two names differing only by case, and a nested `relativePath` is only checked for the absolute forms where it is written. Relaying the `nullopt` makes ONE such file an unactionable total failure of the whole export. `export_plan` must map bank entries to package names that satisfy these rules (and disambiguate case-folded collisions) before calling this layer; the codec's refusal is the backstop, not the user-facing behaviour. - **NFC/NFD normalization collisions are accepted, not solved.** macOS compares file names normalization-insensitively, so the NFC and NFD spellings of one accented name are two manifest entries that extract onto one file — the same collision class as the ASCII case fold, which `sameEntryName` does catch. A table-free fix does not exist, and restricting names to ASCII would be genuinely over-strict for non-English users. Left open knowingly. - **`duplicateName` folds through a hash set, not a pairwise scan.** Under the `kMaxManifestBytes` cap (64 MB) a minimal entry is ~100 bytes, so a hostile package can declare ~670k entries; the former double loop was ~2×10¹¹ pair comparisons — a multi-minute hang on the decode path an import drives. The set is keyed on `entryNameKey`, which is `sameEntryName`'s ASCII-case fold made explicit, so the equivalence rule still has one home (`lowerAscii`). Do not reintroduce the pairwise scan. - **Cross-module contract with `src/shell/package`:** a genuinely zero-length entry cannot round-trip through the filesystem seam there (`appendPayload` refuses an empty payload — an empty buffer signals an upstream read failure, not a real entry). `serializeManifest` refuses a zero-length `PackageEntry` at encode so this layer never produces one; decode does not enforce it (a hostile/older package declaring one is not this track's concern). - **`import_plan`'s `spelledLikeABankFile` mints a fresh name even with NO collision, and that third condition is a deliberate decision, not spec-derived.** `docs/product/bank-package.md` §"Identity and collision on import" (collision rule 2) ties the auto-rename mint to a *collision* only; `spelledLikeABankFile` additionally mints whenever the package's own name isn't spelled the way `deriveBankPaths` spells one (extension, sanitized stem). Kept for two reasons: uniform folder spelling for every landed file regardless of origin, and — the sharper one — a hostile entry name that isn't a legal Windows filename or carries an unexpected extension (e.g. `evil.exe`) lands sanitized (`evil_.wav`) rather than verbatim. `ImportPlan` counts this separately from a genuine folder-name collision (`sanitizeRenameCount` vs `collisionRenameCount`) so the summary line means what `bank-package.md` §"Identity and collision on import" (collision rule 2) says it means.