b584fa4da9
Ten more were already pointing at unrelated code. Vendor SDK and SWELL citations left alone — those files don't move.
13 KiB
13 KiB
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 tovendor/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 isminReaderVersion <= kPackageFormatVersion. Additive changes (a new optional manifest key, a new enum value with a defined degrade) bumpformatVersiononly; structural changes bump both. The full ladder lives as a comment inpackage_format.hand is READ and validated, never merely written. - TooNew refuses whole. A
minReaderVersionabove 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 nestedSample::relativePathIS a path by design, and is the one field here that can express one. It refuses a..component and every absolute form;BankModel::addchecks only the latter, so traversal would otherwise reach a futureimport_planinside a record the format vouched for. Scope is traversal and absolute-form only — no UTF-8 well-formedness check (unlikeisValidEntryName), no device-name check, no case-fold dedup onrelativePath(unlikesameEntryNameon the entry name). Correct for what this field is — a record field, not a filesystem destination;BankModel::addowns the rest. Forward contract forimport_plan: the destination file is derived from the entry name, never fromrelativePath.
- Framing only, never a payload.
bank_packageproduces header bytes and an ordered{name, offset, length}layout; it never holds, copies, or hashes an entry's audio.decodePackageproves 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
BankModelblob emitted/parsed bybank_model's own codec (thebank_book_jsonprecedent), so a futureSamplefield reaches packages with no change here. - Hostile input: error signaled, never UB — the
bank_model.hdeserialize 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-wayclassifyPackageVersion(Readable/TooNew/Malformed), the three naming rules above, andPackageHeader.package_manifest— the manifest model (PackageEntry/PackageManifest) and its JSON codec. Per entry: bare name, byte length, and a whole-filecapture::hashBytesdigest (deliberately NOThashWavContent, which skips chunks and cannot answer "did these bytes survive") — the digest is carried here, computed where payloads are streamed (shell). The bank'sslot_maprides 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'srelativePathto 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 afterBankBook's own fold, the reminted sample ids and remapped parents, and the per-entry land / collapse / rename disposition. AlsoimportLedgerRefusal(the import's ledger gate, delegating entirely totracking::ledgerDegraded) andledgerRefusalMessage(the gate's console-block body, a pure(LedgerRefusal, namespace) -> stringfold 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 andversion::stampVersion()),decodePackage(prefix + observed file size in; header/manifest/layout out), andrequiredPrefixSize(the incremental-read seam for the shell). Framing ridescore/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
BankModelblob followbank_model's own rule — an out-of-rangesourceMode/tierREJECTS the parse — so growing one of those vocabularies is aminReaderVersionbump for packages, not an additive change. Any enum integer the manifest itself ever adds must instead follow the degrade-to-Unknownrule (core/wire'sBakeStatusprecedent) 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_plandecisions. The codec rejects only what makes the container itself incoherent (duplicate entry names, invalid names, a non-single-sample nested index). import_planconsults 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.requiredPrefixSizetrusts fields beyond the frozen region only when the version pair classifiesReadable; forTooNewit 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 reportedTooNew, notMalformed: the header is valid and already carries the writer's semver, so the refusal can still name what to install. This widensTooNewto 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.classifyPackageVersionand the frozen-regionTooNewpath 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
Malformedeven withformatVersionabove 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 aminReaderVersionbump, 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.
isValidNestedSamplePathpermits a relativerelativePathbecause a record may hold one, butexport_planwrites each shipping entry'srelativePathas its bare, sanitized and disambiguated transport name (export_plan.cpp'se.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 fixedcapture_pathsconstant (capture_paths.cpp'sderiveBankPaths) the importer re-spells. The basename spelling is dropped too, not just the directory:e.fileNameisuniqueEntryName(sanitizeEntryName(...)), not the source basename, so a macOS-authoredHit?.wavsurvives only indisplayName— 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, anddisplayNameis 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.
serializeManifestreturns one indistinguishablenulloptfor every rejection — an unrepresentable name, a case-folded collision, a traversing nested path, a zero-length entry, a recordBankModel::addrefuses — and most of the naming rules are Windows'. A bank ingested on macOS/Linux legitimately holdsHit?.wav,snare .wav, or two names differing only by case, and a nestedrelativePathis only checked for the absolute forms where it is written. Relaying thenulloptmakes ONE such file an unactionable total failure of the whole export.export_planmust 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
sameEntryNamedoes 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. duplicateNamefolds through a hash set, not a pairwise scan. Under thekMaxManifestBytescap (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 onentryNameKey, which issameEntryName'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 (appendPayloadrefuses an empty payload — an empty buffer signals an upstream read failure, not a real entry).serializeManifestrefuses a zero-lengthPackageEntryat 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'sspelledLikeABankFilemints 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;spelledLikeABankFileadditionally mints whenever the package's own name isn't spelled the wayderiveBankPathsspells 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_<tag>.wav) rather than verbatim.ImportPlancounts this separately from a genuine folder-name collision (sanitizeRenameCountvscollisionRenameCount) so the summary line means whatbank-package.md§"Identity and collision on import" (collision rule 2) says it means.