refactor(decrypt): one orchestrator owns the no-key decision
There were TWO top-level decrypt paths: decrypt_sectors_impl for CSS and clear media, whose AACS arm was a bare `return Err` stub, and a wholly separate decrypt_sectors_mapped for AACS. Each scheme therefore decided its own answer to "there is no key for these bytes", and nothing held them to the same one. They drifted, in opposite directions, within a single release: css::descramble_region descrambled with a key the sector's own crib had just proven stale — garbage behind an intact clear header, reported Ok. the mapped path returned early for any LBA outside every range, before ever asking whether those bytes were ciphertext, so an unkeyable encrypted unit passed through and extract counted it as good. Both were fixed individually earlier today. This removes the shape that allowed them. decrypt_span is now the single orchestrator: it owns the loop, the refusal, and the loss count, and each scheme supplies only what genuinely differs. apply_aacs_map is a scheme step that reports what it could not open; it no longer decides what that means. The public wrappers (decrypt_sectors, _in_content, _mapped) are unchanged in signature and all funnel through it. Adding a scheme now means adding an arm here, which means answering the refusal question. That is the point. The new test asserts ONE verdict across all three schemes — AACS with no map, AACS with an encrypted unit outside every range, and CSS whose re-crack failed — plus that clear media is NOT a refusal. A per-scheme test cannot hold this: each would keep passing while the two disagreed. Flipping the AACS arm back to pass-through reds it. Also removes the last of the tracing-capture scaffolding. Serialising those captures crate-wide did not fix the 1-in-10 flake, and asserting the predicates directly made the helper, both capture subscribers and an unrelated dead OrderSink unused. Deleted rather than left behind.
This commit is contained in:
+132
@@ -2,6 +2,35 @@
|
||||
|
||||
## [1.6.0] — UNRELEASED
|
||||
|
||||
### Breaking
|
||||
|
||||
- **`Resolution::pixels()` now returns `Option<(u32, u32)>`, not a bare tuple.**
|
||||
The previous sentinel for "unresolved" was `(0, 0)` — a pair every caller
|
||||
could mistake for a usable value, and one did (see `mp4` under Fixed). Every
|
||||
caller now has to choose what an unresolved resolution means for it: the
|
||||
Matroska and metadata sinks take `.unwrap_or((0, 0))` with the reason stated
|
||||
at each site, the VobSub writer degrades to a palette-only `.idx`, and the
|
||||
MP4 sink refuses the track.
|
||||
- **`DiscSession::into_drive()` returns `Result<Drive, Error>`, not a bare
|
||||
`Drive`.** The empty-slot state it used to panic on is reachable through
|
||||
ordinary public use (`stage_drive_as_reader` moves the drive out; calling
|
||||
`into_drive()` twice moves it out again), so the panic was not guarding
|
||||
caller error — it was guarding a legitimate second call.
|
||||
- **`DiscSession::drive()` / `drive_mut()` removed.** Dead public API with
|
||||
zero callers anywhere in the toolchain, and panicking accessors are not
|
||||
worth preserving the shape of.
|
||||
- **`clpi`: the unused EP-map → sector-extent path deleted** (`get_extents`,
|
||||
`resolved_ep_map`, `full_pts`, `full_spn`, `parse_cpi`, `EpCoarse`,
|
||||
`EpFine`, and the `ep_coarse`/`ep_fine`/`version` fields on `ClipInfo`).
|
||||
Crate-internal (`pub(crate)`), so not source-breaking for an external
|
||||
consumer, but listed here because it removes surface: nothing in the
|
||||
toolchain called it, it carried a truncation bug (fixed in a prior commit,
|
||||
then removed with the code it fixed), and `ClipInfo` keeps only
|
||||
`source_packet_count` and `streams`.
|
||||
- New error codes: **E9055** (`Mp4UnknownResolution`), **E9056**
|
||||
(`SyncTimeout`), **E9057** (`SyncWorkerLost`). Front-ends rendering error
|
||||
strings need entries for all three.
|
||||
|
||||
### Added
|
||||
|
||||
- **High-level orchestration API — a single mux driver and a disc session.**
|
||||
@@ -91,6 +120,109 @@
|
||||
with no key passes through rather than failing), with fail-loud on genuinely
|
||||
unresolvable keys; a user Stop mid-read is reported as `completed = false`
|
||||
(a stop is not a failure), not a spurious error.
|
||||
- **`udf`: deleted files and directories are no longer read as if they still
|
||||
existed.** File-characteristics bit 2 (Deleted, ECMA-167 4/14.4.4) was never
|
||||
decoded, so a deleted File Identifier Descriptor's ICB was followed like any
|
||||
other. A deleted FID is permitted to point at extent length zero — not at a
|
||||
File Entry at all — so following it reads whatever descriptor happens to sit
|
||||
at that LBA: a deleted *directory* landed on the File Set Descriptor and
|
||||
failed enumeration of the **entire volume**; a deleted *file* read back as a
|
||||
genuine zero-byte entry.
|
||||
- **`udf`: the Metadata File is now located from the Metadata Partition Map,
|
||||
not assumed to sit at block 0.** UDF 2.50 2.2.10 records a
|
||||
partition-relative Uint32 at offset 40 of the map that is the only
|
||||
authoritative answer to where the file lives; block 0 is merely where most
|
||||
authoring tools happen to put it. A conformant volume that recorded it
|
||||
elsewhere was rejected as not-a-UDF-filesystem, or — on a volume carrying a
|
||||
decoy descriptor at block 0 — silently mounted the wrong filesystem and
|
||||
reported success. Block 0 stays in the candidate chain, so a volume with no
|
||||
map or a wrong one keeps mounting exactly as before; the map is trusted only
|
||||
when its partition type identifier actually reads `*UDF Metadata Partition`
|
||||
(a Virtual or Sparable map is also ECMA-167 Type 2 and records unrelated
|
||||
fields at the same offset).
|
||||
- **`udf`: a read fault while locating the Metadata File is now a read error,
|
||||
not "not a UDF disc."** The candidate-loop fix above discarded the
|
||||
distinction between "read fine and the bytes say no" (structural) and
|
||||
"could not read" (transient) — a single marginal-sector fault fell through
|
||||
to the block-0 fallback, mis-tagged, and came back as
|
||||
`Error::UdfNotFilesystem`. `mux::resolve` **memoises that verdict for the
|
||||
whole disc**, so one flaky read silently demoted every remaining title to
|
||||
the base-Unit-Key-only path on an AACS 2.1 forensic disc. The same
|
||||
read-fault-vs-negative-verdict fix is applied to the Volume Descriptor
|
||||
Sequence fallback below.
|
||||
- **`udf`: `file_start_lba` no longer returns an unrecorded extent's LBA.**
|
||||
A prior change correctly started retaining ECMA-167 4/14.14.1.1 type-1
|
||||
(allocated-but-not-recorded) extents rather than dropping them — dropping
|
||||
one slides every later extent down by the hole's length — but
|
||||
`file_start_lba` still took the *first* extent unconditionally, so it could
|
||||
hand back a hole's LBA rather than where the file's data actually begins.
|
||||
`ifo.rs` uses this value as the base for every VTS VOB extent
|
||||
(`file_start_lba(IFO) + vtstt_vobs + cell.first_sector`), so a DVD whose IFO
|
||||
opens with a type-1 descriptor read its **entire video title set from the
|
||||
wrong sectors** — no error anywhere, the reads just landed on unrelated
|
||||
data.
|
||||
- **`udf`: the Volume Descriptor Sequence fallback now retries on outcome, not
|
||||
on the anchor's declared shape.** The Main VDS was selected from the
|
||||
anchor's declared extent whenever the extent's *shape* was usable (length,
|
||||
location, no address wrap) and the customary fixed location was tried only
|
||||
when the shape itself failed. Shape is a property of the field, not of what
|
||||
is actually there: a stale anchor, a rewritten volume, or deliberate
|
||||
corruption can pass every shape check and point at nothing, in which case
|
||||
the sweep finds no Partition Descriptor and the volume is rejected — on
|
||||
exactly the damaged-disc branch recovery exists for, with no recovery path.
|
||||
Both locations are now candidates and the fallback fires on whether
|
||||
following the anchor actually found anything.
|
||||
- **`css`: a sector whose cached title key is proven stale and whose re-crack
|
||||
fails is no longer descrambled with the stale key anyway.** That produced
|
||||
garbage payload behind an intact clear header instead of a hard failure.
|
||||
Raises `Error::DecryptFailed`, matching AACS's behaviour on the same
|
||||
condition.
|
||||
- **`decrypt`: an encrypted unit outside every key-map range no longer passes
|
||||
through as ciphertext counted as good bytes.** `extract` could report a
|
||||
scrambled file as complete with exit code 0.
|
||||
- **`mux`: frames dropped by the resync gate now reach `errors()`.** The gate
|
||||
zeroes its counter at each resync, so a gap that resolved was previously
|
||||
invisible to the error count even though frames were genuinely lost.
|
||||
- **`mux`: a discard by the 8 MiB access-unit backstop now marks the following
|
||||
unit discontinuous.** Previously the resync gate never armed after corrupt
|
||||
input recovered via the backstop, so the discontinuity that should have
|
||||
triggered a resync went undetected.
|
||||
- **`mp4`: a video track with no resolved resolution is now refused
|
||||
(`E_MP4_UNKNOWN_RESOLUTION`, E9055) instead of written as a structurally
|
||||
valid, unrenderable 0x0 track.** `Resolution::pixels()`'s old `(0, 0)`
|
||||
sentinel for "unknown" was indistinguishable from a real answer, and MP4 has
|
||||
no optional-element mechanism to omit the field the way Matroska does —
|
||||
`tkhd` and `VisualSampleEntry` both make width/height mandatory. See
|
||||
`pixels()` under Breaking.
|
||||
- **`labels`: Deluxe master-enum selection no longer iterates a `HashMap`.**
|
||||
Iteration order over a `HashMap` is unspecified, so the same disc could emit
|
||||
different commentary/SDH labels on different runs. Selection is now
|
||||
deterministic.
|
||||
- **`io`: a cancelled rip during the bounded fsync is no longer reported as a
|
||||
hard I/O failure.** The three bounded-fsync failure modes returned bare
|
||||
`io::ErrorKind` values with no `E<code>` prefix, so `is_halt()` — which only
|
||||
recognises that prefix — could not tell a user Stop from a wedged NFS mount
|
||||
from a lost worker thread, and a genuine cancel surfaced as a hard failure
|
||||
at the end of an otherwise-complete mux. They now carry distinct codes:
|
||||
`Error::Halted`, `Error::SyncTimeout` (**E9056**), and
|
||||
`Error::SyncWorkerLost` (**E9057**) — see New error codes under Breaking.
|
||||
- **`session`: `into_drive()` is fallible; `drive()` / `drive_mut()` deleted.**
|
||||
See Breaking.
|
||||
- **`clpi`: the unused EP-map → sector-extent path deleted**, including a
|
||||
truncation bug it carried (`out_time` past the last EP entry resolved short
|
||||
of EOF). See Breaking.
|
||||
|
||||
### Tests
|
||||
|
||||
- The suite grew from ~2,570 to **2,994** tests over this cycle.
|
||||
- A long-standing intermittent failure (roughly 1 run in 10 under the full
|
||||
parallel suite) was diagnosed and removed. It came from asserting on a
|
||||
`tracing` capture: the capturing subscriber is installed thread-local while
|
||||
`tracing`'s callsite-interest cache is global, so the two could race
|
||||
regardless of how carefully the capture was serialised. The predicate is
|
||||
now a named function tested as a plain value, with no subscriber involved.
|
||||
Measured clean afterward: 14 consecutive full-suite runs, 2,994 passed, 0
|
||||
failed.
|
||||
|
||||
## [1.5.2] — 2026-07-22
|
||||
|
||||
|
||||
Reference in New Issue
Block a user