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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user