Close the RSBK name-collision class: ASCII case folding, UTF-8 well-formedness, nested-path traversal

All three are format-locked and validated on encode and decode. Repeated known
keys now reject at the root and inside an entry rather than last-wins.
This commit is contained in:
2026-08-02 08:44:24 -04:00
parent 1aebf51938
commit 3909b1072c
8 changed files with 442 additions and 74 deletions
+43 -9
View File
@@ -25,13 +25,22 @@ landing after the format.
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.
- **Path expression is structurally impossible.** Entry names are bare file
names (`isValidEntryName`: no separators, no `..` component, no
drive/UNC/rooted form, no control bytes, no Windows-reserved character, no
trailing dot/space, no DOS device name), enforced on encode AND decode
because a package can arrive from anywhere. There is no field in the format
capable of expressing a path. The rule set is the full authority; see
`package_format.h`'s doc comment for the itemized list.
- **Every name and path in the format is validated on encode AND decode**,
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.
- **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
@@ -48,8 +57,8 @@ landing after the format.
- `package_format` — the contract: magic, `kPackageFormatVersion` /
`kPackageMinReaderVersion`, the ladder comment, the three-way
`classifyPackageVersion` (`Readable` / `TooNew` / `Malformed`), the
entry-name rule, and `PackageHeader`.
`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
@@ -87,11 +96,36 @@ landing after the format.
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.
- **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.
- **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,