Compare commits

..
76 Commits
Author SHA1 Message Date
Matthew Jackson e5540da6d4 v1.2.0: bump version (freemkv-unlock git-pinned for the tag) 2026-07-01 16:53:03 -07:00
Matthew Jackson 9b49beabee changelog: DVD DTS/LPCM silent-audio fix + Pass-N recovery specialists + flat scheduler 2026-07-01 16:50:08 -07:00
Matthew Jackson b0037f2db9 recovery: flat-pool bandit scheduler (FREEMKV_PATCH_FLAT, opt-in)
The tier ladder (bucket->mug->thimble) forces tier 0 to attempt ALL
ranges before tier 1, and tier 1 all before tier 2 — so on a hardened
residual (late resume, flood already gone) the marginal specialists are
unreachable for hours: you bail near-empty buckets over hundreds of dead
ranges before a thimble ever touches water.

FREEMKV_PATCH_FLAT collapses the 3 tiers into ONE flat pool of all 16
handler configs. run_handlers already sorts best-first by the live
decayed-yield scorecard, so this is a data-driven bandit: the first
ranges try them all (explore), the ranking floats whatever is actually
landing sectors to the front (exploit), re-measured per range; a handler
that doesn't fit stays last but is never dropped (floor -> can revive).
The new handlers self-limit (yield after 4 dead reads), so the flat
scheduler does NOT reintroduce the old depth-first per-range starvation.

Opt-in; unset keeps the proven tier ladder as the default for 1.2.0.
2026-07-01 16:44:29 -07:00
Matthew Jackson 90070c81bd test: widen profile_03 efficiency guard for tier-2 roster
The tier-2 marginal specialists now additionally probe permanently-bad
residual sectors before leaving them NonTrimmed, so the alternating-bad
profile's finite read count rose past the old tier-0/1 bound. Guard
still catches runaway; behavior asserts unchanged.
2026-07-01 13:55:20 -07:00
Matthew Jackson b8c2054249 recovery: wire tier 2 marginal specialists into the Pass-N chain
Add a third breadth-first tier (PATCH_TIERS 2->3) that runs the marginal
specialists on the hardened residual tiers 0-1 leave: SlowSpin (Linear
fwd+rev @ min), FuaRetry (Linear fwd+rev+Bisect @ FUA), SlowFua (Linear @
min+FUA), CachePrime, Oscillate (@ max and @ min), SpeedSweep. Every read is
a wedge-safe read_span, so they inherit wedge-abort / unproductive-yield /
deadline for free. All are new configs, so the EWMA scorecard calibrates each
once then ranks by decayed rate — a specialist that doesn't fit self-
deprioritises. Tiers 0-1 (fast scouts, slow-deep) are unchanged; this is
purely additive. Also switch the scorecard log sort to sort_by_key.

cargo test -p libfreemkv green (2200 passed).
2026-07-01 13:50:35 -07:00
Matthew Jackson c4357d9d87 recovery: CachePrime + Oscillate handlers + fixtures
CachePrime reads the good run immediately preceding a residual island to lock
the servo/PLL, then reads the island warm (boundary sectors the drive can't
cold-seek). Oscillate reads each residual sector by alternating approach —
forward-into then reverse-into — for direction-dependent tracking. Both go
through the wedge-safe read_span (primes included). FakeDisc models a
direction-dependent sector and a servo-primed boundary sector; fixtures prove
a forward/cold Linear misses each while Oscillate/CachePrime recover them.
2026-07-01 13:50:35 -07:00
Matthew Jackson 63c1d5db63 recovery: FuaRetry + SlowFua fixtures (FUA / min+FUA levers)
FuaRetry (Linear fwd+rev+Bisect at {max,fua,deep}) and SlowFua (Linear at
{min,fua,deep}) are Linear/Bisect at FUA params, no new struct. FakeDisc now
models a stochastic sector that lands only on the Nth physical (FUA) read (a
cached re-read keeps missing) and a hardest sector needing BOTH min speed and
FUA. Fixtures prove cached reads keep missing while the FUA group lands the
stochastic sector, and that neither lever alone recovers the hardest sector —
only the min+FUA combination does.
2026-07-01 13:50:35 -07:00
Matthew Jackson e91ce25db3 recovery: SpeedSweep handler + slow-spindle fixtures (SlowSpin/SpeedSweep)
SlowSpin is Linear pinned to min speed (no new struct). SpeedSweep is a new
per-sector handler that tries Max->Min until one reads (speed resonance).
FakeDisc now models a sector that reads ONLY at min speed; fixtures prove a
max-speed Linear misses it while SlowSpin and SpeedSweep recover it (and that
SpeedSweep tries fast-then-slow).
2026-07-01 13:50:35 -07:00
Matthew Jackson 2c94ce97c6 recovery: decayed (EWMA) handler scorecard so the ranking flips
Grade handlers by an exponentially-decayed recovery rate (bytes/second,
alpha=0.5) instead of a cumulative rate. The residual hardens mid-pass, so
the best technique changes: cumulative froze the early winner in the lead
forever; the EWMA re-prices continuously — a handler that stops earning
decays down, a late-starting specialist climbs. Keeps rank()'s untried->top
one-shot calibration and attempted-but-zero-time->bottom.

New flip fixture proves a handler that recovers a lot early then nothing
loses its lead to one that starts recovering later.
2026-07-01 13:50:35 -07:00
Matthew Jackson e7998a1376 recovery: parameterize the read primitive with ReadParams (speed/FUA/timeout)
Add ReadParams { speed: SpeedPref, fua: bool, timeout: TimeoutPref } and
thread it through read_span so every wedge-safe handler read can request a
spindle speed (SET CD SPEED issued only on change, restored to max when the
handler exits), set the READ(10) FUA bit, and pick the 10s vs 60s timeout.

- SectorSource gains read_sectors_fua (default ignores fua); Drive sets the
  CDB bit, DecryptingSectorSource threads fua to its inner read.
- recovery_read gains a fua param.
- Linear becomes { direction, params }; Bisect/Jump take params. Existing
  tier-0/1 instances keep identical behavior (max speed, no FUA, fast/deep).
- Scoreboard keys on the full-config String name (linear:fwd:max:fast, ...).
- FakeDisc observes speed + FUA + approach so specialist techniques are
  provably exercised in later commits.

cargo test -p libfreemkv green (2193 passed).
2026-07-01 13:50:35 -07:00
Matthew Jackson ba6ac3ccdb dvd: route private_stream_1 audio sub-ids by positional stream number
The private_stream_1 sub-stream id low nibble is the DVD audio-stream
NUMBER (0-7), shared across codecs, not a per-codec ordinal. A DTS (or
LPCM) track that isn't the disc's first audio stream got a sub-id one
too low, so the demux routing key (0xBD00 | sub_id) never matched and
every packet was dropped -> present-but-silent track. AC-3 at position
0 coincidentally worked (ordinal==position). Route by positional index.

Fixes silent DTS 5.0 track on The Punisher (AC-3 5.1 @ pos0 plays,
DTS 5.0 @ pos1 was 0xBD88, real wire id 0xBD89).
2026-07-01 13:49:55 -07:00
Matthew Jackson 1d1fe984d7 lib: remove standalone sector-health verify feature
Delete src/verify.rs (standalone 'read every sector, classify health'
module) + its lib.rs pub mod/pub use. It was old, unused code. The
decrypt-verify gate (disc::verify / UnitVerifier) is a DIFFERENT module and
stays. Relocated the pure chapter_at_offset helper (used by locate_ranges +
autorip's done-card) into disc/mod.rs as a pub fn.
2026-07-01 13:09:21 -07:00
Matthew Jackson 9298534b4b recovery: audit fixes — live at-risk excludes NonTried, latency-gated wedge, drop dead block counters
- Live located drilldown (sweep + patch progress snapshots) now excludes
  NonTried, matching the one-shot path. Including the unread remainder made
  main_at_risk_ms show ~full-movie at sweep start and melt to 0 as it
  progressed — unread is unknown, not damage.
- Wedge abort now requires the failure to be FAST (< WEDGE_FASTFAIL_MS,
  500ms) as well as wedge-family sense: a real uncorrectable sector on
  Hardware-error media spends ECC-recovery time before failing, so it no
  longer false-trips the fast-fail wedge abort. New regression test.
- Removed the always-zero blocks_attempted/read_ok/read_failed/
  unreadable_count from PatchOutcome + PatchLoopState (dead residue from
  the old grind loop; the HandlerScoreboard supersedes them) so the
  patch.done log no longer emits misleading zeros.
2026-07-01 12:07:08 -07:00
Matthew Jackson 6799890379 docs: 1.2.0 changelog — Pass-N handler chain, DVD drive-prep unlock fix, unlocker matrix 2026-07-01 11:05:31 -07:00
Matthew Jackson f36163ad02 disc: unlocker_matrix reports 'did work this rip', not 'matched'
yes now means the unlocker actually ran and did its job: LibreDrive from
the runtime firmware-unlock success, AACS host-cert only when LibreDrive
didn't do the bus (stock-drive fallback), CSS from the crack succeeding.
On a LibreDrive UHD that correctly reads LibreDrive: yes, AACS: no (LD
removed the bus, the cert route never ran) instead of the misleading
AACS: yes. Names stay registry-driven; runtime logic lives here.
2026-07-01 10:44:39 -07:00
Matthew Jackson 73ae12ed1f drive: disable FUA on READ(10) — the ~10x sequential-read regression
Forcing FUA (Force Unit Access) on EVERY READ(10) bypassed the drive's
readahead/streaming cache on the bulk Pass-1 sweep, collapsing sequential
throughput ~10x (UHD 15-25 → ~2 MB/s, DVD → ~0.5 MB/s), disc-type-
agnostic — the cache IS the streaming throughput. This was the real speed
regression (not unlock/riplock, which the earlier chase suspected). Clear
byte-1 bit 0x08. FUA will return as a dedicated Pass-N recovery handler
that sets/clears it per marginal-sector re-read, where cache-masking of a
stochastic sector actually matters — never blanket on the bulk path (#55).
2026-07-01 10:26:56 -07:00
Matthew Jackson 2f47b552e8 disc: Disc::unlocker_matrix() — registry-driven unlocker report
Returns each registered unlocker's name + whether it applies to this
drive+disc (via the unlock bridge over all_unlockers()). Disc crypto kind
derived in the library so the CLI and autorip render an identical, always-
current report with no hardcoded names. Also adds a Drive::unlocker_name()
runtime getter.
2026-07-01 10:00:59 -07:00
Matthew Jackson 89c4acdf8f drive: run drive-prep unlock for EVERY disc, incl. DVD (fix riplock regression)
The v1.0.0-rc.1 unlocker refactor added an `if disc_is_dvd() { return }`
early-return in Drive::init() that skipped the whole drive-prep unlock for
DVDs. That firmware unlock is what lifts riplock and readies max read
speed — a DRIVE-level, disc-independent feature — so skipping it left
every DVD stuck at stock/riplock speed (~0.4x, 3h ETA). UHD was unaffected
because it flows through the unlock.

Remove the skip: init() now runs the identity-keyed drive unlocker for all
discs (disc kind is Unknown at init, so only the drive unlocker matches;
the AACS host-cert handshake and CSS bus-auth still run later, gated on the
real disc kind, on top of the unlocked drive). Speed stays where it
belongs — SET CD SPEED(0xFFFF) at pass-1 start (disc/mod.rs) — not in the
unlocker. Also drop the matching probe_disc DVD skip.

Reverts the SET STREAMING stopgap (separate freemkv-unlock revert): that
treated the symptom; the real bug was the skipped unlock. Drive features
come from the firmware unlock, not a stock speed CDB.
2026-07-01 09:38:51 -07:00
Matthew Jackson 1d5a730853 recovery: fresh-eyes audit fixes (handlers + Pass-N engine + sweep)
Handlers (section_recover.rs):
- Bisect expand loops now honor ctx.halted() (were deadline-only, so a
  Stop could hang up to 60s vacuuming a readable island).
- read_span: explicit Transport arm so a bus-abort read isn't counted as
  unproductive grinding; debug_assert the sector-aligned span invariant.
- Scoreboard rank: an attempted-but-zero-time handler (e.g. returned Halted
  on its first check) now ranks BOTTOM, not top — it no longer crowds out
  proven performers.
- Document the wedge tier-size coupling + new regression test that a
  2-handler (tier-1) chain still catches a wedge via cross-section streak.

Pass-N engine (patch.rs):
- Rebuild PatchOutcome stats AFTER the post-read re-verify downgrade flush
  (was snapshotting before it, over-reporting bytes_good / recovered and
  risking a 'perfect rip' verdict on an imperfect one).
- Progress 'recovered' composes the still-bad set to MATCH work_total
  (subtract NonTried, add Unreadable) so the bar can't pin at 0 on a
  partially-swept disc or run backward on the Unreadable→NonTrimmed relabel.
- Remove dead work_done field; rewrite the stale 'adaptive batching' comment
  to describe the handler chain and mark block_sectors/full_recovery as
  informational-only.

Sweep (disc/mod.rs):
- Saturating arithmetic at the damage-jump position math (honor the
  read_error side's documented defence-in-depth guarantee).

Deferred (noted, need focused passes): fast_capture re-introduction,
Pass-1 halt-misclassified-as-jump, bytes_good display inflation, the
always-zero blocks_* telemetry, Pass-1 jump-on-first-error policy.
2026-07-01 09:14:32 -07:00
Matthew Jackson 0a0a8f1327 drive: apply drive-features for every disc, incl. DVD (fix riplock)
DVD ran the drive at ~0.4x stock speed: init() early-returned for DVD
before any speed was set, and its only speed command was the sweep's lone
legacy SET CD SPEED, which this BU40N ignores for DVD. BD/UHD got up to
speed only via the firmware unlocker's calibration — a path a DVD can't
take (it breaks stock CSS).

Call the new unlock-crate drive-features capability at init() for ALL disc
kinds, before the DVD stock-mode early-return, via a bridge shim. Stock
MMC only (SET STREAMING + SET CD SPEED), no bus unlock, so CSS is
undisturbed.
2026-07-01 08:56:19 -07:00
Matthew Jackson e396dcaab7 Pass-N: detect drive fast-fail wedge and abort instead of grinding
The 2026-07-01 overnight rip ground a wedged BU40N for 28 minutes at
0 B/s. Root cause: the handler chain's read_span split errors only into
Transport (bus gone) vs Bad (everything else), so the drive's fast-fail
wedge sense (ILLEGAL REQUEST 0x05/0x24 — it rejects every CDB in <100ms
without attempting recovery) was treated as an ordinary bad sector. The
chain hopped to the next section forever, and the existing wedge detector
in read_error.rs never saw it because the chain reads through
recovery_read directly.

Add a pass-level wedge streak: read_span counts consecutive wedge-family
(Hardware/IllegalRequest) senses; at WEDGE_ABORT_STREAK (16) it escalates
the read to Transport, which every handler already propagates as
TransportFault — aborting the whole pass and setting wedged_exit so the
caller spin-cycles. The streak is carried across sections via PatchCtx
(seeded into and read back from each per-section HandlerCtx) so a wedge is
caught even when every bad sub-range is smaller than the streak. Any Good
or non-wedge (medium-error) read resets it, so scattered bad sectors on
real media never trip it. New fixture test: a wholly-wedged 1000-sector
section aborts in <100 reads, not 1000.
2026-07-01 08:02:52 -07:00
Matthew Jackson f6f68cb9dc Bisect: halve at dead boundary + exempt boundary probes from stall
The expand loops broke out of the batch sweep on the first failing read,
overshooting the good/dead boundary by up to a batch and leaving the
readable sectors adjacent to a dead edge for the re-bisect to re-pin. On
top of that, early-yield counted those boundary-probe failures as a
stall, so the forward expand quit early AND the backward expand inherited
a poisoned unproductive counter and yielded instantly.

Now on a failed expand batch we halve the step (down to a single sector)
to recover right up to the dead edge in ~log2(batch) reads, drive the
expand loops off the deadline only (they self-terminate via halving), and
clear the unproductive streak once an island is located. patch_recovers_
good_middle_of_a_bad_range now recovers 50/50 good-middle sectors.
2026-06-30 22:06:04 -07:00
Matthew Jackson bdafec8081 section_recover: per-rip handler scorecard + Bisect-leads-scouts + proportional Jump
Scorecard: grade each handler by recovery rate (MB/s) per rip, order
best-first on later sections, log the ranking at pass end. Untried
handlers rank top so each is calibrated once before ranking narrows to
the winners. Ephemeral (reset per pass), no persistence.

Tier 0 scouts are now [Bisect, Jump, Linear-fast x2], scorecard-ordered.
Bisect leads: probing the MIDDLE of a range lands on a readable island in
one read where a linear scan grinds the dead front to reach it. Jump now
jumps to the middle of the REMAINING span (proportional) instead of a
fixed 8 MiB that leapt clean over small ranges and missed their readable
middles. Tier 1 is slow deep-recovery on the residue.
2026-06-30 21:26:35 -07:00
Matthew Jackson 71f6cb17a8 section_recover: per-rip handler scorecard + Bisect leads the scouts
Scorecard (ephemeral, reset each pass, no persistence): grades every
handler by recovery rate (bytes/sec). run_handlers orders handlers
best-first by that rate; an untried handler ranks top so each is
calibrated once, then the ranking narrows to the winners. Logged at pass
end (phase=scorecard) so the operator sees which handler is pulling the
weight on this drive/disc and which is a dud.

Tier 0 scouts are now [Bisect, Jump, Linear-fast x2], scorecard-ordered.
Bisect leads because probing a range's MIDDLE lands on a readable island
in one read, where Jump (linear from the front, big skip) can grind the
dead front or overshoot a small range entirely. The scorecard confirms
or overturns that order with real per-disc data.
2026-06-30 21:25:39 -07:00
Matthew Jackson 326868ec64 section_recover: Jump starts at 8 MiB base to clear big dead spots faster
Each dead probe read costs the drive's full ~10s timeout, so a large dead
region took a dozen escalating probes. Starting the jump at 8 MiB (vs
1 MiB) clears it in a handful; a skipped span stays bad for Bisect to
reclaim readable islands, so an over-jump loses nothing.
2026-06-30 21:12:12 -07:00
Matthew Jackson ebb3227294 patch: single Jump-scout tier 0, expand-Bisect, live in-handler progress
Tier 0 is now a single fast Jump scout: it streams the big readable
ranges back and skips dead runs in seconds, so the pass reaches every
section fast and converges to the small genuine-dead residue instead of
grinding three handlers x 60s on each dead fragment. Tier 1 (fast
mop-up + slow deep reads + Bisect) works only that residue.

Bisect now expands: on a good probe it reads outward forward and
backward in full batches until a read fails, recovering the whole
readable island in large reads; the two failing ends become smaller bad
sub-ranges it bisects again. One huge bad range becomes many precisely
located small dead clusters.

Progress heartbeat: HandlerCtx gains a throttled tick (250ms) called
from every read, pushing a fresh snapshot to the reporter DURING a
handler. The bar and speed now move continuously as recovery happens
instead of jumping once per section (the reason speed read 0 B/s and the
% looked frozen between range boundaries).
2026-06-30 20:57:36 -07:00
Matthew Jackson e8b3c719e6 patch: add Jump handler (lead fast tier) + recovery-based progress %
Jump: on sustained batch failures skip ahead an escalating distance
(1 MiB doubling to 256 MiB) to find where readable data resumes, leaving
the skipped span for Bisect to pin — mirrors the Pass-1 damage-jump. It
leads the fast tier so a large dead run is skipped in seconds instead of
the linear sweeps grinding every dead batch (10 s each) first; on a
readable range it just streams it back. Recovers readable data buried
behind a big dead front (the 192 MB Dune range).

Progress %: report bytes RECOVERED (initial-bad minus still-pending)
instead of a per-range counter that only advanced on the final tier — so
the bar reflects the readable bulk recovered during tier 0 the instant it
lands, matching the 'MB remaining' number.
2026-06-30 20:40:22 -07:00
Matthew Jackson 4e65f87b71 patch: breadth-first two-tier recovery, largest ranges first
The per-range walk was depth-first: each bad range ran the full handler
chain (fast + slow deep-recovery + bisect) before the next range was
touched. So a handful of tiny dead fragments at one end of the disc
burned the whole pass and the big NonTrimmed ranges elsewhere — usually
sweep-jump over-marks that read straight back — were never attempted.

Now recovery runs in two breadth-first tiers over ALL sections:
- Tier 0 gives every section one fast full-batch attempt (fast reads
  only), largest ranges first, so the recoverable bulk of the disc comes
  back in the first minutes.
- Tier 1 deep-recovers only the residue tier 0 could not pull.
Per-section still-bad sets persist across tiers. Largest-first ordering
means a big readable region is reached before time is spent on tiny dead
fragments.

Linear no longer collapses a failed batch to count=1 single-sector reads
(live probing: a marginal sector recovers in a large read, not a lone
one) — a failed 32-batch stays 32 and is re-attempted at full size by the
next handler/pass; Bisect salvages readable islands.

Adds a handler-start trace line so the debug log shows which handler is
running and the hand-off to the next.
2026-06-30 20:25:48 -07:00
Matthew Jackson c5e4fa7109 patch: replace grind-until-wedge loop with bounded handler chain
Pass-N recovery is now a chain of time-bounded recovery handlers instead
of one monolithic per-range loop that could grind the front of a bad
range for up to 30 min, wedge the drive, and abort the whole pass.

A bad range is a SubRanges set; recovery is an ordered list of
SectionHandlers (Linear{reverse,fast} covering back/forward x fast/slow,
and Bisect). A coordinator runs each handler with a hard per-handler
deadline: a handler recovers what it can (removing it from the still-bad
set) and hands the rest to the next handler; whatever is still bad after
the chain becomes NonTrimmed residue and we move on to the next range.

Guarantees, now structural rather than bolted-on:
- never hangs: every handler is deadline-bounded; the loop always drains
  to recovered-or-residue.
- always moves on: a range that cannot be finished leaves residue and
  advances; only a genuine transport fault or user halt ends the pass.
- extensible: a new recovery idea is one SectionHandler impl added to the
  chain; a proven-ineffective one is removed. The engine never changes.

Removes ~1.9k lines of the old inner loop (watchdogs, skip escalation,
NOT_READY grind, wedge counters) and their tests. fast_capture is now
inert (the chain supersedes it); breadth-first ordering becomes a future
scheduler concern. New module: disc/section_recover.rs (8 fixture tests,
injectable clock — bounded/never-hang proven without touching a drive).
Two A/B tests updated to the chain's strictly-better recovery counts.
2026-06-30 19:55:37 -07:00
Matthew Jackson b67eaab3a8 drive: always set FUA on READ(10) to bypass the drive cache
Every Drive::read now forces the read from physical media instead of
letting the drive satisfy it from cache. A recovery tool must not trust
the cache: on a re-read of a marginal sector the BU40N can return a
cached miss (~4 ms) instead of giving the surface a fresh physical
attempt (~40-250 ms), masking a sector that would otherwise read on a
real media hit. FUA (READ(10) byte 1 bit 3) is validated on the
BU40N/Initio bridge.
2026-06-30 18:49:14 -07:00
Matthew Jackson dfc54891c5 drive: Drive::spin_cycle() — soft un-wedge via START STOP UNIT (no eject)
Spin the disc down (START=0, LOEJ=0) then back up (START=1) to clear the
BU40N/Initio fast-fail wedge state a run of HARDWARE_ERROR reads leaves the drive
in — the non-eject power-cycle our notes say the wedge needs. The disc stays
loaded; we never eject (slot-loading — a human eject is a product failure for an
unattended service). Validated live 2026-07-01: took the drive from
failing-every-read back to reading at MB/s.
2026-06-30 17:54:51 -07:00
Matthew Jackson 4169cf7f4f disc/patch: fast-capture reads fail-fast (no deep recovery)
A fast_capture pass defers every failed block to a granular pass anyway, so
spending the drive's 60s deep-recovery timeout on it here only freezes the
breadth-first sweep on a pure-bad cluster (~25s per 32-block, incl. NOT_READY
grind). Read with recovery=false in fast_capture so a bad block fails on the
short timeout (~10s) and the sweep steps on; the granular passes
(fast_capture=false) do the deep recovery on what's left. No recovery lost —
the block stays NonTrimmed for the granular retry.
2026-06-30 17:08:26 -07:00
Matthew Jackson edd316b8a8 docs: 1.2.0 changelog — fast_capture breadth-first patch recovery 2026-06-30 16:38:56 -07:00
Matthew Jackson c57ac14ad1 disc/patch: fast_capture mode — breadth-first recovery (#50)
A PatchOptions.fast_capture pass reads each bad range ONCE at the full batch and
leaves every FAILED block NonTrimmed for a later pass — no bisect, no per-sector
grind, no retry. This lets a first retry pass grab the readable blocks (the
sweep's good skip-ahead overshoot) of EVERY section quickly, before any single
section's slow per-sector recovery — instead of grinding section 1 to exhaustion
before even touching section 2. A later pass (fast_capture = false) does the
granular bisect/retry on what's left.

Load-bearing invariant (fixture test): NO data is dropped. A failed block becomes
NonTrimmed (pending, retried by a granular pass), NEVER Unreadable. The test pins
that the readable half of a range recovers, the bad half stays NonTrimmed (not
Unreadable), and the bad block is marked in ONE batch read with zero bisection.
Disc::copy's internal patch keeps fast_capture=false (single-call full recovery).
2026-06-30 16:37:59 -07:00
Matthew Jackson 9154ac12d6 docs: 1.2.0 changelog — PassProgress mapfile-free contract + located drilldown 2026-06-30 16:13:05 -07:00
Matthew Jackson ec3d072ec7 disc: add progress_snapshot_from_mapfile one-shot + locate_ranges test
The library now offers a one-shot PassProgress snapshot built from a mapfile on
disk + title, so a client gets the fully-rendered drilldown without parsing the
mapfile itself — used for autorip's pass-boundary and mux-entry paints (no live
callback yet at those points). Adds a locate_ranges test covering in-feature vs
out-of-feature at-risk time (ported from autorip's removed from_map tests).
2026-06-30 16:09:28 -07:00
Matthew Jackson bbf04c3d51 progress: PassProgress carries the located drilldown (emit side)
Add LocatedRange + LocatedProgress to the progress contract and a 'located'
field on PassProgress, populated by the sweep + patch emitters from the
in-memory bad-range set + title. Move the range->chapter/time annotation
(locate_ranges, range_chapter, byte_offset_in_title) into the library so a
client renders the disc map + at-risk movie time straight from PassProgress
and never reads the mapfile itself — if the mapfile becomes a mapdb, this type
and its producer change, clients don't.

PassProgress is no longer Copy (located carries a Vec); it's built once per
throttled emission and passed by reference. Non-locating phases (verify,
extract) emit LocatedProgress::default(). Adds consts::MILLIS_PER_SEC.
Consumer-side wiring (autorip drops Mapfile::load) follows.
2026-06-30 15:26:49 -07:00
Matthew Jackson d4f8c86cbc disc/patch: remove scatter-recovery (ruled out by live probing); add SubRanges
Live drive probing (cold-single vs in-stream batch vs centered window,
one clean uncached read per sector) showed recovery RATE is identical
across approach: 3/5 every mode. The drive's per-sector ECC is
media-bound and partly stochastic, NOT approach-bound. So scatter
(seek-away recalibration + far-anchor re-read) does not improve recovery
and is dead weight — removed (function, read_good_sectors, SCATTER_*
consts, and its tests).

Add SubRanges: the still-bad (pos,len) sub-ranges of a section with
remove()/split, the work-list foundation for the upcoming per-section
recovery decomposition (recover_section -> stream/bisect/retry phase
helpers). Unit-tested; unused until the decomposition lands.

25 patch + 8 AB profile tests green; precommit clean on Rust 1.86.
2026-06-30 13:56:49 -07:00
Matthew Jackson a4da4dbe24 disc/patch: keep MAX speed on recovery reads + geometric batch re-grow
Live probing on the BU40N/UHD testbed (rprobe/dprobe) settled the speed
question empirically: a marginal sector reads ~12x FASTER at MAX speed
than at slow, and slow NEVER recovered one MAX didn't. Cleanly-tested
(one uncached read per sector) the recovery RATE is identical across
speed/direction/window/batch — the drive's per-sector ECC is media-bound,
not approach-bound. The only host lever that matters is read SPEED.

So the old 'drop to 0x0000 on first failure and hold it for the whole
range' only slowed the GOOD sectors of a bad range — measured ~3x slower
overall. Now the range stays at MAX; the first failure just re-attempts
once (stochastic media) and falls through. Live A/B: 11 KB/s -> 33 KB/s,
bytes_lost unchanged.

Also: batch adaptation is now halve-on-failure / double-on-success
(geometric), so a bisected batch climbs back through clean runs from any
size (the old count==1-only upscale left it stuck at mid sizes). Removed
the dead consecutive_singles_ok counter and the inert batch-retry probe.

25 patch tests green; precommit clean on Rust 1.86.
2026-06-30 13:25:07 -07:00
Matthew Jackson e6dfe54e10 disc/patch: drop cache-priming, bisect batch failures instead of count=1
Two patch-recovery speed fixes, grounded in live rip1 telemetry on the
Dune UHD bad regions.

1. Pull prime_cache. In the reverse walk it issued throwaway reads of
   lba-3..lba-1 — i.e. straight into the not-yet-visited dead L-EC core —
   grinding each ~7-9s at the fast timeout, then discarding the result.
   The live trace showed a good target read (read_duration_ms=1) taking
   9s wall-clock purely from the prime grind ahead of it. The dead-zone
   skip (Tier 1) + scatter's recalibration read (read_good_sectors)
   cover the cache-warm intent without grinding the dead zone, so prime
   is redundant harm — removed (function + CACHE_PRIME_SECTORS + call).

2. Bisect on batch failure. handle_read_failure used to collapse a
   failed count>1 batch straight to count=1 and single-walk the entire
   batch. Now it halves (count/2) and retries the same start: a good
   half recovers in BULK, the bad sector is isolated in O(log n) reads
   instead of O(n). Invariant preserved — only a count==1 failure marks
   NonTrimmed, so no good sector is lumped into a bad mark.

These are Tier 2 (bisect) of the progressive recovery model
(fast-mass-skip -> bisect -> scatter); composable so order can be tuned.
25 lib + 8 AB fixture tests green; precommit clean on Rust 1.86.
2026-06-30 11:20:44 -07:00
Matthew Jackson c68d40887a disc/patch: add scatter-recovery for stuck bad sectors
The 'reset, read good data, come back for one sector' technique. A
genuinely-damaged sector makes the drive grind its full recovery timeout
and still fail; re-reading consecutive bad LBAs at identical conditions
re-fails AND is the rapid-failure cadence that drops the BU40N into a
firmware fast-fail wedge (CLAUDE.md hard-rule #2).

scatter_recover: on a stuck single sector, make up to SCATTER_MAX_ATTEMPTS
fresh tries, each preceded by read_good_sectors() — a seek to a far
known-good anchor + a sustained read that re-seats the head/servo and
breaks the failure cadence. The fresh re-read uses the FAST timeout
(recovery=false) so a recalibrated marginal sector reads quick and a
truly-dead one fails fast instead of burning 60s per attempt. The seek +
good read IS the settle (matches ddrescue/MakeMKV) — no idle sleep.

Gated to genuine single-sector MEDIUM_ERROR (0x03): transport faults
still abort the pass, NOT_READY keeps its own retry path, wedge-family
senses are left for cooldown/eject. A scatter recovery is recorded
exactly like a normal read success (write-then-mark-Finished); nothing
marks a range good without a successful read + write.

Logs anchor_ms/reread_ms per attempt for live tuning. 5 fixture tests
(recover-marginal, give-up-dead, skip-non-medium, skip-batch, AACS
widen); 2 AB read-count guards raised for scatter's bounded extra reads.
Precommit green on Rust 1.86.
2026-06-30 10:29:55 -07:00
Matthew Jackson ad9ea4c199 disc/patch: extract PatchCtx/patch_region orchestration + unify log taxonomy
Pure structural refactor of the Pass-N recovery path, no behavior change.

- PatchCtx<R> holds the one-pass context (reader, pipe, shared, opts,
  reused read buffer, cross-region state) and owns orchestration.
- PatchCtx::run() walks the ordered bad ranges, applies the inter-range
  cooldown (armed on a grind, consumed at the next range's entry), and
  stops the pass the moment a range reports halt/wedge/transport-fault.
- PatchCtx::patch_region() is the focused single-range loop: read ->
  success/failure -> damage-skip -> per-range watchdog. Every former
  break / break 'outer is now a typed RegionOutcome (Completed,
  SkipLimit, BudgetExceeded, Wedged, Halted, TransportFault) that run()
  maps back to next-range vs stop-pass exactly as before.
- recovery_read() extracts the AACS mid-unit-window widening + plain
  read into one reusable helper (the building block the upcoming
  scatter re-read will share).
- Logging unified onto a single dotted patch.* taxonomy (patch.region.*,
  patch.read.*, patch.probe.*, patch.wedge.*, patch.batch.*,
  patch.mapfile.*, patch.skip.*, patch.stall, patch.speed); the
  freemkv::scan pass-boundary markers are left intact.

20/20 disc::patch tests green; precommit (fmt + clippy -D warnings +
tests) clean on Rust 1.86.
2026-06-30 10:04:51 -07:00
Matthew Jackson 9422909773 error: treat IoError/DeviceNotFound as SCSI transport failures (dead bus)
is_scsi_transport_failure recognized only the 0xFF SCSI sentinel, so the two
NON-SCSI dead-bus faults the Linux SG_IO transport returns — Error::IoError
(ioctl(SG_IO) == -1: ENODEV/EIO on an unplugged bridge) and Error::DeviceNotFound
(fd gone) — were classified as recoverable bad sectors. Every caller
(sweep read_error, patch, single-pass fill_extents) would then zero-fill /
skip-ahead across a wedged bus instead of aborting the pass and re-enumerating
the bridge — potentially zero-filling a huge span. Add both to the classifier so
a dead bus aborts the pass everywhere. (Companion to the unlock_bridge fix, which
maps the same two faults to 0xFF across the freemkv-unlock seam.)
2026-06-30 07:19:03 -07:00
Matthew Jackson 53e2e9a6ce unlock_bridge: map non-SCSI transport faults (IoError/DeviceNotFound) to 0xFF
Round-2 audit gap in the prior bridge fix: extract_scsi_context only recognizes
Error::ScsiError / DiscRead and collapses every other variant to (0, None). But
the Linux SG_IO transport returns genuine dead-bus faults as non-SCSI variants —
Error::IoError (ioctl(SG_IO) == -1: ENODEV/EIO on an unplugged bridge) and
Error::DeviceNotFound (fd gone) — which were crossing the seam as status 0x00, so
the unlock crate classified neither as a transport fault (needs 0xFF) nor as a
sense-bearing rejection, and kept hammering a dead bus.

Match the error variant in the adapter: a SCSI status (CHECK CONDITION or a
drive-tagged 0xFF) keeps its real status + sense; any other variant is a
non-SCSI transport/IO fault → SCSI_STATUS_TRANSPORT_FAILURE / None. Keying off
`sense.is_none()` would be wrong (a senseless CHECK CONDITION is a rejection).
Add bridge tests covering CHECK-CONDITION sense preservation, 0xFF passthrough,
and IoError/DeviceNotFound → 0xFF.
2026-06-29 22:57:09 -07:00
Matthew Jackson ab289fb5c4 mux: add the B1 drop-to-keyframe resync gate to DiscStream
DiscStream (the live-drive single-pass path) enables AACS decrypt-loss
concealment (NULL-TS fill on an undecryptable unit) but, unlike the file-backed
PipelinedPesStream, had no B1 resync gate — so after a concealed gap it forwarded
inter-coded video frames referencing the now-missing data, producing
decode-broken MKV output (dangling-reference frames).

Mirror PipelinedPesStream: add per-stream ResyncGate + is_video, and route every
codec-frame emit on the TS path (in-stream parse, demuxer EOF flush, parser EOF
flush) through gate.admit(is_video, frame.discontinuity, frame.keyframe) so a
video track drops forward to the next keyframe after a concealment event. Warn
once if a gate is still armed at EOF (a concealed gap in the final GOP with no
trailing keyframe). Audio/subtitle always admit.
2026-06-29 22:27:52 -07:00
Matthew Jackson dc3b7970e7 unlock/patch: preserve SCSI sense across the bridge; init aborts on dead bus; slow-retry first patch failure
Audit fixes (v1.1.0..HEAD regressions in the unlock migration + adaptive patch
speed):

- unlock_bridge ScsiAdapter: libfreemkv's transport returns Err on ANY non-zero
  SCSI status (a normal CHECK CONDITION), not only transport faults. The adapter
  was collapsing every such Err to { status: 0xFF, sense: None }, which discarded
  the parsed sense and defeated the AACS handshake's ILLEGAL_REQUEST wedge guard
  (so it kept hammering the drive — hard-rule #2) and inverted its
  transport-vs-rejection diagnosis. Now reconstruct status + the 32-byte sense
  buffer (sense_key@2, asc@12, ascq@13) and only emit 0xFF/None for a genuine
  transport fault.

- Drive::init: a genuine transport fault during the drive-prep unlock means the
  bus is dead — propagate it (the v1.1.0 invariant) instead of silently
  swallowing it via `if let Ok`. Other errors (no matching unlocker) still fall
  through to stock mode. SET CD SPEED max now runs only when the bus is alive.

- disc::patch: on the first read failure in a range, drop to slow recovery speed
  and RE-ATTEMPT the same position at slow speed before marking it. A
  single-sector range's first failing sector was being marked from a MAX-speed
  read it never got to recover.

- docs: lib.rs architecture diagram (handshake → host_certs) and README (stale
  pluggable-unlock-seam / register-unlocker / crates.io / docs.rs references).
2026-06-29 22:16:28 -07:00
Matthew Jackson 7cac0a1b15 disc: log adaptive patch speed transitions; issue SET CD SPEED max unconditionally
Two recovery-path fixes:
- patch: log the per-range speed transitions (INFO, phase="patch_speed") — each
  range enters at 0xFFFF (max) and drops to 0x0000 (slow recovery) on its first
  read failure. Previously the adaptive-speed behavior was invisible in the logs.
- drive init: issue the generic SET CD SPEED max UNCONDITIONALLY at drive-open,
  not only when a firmware unlocker matched. A stock-mode BD/UHD drive (no
  firmware unlock) was left riplocked because the call sat inside the
  unlocker-matched branch.
2026-06-29 21:47:50 -07:00
Matthew Jackson 5bd7da0f6f README: drop crates.io/docs.rs badges + stale plugin-model line (off crates.io now) 2026-06-29 21:17:06 -07:00
Matthew Jackson dd23f3f3c9 Cargo: publish = false — off crates.io (git-deps the firmware crate)
libfreemkv now git-deps freemkv-unlock (which carries drive firmware and can
never be on crates.io), so libfreemkv itself can only be consumed by git tag.
The freemkv-unlock dep stays an interim path dep for local cross-repo dev; the
release script swaps it to a git tag in the tagged commit.
2026-06-29 21:04:05 -07:00
Matthew Jackson c7925a293d unlock: dispatch via freemkv-unlock; delete in-tree handshake/css-auth/registry
Rewire the three unlock dispatch points through the freemkv-unlock crate via a
private `unlock_bridge`: drive-prep (kind=Unknown) at `Drive::init`, AACS cert
(kind=Aacs) at `do_handshake_cert`, CSS bus-auth (kind=Css) at scan. The bridge
news up `all_unlockers()` and runs the first matching one, mapping its
`Unlocked` result to the bus-key gate. After a successful drive unlock,
libfreemkv issues a generic SET CD SPEED (max) itself — the old per-unlocker
trait method is gone.

Delete the in-tree unlock code now owned by freemkv-unlock: the AACS cert
handshake (`aacs/handshake.rs`), the CSS bus-auth (`css/auth.rs`), and the
unlock registry (`unlock.rs`). Host-cert collection (a keysource concern) stays
in a small `aacs/host_certs.rs`. No public unlock surface remains — clients
touch libfreemkv only, oblivious to unlockers (as they are to SCSI). 2277 tests
pass.
2026-06-29 20:45:00 -07:00
Matthew Jackson 37a8d8f710 stage 4a: bridge libfreemkv to the freemkv-unlock crate
Add the freemkv-unlock dependency (path dep for local dev; re-pinned to a git
tag at release) and the consumption bridge: one generic ScsiAdapter mapping
libfreemkv's transport to the unlock contract, host-cert + DriveId mapping, and
run_unlockers() which news up all_unlockers() and runs the first matching one.
Additive — validates the dep resolves and the adapter compiles; the in-tree
registry/handshake/css path is still active and gets replaced in 4b.
2026-06-29 19:48:50 -07:00
Matthew Jackson 48bea05472 aacs: extract run_cert_handshake + collect_host_certs as shared primitives
Pull the wedge-guarded cert loop and host-cert collection out of the in-tree
AacsCertUnlocker into public aacs::handshake primitives (run_cert_handshake +
CertHandshake, collect_host_certs). The in-tree path now delegates to them, so
the external freemkv-unlock-aacs plugin runs the IDENTICAL cert handshake — one
implementation, two callers. Pure refactor of the live AACS path; the existing
handshake + collect_host_certs unit tests validate it unchanged.
2026-06-29 17:33:26 -07:00
Matthew Jackson 04ae2704f1 unlock: route_unlock returns a structured UnlockRoute; ctx carries opts
Replace route_unlock's Option<(name, Vid)> with a structured UnlockRoute
{ Unlocked(name, Unlocked) | Failed(UnlockError) | NoMatch } so a single
dispatch serves every caller: drive-prep wants "did anything unlock", and the
AACS cert route (next) needs the FAILURE REASON to render "missing keys" vs
"host cert rejected" instead of collapsing it to a bare None. Only a genuine
SCSI transport fault still returns Err (abort). UnlockCtx gains an optional
ScanOptions (the cert route's host-cert source), and read_mkb_from_drive now
takes &mut dyn ScsiTransport — both prerequisites for the cert handshake to
become an external freemkv-unlock-aacs unlocker. Drive-prep + CSS callers fold
the new outcome; no behavior change.
2026-06-29 17:18:11 -07:00
Matthew Jackson 26fc70b91b unlock: Unlocked carries the bus-key gate nuance; route_unlock returns it
Extend Unlocked with drive_unlocked + read_data_key_err so the registry result
carries everything the downstream bus-key gate needs — a firmware unlocker sets
drive_unlocked=true (bus encryption removed at the drive, no bus key), the cert
route will set read_data_key (+ read_data_key_err on a failed bus-key read).
route_unlock now returns the full Unlocked rather than just a Vid, so a single
dispatch can fold the firmware, cert, and CSS results uniformly. Prep for the
AACS cert handshake becoming a registry unlocker.
2026-06-29 16:59:04 -07:00
Matthew Jackson a061694ba0 css: CSS bus-auth becomes a uniform registry Unlocker
Convert the CSS read-unlock into a first-class registry Unlocker (CssUnlocker)
dispatched through route_unlock like every other barrier removal, instead of
a direct call in scan. libfreemkv appends the built-in CSS unlocker (and, next,
the AACS cert handshake) exactly once via ensure_builtins(), AFTER any
client-registered firmware unlocker — so the registry order is firmware → cert
→ css, owned by the lib, not the client.

Defense in depth: the unlocker does NOT trust the caller-declared DiscKind.
matches() filters on the declared kind (Css), but unlock() self-verifies
against the drive's GET CONFIGURATION profile and refuses (UnlockError::
NotApplicable, a new shared "this unlocker doesn't apply" variant) WITHOUT
issuing a single CSS CDB if the drive reports a non-DVD profile — so a
mis-routed Blu-ray is never sent CSS bus-auth. Guard the firmware unlocker the
same structural way (it matches only the drive-prep phase, kind == Unknown).

Tests: CssUnlocker matches only DiscKind::Css; a BD-profile drive yields
NotApplicable with zero CSS CDBs issued.
2026-06-29 16:49:52 -07:00
Matthew Jackson 45c0bc943c unlock: introduce UnlockCtx + DiscKind; trait keys off context
Reshape the Unlocker seam so every unlocker is dispatched at ONE place from
ONE ordered registry — the firmware, cert, and CSS routes are all "remove the
bus-encryption barrier", differing only in what they key off. matches() and
unlock() now take an UnlockCtx { drive_id, kind: DiscKind } instead of a bare
DriveId: a firmware unlocker keys off drive_id (kind irrelevant), the cert
unlocker will match DiscKind::Aacs, the CSS unlocker DiscKind::Css. UnlockCtx
is #[non_exhaustive] so a host-cert source can be added without breaking
external unlockers. Drive-prep dispatch passes DiscKind::Unknown (no disc
probed yet); the cert/CSS registry impls + the single post-probe dispatch
point follow in subsequent commits.
2026-06-29 16:27:30 -07:00
Matthew Jackson c9c64e2cbf css: auth primitives operate on &mut dyn ScsiTransport; clean up bus_auth
Thread &mut dyn ScsiTransport through unlock_css_reads and its bus-auth /
disc-key primitives instead of &mut Drive — they only ever issued SCSI via
drive.scsi_mut(), so this is mechanical and lossless, and it makes the CSS
unlock speak the same transport interface as the registry Unlocker trait
(prerequisite for CSS becoming a uniform unlocker).

Also clean up the badly-named, half-dead bus_auth:
- rename bus_auth -> establish_authenticated_session: it is run for its
  side effect (sets the drive's ASF=1, unlocking scrambled-sector reads),
  which the name now states.
- drop the derived CSS bus key from the return + computation: it had no
  consumer (descrambling is keyless via the Stevenson attack), so it was
  dead crypto computed on every DVD unlock. Return just the negotiated AGID,
  which the caller genuinely needs for the best-effort disc-key REPORT KEY.
2026-06-29 16:01:07 -07:00
Matthew Jackson 8ac18d857d aacs: handshake primitives operate on &mut dyn ScsiTransport
The AACS cert-auth primitives (aacs_authenticate, the AACS 2.0 P-256
variants, read_volume_id, read_data_keys) and their scsi_read/scsi_write
helpers touched the drive ONLY through Drive::scsi_execute — a pure
pass-through to the transport. Thread &mut dyn ScsiTransport instead of
&mut Drive so these primitives are transport-level, matching the firmware
Unlocker seam (which hands out &mut dyn ScsiTransport for testability).
Pure mechanical signature change, no logic change; the cert orchestrator
(do_handshake_cert) keeps &mut Drive for the OEM-VID shortcut and passes
session.scsi_mut() into the primitives. Step toward making the cert
handshake a uniform registry unlocker.
2026-06-29 15:47:30 -07:00
Matthew Jackson 0a006bbfa6 unlock: Unlocker::unlock returns Unlocked { vid, read_data_key }
Generalize the unlock seam toward uniform unlockers (firmware / cert / CSS).
`unlock` now reports what it LEARNED — a Volume ID and/or an AACS bus key —
instead of just a Vid; libfreemkv files those onto the disc/drive in one
place, so a plugin never touches Disc. The hardware side-effect (extended
mode) still happens inside unlock. route_unlock walks the registry in
registration order (the single ordered place that decides run order); no
priority field. Firmware route reports {vid: Some, read_data_key: None}.
2026-06-29 15:09:17 -07:00
Matthew Jackson 11554c1722 disc: credit firmware unlock in the bus-encryption gate
The bus-key gate only credited the cert handshake's read_data_key as proof
bus encryption was removed. A firmware unlocker removes it AT THE DRIVE
(serves clear content) and yields no read_data_key — so a SUCCESSFUL
firmware unlock (VID present, read_data_key None) tripped the gate and
blocked ALL key resolution, including the online source. That was the
root cause of live UHD discs reporting "missing keys" after an unlock.

Now a single predicate answers "is bus encryption gone?": never-had-it ||
file/ISO || firmware-unlocked || cert-bus-key. The gate is just
`if !bus_encryption_removed { error }` — no enumerated cases. HandshakeResult
gains `drive_unlocked`, and the read_data_key failure reason is captured so
the warn says WHY the bus key is missing.

Also: reword the first hardware-sense escalation as "fast-fail escalation"
(it is often transient — the drive recovers), reserving "wedge" for a
persistent run; and scrub the product name from core comments (it belongs
only in the unlocker crate).
2026-06-29 15:09:16 -07:00
Matthew Jackson 321442632a disc: make bus-key unavailability self-explaining in the log
When a bus-encrypted disc's handshake yields no read_data_key, the gate
logged a bare "bus_key_unavailable" with no indication of WHY — turning
every occurrence into archaeology (is the bus key not-attempted, or did
the read fail?).

Now:
- read_data_keys failure is captured (error code) instead of swallowed by
  `.ok()`, and logged at the handshake with its consequence.
- HandshakeResult carries `read_data_key_err: Option<u16>` so the gate
  distinguishes "never attempted" (None — VID-only/OEM path) from "read
  FAILED" (a code), and the bus_key_unavailable warn now reports the code
  plus whether a Volume ID was present.

No behavior change — purely diagnostic. The handshake_ok debug also now
reports has_volume_id.
2026-06-29 13:33:09 -07:00
Matthew Jackson 46797c01a3 mux: harden audio discontinuity handling (audit follow-up)
Two defensive hardenings from the post-fix audit (vs FFmpeg/GStreamer):

1. Move the `pes.discontinuity` partial-drop ABOVE the empty-data guard in
   all three audio parsers (ac3/dts/truehd), so a discontinuity signal can
   never be stranded by an empty post-gap PES. The demuxer only emits
   non-empty PES today; this is defense-in-depth for any future caller.

2. A PES with no PTS must not reset the timeline to 0. ac3 now carries
   `flush_pts_ns`, dts continues from the most recent known base; truehd
   already kept its running cadence on a None PTS. Matches OSS behavior
   (PTS rebases off the next PES that actually carries a PTS).

Adds an ac3 regression test (empty-payload discontinuity PES still drops
the stranded partial). Loss accounting was reviewed: TS-demux CC-gaps are
NOT counted toward lost_video_secs / abort (that is sector-based via
DiscStream::errors / mapfile bytes_unreadable), so a source splice never
inflates loss — no gating needed there.
2026-06-29 12:50:52 -07:00
Matthew Jackson 790a40138b mux: drop truncated partial audio frame on concealed gap
The AC-3, DTS and TrueHD parsers buffer access units across PES
boundaries. At a concealed-loss gap the buffered unit is truncated:
splicing post-gap bytes onto it manufactures a corrupt frame on top of
the real loss (FFmpeg "Failed to decode block code(s)" / "Invalid data
found" at the gap) and, for TrueHD, strands the PTS cadence into the
non-monotonic audio-DTS band seen on multi-clip titles.

The video parsers already handle this via the ResyncGate, but the
discontinuity signal was only wired into video — audio parsers ignored
pes.discontinuity and spliced across the gap.

Now, when pes.discontinuity is set, each audio parser drops the partial
(clears buf, and for DTS its PTS marks / pending base) so the post-gap
PES re-bases a fresh unit. A lost gap degrades to a clean single-frame
drop instead of a corrupt spliced frame. No effect on perfect rips: the
branch only runs when concealment inserted a discontinuity marker.

Adds a per-parser test feeding a partial frame then a discontinuity PES,
asserting the truncated partial is dropped (not spliced) and the post-gap
PTS is adopted.
2026-06-29 11:23:08 -07:00
Matthew Jackson 4e545e6e46 disc: adaptive patch speed — enter each range fast, slow on first failure
Pass N (patch) pinned the slow recovery speed (0x0000) for the whole pass.
But Pass 1's damage-jump overshoots, so most of a jumped range is clean data
the reverse-walk reads first — grinding it at slow speed wastes minutes per
gap. Now each range mirrors Pass 1's model:

- Enter at max speed (0xFFFF) + reset to the initial batch (current_batch
  carries across ranges, so a prior single-sector grind would otherwise start
  the next range slow). Reads the clean overshoot fast.
- First read failure in the range → drop to 0x0000 (once; idempotent SET CD
  SPEED) for the rest of that range — grind only the genuine damage.
- 10 s halt-responsive cooldown between ranges, gated on "this range actually
  grinded" so a many-small-range pass (100+ ranges) doesn't stall ~20 min on
  unconditional pauses.

No change to good/bad classification, the mapfile, or the abort math — purely
read speed + an inter-range cooldown. Synthetic speed-transition test to follow.
2026-06-29 10:34:18 -07:00
Matthew Jackson afe55c7871 docs: 1.2.0 changelog date → 2026-06-29 (release prep); fix neither/nor grammar in 1.1.0 entry 2026-06-29 09:57:29 -07:00
Matthew Jackson 7114e46018 mux/decrypt: audit-nit cleanup + defensive conceal fallback
Non-blocking follow-ups from the 1.2.0 audit:
- conceal loop: if decrypt reported loss but the padding-aware predicate
  matched nothing to conceal (a ~256^-31 contradiction), fall back to the
  strict predicate and conceal whatever it flags, loudly — belt-and-
  suspenders so ciphertext can never reach the mux.
- decrypt_dropped doc: reflect 1.2.0 (mux-path loss is concealed + tallied,
  not silently dropped).
- direct unit test for aacs_unit_still_ciphertext (the padding-aware
  conceal predicate): clear/all-zero/full-decrypted/full-ciphertext/
  decrypted-short-tail.
- fix three stale "v1.1.1" comment refs (the fragment-tail fix ships in
  1.2.0; there is no v1.1.1 release).
2026-06-29 09:51:28 -07:00
Matthew Jackson ccb1b16ffb mux: make B1 concealment decode-clean on every gap shape
Closes the three residual holes where a concealed/lost gap could still let
a dangling-reference frame reach the muxer (degraded/undecryptable-disc
path only; clean rips are byte-identical and untouched). Root cause: the
discontinuity signal was reconstructed from the 4-bit continuity counter
and applied per-PES, both of which are lossy.

Three coordinated changes:

1. CC-INDEPENDENT marker. fill_null_ts_unit now tags its NULL packets with
   an adaptation-field discontinuity_indicator; the demuxer recognises a
   0x1FFF packet carrying it as a concealed gap and forces a discontinuity
   on every tracked PID (the lost unit's PID is unknowable). This survives
   a loss that is an exact multiple of 16 packets (CC aliases to in-sequence
   — hole 3) and a loss at a PID's very start (no prior CC — hole 4); it
   also drops any open, potentially-truncated partial PES.

2. PUSI ATTRIBUTION. A gap landing on a PES boundary now flags the PES
   STARTING after it, not the one flushed at the boundary (hole 1) —
   stamping the pre-gap frame could arm-then-disarm the gate on a keyframe
   and admit the real post-gap inter frame.

3. PER-FRAME signal. codec::Frame gains `discontinuity`; each parser
   propagates it onto the first post-gap frame. MPEG-2 buffers whole GOPs
   asynchronously, so it associates the gap by ES OFFSET (like PTS/source),
   landing it on the exact post-gap picture mid-GOP (hole 2) — a per-PES
   flag stamped the previous picture. consume_ts (and the EOF flush drain)
   gate on frame.discontinuity.

Tests: CC-independent marker with in-sequence CC + leading-loss; PUSI
attribution flags the post-gap PES; MPEG-2 offset-mark stamps the post-gap
picture through GOP reorder, not the previous one. Existing B1 gate + EOF
tests still green (2270 lib tests).
2026-06-29 09:39:03 -07:00
Matthew Jackson a5268d15ca docs: 1.2.0 CHANGELOG — document the breaking public-API changes
Per the release decision: ship the AACS-version-stride API changes under
the 1.2.0 minor bump, with an explicit Breaking section (DiscInputs new
field, DiscInputsCtx::new arity, read_aacs_inputs* 3-tuple return).
2026-06-29 08:41:26 -07:00
Matthew Jackson accf21b03d mux: conceal only genuinely-undecryptable units (padding-aware)
The P3 concealment loop in DecryptingSectorSource::read_sectors keyed on
aacs_unit_needs_decrypt, whose sync check is the majority-vote
ts_sync_destroyed (<=16 of 32 syncs). A successfully padding-aware-
decrypted content-fragment TAIL unit (e.g. 11 content packets + 21 zero
padding) has only 11 syncs, so the majority vote called it "still
encrypted" — and when such a good unit shared a read buffer with a
genuinely-undecryptable one (dropped>0), the loop overwrote the GOOD
decrypted tail with NULL-TS, silently discarding correct video and
over-counting concealed units vs the tallied dropped bytes.

Add aacs_unit_still_ciphertext (padding-aware): encrypted AND at least
one non-zero (non-padding) 192-byte packet missing its 0x47 sync — the
same discriminator decrypt_unit uses to accept a fragment tail. The
conceal loop now uses it, so only genuinely-unrestored ciphertext is
concealed. Full and fully-ciphertext units are unchanged. Regression
test: a decrypted short-padding-tail co-resident with a failed unit is
left byte-for-byte intact while the failed unit is concealed.
2026-06-29 00:29:20 -07:00
Matthew Jackson 07396ec19d v1.2.0: bump version + reconcile CHANGELOG to [1.2.0] 2026-06-28 23:34:19 -07:00
Matthew Jackson da3859d0c5 mux: B1 drop-to-keyframe resync after a concealed gap
Pairs with A2 (read-path NULL-TS concealment). When the demux assembler
sees a TS continuity gap it now stamps `discontinuity` on the next
completed PES; the codec-parse stage carries that onto a per-track
ResyncGate. After a gap on an inter-coded video track the gate drops
forward to the next IRAP/IDR keyframe so no frame with a dangling
reference reaches the muxer (an ffmpeg deep scan would otherwise report
a missing-reference / non-existing-PPS error). Audio and subtitle tracks
have no cross-frame references, so the gate is a no-op there.

- ts.rs: PesPacket gains `discontinuity`; PesAssembler tracks a sticky
  pending_discontinuity flag set on CC gap / discontinuity_indicator and
  carried to the next completed/flushed PES.
- resync.rs (new): ResyncGate — per-track arm-on-gap, drop non-keyframes
  until the next keyframe disarms and resumes. Logs the resync + drop
  count once at the keyframe.
- pipelined_stream.rs: precompute per-track is_video, apply the gate in
  consume_ts. Out-of-range track index emits as-is (defensive).

Tests: ResyncGate unit tests; ts.rs gap-stamps-discontinuity; end-to-end
B1 video-drops-to-keyframe and audio-never-drops through PipelinedPesStream.
2026-06-28 23:01:47 -07:00
Matthew Jackson d334ab5749 1.2.0: mux loss-concealment read path (P3/Edit-2, A2 NULL-TS fill)
Decrypt-verify is a RIP gate, not a MUX gate. On the mux read path an
undecryptable content unit must never abort the mux:

- DecryptingSectorSource gains tolerate_decrypt_loss(): when set, an
  undecryptable in-content unit is tallied, overwritten with valid NULL
  TS packets (PID 0x1FFF) via aacs::fill_null_ts_unit, logged loud with
  its LBA, and the read returns Ok — the stream keeps flowing. The rip
  paths keep the fail-loud DECRYPT_VERIFY_READ decorator (re-read off the
  disc); only the mux opts in.
- Wire it into both mux read paths: the file-backed highway
  (build_iso_pipeline) and the inline DiscStream.
- NULL-TS fill keeps the demuxer byte-synced on the 192-byte stride; the
  lost video/audio PID packets surface as a CC gap the TS assembler
  already drops a partial PES on (the B1 foundation). Ciphertext is never
  passed downstream either way.
- Fix stale resolve_vid_only no-cert test: default is UHD (audit #4).

Tests: conceal-as-NULL-TS, fill well-formedness, fail-loud still holds.
2026-06-28 22:44:19 -07:00
Matthew Jackson 0b2e83e523 1.2.0: single MKB framing walker + AACS resolve hardening
- mkb_records() as the one record-framing iterator; rebuild walk_mkb,
  find_record_body, mkb_find_subdiff_records, mkb_content_len, mkb_version,
  mkb_type_raw, mkb_find_mk_dv on it (D4).
- resolve_vid_only / read_aacs_version default to UHD (+warn) on a missing
  content cert instead of BD; route MKB through bounded read_mkb_content.
- AacsVersion major()/from_major() + AACS_MAJOR_BD/UHD as the stride source;
  table + stride-discriminating regression tests.
- read_encrypted_units probes 8 evenly-spaced points per extent (off-midpoint
  scrambled content now sampled); decrypt source-zero mask uses PKT.
2026-06-28 22:28:09 -07:00
Matthew Jackson abd7af3907 1.2.0: unify hex parsing (one parser) + robust encrypted-unit sampling
- One workspace hex parser (libfreemkv::hex): the keydb / online / mapfile
  parsers had divergent prefix handling (0x vs 0X vs none) — a key written with
  a prefix one parser didn't expect was silently dropped. All three now call it.
- read_encrypted_units probes several points spread across each extent instead
  of only the midpoint-forward window, so a late-starting / sparse encrypted
  body still yields samples (empty samples make decrypt_with skip wrong-key
  validation). A read error at one probe no longer abandons the extent.
2026-06-28 22:12:06 -07:00
Matthew Jackson 7716a2cb17 1.1.1: unify AACS key-input path on Disc::inputs() + named constants
- read_aacs_inputs* now returns the AACS major version; DiscInputs carries it,
  and DiscInputsCtx parses Unit_Key_RO.inf at the disc's own stride (fixes the
  hardcoded-V20 read-time fetch for V10 discs). One source of truth, no version
  argument to drift.
- Disc::inputs() is the single complete AACS-input source (inf/MKB/VID/hash/
  version); the out-of-band duplicate readers go away.
- Named constants for AACS file paths (aacs::PATH_*) and the AACS majors
  (aacs::AACS_MAJOR_*, AacsVersion::major/from_major) replace magic strings/ints.
- push_ranges saturating (corrupt-disc panic guard).
2026-06-28 21:45:58 -07:00
Matthew Jackson 391e1ae39c 1.1.1: AACS decrypt + key-resolution hardening
- decrypt_unit: padding-aware acceptance — recover real video at content-
  fragment tails (the phantom mux-loss class) without weakening wrong-key
  rejection (a full content unit still needs all 32 TS syncs).
- scan: read the MKB via the bounded read_mkb_content so Disc::inputs()
  carries it. Online key resolution was shipping mkb=0 (a full read of the
  ~128 MiB MKB_RO allocation fails) → the decode service 404'd.
- resolve_vid_only: surface an MKB read error instead of silently emptying.
- fetch: a per-sample dry-set replaces the global fetch_spent latch, so a
  second CPS unit's key can still be fetched after the first came back empty.
- verify::push_ranges: saturating arithmetic (corrupt-disc panic guard).
- Tests for all of the above.
2026-06-28 21:12:57 -07:00
57 changed files with 6811 additions and 7081 deletions
+196 -2
View File
@@ -1,5 +1,199 @@
# Changelog
## [1.2.0] — 2026-07-01
### Breaking
The disc's AACS version is now carried through the key-resolution path as the
single source of truth for the `Unit_Key_RO` stride (AACS-1.0 = 48-byte,
AACS-2.x = 64-byte), so keys are always read at the disc's own layout. That
threaded one new value through three public signatures. In-tree consumers
(`freemkv`, `autorip`, `freemkv-keysources`) are updated; external callers must
adjust:
- **`DiscInputs` gains a `version: u8` field** (between `volume_id` and `mkb`).
Code constructing it with a struct literal must add the field. It is normally
obtained from `Disc::inputs()`, not constructed by hand.
- **`keysource::DiscInputsCtx::new` takes one argument, not two** — the version
is now read from `inputs.version` (`new(inputs)` instead of
`new(inputs, version)`).
- **`disc::read_aacs_inputs` / `read_aacs_inputs_from_drive` return a 3-tuple**
`(inf, mkb, version)` instead of `(inf, mkb)`.
- **`PassProgress` is no longer `Copy` and gains a `located: LocatedProgress`
field.** It now carries a `Vec` (the rendered bad-range drilldown), so it's
`Clone` only — still built once per throttled emission and passed by reference
to `Progress::report`. Struct-literal constructors must add the field (empty:
`located: Default::default()`). New public types `LocatedRange` /
`LocatedProgress`.
These are source-breaking for external crates.io consumers. Shipped under a
minor bump (1.2.0): libfreemkv's surface is not yet frozen and the only known
consumers are the in-tree toolchain crates.
### Added
- **Pass-N marginal-sector recovery specialists.** The patch pass gained a
roster of parameterized recovery techniques — read speed (max/min), cache
bypass (FUA), and traversal (linear fwd/rev, bisect, cache-prime, oscillate,
per-sector speed-sweep) — each targeting a distinct physical failure mode of
marginal media. A per-rip **decayed (EWMA) scorecard** grades every technique
by its recent recovery rate and re-orders them best-first, so the engine
hardcodes no conclusion: a technique that fits *this* disc floats to the front
and one that doesn't self-deprioritises (but is never dropped). Every read is
wedge-safe and deadline-bounded; the existing fast/deep recovery behavior is
unchanged (the specialists are additive, tried only on the hardened residue).
- **Opt-in flat-pool recovery scheduler (`FREEMKV_PATCH_FLAT`).** Collapses the
breadth-first recovery tiers into one flat pool so every technique gets a shot
at each bad range immediately, scorecard-ordered — a data-driven bandit for a
hardened residual (e.g. a late resume) where the tiered ladder would spend a
long time on cheap techniques before reaching the specialists. Unset keeps the
proven tier ladder as the default.
- **`PassProgress` is the complete, mapfile-free progress contract.** Every
emission now carries the fully-rendered "where is the damage" drilldown
(`located`): the bad ranges annotated with chapter + movie-time offset, the
main-feature at-risk time, the section count and the largest gap — computed by
the library from its in-memory mapfile + title. A client (autorip, a future
GUI/CLI) renders the disc map + at-risk time straight from it and never parses
the mapfile, so a mapfile→mapdb change is invisible to clients. Adds
`disc::locate_ranges`, the one-shot `disc::progress_snapshot_from_mapfile`
(builds a snapshot from a mapfile on disk so a boundary/verdict paint stays
mapfile-free client-side), and `consts::MILLIS_PER_SEC`.
- **`PatchOptions::fast_capture` — breadth-first patch recovery.** A fast-capture
pass reads each bad range once at the full batch and leaves every failed block
`NonTrimmed` for a later pass — no bisect, no re-read, no per-sector grind — so
a first retry pass grabs the readable blocks (a sweep's good skip-ahead
overshoot) of EVERY range before any single range's slow per-sector recovery.
No data is dropped: a failed block stays `NonTrimmed` (retried by a granular
pass), never `Unreadable`. A transport fault still aborts. `Disc::copy`'s
internal patch leaves it `false` (single-call full recovery).
- **Mux loss concealment — a logged gap still produces a decode-clean file.**
When a unit genuinely cannot be decrypted on the mux read path (a key the disc
never yielded, after the rip's own decrypt-verify already failed loud and
re-read), the mux no longer passes ciphertext downstream or emits a broken
frame. The undecryptable aligned unit is concealed as NULL transport-stream
packets (PID 0x1FFF, invisible to every real stream), and the codec layer
**drops forward to the next keyframe** so no frame with a dangling reference
reaches the muxer. An ffmpeg deep scan of the result is clean — no missing
references, no partial frames. The loss is tallied and logged, never silently
dropped, and the mux always completes. Audio and subtitle tracks have no
cross-frame references, so only the directly-affected frames are dropped there.
Decrypt-verify remains a **rip** gate (fail loud → re-read), never a mux gate.
- **`Disc::unlocker_matrix()` — registry-driven unlocker did-work report.** Returns
each registered unlocker's name alongside a `did_work` flag recording whether it
performed authentication steps during the current rip. Callers (autorip, the CLI)
surface this so an operator can confirm at a glance which unlock paths —
LibreDrive firmware, AACS, CSS — actually ran, with no hardcoded names on the
caller side.
### Changed
- **One hex parser.** All hex parsing (keys, IDs, key-source inputs) routes
through a single `libfreemkv::hex` parser instead of several ad-hoc decoders,
so length/odd-nibble/invalid-digit handling is identical everywhere.
- **Robust encrypted-unit sampling + a single MKB framing walker.** Up-front
AACS sampling tolerates content layouts that previously yielded too few
encrypted units to resolve a key, and the Media Key Block is now walked by one
framing routine shared across the in-band and out-of-band readers (no
divergent record-stride logic). AACS resolution hardened around these paths.
- **One reader, one `DiscInputs`.** `Disc::inputs()` is now the single, complete
source of a disc's AACS inputs (inf, MKB, VID, disc_hash, version), and
`read_aacs_inputs*` returns the version alongside inf+MKB. Both the CLI and
autorip resolve through `Disc::inputs()`; the duplicate out-of-band readers
(autorip's `key_files()`/`volume_id()`) and the stale mapfile-VID read are
removed. AACS file paths and the AACS major versions are now named constants
(`aacs::PATH_*`, `aacs::AACS_MAJOR_*`, `AacsVersion::major`/`from_major`) so a
fallback or stride change lives in exactly one place.
- **Pass-N recovery rebuilt as a bounded, never-hang handler chain.** The 1.1.0
patch loop retried each bad range sector-by-sector until a per-range budget was
exhausted, with no escape from a wedged drive short of the watchdog firing after
tens of minutes. 1.2.0 replaces that with a two-tier handler chain dispatched
breadth-first, largest bad range first:
- **Jump** (lead tier): reads each range in large forward-skipping batches to
quickly locate readable islands — clearing a multi-gigabyte dead spot in
seconds rather than sector-by-sector.
- **Bisect** (trailing tier): binary-searches the boundaries of each remaining
bad block, converging to within a single sector of the last-readable LBA.
Boundary-probe reads are exempt from the early-yield stall so the boundary
walk always completes.
- **Handler scorecard**: handlers that make progress stay at the front of the
rotation per rip; an idle handler is ranked last so proven performers lead.
- **Wedge detection**: a pass-level streak counter tracks consecutive
wedge-family senses (HARDWARE ERROR / ILLEGAL REQUEST) across section
boundaries. At the threshold the pass aborts and a soft un-wedge
(`Drive::spin_cycle()` — START STOP UNIT, no eject) runs before the next retry
pass, instead of grinding at near-zero throughput until the pass watchdog
fires.
No data is dropped: a block that neither handler recovers in a pass stays
`NonTrimmed` for the next pass.
### Fixed
- **DVD DTS/LPCM audio tracks no longer mux silent.** On DVD-Video the
`private_stream_1` sub-stream id's low nibble is the audio-stream *number*
(shared across codecs), not a per-codec ordinal. A DTS or LPCM track that
wasn't the disc's first audio stream got a sub-id one too low, so the demux
routing key (`0xBD00 | sub_id`) never matched and every packet was dropped —
the track appeared in the container but played silent (AC-3 at position 0
worked by coincidence). Audio sub-stream ids are now assigned by positional
stream number, so a DTS 5.0 track after an AC-3 5.1 track routes correctly.
- **ISO mux no longer drops real video at content-fragment tails.** A title's
encrypted content can end mid-AACS-unit, with the disc zero-padding the rest
of the 6144-byte aligned unit to the next fragment. The decrypt-verify
demanded the TS sync byte on *all 32* source packets, so it rejected such a
tail unit over its legitimate padding — discarding the real video packets it
contained. On a flawless rip this surfaced as a small phantom "loss" at mux
(and, once retries were exhausted, a truncated MKV). Unit acceptance is now
**padding-aware**: only packets whose *source* (pre-decrypt) bytes are
non-zero must restore their TS sync; the zero padding is excluded from the
check and emitted as clean zeros. A full content unit still requires all 32
(unchanged — no wrong-key relaxation), and a unit whose *non-zero* tail fails
to decrypt is still rejected as a genuine bad read.
- **ISO online key resolution now sends the Media Key Block.** Capturing a
disc's AACS inputs at scan read the MKB with a full `read_file` of the
~128 MiB `MKB_RO`/`MKB_RW` allocation, which fails on file-backed readers —
leaving the MKB empty, so `Disc::inputs()` shipped `mkb=0` to an online key
service and the request was rejected (no key → no decrypt). Scan now reads the
MKB through the same bounded prefix-grow + trim reader as the out-of-band
path, so `Disc::inputs()` is the single complete source of AACS inputs — one
reader for every caller.
- **Read-time key-fetch parses `Unit_Key_RO.inf` at the disc's own AACS stride.**
The on-demand fetch (for a CPS unit not sampled up front) hardcoded the V20
64-byte stride, so an AACS-1.0 (V10) disc whose key arrived as a VUK derived
the wrong unit keys. `DiscInputs` now carries the disc's `version`, and the
context parses at the matching stride — the disc is the single source of truth
for its own stride (no separate version argument to drift).
- **A dry key-fetch for one unit no longer blocks fetching a different unit.**
A global "fetch spent" latch meant that once the key service returned nothing
for one CPS unit's ciphertext, no further unit was ever asked — so a multi-CPS
disc could strand a unit whose key the service *would* have served. Replaced
with a per-unit "already-asked-dry" set (still bounded by the fetch budget).
- **`verify::push_ranges` uses saturating arithmetic** so a corrupt-disc LBA near
`u32::MAX` can't panic (matches `udf::merge_ranges`).
- **Audio no longer corrupts at a stream discontinuity.** At a transport-stream
discontinuity — a continuity-counter break, an adaptation-field
discontinuity_indicator, or a concealed-loss gap — the AC-3 / DTS / TrueHD
parsers held a *truncated* partial access unit and spliced the post-gap bytes
onto it, manufacturing a corrupt frame (ffmpeg "exponent out of range" /
"Failed to decode block code(s)" / "Invalid data found") and, for TrueHD, a
non-monotonic timestamp band on multi-segment titles. The video path already
resynced via the keyframe gate; the audio parsers now do too — on a
discontinuity they drop the un-completable partial and resync on the next
syncword, rebasing the timestamp from the post-gap PES. A discontinuity becomes
a clean single-frame gap instead of a corrupt splice. Audio has no inter-frame
references, so dropping the truncated partial is the complete fix; the approach
matches FFmpeg's parser layer and GStreamer's `tsdemux`.
- **Drive-prep firmware unlock skipped for DVD discs.** An
`if disc_is_dvd() { return }` guard in `Drive::init()` (present since
1.0.0-rc.1) bypassed the entire drive-prep unlock step for DVDs. That unlock is
what removes riplock and raises the drive to maximum read speed — a drive-level,
disc-independent feature — so every DVD rip ran at riplock speed (~0.4× rated,
multi-hour ETA). The guard is removed; all disc types now go through the full
drive-prep sequence. UHD and Blu-ray were unaffected (they already ran through
the unlock path).
## [1.1.0]
### Added
@@ -7,8 +201,8 @@
- **Post-read decrypt-verify gate.** Every AACS unit read off the disc is now
buffered, re-aligned to its clip-file 6144-byte unit grid, and verified
(CPI flag → decrypt → strict all-32 TS-sync, matching libaacs `_verify_ts`)
before it is signed off as good. A unit that neither a held nor a
freshly-fetched key decrypts is treated exactly like a bad read — re-read by
before it is signed off as good. A unit that no held or freshly-fetched key
decrypts is treated exactly like a bad read — re-read by
the patch pass, terminal loss only if truly unrecoverable — closing the
"silent bad read" class where a sector reads OK but its ciphertext is subtly
wrong. **Fail-safe:** it only ever downgrades a unit it is *confident* is bad;
+8 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "1.1.0"
version = "1.2.0"
edition = "2024"
rust-version = "1.86"
license = "AGPL-3.0-only"
@@ -10,6 +10,9 @@ keywords = ["bluray", "uhd", "optical", "scsi", "disc"]
categories = ["hardware-support", "multimedia"]
# Keep internal AI-instruction / private notes out of the published crate.
exclude = ["CLAUDE.md"]
# OFF crates.io: libfreemkv git-deps freemkv-unlock (firmware, never published),
# so libfreemkv itself can only be consumed by git tag. Clients git-tag-pin it.
publish = false
[profile.release]
lto = "thin"
@@ -22,6 +25,10 @@ sha1 = "0.10"
sha2 = "0.10"
aes = "0.8"
cbc = "0.1"
# Interim path dep for local cross-repo dev; the release script re-pins this to
# `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` before tagging libfreemkv (so
# the released tag resolves freemkv-unlock from git, not a sibling path).
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.2.0" }
num-bigint = "0.4"
num-traits = "0.2"
num-integer = "0.1"
+10 -10
View File
@@ -1,26 +1,26 @@
[![Crates.io](https://img.shields.io/crates/v/libfreemkv)](https://crates.io/crates/libfreemkv)
[![docs.rs](https://img.shields.io/docsrs/libfreemkv)](https://docs.rs/libfreemkv)
[![License: AGPL-3.0](https://img.shields.io/badge/license-AGPL--3.0-blue)](LICENSE)
# libfreemkv
Rust library for 4K UHD / Blu-ray / DVD optical drives. Drive access, disc scanning, stream labels, AACS decryption, CSS decryption, KEYDB updates, and content reading in one crate. Firmware-clean core: drive-unlock support is plugged in via the `Unlocker` trait, with concrete unlockers shipped as separate crates.
Rust library for 4K UHD / Blu-ray / DVD optical drives. Drive access, disc scanning, stream labels, AACS decryption, CSS decryption, KEYDB updates, and content reading in one crate. Drive-level unlocking is handled internally; consumers work with disc access and decryption only.
DVDs (CSS) decrypt out of the box. Blu-ray and UHD (AACS) require a `keydb.cfg` (default `~/.config/freemkv/keydb.cfg`) supplying disc-specific volume unique keys; no AACS key material is compiled in.
**12+ MB/s** sustained read speeds on BD. Drive prep routes through the pluggable unlock seam — register an unlocker and `init()` drives it; with none registered the library rips via the host-certificate AACS handshake.
**12+ MB/s** sustained read speeds on BD. Drive prep (`init()`) handles unlocking internally via the `freemkv-unlock` crate — clients never see it; when no drive unlock applies, the library rips via the host-certificate AACS handshake.
Multi-lingual by design — the library outputs structured data and numeric error codes, never English text. Build any UI or localization on top.
**[API Documentation](https://docs.rs/libfreemkv)** · **[Technical Docs](docs/)**
**[Source & API](https://github.com/freemkv/libfreemkv)** · **[Technical Docs](docs/)**
Part of the [freemkv](https://github.com/freemkv) project.
## Install
Consumed by git tag (not published to crates.io):
```toml
[dependencies]
libfreemkv = "1.0.0-rc.1"
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "vX.Y.Z" }
```
## Quick Start
@@ -32,7 +32,7 @@ use std::path::Path;
// Open drive — identified via INQUIRY
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
drive.wait_ready()?; // wait for disc
drive.init()?; // route through the unlock seam (if an unlocker is registered)
drive.init()?; // unlock + prep (handled internally)
drive.probe_disc()?; // probe disc surface for optimal speeds
// Scan disc — UDF, playlists, streams, AACS (all automatic)
@@ -100,7 +100,7 @@ loop {
## What It Does
- **Drive access** — open, identify, pluggable unlock seam, speed control, eject
- **Drive access** — open, identify, internal unlock + prep, speed control, eject
- **12+ MB/s reads** — auto-detects kernel transfer limits, sustained full speed
- **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info
- **Stream labels** — 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
@@ -134,8 +134,8 @@ Blu-rays and UHD (AACS) require a `keydb.cfg` at `~/.config/freemkv/keydb.cfg` (
```text
Drive — open, identify, init, single-shot read
├── ScsiTransport — SG_IO (Linux), IOKit (macOS), SPTI (Windows)
└── Unlocker seam — pluggable trait + registry; concrete unlockers
live in the separate freemkv-unlock repo
└── unlock_bridge — private seam to the freemkv-unlock crate
(firmware / AACS cert / CSS bus-auth unlockers)
Disc — scan titles, streams, AACS/CSS state
├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
+302 -1
View File
@@ -163,6 +163,101 @@ pub fn aacs_unit_needs_decrypt(unit: &[u8]) -> bool {
aacs_unit_encrypted(unit) && ts_sync_destroyed(unit)
}
/// PADDING-AWARE "is this aligned unit STILL genuine ciphertext?" — the conceal-
/// path twin of [`decrypt_unit`]'s acceptance criterion, run on the POST-decrypt
/// bytes.
///
/// [`aacs_unit_needs_decrypt`] cannot answer this: it composes CPI with the
/// MAJORITY-VOTE [`ts_sync_destroyed`] (`ts_sync_count <= total/2`). A
/// successfully padding-aware-decrypted content-fragment TAIL unit (e.g. 11 of 32
/// packets are real content, the other 21 source-zero padding) carries only 11 TS
/// syncs after decrypt, so the majority vote calls it "destroyed" and
/// `aacs_unit_needs_decrypt` returns true — even though the unit decrypted
/// PERFECTLY. Concealing on that predicate overwrites the good decrypted tail with
/// NULL-TS, silently discarding correct video (the bug this fixes; the 1.2.0
/// fragment-tail fix in [`decrypt_unit`] must not be undone by the conceal loop).
///
/// The correct, padding-aware notion of "still ciphertext", checkable on the
/// post-decrypt bytes, uses the SAME discriminator [`decrypt_unit`] uses:
/// * A genuinely-FAILED unit was restored to on-disc ciphertext by
/// `decrypt_unit_try_keys`/`decrypt_buf` → its packets are scrambled: a
/// non-padding (non-zero payload) packet LACKS the `0x47` sync at offset 4.
/// * A SUCCESSFULLY-decrypted unit (full OR padding-tail) → every non-zero
/// (content) packet carries `0x47`; padding packets are all-zero.
///
/// So this is true iff `aacs_unit_encrypted` AND at least one 192-byte packet
/// whose 188-byte payload (`[off+4..off+192]`) is NOT all-zero is missing its
/// `0x47` sync at `off+4`. A full content unit (no zero-payload packets) reduces
/// to the strict all-32 check, so the common cases are unchanged: a fully-clear /
/// fully-decrypted unit is never flagged; a fully-ciphertext unit always is.
pub fn aacs_unit_still_ciphertext(unit: &[u8]) -> bool {
if !aacs_unit_encrypted(unit) {
return false;
}
const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192
let limit = ALIGNED_UNIT_LEN.min(unit.len());
let mut off = 0;
while off + PKT <= limit {
// A packet whose 188-byte payload is all-zero is padding (source zeros) —
// excluded from the verdict, exactly as `decrypt_unit` excludes it. Any
// other (content) packet that lacks its TS sync is un-restored ciphertext.
let payload = &unit[off + 4..off + PKT];
if !payload.iter().all(|&b| b == 0) && unit[off + 4] != TS_SYNC {
return true;
}
off += PKT;
}
false
}
/// Overwrite an aligned unit (6144 bytes) IN PLACE with valid NULL MPEG-TS
/// source packets — the [A2] mux loss-concealment fill for a content unit that
/// genuinely would not decrypt.
///
/// Zero-filling such a unit is wrong at the TS layer: a run of `0x00` bytes
/// carries no `0x47` sync, so the demuxer loses packet framing and can mis-parse
/// the *next* unit if a stray `0x47` appears mid-zero. Instead we lay down 32
/// well-formed BD source packets, each a TS null packet (PID `0x1FFF`) carrying
/// an adaptation-field **discontinuity_indicator**:
///
/// ```text
/// [4-byte TP_extra_header = 0][47 1F FF 20 B7 80 + 182 bytes 0xFF stuffing]
/// ^sync ^PID ^AF-only ^af_len=183 ^disc_indicator
/// ```
///
/// The demuxer stays byte-synced on the 192-byte stride, and because PID
/// `0x1FFF` matches no elementary stream every null packet is silently dropped.
/// The discontinuity_indicator is the B1 loss SIGNAL: `mux::ts` recognises a
/// `0x1FFF` packet with that bit set as a concealed gap and forces a discontinuity
/// on every tracked PID's next PES (the codec consumer then drops forward to the
/// next keyframe). This is CC-INDEPENDENT — unlike the real PID's continuity
/// counter it survives a loss that is an exact multiple of 16 packets, or a loss
/// at a PID's very start. NEVER emits ciphertext; lossless framing, not fabricated
/// content.
pub fn fill_null_ts_unit(unit: &mut [u8]) {
const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192
let mut off = 0;
while off + PKT <= unit.len() {
// TP_extra_header (arrival timestamp / copy-control) — zero is fine; the
// demuxer never reads it for a PID it does not track.
unit[off..off + 4].fill(0);
// 188-byte TS null packet: sync, PID 0x1FFF (no PUSI/TEI).
unit[off + 4] = TS_SYNC; // 0x47
unit[off + 5] = 0x1F; // PID high (top 5 bits of 0x1FFF, flags clear)
unit[off + 6] = 0xFF; // PID low
// adaptation_field_control = 0b10 (AF only, no payload), CC = 0.
unit[off + 7] = 0x20;
// adaptation_field_length = 183: the AF (its flags byte + 182 stuffing)
// fills the rest of the 188-byte packet.
unit[off + 8] = 0xB7;
// AF flags: discontinuity_indicator (0x80) — the concealed-gap signal.
unit[off + 9] = 0x80;
// Stuffing: 0xFF is the conventional adaptation-field fill.
unit[off + 10..off + PKT].fill(0xFF);
off += PKT;
}
}
/// Count the MPEG-TS sync bytes (`0x47`) present at the BD-TS packet stride
/// (offset 4 and every 192 bytes after — 4-byte TP_extra_header + 188-byte
/// TS packet). A clear or correctly-decrypted m2ts unit shows ~one per
@@ -258,7 +353,62 @@ pub fn unit_is_clean_ps(unit: &[u8]) -> bool {
/// Decryption restores the TS sync bytes, so the unit reads as clear afterward;
/// there is no flag to clear.
pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
decrypt_unit_checked(unit, unit_key, unit_is_clean_ts)
if unit.len() < ALIGNED_UNIT_LEN {
return false;
}
if !aacs_unit_encrypted(unit) {
return true; // CPI flag clear → plaintext, pass through untouched
}
// PADDING-AWARE acceptance. The question this answers is "did we read good,
// decryptable data?" — NOT "are all 32 packets present". A content fragment
// can END mid-unit, with the disc zero-padding the rest of the aligned unit
// to the next fragment. Such a tail unit is `[real encrypted packets][source
// zeros]`: the real packets decrypt perfectly, but the strict all-32
// `unit_is_clean_ts` would reject the whole unit over the padding tail and
// discard real video. AES ciphertext is high-entropy, so a SOURCE-zero packet
// (all 192 bytes zero before decrypt) can only be padding, never content.
//
// So: a packet whose SOURCE bytes are all zero is padding — excluded from the
// verify and emitted as clean zeros. Every other (content) packet must
// restore its TS sync. A full content unit has no zero-source packets, so this
// is byte-identical to the old all-32 check (no regression, no wrong-key
// hole). The discriminator between a legitimate short tail and a genuine
// misread is exactly this: a misread leaves the failing packets' SOURCE
// non-zero (real ciphertext that won't decrypt) → still rejected.
const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192
let npkt = ALIGNED_UNIT_LEN / PKT;
let mut pad = [false; ALIGNED_UNIT_LEN / PKT];
for (p, slot) in pad.iter_mut().enumerate().take(npkt) {
let off = p * PKT;
*slot = unit[off..off + PKT].iter().all(|&b| b == 0);
}
// Save original first 16 bytes (the plaintext seed / header) and derive the
// per-unit decrypt key (identical to `decrypt_unit_checked`).
let mut header = [0u8; 16];
header.copy_from_slice(&unit[..16]);
let derived = aes_ecb_encrypt(unit_key, &header);
let mut decrypt_key = [0u8; 16];
for i in 0..16 {
decrypt_key[i] = derived[i] ^ header[i];
}
aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]);
// Verify content packets; zero out padding packets (their decrypted bytes are
// garbage from AES-decrypting zeros, but the source was zero so a clean zero
// fill is lossless and gives the demux a tidy gap instead of garbage).
for (p, &is_pad) in pad.iter().enumerate().take(npkt) {
let off = p * PKT;
if is_pad {
for b in unit[off..off + PKT].iter_mut() {
*b = 0;
}
} else if unit[off + 4] != TS_SYNC {
return false; // a real content packet failed → genuinely undecryptable
}
}
true
}
/// Decrypt an AACS aligned unit in place, accepting the key only when `accept`
@@ -703,6 +853,101 @@ mod tests {
unit
}
// ── Padding-aware IsDecryptable (fragment-tail recovery) ───────────────
//
// A content fragment can end mid-unit, with the disc zero-padding the rest
// of the aligned unit to the next fragment (proven on Dunkirk: ~11 real
// video packets + source-zero pad). `decrypt_unit` must accept such a unit
// — its real packets decrypt; the source-zero tail is padding, not content
// — while still REJECTING a unit whose undecryptable tail is non-zero (a
// genuine misread / wrong key). The discriminator is the SOURCE bytes of the
// failing packets: zero ⇒ padding (still decryptable), non-zero ⇒ bad data.
/// Encrypt a full clear unit under `unit_key`, then overwrite the tail (from
/// packet `keep` onward) with `fill`. `0x00` models disc fragment padding; a
/// non-zero `fill` models a corrupt/misread tail.
fn tail_filled_unit(unit_key: &[u8; 16], keep_pkts: usize, fill: u8) -> Vec<u8> {
let mut unit = clear_unit();
aacs_encrypt_unit(&mut unit, unit_key);
for b in unit[keep_pkts * BD_SOURCE_PACKET_BYTES..].iter_mut() {
*b = fill;
}
unit
}
#[test]
fn decryptable_full_content_unit_under_correct_key() {
let key = [0x5Au8; 16];
let mut unit = clear_unit();
aacs_encrypt_unit(&mut unit, &key);
assert!(
decrypt_unit(&mut unit, &key),
"full clean unit is decryptable"
);
assert_eq!(ts_sync_count(&unit), 32, "all 32 syncs restored");
}
#[test]
fn fragment_tail_with_source_zero_pad_is_decryptable() {
// 11 real content packets, then source-zero padding (the Dunkirk shape).
let key = [0x5Au8; 16];
let mut unit = tail_filled_unit(&key, 11, 0x00);
assert!(
decrypt_unit(&mut unit, &key),
"real prefix + source-zero pad IS decryptable"
);
for p in 0..11 {
assert_eq!(
unit[p * BD_SOURCE_PACKET_BYTES + 4],
TS_SYNC,
"content pkt {p} restored its sync"
);
}
// Padding emitted as clean zeros, not decrypted garbage.
for p in 11..32 {
let off = p * BD_SOURCE_PACKET_BYTES;
assert!(
unit[off..off + BD_SOURCE_PACKET_BYTES]
.iter()
.all(|&b| b == 0),
"padding pkt {p} zeroed"
);
}
}
#[test]
fn fragment_tail_with_nonzero_garbage_is_not_decryptable() {
// Same shape, but the tail is NON-zero — a genuine misread, not padding.
let key = [0x5Au8; 16];
let mut unit = tail_filled_unit(&key, 11, 0xC3);
assert!(
!decrypt_unit(&mut unit, &key),
"real prefix + non-zero garbage tail is NOT decryptable (misread)"
);
}
#[test]
fn wrong_key_full_unit_is_not_decryptable() {
let mut unit = clear_unit();
aacs_encrypt_unit(&mut unit, &[0x11u8; 16]);
assert!(
!decrypt_unit(&mut unit, &[0x22u8; 16]),
"wrong key on a full content unit is rejected"
);
}
#[test]
fn cpi_clear_unit_passes_through_decryptable() {
// CPI-clear (plaintext) unit: decryptable by definition, untouched.
let mut unit = clear_unit(); // byte 0 high bits clear
let before = unit.clone();
assert!(
decrypt_unit(&mut unit, &[0u8; 16]),
"clear unit passes through as decryptable"
);
assert_eq!(unit, before, "clear unit left untouched");
}
// ── AES-ECB KAT (FIPS-197 Appendix C.1) ────────────────────────────────
#[test]
@@ -1197,4 +1442,60 @@ mod tests {
// 6144 = 32 packets.
assert_eq!(ts_packet_total(&[0u8; ALIGNED_UNIT_LEN]), 32);
}
/// Direct coverage for the padding-aware conceal predicate. It must conceal
/// ONLY genuinely-undecryptable ciphertext — never a decrypted unit (full or
/// short padding-tail), a clear/non-encrypted unit, or an all-zero unit.
#[test]
fn aacs_unit_still_ciphertext_is_padding_aware() {
let pkt = BD_SOURCE_PACKET_BYTES;
// Build a 32-packet aligned unit. `cpi` sets the AACS CPI bits (byte 0).
// Per packet: b'S' = decrypted TS (0x47 sync + non-zero payload),
// b'C' = ciphertext (non-zero payload, no sync), b'P' = zero padding.
let build = |cpi: bool, kinds: &[u8]| {
let mut u = vec![0u8; ALIGNED_UNIT_LEN];
if cpi {
u[0] = 0xC0; // CPI bits in the packet-0 header (not the payload)
}
for (i, &k) in kinds.iter().enumerate() {
let off = i * pkt;
match k {
b'S' => {
u[off + 4] = TS_SYNC;
for b in &mut u[off + 5..off + pkt] {
*b = 0x10;
}
}
b'C' => {
// Scrambled: non-zero payload, no 0x47 at the sync position.
for b in &mut u[off + 4..off + pkt] {
*b = 0x5A;
}
}
_ => {} // b'P' → leave zero
}
}
u
};
// Not encrypted (CPI clear) → never concealed, even if it looks scrambled.
assert!(!aacs_unit_still_ciphertext(&build(false, &[b'C'; 32])));
// All-zero unit (CPI clear) → not encrypted → false.
assert!(!aacs_unit_still_ciphertext(&build(false, &[b'P'; 32])));
// Fully decrypted (all packets carry their sync) → false.
assert!(!aacs_unit_still_ciphertext(&build(true, &[b'S'; 32])));
// Fully ciphertext (no packet carries its sync) → true.
assert!(aacs_unit_still_ciphertext(&build(true, &[b'C'; 32])));
// Decrypted SHORT padding-tail: 11 content packets + 21 zero padding. The
// majority vote would mis-flag it (<16 syncs); the padding-aware predicate
// skips the zero padding and sees every non-zero packet has its sync.
let mut tail = [b'P'; 32];
for k in tail.iter_mut().take(11) {
*k = b'S';
}
assert!(
!aacs_unit_still_ciphertext(&build(true, &tail)),
"a decrypted short padding-tail must NOT be flagged as ciphertext"
);
}
}
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
//! Host-certificate collection — the one libfreemkv-side concern left from the
//! old in-tree AACS handshake. The cert mutual-auth itself now lives in the
//! `freemkv-unlock` AACS unlocker; libfreemkv only gathers the certs (a
//! keysource concern) and hands them across the seam.
/// Union the host certificates a scan can offer the drive: the explicit
/// `DriveCredentials`, then each key source's `host_certs(mkb)`. Host certs are
/// keysource-served, never compiled in. `mkb` lets a source pick a
/// generation-appropriate cert (the default impl ignores it).
pub fn collect_host_certs(
opts: &crate::disc::ScanOptions,
mkb: Option<u32>,
) -> Vec<crate::aacs::HostCert> {
let mut host_certs: Vec<crate::aacs::HostCert> = Vec::new();
if let Some(c) = &opts.credentials {
host_certs.extend(c.host_certs.iter().cloned());
}
for src in &opts.key_sources {
host_certs.extend(src.host_certs(mkb));
}
host_certs
}
+85 -105
View File
@@ -24,6 +24,13 @@ pub enum AacsVersion {
V21,
}
/// AACS major version as the small integer threaded through the scan / key
/// paths (`AacsState.version`, `DiscInputs.version`, `DiscInputsCtx::new`):
/// 1 = AACS 1.0 (BD), 2 = AACS 2.x (UHD). Centralised so the bare `1`/`2` — and
/// the V10-vs-else stride choice it drives — lives in exactly one place.
pub const AACS_MAJOR_BD: u8 = 1;
pub const AACS_MAJOR_UHD: u8 = 2;
impl AacsVersion {
/// Stride (in bytes) between successive encrypted unit keys in
/// `Unit_Key_RO.inf`.
@@ -33,6 +40,24 @@ impl AacsVersion {
AacsVersion::V20 | AacsVersion::V21 => 64,
}
}
/// This version as the major integer ([`AACS_MAJOR_BD`] / [`AACS_MAJOR_UHD`]).
pub fn major(self) -> u8 {
match self {
AacsVersion::V10 => AACS_MAJOR_BD,
AacsVersion::V20 | AacsVersion::V21 => AACS_MAJOR_UHD,
}
}
/// The version a bare major integer selects for stride purposes: only the
/// BD major is V10; every other value takes the V20/V21 64-byte stride.
pub fn from_major(major: u8) -> Self {
if major == AACS_MAJOR_BD {
AacsVersion::V10
} else {
AacsVersion::V20
}
}
}
// ── VUK derivation ──────────────────────────────────────────────────────────
@@ -435,61 +460,38 @@ pub mod probe {
/// Find Verify Media Key Record (type 0x81 for AACS 1.0, 0x86 for AACS 2.0/2.1) in MKB.
fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
let mut pos = 0;
let mut verify_rec_seen: Vec<(u8, usize, usize)> = Vec::new();
while pos + 4 <= mkb.len() {
let rec_type = mkb[pos];
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
if rec_len < 4 || pos + rec_len > mkb.len() {
break;
}
if rec_type == 0x81 || rec_type == 0x86 {
verify_rec_seen.push((rec_type, pos, rec_len));
}
if (rec_type == 0x81 || rec_type == 0x86) && rec_len >= 20 {
// mk_dv is at offset 4 of the record (after the 4-byte header)
// Verify-Media-Key record (0x81 for AACS 1.0, 0x86 for AACS 2.x): mk_dv is
// the 16 bytes at record offset 4 (body offset 0). Needs rec_len >= 20.
let found = crate::aacs::variants::mkb_records(mkb)
.find(|&(_, rt, len)| (rt == 0x81 || rt == 0x86) && len >= 20);
match found {
Some((o, rec_type, rec_len)) => {
let mut dv = [0u8; 16];
dv.copy_from_slice(&mkb[pos + 4..pos + 20]);
dv.copy_from_slice(&mkb[o + 4..o + 20]);
tracing::debug!(
target: "freemkv::disc",
phase = "mkb_mk_dv_found",
rec_type,
pos,
pos = o,
rec_len,
"mk_dv extracted from MKB"
);
return Some(dv);
Some(dv)
}
None => {
tracing::warn!(
target: "freemkv::disc",
phase = "mkb_mk_dv_not_found",
"no 0x81/0x86 record with rec_len>=20 found"
);
None
}
pos += rec_len;
}
tracing::warn!(
target: "freemkv::disc",
phase = "mkb_mk_dv_not_found",
verify_rec_seen = ?verify_rec_seen,
scanned_bytes = pos,
"no 0x81/0x86 record with rec_len>=20 found"
);
None
}
/// Find Subset-Difference records (type 0x04) in MKB.
fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> {
let mut pos = 0;
while pos + 4 <= mkb.len() {
let rec_type = mkb[pos];
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
if rec_len < 4 || pos + rec_len > mkb.len() {
break;
}
if rec_type == 0x04 && rec_len > 4 {
return Some(mkb[pos + 4..pos + rec_len].to_vec());
}
pos += rec_len;
}
None
find_record_body(mkb, 0x04)
}
/// Find the Media Key Data Record (cvalues table) in an MKB.
@@ -520,19 +522,9 @@ fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
/// record matching `rec_type`. Returns `None` if no such record exists or
/// the record is empty.
fn find_record_body(mkb: &[u8], rec_type_wanted: u8) -> Option<Vec<u8>> {
let mut pos = 0;
while pos + 4 <= mkb.len() {
let rec_type = mkb[pos];
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
if rec_len < 4 || pos + rec_len > mkb.len() {
break;
}
if rec_type == rec_type_wanted && rec_len > 4 {
return Some(mkb[pos + 4..pos + rec_len].to_vec());
}
pos += rec_len;
}
None
crate::aacs::variants::mkb_records(mkb)
.find(|&(_, rt, len)| rt == rec_type_wanted && len > 4)
.map(|(o, _, len)| mkb[o + 4..o + len].to_vec())
}
/// Real content length of an MKB: the byte offset where the record stream
@@ -543,17 +535,13 @@ fn find_record_body(mkb: &[u8], rec_type_wanted: u8) -> Option<Vec<u8>> {
/// actual size so callers can trim off megabytes of zeros before sending or
/// archiving. Returns `mkb.len()` only if the whole buffer parsed as records.
pub fn mkb_content_len(mkb: &[u8]) -> usize {
let mut pos = 0;
while pos + 4 <= mkb.len() {
let rec_type = mkb[pos];
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
// A zero type, a zero/short length, or an overrun = records done, padding begun.
if rec_type == 0x00 || rec_len < 4 || pos + rec_len > mkb.len() {
break;
}
pos += rec_len;
}
pos
// End of the last framed record = where the fixed-region zero padding begins.
// (The `00 000000` terminator / overrun stops the walk; real MKBs pad with
// zeros, so this matches the prior "stop at the first padding byte".)
crate::aacs::variants::mkb_records(mkb)
.last()
.map(|(o, _, len)| o + len)
.unwrap_or(0)
}
/// Trim an MKB's trailing fixed-region padding to its real content length —
@@ -577,25 +565,11 @@ pub fn trim_mkb(mut mkb: Vec<u8>) -> Vec<u8> {
/// body offset 0 (`pos + 4`), then the BE u32 version at body offset 4
/// (`pos + 8`).
pub fn mkb_version(mkb: &[u8]) -> Option<u32> {
let mut pos = 0;
while pos + 4 <= mkb.len() {
let rec_type = mkb[pos];
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
if rec_len < 4 || pos + rec_len > mkb.len() {
break;
}
if rec_type == 0x10 && rec_len >= 12 {
return Some(u32::from_be_bytes([
mkb[pos + 8],
mkb[pos + 9],
mkb[pos + 10],
mkb[pos + 11],
]));
}
pos += rec_len;
}
None
// Type-and-Version record (0x10): version is the BE u32 at body offset 4
// (record offset 8). Needs rec_len >= 12 (4 header + 4 type + 4 version).
crate::aacs::variants::mkb_records(mkb)
.find(|&(_, rt, len)| rt == 0x10 && len >= 12)
.map(|(o, _, _)| u32::from_be_bytes([mkb[o + 8], mkb[o + 9], mkb[o + 10], mkb[o + 11]]))
}
// ── MKB Type field (Type-and-Version record 0x10, bytes 4-7) ────────────────
@@ -662,24 +636,11 @@ impl MkbType {
/// The raw 32-bit MKBType field from the Type-and-Version record (0x10), bytes
/// 4-7. `None` if no 0x10 record is present.
pub fn mkb_type_raw(mkb: &[u8]) -> Option<u32> {
let mut pos = 0;
while pos + 4 <= mkb.len() {
let rec_type = mkb[pos];
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
if rec_len < 4 || pos + rec_len > mkb.len() {
break;
}
if rec_type == 0x10 && rec_len >= 8 {
return Some(u32::from_be_bytes([
mkb[pos + 4],
mkb[pos + 5],
mkb[pos + 6],
mkb[pos + 7],
]));
}
pos += rec_len;
}
None
// Type-and-Version record (0x10): the 32-bit MKBType is bytes 4-7 (body
// offset 0). Needs rec_len >= 8 (4 header + 4 type).
crate::aacs::variants::mkb_records(mkb)
.find(|&(_, rt, len)| rt == 0x10 && len >= 8)
.map(|(o, _, _)| u32::from_be_bytes([mkb[o + 4], mkb[o + 5], mkb[o + 6], mkb[o + 7]]))
}
/// Decode an MKB's Type field. `None` if no Type-and-Version record is present.
@@ -972,7 +933,9 @@ const MKB_PACK_SIZE: usize = 32772;
/// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83).
/// Returns the concatenated MKB data from all packs.
pub fn read_mkb_from_drive(session: &mut crate::drive::Drive) -> crate::error::Result<Vec<u8>> {
pub fn read_mkb_from_drive(
session: &mut dyn crate::scsi::ScsiTransport,
) -> crate::error::Result<Vec<u8>> {
use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE};
let cdb = [
@@ -990,7 +953,7 @@ pub fn read_mkb_from_drive(session: &mut crate::drive::Drive) -> crate::error::R
0x00,
];
let mut buf = vec![0u8; 32772];
session.scsi_execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)?;
session.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)?;
let data_len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
if data_len < 2 {
@@ -1028,7 +991,7 @@ pub fn read_mkb_from_drive(session: &mut crate::drive::Drive) -> crate::error::R
let mut buf = vec![0u8; 32772];
if session
.scsi_execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)
.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)
.is_ok()
{
let len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
@@ -1546,6 +1509,23 @@ mod tests {
use super::super::types::DiscEntry;
use super::*;
/// Audit #5: the `major` / `from_major` mapping is load-bearing for the
/// Unit_Key_RO stride, so pin it as a table. V10 ↔ BD; V20/V21 → UHD; any
/// non-BD major selects the V20/V21 64-byte stride (V10 is the only 48-byte).
#[test]
fn aacs_major_round_trips_and_strides_differ() {
assert_eq!(AacsVersion::V10.major(), AACS_MAJOR_BD);
assert_eq!(AacsVersion::V20.major(), AACS_MAJOR_UHD);
assert_eq!(AacsVersion::V21.major(), AACS_MAJOR_UHD);
assert_eq!(AacsVersion::from_major(AACS_MAJOR_BD), AacsVersion::V10);
assert_eq!(AacsVersion::from_major(AACS_MAJOR_UHD), AacsVersion::V20);
assert_eq!(AacsVersion::from_major(99), AacsVersion::V20); // any non-BD → V20
assert_ne!(
AacsVersion::from_major(AACS_MAJOR_BD).unit_key_stride(),
AacsVersion::from_major(AACS_MAJOR_UHD).unit_key_stride()
);
}
/// Finding #5 regression: parse_unit_key_ro must REJECT a Unit_Key_RO.inf
/// whose declared `num_unit_keys` exceeds the keys actually present in the
/// buffer, instead of silently returning a short list. A truncated list
+20 -7
View File
@@ -16,13 +16,25 @@
pub mod boil;
pub mod decrypt;
pub mod handshake;
pub mod host_certs;
pub mod keys;
pub mod provider;
pub mod trace;
pub mod types;
pub mod variants;
/// On-disc UDF paths to the AACS key-input files (with their fallbacks).
/// Centralised so every reader (`resolve_vid_only`, `read_aacs_inputs`,
/// `read_mkb_content`, `read_aacs_version`) walks the exact same files — adding
/// or changing a fallback in one place can then never silently diverge the
/// disc_hash / MKB / VID that another reader feeds a key service.
pub const PATH_UNIT_KEY_RO: &str = "/AACS/Unit_Key_RO.inf";
pub const PATH_UNIT_KEY_RO_DUPLICATE: &str = "/AACS/DUPLICATE/Unit_Key_RO.inf";
pub const PATH_MKB_RO: &str = "/AACS/MKB_RO.inf";
pub const PATH_MKB_RW: &str = "/AACS/MKB_RW.inf";
pub const PATH_CONTENT_CERT: &str = "/AACS/Content000.cer";
pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer";
// Boil-down derivation primitives (thin newtypes + wrappers over the crypto).
pub use boil::{MediaKey, UnitKey, Vid, Vuk, mk_from_dk, mk_from_pk, uk_from_vuk, vuk_from_mk};
// Structured, English-free resolution trace.
@@ -32,9 +44,10 @@ pub use trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace, UnlockOutcome, Un
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
pub use decrypt::{
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, UnitKeyResult, aacs_unit_encrypted,
aacs_unit_needs_decrypt, decrypt_bus, decrypt_unit, decrypt_unit_checked, decrypt_unit_full,
decrypt_unit_try_keys, is_unit_aligned, ts_packet_total, ts_sync_count, ts_sync_destroyed,
unit_is_clean_ps, unit_is_clean_ts, unit_key_validates,
aacs_unit_needs_decrypt, aacs_unit_still_ciphertext, decrypt_bus, decrypt_unit,
decrypt_unit_checked, decrypt_unit_full, decrypt_unit_try_keys, fill_null_ts_unit,
is_unit_aligned, ts_packet_total, ts_sync_count, ts_sync_destroyed, unit_is_clean_ps,
unit_is_clean_ts, unit_key_validates,
};
// `probe` is a reproduction-harness helper (see keys.rs), not part of the
// documented 1.0 surface; keep it reachable but off the rendered docs so we
@@ -42,9 +55,9 @@ pub use decrypt::{
#[doc(hidden)]
pub use keys::probe;
pub use keys::{
AacsVersion, ContentCert, MKB_20_CATEGORY_C, MKB_21_CATEGORY_C, MKB_TYPE_3_RECORDABLE,
MKB_TYPE_4_PRERECORDED, MKB_TYPE_10_CLASS_II, MkbType, ResolveContext, ResolveFailure,
ResolvedKeys, UnitKeyFile, decrypt_unit_key, derive_media_key_and_pk_from_dk,
AACS_MAJOR_BD, AACS_MAJOR_UHD, AacsVersion, ContentCert, MKB_20_CATEGORY_C, MKB_21_CATEGORY_C,
MKB_TYPE_3_RECORDABLE, MKB_TYPE_4_PRERECORDED, MKB_TYPE_10_CLASS_II, MkbType, ResolveContext,
ResolveFailure, ResolvedKeys, UnitKeyFile, decrypt_unit_key, derive_media_key_and_pk_from_dk,
derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex,
mkb_content_len, mkb_is_uhd, mkb_type, mkb_type_raw, mkb_version, parse_content_cert,
parse_unit_key_ro, read_mkb_from_drive, recover_dk_position, resolve_keys_v1, resolve_keys_v2,
+1 -1
View File
@@ -31,7 +31,7 @@ impl ResolutionTrace {
// ── Unlock phase ────────────────────────────────────────────────────────────
/// One unlocker's contribution to the unlock phase. `who` is the unlocker's
/// `name()` (a stable identifier, e.g. `"LibreDrive"`), carried verbatim.
/// `name()` (a stable, product-neutral identifier), carried verbatim.
#[derive(Debug, Clone, PartialEq)]
pub struct UnlockStep {
pub who: String,
+46 -14
View File
@@ -88,29 +88,44 @@ pub struct MkbRecord {
/// INCLUDING the 4-byte header, followed by payload. The walker stops
/// at the first `(type=0, len=0)` end marker or at end of buffer.
pub fn walk_mkb(mkb: &[u8]) -> Vec<MkbRecord> {
let mut out = Vec::new();
let mut pos = 0;
while pos + 4 <= mkb.len() {
mkb_records(mkb)
.map(|(offset, rec_type, rec_len)| MkbRecord {
offset,
rec_type,
rec_len,
body: mkb[offset + 4..offset + rec_len].to_vec(),
})
.collect()
}
/// THE single MKB record-framing walker: yields `(offset, rec_type, rec_len)`
/// for each record — a 4-byte header (type byte + big-endian 24-bit length)
/// then the body — stopping at the `00 000000` end marker or a
/// malformed/out-of-bounds length. Lazy (no body clone), so a find-one-record
/// caller never materialises the multi-MB cvalue table. [`walk_mkb`] and every
/// MKB record walk in `aacs::keys` are built on this, so the framing rules — and
/// any future fix to them — live in exactly one place (they had drifted across
/// six hand-rolled copies).
pub(crate) fn mkb_records(mkb: &[u8]) -> impl Iterator<Item = (usize, u8, usize)> + '_ {
let mut pos = 0usize;
std::iter::from_fn(move || {
if pos + 4 > mkb.len() {
return None;
}
let rec_type = mkb[pos];
let rec_len = ((mkb[pos + 1] as usize) << 16)
| ((mkb[pos + 2] as usize) << 8)
| (mkb[pos + 3] as usize);
if rec_type == 0 && rec_len == 0 {
break;
return None;
}
if rec_len < 4 || pos + rec_len > mkb.len() {
break;
return None;
}
let body = mkb[pos + 4..pos + rec_len].to_vec();
out.push(MkbRecord {
offset: pos,
rec_type,
rec_len,
body,
});
let here = pos;
pos += rec_len;
}
out
Some((here, rec_type, rec_len))
})
}
/// True iff `records` contains at least one Media Key Variant record
@@ -773,6 +788,23 @@ mod tests {
assert_eq!(recs[1].body, vec![1, 2, 3, 4]);
}
#[test]
fn mkb_records_matches_walk_mkb_framing() {
// The lazy `mkb_records` iterator and the owning `walk_mkb` must agree on
// (offset, type, len) for every record — they share the one framing
// walker, and every keys.rs MKB walk now relies on this equivalence.
let mut mkb = vec![0x10, 0x00, 0x00, 0x06, 0xAA, 0xBB];
mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 1, 2, 3, 4]);
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00, 0xFF]); // terminator + trailing
let owned: Vec<(usize, u8, usize)> = walk_mkb(&mkb)
.iter()
.map(|r| (r.offset, r.rec_type, r.rec_len))
.collect();
let lazy: Vec<(usize, u8, usize)> = mkb_records(&mkb).collect();
assert_eq!(lazy, owned);
assert_eq!(lazy, vec![(0, 0x10, 6), (6, 0x05, 8)]);
}
#[test]
fn walk_mkb_be24_high_byte_is_honored() {
// A record longer than 255 bytes needs the high BE24 byte. Build a
+4
View File
@@ -20,6 +20,10 @@ pub const SECTOR_BYTES: usize = 2048;
/// the workspace reads as `sectors * SECTOR_BYTES_U64` with no per-site cast.
pub const SECTOR_BYTES_U64: u64 = SECTOR_BYTES as u64;
/// Milliseconds per second. For turning a byte count ÷ bytes-per-second into a
/// movie-time figure (`bytes / bps * MILLIS_PER_SEC`) without a bare `1000.0`.
pub const MILLIS_PER_SEC: f64 = 1_000.0;
/// Bytes per MPEG-2 transport-stream packet. Common to all MPEG-TS, not just
/// Blu-ray — prefixed by the format, not a disc type.
pub const TS_PACKET_BYTES: usize = 188;
-838
View File
@@ -1,838 +0,0 @@
//! CSS drive bus-authentication — read-unlock primitive.
//!
//! A CSS-enforcing DVD drive refuses to return scrambled sectors until a
//! CSS bus-auth handshake has set its Authentication Success Flag (ASF=1).
//! [`unlock_css_reads`] runs that bus-auth challenge-response (which is what
//! actually opens scrambled-sector reads), then a best-effort, non-fatal
//! disc-key REPORT KEY. The bytes are NOT used as keys: the descramble title
//! key is recovered keylessly by the Stevenson known-plaintext attack (see
//! [`super::crack_key`]).
use crate::drive::Drive;
use crate::error::{Error, Result};
// ── CryptKey tables ───────────────────────────────────────────────────────
const CRYPT_TAB0: [u8; 256] = [
0xB7, 0xF4, 0x82, 0x57, 0xDA, 0x4D, 0xDB, 0xE2, 0x2F, 0x52, 0x1A, 0xA8, 0x68, 0x5A, 0x8A, 0xFF,
0xFB, 0x0E, 0x6D, 0x35, 0xF7, 0x5C, 0x76, 0x12, 0xCE, 0x25, 0x79, 0x29, 0x39, 0x62, 0x08, 0x24,
0xA5, 0x85, 0x7B, 0x56, 0x01, 0x23, 0x68, 0xCF, 0x0A, 0xE2, 0x5A, 0xED, 0x3D, 0x59, 0xB0, 0xA9,
0xB0, 0x2C, 0xF2, 0xB8, 0xEF, 0x32, 0xA9, 0x40, 0x80, 0x71, 0xAF, 0x1E, 0xDE, 0x8F, 0x58, 0x88,
0xB8, 0x3A, 0xD0, 0xFC, 0xC4, 0x1E, 0xB5, 0xA0, 0xBB, 0x3B, 0x0F, 0x01, 0x7E, 0x1F, 0x9F, 0xD9,
0xAA, 0xB8, 0x3D, 0x9D, 0x74, 0x1E, 0x25, 0xDB, 0x37, 0x56, 0x8F, 0x16, 0xBA, 0x49, 0x2B, 0xAC,
0xD0, 0xBD, 0x95, 0x20, 0xBE, 0x7A, 0x28, 0xD0, 0x51, 0x64, 0x63, 0x1C, 0x7F, 0x66, 0x10, 0xBB,
0xC4, 0x56, 0x1A, 0x04, 0x6E, 0x0A, 0xEC, 0x9C, 0xD6, 0xE8, 0x9A, 0x7A, 0xCF, 0x8C, 0xDB, 0xB1,
0xEF, 0x71, 0xDE, 0x31, 0xFF, 0x54, 0x3E, 0x5E, 0x07, 0x69, 0x96, 0xB0, 0xCF, 0xDD, 0x9E, 0x47,
0xC7, 0x96, 0x8F, 0xE4, 0x2B, 0x59, 0xC6, 0xEE, 0xB9, 0x86, 0x9A, 0x64, 0x84, 0x72, 0xE2, 0x5B,
0xA2, 0x96, 0x58, 0x99, 0x50, 0x03, 0xF5, 0x38, 0x4D, 0x02, 0x7D, 0xE7, 0x7D, 0x75, 0xA7, 0xB8,
0x67, 0x87, 0x84, 0x3F, 0x1D, 0x11, 0xE5, 0xFC, 0x1E, 0xD3, 0x83, 0x16, 0xA5, 0x29, 0xF6, 0xC7,
0x15, 0x61, 0x29, 0x1A, 0x43, 0x4F, 0x9B, 0xAF, 0xC5, 0x87, 0x34, 0x6C, 0x0F, 0x3B, 0xA8, 0x1D,
0x45, 0x58, 0x25, 0xDC, 0xA8, 0xA3, 0x3B, 0xD1, 0x79, 0x1B, 0x48, 0xF2, 0xE9, 0x93, 0x1F, 0xFC,
0xDB, 0x2A, 0x90, 0xA9, 0x8A, 0x3D, 0x39, 0x18, 0xA3, 0x8E, 0x58, 0x6C, 0xE0, 0x12, 0xBB, 0x25,
0xCD, 0x71, 0x22, 0xA2, 0x64, 0xC6, 0xE7, 0xFB, 0xAD, 0x94, 0x77, 0x04, 0x9A, 0x39, 0xCF, 0x7C,
];
const CRYPT_TAB1: [u8; 256] = [
0x8C, 0x47, 0xB0, 0xE1, 0xEB, 0xFC, 0xEB, 0x56, 0x10, 0xE5, 0x2C, 0x1A, 0x5D, 0xEF, 0xBE, 0x4F,
0x08, 0x75, 0x97, 0x4B, 0x0E, 0x25, 0x8E, 0x6E, 0x39, 0x5A, 0x87, 0x53, 0xC4, 0x1F, 0xF4, 0x5C,
0x4E, 0xE6, 0x99, 0x30, 0xE0, 0x42, 0x88, 0xAB, 0xE5, 0x85, 0xBC, 0x8F, 0xD8, 0x3C, 0x54, 0xC9,
0x53, 0x47, 0x18, 0xD6, 0x06, 0x5B, 0x41, 0x2C, 0x67, 0x1E, 0x41, 0x74, 0x33, 0xE2, 0xB4, 0xE0,
0x23, 0x29, 0x42, 0xEA, 0x55, 0x0F, 0x25, 0xB4, 0x24, 0x2C, 0x99, 0x13, 0xEB, 0x0A, 0x0B, 0xC9,
0xF9, 0x63, 0x67, 0x43, 0x2D, 0xC7, 0x7D, 0x07, 0x60, 0x89, 0xD1, 0xCC, 0xE7, 0x94, 0x77, 0x74,
0x9B, 0x7E, 0xD7, 0xE6, 0xFF, 0xBB, 0x68, 0x14, 0x1E, 0xA3, 0x25, 0xDE, 0x3A, 0xA3, 0x54, 0x7B,
0x87, 0x9D, 0x50, 0xCA, 0x27, 0xC3, 0xA4, 0x50, 0x91, 0x27, 0xD4, 0xB0, 0x82, 0x41, 0x97, 0x79,
0x94, 0x82, 0xAC, 0xC7, 0x8E, 0xA5, 0x4E, 0xAA, 0x78, 0x9E, 0xE0, 0x42, 0xBA, 0x28, 0xEA, 0xB7,
0x74, 0xAD, 0x35, 0xDA, 0x92, 0x60, 0x7E, 0xD2, 0x0E, 0xB9, 0x24, 0x5E, 0x39, 0x4F, 0x5E, 0x63,
0x09, 0xB5, 0xFA, 0xBF, 0xF1, 0x22, 0x55, 0x1C, 0xE2, 0x25, 0xDB, 0xC5, 0xD8, 0x50, 0x03, 0x98,
0xC4, 0xAC, 0x2E, 0x11, 0xB4, 0x38, 0x4D, 0xD0, 0xB9, 0xFC, 0x2D, 0x3C, 0x08, 0x04, 0x5A, 0xEF,
0xCE, 0x32, 0xFB, 0x4C, 0x92, 0x1E, 0x4B, 0xFB, 0x1A, 0xD0, 0xE2, 0x3E, 0xDA, 0x6E, 0x7C, 0x4D,
0x56, 0xC3, 0x3F, 0x42, 0xB1, 0x3A, 0x23, 0x4D, 0x6E, 0x84, 0x56, 0x68, 0xF4, 0x0E, 0x03, 0x64,
0xD0, 0xA9, 0x92, 0x2F, 0x8B, 0xBC, 0x39, 0x9C, 0xAC, 0x09, 0x5E, 0xEE, 0xE5, 0x97, 0xBF, 0xA5,
0xCE, 0xFA, 0x28, 0x2C, 0x6D, 0x4F, 0xEF, 0x77, 0xAA, 0x1B, 0x79, 0x8E, 0x97, 0xB4, 0xC3, 0xF4,
];
const CRYPT_TAB2: [u8; 256] = [
0xB7, 0x75, 0x81, 0xD5, 0xDC, 0xCA, 0xDE, 0x66, 0x23, 0xDF, 0x15, 0x26, 0x62, 0xD1, 0x83, 0x77,
0xE3, 0x97, 0x76, 0xAF, 0xE9, 0xC3, 0x6B, 0x8E, 0xDA, 0xB0, 0x6E, 0xBF, 0x2B, 0xF1, 0x19, 0xB4,
0x95, 0x34, 0x48, 0xE4, 0x37, 0x94, 0x5D, 0x7B, 0x36, 0x5F, 0x65, 0x53, 0x07, 0xE2, 0x89, 0x11,
0x98, 0x85, 0xD9, 0x12, 0xC1, 0x9D, 0x84, 0xEC, 0xA4, 0xD4, 0x88, 0xB8, 0xFC, 0x2C, 0x79, 0x28,
0xD8, 0xDB, 0xB3, 0x1E, 0xA2, 0xF9, 0xD0, 0x44, 0xD7, 0xD6, 0x60, 0xEF, 0x14, 0xF4, 0xF6, 0x31,
0xD2, 0x41, 0x46, 0x67, 0x0A, 0xE1, 0x58, 0x27, 0x43, 0xA3, 0xF8, 0xE0, 0xC8, 0xBA, 0x5A, 0x5C,
0x80, 0x6C, 0xC6, 0xF2, 0xE8, 0xAD, 0x7D, 0x04, 0x0D, 0xB9, 0x3C, 0xC2, 0x25, 0xBD, 0x49, 0x63,
0x8C, 0x9F, 0x51, 0xCE, 0x20, 0xC5, 0xA1, 0x50, 0x92, 0x2D, 0xDD, 0xBC, 0x8D, 0x4F, 0x9A, 0x71,
0x2F, 0x30, 0x1D, 0x73, 0x39, 0x13, 0xFB, 0x1A, 0xCB, 0x24, 0x59, 0xFE, 0x05, 0x96, 0x57, 0x0F,
0x1F, 0xCF, 0x54, 0xBE, 0xF5, 0x06, 0x1B, 0xB2, 0x6D, 0xD3, 0x4D, 0x32, 0x56, 0x21, 0x33, 0x0B,
0x52, 0xE7, 0xAB, 0xEB, 0xA6, 0x74, 0x00, 0x4C, 0xB1, 0x7F, 0x82, 0x99, 0x87, 0x0E, 0x5E, 0xC0,
0x8F, 0xEE, 0x6F, 0x55, 0xF3, 0x7E, 0x08, 0x90, 0xFA, 0xB6, 0x64, 0x70, 0x47, 0x4A, 0x17, 0xA7,
0xB5, 0x40, 0x8A, 0x38, 0xE5, 0x68, 0x3E, 0x8B, 0x69, 0xAA, 0x9B, 0x42, 0xA5, 0x10, 0x01, 0x35,
0xFD, 0x61, 0x9E, 0xE6, 0x16, 0x9C, 0x86, 0xED, 0xCD, 0x2E, 0xFF, 0xC4, 0x5B, 0xA0, 0xAE, 0xCC,
0x4B, 0x3B, 0x03, 0xBB, 0x1C, 0x2A, 0xAC, 0x0C, 0x3F, 0x93, 0xC7, 0x72, 0x7A, 0x09, 0x22, 0x3D,
0x45, 0x78, 0xA9, 0xA8, 0xEA, 0xC9, 0x6A, 0xF7, 0x29, 0x91, 0xF0, 0x02, 0x18, 0x3A, 0x4E, 0x7C,
];
const CRYPT_TAB3: [u8; 256] = [
0x73, 0x51, 0x95, 0xE1, 0x12, 0xE4, 0xC0, 0x58, 0xEE, 0xF2, 0x08, 0x1B, 0xA9, 0xFA, 0x98, 0x4C,
0xA7, 0x33, 0xE2, 0x1B, 0xA7, 0x6D, 0xF5, 0x30, 0x97, 0x1D, 0xF3, 0x02, 0x60, 0x5A, 0x82, 0x0F,
0x91, 0xD0, 0x9C, 0x10, 0x39, 0x7A, 0x83, 0x85, 0x3B, 0xB2, 0xB8, 0xAE, 0x0C, 0x09, 0x52, 0xEA,
0x1C, 0xE1, 0x8D, 0x66, 0x4F, 0xF3, 0xDA, 0x92, 0x29, 0xB9, 0xD5, 0xC5, 0x77, 0x47, 0x22, 0x53,
0x14, 0xF7, 0xAF, 0x22, 0x64, 0xDF, 0xC6, 0x72, 0x12, 0xF3, 0x75, 0xDA, 0xD7, 0xD7, 0xE5, 0x02,
0x9E, 0xED, 0xDA, 0xDB, 0x4C, 0x47, 0xCE, 0x91, 0x06, 0x06, 0x6D, 0x55, 0x8B, 0x19, 0xC9, 0xEF,
0x8C, 0x80, 0x1A, 0x0E, 0xEE, 0x4B, 0xAB, 0xF2, 0x08, 0x5C, 0xE9, 0x37, 0x26, 0x5E, 0x9A, 0x90,
0x00, 0xF3, 0x0D, 0xB2, 0xA6, 0xA3, 0xF7, 0x26, 0x17, 0x48, 0x88, 0xC9, 0x0E, 0x2C, 0xC9, 0x02,
0xE7, 0x18, 0x05, 0x4B, 0xF3, 0x39, 0xE1, 0x20, 0x02, 0x0D, 0x40, 0xC7, 0xCA, 0xB9, 0x48, 0x30,
0x57, 0x67, 0xCC, 0x06, 0xBF, 0xAC, 0x81, 0x08, 0x24, 0x7A, 0xD4, 0x8B, 0x19, 0x8E, 0xAC, 0xB4,
0x5A, 0x0F, 0x73, 0x13, 0xAC, 0x9E, 0xDA, 0xB6, 0xB8, 0x96, 0x5B, 0x60, 0x88, 0xE1, 0x81, 0x3F,
0x07, 0x86, 0x37, 0x2D, 0x79, 0x14, 0x52, 0xEA, 0x73, 0xDF, 0x3D, 0x09, 0xC8, 0x25, 0x48, 0xD8,
0x75, 0x60, 0x9A, 0x08, 0x27, 0x4A, 0x2C, 0xB9, 0xA8, 0x8B, 0x8A, 0x73, 0x62, 0x37, 0x16, 0x02,
0xBD, 0xC1, 0x0E, 0x56, 0x54, 0x3E, 0x14, 0x5F, 0x8C, 0x8F, 0x6E, 0x75, 0x1C, 0x07, 0x39, 0x7B,
0x4B, 0xDB, 0xD3, 0x4B, 0x1E, 0xC8, 0x7E, 0xFE, 0x3E, 0x72, 0x16, 0x83, 0x7D, 0xEE, 0xF5, 0xCA,
0xC5, 0x18, 0xF9, 0xD8, 0x68, 0xAB, 0x38, 0x85, 0xA8, 0xF0, 0xA1, 0x73, 0x9F, 0x5D, 0x19, 0x0B,
];
const VARIANTS: [u8; 32] = [
0xB7, 0x74, 0x85, 0xD0, 0xCC, 0xDB, 0xCA, 0x73, 0x03, 0xFE, 0x31, 0x03, 0x52, 0xE0, 0xB7, 0x42,
0x63, 0x16, 0xF2, 0x2A, 0x79, 0x52, 0xFF, 0x1B, 0x7A, 0x11, 0xCA, 0x1A, 0x9B, 0x40, 0xAD, 0x01,
];
const SECRET: [u8; 5] = [0x55, 0xD6, 0xC4, 0xC5, 0x28];
const PERM_CHALLENGE: [[usize; 10]; 3] = [
[1, 3, 0, 7, 5, 2, 9, 6, 4, 8],
[6, 1, 9, 3, 8, 5, 7, 4, 0, 2],
[4, 0, 3, 5, 7, 2, 8, 6, 1, 9],
];
const PERM_VARIANT: [[u8; 32]; 2] = [
[
0x0A, 0x08, 0x0E, 0x0C, 0x0B, 0x09, 0x0F, 0x0D, 0x1A, 0x18, 0x1E, 0x1C, 0x1B, 0x19, 0x1F,
0x1D, 0x02, 0x00, 0x06, 0x04, 0x03, 0x01, 0x07, 0x05, 0x12, 0x10, 0x16, 0x14, 0x13, 0x11,
0x17, 0x15,
],
[
0x12, 0x1A, 0x16, 0x1E, 0x02, 0x0A, 0x06, 0x0E, 0x10, 0x18, 0x14, 0x1C, 0x00, 0x08, 0x04,
0x0C, 0x13, 0x1B, 0x17, 0x1F, 0x03, 0x0B, 0x07, 0x0F, 0x11, 0x19, 0x15, 0x1D, 0x01, 0x09,
0x05, 0x0D,
],
];
// ── Public API ────────────────────────────────────────────────────────────
/// CSS bus-auth **unlock** primitive.
///
/// Runs the bus-auth challenge-response (which sets the drive's ASF=1 and is
/// what actually unlocks scrambled-sector reads), then a best-effort,
/// non-fatal disc-key REPORT KEY. The title-key REPORT KEY is NOT issued: it
/// is unnecessary (the descramble key is recovered keylessly by the Stevenson
/// attack in [`super::crack_key`]) and its hard failure on some USB bridges
/// used to abort the whole unlock (the 7014 bug). The bytes are discarded.
pub fn unlock_css_reads(drive: &mut Drive, lba: u32) -> Result<()> {
let t0 = std::time::Instant::now();
tracing::info!(target: "freemkv::css", phase = "unlock_css_reads", lba, "begin");
let r = unlock_css_reads_inner(drive, lba);
tracing::info!(
target: "freemkv::css",
phase = "unlock_css_reads",
lba,
ok = r.is_ok(),
elapsed_ms = t0.elapsed().as_millis() as u64,
"end"
);
r
}
fn unlock_css_reads_inner(drive: &mut Drive, _lba: u32) -> Result<()> {
tracing::debug!(target: "freemkv::css", "css unlock: begin");
// The bus-auth challenge-response sets the drive's Authentication Success
// Flag (ASF=1), which is what opens scrambled-sector reads. This is the
// ONLY step required to unlock reads; a failure here is fatal — we
// genuinely cannot read scrambled sectors.
let (agid, _bus_key) = bus_auth(drive).inspect_err(|e| {
tracing::warn!(target: "freemkv::css", error_code = e.code(), "css unlock: bus_auth failed");
})?;
tracing::debug!(target: "freemkv::css", agid, "css unlock: bus_auth ok");
// Disc-key REPORT KEY: issued BEST-EFFORT for any firmware that ties part
// of its read-unlock to it. The bytes are unused (the descramble key is
// recovered keylessly) and a failure is NON-FATAL — the gate is already
// open from bus-auth. This replaces the title-key REPORT KEY, whose hard
// failure used to abort the whole unlock (the 7014 bug on USB bridges).
if let Err(e) = read_disc_key(drive, agid) {
tracing::debug!(target: "freemkv::css", error_code = e.code(), "css unlock: disc-key REPORT KEY skipped (non-fatal)");
}
tracing::debug!(target: "freemkv::css", "css unlock: ok");
Ok(())
}
// ── Step 1: Bus Authentication ────────────────────────────────────────────
fn bus_auth(drive: &mut Drive) -> Result<(u8, [u8; 5])> {
let scsi = drive.scsi_mut();
// Invalidate all AGIDs via REPORT KEY format 0x3F
for agid in 0..4u8 {
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
// alloc_len = 0 (no data transfer)
cdb[10] = (agid << 6) | 0x3F;
let mut buf = [0u8; 8];
let _ = scsi.execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
);
}
// Allocate AGID
let mut buf = [0u8; 8];
scsi.execute(
&report_key_cdb(0, 0x00, 8),
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
)
.map_err(|_| Error::CssAuthFailed)?;
let agid = (buf[7] >> 6) & 0x03;
// Host sends challenge. The spec wants a fresh per-session random nonce,
// not a fixed constant — a predictable challenge weakens the bus-auth
// handshake.
let mut host_challenge = [0u8; 10];
{
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut host_challenge);
}
let mut hc_buf = [0u8; 16];
hc_buf[0] = 0x00;
hc_buf[1] = 0x0E;
for i in 0..10 {
hc_buf[4 + i] = host_challenge[9 - i];
}
scsi.execute(
&send_key_cdb(agid, 0x01, 16),
crate::scsi::DataDirection::ToDevice,
&mut hc_buf,
5_000,
)
.map_err(|_| Error::CssAuthFailed)?;
// Get Key1 from drive
let mut dk_buf = [0u8; 12];
scsi.execute(
&report_key_cdb(agid, 0x02, 12),
crate::scsi::DataDirection::FromDevice,
&mut dk_buf,
5_000,
)
.map_err(|_| Error::CssAuthFailed)?;
let mut key1 = [0u8; 5];
for i in 0..5 {
key1[i] = dk_buf[4 + (4 - i)];
}
// Brute-force variant (0-31)
let mut variant: Option<u8> = None;
for v in 0..32u8 {
if crypt_key(0, v, &host_challenge) == key1 {
variant = Some(v);
break;
}
}
let variant = variant.ok_or(Error::CssAuthFailed)?;
// Get drive challenge
let mut dc_buf = [0u8; 16];
scsi.execute(
&report_key_cdb(agid, 0x01, 16),
crate::scsi::DataDirection::FromDevice,
&mut dc_buf,
5_000,
)
.map_err(|_| Error::CssAuthFailed)?;
let mut drive_challenge = [0u8; 10];
for i in 0..10 {
drive_challenge[i] = dc_buf[4 + (9 - i)];
}
// Compute Key2 and send it
let key2 = crypt_key(1, variant, &drive_challenge);
let mut hk_buf = [0u8; 12];
hk_buf[0] = 0x00;
hk_buf[1] = 0x0A;
for i in 0..5 {
hk_buf[4 + i] = key2[4 - i];
}
scsi.execute(
&send_key_cdb(agid, 0x03, 12),
crate::scsi::DataDirection::ToDevice,
&mut hk_buf,
5_000,
)
.map_err(|_| Error::CssAuthFailed)?;
// Bus key = CryptKey(2, variant, key1 || key2)
let mut combined = [0u8; 10];
combined[..5].copy_from_slice(&key1);
combined[5..].copy_from_slice(&key2);
let bus_key = crypt_key(2, variant, &combined);
Ok((agid, bus_key))
}
// ── Step 2: Disc Key ──────────────────────────────────────────────────────
/// Issue READ DVD STRUCTURE format 0x02 (Copyright Information — opcode 0xAD,
/// NOT the REPORT KEY 0xA4 disc-key block) purely for the bus-auth unlock side
/// effect. The returned block contents are not used — the descramble title key
/// is recovered keylessly elsewhere, so the genuine disc-key REPORT KEY is
/// intentionally skipped. (If a drive is ever found where bus-auth alone does
/// not open scrambled reads, a real REPORT KEY format 0x02 belongs here.)
fn read_disc_key(drive: &mut Drive, agid: u8) -> Result<()> {
let scsi = drive.scsi_mut();
// READ DVD STRUCTURE, format 0x02 (disc key), 2048+4 bytes
let alloc_len: u16 = 2048 + 4;
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_READ_DISC_STRUCTURE;
// bytes 2-5: address = 0
cdb[6] = 0; // layer
cdb[7] = 0x02; // format = disc key
cdb[8] = (alloc_len >> 8) as u8;
cdb[9] = alloc_len as u8;
cdb[10] = agid << 6;
let mut buf = vec![0u8; alloc_len as usize];
let dvd_result = scsi.execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
);
dvd_result.map_err(|_| Error::CssAuthFailed)?;
Ok(())
}
// ── CSSCryptKey ───────────────────────────────────────────────────────────
fn crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
// key_type indexes PERM_CHALLENGE ([_;3]); variant indexes
// VARIANTS/PERM_VARIANT ([_;32]). All internal callers pass key_type in
// 0..3 and variant in 0..32; the asserts document the contract for the
// pub(crate) test entry point test_crypt_key and turn a would-be
// out-of-bounds panic into an explicit precondition violation.
debug_assert!(key_type < 3, "crypt_key: key_type out of range");
debug_assert!((variant as usize) < 32, "crypt_key: variant out of range");
let perm = &PERM_CHALLENGE[key_type];
let mut scratch = [0u8; 10];
for i in 0..10 {
scratch[i] = challenge[perm[i]];
}
let css_variant = match key_type {
0 => variant as usize,
1 => PERM_VARIANT[0][variant as usize] as usize,
_ => PERM_VARIANT[1][variant as usize] as usize,
};
let cse = VARIANTS[css_variant] ^ CRYPT_TAB2[css_variant];
let mut tmp1 = [0u8; 5];
for i in 0..5 {
tmp1[i] = scratch[5 + i] ^ SECRET[i] ^ CRYPT_TAB2[i];
}
let mut lfsr0: u32 = ((tmp1[0] as u32) << 17)
| ((tmp1[1] as u32) << 9)
| (((tmp1[2] as u32) & !7) << 1)
| 8
| (tmp1[2] as u32 & 7);
let mut lfsr1: u32 = ((tmp1[3] as u32) << 9) | 0x100 | (tmp1[4] as u32);
let mut bits = [0u8; 30];
let mut carry: u32 = 0;
for idx in (0..30).rev() {
let mut val: u8 = 0;
for bit in 0..8u8 {
let lfsr0_out = ((lfsr0 >> 24) ^ (lfsr0 >> 21) ^ (lfsr0 >> 20) ^ (lfsr0 >> 12)) & 1;
lfsr0 = ((lfsr0 << 1) | lfsr0_out) & 0x1FFFFFF;
let lfsr1_out = ((lfsr1 >> 16) ^ (lfsr1 >> 2)) & 1;
lfsr1 = ((lfsr1 << 1) | lfsr1_out) & 0x1FFFF;
let combined = ((!lfsr1_out) & 1) + carry + ((!lfsr0_out) & 1);
carry = (combined >> 1) & 1;
val |= ((combined & 1) as u8) << bit;
}
bits[idx] = val;
}
let mut tmp1 = [scratch[0], scratch[1], scratch[2], scratch[3], scratch[4]];
let mut tmp2 = [0u8; 5];
// Round 1: bits[25..29] ^ scratch -> tmp1 (term from original scratch)
{
let mut term: u8 = 0;
for i in (0..5usize).rev() {
let idx = (bits[25 + i] ^ tmp1[i]) as usize;
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
tmp1[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
term = scratch[i]; // original challenge, NOT modified tmp1
}
tmp1[4] ^= tmp1[0];
}
// Round 2
{
let mut term: u8 = 0;
for i in (0..5usize).rev() {
let idx = (bits[20 + i] ^ tmp1[i]) as usize;
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
tmp2[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
term = tmp1[i];
}
tmp2[4] ^= tmp2[0];
}
// Round 3 (uses CRYPT_TAB0)
{
let mut term: u8 = 0;
for i in (0..5usize).rev() {
let idx = (bits[15 + i] ^ tmp2[i]) as usize;
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
let idx3 = (CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term) as usize;
tmp1[i] = CRYPT_TAB0[idx3] ^ CRYPT_TAB2[idx3];
term = tmp2[i];
}
tmp1[4] ^= tmp1[0];
}
// Round 4 (uses CRYPT_TAB0)
{
let mut term: u8 = 0;
for i in (0..5usize).rev() {
let idx = (bits[10 + i] ^ tmp1[i]) as usize;
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
let idx3 = (CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term) as usize;
tmp2[i] = CRYPT_TAB0[idx3] ^ CRYPT_TAB2[idx3];
term = tmp1[i];
}
tmp2[4] ^= tmp2[0];
}
// Round 5
{
let mut term: u8 = 0;
for i in (0..5usize).rev() {
let idx = (bits[5 + i] ^ tmp2[i]) as usize;
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
tmp1[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
term = tmp2[i];
}
tmp1[4] ^= tmp1[0];
}
// Round 6
let mut key = [0u8; 5];
{
let mut term: u8 = 0;
for i in (0..5usize).rev() {
let idx = (bits[i] ^ tmp1[i]) as usize;
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
key[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
term = tmp1[i];
}
}
key
}
// ── SCSI CDB builders ────────────────────────────────────────────────────
fn report_key_cdb(agid: u8, format: u8, alloc_len: u16) -> [u8; 12] {
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
cdb[8] = (alloc_len >> 8) as u8;
cdb[9] = alloc_len as u8;
cdb[10] = (agid << 6) | (format & 0x3F);
cdb
}
fn send_key_cdb(agid: u8, format: u8, param_len: u16) -> [u8; 12] {
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_SEND_KEY;
cdb[8] = (param_len >> 8) as u8;
cdb[9] = param_len as u8;
cdb[10] = (agid << 6) | (format & 0x3F);
cdb
}
// ── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// SECURITY REGRESSION GUARD: no instrumentation in libfreemkv may emit
/// raw key material. Scan every source file for a `tracing` field that
/// binds a forbidden key name to a value-producing expression (`= expr`
/// or `%expr` / `?expr`). The only allowed forms are a string literal
/// (e.g. `disc_key = "<redacted>"`) or a `_fp` fingerprint field.
///
/// This is a source-scan test (not a runtime capture) so it stays cheap
/// and catches re-introductions at compile/CI time.
#[test]
fn no_key_bytes_in_instrumentation() {
use std::path::Path;
// Forbidden field names whose VALUES must never be logged.
const FORBIDDEN: &[&str] = &[
"title_key",
"disc_key",
"unit_key",
"vuk",
"player_key",
"bus_key",
];
fn scan_dir(dir: &Path, forbidden: &[&str], violations: &mut Vec<String>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
scan_dir(&path, forbidden, violations);
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let src = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => continue,
};
for (lineno, line) in src.lines().enumerate() {
let trimmed = line.trim_start();
// Only inspect tracing instrumentation lines.
if !(trimmed.contains("tracing::")
|| trimmed.starts_with("debug!")
|| trimmed.starts_with("info!")
|| trimmed.starts_with("warn!")
|| trimmed.starts_with("trace!")
|| trimmed.starts_with("error!"))
{
continue;
}
// This guard test itself contains the forbidden names.
if path.file_name().and_then(|n| n.to_str()) == Some("auth.rs")
&& line.contains("FORBIDDEN")
{
continue;
}
for &name in forbidden {
// A fingerprint field (`<name>_fp = ...`) is allowed.
// Match `<name>` followed by optional fingerprint
// suffix then `=` and a value that is NOT a string
// literal redaction marker.
if let Some(idx) = line.find(name) {
let after = &line[idx + name.len()..];
let after = after.trim_start();
// `<name>_fp` / `<name>_id` etc. are safe.
if after.starts_with('_') {
continue;
}
// Must be a field binding `name = ...`.
let Some(rest) = after.strip_prefix('=') else {
continue;
};
let rest = rest.trim_start();
// Redaction string literal is the only allowed value.
if rest.starts_with('"') {
continue;
}
// Anything else (`%expr`, `?expr`, bare expr) leaks bytes.
violations.push(format!(
"{}:{}: forbidden key field `{}` logged with a value: {}",
path.display(),
lineno + 1,
name,
line.trim()
));
}
}
}
}
}
// Scan this crate's `src` plus the sibling workspace crates so the
// key-material logging guard covers every crate that can reach the
// CSS/AACS internals, not just libfreemkv. Missing sibling dirs (e.g.
// when building the crate standalone) are simply skipped.
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace = manifest.parent().unwrap_or(manifest);
let mut violations = Vec::new();
scan_dir(&manifest.join("src"), FORBIDDEN, &mut violations);
for sibling in ["autorip", "freemkv", "freemkv-keysources"] {
let dir = workspace.join(sibling).join("src");
if dir.is_dir() {
scan_dir(&dir, FORBIDDEN, &mut violations);
}
}
assert!(
violations.is_empty(),
"key material logged in instrumentation:\n{}",
violations.join("\n")
);
}
#[test]
fn crypt_key_is_deterministic() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
for v in 0..32u8 {
let r1 = crypt_key(0, v, &challenge);
let r2 = crypt_key(0, v, &challenge);
assert_eq!(r1, r2);
}
}
#[test]
fn crypt_key_varies_by_variant() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
assert_ne!(crypt_key(0, 0, &challenge), crypt_key(0, 1, &challenge));
}
#[test]
fn crypt_key_varies_by_type() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
assert_ne!(crypt_key(0, 5, &challenge), crypt_key(1, 5, &challenge));
}
#[test]
fn crypt_key_nonzero() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
for v in 0..32u8 {
assert_ne!(crypt_key(0, v, &challenge), [0u8; 5]);
}
}
// ── CSS constant-table integrity ───────────────────────────────────────
/// Each PERM_CHALLENGE row is a permutation of indices 0..10 (it reorders
/// the 10 challenge bytes). A non-permutation would drop/duplicate
/// challenge bytes, weakening or corrupting the bus key derivation.
///
/// Grounding: crypt_key does `scratch[i] = challenge[perm[i]]` for i in
/// 0..10 — perm must be a bijection on 0..10 to use every challenge byte
/// exactly once.
/// Mutation: change PERM_CHALLENGE[0] entry `9` to `8` (duplicate) -> the
/// "covers 0..10" assert fires.
#[test]
fn perm_challenge_rows_are_permutations() {
for (row, perm) in PERM_CHALLENGE.iter().enumerate() {
let mut seen = [false; 10];
for &idx in perm.iter() {
assert!(idx < 10, "PERM_CHALLENGE[{row}] index {idx} out of range");
assert!(!seen[idx], "PERM_CHALLENGE[{row}] duplicates index {idx}");
seen[idx] = true;
}
assert!(
seen.iter().all(|&b| b),
"PERM_CHALLENGE[{row}] misses an index"
);
}
}
/// Each PERM_VARIANT row maps the 32 variants to 32 distinct 5-bit values
/// (it is a permutation of 0..32). key_type 1 uses PERM_VARIANT[0],
/// key_type 2 uses PERM_VARIANT[1] to pick the css_variant; a collision
/// would make two variants indistinguishable.
///
/// Grounding: `css_variant = PERM_VARIANT[k][variant]` then indexes
/// VARIANTS[css_variant] (0..32).
/// Mutation: set PERM_VARIANT[0][1] = PERM_VARIANT[0][0] -> duplicate
/// assert fires; also any value >= 32 would later index VARIANTS OOB.
#[test]
fn perm_variant_rows_are_permutations_of_0_31() {
for (row, perm) in PERM_VARIANT.iter().enumerate() {
let mut seen = [false; 32];
for &v in perm.iter() {
let v = v as usize;
assert!(v < 32, "PERM_VARIANT[{row}] value {v} out of 0..32");
assert!(!seen[v], "PERM_VARIANT[{row}] duplicates {v}");
seen[v] = true;
}
assert!(
seen.iter().all(|&b| b),
"PERM_VARIANT[{row}] misses a value"
);
}
}
// ── crypt_key behaviour ────────────────────────────────────────────────
/// crypt_key result depends on every challenge byte. The challenge is
/// permuted into `scratch` and folded through the LFSR seeding and the 6
/// XOR rounds. Flipping any single challenge byte must change the output.
///
/// Grounding: scratch[i]=challenge[perm[i]] for all 10 i, and scratch
/// seeds both LFSRs (bytes 5..10 via tmp1) and the round terms (bytes
/// 0..5).
/// Mutation: in `scratch[i] = challenge[perm[i]]` replace with
/// `challenge[i]` for a perm that drops a byte — or hardcode one scratch
/// entry — and some challenge byte stops mattering; this fails.
#[test]
fn crypt_key_depends_on_every_challenge_byte() {
let base: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let base_out = crypt_key(0, 5, &base);
for i in 0..10 {
let mut c = base;
c[i] ^= 0x55;
assert_ne!(
crypt_key(0, 5, &c),
base_out,
"flipping challenge byte {i} did not change the bus-key derivation"
);
}
}
/// crypt_key(0, v, ..) must produce a DISTINCT result for each of the 32
/// variants on a fixed challenge. bus_auth brute-forces the variant by
/// matching crypt_key(0, v, host_challenge) == key1; if two variants
/// collided, the wrong variant could be selected and the whole auth
/// derail.
///
/// Grounding: variant selects css_variant -> VARIANTS[css_variant] -> cse,
/// which feeds every round; distinct variants give distinct cse-driven
/// keys in practice.
/// Mutation: make `cse` ignore the variant (e.g. `let cse = 0`) -> all 32
/// outputs collapse to one value; the distinctness assert fires.
#[test]
fn crypt_key_type0_distinct_per_variant() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let mut outs = Vec::new();
for v in 0..32u8 {
let k = crypt_key(0, v, &challenge);
assert!(
!outs.contains(&k),
"variant {v} collides with an earlier variant"
);
outs.push(k);
}
}
/// crypt_key enforces its documented precondition `key_type < 3` via
/// debug_assert (active in test builds). A key_type of 3 would index
/// PERM_CHALLENGE (len 3) out of bounds; the assert turns that into an
/// explicit precondition panic.
///
/// Grounding: `debug_assert!(key_type < 3, ...)`; PERM_CHALLENGE has 3
/// rows (indices 0,1,2).
/// Mutation: delete the debug_assert AND the match-arm guard — but the
/// match `_ =>` arm would then index PERM_CHALLENGE[3] OOB and panic
/// differently; with the assert in place this test pins the contract.
#[test]
#[should_panic]
fn crypt_key_rejects_out_of_range_key_type() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let _ = crypt_key(3, 0, &challenge);
}
/// crypt_key enforces `variant < 32` via debug_assert. A variant of 32
/// would index VARIANTS / PERM_VARIANT (len 32) out of bounds.
///
/// Grounding: `debug_assert!((variant as usize) < 32, ...)`.
/// Mutation: removing the assert makes this index VARIANTS[32] (still a
/// panic, but unguarded); the assert documents/enforces the contract.
#[test]
#[should_panic]
fn crypt_key_rejects_out_of_range_variant() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let _ = crypt_key(0, 32, &challenge);
}
// ── SCSI CDB builders (MMC REPORT KEY / SEND KEY layout) ───────────────
/// report_key_cdb encodes a 12-byte MMC REPORT KEY (opcode 0xA4) CDB:
/// byte 0 = operation code 0xA4
/// bytes 8-9 = allocation length, big-endian
/// byte 10 = (AGID << 6) | (key_format & 0x3F)
/// All other bytes are zero.
///
/// Grounding: MMC REPORT KEY CDB; the AGID is the top 2 bits of byte 10,
/// key format the low 6 bits.
/// Mutation: change `(alloc_len >> 8)` to `alloc_len` for byte 8 (lose the
/// big-endian split) -> byte 8/9 assert fails. Change `agid << 6` to
/// `agid << 5` -> the AGID-position assert fails.
#[test]
fn report_key_cdb_matches_mmc_layout() {
let cdb = report_key_cdb(0b10, 0x04, 0x010C); // AGID=2, format=0x04, len=268
assert_eq!(cdb[0], 0xA4, "REPORT KEY opcode");
assert_eq!(cdb[8], 0x01, "alloc_len high byte (big-endian)");
assert_eq!(cdb[9], 0x0C, "alloc_len low byte");
assert_eq!(
cdb[10],
(0b10 << 6) | 0x04,
"AGID in bits 6-7, format in bits 0-5"
);
// Every other byte must be zero.
for (i, &b) in cdb.iter().enumerate() {
if ![0, 8, 9, 10].contains(&i) {
assert_eq!(b, 0, "CDB byte {i} must be zero");
}
}
assert_eq!(cdb.len(), 12, "REPORT KEY is a 12-byte CDB");
}
/// The key format field is masked to 6 bits: a format with high bits set
/// must not corrupt the AGID. report_key_cdb(0, 0xFF, _) -> byte 10 low 6
/// bits = 0x3F, AGID = 0.
///
/// Grounding: `(agid << 6) | (format & 0x3F)`.
/// Mutation: drop the `& 0x3F` mask -> 0xFF would overwrite the AGID bits;
/// byte 10 would be 0xFF not 0x3F, this fails.
#[test]
fn report_key_cdb_masks_format_to_6_bits() {
let cdb = report_key_cdb(0, 0xFF, 8);
assert_eq!(cdb[10], 0x3F, "format masked to 6 bits, AGID stays 0");
}
/// send_key_cdb encodes a 12-byte MMC SEND KEY (opcode 0xA3) CDB with the
/// parameter-list length at bytes 8-9 (big-endian) and AGID/format at byte
/// 10.
///
/// Grounding: MMC SEND KEY CDB layout.
/// Mutation: change opcode to SCSI_REPORT_KEY -> opcode assert fails;
/// swap bytes 8/9 -> length assert fails.
#[test]
fn send_key_cdb_matches_mmc_layout() {
let cdb = send_key_cdb(0b11, 0x03, 0x000C); // AGID=3, format=3, param_len=12
assert_eq!(cdb[0], 0xA3, "SEND KEY opcode");
assert_eq!(cdb[8], 0x00, "param_len high byte");
assert_eq!(cdb[9], 0x0C, "param_len low byte");
assert_eq!(
cdb[10],
(0b11 << 6) | 0x03,
"AGID bits 6-7, format bits 0-5"
);
assert_eq!(cdb.len(), 12);
}
/// Allocation length larger than 255 must split across bytes 8 (high) and
/// 9 (low) — a 16-bit big-endian field. report_key_cdb with alloc_len
/// 0x0804 (2052, the disc-key block size used in read_disc_key) -> byte 8
/// = 0x08, byte 9 = 0x04.
///
/// Grounding: read_disc_key uses `alloc_len = 2048 + 4 = 2052 = 0x0804`
/// and writes `cdb[8] = (alloc_len >> 8); cdb[9] = alloc_len`.
/// Mutation: write only byte 9 (`cdb[9] = alloc_len as u8`) without byte 8
/// -> the drive sees a 4-byte transfer, truncating the disc-key block;
/// this asserts the high byte is present.
#[test]
fn report_key_cdb_alloc_len_is_16bit_big_endian() {
let cdb = report_key_cdb(0, 0x00, 0x0804);
assert_eq!(cdb[8], 0x08, "high byte of 2052-byte transfer");
assert_eq!(cdb[9], 0x04, "low byte of 2052-byte transfer");
}
}
-1
View File
@@ -14,7 +14,6 @@
//! }
//! ```
pub mod auth;
pub mod lfsr;
pub mod stevenson;
pub(crate) mod tables;
+12 -11
View File
@@ -922,18 +922,19 @@ mod tests {
2,
"DTS 2.0 nibble must decode to 2 channels"
);
// PIDs route via the per-codec sub-id table: AC-3 #0 → 0x80 → 0xBD80,
// DTS #0 → 0x88 → 0xBD88. Distinct (no 0xBD00 collision) AND the exact
// canonical PIDs.
assert_eq!(audios[0].pid, 0xBD80, "AC-3 #0 → 0xBD80");
assert_eq!(audios[1].pid, 0xBD88, "DTS #0 → 0xBD88");
// PIDs route via the positional sub-id table: AC-3 @ pos 0 → 0x80 →
// 0xBD80, DTS @ pos 1 → 0x89 → 0xBD89 (the shared audio-stream number
// in the low nibble, NOT a per-codec ordinal). Distinct AND the exact
// canonical wire PIDs the demux routes on.
assert_eq!(audios[0].pid, 0xBD80, "AC-3 @ pos 0 → 0xBD80");
assert_eq!(audios[1].pid, 0xBD89, "DTS @ pos 1 → 0xBD89");
assert_ne!(audios[0].pid, audios[1].pid);
}
/// LPCM SCAN ROUTING (audit §2 / §5 #6): the 0xA0..=0xA7 PID range was never
/// exercised in the dvd.rs scan. An LPCM stream (coding_mode 4) must get
/// sub_stream_id 0xA0 → PID 0xBDA0 via `dvd_audio_pid`, distinct from the
/// AC-3 0xBD80 space, with its real channel count preserved.
/// exercised in the dvd.rs scan. An LPCM stream (coding_mode 4) at audio
/// position 1 must get sub_stream_id 0xA1 → PID 0xBDA1 via `dvd_audio_pid`,
/// distinct from the AC-3 0xBD80 space, with its real channel count preserved.
#[test]
fn scan_dvd_titles_lpcm_routes_to_a0_pid_range() {
let mut disc = MemDisc::new();
@@ -978,10 +979,10 @@ mod tests {
assert_eq!(audios.len(), 2);
assert_eq!(audios[0].codec, Codec::Ac3);
assert_eq!(audios[1].codec, Codec::Lpcm, "coding_mode 4 → LPCM");
assert_eq!(audios[0].pid, 0xBD80, "AC-3 #0 → 0xBD80");
assert_eq!(audios[0].pid, 0xBD80, "AC-3 @ pos 0 → 0xBD80");
assert_eq!(
audios[1].pid, 0xBDA0,
"LPCM #0 → 0xBDA0 (the 0xA0 sub-id range), NOT the AC-3 space"
audios[1].pid, 0xBDA1,
"LPCM @ pos 1 → 0xBDA1 (the 0xA0 sub-id range | position), NOT the AC-3 space"
);
assert_eq!(
audios[1].channels.count(),
+243 -200
View File
@@ -11,47 +11,90 @@ use crate::udf;
pub(super) struct HandshakeResult {
pub volume_id: [u8; 16],
pub read_data_key: Option<[u8; 16]>,
/// When `read_data_key` is `None` because the bus-key read FAILED (as opposed
/// to a path that never attempts it), the error code from `read_data_keys`.
/// Carried so the downstream bus-key gate can log WHY the bus key is missing
/// instead of a bare "unavailable" — the difference between a diagnosable log
/// and archaeology.
pub read_data_key_err: Option<u16>,
/// True when the VID came from an unlocker (in `freemkv-unlock`) that
/// unlocked the drive. Such a drive serves CLEAR
/// content, so AACS bus encryption is already removed AT THE DRIVE — the same
/// end state a successful cert handshake's `read_data_key` provides, just via
/// firmware instead of the AKE. The bus-key gate MUST credit this as a valid
/// bus-removal: bus encryption is unremovable only when NEITHER the firmware
/// unlocked the drive NOR the cert handshake yielded a bus key. Without this,
/// a SUCCESSFUL unlock (VID present, `read_data_key: None`) paradoxically trips
/// the gate and blocks ALL key resolution (incl. the online source).
pub drive_unlocked: bool,
}
/// In-tree AACS host-certificate cert-auth "unlocker" — the Drive-level peer of
/// the external firmware [`crate::unlock::Unlocker`]s.
/// Single source of truth for "is AACS bus encryption gone for this scan?". The
/// gate asks ONLY this — `if !removed { error }` — never enumerating cases. Bus
/// encryption is gone when ANY of these holds:
/// - the disc never had it (`!bus_encryption`): nothing to remove;
/// - file/ISO reads (`handshake == None`): content is already clear at read time;
/// - an unlocker unlocked the drive (`drive_unlocked`): it serves clear
/// content;
/// - the cert handshake produced the bus key (`read_data_key`).
///
/// It is NOT a registry `dyn Unlocker`: the cert handshake helpers
/// ([`crate::aacs::handshake::aacs_authenticate`] et al.) operate on a concrete
/// `&mut Drive`, whereas the registry trait hands out a `&mut dyn ScsiTransport`
/// for external firmware unlockers (and keeps their unit tests trivially
/// fakeable). So the firmware path stays transport-level and registry-routed,
/// while this cert path is an in-tree Drive-level peer invoked directly by
/// [`Disc::do_handshake`]. Both produce a Volume ID under the shared
/// [`crate::unlock::UnlockError`] taxonomy.
/// Add a NEW removal mechanism HERE, never in the gate.
fn bus_encryption_removed(bus_encryption: bool, handshake: Option<&HandshakeResult>) -> bool {
if !bus_encryption {
return true; // never had it → nothing to remove
}
match handshake {
None => true, // file/ISO: clear at read time
Some(h) => h.drive_unlocked || h.read_data_key.is_some(),
}
}
/// libfreemkv-side driver for the AACS cert route. It owns the host-cert
/// collection (a keysource concern that stays in libfreemkv) and then dispatches
/// the actual mutual-auth to the `freemkv-unlock` AACS unlocker via the
/// [`crate::unlock_bridge`]. The firmware (drive-prep) and CSS routes dispatch
/// the same way at their own call sites; this one carries the host certs.
struct AacsCertUnlocker<'a> {
opts: &'a ScanOptions,
}
/// Why the AACS cert path produced no Volume ID. Distinguishes the libfreemkv-
/// side "no host cert at all" case (which carries the disc MKB generation for
/// the outcome trace) from the unlocker-reported [`freemkv_unlock::UnlockError`].
enum CertUnlockFailure {
/// No host cert was available from any source — detected in libfreemkv
/// before the unlocker runs, so the MKB generation is still known.
NoHostCert { mkb: Option<u32> },
/// The AACS unlocker ran and reported a specific failure.
Unlock(freemkv_unlock::UnlockError),
}
impl AacsCertUnlocker<'_> {
/// Run the host-certificate mutual-auth handshake: collect non-compiled-in
/// host certs from the key sources + credentials, try each (wedge-guarded),
/// and on success read the Volume ID + `read_data_key` (the AACS 2.0 bus
/// key). Returns a structured [`crate::unlock::UnlockError`] on every
/// no-VID outcome.
/// host certs from the key sources + credentials, then hand them to the AACS
/// unlocker (via the `freemkv-unlock` dispatch), which tries each cert
/// (wedge-guarded) and on success yields the Volume ID + `read_data_key`
/// (the AACS 2.0 bus key). Returns a [`CertUnlockFailure`] on every no-VID
/// outcome.
fn authenticate(
&self,
session: &mut crate::drive::Drive,
) -> std::result::Result<HandshakeResult, crate::unlock::UnlockError> {
) -> std::result::Result<HandshakeResult, CertUnlockFailure> {
use crate::aacs;
use crate::unlock::UnlockError;
use freemkv_unlock::UnlockError;
// MKB generation (best-effort) — forwarded to each source's
// `host_certs(mkb)` so a source MAY select a generation-appropriate cert
// (the default impl ignores it). A read failure leaves it `None`.
let mkb_gen = aacs::read_mkb_from_drive(session)
let mkb_gen = aacs::read_mkb_from_drive(session.scsi_mut())
.ok()
.and_then(|m| aacs::mkb_version(&m));
// Host certs are keysource-served, never compiled in — unioned from the
// explicit `DriveCredentials` and the key-source layer. With ZERO certs
// the cert route cannot run: NoUsableHostCert (folded to AacsNoHostCert
// by the caller, preserving the graceful path-1 disc-hash → VUK fallback).
// the cert route cannot run: NoHostCert (folded to AacsNoHostCert by the
// caller, preserving the graceful path-1 disc-hash → VUK fallback). This
// is detected here, where the MKB generation is still in hand.
let host_certs = Disc::collect_host_certs(self.opts, mkb_gen);
if host_certs.is_empty() {
tracing::warn!(
@@ -59,122 +102,70 @@ impl AacsCertUnlocker<'_> {
phase = "handshake_no_host_cert",
"No AACS host certificate available from any key source, so the host-certificate handshake can't run."
);
return Err(UnlockError::NoUsableHostCert { mkb: mkb_gen });
return Err(CertUnlockFailure::NoHostCert { mkb: mkb_gen });
}
let host_cert_count = host_certs.len();
tracing::debug!(
target: "freemkv::disc",
phase = "handshake_start",
host_cert_count,
"handshake starting"
);
// Cert-attempt wedge guard. An earlier version fired up to 16 AACS
// authenticate attempts back-to-back with no pause — 80-160 SCSI
// REPORT_KEY/SEND_KEY commands in a few hundred ms, which can drive
// consumer optical drives into a fast-fail firmware wedge (every CDB
// returns ILLEGAL_REQUEST until power-cycled). Defense-in-depth: cap
// attempts, sleep between, bail early on the drive's wedge sense.
const MAX_CERT_ATTEMPTS: usize = 3;
const PER_CERT_BACKOFF_MS: u64 = 1000;
let mut last_err_code: Option<u16> = None;
for (idx, hc) in host_certs.iter().take(MAX_CERT_ATTEMPTS).enumerate() {
if idx > 0 {
std::thread::sleep(std::time::Duration::from_millis(PER_CERT_BACKOFF_MS));
}
match aacs::handshake::aacs_authenticate(session, &hc.private_key, &hc.certificate) {
Ok(mut auth) => {
let volume_id = match aacs::handshake::read_volume_id(session, &mut auth) {
Ok(vid) => vid,
Err(e) => {
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_vid_read_failed",
cert_index = idx,
error_code = e.code(),
"auth ok but volume ID read failed"
);
return Err(UnlockError::VidUnavailable);
}
};
let read_data_key = aacs::handshake::read_data_keys(session, &mut auth)
.ok()
.map(|(rdk, _)| rdk);
tracing::debug!(
target: "freemkv::disc",
phase = "handshake_ok",
cert_index = idx,
has_read_data_key = read_data_key.is_some(),
);
return Ok(HandshakeResult {
volume_id,
read_data_key,
});
}
Err(e) => {
last_err_code = Some(e.code());
// Read the wedge sense off the structured ScsiSense, NOT
// `e.code()` (a flat constant for every ScsiError). On
// ILLEGAL_REQUEST the drive is signalling it won't talk to us
// — trying more certs worsens the wedge, so bail immediately.
let sense = e.scsi_sense();
if sense.map(|s| s.is_illegal_request()).unwrap_or(false) {
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_wedge_detected",
cert_index = idx,
sense_key = sense.map(|s| s.sense_key),
asc = sense.map(|s| s.asc),
ascq = sense.map(|s| s.ascq),
"drive returned ILLEGAL_REQUEST during auth; bailing out to avoid wedge"
);
return Err(UnlockError::HandshakeRejected);
}
continue;
}
}
}
tracing::info!(
target: "freemkv::disc",
phase = "vid_cert_rejected",
host_cert_count,
tried = host_cert_count.min(MAX_CERT_ATTEMPTS),
last_error_code = last_err_code,
"The drive rejected the AACS host certificate, so no Volume ID was obtained."
);
Err(UnlockError::HandshakeRejected)
// Hand the collected certs to the AACS unlocker. The cert-route bus
// removal depends on the read_data_key, NOT a drive unlock. The
// borrow checker can't split `session` across `scsi_mut()` + `&drive_id`
// through method calls, so clone the (cheap) identity first.
let drive_id = session.drive_id.clone();
let fu_certs = crate::unlock_bridge::map_host_certs(&host_certs);
let unlocked = crate::unlock_bridge::run_unlockers(
session.scsi_mut(),
&drive_id,
freemkv_unlock::DiscKind::Aacs,
&fu_certs,
)
.map_err(CertUnlockFailure::Unlock)?;
// The cert handshake yields a VID on success; its absence is VidUnavailable.
let Some(volume_id) = unlocked.vid else {
return Err(CertUnlockFailure::Unlock(UnlockError::VidUnavailable));
};
Ok(HandshakeResult {
volume_id,
read_data_key: unlocked.bus_key,
// The generic `Unlocked` contract carries no bus-key error code; the
// AACS-specific "why the read_data_key read failed" diagnostic does
// not cross the seam. The bus-key gate keys off presence, not cause.
read_data_key_err: None,
drive_unlocked: unlocked.drive_unlocked,
})
}
}
/// Map an [`crate::unlock::UnlockError`] from the cert path back to the
/// `Error` variant `do_handshake_cert` has always surfaced, so `scan_with`'s
/// rendering and the path-1 disc-hash → VUK fallback are byte-for-byte
/// unchanged. (`NoUsableHostCert` keeps the `<no host cert>` sentinel.)
fn unlock_error_to_error(e: crate::unlock::UnlockError) -> Error {
use crate::unlock::UnlockError;
/// Map a [`CertUnlockFailure`] back to the `Error` variant `do_handshake_cert`
/// has always surfaced, so `scan_with`'s rendering and the path-1 disc-hash →
/// VUK fallback are byte-for-byte unchanged. (`NoHostCert` keeps the
/// `<no host cert>` sentinel.)
fn unlock_error_to_error(e: &CertUnlockFailure) -> Error {
use freemkv_unlock::UnlockError;
match e {
UnlockError::NoUsableHostCert { .. } => Error::AacsNoHostCert {
CertUnlockFailure::NoHostCert { .. }
| CertUnlockFailure::Unlock(UnlockError::NoUsableHostCert) => Error::AacsNoHostCert {
path: "<no host cert>".into(),
},
UnlockError::VidUnavailable => Error::AacsVidUnavailable,
UnlockError::HandshakeRejected
| UnlockError::CertRevoked { .. }
| UnlockError::FirmwareNotUnlockable
| UnlockError::Scsi(_) => Error::AacsHostCertRejected,
CertUnlockFailure::Unlock(UnlockError::VidUnavailable) => Error::AacsVidUnavailable,
CertUnlockFailure::Unlock(
UnlockError::HandshakeRejected | UnlockError::NotApplicable | UnlockError::Transport,
) => Error::AacsHostCertRejected,
}
}
/// Map a cert-path [`crate::unlock::UnlockError`] to a structured
/// [`crate::aacs::UnlockOutcome`] for the resolution trace (English-free).
fn cert_unlock_outcome(e: &crate::unlock::UnlockError) -> crate::aacs::UnlockOutcome {
/// Map a [`CertUnlockFailure`] to a structured [`crate::aacs::UnlockOutcome`]
/// for the resolution trace (English-free).
fn cert_unlock_outcome(e: &CertUnlockFailure) -> crate::aacs::UnlockOutcome {
use crate::aacs::UnlockOutcome;
use crate::unlock::UnlockError;
use freemkv_unlock::UnlockError;
match e {
UnlockError::FirmwareNotUnlockable => UnlockOutcome::FirmwareNotUnlockable,
UnlockError::NoUsableHostCert { mkb } => UnlockOutcome::NoUsableHostCert { mkb: *mkb },
UnlockError::CertRevoked { mkb } => UnlockOutcome::CertRevoked { mkb: *mkb },
UnlockError::VidUnavailable => UnlockOutcome::VidUnavailable,
UnlockError::HandshakeRejected | UnlockError::Scsi(_) => UnlockOutcome::HandshakeRejected,
CertUnlockFailure::NoHostCert { mkb } => UnlockOutcome::NoUsableHostCert { mkb: *mkb },
CertUnlockFailure::Unlock(UnlockError::NoUsableHostCert) => {
UnlockOutcome::NoUsableHostCert { mkb: None }
}
CertUnlockFailure::Unlock(UnlockError::VidUnavailable) => UnlockOutcome::VidUnavailable,
CertUnlockFailure::Unlock(
UnlockError::HandshakeRejected | UnlockError::NotApplicable | UnlockError::Transport,
) => UnlockOutcome::HandshakeRejected,
}
}
@@ -183,11 +174,11 @@ impl Disc {
/// a structured `HandshakeResult` for downstream key resolution.
///
/// VID acquisition runs through [`Self::do_handshake_cert`], which first
/// asks the pluggable [`crate::unlock::Unlocker`] seam for the OEM VID
/// uses the OEM VID a firmware unlocker may have stashed at drive `init()`
/// (a drive-functionality capability decoupled from the host cert + HRL)
/// and falls back to the cert-based mutual-auth handshake when no
/// unlocker serves one. The cert path also yields `read_data_key`,
/// required for AACS 2.0 bus decryption.
/// and falls back to the cert-based mutual-auth handshake (dispatched to the
/// `freemkv-unlock` AACS unlocker) when none is present. The cert path also
/// yields `read_data_key`, required for AACS 2.0 bus decryption.
///
/// Returns `(handshake, error)`:
/// * `(Some(_), None)` — VID acquired
@@ -219,11 +210,11 @@ impl Disc {
/// Cert-based AACS handshake — the cert route for VID acquisition.
///
/// Before running the cert mutual-auth, this asks the pluggable
/// [`crate::unlock::Unlocker`] seam for the OEM Volume ID. An unlocker
/// unlocks *drive functionality*, not just the disc: VID retrieval via
/// the drive's OEM CDB is a capability separate from `unlock`. When the
/// matching unlocker serves a VID, we use it and SKIP the cert handshake
/// Before running the cert mutual-auth, this checks for an OEM Volume ID a
/// firmware unlocker stashed at drive `init()`. Such an unlocker unlocks
/// *drive functionality*, not just the disc: VID retrieval via the drive's
/// OEM CDB is a capability separate from `unlock`. When one served a VID,
/// we use it and SKIP the cert handshake
/// entirely — the OEM path gets the VID *without* the host certificate +
/// HRL, decoupling VID from the cert chain. The OEM path yields no
/// `read_data_key` (no bus-key is derived); AACS 2.0 content needing
@@ -240,23 +231,19 @@ impl Disc {
/// [`crate::KeySource::host_certs`] so a source MAY return only
/// generation-appropriate certs (the default ignores it).
fn collect_host_certs(opts: &ScanOptions, mkb: Option<u32>) -> Vec<crate::aacs::HostCert> {
let mut host_certs: Vec<crate::aacs::HostCert> = Vec::new();
if let Some(c) = &opts.credentials {
host_certs.extend(c.host_certs.iter().cloned());
}
for src in &opts.key_sources {
host_certs.extend(src.host_certs(mkb));
}
host_certs
// Delegates to the shared cert primitive (the external freemkv-unlock-aacs
// plugin uses the same one). Kept as a thin Disc method so the existing
// collect_host_certs_* unit tests and call sites are unchanged.
crate::aacs::host_certs::collect_host_certs(opts, mkb)
}
fn do_handshake_cert(
session: &mut crate::drive::Drive,
opts: &ScanOptions,
) -> (Option<HandshakeResult>, Option<Error>) {
// OEM VID shortcut: a matching firmware unlocker stashed the disc's
// Volume ID at drive `init()` (the new `unlock()` folds in the old
// `read_volume_id`). Use it and SKIP the cert handshake — the OEM path
// OEM VID shortcut: a matching unlocker stashed the disc's Volume ID at
// drive `init()` (the new `unlock()` folds in the old `read_volume_id`).
// Use it and SKIP the cert handshake — the OEM path
// decouples the VID from the host cert + HRL. It yields no
// `read_data_key`; a bus-encrypted disc that needs the bus key is caught
// by the bus-key gate in `resolve_vid_only`.
@@ -270,6 +257,13 @@ impl Disc {
Some(HandshakeResult {
volume_id,
read_data_key: None,
// OEM/VID-only path never attempts the bus-key read — None here
// is "not attempted", not "failed".
read_data_key_err: None,
// The unlocker stashed this VID at init, which means it
// matched and unlocked the drive — it now serves clear content,
// so bus encryption is removed at the drive. Credit it.
drive_unlocked: true,
}),
None,
);
@@ -296,7 +290,7 @@ impl Disc {
outcome = ?cert_unlock_outcome(&e),
"AACS cert handshake produced no VID; a key source may still supply this disc's key."
);
(None, Some(unlock_error_to_error(e)))
(None, Some(unlock_error_to_error(&e)))
}
}
}
@@ -315,63 +309,91 @@ impl Disc {
use crate::aacs;
let uk_ro_data = udf_fs
.read_file(reader, "/AACS/Unit_Key_RO.inf")
.or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO)
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO_DUPLICATE))
.map_err(|_| Error::AacsNoKeys)?;
let dh = aacs::disc_hash(&uk_ro_data);
let cc = udf_fs
.read_file(reader, "/AACS/Content000.cer")
.or_else(|_| udf_fs.read_file(reader, "/AACS/Content001.cer"))
.read_file(reader, crate::aacs::PATH_CONTENT_CERT)
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT))
.ok()
.as_deref()
.and_then(aacs::parse_content_cert);
let bus_encryption = cc.as_ref().map(|c| c.bus_encryption).unwrap_or(false);
let version = match cc.as_ref().map(|c| c.version) {
Some(aacs::AacsVersion::V10) => 1,
Some(_) => 2,
None if bus_encryption => 2,
None => 1,
};
// No-cert default = UHD (V20 stride), matching `read_aacs_version` so the
// scanned `AacsState.version` and the out-of-band fetch agree. A wrong
// stride on the main resolve path fails loudly (sample validation) rather
// than silently, so the conservative V20 default is safe here too.
let version = cc
.as_ref()
.map(|c| c.version.major())
.unwrap_or(aacs::AACS_MAJOR_UHD);
// OEM bus-key gate (wrong-keys guard). A bus-encrypted disc (Content
// Certificate bus-encryption bit set) still carries bus encryption on
// its sectors; descrambling needs the `read_data_key` (bus key), which
// ONLY the AACS host-certificate cert-auth handshake produces. A
// VID-only OEM unlock path returns `read_data_key: None`, and a VID
// alone does NOT remove bus encryption — so if a handshake ran (live
// drive) and yielded a VID but no bus key on a bus-encrypted disc, the
// bytes would decrypt to garbage. Fail loudly here instead.
// Bus-encryption gate (wrong-keys guard). A bus-encrypted disc (Content
// Certificate bus-encryption bit set) carries bus encryption on its
// sectors, which MUST be removed before any AACS key can decrypt them.
// There are TWO ways it gets removed, and bus encryption is unremovable
// only when NEITHER succeeded:
// 1. An unlocker unlocked the drive → it serves CLEAR content
// (`drive_unlocked`). This is the common live-drive case and yields
// no `read_data_key` — it doesn't need one.
// 2. The AACS host-certificate cert-auth handshake produced the bus key
// (`read_data_key`).
// The old gate credited ONLY (2), so a SUCCESSFUL drive unlock (VID
// present, `read_data_key: None`, `drive_unlocked: true`) tripped it and
// blocked ALL key resolution — including the online source — even though
// the drive was serving clear content. That was the bug.
//
// Gated on `handshake.is_some()` so the two preserved cases never
// regress: (1) file-backed/ISO scans reach here with `handshake = None`
// and have already had bus encryption removed at read time; (2) AACS 1.0
// BD is not bus-encrypted, so `bus_encryption` is false and the gate is
// skipped (its `read_data_key` is legitimately absent).
if bus_encryption && handshake.is_some_and(|h| h.read_data_key.is_none()) {
// Also skipped when `handshake = None` (file-backed/ISO scans — bus
// encryption already removed at read time) and when `bus_encryption` is
// false (AACS 1.0 BD is not bus-encrypted).
// ONE question — "is AACS bus encryption gone?" — asked of the single
// `bus_encryption_removed` predicate, which OWNS every case (never had it,
// file/ISO, drive unlock, cert bus key). The gate enumerates nothing.
if !bus_encryption_removed(bus_encryption, handshake) {
let (rdk_err, has_vid) = handshake
.map(|h| (h.read_data_key_err, h.volume_id != [0u8; 16]))
.unwrap_or((None, false));
tracing::warn!(
target: "freemkv::disc",
phase = "bus_key_unavailable",
"Disc declares bus encryption but the handshake produced no read_data_key; a VID-only/OEM unlock cannot remove bus encryption. Refusing to proceed with a key that would decrypt to garbage."
read_data_key_err = ?rdk_err,
has_volume_id = has_vid,
"Disc declares bus encryption but it could not be removed: no unlocker \
unlocked the drive AND the cert handshake produced no read_data_key. Refusing to \
emit a key that would decrypt to garbage."
);
return Err(Error::AacsBusKeyUnavailable);
}
// MKB_RO/RW are allocated to a fixed ~128 MiB and zero-padded; trim to
// the real record length (same as `read_aacs_inputs`). Without this the
// MKB stashed on `AacsState` — which `Disc::inputs()` and the device/
// processing-key `decrypt_with` derivation consume, and which a key
// source ships to an online service — is the full 128 MiB pad, not the
// ~few-MB record stream.
let mkb_bytes = udf_fs
.read_file(reader, "/AACS/MKB_RO.inf")
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RW.inf"))
.ok()
.unwrap_or_default();
// Trim to the real record length. Use trim_mkb rather than a raw
// truncate: trim_mkb only truncates when content_len > 0 and strictly
// inside the buffer, so a malformed/unrecognised MKB is preserved
// intact instead of being zeroed by truncate(0).
let mkb_bytes = aacs::trim_mkb(mkb_bytes);
// Read the MKB record stream via the SAME bounded reader the
// out-of-band `read_aacs_inputs` uses (`read_mkb_content`: a prefix-grow
// read + trim), NOT a full `read_file`. MKB_RO/RW is allocated to a
// fixed ~128 MiB of zero padding, and a full `read_file` of it FAILS on
// file-backed / large readers — which left `a.mkb` empty here, silently
// breaking online key resolution: `Disc::inputs()` shipped `mkb=0` to
// the decode service and it 404'd, while autorip's separate
// `read_aacs_inputs` path (this same helper) worked. One reader now, so
// `Disc::inputs()` is the single complete source of AACS inputs.
// A read ERROR is surfaced (logged), not silently emptied: an empty MKB
// here is invisible until an online key service rejects the request, so
// a transient I/O hiccup must not masquerade as "no MKB". We still
// continue with an empty MKB (disc-hash-keyed keydb lookups don't need
// it), but the cause is now on the log.
let mkb_bytes = match Self::read_mkb_content(reader, udf_fs) {
Ok(m) => m,
Err(e) => {
tracing::warn!(
target: "freemkv::disc",
phase = "scan_aacs_mkb",
error = %e,
"MKB read failed at scan; AACS inputs will carry an empty MKB \
(online key resolution cannot proceed without it). Continuing \
— disc-hash-keyed lookups are unaffected."
);
Vec::new()
}
};
let mkb_ver = aacs::mkb_version(&mkb_bytes);
tracing::debug!(
@@ -646,10 +668,12 @@ mod tests {
assert!(st.bus_encryption, "cert bus_encryption bit must propagate");
}
/// No content cert at all but bus_encryption can't be read → version
/// defaults to 1 (encrypt.rs: `None => 1`). bus_encryption false.
/// No content cert at all → version defaults to UHD (major 2), matching
/// `read_aacs_version` so the scanned `AacsState.version` and the out-of-band
/// fetch agree on the Unit_Key_RO stride (audit #4: a wrong BD-vs-UHD guess
/// mis-parses unit keys). bus_encryption false (unreadable → off).
#[test]
fn resolve_vid_only_no_cert_defaults_version_1() {
fn resolve_vid_only_no_cert_defaults_version_uhd() {
let mut disc = MemDisc::new();
let udf = build_aacs_fs(
&mut disc,
@@ -661,7 +685,11 @@ mod tests {
}],
);
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
assert_eq!(st.version, 1, "no cert → default version 1");
assert_eq!(
st.version,
aacs::AACS_MAJOR_UHD,
"no cert → default UHD (major 2)"
);
assert!(!st.bus_encryption);
}
@@ -765,6 +793,8 @@ mod tests {
let hs = HandshakeResult {
volume_id: vid,
read_data_key: Some(rdk),
read_data_key_err: None,
drive_unlocked: false,
};
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("state");
assert_eq!(st.volume_id, vid);
@@ -809,6 +839,8 @@ mod tests {
let hs = HandshakeResult {
volume_id: [0x11u8; 16],
read_data_key: None,
read_data_key_err: None,
drive_unlocked: false,
};
let err = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs))
.expect_err("bus-encrypted disc with no bus key must hard-error");
@@ -823,6 +855,8 @@ mod tests {
let hs = HandshakeResult {
volume_id: [0x11u8; 16],
read_data_key: Some([0x22u8; 16]),
read_data_key_err: None,
drive_unlocked: false,
};
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("bus key present → ok");
assert!(st.bus_encryption);
@@ -849,6 +883,8 @@ mod tests {
let hs = HandshakeResult {
volume_id: [0x11u8; 16],
read_data_key: None,
read_data_key_err: None,
drive_unlocked: false,
};
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("AACS 1.0 → ok");
assert!(!st.bus_encryption);
@@ -1013,22 +1049,28 @@ mod tests {
#[test]
fn unlock_error_maps_to_legacy_error_variants() {
use crate::unlock::UnlockError;
// No host cert keeps the AacsNoHostCert sentinel path.
match unlock_error_to_error(UnlockError::NoUsableHostCert { mkb: Some(68) }) {
use freemkv_unlock::UnlockError;
// No host cert (libfreemkv-side, carries mkb) keeps the AacsNoHostCert
// sentinel path — as does the unlocker's own NoUsableHostCert.
match unlock_error_to_error(&CertUnlockFailure::NoHostCert { mkb: Some(68) }) {
Error::AacsNoHostCert { path } => assert_eq!(path, "<no host cert>"),
other => panic!("expected AacsNoHostCert, got {other:?}"),
}
match unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::NoUsableHostCert)) {
Error::AacsNoHostCert { path } => assert_eq!(path, "<no host cert>"),
other => panic!("expected AacsNoHostCert, got {other:?}"),
}
assert!(matches!(
unlock_error_to_error(UnlockError::VidUnavailable),
unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::VidUnavailable)),
Error::AacsVidUnavailable
));
assert!(matches!(
unlock_error_to_error(UnlockError::HandshakeRejected),
unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::HandshakeRejected)),
Error::AacsHostCertRejected
));
// A transport fault folds to the rejected surface too.
assert!(matches!(
unlock_error_to_error(UnlockError::CertRevoked { mkb: None }),
unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::Transport)),
Error::AacsHostCertRejected
));
}
@@ -1036,22 +1078,23 @@ mod tests {
#[test]
fn cert_unlock_outcome_maps_to_structured_trace_step() {
use crate::aacs::UnlockOutcome;
use crate::unlock::UnlockError;
use freemkv_unlock::UnlockError;
// The libfreemkv-side no-cert case carries the MKB generation.
assert_eq!(
cert_unlock_outcome(&UnlockError::NoUsableHostCert { mkb: Some(77) }),
cert_unlock_outcome(&CertUnlockFailure::NoHostCert { mkb: Some(77) }),
UnlockOutcome::NoUsableHostCert { mkb: Some(77) }
);
assert_eq!(
cert_unlock_outcome(&UnlockError::VidUnavailable),
cert_unlock_outcome(&CertUnlockFailure::Unlock(UnlockError::VidUnavailable)),
UnlockOutcome::VidUnavailable
);
assert_eq!(
cert_unlock_outcome(&UnlockError::HandshakeRejected),
cert_unlock_outcome(&CertUnlockFailure::Unlock(UnlockError::HandshakeRejected)),
UnlockOutcome::HandshakeRejected
);
// A SCSI/transport error folds to HandshakeRejected at the trace layer.
// A transport fault folds to HandshakeRejected at the trace layer.
assert_eq!(
cert_unlock_outcome(&UnlockError::Scsi(4000)),
cert_unlock_outcome(&CertUnlockFailure::Unlock(UnlockError::Transport)),
UnlockOutcome::HandshakeRejected
);
}
+1
View File
@@ -686,6 +686,7 @@ fn report(opts: &ExtractOptions, done: u64, total: u64) -> bool {
bytes_bad_in_main_title: 0,
main_title_duration_secs: None,
main_title_size_bytes: None,
located: Default::default(),
};
p.report(&pp)
}
+3 -29
View File
@@ -651,35 +651,9 @@ impl Drop for Mapfile {
/// caller treats a bad VID comment as simply absent rather than an
/// error, so a corrupt header never fails a mapfile load.
fn parse_vid_hex(s: &str) -> Option<[u8; 16]> {
let s = s.strip_prefix("0x").unwrap_or(s);
// Parse on bytes, not on the &str: slicing a &str by byte index
// (`&s[i*2..i*2+2]`) panics when the cut lands inside a multi-byte
// UTF-8 char. A hand-edited/corrupt `# freemkv-vid:` comment of
// exactly 32 bytes containing a multi-byte char would otherwise
// kill the whole load. ASCII hex is one byte per char, so anything
// non-ASCII is simply rejected here as malformed.
let bytes = s.as_bytes();
if bytes.len() != 32 {
return None;
}
let mut out = [0u8; 16];
for (i, b) in out.iter_mut().enumerate() {
let hi = hex_nibble(bytes[i * 2])?;
let lo = hex_nibble(bytes[i * 2 + 1])?;
*b = (hi << 4) | lo;
}
Some(out)
}
/// Map a single ASCII hex digit byte to its 0-15 value. Returns `None`
/// for any non-hex byte (including any non-ASCII / multi-byte lead byte).
fn hex_nibble(c: u8) -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
// The one workspace hex parser (accepts an optional `0x`/`0X` prefix,
// byte-based so a multi-byte `# freemkv-vid:` comment rejects, never panics).
crate::hex::parse_hex_fixed::<16>(s)
}
/// Parse a `# freemkv-uk:` value `<cps>:<32hex>` into `(cps_unit, key)`. Returns
+336 -30
View File
@@ -16,6 +16,7 @@ mod extract;
pub mod mapfile;
mod patch;
pub mod read_error;
mod section_recover;
mod sweep;
pub mod verify;
@@ -588,6 +589,167 @@ pub fn bytes_bad_in_title(title: &DiscTitle, bad_ranges: &[(u64, u64)]) -> u64 {
total
}
/// Byte offset of `lba` within `title`'s extents (concatenated in order), or
/// `None` if the LBA falls outside every extent. The title is a virtual
/// contiguous stream; this maps a disc LBA into that stream so a chapter/time
/// lookup can place it. (Moved from autorip — clients must not re-derive it.)
fn byte_offset_in_title(lba: u32, title: &DiscTitle) -> Option<u64> {
use crate::consts::SECTOR_BYTES_U64;
let mut cumulative = 0u64;
for ext in &title.extents {
if lba >= ext.start_lba && lba < ext.start_lba + ext.sector_count {
return Some(cumulative + (lba - ext.start_lba) as u64 * SECTOR_BYTES_U64);
}
cumulative += ext.sector_count as u64 * SECTOR_BYTES_U64;
}
None
}
/// The 1-based chapter index + movie-time offset a byte position within a title
/// falls in, or `None` if the title has no size/chapters. Pure helper for the
/// range→chapter/time annotation the progress drilldown ([`locate_ranges`])
/// renders — also used by autorip's done-card range annotation. (Formerly lived
/// in the removed standalone sector-verify module.)
pub fn chapter_at_offset(
chapters: &[Chapter],
byte_offset: u64,
duration_secs: f64,
total_bytes: u64,
) -> Option<(usize, f64)> {
if total_bytes == 0 || chapters.is_empty() {
return None;
}
let time_secs = byte_offset as f64 / total_bytes as f64 * duration_secs;
let mut chapter_idx = 0;
for (i, ch) in chapters.iter().enumerate() {
if ch.time_secs <= time_secs {
chapter_idx = i;
} else {
break;
}
}
Some((chapter_idx + 1, time_secs))
}
/// The 1-based chapter + movie-time offset an LBA falls in, or `(None, None)`
/// if it isn't inside the title.
fn range_chapter(lba: u32, title: &DiscTitle) -> (Option<u32>, Option<f64>) {
if let Some(byte_offset) = byte_offset_in_title(lba, title) {
if let Some((ch, t)) = chapter_at_offset(
&title.chapters,
byte_offset,
title.duration_secs,
title.size_bytes,
) {
return (Some(ch as u32), Some(t));
}
}
(None, None)
}
/// Annotate raw bad byte-ranges with chapter + movie time, producing the
/// rendered drilldown ([`crate::progress::LocatedProgress`]) a client draws.
/// `raw` is the mapfile's `(byte_pos, byte_len)` set for whichever statuses the
/// caller cares about (the live "Maybe" set during a patch, or terminal
/// `Unreadable` for the verdict). The list is sorted largest-movie-time first
/// and capped at 50; `truncated` reports the overflow. `bps` (title bytes/sec)
/// is derived from the title so callers don't thread it.
///
/// This is the single place range→chapter/time annotation happens; autorip used
/// to own it and read the mapfile to do so. Now the library computes it from
/// its in-memory mapfile + title, and the client renders the result verbatim.
pub fn locate_ranges(raw: &[(u64, u64)], title: &DiscTitle) -> crate::progress::LocatedProgress {
use crate::consts::{MILLIS_PER_SEC, SECTOR_BYTES_U64};
use crate::progress::{LocatedProgress, LocatedRange};
const MAX_LOCATED: usize = 50;
let bps = if title.duration_secs > 0.0 {
title.size_bytes as f64 / title.duration_secs
} else {
0.0
};
let num_ranges = raw.len() as u32;
let mut ranges: Vec<LocatedRange> = raw
.iter()
.map(|(pos, size)| {
let lba = pos / SECTOR_BYTES_U64;
let count = (size / SECTOR_BYTES_U64) as u32;
let duration_ms = if bps > 0.0 {
(*size as f64) / bps * MILLIS_PER_SEC
} else {
0.0
};
let (chapter, time_offset_secs) = range_chapter(lba as u32, title);
LocatedRange {
lba,
count,
duration_ms,
chapter,
time_offset_secs,
}
})
.collect();
ranges.sort_by(|a, b| {
b.duration_ms
.partial_cmp(&a.duration_ms)
.unwrap_or(std::cmp::Ordering::Equal)
});
let largest_gap_ms = ranges.first().map(|r| r.duration_ms).unwrap_or(0.0);
let truncated = ranges.len().saturating_sub(MAX_LOCATED) as u32;
ranges.truncate(MAX_LOCATED);
// At-risk movie time = duration of the ranges that intersect the title
// extents (the others are menus/extras → no movie impact).
let main_at_risk_ms = if bps > 0.0 {
bytes_bad_in_title(title, raw) as f64 * MILLIS_PER_SEC / bps
} else {
0.0
};
LocatedProgress {
ranges,
num_ranges,
truncated,
main_at_risk_ms,
largest_gap_ms,
}
}
/// One-shot progress snapshot built from a mapfile on disk plus the title. The
/// library reads + parses the mapfile HERE so a client (autorip) gets a
/// fully-rendered [`crate::progress::PassProgress`] without ever touching
/// mapfile internals — used for the pass-boundary paint (before the live
/// callback stream begins) and the terminal done-card verdict. Returns `None`
/// if the mapfile can't be read. `work_done`/`work_total` are `0`: this is a
/// point-in-time snapshot, not a per-pass progress tick.
pub fn progress_snapshot_from_mapfile(
mapfile_path: &std::path::Path,
title: Option<&DiscTitle>,
kind: crate::progress::PassKind,
bytes_total_disc: u64,
) -> Option<crate::progress::PassProgress> {
use mapfile::SectorStatus::{NonScraped, NonTrimmed, Unreadable};
let map = mapfile::Mapfile::load(mapfile_path).ok()?;
let stats = map.stats();
// MAYBE set = not-yet-good (NonTrimmed/NonScraped/Unreadable), excluding
// NonTried (the unread remainder) — same set the live patch emitter uses.
let maybe = map.ranges_with(&[NonTrimmed, NonScraped, Unreadable]);
let located = title.map(|t| locate_ranges(&maybe, t)).unwrap_or_default();
let main_bad = title.map(|t| bytes_bad_in_title(t, &maybe)).unwrap_or(0);
Some(crate::progress::PassProgress {
kind,
work_done: 0,
work_total: 0,
bytes_good_total: stats.bytes_good,
bytes_unreadable_total: stats.bytes_unreadable,
bytes_pending_total: stats.bytes_pending,
bytes_retryable_total: stats.bytes_retryable,
bytes_total_disc,
disc_duration_secs: title.map(|t| t.duration_secs),
bytes_bad_in_main_title: main_bad,
main_title_duration_secs: title.map(|t| t.duration_secs),
main_title_size_bytes: title.map(|t| t.size_bytes),
located,
})
}
// ─── Display helpers ────────────────────────────────────────────────────────
impl Codec {
@@ -1397,7 +1559,7 @@ impl Disc {
tracing::info!(target: "freemkv::scan", handshake = handshake.is_some(), "phase: handshake done");
// Request max read speed — removes riplock on DVD
// (BD/UHD speed is set by firmware init, but DVD needs explicit SET CD SPEED)
// (BD/UHD speed is set by drive unlock/init, but DVD needs explicit SET CD SPEED)
session.set_speed(0xFFFF);
// Read UDF filesystem with buffered sector reader
@@ -1471,18 +1633,27 @@ impl Disc {
tracing::info!(target: "freemkv::scan", extents = main_extents.len(), "phase: CSS — main feature located");
if let Some(unlock_lba) = main_extents.first().map(|e| e.start_lba) {
tracing::info!(target: "freemkv::scan", unlock_lba, "phase: CSS — bus-auth unlock");
// Unlock the drive's CSS read gating. A CSS-enforcing drive (the
// BU40N) refuses to return scrambled sectors until a CSS bus-auth
// handshake has run for the title; we run it here purely for that
// unlock and IGNORE the key it derives (the disc-key crack is
// unreliable). The real descramble key is recovered from the
// scrambled movie data itself via the known-plaintext attack — no
// player keys, no disc-key crack, no REPORT-KEY-derived title key.
if let Err(e) = crate::css::auth::unlock_css_reads(session, unlock_lba) {
// Unlock the drive's CSS read gating through the uniform
// unlocker dispatch: the CSS unlocker matches DiscKind::Css and
// runs the bus-auth handshake (self-guarding to DVD media). A
// CSS-enforcing drive (the BU40N) refuses to return scrambled
// sectors until that handshake has run; we run it purely for that
// unlock and IGNORE any key (the descramble key is recovered
// keylessly from the scrambled movie data via the known-plaintext
// attack — no player keys, no disc-key crack, no REPORT-KEY title
// key). Any failure is non-fatal: continue to the crack, which
// simply finds nothing if the drive kept the sectors gated.
let drive_id = session.drive_id.clone();
if let Err(e) = crate::unlock_bridge::run_unlockers(
session.scsi_mut(),
&drive_id,
freemkv_unlock::DiscKind::Css,
&[],
) {
tracing::warn!(
target: "freemkv::scan",
error_code = e.code(),
"CSS bus-auth unlock failed; scrambled sectors may be unavailable"
outcome = ?e,
"CSS bus-auth unlock did not apply; scrambled sectors may be unavailable"
);
}
// Size the crack's batch reads to THIS drive's per-command max
@@ -1600,13 +1771,46 @@ impl Disc {
pub(crate) fn read_aacs_inputs_from_reader(
reader: &mut dyn SectorSource,
udf_fs: &udf::UdfFs,
) -> Result<(Vec<u8>, Vec<u8>)> {
) -> Result<(Vec<u8>, Vec<u8>, u8)> {
let inf = udf_fs
.read_file(reader, "/AACS/Unit_Key_RO.inf")
.or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO)
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO_DUPLICATE))
.map_err(|_| Error::AacsNoKeys)?;
let mkb = Self::read_mkb_content(reader, udf_fs)?;
Ok((inf, mkb))
let version = Self::read_aacs_version(reader, udf_fs);
Ok((inf, mkb, version))
}
/// AACS major version ([`crate::aacs::AACS_MAJOR_BD`] /
/// [`crate::aacs::AACS_MAJOR_UHD`]) from the content certificate. Drives the
/// `Unit_Key_RO.inf` parse stride (48-byte V10 vs 64-byte V20/V21), so the
/// out-of-band key-fetch path parses `enc_title_keys` at the right stride (a
/// server VUK then derives the correct unit keys).
///
/// When no content certificate is readable/parseable, defaults to **UHD
/// (V20, 64-byte stride)** — the conservative choice the pre-1.2.0 fetch path
/// hardcoded — and logs it: a wrong stride here folds a server VUK against
/// mis-strided title keys (silent wrong unit keys), so a missing cert must
/// not quietly pick the V10 stride for a UHD disc.
fn read_aacs_version(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> u8 {
match udf_fs
.read_file(reader, crate::aacs::PATH_CONTENT_CERT)
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT))
.ok()
.as_deref()
.and_then(crate::aacs::parse_content_cert)
{
Some(c) => c.version.major(),
None => {
tracing::warn!(
target: "freemkv::disc",
phase = "scan_aacs_version",
"no readable AACS content certificate; defaulting to the V20/UHD \
Unit_Key_RO stride (a VUK-from-server path would otherwise mis-stride)"
);
crate::aacs::AACS_MAJOR_UHD
}
}
}
/// Read the AACS MKB's real record stream — NOT its zero padding.
@@ -1626,8 +1830,8 @@ impl Disc {
let mut want = START_BYTES;
loop {
let buf = udf_fs
.read_file_prefix(reader, "/AACS/MKB_RO.inf", want)
.or_else(|_| udf_fs.read_file_prefix(reader, "/AACS/MKB_RW.inf", want))
.read_file_prefix(reader, crate::aacs::PATH_MKB_RO, want)
.or_else(|_| udf_fs.read_file_prefix(reader, crate::aacs::PATH_MKB_RW, want))
.map_err(|_| Error::AacsNoKeys)?;
let n = crate::aacs::mkb_content_len(&buf);
// `n` strictly inside `buf` => the record walk reached the padding
@@ -1642,10 +1846,10 @@ impl Disc {
}
/// Read a disc's AACS key-input files from an ISO image: returns
/// `(Unit_Key_RO.inf, MKB)` raw bytes. For callers that resolve a Unit Key
/// out-of-band: obtain the key however you like, then apply it via
/// `(Unit_Key_RO.inf, MKB, aacs_major_version)`. For callers that resolve a
/// Unit Key out-of-band: obtain the key however you like, then apply it via
/// [`Disc::decrypt_with`]. libfreemkv never makes a network call.
pub fn read_aacs_inputs(iso_path: &std::path::Path) -> Result<(Vec<u8>, Vec<u8>)> {
pub fn read_aacs_inputs(iso_path: &std::path::Path) -> Result<(Vec<u8>, Vec<u8>, u8)> {
// Preserve the underlying open error (`Error::IoError`, E5000, carrying
// the OS errno) instead of collapsing ENOENT/EPERM/etc. into
// `Error::AacsNoKeys` (E7000). A missing or unreadable ISO is an I/O
@@ -1661,7 +1865,7 @@ impl Disc {
/// resolves a key from them however it likes, then applies it via
/// [`Disc::decrypt_with`]. These files are plaintext UDF metadata — no
/// AACS handshake or keys are required to read them.
pub fn read_aacs_inputs_from_drive(drive: &mut Drive) -> Result<(Vec<u8>, Vec<u8>)> {
pub fn read_aacs_inputs_from_drive(drive: &mut Drive) -> Result<(Vec<u8>, Vec<u8>, u8)> {
let (_, mut reader, udf_fs) = Self::read_udf(drive)?;
Self::read_aacs_inputs_from_reader(&mut reader, &udf_fs)
}
@@ -2091,6 +2295,42 @@ impl Disc {
.unwrap_or_default()
}
/// The unlocker matrix for this scanned disc on `drive`: each REGISTERED
/// unlocker's name + whether it actually **did work this rip** — i.e. ran and
/// accomplished its job, NOT merely "matched the disc kind". Registry-driven
/// names (no hardcoding) so the CLI and autorip render an identical, always-
/// current report; the per-unlocker runtime signal is computed here because
/// the library owns unlock semantics AND the disc/drive state.
///
/// The distinction matters: on a LibreDrive drive, LibreDrive's firmware route
/// removes the AACS bus and reads the VID, so the AACS host-cert unlocker
/// never runs — it "matched" (AACS disc) but did nothing. `did-work` reports
/// that honestly (`AACS: no`), and on a *stock* drive that fell back to the
/// cert route it reports `LibreDrive: no, AACS: yes` — the real diagnostic.
pub fn unlocker_matrix(&self, drive: &crate::Drive) -> Vec<(&'static str, bool)> {
// LibreDrive firmware-unlocked the drive iff a drive unlocker actually
// succeeded at init (name recorded only on success).
let ld_worked = drive.unlocker_name().is_some();
crate::unlock_bridge::unlocker_names()
.into_iter()
.map(|name| {
let did_work = match name {
// The drive firmware unlock ran and succeeded.
"LibreDrive" => ld_worked,
// The AACS host-cert route removed the bus ONLY when LibreDrive
// didn't (stock drive) AND AACS state was actually obtained.
"AACS" => self.aacs.is_some() && !ld_worked,
// The CSS handshake/crack succeeded → title keys recovered.
"CSS" => self.css.is_some(),
// A newly-registered unlocker with no runtime signal wired
// here yet: report `no` rather than guess.
_ => false,
};
(name, did_work)
})
.collect()
}
/// The system-wide decrypt correctness gate.
///
/// Returns `Ok(())` when it is safe to proceed with a copy or mux, and a
@@ -2326,7 +2566,11 @@ impl Disc {
aacs.key_source = KeyOrigin::ExternalUk;
} else if self.encrypted && self.css.is_none() {
self.aacs = Some(AacsState {
version: if self.format == DiscFormat::Uhd { 2 } else { 1 },
version: if self.format == DiscFormat::Uhd {
crate::aacs::AACS_MAJOR_UHD
} else {
crate::aacs::AACS_MAJOR_BD
},
bus_encryption: self.format == DiscFormat::Uhd,
mkb_version: None,
disc_hash: String::new(),
@@ -2353,6 +2597,7 @@ impl Disc {
self.aacs.as_ref().map(|a| crate::keysource::DiscInputs {
disc_hash: a.disc_hash.clone(),
volume_id: a.volume_id,
version: a.version,
mkb: a.mkb.clone(),
unit_key_ro: a.uk_ro.clone(),
// Content samples need the disc reader, which scan does not retain;
@@ -2702,14 +2947,15 @@ impl Disc {
progress: opts.progress,
halt: opts.halt.clone(),
key_fetch: opts.key_fetch.clone(),
// Disc::copy's internal patch grinds each range fully (it's a
// single-call recovery); the breadth-first fast-capture ordering is
// an autorip multi-pass concern.
fast_capture: false,
};
let pr = self.patch(reader, path, &patch_opts)?;
tracing::info!(
target: "freemkv::disc",
phase = "patch_done",
blocks_attempted = pr.blocks_attempted,
blocks_read_ok = pr.blocks_read_ok,
blocks_read_failed = pr.blocks_read_failed,
bytes_recovered = pr.bytes_recovered_this_pass,
halted = pr.halted,
wedged_exit = pr.wedged_exit,
@@ -3021,7 +3267,7 @@ impl Disc {
);
// Request the drive's max read speed for the whole sweep — removes
// riplock. BD/UHD get their speed from the firmware unlock/init, but a
// riplock. BD/UHD get their speed from the drive unlock/init, but a
// DVD skips that path (the stock-mode gate, `Drive::disc_is_dvd`), so
// without this explicit SET CD SPEED a DVD rip sweeps at the drive's
// default (riplocked) speed. The damage-recovery branch below also
@@ -3318,7 +3564,15 @@ impl Disc {
);
}
let jump_pos = (pos + block_bytes + sectors * 2048).min(region_end);
// Saturating throughout — the read_error side
// computes the sector count with saturating_mul as
// "defence in depth"; honor the same guarantee at
// the consuming multiply/add so a pathological jump
// distance can't wrap.
let jump_pos = pos
.saturating_add(block_bytes)
.saturating_add(sectors.saturating_mul(2048))
.min(region_end);
let gap_start = pos + block_bytes;
let gap_bytes = jump_pos.saturating_sub(gap_start);
if gap_bytes > 0 {
@@ -3465,6 +3719,15 @@ impl Disc {
bytes_bad_in_main_title: main_title_bad,
main_title_duration_secs: main_title.map(|t| t.duration_secs),
main_title_size_bytes: main_title.map(|t| t.size_bytes),
// Rendered drilldown from the consumer's in-memory
// snapshot (bad ranges) + title; empty until the first
// snapshot arrives.
located: match &cached_snapshot {
Some(snap) => main_title
.map(|t| locate_ranges(&snap.bad_ranges, t))
.unwrap_or_default(),
None => crate::progress::LocatedProgress::default(),
},
};
if !reporter.report(&pp) {
halt_requested = true;
@@ -3608,6 +3871,18 @@ pub struct PatchOptions<'a> {
/// On-decrypt-miss key fetch (see [`CopyOptions::key_fetch`]). Lets Pass N
/// recover an orphan CPS unit's key when re-reading its bad range.
pub key_fetch: Option<crate::sector::KeyFetch>,
/// Fast-capture pass: read each bad range ONCE at the full batch and leave
/// every failed block `NonTrimmed` for a later pass — WITHOUT bisecting,
/// re-reading, or grinding it here. This lets a first retry pass grab the
/// readable blocks (the sweep's good skip-ahead overshoot) of EVERY range
/// quickly, before any single range's slow per-sector recovery — so
/// recovered data surfaces across the whole disc first instead of grinding
/// section 1 to exhaustion before even touching section 2. A later pass
/// (`fast_capture = false`) does the granular bisect/retry on what's left.
/// No data is dropped: a failed block stays `NonTrimmed` until a granular
/// pass recovers it or finally gives up. A transport fault (bridge crash)
/// still aborts — it isn't a recoverable bad sector.
pub fast_capture: bool,
}
/// Result returned by [`Disc::patch`].
@@ -3618,9 +3893,6 @@ pub struct PatchOutcome {
pub bytes_pending: u64,
pub bytes_recovered_this_pass: u64,
pub halted: bool,
pub blocks_attempted: u64,
pub blocks_read_ok: u64,
pub blocks_read_failed: u64,
pub wedged_exit: bool,
pub wedged_threshold: u64,
}
@@ -4028,6 +4300,40 @@ mod tests {
}
}
#[test]
fn locate_ranges_at_risk_in_vs_out_of_feature() {
// The honest-"Maybe" behaviour the rendered drilldown must preserve:
// in-feature damage counts as movie time at risk; out-of-feature damage
// is still located but reads 0:00. (Ported from autorip's old
// RipProgress::from_map tests when that logic moved into the library.)
// bps = size_bytes / duration_secs = 4096 B/s → 4096 B == 1000 ms.
let mut title = title_with_video(Codec::Hevc, Resolution::R2160p);
title.duration_secs = 100.0;
title.size_bytes = 409_600;
// Feature extent = sectors [10,110) → bytes [20480, 225280).
title.extents = vec![Extent {
start_lba: 10,
sector_count: 100,
}];
// In-feature pending range: 4096 B == 1000 ms of movie at risk.
let in_feat = locate_ranges(&[(40_960, 4096)], &title);
assert_eq!(in_feat.num_ranges, 1);
assert!(
(in_feat.main_at_risk_ms - 1000.0).abs() < 1.0,
"in-feature damage must count as at-risk movie time, got {}",
in_feat.main_at_risk_ms
);
// Out-of-feature range: located, but zero movie time at risk.
let out_feat = locate_ranges(&[(2_000_000, 100_000)], &title);
assert_eq!(out_feat.num_ranges, 1, "still a located range");
assert_eq!(
out_feat.main_at_risk_ms, 0.0,
"out-of-feature damage must not read as movie loss"
);
}
/// Build a DiscTitle with full control over the fields the title
/// sorter cares about. Used by the canonical-title-order tests.
fn title_with(
+815 -2252
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -531,13 +531,20 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
);
if is_wedge_transition {
// NOTE: this is the FIRST escalation into the hardware/illegal-request
// sense family — NOT a confirmed wedge. Drives frequently recover and keep
// reading after one such error (a single bad spot), so calling it a "wedge"
// here over-claims (it sent past investigations chasing a drive ghost). A
// genuine wedge is PERSISTENT — see the `wedge_skip` / WEDGE_ABORT_THRESHOLD
// path below, which only fires after repeated fast-fails with no recovery.
tracing::warn!(
target: "freemkv::disc",
phase = "wedge_transition",
phase = "fastfail_escalation",
errors_in_zone = ctx.total_errors,
ms_since_last_success,
new_family = ?current_family,
"drive entered wedge / fast-fail family (was returning recoverable medium errors before this)"
"drive escalated into the fast-fail sense family (was returning recoverable medium \
errors before this) — often transient; only a PERSISTENT run is a real wedge"
);
}
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -190,11 +190,17 @@ impl Sink<WorkItem> for SweepSink {
}
WorkItem::StatsRequest => {
let stats = self.map.stats();
// DAMAGE only — NOT NonTried. NonTried is the unread remainder
// ahead of the sweep head, not damage; including it made the live
// located drilldown (at-risk movie time + range count) treat the
// whole unread disc as confirmed damage, so at sweep start it
// showed ~full-movie at-risk and melted to 0 as the sweep
// progressed. Matches the one-shot progress path, which already
// excludes NonTried.
let bad_ranges = self.map.ranges_with(&[
SectorStatus::NonTrimmed,
SectorStatus::Unreadable,
SectorStatus::NonScraped,
SectorStatus::NonTried,
]);
// Best-effort: drop on backpressure; producer's cache
// stays current enough.
+3 -1
View File
@@ -477,7 +477,9 @@ fn push_ranges(out: &mut Vec<(u32, u32)>, lbas: &[u32; 3]) {
present.sort_unstable();
for lba in present {
if let Some(last) = out.last_mut() {
if last.0 + last.1 == lba {
// Saturating: LBAs come from disc-controlled ICB extents, so a
// corrupt disc must not panic here (matches `udf::merge_ranges`).
if last.0.saturating_add(last.1) == lba {
last.1 += 1;
continue;
}
+184 -79
View File
@@ -1,8 +1,8 @@
//! Drive session — open, identify, and read from optical drives.
//!
//! A `Drive` is opened from a device path, identifies itself via INQUIRY,
//! optionally unlocks/initializes via a registered [`crate::unlock::Unlocker`],
//! and reads sectors.
//! optionally unlocks/initializes via the `freemkv-unlock` dispatch
//! (through [`crate::unlock_bridge`]), and reads sectors.
pub(crate) fn extract_scsi_context(e: &Error) -> (u8, Option<crate::scsi::ScsiSense>) {
match e {
@@ -51,6 +51,13 @@ pub enum DriveStatus {
// SCSI opcodes used in drive control
const SCSI_TEST_UNIT_READY: u8 = 0x00;
const SCSI_START_STOP_UNIT: u8 = 0x1B;
/// Idle time the disc sits spun-down during [`Drive::spin_cycle`] before it's
/// spun back up — long enough for the mechanism's fast-fail wedge state to
/// clear. Validated at 5–6 s live.
const SPIN_DOWN_IDLE_SECS: u64 = 5;
/// Settle time after spin-up in [`Drive::spin_cycle`] before the caller reads
/// again, so the first post-cycle read doesn't hit a transient NOT_READY.
const SPIN_UP_SETTLE_SECS: u64 = 10;
const SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL: u8 = 0x1E;
const SCSI_GET_EVENT_STATUS: u8 = 0x4A;
const SCSI_MODE_SENSE: u8 = 0x5A;
@@ -59,9 +66,9 @@ const SCSI_REPORT_KEY: u8 = 0xA4;
/// Optical disc drive session -- open, identify, unlock, and read.
pub struct Drive {
scsi: Box<dyn ScsiTransport>,
/// Name of the [`crate::unlock::Unlocker`] that handled this drive at
/// `init()`, if any matched. `None` means no unlocker matched and the
/// drive runs in stock mode (host-cert AACS handshake carries discs).
/// Name of the unlocker that handled this drive at `init()`, if any matched.
/// `None` means no unlocker matched and the drive runs in stock mode
/// (host-cert AACS handshake carries discs).
unlocker_name: Option<String>,
/// The OEM Volume ID the matching unlocker returned from `unlock()` at
/// `init()`, stashed for the AACS handshake phase (which reads it via
@@ -212,11 +219,20 @@ impl Drive {
self.unlock_tray();
}
/// Whether a registered unlocker matches this drive (i.e. it can be
/// firmware-unlocked). Queried against the unlock registry by identity;
/// does not require `init()` to have run.
/// Whether an unlocker claims this drive by identity (i.e. it can be
/// unlocked at drive-prep). Queried via `freemkv-unlock`; does not require
/// `init()` to have run.
pub fn has_profile(&self) -> bool {
crate::unlock::matching_name(&self.drive_id).is_some()
crate::unlock_bridge::unlocker_name(&self.drive_id).is_some()
}
/// The name of the drive unlocker that ACTUALLY unlocked this drive at
/// `init()`, or `None` if none applied (unsupported drive, or the unlock
/// failed). Distinct from [`has_profile`](Self::has_profile), which reports
/// only an identity match: this is the runtime outcome. Apps render it in the
/// user-facing unlocker report.
pub fn unlocker_name(&self) -> Option<&str> {
self.unlocker_name.as_deref()
}
/// Access the SCSI transport for direct commands (used by CSS/AACS auth).
@@ -224,10 +240,10 @@ impl Drive {
self.scsi.as_mut()
}
/// The OEM Volume ID a matching [`crate::unlock::Unlocker`] returned at
/// [`Drive::init`], if any. The AACS handshake uses this to skip the cert
/// handshake when an unlocker already supplied the VID. `None` when no
/// unlocker matched or it produced no VID.
/// The OEM Volume ID a matching unlocker returned at [`Drive::init`], if any.
/// The AACS handshake uses this to skip the cert handshake when an unlocker
/// already supplied the VID. `None` when no unlocker matched or it produced
/// no VID.
pub(crate) fn oem_vid(&self) -> Option<[u8; 16]> {
self.oem_vid
}
@@ -334,15 +350,17 @@ impl Drive {
}
/// Name of the unlocker handling this drive. After `init()` this is the
/// unlocker that ran; before `init()` it reflects the registry match by
/// unlocker that ran; before `init()` it reflects the unlocker match by
/// identity. `"Unknown"` when no unlocker matches.
pub fn platform_name(&self) -> &str {
if let Some(ref n) = self.unlocker_name {
return n;
}
// Cache the registry match so we can hand out a `&str` borrow.
// Cache the unlocker match so we can hand out a `&str` borrow.
self.matched_name_cache.get_or_init(|| {
crate::unlock::matching_name(&self.drive_id).unwrap_or_else(|| "Unknown".to_string())
crate::unlock_bridge::unlocker_name(&self.drive_id)
.map(str::to_string)
.unwrap_or_else(|| "Unknown".to_string())
})
}
@@ -353,7 +371,7 @@ impl Drive {
/// Current mounted-disc profile from the GET CONFIGURATION header
/// (Current Profile, bytes 6-7). DVD family is `0x0010..=0x001F`, BD
/// family `0x0040..=0x0043`. This is a stock MMC command — it works
/// before (and without) any firmware unlock. `None` if unreadable.
/// before (and without) any drive unlock. `None` if unreadable.
fn current_profile(&mut self) -> Option<u16> {
let cdb = [
crate::scsi::SCSI_GET_CONFIGURATION,
@@ -390,10 +408,10 @@ impl Drive {
matches!(self.current_profile(), Some(p) if (0x0010..=0x001F).contains(&p))
}
/// Initialize drive — unlock + firmware upload.
/// Initialize drive — drive-prep unlock + init.
/// Optional. Adds features: removes riplock, enables UHD reads, speed control.
///
/// The LibreDrive/OEM firmware unlock is required for BD/UHD (AACS) reads,
/// The drive-prep (OEM) unlock is required for BD/UHD (AACS) reads,
/// but it puts the drive in an extended-access state where stock CSS
/// authentication no longer works — so a CSS-protected DVD can't be read.
/// For a DVD we therefore SKIP the unlock and run the drive in its normal
@@ -402,45 +420,52 @@ impl Drive {
pub fn init(&mut self) -> Result<()> {
let t0 = std::time::Instant::now();
tracing::info!(target: "freemkv::drive", phase = "init", "begin");
if self.disc_is_dvd() {
tracing::info!(target: "freemkv::drive", phase = "init", dvd = true, elapsed_ms = t0.elapsed().as_millis() as u64, "end (stock-mode DVD, no unlock)");
self.init_ran = true;
return Ok(());
}
// Walk the unlock registry: the first unlocker whose identity
// matches runs; none matching leaves the drive in stock mode so the
// host-cert AACS handshake (the OEM route) carries the disc.
let r = crate::unlock::route_unlock(self.scsi.as_mut(), &self.drive_id);
// Drive-prep runs for EVERY disc, DVD INCLUDED. The drive-level firmware
// unlock lifts riplock and readies max read speed regardless of disc type
// — drive features are disc-independent. At init the disc kind is not yet
// probed (Unknown), so only the identity-keyed DRIVE unlocker matches
// here; the AACS host-cert handshake and the CSS bus-auth handshake run
// LATER, each gated on the actual disc kind, on TOP of the already-
// unlocked drive. A genuine transport fault means the bus is dead — abort
// init (the v1.1.0 invariant; `if let Ok` was silently swallowing it).
// Every other error (NotApplicable / no match) is "nothing applied" —
// fall through to stock mode. NOTE: the `if disc_is_dvd() { return }` skip
// that used to sit here was the v1.0.0-rc.1 regression — it skipped the
// drive-prep for DVD, leaving DVDs riplocked at stock speed.
self.init_ran = true;
let r = match r {
Ok(Some((name, vid))) => {
self.unlocker_name = Some(name);
let r: Result<()> = match crate::unlock_bridge::run_unlockers(
self.scsi.as_mut(),
&self.drive_id,
freemkv_unlock::DiscKind::Unknown,
&[],
) {
Ok(unlocked) => {
self.unlocker_name =
crate::unlock_bridge::unlocker_name(&self.drive_id).map(str::to_string);
// Stash the OEM Volume ID the unlocker returned for the AACS
// handshake phase (do_handshake reads it via `oem_vid()`).
self.oem_vid = Some(vid.0);
// The matched unlocker may also be able to raise the drive to
// its maximum read speed. Best-effort: a failure here must NOT
// fail the rip — a slow drive still rips. Log and continue.
if let Err(e) =
crate::unlock::unlocker_set_max_read_speed(self.scsi.as_mut(), &self.drive_id)
{
tracing::warn!(
target: "freemkv::drive",
phase = "init",
error = ?e,
"unlocker set_max_read_speed failed; continuing at current speed"
);
// handshake phase (do_handshake reads it via `oem_vid()`). A
// drive-prep unlocker always carries a VID; guard anyway.
if let Some(vid) = unlocked.vid {
self.oem_vid = Some(vid);
}
Ok(())
}
// No unlocker matched, or one matched but only hit a capability
// failure (not firmware-unlockable / no OEM VID): not an error —
// fall through to the OEM host-cert route.
Ok(None) => Ok(()),
// A genuine transport fault during unlock (UnlockError::Scsi)
// propagates here and aborts init — the bus is dead.
Err(e) => Err(e),
Err(freemkv_unlock::UnlockError::Transport) => Err(Error::ScsiError {
opcode: 0,
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
}),
Err(_) => Ok(()),
};
// Raise the drive to its maximum read speed — UNCONDITIONALLY whenever
// the bus is alive (init didn't transport-fault), whether or not an
// unlocker matched, and for ANY disc type (DVD now flows through here too,
// so it gets max speed on the freshly firmware-unlocked drive instead of
// the stock riplock). A stock-mode drive with no firmware unlocker still
// wants max speed. Best-effort: a failure here must NOT fail the rip.
if r.is_ok() {
self.set_speed(crate::speed::DriveSpeed::Max.to_kbps());
}
tracing::info!(
target: "freemkv::drive",
phase = "init",
@@ -458,14 +483,9 @@ impl Drive {
pub fn probe_disc(&mut self) -> Result<()> {
let t0 = std::time::Instant::now();
tracing::info!(target: "freemkv::drive", phase = "probe_disc", "begin");
// A DVD runs in stock mode (see `init`); skip the OEM/firmware-path
// disc calibration, which only applies to the unlocked BD/UHD drive.
if self.disc_is_dvd() {
tracing::info!(target: "freemkv::drive", phase = "probe_disc", dvd = true, elapsed_ms = t0.elapsed().as_millis() as u64, "end (stock-mode DVD, no calibration)");
return Ok(());
}
// Disc-speed calibration is firmware-specific and now lives inside
// the unlocker's `unlock()` (run at `init()`). Nothing to do here.
// Disc-speed calibration is unlocker-specific and now lives inside
// the unlocker's `unlock()` (run at `init()`, for every disc including
// DVD). Nothing to do here — no disc-type branch.
tracing::info!(
target: "freemkv::drive",
phase = "probe_disc",
@@ -609,16 +629,14 @@ impl Drive {
/// Whether libfreemkv should take the OEM extended-access read path.
///
/// Whether a registered [`crate::unlock::Unlocker`] matches this drive.
///
/// An unlocker unlocks *drive functionality* — firmware unlock, OEM VID
/// retrieval, and other vendor capabilities. When one matches, libfreemkv
/// routes both `unlock` and OEM VID through it (VID via the OEM path is
/// decoupled from the host cert + HRL). This mirrors [`Self::has_profile`]
/// — the honest signal is "a registered unlocker claims this drive" —
/// rather than the old const `false`.
/// True when an unlocker claims this drive by identity. Such an unlocker
/// unlocks *drive functionality* — drive unlock, OEM VID retrieval, and other
/// vendor capabilities. When one matches, libfreemkv routes both `unlock` and
/// OEM VID through it (VID via the OEM path is decoupled from the host cert +
/// HRL). This mirrors [`Self::has_profile`] — the honest signal is "an
/// unlocker claims this drive" — rather than the old const `false`.
pub fn is_unlocked(&self) -> bool {
crate::unlock::matching_name(&self.drive_id).is_some()
crate::unlock_bridge::unlocker_name(&self.drive_id).is_some()
}
/// Read sectors from the disc. Single-shot — no inline retries, no
@@ -640,6 +658,25 @@ impl Drive {
/// recovery layers (Disc::patch multi-pass, DiscStream batch halving)
/// do not touch the wedge-prone reset path.
pub fn read(&mut self, lba: u32, count: u16, buf: &mut [u8], recovery: bool) -> Result<usize> {
// Bulk path: FUA off (the drive cache IS the streaming throughput).
self.read_fua(lba, count, buf, recovery, false)
}
/// [`read`], but with an explicit Force Unit Access request: `fua = true`
/// sets the READ(10) FUA bit so the drive re-fetches the medium instead of
/// returning a cached copy — the Pass-N marginal-sector lever (see
/// [`crate::sector::SectorSource::read_sectors_fua`]). The bulk sweep always
/// passes `false`; only a per-sector recovery handler asks for FUA.
///
/// [`read`]: Drive::read
pub fn read_fua(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
fua: bool,
) -> Result<usize> {
let timeout_ms = if recovery {
crate::scsi::READ_RECOVERY_TIMEOUT_MS
} else {
@@ -665,7 +702,7 @@ impl Drive {
// read_one call with no behavior change.
let max_sectors = (self.scsi.max_transfer_bytes() / 2048).max(1) as u32;
if count as u32 <= max_sectors {
return self.read_one(lba, count, buf, timeout_ms, recovery);
return self.read_one(lba, count, buf, timeout_ms, recovery, fua);
}
// Large read: split into chunks of at most `max_sectors` sectors,
@@ -681,7 +718,7 @@ impl Drive {
let byte_off = done as usize * 2048;
let byte_len = chunk as usize * 2048;
let slice = &mut buf[byte_off..byte_off + byte_len];
let n = self.read_one(cur_lba, chunk as u16, slice, timeout_ms, recovery)?;
let n = self.read_one(cur_lba, chunk as u16, slice, timeout_ms, recovery, fua)?;
total += n;
done += chunk;
}
@@ -701,13 +738,23 @@ impl Drive {
count: u16,
buf: &mut [u8],
timeout_ms: u32,
// `recovery` only gates the Linux /dev/sr0 pread fallback below; on
// `recovery` gates only the Linux /dev/sr0 pread fallback below; on
// other platforms it is intentionally unused.
#[cfg_attr(not(target_os = "linux"), allow(unused_variables))] recovery: bool,
// FUA (Force Unit Access): when set, byte-1 bit 0x08 forces the drive to
// re-fetch the medium past its cache.
fua: bool,
) -> Result<usize> {
// FUA is OFF on the bulk path — unconditionally forcing every READ(10)
// past the cache disabled the drive's readahead/streaming cache on the
// sequential sweep and collapsed throughput ~10x (UHD 15-25 → ~2 MB/s,
// DVD → ~0.5 MB/s), disc-type-agnostic — the cache IS the streaming
// throughput. It is set ONLY when a Pass-N recovery handler (FuaRetry)
// asks for it per marginal-sector re-read, where cache-masking of a
// stochastic sector actually matters (#55).
let cdb = [
crate::scsi::SCSI_READ_10,
0x00,
if fua { 0x08 } else { 0x00 },
(lba >> 24) as u8,
(lba >> 16) as u8,
(lba >> 8) as u8,
@@ -886,6 +933,29 @@ impl Drive {
Ok(())
}
/// Soft power-cycle the drive mechanism WITHOUT ejecting: spin the disc
/// down (`START STOP UNIT`, START=0, **LOEJ=0**) then back up (START=1).
/// This clears the BU40N/Initio fast-fail *wedge* state that a run of
/// `HARDWARE_ERROR` reads leaves the drive in — the non-eject equivalent of
/// the power-cycle our notes say the wedge needs. The disc stays loaded (the
/// BU40N is slot-loading; we NEVER eject to recover — a hands-on eject is a
/// failure for an unattended service). Validated live 2026-07-01: took the
/// drive from failing-every-read back to reading at MB/s.
pub fn spin_cycle(&mut self) -> Result<()> {
let stop = [SCSI_START_STOP_UNIT, 0, 0, 0, 0x00, 0]; // START=0, LOEJ=0 → spin down
let start = [SCSI_START_STOP_UNIT, 0, 0, 0, 0x01, 0]; // START=1, LOEJ=0 → spin up
let mut buf = [0u8; 0];
self.scsi
.as_mut()
.execute(&stop, crate::scsi::DataDirection::None, &mut buf, 30_000)?;
std::thread::sleep(std::time::Duration::from_secs(SPIN_DOWN_IDLE_SECS));
self.scsi
.as_mut()
.execute(&start, crate::scsi::DataDirection::None, &mut buf, 30_000)?;
std::thread::sleep(std::time::Duration::from_secs(SPIN_UP_SETTLE_SECS));
Ok(())
}
pub fn scsi_execute(
&mut self,
cdb: &[u8],
@@ -970,6 +1040,17 @@ impl SectorSource for Drive {
self.read(lba, count, buf, recovery)
}
fn read_sectors_fua(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
fua: bool,
) -> Result<usize> {
self.read_fua(lba, count, buf, recovery, fua)
}
fn set_speed(&mut self, kbs: u16) {
Drive::set_speed(self, kbs);
}
@@ -1245,7 +1326,7 @@ mod command_tests {
/// `disc_is_dvd()` must match the DVD profile family (0x0010..=0x001F)
/// and ONLY that family. A false positive on a BD/UHD profile (0x0040+)
/// would skip the LibreDrive firmware unlock that UHD reads require; a
/// would skip the drive unlock that UHD reads require; a
/// false negative on a DVD would re-introduce the CSS read failure. The
/// Current Profile is bytes 6-7 of the GET CONFIGURATION header.
/// Mutation: widening the range to `..=0x0040` makes the BD-ROM assert
@@ -1259,7 +1340,7 @@ mod command_tests {
hdr[7] = profile as u8;
drive_with(hdr).disc_is_dvd()
};
// DVD family → DVD (skip firmware unlock, run stock for CSS).
// DVD family → DVD (skip drive unlock, run stock for CSS).
assert!(probe(0x0010), "DVD-ROM");
assert!(probe(0x0011), "DVD-R");
assert!(probe(0x001B), "DVD+R DL");
@@ -1269,7 +1350,7 @@ mod command_tests {
assert!(!probe(0x0008), "CD-ROM");
assert!(!probe(0x0000), "no/unknown profile");
// Short / failed GET CONFIGURATION → no Current Profile → NOT DVD,
// so the firmware unlock still runs (safe default).
// so the drive unlock still runs (safe default).
assert!(
!drive_with(vec![0u8; 4]).disc_is_dvd(),
"short GET CONFIGURATION must default to not-DVD (unlock still runs)"
@@ -1364,8 +1445,10 @@ mod command_tests {
#[test]
fn read_builds_read10_cdb_with_be_lba_and_count() {
// Drive::read issues READ(10) (0x28). LBA bytes 2..5 big-endian,
// transfer length bytes 7..8 big-endian (MMC-6). No FUA on this
// path (byte 1 == 0). Distinct nibbles catch a swapped shift.
// transfer length bytes 7..8 big-endian (MMC-6). FUA is DISABLED (byte 1
// == 0x00) so the drive cache/readahead is allowed — forcing FUA on the
// bulk sweep collapsed throughput ~10x. Distinct nibbles catch a swapped
// shift.
let RecordingHarness {
drive: mut d,
cdb,
@@ -1376,11 +1459,33 @@ mod command_tests {
assert_eq!(n, 4096, "returns transport bytes_transferred");
let c = cdb.lock().unwrap();
assert_eq!(c[0], crate::scsi::SCSI_READ_10);
assert_eq!(c[1], 0x00, "Drive::read path sets no FUA");
assert_eq!(
c[1], 0x00,
"FUA disabled — cache/readahead allowed on the bulk read path"
);
assert_eq!(&c[2..6], &[0x00, 0xAB, 0xCD, 0xEF], "LBA big-endian");
assert_eq!(&c[7..9], &[0x00, 0x02], "transfer length big-endian");
}
#[test]
fn read_fua_sets_the_force_unit_access_bit() {
// The Pass-N FuaRetry lever: read_fua(.., fua=true) sets READ(10) byte-1
// bit 0x08 so the drive re-fetches the medium past its cache; fua=false
// leaves it clear (the bulk path).
let RecordingHarness {
drive: mut d,
cdb,
timeouts: _to,
} = recording(TransportOutcome::Ok(2048));
let mut buf = vec![0u8; 2048];
d.read_fua(0, 1, &mut buf, false, true).unwrap();
assert_eq!(
cdb.lock().unwrap()[1],
0x08,
"FUA requested — byte-1 bit 0x08 set so the drive bypasses its cache"
);
}
#[test]
fn read_recovery_flag_selects_60s_timeout() {
// recovery=true must use READ_RECOVERY_TIMEOUT_MS (60 s); false
+31 -5
View File
@@ -879,6 +879,15 @@ impl Error {
status: Some(crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE),
..
}
) || matches!(
// A failed `ioctl(SG_IO)` (Error::IoError, e.g. ENODEV/EIO on an
// unplugged USB bridge) and a vanished device (Error::DeviceNotFound,
// fd gone) are dead-bus / transport-layer faults too — NOT recoverable
// bad sectors. Treat them as transport failures so sweep / patch /
// fill_extents abort the pass and re-enumerate the bridge instead of
// zero-filling every read against a wedged device.
self,
Error::IoError { .. } | Error::DeviceNotFound { .. }
)
}
@@ -1375,11 +1384,13 @@ mod tests {
}
}
/// is_scsi_transport_failure returns true only for SCSI_STATUS_TRANSPORT_FAILURE (0xFF).
/// Spec: comment on SCSI_STATUS_TRANSPORT_FAILURE says "synthesised sentinel: the
/// transport never delivered a SCSI status byte".
/// is_scsi_transport_failure is true for the 0xFF SCSI sentinel AND for the
/// non-SCSI dead-bus faults (Error::IoError from a failed ioctl(SG_IO),
/// Error::DeviceNotFound from a vanished fd) — but NEVER for a real SCSI
/// reply (CHECK CONDITION) or unrelated errors.
/// Mutation: testing against 0x02 (CHECK CONDITION) would wrongly mark CHECK
/// CONDITION replies as transport failures.
/// CONDITION replies as transport failures; dropping the IoError/
/// DeviceNotFound arm would let a dead bus zero-fill the disc.
#[test]
fn is_scsi_transport_failure_only_for_0xff() {
use crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE;
@@ -1403,7 +1414,22 @@ mod tests {
};
assert!(!cc.is_scsi_transport_failure());
// False for non-SCSI errors.
// True: non-SCSI dead-bus faults — a failed ioctl(SG_IO) and a vanished
// device are transport-layer failures, not recoverable bad sectors.
assert!(
Error::IoError {
source: std::io::Error::from(std::io::ErrorKind::NotConnected)
}
.is_scsi_transport_failure()
);
assert!(
Error::DeviceNotFound {
path: "/dev/sg9".into()
}
.is_scsi_transport_failure()
);
// False for unrelated errors.
assert!(!Error::Halted.is_scsi_transport_failure());
}
+106
View File
@@ -0,0 +1,106 @@
//! The single hex → bytes parser for the whole workspace.
//!
//! Key material arrives as hex from three third-party sources — the keydb, an
//! online key service, and the mapfile's `# freemkv-vid:` comment — and each
//! used to parse it slightly differently (one stripped `0x`/`0X`, one stripped
//! nothing, one stripped `0x` only). A key written with a prefix one parser
//! didn't expect was silently dropped → "can't decrypt" with no error. This is
//! the one parser they all call, so the prefix/case/validation rules live in
//! exactly one place.
//!
//! Operates on BYTES, not `&str` char indices: the inputs are untrusted, so a
//! multi-byte UTF-8 scalar must reject as malformed, never panic on a
//! mid-codepoint slice.
/// Parse a hex string into bytes. Accepts an optional `0x`/`0X` prefix
/// (case-insensitive), then requires an even run of ASCII hex digits. Any
/// non-hex byte, or an odd length, yields `None`.
pub fn parse_hex_bytes(s: &str) -> Option<Vec<u8>> {
let body = strip_prefix(s.trim());
let bytes = body.as_bytes();
// Empty → empty Vec (a legitimately-empty variable-length field); odd length
// is malformed. (`parse_hex_fixed` enforces a concrete length separately.)
if bytes.len() % 2 != 0 {
return None;
}
let mut out = Vec::with_capacity(bytes.len() / 2);
for pair in bytes.chunks_exact(2) {
out.push(byte(pair[0], pair[1])?);
}
Some(out)
}
/// Parse a hex string into a fixed `[u8; N]`. Accepts an optional `0x`/`0X`
/// prefix; requires EXACTLY `2*N` ASCII hex digits after it. `None` on any
/// non-hex byte or a length mismatch.
pub fn parse_hex_fixed<const N: usize>(s: &str) -> Option<[u8; N]> {
let body = strip_prefix(s.trim());
let bytes = body.as_bytes();
if bytes.len() != 2 * N {
return None;
}
let mut out = [0u8; N];
for (i, slot) in out.iter_mut().enumerate() {
*slot = byte(bytes[2 * i], bytes[2 * i + 1])?;
}
Some(out)
}
/// Strip a single leading `0x` / `0X` if present (case-insensitive).
fn strip_prefix(s: &str) -> &str {
s.strip_prefix("0x")
.or_else(|| s.strip_prefix("0X"))
.unwrap_or(s)
}
/// Combine two ASCII hex-digit bytes into one byte. `as char` is intentional:
/// for a non-ASCII byte it produces a Latin-1 scalar that `to_digit(16)` then
/// rejects — so non-hex (incl. `+`/`-` sign chars) and multi-byte input fail
/// cleanly rather than slipping through `from_str_radix`'s sign handling.
fn byte(hi: u8, lo: u8) -> Option<u8> {
let hi = (hi as char).to_digit(16)?;
let lo = (lo as char).to_digit(16)?;
Some((hi * 16 + lo) as u8)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fixed_accepts_0x_0x_and_bare_same_result() {
let want = [0x00, 0x11, 0xab, 0xCD, 0xef, 0x42, 0x99, 0x00];
let bare = "0011abcdef429900";
assert_eq!(parse_hex_fixed::<8>(bare), Some(want));
assert_eq!(parse_hex_fixed::<8>(&format!("0x{bare}")), Some(want));
// The case that used to be dropped by one parser but not another.
assert_eq!(parse_hex_fixed::<8>(&format!("0X{bare}")), Some(want));
assert_eq!(parse_hex_fixed::<8>(&format!(" 0X{bare} ")), Some(want));
}
#[test]
fn fixed_rejects_wrong_length_and_non_hex_and_signs() {
assert_eq!(parse_hex_fixed::<16>("00"), None); // too short
assert_eq!(parse_hex_fixed::<2>("00112233"), None); // too long
assert_eq!(parse_hex_fixed::<2>("zz11"), None); // non-hex
assert_eq!(parse_hex_fixed::<2>("+5-A"), None); // sign chars
}
#[test]
fn does_not_panic_on_multibyte_of_exact_byte_length() {
// "中" is 3 bytes; + 29 'a' = 32 bytes → would mis-slice a &str-indexed
// parser. Must reject, not panic.
let s = "中".to_string() + &"a".repeat(29);
assert_eq!(s.len(), 32);
assert_eq!(parse_hex_fixed::<16>(&s), None);
}
#[test]
fn bytes_variable_length_and_odd_rejected() {
assert_eq!(parse_hex_bytes("0xAABBCC"), Some(vec![0xAA, 0xBB, 0xCC]));
assert_eq!(parse_hex_bytes("AABBC"), None); // odd
// Empty (or prefix-only) → empty Vec: a legitimately-empty field.
assert_eq!(parse_hex_bytes(""), Some(vec![]));
assert_eq!(parse_hex_bytes("0x"), Some(vec![]));
}
}
+69 -35
View File
@@ -621,37 +621,34 @@ fn parse_audio_attr(data: &[u8], offset: usize) -> Result<DvdAudioAttr> {
}
/// Assign the on-wire `private_stream_1` sub-stream id to each audio
/// stream by per-codec ordinal, matching DVD authoring convention and the
/// muxer's `dvd_pid()` routing:
/// - AC-3 → `0x80 + n` (n = 0-based index among AC-3 streams)
/// - DTS → `0x88 + n`
/// - LPCM → `0xA0 + n`
/// stream. On DVD-Video the sub-id's **low nibble is the audio-stream
/// *number* (0-7), shared across all codecs** — the single stream index the
/// PGC `audio_control` table / navigation registers select — and the high
/// nibble is the codec base. So the sub-id is `codec_base | position`, where
/// `position` is the stream's index in the IFO audio-attribute table (NOT a
/// per-codec running count):
/// - AC-3 → `0x80 | i`
/// - DTS → `0x88 | i`
/// - LPCM → `0xA0 | i`
/// - MP1/MP2 and anything else → `None` (regular MPEG-audio PES, not a
/// private-stream-1 sub-id).
///
/// Indices saturate at the codec range ceiling (8 AC-3/DTS, 8 LPCM) so a
/// malformed over-count never produces an out-of-range sub-id.
/// A per-codec ordinal was wrong: it only coincides with the wire id when a
/// codec's first stream is also the disc's audio stream #0. Any codec that is
/// not the first audio stream (e.g. a DTS track after an AC-3 track) then got
/// a sub-id one-too-low, so the demux routing key (`0xBD00 | sub_id`) never
/// matched and the track muxed silent. The positional index is the real wire
/// number, so distinct positions still give distinct sub-ids (no collision).
///
/// Position saturates at 7 so a malformed over-count never produces an
/// out-of-range sub-id.
fn assign_audio_sub_stream_ids(streams: &mut [DvdAudioAttr]) {
let mut n_ac3 = 0u8;
let mut n_dts = 0u8;
let mut n_lpcm = 0u8;
for s in streams.iter_mut() {
for (i, s) in streams.iter_mut().enumerate() {
let n = (i as u8).min(7);
s.sub_stream_id = match s.codec {
Codec::Ac3 => {
let id = 0x80 + n_ac3.min(7);
n_ac3 = n_ac3.saturating_add(1);
Some(id)
}
Codec::Dts => {
let id = 0x88 + n_dts.min(7);
n_dts = n_dts.saturating_add(1);
Some(id)
}
Codec::Lpcm => {
let id = 0xA0 + n_lpcm.min(7);
n_lpcm = n_lpcm.saturating_add(1);
Some(id)
}
Codec::Ac3 => Some(0x80 | n),
Codec::Dts => Some(0x88 | n),
Codec::Lpcm => Some(0xA0 | n),
_ => None,
};
}
@@ -1138,9 +1135,10 @@ mod tests {
#[test]
fn mixed_codec_sub_stream_ids_are_distinct() {
// A title mixing AC-3, DTS and LPCM must get per-codec ordinal
// sub-ids (0x80, 0x88, 0xA0...), all distinct — this is the
// routing key that keeps mixed-codec audio from colliding.
// A title mixing AC-3, DTS and LPCM: the sub-id low nibble is the
// POSITIONAL audio-stream number (shared across codecs), OR'd with the
// codec base. So idx 1 (DTS) → 0x89, idx 3 (AC-3) → 0x83 — the real
// wire ids the demux routes on. All distinct (positions are unique).
let mut streams = vec![
DvdAudioAttr {
codec: Codec::Ac3,
@@ -1172,10 +1170,10 @@ mod tests {
},
];
assign_audio_sub_stream_ids(&mut streams);
assert_eq!(streams[0].sub_stream_id, Some(0x80)); // AC-3 #0
assert_eq!(streams[1].sub_stream_id, Some(0x88)); // DTS #0
assert_eq!(streams[2].sub_stream_id, Some(0xA0)); // LPCM #0
assert_eq!(streams[3].sub_stream_id, Some(0x81)); // AC-3 #1
assert_eq!(streams[0].sub_stream_id, Some(0x80)); // AC-3 @ pos 0
assert_eq!(streams[1].sub_stream_id, Some(0x89)); // DTS @ pos 1
assert_eq!(streams[2].sub_stream_id, Some(0xA2)); // LPCM @ pos 2
assert_eq!(streams[3].sub_stream_id, Some(0x83)); // AC-3 @ pos 3
// All sub-ids unique.
let ids: Vec<u8> = streams.iter().filter_map(|s| s.sub_stream_id).collect();
let mut sorted = ids.clone();
@@ -1184,6 +1182,42 @@ mod tests {
assert_eq!(ids.len(), sorted.len(), "sub-stream ids must be unique");
}
/// Regression (The Punisher 2004): audio[0]=AC-3 5.1, audio[1]=DTS 5.0.
/// The DTS track sits at audio position 1, so its wire sub-id is 0x89
/// (0x88 | 1), NOT the per-codec 0x88. With the old per-codec ordinal it
/// got 0x88 → demux routing key 0xBD88 had no match → every DTS packet
/// (which carries 0x89) was dropped → the track muxed present-but-silent
/// while the AC-3 (at position 0, where ordinal and position coincide)
/// played fine. Positional numbering fixes it end-to-end.
#[test]
fn dts_after_ac3_uses_positional_substream_id() {
let mut streams = vec![
DvdAudioAttr {
codec: Codec::Ac3,
channels: 6,
sample_rate: 48000,
language: "en".into(),
sub_stream_id: None,
},
DvdAudioAttr {
codec: Codec::Dts,
channels: 5,
sample_rate: 48000,
language: "en".into(),
sub_stream_id: None,
},
];
assign_audio_sub_stream_ids(&mut streams);
assert_eq!(streams[0].sub_stream_id, Some(0x80));
assert_eq!(
streams[1].sub_stream_id,
Some(0x89),
"DTS at audio position 1 routes to 0x89 on the wire, not 0x88"
);
// The routing key the muxer actually uses must resolve for 0x89.
assert_eq!(crate::mux::ps::dvd_audio_pid(0x89), Some(0xBD89));
}
#[test]
fn audio_attr_dts() {
let mut data = vec![0u8; 16];
@@ -1371,10 +1405,10 @@ mod tests {
];
assign_audio_sub_stream_ids(&mut streams);
assert_eq!(streams[0].sub_stream_id, None); // MP2 → no sub-id
assert_eq!(streams[1].sub_stream_id, Some(0x80)); // AC3 #0
assert_eq!(streams[1].sub_stream_id, Some(0x81)); // AC3 @ pos 1
}
/// assign_audio_sub_stream_ids saturates the per-codec ordinal at the
/// assign_audio_sub_stream_ids saturates the positional index at the
/// range ceiling (min(7)) so a malformed over-count never produces an
/// out-of-range sub-id. 9 AC-3 streams: the 9th still ≤ 0x87.
#[test]
+172 -29
View File
@@ -32,6 +32,10 @@ pub struct DiscInputs {
/// Volume ID (16 bytes). `[0u8; 16]` when no authenticated handshake ran
/// (e.g. an ISO/mapfile flow), which disables VID-keyed lookups.
pub volume_id: [u8; 16],
/// AACS major version (1 = V10 / BD AACS 1.0, 2 = V20+ / UHD). Drives the
/// `Unit_Key_RO.inf` parse stride (48-byte V10 vs 64-byte V20/V21) when a
/// source returns a VUK to derive unit keys from. Defaults to 2.
pub version: u8,
/// Raw MKB bytes. Empty when not captured.
pub mkb: Vec<u8>,
/// Raw `Unit_Key_RO.inf` bytes. Empty when not captured.
@@ -99,24 +103,21 @@ pub struct DiscInputsCtx<'a> {
impl<'a> DiscInputsCtx<'a> {
/// Build a context over `inputs`, parsing the encrypted title keys at the
/// stride for AACS major `version_u8` (1 = V10, else V20/V21).
/// stride for the disc's own AACS major (`inputs.version`: 1 → 48-byte V10
/// stride, else 64-byte V20/V21) — the single source of truth, no separate
/// version argument to drift from it.
///
/// A present-but-malformed `unit_key_ro` (truncated / wrong magic / wrong
/// stride) parses to an empty key set, so a later [`Self::enc_title_keys`]
/// returns `Ok(&[])` indistinguishably from a disc that legitimately has no
/// title keys — the parse failure is swallowed here, not surfaced as an
/// error.
pub fn new(inputs: &'a DiscInputs, version_u8: u8) -> Self {
pub fn new(inputs: &'a DiscInputs) -> Self {
use crate::aacs::{AacsVersion, parse_unit_key_ro};
let enc_keys = if inputs.unit_key_ro.is_empty() {
Vec::new()
} else {
let version = if version_u8 == 1 {
AacsVersion::V10
} else {
AacsVersion::V20
};
parse_unit_key_ro(&inputs.unit_key_ro, version)
parse_unit_key_ro(&inputs.unit_key_ro, AacsVersion::from_major(inputs.version))
.map(|f| f.encrypted_keys.into_iter().map(|(_, k)| k).collect())
.unwrap_or_default()
};
@@ -232,11 +233,9 @@ pub fn resolve_and_apply_traced(
let mut trace = crate::aacs::ResolutionTrace::new();
// AACS major drives the Unit_Key_RO.inf stride the ctx parses at. Default to
// the V20/V21 stride when there is no AACS state (it is the common live case;
// a non-AACS disc has nothing to resolve and the loop simply finds nothing).
let version_u8 = disc.aacs.as_ref().map(|a| a.version).unwrap_or(2);
let ctx = DiscInputsCtx::new(inputs, version_u8);
// The ctx parses Unit_Key_RO.inf at the stride for `inputs.version` (the
// disc's own AACS major), so the stride is the disc's single source of truth.
let ctx = DiscInputsCtx::new(inputs);
for source in sources {
// `who` is the source's own stable identifier — no enum to map back to.
@@ -320,9 +319,11 @@ pub fn key_fetch(
let sources = make_sources();
let mut di = inputs.clone();
di.samples = samples.to_vec();
// V20/V21 stride (BD/UHD AACS 2.x); the online /decode UK path forwards
// raw inf + samples and doesn't depend on the parsed title-key stride.
let ctx = DiscInputsCtx::new(&di, 2);
// Parse Unit_Key_RO.inf at the disc's OWN stride (carried on `inputs`):
// an online /decode reply that returns a VUK (not a terminal UK) then
// derives unit keys from `enc_title_keys`, which a V10 disc parses at the
// 48-byte stride — hardcoding the V20 stride here corrupted them.
let ctx = DiscInputsCtx::new(&di);
fetch_unit_keys(&sources, &ctx)
.into_iter()
.map(|u| u.key)
@@ -343,8 +344,9 @@ pub fn key_fetch(
/// "Encrypted" is decided by [`crate::aacs::ts_sync_destroyed`] — the SAME
/// predicate the decrypt gate uses — so all sides agree. A clip opens with clear
/// navigation units (PAT/PMT, menus); only the feature body is scrambled, and a
/// clear unit proves nothing, so this collects only scrambled ones, sampling the
/// largest extent at its midpoint forward.
/// clear unit proves nothing, so this collects only scrambled ones — probing
/// several points spread across EACH extent so a title whose encrypted body
/// starts late (or whose midpoint lands in clear nav) still yields samples.
pub fn read_encrypted_units(
reader: &mut dyn crate::sector::SectorSource,
title: &crate::disc::DiscTitle,
@@ -352,7 +354,12 @@ pub fn read_encrypted_units(
) -> Vec<Vec<u8>> {
use crate::aacs::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, ts_sync_destroyed};
const CHUNK_UNITS: u32 = 15; // 45 sectors/read — under the drive transfer cap
const MAX_CHUNKS_PER_EXTENT: u32 = 4; // ~60 units scanned at each extent's midpoint
// Probe several evenly-spaced points across EACH extent rather than only the
// midpoint-and-forward: a title whose encrypted feature starts late, or whose
// midpoint lands in a clear nav stretch, must STILL yield scrambled samples.
// Empty samples make `Disc::decrypt_with` skip wrong-key validation, so a
// real encrypted title returning nothing here is a silent wrong-key hazard.
const PROBES_PER_EXTENT: u32 = 8;
let mut out: Vec<Vec<u8>> = Vec::new();
for ext in &title.extents {
@@ -360,25 +367,30 @@ pub fn read_encrypted_units(
if total_units == 0 {
continue;
}
let mut unit = total_units / 2; // midpoint (past the clear nav at the head)
for _ in 0..MAX_CHUNKS_PER_EXTENT {
for p in 1..=PROBES_PER_EXTENT {
// Probe at p/(P+1) of the extent — spreads P points across it while
// skipping the clear nav at the very head.
let unit = ((total_units as u64 * p as u64) / (PROBES_PER_EXTENT as u64 + 1)) as u32;
if unit >= total_units {
break;
continue;
}
let units_this = CHUNK_UNITS.min(total_units - unit);
// Saturate: start_lba comes from attacker-controlled UDF/MPLS
// extents; a malformed extent near u32::MAX would otherwise panic
// (debug) or wrap to a wrong LBA (release). An over-capacity LBA then
// fails cleanly via the read_sectors().is_err() break below.
// fails cleanly via the read_sectors().is_err() skip below.
let lba = ext
.start_lba
.saturating_add(unit.saturating_mul(ALIGNED_UNIT_SECTORS));
let count = (units_this * ALIGNED_UNIT_SECTORS) as u16;
let mut buf = vec![0u8; count as usize * 2048];
// `false` = no recovery retries; the reader is the raw drive/file
// (no decrypt decorator), so these are the on-disc encrypted bytes.
// (no decrypt decorator), so these are the on-disc encrypted bytes. A
// read error at one probe skips THAT probe only — it must not abandon
// the rest of the extent (the old `break` blinded the sampler on a
// single transient miss).
if reader.read_sectors(lba, count, &mut buf, false).is_err() {
break;
continue;
}
for i in 0..units_this as usize {
let o = i * ALIGNED_UNIT_LEN;
@@ -393,7 +405,6 @@ pub fn read_encrypted_units(
}
}
}
unit += units_this;
}
}
out
@@ -443,6 +454,7 @@ mod tests {
let inputs = DiscInputs {
disc_hash: "0xABC".into(),
volume_id: [0u8; 16],
version: crate::aacs::AACS_MAJOR_BD,
mkb: vec![1, 2, 3],
unit_key_ro: uk_ro,
samples: vec![vec![9u8; 4], vec![8u8; 4], vec![7u8; 4]],
@@ -450,7 +462,7 @@ mod tests {
};
// Zero VID → None.
let ctx = DiscInputsCtx::new(&inputs, 1);
let ctx = DiscInputsCtx::new(&inputs);
assert_eq!(ctx.disc_hash(), "0xABC");
assert_eq!(ctx.title(), Some("TITLE_X"));
assert!(ctx.vid().is_none(), "all-zero VID is the no-VID sentinel");
@@ -461,7 +473,7 @@ mod tests {
// Non-zero VID → Some(vid).
let mut inputs2 = inputs.clone();
inputs2.volume_id = [0x42u8; 16];
let ctx2 = DiscInputsCtx::new(&inputs2, 1);
let ctx2 = DiscInputsCtx::new(&inputs2);
assert_eq!(ctx2.vid(), Some(Vid([0x42u8; 16])));
}
@@ -498,6 +510,7 @@ mod tests {
let inputs = DiscInputs {
disc_hash: "0x00".into(),
volume_id: [0u8; 16],
version: crate::aacs::AACS_MAJOR_UHD,
mkb: Vec::new(),
unit_key_ro: Vec::new(),
samples: Vec::new(),
@@ -518,6 +531,7 @@ mod tests {
DiscInputs {
disc_hash: String::new(),
volume_id: [0u8; 16],
version: crate::aacs::AACS_MAJOR_UHD,
mkb: Vec::new(),
unit_key_ro: Vec::new(),
samples: Vec::new(),
@@ -552,7 +566,7 @@ mod tests {
#[test]
fn fetch_unit_keys_first_nonempty_skips_empty_and_errors() {
let inputs = empty_inputs();
let ctx = DiscInputsCtx::new(&inputs, 2);
let ctx = DiscInputsCtx::new(&inputs);
let key = [0xABu8; 16];
let sources: Vec<Box<dyn KeySource>> = vec![
@@ -621,4 +635,133 @@ mod tests {
);
assert_eq!(*builds.lock().unwrap(), 1, "make_sources invoked per fetch");
}
/// #4 regression: encrypted content NOT at the extent midpoint (a late-
/// starting feature, or a midpoint landing in clear nav) must still be
/// sampled — empty samples make `decrypt_with` skip wrong-key validation.
/// The old midpoint-and-forward sampler returned empty; the probe-spread
/// finds the early scrambled band.
#[test]
fn read_encrypted_units_finds_scrambled_content_off_the_midpoint() {
use crate::aacs::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, ts_sync_destroyed};
use crate::error::Result;
use crate::sector::SectorSource;
// Units in the FIRST SIXTH of the extent are scrambled (0xFF → no TS
// sync); everything else (incl. the midpoint) is clear (0x47 syncs).
struct BandSource {
ext_start: u32,
total_units: u32,
}
impl SectorSource for BandSource {
fn capacity_sectors(&self) -> u32 {
self.ext_start + self.total_units * ALIGNED_UNIT_SECTORS + 64
}
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_r: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
for (i, chunk) in buf[..bytes].chunks_mut(ALIGNED_UNIT_LEN).enumerate() {
if chunk.len() < ALIGNED_UNIT_LEN {
break;
}
let abs_unit = (lba - self.ext_start) / ALIGNED_UNIT_SECTORS + i as u32;
if abs_unit < self.total_units / 6 {
chunk.fill(0xFF); // scrambled: no TS sync
} else {
chunk.fill(0);
let mut o = 4;
while o < ALIGNED_UNIT_LEN {
chunk[o] = 0x47; // clear TS syncs
o += 192;
}
}
}
Ok(bytes)
}
}
let total_units = 600u32;
let ext_start = 1000u32;
let mut src = BandSource {
ext_start,
total_units,
};
let title = crate::disc::DiscTitle {
playlist: String::new(),
playlist_id: 0,
duration_secs: 0.0,
size_bytes: 0,
clips: Vec::new(),
streams: Vec::new(),
chapters: Vec::new(),
extents: vec![crate::disc::Extent {
start_lba: ext_start,
sector_count: total_units * ALIGNED_UNIT_SECTORS,
}],
content_format: crate::disc::ContentFormat::BdTs,
codec_privates: Vec::new(),
};
let samples = read_encrypted_units(&mut src, &title, 4);
assert!(
!samples.is_empty(),
"the probe-spread must sample the early scrambled band the midpoint misses"
);
for s in &samples {
assert!(ts_sync_destroyed(s), "every sample is a scrambled unit");
}
}
/// Audit #5 — a DISCRIMINATING test for the version→stride fix. A 2-key
/// `Unit_Key_RO.inf` whose SECOND key sits at the V20 (64-byte) offset; a V10
/// (48-byte) parse reads a DIFFERENT region. Confirms `DiscInputsCtx` parses
/// at the stride for `inputs.version` — a swapped `from_major` branch or a
/// hardcoded stride (the exact bug 1.2.0 fixes) would fail this, where the
/// prior single-key fixtures passed regardless of stride.
#[test]
fn disc_inputs_ctx_parses_unit_keys_at_the_version_stride() {
use crate::aacs::{AACS_MAJOR_BD, AACS_MAJOR_UHD};
const UK_POS: usize = 64;
let mut inf = vec![0u8; 200];
inf[0..4].copy_from_slice(&(UK_POS as u32).to_be_bytes()); // uk_pos
inf[UK_POS..UK_POS + 2].copy_from_slice(&2u16.to_be_bytes()); // num_uk = 2
let key0_at = UK_POS + 48; // first key — same for both strides
let key1_v10_at = key0_at + 48; // second key if parsed at V10 stride
let key1_v20_at = key0_at + 64; // second key if parsed at V20 stride
inf[key0_at..key0_at + 16].fill(0xA0);
inf[key1_v10_at..key1_v10_at + 16].fill(0x10);
inf[key1_v20_at..key1_v20_at + 16].fill(0x20);
let base = DiscInputs {
disc_hash: String::new(),
volume_id: [0u8; 16],
version: AACS_MAJOR_UHD,
mkb: Vec::new(),
unit_key_ro: inf,
samples: Vec::new(),
volume_label: None,
};
let k20 = DiscInputsCtx::new(&base).enc_title_keys().unwrap().to_vec();
let v10_inputs = DiscInputs {
version: AACS_MAJOR_BD,
..base.clone()
};
let k10 = DiscInputsCtx::new(&v10_inputs)
.enc_title_keys()
.unwrap()
.to_vec();
assert_eq!(k20.len(), 2);
assert_eq!(k10.len(), 2);
assert_eq!(k20[0], [0xA0; 16], "first key is at +48 for both strides");
assert_eq!(k10[0], [0xA0; 16]);
assert_eq!(k20[1], [0x20; 16], "V20 reads the 2nd key at +64");
assert_eq!(k10[1], [0x10; 16], "V10 reads the 2nd key at +48");
assert_ne!(k20[1], k10[1], "the parse stride follows inputs.version");
}
}
+15 -18
View File
@@ -1,9 +1,10 @@
//! libfreemkv -- Open source optical drive library for 4K UHD / Blu-ray / DVD.
//!
//! Handles drive access, disc structure parsing, AACS decryption, and raw
//! sector reading. Drive unlocking is pluggable: libfreemkv owns only the
//! [`Unlocker`] seam and registry — firmware blobs and unlock CDBs live in
//! an external crate (e.g. `freemkv-unlock-ld`).
//! sector reading. Unlocking — removing bus encryption (firmware unlock, AACS
//! cert handshake, CSS bus-auth) — lives entirely in the `freemkv-unlock`
//! crate; libfreemkv consumes it privately and exposes none of it, so clients
//! are oblivious to unlockers (just as they are to the SCSI layer).
//!
//! # Quick Start
//!
@@ -47,8 +48,8 @@
//! Drive -- open, identify, unlock, read sectors
//! ├── ScsiTransport -- SG_IO (Linux), IOKit (macOS)
//! ├── DriveId -- INQUIRY + GET_CONFIG identification
//! └── Unlocker -- pluggable, external (e.g. freemkv-unlock-ld);
//! libfreemkv owns only the trait + registry
//! └── unlock_bridge -- private seam to the `freemkv-unlock` crate
//! (firmware / AACS cert / CSS bus-auth unlockers)
//!
//! Disc -- scan titles, streams, AACS state
//! ├── UDF reader -- Blu-ray UDF 2.50 with metadata partitions
@@ -57,7 +58,7 @@
//! ├── JAR parser -- BD-J audio track labels
//! └── AACS -- encryption: key resolution + content decrypt
//! ├── aacs -- KEYDB, VUK, MKB, unit decrypt
//! └── handshake -- SCSI auth, ECDH, bus key
//! └── host_certs -- collect host certs (cert handshake lives in freemkv-unlock)
//! ```
//!
//! # AACS Encryption
@@ -111,6 +112,7 @@ pub mod dvdnav;
pub mod error;
pub mod event;
pub mod halt;
pub mod hex;
pub(crate) mod identity;
pub(crate) mod ifo;
pub mod io;
@@ -125,11 +127,7 @@ pub mod scsi;
pub mod sector;
pub(crate) mod speed;
pub(crate) mod udf;
pub mod unlock;
pub mod verify;
// Re-export verify types at the crate root for ergonomic imports.
pub use verify::{SectorRange, SectorStatus, VerifyResult, verify_title};
pub(crate) mod unlock_bridge;
// ─── Drive lifecycle ────────────────────────────────────────────────────────
//
@@ -175,14 +173,13 @@ pub use io::pipeline::{
pub use event::{BatchSizeReason, Event, EventKind};
pub use identity::DriveId;
// ─── Pluggable unlock seam ──────────────────────────────────────────────────
// ─── Unlock seam ────────────────────────────────────────────────────────────
//
// libfreemkv carries no firmware blobs / unlock CDBs / drive profiles. An
// external unlocker crate (e.g. `freemkv-unlock-ld`) implements `Unlocker`
// and registers it once at process start via `register_unlocker`. At
// drive-prep the registry is walked in order; the first matching unlocker
// runs, else the drive falls through to the host-cert AACS handshake.
pub use unlock::{UnlockError, Unlocker, register_unlocker};
// Drive/disc unlocking (removing bus encryption — firmware, AACS cert, CSS
// bus-auth) lives entirely in the `freemkv-unlock` crate. libfreemkv consumes
// it through the private `unlock_bridge` and exposes nothing of it: clients are
// oblivious to unlockers, exactly as they are to the SCSI layer. There is no
// public unlock surface to import.
// ─── Decryption (AACS / CSS) ────────────────────────────────────────────────
//
+127 -1
View File
@@ -60,6 +60,20 @@ impl Ac3Parser {
impl CodecParser for Ac3Parser {
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
// B1: a concealed/lost gap means the bytes held in `buf` are a TRUNCATED
// frame. Appending the post-gap bytes would splice them into one corrupt
// frame (wrong frame_size, bad CRC → "exponent out of range" / garbage).
// Drop the partial and resync on the next syncword — a clean single-frame
// gap instead of a frankenstein frame. (The video parsers carry this via
// the ResyncGate; audio has no inter-frame refs, so dropping the spliced
// partial is the whole fix.)
//
// Handle the discontinuity BEFORE the empty-data guard so the signal can
// never be stranded by an empty post-gap PES (the demuxer only emits
// non-empty PES today; this is defensive for any future caller).
if pes.discontinuity {
self.buf.clear();
}
if pes.data.is_empty() {
return Vec::new();
}
@@ -69,7 +83,13 @@ impl CodecParser for Ac3Parser {
// PES that carries several AC-3 frames stamps a monotonically increasing
// PTS per frame instead of the same PES timestamp on all of them (which
// collapses their timecodes and drifts A/V).
let base_pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
//
// A PES with no PTS (rare for audio, but legal — and the case OSS demuxers
// guard at a post-gap continuation) must NOT reset the timeline to 0;
// carry the running cadence (`flush_pts_ns` tracks the retained partial /
// last frame's PTS). The discontinuity-carrying PES is a PUSI with a PTS
// in practice, so this is defense-in-depth.
let base_pts_ns = pes.pts.map(pts_to_ns).unwrap_or(self.flush_pts_ns);
// Prepend leftover from previous PES
self.buf.extend_from_slice(&pes.data);
@@ -115,6 +135,7 @@ impl CodecParser for Ac3Parser {
let duration_ns = frame_duration_ns(remaining, bsid);
frames.push(Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: frame_pts_ns,
@@ -201,6 +222,7 @@ impl CodecParser for Ac3Parser {
}
let duration_ns = frame_duration_ns(frame, bsid);
vec![Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: self.flush_pts_ns,
@@ -450,6 +472,7 @@ mod tests {
pts: None,
dts: None,
data: vec![],
discontinuity: false,
};
assert!(parser.parse(&pes).is_empty());
}
@@ -464,6 +487,7 @@ mod tests {
pts: Some(90000),
dts: None,
data: frame_data.clone(),
discontinuity: false,
};
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
@@ -483,6 +507,7 @@ mod tests {
pts: Some(90000),
dts: None,
data: frame_data[..mid].to_vec(),
discontinuity: false,
};
let frames1 = parser.parse(&pes1);
assert!(frames1.is_empty(), "partial frame should not emit");
@@ -494,12 +519,104 @@ mod tests {
pts: Some(93000),
dts: None,
data: frame_data[mid..].to_vec(),
discontinuity: false,
};
let frames2 = parser.parse(&pes2);
assert_eq!(frames2.len(), 1);
assert_eq!(frames2[0].data.len(), 160);
}
#[test]
fn discontinuity_drops_truncated_partial() {
// B1: a partial AC-3 frame is buffered, then a concealed gap arrives
// (PES marked discontinuity) carrying a fresh complete frame. The
// truncated partial must be DROPPED, not spliced — otherwise the parser
// emits one corrupt frame built from [stale partial | head of fresh] and
// strands the tail (FFmpeg: "incomplete frame" / wrong sync).
let mut parser = Ac3Parser::new();
let frame_data = make_ac3_frame(0, 2); // 160 bytes, starts with 0x0B77
// First PES: only the first half of a frame (no boundary marker).
let pes1 = PesPacket {
source: None,
pid: 0,
pts: Some(90000),
dts: None,
data: frame_data[..80].to_vec(),
discontinuity: false,
};
assert!(
parser.parse(&pes1).is_empty(),
"partial frame should not emit"
);
// Concealed gap: a fresh whole frame, marked discontinuity.
let fresh = make_ac3_frame(0, 2);
let pes2 = PesPacket {
source: None,
pid: 0,
pts: Some(99000),
dts: None,
data: fresh.clone(),
discontinuity: true,
};
let frames = parser.parse(&pes2);
assert_eq!(frames.len(), 1, "exactly one clean frame across the gap");
assert_eq!(
frames[0].data, fresh,
"emitted frame is the fresh post-gap frame, not a spliced partial"
);
}
#[test]
fn empty_discontinuity_pes_still_drops_partial() {
// Defensive ordering: the discontinuity clear runs BEFORE the empty-data
// guard, so even an empty-payload discontinuity PES drops the stranded
// partial instead of leaking the signal and splicing on the next PES.
let mut parser = Ac3Parser::new();
let frame_data = make_ac3_frame(0, 2); // 160 bytes
// Partial first half buffered.
let pes1 = PesPacket {
source: None,
pid: 0,
pts: Some(90000),
dts: None,
data: frame_data[..80].to_vec(),
discontinuity: false,
};
assert!(parser.parse(&pes1).is_empty());
// Empty-payload discontinuity PES: must still clear the partial.
let gap = PesPacket {
source: None,
pid: 0,
pts: None,
dts: None,
data: vec![],
discontinuity: true,
};
assert!(parser.parse(&gap).is_empty(), "empty PES emits nothing");
// A fresh whole frame (no discontinuity now): if the partial had leaked,
// this would splice into a frankenstein; instead it emits cleanly.
let fresh = make_ac3_frame(0, 2);
let pes2 = PesPacket {
source: None,
pid: 0,
pts: Some(99000),
dts: None,
data: fresh.clone(),
discontinuity: false,
};
let frames = parser.parse(&pes2);
assert_eq!(frames.len(), 1, "one clean frame, partial was dropped");
assert_eq!(
frames[0].data, fresh,
"no splice — partial did not leak past the empty gap PES"
);
}
#[test]
fn skip_garbage_before_sync() {
let mut parser = Ac3Parser::new();
@@ -512,6 +629,7 @@ mod tests {
pts: None,
dts: None,
data,
discontinuity: false,
};
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
@@ -535,6 +653,7 @@ mod tests {
pts: Some(90000),
dts: None,
data: pes1_data,
discontinuity: false,
};
let frames1 = parser.parse(&pes1);
assert_eq!(frames1.len(), 1, "first complete frame emitted");
@@ -548,6 +667,7 @@ mod tests {
pts: Some(93000),
dts: None,
data: pes2_data,
discontinuity: false,
};
let frames2 = parser.parse(&pes2);
assert_eq!(frames2.len(), 1, "split-sync frame must be recovered");
@@ -574,6 +694,7 @@ mod tests {
pts: None,
dts: None,
data,
discontinuity: false,
};
let frames = parser.parse(&pes);
assert!(frames.is_empty());
@@ -602,6 +723,7 @@ mod tests {
pts: None,
dts: None,
data,
discontinuity: false,
};
assert!(parser.parse(&pes).is_empty());
assert_eq!(parser.buf, vec![0x0B], "lone trailing 0x0B retained");
@@ -639,6 +761,7 @@ mod tests {
pts: Some(90000),
dts: None,
data,
discontinuity: false,
};
let f = parser.parse(&pes);
assert_eq!(f.len(), 1, "frame 1 emitted in parse");
@@ -671,6 +794,7 @@ mod tests {
pts: Some(90000),
dts: None,
data,
discontinuity: false,
};
let f = parser.parse(&pes);
assert_eq!(f.len(), 2);
@@ -714,6 +838,7 @@ mod tests {
pts: Some(90000),
dts: None,
data,
discontinuity: false,
};
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1, "only the real AC-3 frame is emitted");
@@ -1179,6 +1304,7 @@ mod tests {
pts: Some(90000),
dts: None,
data,
discontinuity: false,
}
}
}
+76 -1
View File
@@ -123,10 +123,39 @@ const PTS_UNSET: i64 = -1;
impl CodecParser for DtsParser {
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
// B1: a concealed/lost gap means the buffered DTS access unit is
// TRUNCATED. Splicing post-gap bytes onto it corrupts the core/extension
// framing (→ "Failed to decode block code(s)" / "Invalid data found").
// Drop the partial AU and its PTS marks; the next PES re-bases a fresh
// unit. (Audio has no inter-frame refs — dropping the spliced partial is
// the whole fix; the video ResyncGate handles video.)
//
// Handle the discontinuity BEFORE the empty-data guard so the signal can
// never be stranded by an empty post-gap PES (defensive; the demuxer only
// emits non-empty PES today).
if pes.discontinuity {
self.buf.clear();
self.pts_marks.clear();
self.pending_pts = PTS_UNSET;
}
if pes.data.is_empty() {
return Vec::new();
}
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
// A PES with no PTS (rare for audio, but legal — the case OSS demuxers
// guard at a post-gap continuation) must NOT reset the timeline to 0;
// continue from the most recent known base. Defense-in-depth: the
// discontinuity-carrying PES is a PUSI with a PTS in practice.
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or_else(|| {
self.pts_marks
.last()
.map(|&(_, p)| p)
.filter(|&p| p >= 0)
.unwrap_or(if self.pending_pts >= 0 {
self.pending_pts
} else {
0
})
});
// On Blu-ray, a DTS-HD MA/HRA access unit is a DTS core frame
// (sync 0x7FFE8001) followed by one or more DTS extension substreams
@@ -243,6 +272,7 @@ impl CodecParser for DtsParser {
// extensions or the next core.
let au_pts = self.front_pts();
frames.push(Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: au_pts,
@@ -299,6 +329,7 @@ impl CodecParser for DtsParser {
let au = std::mem::take(&mut self.buf);
self.pts_marks.clear();
vec![Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns,
@@ -482,6 +513,7 @@ mod tests {
pts,
dts: None,
data,
discontinuity: false,
}
}
@@ -581,6 +613,49 @@ mod tests {
assert_eq!(tail[0].data.len(), 512);
}
#[test]
fn discontinuity_drops_truncated_partial() {
// B1: a partial DTS core is buffered, then a concealed gap (PES marked
// discontinuity) carries a fresh core. The truncated partial must be
// DROPPED — splicing it makes the framer emit a corrupt sub-core-length
// AU (the Dunkirk `dca` "Failed to decode block code(s)" class) and
// strands the rest. With the fix the post-gap core is the only AU, and it
// carries the post-gap PTS (not the stale pre-gap one).
let mut parser = DtsParser::new();
// PES 1: first half of a 512-byte core (no boundary marker).
let core = make_dts_core(512);
let pes1 = make_pes(core[..256].to_vec(), Some(90000));
assert!(parser.parse(&pes1).is_empty(), "partial core held");
// Concealed gap: a fresh whole core, marked discontinuity.
let fresh = make_dts_core(512);
let pes2 = PesPacket {
source: None,
pid: 0x1100,
pts: Some(99000),
dts: None,
data: fresh.clone(),
discontinuity: true,
};
assert!(
parser.parse(&pes2).is_empty(),
"post-gap core held awaiting next core — NO corrupt partial emitted"
);
let tail = parser.flush();
assert_eq!(tail.len(), 1, "exactly one clean AU across the gap");
assert_eq!(
tail[0].data, fresh,
"AU is the fresh post-gap core, not a splice"
);
assert_eq!(
tail[0].pts_ns,
pts_to_ns(99000),
"post-gap AU re-bases to the post-gap PTS, not the stranded pre-gap one"
);
}
#[test]
fn two_cores_back_to_back_emit_first_on_boundary() {
// The first complete unit is emitted as soon as the next core sync is
+4
View File
@@ -43,6 +43,7 @@ impl DvdSubParser {
if force || buf.len() >= *size {
let (pts_ns, _, data) = self.pending.take().unwrap();
return Some(Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns,
@@ -103,6 +104,7 @@ impl CodecParser for DvdSubParser {
let d = ((pes.data[0] as usize) << 8) | pes.data[1] as usize;
if d < 2 {
out.push(Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns,
@@ -116,6 +118,7 @@ impl CodecParser for DvdSubParser {
} else {
// Too short to carry SPU_size — pass through as a lone frame.
out.push(Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns,
@@ -233,6 +236,7 @@ mod tests {
pts,
dts: None,
data,
discontinuity: false,
}
}
+7
View File
@@ -243,6 +243,9 @@ impl CodecParser for H264Parser {
source: pes.source,
pts_ns,
keyframe,
// One access unit per PES (BD-TS aligns AUs to PES), so the gap
// signal maps straight onto this frame.
discontinuity: pes.discontinuity,
data: frame_data,
duration_ns: None,
}]
@@ -537,6 +540,7 @@ mod tests {
pts,
dts: None,
data,
discontinuity: false,
}
}
@@ -923,6 +927,7 @@ mod tests {
pts: Some(180000), // 2 seconds (presentation)
dts: Some(90000), // 1 second (decode)
data,
discontinuity: false,
};
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
@@ -1232,6 +1237,7 @@ mod tests {
pts: None,
dts: Some(90000),
data: vec![0x00, 0x00, 0x01, 0x41, 0x10],
discontinuity: false,
};
let f = parser.parse(&pes);
assert_eq!(f.len(), 1);
@@ -1247,6 +1253,7 @@ mod tests {
pts: None,
dts: None,
data: vec![0x00, 0x00, 0x01, 0x41, 0x10],
discontinuity: false,
};
let f = parser.parse(&pes);
assert_eq!(f.len(), 1);
+6
View File
@@ -661,6 +661,9 @@ impl CodecParser for HevcParser {
source: pes.source,
pts_ns,
keyframe,
// One access unit per PES (BD-TS aligns AUs to PES), so the gap
// signal maps straight onto this frame.
discontinuity: pes.discontinuity,
data: frame_data,
duration_ns: None,
}]
@@ -973,6 +976,7 @@ mod tests {
pts,
dts: None,
data,
discontinuity: false,
}
}
@@ -2243,6 +2247,7 @@ mod tests {
pts: Some(180000), // 2 s (presentation)
dts: Some(90000), // 1 s (decode)
data,
discontinuity: false,
};
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
@@ -2850,6 +2855,7 @@ mod tests {
d.extend_from_slice(&[0x10, 0x20]);
d
},
discontinuity: false,
};
let f = parser.parse(&pes);
assert_eq!(f.len(), 1);
+2
View File
@@ -72,6 +72,7 @@ impl CodecParser for LpcmParser {
}
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
vec![Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns,
@@ -98,6 +99,7 @@ mod tests {
pts,
dts: None,
data,
discontinuity: false,
}
}
+15
View File
@@ -44,6 +44,16 @@ pub struct Frame {
pub pts_ns: i64,
/// Whether this is a keyframe (used for cue points).
pub keyframe: bool,
/// This frame is the FIRST coded picture after a concealed/lost gap (P3/B1):
/// its data begins after packets the demuxer never received (an undecryptable
/// unit concealed as NULL-TS upstream, or a continuity break in a damaged
/// source). Inter-coded video frames carrying this flag reference data that is
/// gone, so the consumer's `ResyncGate` arms here and drops forward to the next
/// keyframe. Carried per-FRAME (not per-PES) because buffering parsers — MPEG-2
/// emits whole GOPs, H.264/HEVC lag one access unit — decouple the frame from
/// the PES that carried the gap signal. Default `false`; only ever set on the
/// degraded/conceal path, so a clean rip leaves every frame `false`.
pub discontinuity: bool,
/// Frame data (elementary stream bytes).
pub data: Vec<u8>,
/// Optional duration in nanoseconds — only set by parsers that
@@ -123,11 +133,15 @@ impl PassthroughParser {
impl CodecParser for PassthroughParser {
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
// Passthrough emits exactly one frame per PES with no cross-PES buffering,
// so the PES's discontinuity maps directly onto this frame. (Buffering
// parsers must instead defer the flag to the next emitted frame.)
vec![Frame {
coding: None,
source: None,
pts_ns,
keyframe: self.keyframe,
discontinuity: pes.discontinuity,
data: pes.data.clone(),
duration_ns: None,
}]
@@ -198,6 +212,7 @@ mod tests {
pts,
dts: None,
data,
discontinuity: false,
}
}
+82
View File
@@ -129,6 +129,15 @@ pub struct Mpeg2Parser {
/// each GOP's first PES PTS so video stays in sync with the PES-timestamped
/// audio. None until the first PES timestamp is seen.
origin_pts_ns: Option<i64>,
/// B1: absolute ES offsets at which a concealed/lost-gap PES began, parallel
/// to `pts_marks`/`source_marks` and drained by the SAME mark-drain invariant.
/// MPEG-2 emits whole GOPs asynchronously, so a per-PES flag can't ride
/// through to the right frame (the PES that carries the gap completes the
/// PREVIOUS picture); associating by OFFSET instead stamps `discontinuity` on
/// the access unit whose own bytes begin after the gap — the first post-gap
/// picture — surviving GOP buffering + temporal reorder. The consumer's
/// ResyncGate then arms at that exact picture, mid-GOP if need be.
disc_marks: VecDeque<u64>,
}
/// One coded picture buffered awaiting its GOP's completion (see `gop_buf`).
@@ -165,6 +174,7 @@ impl Mpeg2Parser {
gop_buf: Vec::new(),
emitted_fields: 0,
origin_pts_ns: None,
disc_marks: VecDeque::new(),
}
}
@@ -223,6 +233,13 @@ impl Mpeg2Parser {
break;
}
}
while let Some(&off) = self.disc_marks.front() {
if off < cutoff {
self.disc_marks.pop_front();
} else {
break;
}
}
}
break;
};
@@ -316,6 +333,11 @@ impl Mpeg2Parser {
if gop_boundary && !self.gop_buf.is_empty() {
self.flush_gop(&mut out);
}
// A concealed-gap mark inside this AU's range [start, end_abs) means
// this picture's own bytes begin after the gap — the first post-gap
// AU. Same front-mark invariant as PTS/source. Carries through GOP
// buffering/reorder to the ResyncGate (which arms at this picture).
let discontinuity = self.disc_marks.front().is_some_and(|&off| off < end_abs);
self.gop_buf.push(BufferedPicture {
tr,
info,
@@ -323,6 +345,7 @@ impl Mpeg2Parser {
frame: Frame {
pts_ns: 0,
keyframe,
discontinuity,
data,
duration_ns: None,
coding: Some(info),
@@ -352,6 +375,13 @@ impl Mpeg2Parser {
break;
}
}
while let Some(&off) = self.disc_marks.front() {
if off < end_abs {
self.disc_marks.pop_front();
} else {
break;
}
}
}
// EOF: emit the final (possibly incomplete) GOP so nothing is dropped.
if force {
@@ -429,6 +459,12 @@ impl CodecParser for Mpeg2Parser {
if let Some(src) = pes.source {
self.source_marks.push_back((off, src));
}
// A concealed/lost gap on this PES marks the access unit its bytes begin —
// associated by offset (like PTS/source) so it lands on the first post-gap
// picture, not the previous one that completes when this PES arrives.
if pes.discontinuity {
self.disc_marks.push_back(off);
}
self.buf.extend_from_slice(&pes.data);
self.drain_complete_aus(false)
}
@@ -707,6 +743,7 @@ mod tests {
pts: None,
dts: None,
data,
discontinuity: false,
};
let mut p = Mpeg2Parser::new();
let mut frames = Vec::new();
@@ -822,6 +859,7 @@ mod tests {
pts,
dts: None,
data,
discontinuity: false,
}
}
@@ -995,6 +1033,48 @@ mod tests {
assert!(!frames[1].keyframe);
}
/// B1 hole-2 regression: MPEG-2 buffers a GOP and emits asynchronously, so a
/// concealed gap must be associated by OFFSET (like PTS), landing on the
/// picture whose own bytes begin after the gap — NOT the previous picture
/// that completes when the discontinuity PES arrives. pic1 (I) is pre-gap;
/// pic2 (P), carried by a `discontinuity` PES, is the first post-gap AU.
#[test]
fn discontinuity_offset_mark_stamps_post_gap_picture_not_previous() {
let mut parser = Mpeg2Parser::new();
let mut pic1 = make_picture_header(PICTURE_TYPE_I);
pic1.extend_from_slice(&[0x11; 100]);
let mut pic2 = make_picture_header(2); // P
pic2.extend_from_slice(&[0x22; 100]);
// pic1 on a clean PES; nothing emits (same GOP, buffered).
assert!(parser.parse(&make_pes(pic1.clone(), Some(0))).is_empty());
// pic2 on a PES flagged discontinuity (a concealed gap preceded it).
// parse() of this PES completes pic1's AU (the PREVIOUS picture) — which
// must stay clean — while pic2 keeps buffering.
let pes2 = PesPacket {
source: None,
pid: 0x1011,
pts: Some(90000),
dts: None,
data: pic2.clone(),
discontinuity: true,
};
assert!(parser.parse(&pes2).is_empty(), "same GOP — still buffered");
let frames = parser.flush();
assert_eq!(frames.len(), 2);
assert_eq!(frames[0].data, pic1);
assert!(
!frames[0].discontinuity,
"the previous (pre-gap) I picture must NOT be flagged"
);
assert_eq!(frames[1].data, pic2);
assert!(
frames[1].discontinuity,
"the post-gap P picture (the discontinuity PES's own AU) IS flagged"
);
}
#[test]
fn picture_coding_extension_stays_with_its_picture() {
// Regression for `ignoring pic cod ext after 0`: the picture coding
@@ -1415,6 +1495,7 @@ mod tests {
pts: None,
dts: Some(90000),
data,
discontinuity: false,
};
let f = parse_then_flush(&mut parser, &pes);
assert_eq!(f[0].pts_ns, 1_000_000_000, "DTS fallback");
@@ -1428,6 +1509,7 @@ mod tests {
pts: None,
dts: None,
data: data2,
discontinuity: false,
};
let f2 = parse_then_flush(&mut parser2, &pes2);
assert_eq!(f2[0].pts_ns, 0, "no PTS/DTS → 0");
+7
View File
@@ -57,6 +57,7 @@ impl PgsParser {
let (start_pts, data) = self.pending.take()?;
let duration = end_pts_ns.saturating_sub(start_pts).max(0) as u64;
Some(Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: start_pts,
@@ -92,6 +93,7 @@ impl CodecParser for PgsParser {
.take()
.map(|(start_pts, data)| {
vec![Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: start_pts,
@@ -119,6 +121,7 @@ impl CodecParser for PgsParser {
let frame = match pts {
Some(end) => self.emit_pending(end),
None => self.pending.take().map(|(start_pts, data)| Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: start_pts,
@@ -142,6 +145,7 @@ impl CodecParser for PgsParser {
// Flush any prior pending undurated and skip storing this one.
None => {
out.extend(self.pending.take().map(|(start_pts, data)| Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: start_pts,
@@ -167,6 +171,7 @@ impl CodecParser for PgsParser {
// (A missing PTS falls through to the drop path below: a
// bitmap with no timing reference would land at 00:00:00.)
out.push(Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: pts.unwrap_or(0),
@@ -195,6 +200,7 @@ impl CodecParser for PgsParser {
// the final on-screen subtitle (see the module doc).
match self.pending.take() {
Some((start_pts, data)) => vec![Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: start_pts,
@@ -223,6 +229,7 @@ mod tests {
pts,
dts: None,
data,
discontinuity: false,
}
}
+59
View File
@@ -126,6 +126,19 @@ enum Ac3Size {
impl CodecParser for TrueHdParser {
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
// B1: a concealed/lost gap means the buffered TrueHD AU is TRUNCATED.
// Splicing post-gap bytes onto it corrupts the AU framing (→ "Invalid
// data found") and strands the PTS cadence (the non-monotonic audio-DTS
// band at gaps). Drop the partial; with `buf` now empty the PTS-base block
// below re-seeds the cadence from the post-gap PES, monotonic across the
// gap. (Audio has no inter-frame refs — this is the whole audio fix.)
//
// Handle the discontinuity BEFORE the empty-data guard so the signal can
// never be stranded by an empty post-gap PES (defensive; the demuxer only
// emits non-empty PES today).
if pes.discontinuity {
self.buf.clear();
}
if pes.data.is_empty() {
return Vec::new();
}
@@ -255,6 +268,7 @@ impl CodecParser for TrueHdParser {
}
frames.push(Frame {
discontinuity: false,
coding: None,
source: None,
pts_ns: self.next_pts_ns,
@@ -425,6 +439,7 @@ mod tests {
pts,
dts: None,
data,
discontinuity: false,
}
}
@@ -477,6 +492,50 @@ mod tests {
assert_eq!(frames[0].data.len(), 200);
}
#[test]
fn discontinuity_drops_truncated_partial() {
// B1: a partial TrueHD unit is buffered, then a concealed gap (PES marked
// discontinuity) carries a fresh unit. The truncated partial must be
// DROPPED — splicing it makes the length-prefixed framer emit a
// wrong-size unit (corrupt AU framing) and re-seeds the PTS cadence from
// the post-gap PES rather than stranding it (the non-monotonic audio-DTS
// band at gaps).
let mut parser = TrueHdParser::new();
// PES 1: first 150 bytes of a 300-byte unit (length prefix says 300, only
// 150 present) → held, nothing emitted.
let partial = make_truehd_unit(300);
let pes1 = make_pes(partial[..150].to_vec(), Some(90000));
assert!(parser.parse(&pes1).is_empty(), "partial unit held");
// Concealed gap: a fresh 200-byte unit at a forward PTS jump.
let fresh = make_truehd_unit(200);
let pes2 = PesPacket {
source: None,
pid: 0x1100,
pts: Some(180000),
dts: None,
data: fresh.clone(),
discontinuity: true,
};
let frames = parser.parse(&pes2);
assert_eq!(frames.len(), 1, "exactly one clean unit across the gap");
assert_eq!(
frames[0].data.len(),
200,
"emitted unit is the fresh 200-byte one, not a 300-byte splice"
);
assert_eq!(
frames[0].data, fresh,
"unit bytes are the fresh post-gap unit"
);
assert_eq!(
frames[0].pts_ns,
pts_to_ns(180000),
"cadence re-bases to the post-gap PTS across the cleared buffer"
);
}
#[test]
fn parse_multiple_units_incrementing_pts() {
let mut parser = TrueHdParser::new();
+7
View File
@@ -328,6 +328,9 @@ impl CodecParser for Vc1Parser {
source: pes.source,
pts_ns: ts_ns,
keyframe,
// One frame per PES (BD-TS aligns frames to PES), so the gap signal
// maps straight onto this frame.
discontinuity: pes.discontinuity,
data: frame_data,
duration_ns: None,
}]
@@ -451,6 +454,7 @@ mod tests {
pts,
dts: None,
data,
discontinuity: false,
}
}
@@ -757,6 +761,7 @@ mod tests {
pts: Some(180000), // presentation
dts: Some(90000), // decode
data,
discontinuity: false,
};
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
@@ -1007,6 +1012,7 @@ mod tests {
pts: None,
dts: Some(90000),
data: vec![0x00, 0x00, 0x01, SC_FRAME, 0x55],
discontinuity: false,
};
let f = parser.parse(&pes);
assert_eq!(f[0].pts_ns, 1_000_000_000, "DTS fallback");
@@ -1018,6 +1024,7 @@ mod tests {
pts: None,
dts: None,
data: vec![0x00, 0x00, 0x01, SC_FRAME, 0x55],
discontinuity: false,
};
let f2 = parser2.parse(&pes2);
assert_eq!(f2[0].pts_ns, 0, "no PTS/DTS → 0");
+104 -8
View File
@@ -195,6 +195,16 @@ pub struct DiscStream {
/// timestamp cost or `prof_active()` env-var lookup (which takes a
/// process-wide lock).
profiling: bool,
/// B1 drop-to-keyframe resync gate, one per stream — mirrors
/// `PipelinedPesStream`. After an AACS-concealed (undecryptable) gap stamps
/// a frame `discontinuity`, the gate drops forward inter-coded video frames
/// until the next keyframe so no dangling-reference frame reaches the muxer.
/// Without this the live-drive single-pass path emitted decode-broken output
/// on an undecryptable unit (the file-backed highway path is already gated).
resync: Vec<super::resync::ResyncGate>,
/// Whether each stream (by track index) is video — audio/subtitle frames are
/// independent and always admit.
is_video: Vec<bool>,
}
impl DiscStream {
@@ -227,7 +237,12 @@ impl DiscStream {
// CSS/unencrypted content needs a decrypting wrapper to yield plaintext
// VOB bytes before the AC-3 sub-stream probe can read real `acmod`s.
let mut reader = DecryptingSectorSource::new(reader, decrypt_keys.clone());
// MUX path: tolerate decrypt loss — conceal an undecryptable unit (NULL TS
// fill) + tally + log rather than abort the stream (P3). DiscStream is a
// decode/mux stream (live-drive single-pass / direct), never the
// ciphertext-preserving sweep, so concealment is always correct here.
let mut reader =
DecryptingSectorSource::new(reader, decrypt_keys.clone()).tolerate_decrypt_loss();
// Wrong-substream fix (Silence-of-the-Lambs): re-route the title's
// declared AC-3 audio onto the physically-correct `0x8x` sub-streams by
@@ -282,6 +297,18 @@ impl DiscStream {
// clones an Arc per frame on the mux hot path.
let decrypt_loss = reader.decrypt_loss();
// B1 resync gates: one per stream, video flagged so the gate only
// drop-to-keyframes video (audio/subtitle always admit). Computed before
// `title` is moved into the struct below.
let is_video: Vec<bool> = title
.streams
.iter()
.map(|s| matches!(s, crate::disc::Stream::Video(_)))
.collect();
let resync = (0..title.streams.len())
.map(|_| super::resync::ResyncGate::new())
.collect();
Self {
reader,
decrypt_loss,
@@ -310,6 +337,8 @@ impl DiscStream {
pid_to_track,
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
profiling: std::env::var_os("FREEMKV_PROFILE").is_some(),
resync,
is_video,
}
}
@@ -668,10 +697,25 @@ impl crate::pes::Stream for DiscStream {
if let Some((_, parser)) =
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
{
let resync = &mut self.resync;
let is_video = &self.is_video;
let pending = &mut self.pending_frames;
for frame in parser.parse(pes) {
self.pending_frames.push_back(
crate::pes::PesFrame::from_codec_frame(*track, frame),
);
// Same B1 gate — a concealed gap can leave a
// post-gap frame in the demuxer's final flush.
let emit = match resync.get_mut(*track) {
Some(gate) => gate.admit(
is_video.get(*track).copied().unwrap_or(false),
frame.discontinuity,
frame.keyframe,
),
None => true,
};
if emit {
pending.push_back(crate::pes::PesFrame::from_codec_frame(
*track, frame,
));
}
}
}
}
@@ -718,6 +762,8 @@ impl crate::pes::Stream for DiscStream {
pts: ps.pts.map(|p| p as i64),
dts: ps.dts.map(|d| d as i64),
data: ps.data.clone(),
// PS (DVD/CSS) path: no AACS conceal, no gap flag.
discontinuity: false,
};
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid)
{
@@ -734,12 +780,42 @@ impl crate::pes::Stream for DiscStream {
// PES boundaries).
let pid_to_track = &self.pid_to_track;
let pending = &mut self.pending_frames;
let resync = &mut self.resync;
let is_video = &self.is_video;
for (pid, parser) in self.parsers.iter_mut() {
let Some(&(_, track)) = pid_to_track.iter().find(|(p, _)| p == pid) else {
continue;
};
// Flush frames carry their own per-frame `discontinuity` (a
// post-gap picture buffered at EOF was stamped by the parser),
// so route them through the SAME B1 gate the in-stream path
// uses — otherwise a trailing dangling-reference frame would
// bypass the resync.
for frame in parser.flush() {
pending.push_back(crate::pes::PesFrame::from_codec_frame(track, frame));
let emit = match resync.get_mut(track) {
Some(gate) => gate.admit(
is_video.get(track).copied().unwrap_or(false),
frame.discontinuity,
frame.keyframe,
),
None => true,
};
if emit {
pending.push_back(crate::pes::PesFrame::from_codec_frame(track, frame));
}
}
}
// A gate still armed at EOF dropped post-gap frames that never
// reached a keyframe (a concealed gap in the final GOP). Surface
// it once so the loss is visible, not silent.
for (track, gate) in self.resync.iter().enumerate() {
if gate.is_armed() {
tracing::warn!(
target: "mux",
track,
dropped = gate.dropped_in_run(),
"B1: stream ended while dropping to a keyframe after a concealed gap (no trailing keyframe)"
);
}
}
return Ok(self.pending_frames.pop_front());
@@ -787,10 +863,28 @@ impl crate::pes::Stream for DiscStream {
} else if let Some((_, parser)) =
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
{
// Disjoint field borrows: the resync gate + is_video
// reads coexist with the mutable `self.parsers` drain.
let resync = &mut self.resync;
let is_video = &self.is_video;
let pending = &mut self.pending_frames;
for frame in parser.parse(&pes) {
self.pending_frames.push_back(
crate::pes::PesFrame::from_codec_frame(track, frame),
);
// B1: after a concealed/lost gap, drop forward to
// the next keyframe on a video track so no frame
// with a dangling reference is emitted.
let emit = match resync.get_mut(track) {
Some(gate) => gate.admit(
is_video.get(track).copied().unwrap_or(false),
frame.discontinuity,
frame.keyframe,
),
None => true,
};
if emit {
pending.push_back(crate::pes::PesFrame::from_codec_frame(
track, frame,
));
}
}
}
}
@@ -841,6 +935,8 @@ impl crate::pes::Stream for DiscStream {
pts: ps.pts.map(|p| p as i64),
dts: ps.dts.map(|d| d as i64),
data: ps.data.clone(),
// PS (DVD/CSS) path: no AACS conceal, no gap flag.
discontinuity: false,
};
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) {
+1
View File
@@ -2665,6 +2665,7 @@ mod tests {
pts: Some(gop_pts),
dts: None,
data: es,
discontinuity: false,
}));
}
frames.extend(parser.flush());
+1
View File
@@ -91,6 +91,7 @@ pub(crate) mod mkvstream;
pub(crate) mod network;
pub(crate) mod null;
pub(crate) mod ps;
pub(crate) mod resync;
pub(crate) mod stdio;
/// Shared clip-boundary timeline-continuity corrector (used by the MKV muxer
/// and the `demux://` sink).
+209 -4
View File
@@ -68,6 +68,13 @@ pub struct PipelinedPesStream {
/// are expected on every disc; instead of a per-packet WARN they're tallied
/// and summarised once at EOF.
dropped_nav_packets: u64,
/// Per-track (by stream index) B1 drop-to-keyframe gate. After a TS gap on a
/// video track, drop inter-coded frames until the next IRAP so the muxed
/// stream stays decode-clean across an upstream concealed loss (P3/B1).
resync: Vec<super::resync::ResyncGate>,
/// Per-track "is inter-coded video" flag (only video has cross-frame
/// references the gate must protect). Indexed by stream index.
is_video: Vec<bool>,
}
impl PipelinedPesStream {
@@ -87,6 +94,14 @@ impl PipelinedPesStream {
parsers: Vec<(u16, Box<dyn CodecParser>)>,
pid_to_track: Vec<(u16, usize)>,
) -> Self {
let is_video: Vec<bool> = title
.streams
.iter()
.map(|s| matches!(s, crate::disc::Stream::Video(_)))
.collect();
let resync = (0..title.streams.len())
.map(|_| super::resync::ResyncGate::new())
.collect();
Self {
title,
parsers,
@@ -98,6 +113,8 @@ impl PipelinedPesStream {
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
decrypt_loss: None,
dropped_nav_packets: 0,
resync,
is_video,
}
}
@@ -164,9 +181,39 @@ impl PipelinedPesStream {
} else if let Some((_, parser)) =
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
{
let is_video = self.is_video.get(track).copied().unwrap_or(false);
for frame in parser.parse(&pes) {
self.pending_frames
.push_back(PesFrame::from_codec_frame(track, frame));
// B1: after a concealed/lost gap, drop forward to the next
// keyframe on a video track so no frame with a dangling
// reference is emitted. The signal is read PER-FRAME
// (`frame.discontinuity`), not per-PES: buffering parsers
// (MPEG-2 GOPs, H.264/HEVC AU lag) stamp the exact post-gap
// picture, so only it arms the gate — not a whole PES of
// frames. Audio/subtitle always admit (independent frames);
// a track with no gate (out-of-range index) emits as-is.
let emit = match self.resync.get_mut(track) {
Some(gate) => {
let was_armed = gate.is_armed();
let dropped = gate.dropped_in_run();
let admit =
gate.admit(is_video, frame.discontinuity, frame.keyframe);
if admit && was_armed {
tracing::warn!(
target: "mux",
track,
pid = pes.pid,
dropped,
"B1: resynced at keyframe after concealed gap"
);
}
admit
}
None => true,
};
if emit {
self.pending_frames
.push_back(PesFrame::from_codec_frame(track, frame));
}
}
}
}
@@ -217,6 +264,8 @@ impl PipelinedPesStream {
pts: ps.pts.map(|p| p as i64),
dts: ps.dts.map(|d| d as i64),
data: ps.data,
// PS (DVD/CSS) path: no AACS conceal → no continuity-gap flag.
discontinuity: false,
};
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) {
for frame in parser.parse(&pes) {
@@ -254,15 +303,47 @@ impl Stream for PipelinedPesStream {
);
}
// Drain any access unit a parser buffered past the last
// PES (e.g. DTS-HD's final core+extension unit).
// PES (e.g. DTS-HD's final core+extension unit, or MPEG-2's
// final GOP). These flush frames carry their own per-frame
// `discontinuity` (a post-gap picture buffered at EOF was
// stamped by the parser), so route them through the SAME B1
// gate the in-stream path uses — otherwise a trailing
// dangling-reference frame (MPEG-2 final-GOP corner) would
// bypass the resync. Disjoint field borrows so the gate +
// is_video reads coexist with the mutable parser drain.
let pid_to_track = &self.pid_to_track;
let pending = &mut self.pending_frames;
let resync = &mut self.resync;
let is_video = &self.is_video;
for (pid, parser) in self.parsers.iter_mut() {
let Some(&(_, track)) = pid_to_track.iter().find(|(p, _)| p == pid) else {
continue;
};
for frame in parser.flush() {
pending.push_back(PesFrame::from_codec_frame(track, frame));
let emit = match resync.get_mut(track) {
Some(gate) => gate.admit(
is_video.get(track).copied().unwrap_or(false),
frame.discontinuity,
frame.keyframe,
),
None => true,
};
if emit {
pending.push_back(PesFrame::from_codec_frame(track, frame));
}
}
}
// A gate still armed at EOF dropped post-gap frames that never
// reached a keyframe (e.g. a concealed gap in the final GOP).
// Surface it once so the loss is visible, not silent.
for (track, gate) in self.resync.iter().enumerate() {
if gate.is_armed() {
tracing::warn!(
target: "mux",
track,
dropped = gate.dropped_in_run(),
"B1: stream ended while dropping to a keyframe after a concealed gap (no trailing keyframe)"
);
}
}
return Ok(self.pending_frames.pop_front());
@@ -382,6 +463,7 @@ mod tests {
source: None,
pts_ns: pes.pts.unwrap_or(0) + i as i64,
keyframe: i == 0,
discontinuity: false,
data: pes.data.clone(),
duration_ns: None,
})
@@ -394,6 +476,7 @@ mod tests {
source: None,
pts_ns: 0,
keyframe: false,
discontinuity: false,
data: vec![0xEE],
duration_ns: None,
})
@@ -411,6 +494,7 @@ mod tests {
pts: Some(90_000),
dts: None,
data,
discontinuity: false,
}
}
@@ -498,6 +582,127 @@ mod tests {
);
}
/// A parser that emits exactly one frame per PES, marking it a keyframe iff
/// the PES payload's first byte is `b'K'`. Lets a test script a precise
/// keyframe/inter-frame sequence to exercise the B1 resync gate.
struct KeyframeParser;
impl CodecParser for KeyframeParser {
fn parse(&mut self, pes: &PesPacket) -> Vec<super::super::codec::Frame> {
vec![super::super::codec::Frame {
coding: None,
source: None,
pts_ns: pes.pts.unwrap_or(0),
keyframe: pes.data.first() == Some(&b'K'),
// Propagate so the B1 gate can be driven end-to-end in tests.
discontinuity: pes.discontinuity,
data: pes.data.clone(),
duration_ns: None,
}]
}
fn flush(&mut self) -> Vec<super::super::codec::Frame> {
vec![]
}
fn codec_private(&self) -> Option<Vec<u8>> {
None
}
}
fn ts_pes_disc(pid: u16, data: Vec<u8>, discontinuity: bool) -> PesPacket {
PesPacket {
source: None,
pid,
pts: Some(90_000),
dts: None,
data,
discontinuity,
}
}
/// B1 end-to-end: after a TS discontinuity on a VIDEO track the consumer
/// must DROP every inter-coded frame until the next keyframe, so no frame
/// with a dangling reference reaches the muxer (an ffmpeg deep-scan would
/// otherwise report a missing reference). The frame carrying the
/// discontinuity and the inter frames behind it are dropped; the stream
/// resumes cleanly at the next keyframe.
#[test]
fn b1_video_drops_to_keyframe_after_discontinuity() {
let title = video_title(false); // one HEVC video stream, PID 0x1011
let parsers: Vec<(u16, Box<dyn CodecParser>)> = vec![(0x1011, Box::new(KeyframeParser))];
let pid_to_track = vec![(0x1011u16, 0usize)];
let (mut stream, tx) = make_stream(title, parsers, pid_to_track);
// K0,P1 clean → emit. P2 carries the gap (inter frame referencing the
// lost data) → arms the gate; P2,P3 drop. K4 is the next keyframe →
// resync + emit. P5 then admits cleanly.
tx.send(DemuxBatch::Ts(vec![
ts_pes_disc(0x1011, b"K0".to_vec(), false),
ts_pes_disc(0x1011, b"P1".to_vec(), false),
ts_pes_disc(0x1011, b"P2".to_vec(), true),
ts_pes_disc(0x1011, b"P3".to_vec(), false),
ts_pes_disc(0x1011, b"K4".to_vec(), false),
ts_pes_disc(0x1011, b"P5".to_vec(), false),
]))
.unwrap();
tx.send(DemuxBatch::Eof).unwrap();
let mut emitted = Vec::new();
while let Some(f) = stream.read().unwrap() {
emitted.push(f.data);
}
// P2 (gap) and P3 (still no keyframe) are dropped; the rest survive in
// order. Crucially the FIRST frame after the gap that we emit is the
// keyframe K4 — never a dangling-reference inter frame.
assert_eq!(
emitted,
vec![
b"K0".to_vec(),
b"P1".to_vec(),
b"K4".to_vec(),
b"P5".to_vec()
],
"post-gap inter frames dropped, stream resumes at the keyframe"
);
}
/// Counterpart to B1: a discontinuity on a NON-video track must NOT drop
/// frames — audio/subtitle access units are independent, so the gate admits
/// every frame the parser still produces.
#[test]
fn b1_audio_does_not_drop_on_discontinuity() {
let mut title = DiscTitle::empty();
title.streams.push(crate::disc::Stream::Audio(AudioStream {
pid: 0x1100,
codec: Codec::Ac3,
channels: AudioChannels::Surround51,
language: "eng".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: LabelPurpose::Normal,
label: String::new(),
}));
let parsers: Vec<(u16, Box<dyn CodecParser>)> = vec![(0x1100, Box::new(KeyframeParser))];
let pid_to_track = vec![(0x1100u16, 0usize)];
let (mut stream, tx) = make_stream(title, parsers, pid_to_track);
tx.send(DemuxBatch::Ts(vec![
ts_pes_disc(0x1100, b"a0".to_vec(), false),
ts_pes_disc(0x1100, b"a1".to_vec(), true), // gap — but audio is independent
ts_pes_disc(0x1100, b"a2".to_vec(), false),
]))
.unwrap();
tx.send(DemuxBatch::Eof).unwrap();
let mut emitted = Vec::new();
while let Some(f) = stream.read().unwrap() {
emitted.push(f.data);
}
assert_eq!(
emitted,
vec![b"a0".to_vec(), b"a1".to_vec(), b"a2".to_vec()],
"audio frames are never dropped on a discontinuity"
);
}
/// At EOF the consumer must call `flush()` on every parser and emit the
/// buffered tail frames — a parser that holds the final access unit (e.g.
/// DTS-HD) must NOT have it dropped. Without the flush the last frame is
+6 -1
View File
@@ -629,8 +629,13 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
crate::decrypt::DecryptKeys::Aacs { .. } => 3,
_ => 1,
};
// MUX path: tolerate decrypt loss. An undecryptable content unit is concealed
// (NULL TS fill) + tallied + logged, never an abort — decrypt-verify is a RIP
// gate, not a mux gate (P3). The rip's own read paths keep their fail-loud
// decorator; only this mux pipeline opts in.
let mut decrypting =
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys)
.tolerate_decrypt_loss();
// Install the fresh-key-on-failure callback (if any) so a unit no held key
// decrypts is re-tried via the application's key source before being counted
// as loss.
+123
View File
@@ -0,0 +1,123 @@
//! B1 drop-to-IRAP gate — keep a muxed elementary stream decode-clean across a
//! mid-stream gap (e.g. an undecryptable unit the mux concealed as NULL TS,
//! P3/A2).
//!
//! When packets are lost, the affected access unit is already dropped at the TS
//! layer (the assembler drops the partial PES on the continuity gap). But for
//! INTER-CODED video the frames that follow reference the lost frame (and each
//! other) until the next IRAP/IDR keyframe — emitting them yields an ffmpeg
//! "missing reference / non-existing PPS" deep-scan error and visibly broken
//! decode. So after a gap on a video track we DROP FORWARD to the next keyframe
//! and resume cleanly there. The gap rounds up to (at most) one GOP — the price
//! of never emitting a dangling reference; it is logged.
//!
//! Audio and subtitle frames are independent (no inter-frame references), so a
//! gap there costs only the single already-dropped frame; the gate is a no-op
//! for non-video tracks (it always admits).
/// Per-track keyframe-resync state. One gate per elementary stream; a video
/// track's gate stays "armed" from a discontinuity until the next keyframe.
#[derive(Debug, Default)]
pub(crate) struct ResyncGate {
/// True while dropping post-gap inter-coded frames until the next keyframe.
armed: bool,
/// Count of frames dropped while armed (for a single summary log on resync).
dropped: u64,
}
impl ResyncGate {
pub(crate) fn new() -> Self {
Self {
armed: false,
dropped: 0,
}
}
/// Decide whether a parsed frame should be EMITTED (`true`) or DROPPED
/// (`false`).
///
/// * `is_video` — inter-coded video track (the only kind with cross-frame
/// references); `false` for audio/subtitle, which always admit.
/// * `discontinuity` — this frame's source PES followed a TS continuity gap.
/// * `keyframe` — this frame is a self-contained IRAP/IDR.
///
/// A non-video track always admits. A video track arms on a discontinuity
/// and then drops every non-keyframe until (and excluding the drop of) the
/// next keyframe, which disarms and is emitted.
pub(crate) fn admit(&mut self, is_video: bool, discontinuity: bool, keyframe: bool) -> bool {
if !is_video {
return true;
}
if discontinuity {
self.armed = true;
}
if self.armed {
if keyframe {
self.armed = false;
self.dropped = 0;
true
} else {
self.dropped += 1;
false
}
} else {
true
}
}
/// Frames dropped so far in the CURRENT armed run (0 when not armed / just
/// resynced). Lets the consumer log the resync cost once at the keyframe.
pub(crate) fn dropped_in_run(&self) -> u64 {
self.dropped
}
/// Whether the gate is currently dropping frames (armed, awaiting keyframe).
pub(crate) fn is_armed(&self) -> bool {
self.armed
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn non_video_always_admits_even_on_discontinuity() {
let mut g = ResyncGate::new();
// Audio/subtitle: a gap drops only the (already TS-dropped) frame; every
// frame the parser still emits is independent and must pass.
assert!(g.admit(false, true, false));
assert!(g.admit(false, true, false));
assert!(!g.is_armed(), "non-video never arms");
}
#[test]
fn video_drops_inter_frames_until_next_keyframe() {
let mut g = ResyncGate::new();
// Clean run: everything admits.
assert!(g.admit(true, false, true)); // IDR
assert!(g.admit(true, false, false)); // P
// Gap arrives on the next frame (a P referencing lost data) → drop it
// and every inter frame until the next keyframe.
assert!(!g.admit(true, true, false), "post-gap P dropped");
assert!(!g.admit(true, false, false), "still dropping (no key yet)");
assert!(!g.admit(true, false, false));
assert_eq!(g.dropped_in_run(), 3);
// Next keyframe resyncs and is emitted.
assert!(g.admit(true, false, true), "keyframe resumes the stream");
assert!(!g.is_armed());
assert_eq!(g.dropped_in_run(), 0);
// Back to a clean run.
assert!(g.admit(true, false, false), "post-resync P admits");
}
#[test]
fn discontinuity_landing_on_a_keyframe_emits_immediately() {
let mut g = ResyncGate::new();
// If the first surviving frame after the gap is itself an IRAP, there is
// nothing to drop — it is self-contained.
assert!(g.admit(true, true, true), "gap+keyframe emits, no drop");
assert!(!g.is_armed());
assert_eq!(g.dropped_in_run(), 0);
}
}
+233 -22
View File
@@ -12,6 +12,10 @@ use crate::consts::TS_PACKET_BYTES;
/// TS sync byte.
const SYNC_BYTE: u8 = 0x47;
/// MPEG-TS null-packet PID (0x1FFF). Carries no elementary stream; the P3
/// concealment fill emits null packets on this PID, tagged with an
/// adaptation-field discontinuity_indicator to signal a concealed gap.
const NULL_PID: u16 = 0x1FFF;
/// A reassembled PES packet with timestamp info.
#[derive(Debug)]
@@ -28,6 +32,18 @@ pub struct PesPacket {
/// from the producer's known stream offset. `None` when the demuxer was fed
/// without a base offset (callers that don't need provenance).
pub source: Option<crate::pes::SourcePos>,
/// True when one or more packets for this stream were lost before this PES —
/// a continuity break (CC gap or adaptation-field discontinuity_indicator) on
/// a tracked PID, or the CC-independent concealment marker the mux emits when
/// it replaces an undecryptable unit with NULL-TS packets (P3/A2). This PES is
/// the FIRST whose data is entirely after the gap: a mid-frame loss drops the
/// truncated partial and flags the next complete PES; a loss landing on a PES
/// boundary flags the PES STARTING after it (never the one just flushed). So
/// for inter-coded video this PES — and every later frame up to the next
/// IRAP/IDR — may reference data that is now gone. The codec-parse consumer
/// (via the per-frame `Frame::discontinuity` its parser propagates) drops
/// forward to the next keyframe (B1) instead of emitting dangling references.
pub discontinuity: bool,
}
/// Per-PID PES reassembly state.
@@ -58,6 +74,12 @@ struct PesAssembler {
/// Stamped at PES start, emitted on the completed packet — provenance is
/// carried, never reconstructed downstream.
pes_source: Option<crate::pes::SourcePos>,
/// Sticky "a gap occurred on this PID" flag. Set by a CC gap, an explicit
/// discontinuity_indicator, or the concealment marker; consumed by the NEXT
/// PES this assembler completes — the first whose data is entirely post-gap —
/// then cleared. A gap detected on a PUSI sets it AFTER `start()` so it rides
/// the new PES, not the one just flushed. Drives B1 drop-to-keyframe.
pending_discontinuity: bool,
}
/// Initial capacity for a fresh PES buffer. Sized to cover the
@@ -92,6 +114,7 @@ impl PesAssembler {
header_remaining: 0,
last_cc: None,
pes_source: None,
pending_discontinuity: false,
}
}
@@ -105,12 +128,15 @@ impl PesAssembler {
source: Option<crate::pes::SourcePos>,
) -> Option<PesPacket> {
let completed = if self.active && !self.buffer.is_empty() {
let discontinuity = self.pending_discontinuity;
self.pending_discontinuity = false;
Some(PesPacket {
pid: self.pid,
pts: self.pts,
dts: self.dts,
data: std::mem::replace(&mut self.buffer, Vec::with_capacity(PES_BUFFER_INIT_CAP)),
source: self.pes_source,
discontinuity,
})
} else {
self.buffer.clear();
@@ -151,12 +177,15 @@ impl PesAssembler {
fn flush(&mut self) -> Option<PesPacket> {
if self.active && !self.buffer.is_empty() {
self.active = false;
let discontinuity = self.pending_discontinuity;
self.pending_discontinuity = false;
Some(PesPacket {
pid: self.pid,
pts: self.pts,
dts: self.dts,
data: std::mem::take(&mut self.buffer),
source: self.pes_source,
discontinuity,
})
} else {
None
@@ -335,6 +364,39 @@ impl TsDemuxer {
let pusi = ts[1] & 0x40 != 0; // Payload Unit Start Indicator
let adaptation = (ts[3] >> 4) & 0x03;
// P3/B1 CONCEALMENT MARKER. The decrypt layer fills an undecryptable
// aligned unit with NULL-TS packets (PID 0x1FFF) that carry an
// adaptation-field discontinuity_indicator (see `aacs::fill_null_ts_unit`).
// This is the authoritative loss signal — unlike a tracked PID's 4-bit
// continuity_counter it is CC-INDEPENDENT, so it survives a loss that is
// an exact multiple of 16 packets and a loss at the very start of a PID
// (no prior CC to diff against). The decrypt layer cannot know which
// elementary PID(s) the lost unit carried (the data was undecryptable),
// so force a pending discontinuity on EVERY tracked assembler: the next
// completed PES of each resyncs. Harmless for audio/subtitle (the codec
// gate is a no-op there) and at most one extra GOP on a video track that
// did not actually lose packets — bounded, and only on a degraded disc.
if pid == NULL_PID
&& (adaptation == 0x02 || adaptation == 0x03)
&& (ts[4] as usize) > 0
&& (ts[5] & 0x80) != 0
{
for a in &mut self.assemblers {
// A concealed unit may have dropped packets belonging to a PES
// currently open on any PID — so that partial is potentially
// TRUNCATED (a hole in the middle of its access unit). Drop it
// like a mid-PES continuity break, and flag pending so the NEXT
// completed PES (the first frame whose data is entirely post-gap)
// resyncs. Mirrors the non-PUSI cc_gap path, applied to every PID
// because the lost unit's PID(s) are unknowable (undecryptable).
a.buffer.clear();
a.active = false;
a.header_remaining = 0;
a.pending_discontinuity = true;
}
return;
}
let idx = if (pid as usize) < self.pid_index.len() {
self.pid_index[pid as usize]
} else {
@@ -380,8 +442,9 @@ impl TsDemuxer {
// — splicing the new payload would corrupt the elementary stream — so
// drop the partial and resync on the next PUSI.
let cc = ts[3] & 0x0f;
let discontinuity_flag =
(adaptation == 0x03 || adaptation == 0x02) && ts[4] > 0 && (ts[5] & 0x80) != 0;
// adaptation == 0x02 (AF only) already returned above, so only 0x03
// (AF + payload) can carry an adaptation field here.
let discontinuity_flag = adaptation == 0x03 && ts[4] > 0 && (ts[5] & 0x80) != 0;
// A gap is a CC that is neither the expected `(prev + 1) & 0xf` nor a
// duplicate `prev` (ISO 13818-1 permits a packet to repeat its CC; a
// duplicate is not a loss). Anything else means one or more packets for
@@ -391,25 +454,30 @@ impl TsDemuxer {
None => false,
};
asm.last_cc = Some(cc);
if !pusi && (discontinuity_flag || cc_gap) && asm.active {
tracing::trace!(
target: "mux",
pid = asm.pid,
"TS continuity break on non-PUSI continuation; dropping partial PES",
);
asm.buffer.clear();
asm.active = false;
asm.header_remaining = 0;
return;
}
// A continuity gap means packets for THIS PID were lost (a damaged source,
// or — for the conceal path — a loss that the CC-independent NULL-TS marker
// above did not already flag). The flag is sticky and rides to the FIRST
// post-gap PES so the codec consumer drops forward to the next keyframe
// (B1). Attribution differs by where the gap lands (see below).
let gap = discontinuity_flag || cc_gap;
if pusi {
// `header_len` is the FULL (uncapped) PES-header length:
// 0 = malformed (payload is not a PES start), else 6/9+N.
let (pts, dts, header_len) = parse_pes_header(payload);
// Flush the previous PES FIRST — a gap detected on this PUSI packet
// belongs to the PES STARTING now (its data begins after the lost
// packets), NOT the one just completing. So set `pending_discontinuity`
// AFTER start(): it rides the new PES to its own completion. (Setting
// it before would stamp the pre-gap frame; if that frame were a
// keyframe the gate would arm-then-disarm on it and admit the real
// post-gap inter frame with a dangling reference.)
if let Some(prev) = asm.start(pts, dts, source) {
completed.push(prev);
}
if gap {
asm.pending_discontinuity = true;
}
if header_len == 0 {
// PUSI packet whose payload is not a valid PES start. Do
// NOT push it — those bytes are not elementary-stream data
@@ -426,16 +494,37 @@ impl TsDemuxer {
// the following continuation packet(s).
asm.header_remaining = header_len - payload.len();
}
} else if asm.header_remaining > 0 {
// Continuation packet still inside a PES header that spanned
// the boundary — consume header bytes before any ES data.
let skip = asm.header_remaining.min(payload.len());
asm.header_remaining -= skip;
if skip < payload.len() {
asm.push(&payload[skip..]);
}
} else {
asm.push(payload);
// Non-PUSI continuation.
if gap {
// Mid-PES hole: the open partial has a gap, so splicing this
// payload would corrupt the ES. Flag pending (consumed at the
// NEXT completed PES — the first post-gap frame) and drop the
// open partial; resync on the next PUSI.
asm.pending_discontinuity = true;
if asm.active {
tracing::trace!(
target: "mux",
pid = asm.pid,
"TS continuity break on non-PUSI continuation; dropping partial PES",
);
asm.buffer.clear();
asm.active = false;
asm.header_remaining = 0;
return;
}
}
if asm.header_remaining > 0 {
// Continuation packet still inside a PES header that spanned
// the boundary — consume header bytes before any ES data.
let skip = asm.header_remaining.min(payload.len());
asm.header_remaining -= skip;
if skip < payload.len() {
asm.push(&payload[skip..]);
}
} else {
asm.push(payload);
}
}
}
@@ -916,6 +1005,128 @@ mod tests {
);
}
/// B1 plumbing: a continuity gap detected on a PUSI must stamp
/// `discontinuity = true` on the PES STARTING after the gap, NOT the one
/// flushed at the boundary — the post-gap PES is the one whose data begins
/// after the lost packets and references them. (Attribution fix: stamping the
/// pre-gap PES would, if it were a keyframe, arm-then-disarm the gate and let
/// the real post-gap inter frame through with a dangling reference.) A clean
/// in-sequence PES carries `discontinuity = false`.
#[test]
fn continuity_gap_stamps_discontinuity_on_next_pes() {
let pid = 0x1011;
let mut demux = TsDemuxer::new(&[pid]);
// Open A (cc=0): nothing completed yet.
let out = demux.feed(&ts_payload_packet(pid, true, 0, &pes_start(b"AAAA")));
assert!(out.is_empty());
// B's PUSI (cc=1, in sequence) flushes A. A saw no gap → clean.
let out = demux.feed(&ts_payload_packet(pid, true, 1, &pes_start(b"BBBB")));
assert_eq!(out.len(), 1, "A completes");
assert_eq!(&out[0].data[..4], b"AAAA");
assert!(
!out[0].discontinuity,
"in-sequence PES is not a discontinuity"
);
// C's PUSI jumps cc 1 -> 5: packets were lost between B and C. B (flushed
// here) is PRE-gap and stays clean — the gap belongs to C, which starts
// after the lost packets.
let out = demux.feed(&ts_payload_packet(pid, true, 5, &pes_start(b"CCCC")));
assert_eq!(out.len(), 1, "B completes");
assert_eq!(&out[0].data[..4], b"BBBB");
assert!(
!out[0].discontinuity,
"the pre-gap PES flushed at the boundary must NOT be flagged"
);
// C carries the discontinuity — it is the first post-gap PES.
let out = demux.flush();
assert_eq!(out.len(), 1);
assert_eq!(&out[0].data[..4], b"CCCC");
assert!(
out[0].discontinuity,
"the post-gap PES must be flagged so B1 resyncs at/after it"
);
}
/// One 192-byte BD source packet that is a B1 concealment marker: a PID-0x1FFF
/// null packet carrying the adaptation-field discontinuity_indicator (the byte
/// shape `fill_null_ts_unit` writes for every packet of a concealed unit).
fn null_marker_packet() -> Vec<u8> {
let mut pkt = vec![0u8; BD_SOURCE_PACKET_BYTES];
pkt[4] = SYNC_BYTE; // 0x47
pkt[5] = 0x1F; // PID 0x1FFF
pkt[6] = 0xFF;
pkt[7] = 0x20; // adaptation-field only
pkt[8] = 0xB7; // af_len 183
pkt[9] = 0x80; // discontinuity_indicator
for b in &mut pkt[10..] {
*b = 0xFF;
}
pkt
}
/// HOLE 3 (16-multiple CC blind spot) + the truncated-partial drop. A concealed
/// unit can drop an exact multiple of 16 packets on a PID, leaving its 4-bit
/// continuity_counter looking IN-SEQUENCE — so CC-based detection is blind. The
/// CC-INDEPENDENT marker flags the loss anyway, drops the (potentially
/// truncated) open PES, and stamps the first post-gap PES.
#[test]
fn conceal_marker_forces_discontinuity_with_in_sequence_cc() {
let pid = 0x1011;
let mut demux = TsDemuxer::new(&[pid]);
// A (cc=0) opens; B's PUSI (cc=1) flushes A clean.
demux.feed(&ts_payload_packet(pid, true, 0, &pes_start(b"AAAA")));
let out = demux.feed(&ts_payload_packet(pid, true, 1, &pes_start(b"BBBB")));
assert_eq!(&out[0].data[..4], b"AAAA");
assert!(!out[0].discontinuity);
// Concealment marker: a unit was dropped. B is open → potentially truncated
// → dropped. CC is NOT consulted.
assert!(
demux.feed(&null_marker_packet()).is_empty(),
"marker emits nothing itself"
);
// C (cc=2) is EXACTLY in-sequence after B's cc=1 — as if a multiple of 16
// packets were lost, so `cc_gap` is false. The marker is the only signal.
let out = demux.feed(&ts_payload_packet(pid, true, 2, &pes_start(b"CCCC")));
assert!(
out.is_empty(),
"the truncated open PES (B) is dropped, not emitted"
);
let out = demux.flush();
assert_eq!(out.len(), 1);
assert_eq!(&out[0].data[..4], b"CCCC");
assert!(
out[0].discontinuity,
"marker flags the post-gap PES despite in-sequence CC (16-aligned blind spot)"
);
}
/// HOLE 4 (leading loss). If the disc's very first unit is undecryptable the
/// first surviving packet has no predecessor CC (`last_cc == None`), so CC
/// detection is blind. The marker still flags the first PES.
#[test]
fn conceal_marker_at_stream_start_flags_first_pes() {
let pid = 0x1011;
let mut demux = TsDemuxer::new(&[pid]);
// Marker FIRST — no prior CC exists for this PID.
assert!(demux.feed(&null_marker_packet()).is_empty());
// The first real PES of the PID.
let out = demux.feed(&ts_payload_packet(pid, true, 0, &pes_start(b"AAAA")));
assert!(out.is_empty());
let out = demux.flush();
assert_eq!(out.len(), 1);
assert_eq!(&out[0].data[..4], b"AAAA");
assert!(
out[0].discontinuity,
"leading concealed loss flags the first PES (last_cc == None case)"
);
}
/// In-sequence continuation (cc 0 -> 1) must still splice normally — the
/// continuity check must not break the happy path.
#[test]
+3 -3
View File
@@ -1,7 +1,7 @@
//! Platform-specific filesystem / IO helpers.
//!
//! Drive unlock no longer lives here — it moved out behind the pluggable
//! [`crate::unlock::Unlocker`] seam. This module now carries only the
//! filesystem-type detection used by the writeback / sink paths.
//! Drive unlock no longer lives here — it moved out to the `freemkv-unlock`
//! crate (consumed via [`crate::unlock_bridge`]). This module now carries only
//! the filesystem-type detection used by the writeback / sink paths.
pub mod fs_type;
+56 -1
View File
@@ -35,6 +35,51 @@ pub enum PassKind {
Verify,
}
/// One located bad/not-yet-good range, annotated with the chapter and movie
/// time it falls in. This is the *rendered* drilldown a client draws: the LBA
/// and sector count place it on the disc map, while `chapter` and
/// `time_offset_secs` tell the user *what* is affected. Computed by the library
/// (which owns the mapfile and title) so no client ever re-derives it. If the
/// mapfile becomes a mapdb, this type and its producer change; clients don't.
#[derive(Debug, Clone)]
pub struct LocatedRange {
/// First sector (LBA) of the range.
pub lba: u64,
/// Length of the range in sectors.
pub count: u32,
/// Movie time this range spans, in milliseconds (range bytes ÷ title
/// bytes/sec). Used to sort the drilldown and size the "largest gap".
pub duration_ms: f64,
/// 1-based chapter the range falls in, if it lands inside the title.
pub chapter: Option<u32>,
/// Movie time offset (seconds) where the range begins, if in-title.
pub time_offset_secs: Option<f64>,
}
/// The fully-rendered "where is the damage" view for one progress sample: the
/// located drilldown plus the derived movie-time figures. A client maps this
/// straight to its UI — it never touches the mapfile. `Default` is the empty
/// (no-damage / not-applicable) view, used by phases that don't locate ranges
/// (verify, extract, mux-label).
#[derive(Debug, Clone, Default)]
pub struct LocatedProgress {
/// Located not-yet-good ranges, largest-movie-time first, capped (see
/// `truncated`).
pub ranges: Vec<LocatedRange>,
/// Total number of located ranges before the cap (so a client can say
/// "N sections").
pub num_ranges: u32,
/// How many ranges were dropped by the display cap (`ranges.len()` is the
/// kept count; this is the "+X more").
pub truncated: u32,
/// Main-feature movie time still at risk: duration of the not-yet-good
/// ranges that intersect the title extents, in milliseconds. `0` when all
/// damage is out-of-feature (menus/extras).
pub main_at_risk_ms: f64,
/// Movie time of the single largest range, in milliseconds.
pub largest_gap_ms: f64,
}
/// One progress sample from a pipeline phase.
///
/// `work_done / work_total` is the per-pass percentage — always 0..=100%
@@ -48,7 +93,12 @@ pub enum PassKind {
/// - `bytes_good_total` = good + slow + recovered sectors × 2048
/// - `bytes_unreadable_total` = bad sectors × 2048
/// - `bytes_pending_total` = 0 (verify processes sequentially, nothing pending)
#[derive(Debug, Clone, Copy)]
///
/// NOT `Copy`: `located` carries a `Vec`. Constructed once per (throttled)
/// emission and passed by reference to `Progress::report`, so this costs one
/// small heap alloc per UI tick — cheap, and it makes `PassProgress` the single
/// complete contract a client renders from.
#[derive(Debug, Clone)]
pub struct PassProgress {
pub kind: PassKind,
pub work_done: u64,
@@ -72,6 +122,11 @@ pub struct PassProgress {
pub main_title_duration_secs: Option<f64>,
/// Main title size in bytes (sum of extent sizes).
pub main_title_size_bytes: Option<u64>,
/// The fully-rendered "where is the damage" drilldown for this sample:
/// located ranges + at-risk movie time. Empty (`Default`) for phases that
/// don't locate ranges. A client renders the disc map + section list from
/// this and NEVER reads the mapfile itself.
pub located: LocatedProgress,
}
impl PassProgress {
+465 -14
View File
@@ -99,10 +99,11 @@ pub struct DecryptingSectorSource<S: SectorSource> {
unit_base: u32,
/// Cumulative bytes of scrambled AACS units that no key could decrypt.
/// `decrypt_sectors` restores those bytes to their original ciphertext (so a
/// clear nav-file is never corrupted), but for genuine encrypted content the
/// still-encrypted bytes are silently dropped by the downstream TS assembler
/// — real, unaccounted loss. Mux read paths share this counter into their
/// loss accounting (via [`decrypt_loss`]) so a partial AACS/CSS decrypt
/// clear nav-file is never corrupted). On the mux read path
/// (`tolerate_decrypt_loss`) such content is concealed as NULL-TS and tallied
/// here (not silently dropped); on the rip path the read fails loud for
/// re-read. Either way this counter is the loss signal: mux read paths fold it
/// into their accounting (via [`decrypt_loss`]) so a partial AACS/CSS decrypt
/// failure can't be reported as a perfect rip. Shared `Arc` so the highway's
/// producer thread and the consuming `Stream` see the same tally.
///
@@ -113,10 +114,13 @@ pub struct DecryptingSectorSource<S: SectorSource> {
/// [`with_key_fetch`](Self::with_key_fetch) by an application that wants
/// to ask its key source for a key when a unit fails to decrypt.
fetch: Option<KeyFetch>,
/// Latched once a fetch call returns no NEW key — further failures on this
/// decorator then skip the callback (the source has nothing more to offer, so
/// re-asking would only burn key-server requests).
fetch_spent: bool,
/// Fingerprints (hash over the unit ciphertext) of failing units a fetch
/// already returned NO new key for. A later failure re-asks the source only
/// for units NOT in this set — so on a multi-CPS disc the source is still
/// asked for the *second* CPS unit's key even after the first came back dry
/// (the old global latch blocked that), while the *same* failing unit is
/// never re-fetched (and the total is still bounded by `MAX_FETCH_CALLS`).
fetch_dry: std::collections::HashSet<u64>,
/// How many times the fetch closure has been invoked, capped at
/// [`MAX_FETCH_CALLS`].
fetch_calls: usize,
@@ -140,6 +144,20 @@ pub struct DecryptingSectorSource<S: SectorSource> {
/// Reused scratch buffer for verify-only decrypt checks — avoids a per-read
/// allocation on the sweep's hot path. Grown on demand, never shrunk.
scratch: Vec<u8>,
/// MUX loss-concealment switch (P3 / Edit-2). When `true`, a content unit
/// that genuinely won't decrypt is NOT a read failure: it is overwritten with
/// valid NULL TS packets ([`crate::aacs::fill_null_ts_unit`]), tallied into
/// [`decrypt_dropped`](Self::decrypt_dropped), logged loud with its LBA, and
/// the read returns `Ok` so the mux KEEPS GOING (it can never abort over an
/// undecryptable unit). This is the spec's "decrypt-verify is a RIP gate, not
/// a MUX gate": the rip path leaves this `false` (default) and fails loud via
/// [`DECRYPT_VERIFY_READ`] so its read-error recovery re-reads the disc; only
/// the mux read path opts in. Ciphertext is NEVER passed downstream either
/// way — fail-loud re-reads it, conceal replaces it with null packets.
///
/// Mutually meaningful only with `!verify_only` (the in-place decrypt path
/// the mux uses); a verify-only sweep keeps the rip's fail-loud contract.
tolerate_decrypt_loss: bool,
}
impl<S: SectorSource> DecryptingSectorSource<S> {
@@ -156,14 +174,25 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
unit_base: 0,
decrypt_dropped: Arc::new(AtomicU64::new(0)),
fetch: None,
fetch_spent: false,
fetch_dry: std::collections::HashSet::new(),
fetch_calls: 0,
verify_only: false,
content_ranges: None,
scratch: Vec::new(),
tolerate_decrypt_loss: false,
}
}
/// Opt into MUX loss-concealment: an undecryptable content unit is concealed
/// (filled with NULL TS packets), tallied, logged loud, and the read still
/// succeeds — the mux never aborts over it. See
/// [`tolerate_decrypt_loss`](Self::tolerate_decrypt_loss). The rip path must
/// NOT set this (it relies on fail-loud read-error recovery).
pub fn tolerate_decrypt_loss(mut self) -> Self {
self.tolerate_decrypt_loss = true;
self
}
/// Restrict decrypt/verify to the disc's encrypted-content extents
/// (sorted/merged `(start_lba, sector_count)` — see
/// [`Disc::encrypted_content_ranges`](crate::Disc::encrypted_content_ranges)).
@@ -289,6 +318,15 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
if samples.is_empty() {
return prev_dropped;
}
// Skip the call when EVERY failing unit here is one a prior fetch already
// came back empty for — re-asking the identical ciphertext only burns a
// key-server request. A unit we have NOT asked about yet (e.g. a second
// CPS unit on a multi-CPS disc) still gets its one chance, where the old
// global `fetch_spent` latch wrongly blocked it.
let fps: Vec<u64> = samples.iter().map(|s| Self::sample_fp(s)).collect();
if fps.iter().all(|fp| self.fetch_dry.contains(fp)) {
return prev_dropped;
}
// Ask the application's key source for keys that open this ciphertext.
self.fetch_calls += 1;
let fresh = match self.fetch.as_ref() {
@@ -307,8 +345,9 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
}
}
if added == 0 {
// Nothing new — stop asking for the rest of this decorator's life.
self.fetch_spent = true;
// Nothing new for THESE units — remember them so we don't re-ask the
// same ciphertext, but leave the door open for other units.
self.fetch_dry.extend(fps);
return prev_dropped;
}
// Retry now that the pool has grown; a unit that still won't decrypt is
@@ -317,6 +356,16 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
.unwrap_or(prev_dropped)
}
/// Stable per-run fingerprint of a failing unit's ciphertext, for the
/// `fetch_dry` set. `DefaultHasher` is fixed-seed, so equal samples map to
/// equal fingerprints within a process — all the dedup needs.
fn sample_fp(sample: &[u8]) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
sample.hash(&mut h);
h.finish()
}
/// Emit a bounded, structured diagnostic for each undecryptable unit in a
/// failed verify read. Called only on the failure (cold) path. On a fresh
/// rip `buf` holds the post-decrypt bytes straight off the drive, so the
@@ -421,6 +470,20 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
count: u16,
buf: &mut [u8],
recovery: bool,
) -> Result<usize> {
// Bulk path: no Force Unit Access (the cache IS the streaming
// throughput). FUA is a Pass-N recovery lever threaded through
// `read_sectors_fua`.
self.read_sectors_fua(lba, count, buf, recovery, false)
}
fn read_sectors_fua(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
fua: bool,
) -> Result<usize> {
// Defense-in-depth: AACS aligned units are 3 sectors (6144 bytes) and
// `decrypt_sectors` anchors units at buffer offset 0. A read that does
@@ -439,7 +502,9 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
return Err(crate::error::Error::DecryptFailed);
}
let read_t0 = std::time::Instant::now();
let n = self.inner.read_sectors(lba, count, buf, recovery)?;
let n = self
.inner
.read_sectors_fua(lba, count, buf, recovery, fua)?;
let read_ms = read_t0.elapsed().as_millis() as u64;
// Decrypt the bytes just read. Scheme-agnostic: `decrypt_sectors*`
// dispatches on the keys (None / CSS / AACS) and returns the count of
@@ -457,8 +522,7 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
let content = self.content_ranges.clone(); // cheap Arc bump; frees the &self borrow
let content_ref = content.as_deref();
// Whether a fresh-key fetch is still worth attempting on this decorator.
let fetch_viable =
!self.fetch_spent && self.fetch.is_some() && self.fetch_calls < MAX_FETCH_CALLS;
let fetch_viable = self.fetch.is_some() && self.fetch_calls < MAX_FETCH_CALLS;
// First decrypt, then the FRESH-KEY-ON-FAILURE retry (read → decrypt → on
// fail fetch a new key → retry → CACHE or fail). This runs in BOTH modes:
// * VERIFY-ONLY (multipass sweep): decrypt a reused SCRATCH copy so `buf`
@@ -511,6 +575,77 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
if dropped > 0 {
self.decrypt_dropped
.fetch_add(dropped as u64, Ordering::Relaxed);
// MUX CONCEALMENT (P3 / Edit-2): on the mux read path an undecryptable
// content unit is NOT a read failure — never abort the mux over it.
// Overwrite each still-scrambled in-content unit with valid NULL TS
// packets (A2: keeps the demuxer byte-synced; the lost video/audio PID
// packets surface as a CC gap the TS assembler already drops a partial
// PES on), tally it (done above), log it LOUD with the LBA, and return
// Ok so the stream keeps flowing. Verify-only (sweep) is excluded — the
// rip stays fail-loud. Ciphertext is never passed downstream: it is
// replaced by null packets, not emitted.
if self.tolerate_decrypt_loss && !self.verify_only {
let unit_len = crate::aacs::ALIGNED_UNIT_LEN;
let mut concealed = 0usize;
let mut first_lba = lba;
for (i, chunk) in buf[..n].chunks_mut(unit_len).enumerate() {
if chunk.len() < unit_len {
continue; // trailing partial can't be a whole scrambled unit
}
// Conceal ONLY a unit that is GENUINELY still ciphertext, using
// the PADDING-AWARE test that matches `decrypt_unit`'s success
// criterion — NOT the majority-vote `aacs_unit_needs_decrypt`.
// A successfully padding-aware-decrypted content-fragment TAIL
// (the 1.2.0 fix: a few real packets + source-zero padding) has
// <16 TS syncs, so the majority vote would mis-flag it as
// "needs decrypt" and overwrite GOOD video with NULL-TS. The
// padding-aware predicate excludes zero-payload (padding) packets
// and flags the unit only when a real content packet is still
// un-restored ciphertext. In-content gating already happened in
// `decrypt_buf`, which restored only failed units to ciphertext;
// clear nav and decrypted tails pass through clean.
if crate::aacs::aacs_unit_still_ciphertext(chunk) {
if concealed == 0 {
first_lba = lba + (i as u32) * crate::aacs::ALIGNED_UNIT_SECTORS;
}
crate::aacs::fill_null_ts_unit(chunk);
concealed += 1;
}
}
if concealed > 0 {
tracing::warn!(
target: "freemkv::decrypt",
lba = first_lba,
units = concealed,
bytes = dropped,
"mux: undecryptable content concealed as NULL TS (loss tallied)"
);
} else {
// dropped > 0 (decrypt reported undecryptable bytes) yet the
// padding-aware predicate matched NOTHING to conceal — a
// contradiction: the only way a genuinely-ciphertext unit passes
// `aacs_unit_still_ciphertext` is if every one of its non-zero
// packets coincidentally carried a 0x47 (~256^-31). Belt-and-
// suspenders so ciphertext can NEVER reach the mux: fall back to
// the strict majority-vote predicate and conceal whatever it
// flags, loudly. Cryptographically unreachable in practice.
let mut forced = 0usize;
for chunk in buf[..n].chunks_mut(unit_len) {
if chunk.len() == unit_len && crate::aacs::aacs_unit_needs_decrypt(chunk) {
crate::aacs::fill_null_ts_unit(chunk);
forced += 1;
}
}
tracing::warn!(
target: "freemkv::decrypt",
lba,
bytes = dropped,
forced,
"mux: decrypt reported loss but padding-aware conceal matched nothing; forced strict conceal (unexpected)"
);
}
return Ok(n);
}
// DECRYPT_VERIFY_READ: a unit that SHOULD have decrypted but didn't
// means this read did NOT truly succeed — it returned ciphertext the
// TS assembler would silently drop. Fail the read loud so the caller's
@@ -1244,6 +1379,241 @@ mod tests {
);
}
/// MUX CONCEALMENT (P3): with `tolerate_decrypt_loss()` an undecryptable AACS
/// content unit must NOT fail the read. Instead the decorator (a) tallies the
/// loss, (b) overwrites the unit with valid NULL TS packets (PID 0x1FFF, sync
/// 0x47 at the BD-TS stride), and (c) returns `Ok` so the mux keeps going.
/// This is the inverse of the fail-loud rip path proven directly above.
#[test]
fn tolerate_decrypt_loss_conceals_undecryptable_unit_as_null_ts() {
let real_key = [0x33u8; 16];
let wrong_key = [0x44u8; 16];
// One unit encrypted under real_key, plus one trailing CLEAR (TS-sync)
// unit so we can confirm conceal touches ONLY the undecryptable unit.
let enc = encrypt_aacs_unit(&real_key);
let mut clear = vec![0u8; crate::aacs::ALIGNED_UNIT_LEN];
let mut o = 4;
while o < clear.len() {
clear[o] = 0x47;
o += 192;
}
let mut two_units = enc;
two_units.extend_from_slice(&clear);
struct TwoUnitSource {
data: Vec<u8>,
}
impl SectorSource for TwoUnitSource {
fn capacity_sectors(&self) -> u32 {
(self.data.len() / 2048) as u32
}
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
buf[..bytes].copy_from_slice(&self.data[..bytes]);
Ok(bytes)
}
}
let mut wrapped = DecryptingSectorSource::new(
TwoUnitSource { data: two_units },
DecryptKeys::Aacs {
unit_keys: vec![(0, wrong_key)], // can't open the encrypted unit
read_data_key: None,
},
)
.tolerate_decrypt_loss();
let loss = wrapped.decrypt_loss();
let mut buf = vec![0u8; 6 * 2048];
// Must SUCCEED (no DecryptFailed) — the mux never aborts on bad decrypt.
let n = wrapped
.read_sectors(0, 6, &mut buf, false)
.expect("tolerate_decrypt_loss must conceal, not error");
assert_eq!(n, 6 * 2048);
// The undecryptable unit is tallied as loss.
assert_eq!(
loss.load(Ordering::Relaxed),
crate::aacs::ALIGNED_UNIT_LEN as u64,
"the concealed unit is still counted as loss"
);
// Unit 0 is now valid NULL TS packets — sync 0x47 at every 192-byte
// stride (offset 4), PID 0x1FFF — and carries no ciphertext.
let unit0 = &buf[..crate::aacs::ALIGNED_UNIT_LEN];
let mut off = 0;
while off + 192 <= unit0.len() {
assert_eq!(unit0[off + 4], 0x47, "null packet sync at {off}");
assert_eq!(unit0[off + 5] & 0x1F, 0x1F, "PID high bits 0x1FFF");
assert_eq!(unit0[off + 6], 0xFF, "PID low byte 0xFF");
off += 192;
}
assert!(
!crate::aacs::ts_sync_destroyed(unit0),
"concealed unit reads as well-formed TS, not scrambled"
);
// Unit 1 (clear) passed through untouched.
let unit1 = &buf[crate::aacs::ALIGNED_UNIT_LEN..2 * crate::aacs::ALIGNED_UNIT_LEN];
assert_eq!(unit1, &clear[..], "the clear unit is left exactly as read");
}
/// REGRESSION (silent-data-loss): the conceal loop must NOT overwrite a
/// SUCCESSFULLY-decrypted content-fragment TAIL unit. Such a tail (a few real
/// content packets + source-zero padding — the 1.2.0 shape) carries <16 TS
/// syncs after decrypt, so the old majority-vote `aacs_unit_needs_decrypt`
/// predicate mis-flagged it as "still needs decrypt" and, when it shared a read
/// buffer with a genuinely-undecryptable unit (`dropped > 0`), NULL-TS-filled
/// the GOOD decrypted video. The padding-aware `aacs_unit_still_ciphertext`
/// predicate must conceal ONLY the genuinely-undecryptable unit and leave the
/// good tail byte-for-byte intact.
#[test]
fn conceal_leaves_decrypted_padding_tail_unit_intact() {
let bad_key = [0x77u8; 16]; // encrypts the undecryptable unit (NOT provided)
let good_key = [0x33u8; 16]; // encrypts the padding-tail unit (provided)
// Unit A: a full content unit encrypted under `bad_key` — with only
// `good_key` in the pool it cannot be decrypted → restored to ciphertext.
let bad_unit = encrypt_aacs_unit(&bad_key);
// Unit B: a SHORT-PADDING-TAIL unit — encrypt a full clear unit under
// `good_key`, then zero the trailing source packets (from packet 11 on) so
// they decrypt back to clean zero padding. Only 11 of 32 packets are real
// content → 11 TS syncs after decrypt (well under the majority-vote 16).
const KEEP: usize = 11;
let mut good_tail = encrypt_aacs_unit(&good_key);
for b in good_tail[KEEP * 192..].iter_mut() {
*b = 0;
}
// The byte-exact expected post-decrypt form of unit B (independent decrypt).
let mut expected_tail = good_tail.clone();
assert!(
crate::aacs::decrypt_unit(&mut expected_tail, &good_key),
"padding-tail must decrypt under good_key"
);
let mut two_units = bad_unit;
two_units.extend_from_slice(&good_tail);
struct TwoUnitSource {
data: Vec<u8>,
}
impl SectorSource for TwoUnitSource {
fn capacity_sectors(&self) -> u32 {
(self.data.len() / 2048) as u32
}
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
buf[..bytes].copy_from_slice(&self.data[..bytes]);
Ok(bytes)
}
}
let mut wrapped = DecryptingSectorSource::new(
TwoUnitSource { data: two_units },
DecryptKeys::Aacs {
unit_keys: vec![(0, good_key)], // opens unit B, NOT unit A
read_data_key: None,
},
)
.tolerate_decrypt_loss();
let loss = wrapped.decrypt_loss();
let mut buf = vec![0u8; 6 * 2048];
let n = wrapped
.read_sectors(0, 6, &mut buf, false)
.expect("tolerate_decrypt_loss must conceal, not error");
assert_eq!(n, 6 * 2048);
// ONLY the genuinely-undecryptable unit A is tallied / concealed.
assert_eq!(
loss.load(Ordering::Relaxed),
crate::aacs::ALIGNED_UNIT_LEN as u64,
"exactly one unit (the undecryptable one) is counted as loss"
);
// Unit A → NULL TS (concealed).
let unit0 = &buf[..crate::aacs::ALIGNED_UNIT_LEN];
let mut off = 0;
while off + 192 <= unit0.len() {
assert_eq!(unit0[off + 4], 0x47, "unit A null packet sync at {off}");
assert_eq!(
unit0[off + 6],
0xFF,
"unit A null packet PID low 0xFF at {off}"
);
off += 192;
}
// Unit B → the GOOD decrypted padding tail, byte-for-byte intact (NOT
// overwritten with NULL TS). This is the silent-data-loss the old
// majority-vote predicate caused.
let unit1 = &buf[crate::aacs::ALIGNED_UNIT_LEN..2 * crate::aacs::ALIGNED_UNIT_LEN];
assert_eq!(
unit1,
&expected_tail[..],
"the decrypted padding-tail unit must be left byte-for-byte intact"
);
// Sanity: its real content packets carry their TS sync; its padding is zero.
for p in 0..KEEP {
assert_eq!(unit1[p * 192 + 4], 0x47, "content pkt {p} sync preserved");
}
for p in KEEP..32 {
let o = p * 192;
assert!(
unit1[o..o + 192].iter().all(|&b| b == 0),
"padding pkt {p} stayed zero (not NULL-TS-filled)"
);
}
}
/// `fill_null_ts_unit` round-trip: every BD source packet in the unit becomes
/// a well-formed TS null packet (PID 0x1FFF, invisible to any real PID) that
/// carries the B1 adaptation-field discontinuity_indicator — the marker
/// `mux::ts` reads as a concealed gap.
#[test]
fn null_ts_fill_is_well_formed_and_invisible_to_real_pids() {
let mut unit = vec![0xAAu8; crate::aacs::ALIGNED_UNIT_LEN];
crate::aacs::fill_null_ts_unit(&mut unit);
// 32 packets, each: sync 0x47, PID 0x1FFF, adaptation-only (0b10) with a
// discontinuity_indicator in the adaptation field.
let mut off = 0;
let mut pkts = 0;
while off + 192 <= unit.len() {
assert_eq!(unit[off + 4], 0x47, "sync");
let pid = ((unit[off + 5] as u16 & 0x1F) << 8) | unit[off + 6] as u16;
assert_eq!(pid, 0x1FFF, "null PID");
assert_eq!(
(unit[off + 7] >> 4) & 0x03,
0x02,
"adaptation_field_control = AF only (no payload)"
);
assert!(unit[off + 8] > 0, "adaptation_field_length > 0");
assert_eq!(
unit[off + 9] & 0x80,
0x80,
"adaptation-field discontinuity_indicator set (the B1 marker)"
);
off += 192;
pkts += 1;
}
assert_eq!(pkts, 32, "32 source packets per aligned unit");
}
/// Fresh-key-on-failure: a unit encrypted under a key NOT in the initial set
/// would normally count as decrypt loss. With a [`with_key_fetch`] callback
/// that returns that key, the decorator must hand the still-scrambled unit to
@@ -1338,6 +1708,87 @@ mod tests {
);
}
/// A fetch that comes back EMPTY for one unit must NOT block a later fetch
/// for a DIFFERENT unit (the multi-CPS case). The old global `fetch_spent`
/// latch wrongly blocked it; the per-sample `fetch_dry` set must let unit B
/// be asked for after unit A came back dry.
#[test]
fn fetch_dry_does_not_block_a_distinct_later_unit() {
let key_a = [0x5au8; 16];
let key_b = [0x77u8; 16];
let unit_a = encrypt_aacs_unit(&key_a);
let unit_b = encrypt_aacs_unit(&key_b);
assert_ne!(unit_a, unit_b, "distinct ciphertext under distinct keys");
struct AltSource {
units: Vec<Vec<u8>>,
idx: usize,
}
impl SectorSource for AltSource {
fn capacity_sectors(&self) -> u32 {
6
}
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_r: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
let u = &self.units[self.idx.min(self.units.len() - 1)];
buf[..bytes].copy_from_slice(u);
self.idx += 1;
Ok(bytes)
}
}
// Callback serves key_b only when asked about unit B; nothing for A.
let unit_b_cb = unit_b.clone();
let calls = Arc::new(Mutex::new(0usize));
let calls_cb = Arc::clone(&calls);
let fetch: super::KeyFetch = std::sync::Arc::new(move |samples: &[Vec<u8>]| {
*calls_cb.lock().unwrap() += 1;
if samples.iter().any(|s| *s == unit_b_cb) {
vec![key_b]
} else {
vec![]
}
});
let mut wrapped = DecryptingSectorSource::new(
AltSource {
units: vec![unit_a, unit_b],
idx: 0,
},
DecryptKeys::Aacs {
unit_keys: vec![(0, [0x11u8; 16])], // neither real key held up front
read_data_key: None,
},
)
.with_key_fetch(fetch);
// Read A: fetch fires, returns nothing → A undecryptable (read errors).
let mut buf = vec![0u8; 3 * 2048];
let _ = wrapped.read_sectors(0, 3, &mut buf, false);
// Read B: fetch must STILL fire (B's sample isn't in the dry set) and
// recover key_b → B decrypts cleanly.
let mut buf2 = vec![0u8; 3 * 2048];
wrapped
.read_sectors(3, 3, &mut buf2, false)
.expect("unit B recovers via its own fetch");
assert_eq!(
*calls.lock().unwrap(),
2,
"fetch fired for BOTH units — the dry result for A did not latch off B"
);
assert!(
!crate::aacs::ts_sync_destroyed(&buf2),
"unit B is decrypted after its on-demand fetch"
);
}
/// `into_inner` / `inner` / `inner_mut` must hand back the original
/// source unchanged. Grounding: the accessor methods.
#[test]
+49
View File
@@ -53,6 +53,33 @@ pub trait SectorSource: Send {
recovery: bool,
) -> Result<usize>;
/// Like [`read_sectors`], but with an explicit Force Unit Access request.
///
/// `fua = true` asks the drive to bypass its readahead cache and physically
/// re-fetch the medium — a Pass-N marginal-sector lever: a cached hit can
/// mask a *stochastic* sector that would land differently off the platter on
/// each physical read, so FuaRetry re-reads it FUA. It is never blanket-
/// applied to the bulk path (forcing every sequential read past the cache
/// collapses streaming throughput ~10×).
///
/// The default ignores `fua` and delegates to [`read_sectors`]: only a live
/// [`Drive`] sets the CDB bit; file- / memory-backed sources have no drive
/// cache to bypass, so FUA is meaningless to them.
///
/// [`read_sectors`]: SectorSource::read_sectors
/// [`Drive`]: crate::drive::Drive
fn read_sectors_fua(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
fua: bool,
) -> Result<usize> {
let _ = fua;
self.read_sectors(lba, count, buf, recovery)
}
/// Optional speed control for sources that map to a physical
/// drive. No-op for everything else.
fn set_speed(&mut self, _kbs: u16) {}
@@ -87,6 +114,17 @@ impl SectorSource for Box<dyn SectorSource> {
(**self).read_sectors(lba, count, buf, recovery)
}
fn read_sectors_fua(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
fua: bool,
) -> Result<usize> {
(**self).read_sectors_fua(lba, count, buf, recovery, fua)
}
fn set_speed(&mut self, kbs: u16) {
(**self).set_speed(kbs)
}
@@ -107,6 +145,17 @@ impl SectorSource for &mut (dyn SectorSource + '_) {
(**self).read_sectors(lba, count, buf, recovery)
}
fn read_sectors_fua(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
fua: bool,
) -> Result<usize> {
(**self).read_sectors_fua(lba, count, buf, recovery, fua)
}
fn set_speed(&mut self, kbs: u16) {
(**self).set_speed(kbs)
}
+3 -2
View File
@@ -1771,8 +1771,9 @@ mod tests {
reader.put(50, [0xCC; 2048]);
let fs = fs_with(0, 0, root);
let (inf, _mkb) = crate::disc::Disc::read_aacs_inputs_from_reader(&mut reader, &fs)
.expect("read_aacs_inputs must succeed for a Long-AD disc");
let (inf, _mkb, _version) =
crate::disc::Disc::read_aacs_inputs_from_reader(&mut reader, &fs)
.expect("read_aacs_inputs must succeed for a Long-AD disc");
assert_eq!(
inf.len(),
4096,
-532
View File
@@ -1,532 +0,0 @@
//! Pluggable drive-unlock seam.
//!
//! libfreemkv knows the *seam*, never the *mechanism*. An [`Unlocker`] is
//! supplied by an external crate (e.g. `freemkv-unlock-ld`) and registered
//! once at process start via [`register_unlocker`]. At drive-prep the
//! registry is walked in registration order; the first unlocker whose
//! [`Unlocker::matches`] returns true is asked to [`Unlocker::unlock_drive`]
//! the drive by issuing its own CDBs through the raw [`ScsiTransport`].
//!
//! No firmware blobs, no unlock CDBs, no drive profiles live here — only
//! the trait, the registry, and the routing. If no unlocker matches, the
//! drive is left untouched and the caller falls back to the standard
//! host-certificate AACS handshake (the "OEM route").
use crate::aacs::Vid;
use crate::error::Result;
use crate::identity::DriveId;
use crate::scsi::ScsiTransport;
use std::sync::RwLock;
/// Why an [`Unlocker::unlock`] attempt produced no Volume ID. Structured and
/// English-free — applications render it. `Scsi` wraps the numeric error code
/// from [`crate::error::Error::code`] (the `Error` itself is not `Clone`).
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum UnlockError {
/// This unlocker cannot put this drive's firmware into extended mode.
FirmwareNotUnlockable,
/// No usable (non-revoked) host certificate was available for the auth
/// attempt. `mkb` is the disc MKB generation when known.
NoUsableHostCert { mkb: Option<u32> },
/// Every available host cert was revoked on this drive's HRL. `mkb` is the
/// disc MKB generation when known.
CertRevoked { mkb: Option<u32> },
/// The drive rejected the auth handshake (non-revocation rejection / wedge).
HandshakeRejected,
/// Auth succeeded (or was skipped) but the Volume ID could not be read.
VidUnavailable,
/// A SCSI/transport error; carries the numeric [`crate::error::Error`] code.
Scsi(u16),
}
impl From<crate::error::Error> for UnlockError {
fn from(e: crate::error::Error) -> Self {
UnlockError::Scsi(e.code())
}
}
/// A pluggable drive-capability provider.
///
/// Unlockers are optional drive-capability providers. libfreemkv's AACS
/// layer is the always-present baseline; it uses an unlocker's capabilities
/// when one matches, and does the in-tree cert handshake (the
/// `AacsCertUnlocker` peer) when none do.
///
/// Implementors own everything about *how* a particular drive family is
/// driven: firmware upload, vendor CDBs, variant logic. libfreemkv only
/// hands over the raw SCSI transport and the drive identity.
pub trait Unlocker: Send + Sync {
/// Stable, language-neutral identifier for this unlocker (logged).
fn name(&self) -> &str;
/// True if this unlocker handles the given drive.
fn matches(&self, id: &DriveId) -> bool;
/// Put the drive into extended-access mode (firmware/bootloader/whatever
/// THIS unlocker needs) AND return the disc's Volume ID — folding the old
/// `unlock_drive()` + `read_volume_id()` into one step. A firmware unlocker
/// that cannot unlock returns [`UnlockError::FirmwareNotUnlockable`]; one
/// that unlocks but has no OEM VID path returns [`UnlockError::VidUnavailable`].
/// Either error makes libfreemkv fall through to the in-tree cert handshake.
fn unlock(
&self,
scsi: &mut dyn ScsiTransport,
id: &DriveId,
) -> std::result::Result<Vid, UnlockError>;
/// Raise the drive to its maximum read speed. Default: no-op.
fn set_max_read_speed(&self, _scsi: &mut dyn ScsiTransport, _id: &DriveId) -> Result<()> {
Ok(())
}
}
/// Process-wide ordered registry of unlockers.
static REGISTRY: RwLock<Vec<Box<dyn Unlocker>>> = RwLock::new(Vec::new());
/// Register an unlocker. Order is preserved; [`route_unlock`] tries each in
/// registration order and stops at the first whose `matches` is true.
///
/// Call once at process start (CLI / service `main`), before any rip. The
/// single `register_unlocker(...)` line is the entire plug — remove it (and
/// the unlocker crate) and libfreemkv still compiles and falls back to the
/// host-cert handshake.
pub fn register_unlocker(u: Box<dyn Unlocker>) {
if let Ok(mut reg) = REGISTRY.write() {
reg.push(u);
}
}
/// Walk the registry in order and run the first matching unlocker, returning
/// its name AND the Volume ID it produced.
///
/// Returns:
/// * `Ok(Some((name, vid)))` — a registered unlocker matched, put the drive
/// into extended mode, and returned the OEM Volume ID. The caller stashes
/// the VID for the handshake phase and need not run the cert handshake.
/// * `Ok(None)` — no unlocker matched, OR the matching unlocker reported a
/// *capability* failure ([`UnlockError::FirmwareNotUnlockable`],
/// [`UnlockError::VidUnavailable`], or a cert-auth outcome — all logged).
/// Either way the drive is usable in stock mode and the caller falls
/// through to the in-tree cert handshake. Folding a capability failure into
/// `Ok(None)` keeps drive `init()` infallible — a drive that simply isn't
/// firmware-unlockable must not fail init.
/// * `Err(_)` — the matching unlocker hit a genuine SCSI/transport fault
/// ([`UnlockError::Scsi`]). The bus is broken, not merely unsupported, so
/// this propagates and aborts init rather than silently falling through to
/// a cert handshake that would also fail.
pub(crate) fn route_unlock(
scsi: &mut dyn ScsiTransport,
id: &DriveId,
) -> Result<Option<(String, Vid)>> {
let reg = match REGISTRY.read() {
Ok(r) => r,
// A poisoned lock means a prior unlocker panicked; treat as
// "no unlocker available" so the cert fallback still runs.
Err(_) => return Ok(None),
};
for u in reg.iter() {
if u.matches(id) {
let name = u.name().to_string();
match u.unlock(scsi, id) {
Ok(vid) => return Ok(Some((name, vid))),
// A genuine SCSI/transport fault is not "this drive can't be
// unlocked" — the bus is broken. Propagate so init() aborts
// instead of falling through to a cert handshake that will
// also fail on the same dead transport. The numeric code from
// the originating error is logged; the returned error is the
// canonical transport-error variant.
Err(UnlockError::Scsi(code)) => {
tracing::error!(
target: "freemkv::unlock",
unlocker = %name,
code,
"unlocker hit a transport fault during unlock; aborting init"
);
return Err(crate::error::Error::ScsiError {
opcode: 0,
status: 0,
sense: None,
});
}
// A firmware unlocker that can't unlock / has no OEM VID:
// fall through to the cert handshake. Debug-only structured
// log (variant identifiers, no English prose).
Err(e) => {
tracing::warn!(
target: "freemkv::unlock",
unlocker = %name,
outcome = ?e,
"unlocker matched but produced no VID; falling through to cert handshake"
);
return Ok(None);
}
}
}
}
Ok(None)
}
/// Walk the registry in order and ask the first matching unlocker to raise
/// the drive to its maximum read speed.
///
/// Mirrors [`route_unlock`]'s resolution so the SAME identified unlocker
/// that unlocks the drive is the one asked to set speed. Returns:
/// * `Ok(())` — the matching unlocker set max speed, or no unlocker
/// matched (no-op), or the matching unlocker has no speed capability
/// (its default no-op).
/// * `Err(_)` — the matching unlocker's `set_max_read_speed` failed. The
/// caller treats this as non-fatal (log and continue): a slow drive
/// still rips.
pub(crate) fn unlocker_set_max_read_speed(
scsi: &mut dyn ScsiTransport,
id: &DriveId,
) -> Result<()> {
let reg = match REGISTRY.read() {
Ok(r) => r,
// Poisoned lock ⇒ treat as "no unlocker available" (no-op).
Err(_) => return Ok(()),
};
for u in reg.iter() {
if u.matches(id) {
return u.set_max_read_speed(scsi, id);
}
}
Ok(())
}
/// Number of registered unlockers — test/introspection helper.
#[doc(hidden)]
pub fn registered_count() -> usize {
REGISTRY.read().map(|r| r.len()).unwrap_or(0)
}
/// Name of the first registered unlocker that matches `id`, without
/// running it. Used for drive-info display ("is this drive supported?")
/// before any unlock has been attempted.
pub(crate) fn matching_name(id: &DriveId) -> Option<String> {
let reg = REGISTRY.read().ok()?;
reg.iter()
.find(|u| u.matches(id))
.map(|u| u.name().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
struct NoopTransport;
impl ScsiTransport for NoopTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
Ok(ScsiResult {
status: 0,
bytes_transferred: 0,
sense: [0u8; 32],
})
}
}
fn fake_id(vendor: &str) -> DriveId {
let mut inquiry = vec![0u8; 96];
let v = vendor.as_bytes();
inquiry[8..8 + v.len().min(8)].copy_from_slice(&v[..v.len().min(8)]);
DriveId::from_inquiry(&inquiry, "")
}
/// Fake unlocker that records whether its `unlock` ran, matches on vendor
/// id, and serves a Volume ID (`Some` → `Ok(Vid)`; `None` →
/// `Err(VidUnavailable)`, i.e. matched-but-no-OEM-VID → cert fallback) or
/// records a `set_max_read_speed` call.
struct FakeUnlocker {
want_vendor: String,
ran: Arc<AtomicBool>,
/// VID this unlocker returns: `Some(vid)` → `unlock` yields `Ok(Vid)`;
/// `None` → `unlock` yields `Err(UnlockError::VidUnavailable)` so
/// `route_unlock` falls through to the cert handshake.
vid: Option<[u8; 16]>,
/// When `Some(code)`, `unlock` yields `Err(UnlockError::Scsi(code))`
/// (a transport fault) instead of consulting `vid`, so `route_unlock`
/// propagates an error and aborts init.
scsi_err: Option<u16>,
/// Records whether set_max_read_speed was invoked.
speed_ran: Arc<AtomicBool>,
}
impl FakeUnlocker {
fn new(vendor: &str, ran: Arc<AtomicBool>) -> Self {
Self {
want_vendor: vendor.into(),
ran,
// Default: a successful unlock returning an all-zero VID.
vid: Some([0u8; 16]),
scsi_err: None,
speed_ran: Arc::new(AtomicBool::new(false)),
}
}
fn with_vid(mut self, vid: Option<[u8; 16]>) -> Self {
self.vid = vid;
self
}
fn with_scsi_err(mut self, code: u16) -> Self {
self.scsi_err = Some(code);
self
}
fn with_speed(mut self, speed_ran: Arc<AtomicBool>) -> Self {
self.speed_ran = speed_ran;
self
}
}
impl Unlocker for FakeUnlocker {
fn name(&self) -> &str {
"fake"
}
fn matches(&self, id: &DriveId) -> bool {
id.vendor_id.trim() == self.want_vendor
}
fn unlock(
&self,
_scsi: &mut dyn ScsiTransport,
_id: &DriveId,
) -> std::result::Result<Vid, UnlockError> {
self.ran.store(true, Ordering::SeqCst);
if let Some(code) = self.scsi_err {
return Err(UnlockError::Scsi(code));
}
match self.vid {
Some(v) => Ok(Vid(v)),
None => Err(UnlockError::VidUnavailable),
}
}
fn set_max_read_speed(&self, _scsi: &mut dyn ScsiTransport, _id: &DriveId) -> Result<()> {
self.speed_ran.store(true, Ordering::SeqCst);
Ok(())
}
}
/// `UnlockError` is `PartialEq` and a crate `Error` folds into
/// `Scsi(code)` — the conversion `?`-callers rely on, English-free.
#[test]
fn unlock_error_from_crate_error_carries_code() {
let e: UnlockError = crate::error::Error::AacsVidUnavailable.into();
assert_eq!(e, UnlockError::Scsi(crate::error::E_AACS_VID_UNAVAILABLE));
assert_ne!(
UnlockError::NoUsableHostCert { mkb: Some(1) },
UnlockError::NoUsableHostCert { mkb: Some(2) }
);
}
/// A registered, matching unlocker runs and returns its name + VID; a
/// non-matching identity leaves the registry untouched and routes to the
/// cert fallback (`None`).
///
/// Both assertions live in one test because the registry is process-wide
/// and tests share it — running them as one case keeps the ordering
/// deterministic regardless of test-harness threading.
#[test]
fn registry_routes_match_else_oem() {
let ran = Arc::new(AtomicBool::new(false));
register_unlocker(Box::new(FakeUnlocker::new("MATCHVND", ran.clone())));
// Matching identity → unlocker runs, returns its name + VID.
let mut scsi = NoopTransport;
let matched = route_unlock(&mut scsi, &fake_id("MATCHVND")).unwrap();
assert_eq!(
matched.as_ref().map(|(n, _)| n.as_str()),
Some("fake"),
"matching unlocker runs"
);
assert!(ran.load(Ordering::SeqCst), "unlock() was invoked");
// Non-matching identity → no unlocker runs, cert path (None).
ran.store(false, Ordering::SeqCst);
let none = route_unlock(&mut scsi, &fake_id("OTHERVND")).unwrap();
assert!(none.is_none(), "no match → cert fallback");
assert!(
!ran.load(Ordering::SeqCst),
"unlock() not invoked on no-match"
);
}
/// `route_unlock` returns the FIRST matching unlocker's VID. A matching
/// unlocker that yields `Ok(Vid)` returns that VID (OEM path — cert
/// handshake skipped). A matching unlocker whose `unlock` errors (no OEM
/// VID), or no match at all, yields `Ok(None)` (cert fallback).
///
/// Distinct vendor ids keep this independent of the other registry test
/// despite the process-wide shared registry.
#[test]
fn route_unlock_returns_vid_else_cert() {
let mut scsi = NoopTransport;
// Unlocker WITH an OEM VID. Vendor ids are exactly 8 chars: INQUIRY
// field [8..16] has no null padding to trim, so `matches` is exact.
let vid = [0x5Au8; 16];
register_unlocker(Box::new(
FakeUnlocker::new("VIDVNDOR", Arc::new(AtomicBool::new(false))).with_vid(Some(vid)),
));
// Matching identity → its VID is returned.
let got = route_unlock(&mut scsi, &fake_id("VIDVNDOR")).unwrap();
assert_eq!(
got.map(|(_, v)| v),
Some(Vid(vid)),
"matching unlocker's OEM VID is used"
);
// Unlocker that MATCHES but has NO OEM VID path (unlock → Err) → cert.
register_unlocker(Box::new(
FakeUnlocker::new("NOVIDVND", Arc::new(AtomicBool::new(false))).with_vid(None),
));
let got = route_unlock(&mut scsi, &fake_id("NOVIDVND")).unwrap();
assert!(
got.is_none(),
"unlocker without OEM VID falls through to cert"
);
// No matching unlocker → Ok(None), cert fallback.
let got = route_unlock(&mut scsi, &fake_id("UNKNWNVD")).unwrap();
assert!(got.is_none(), "no match → cert fallback");
}
/// A matching unlocker that hits a genuine transport fault
/// (`UnlockError::Scsi`) makes `route_unlock` PROPAGATE an `Err` rather
/// than fold to `Ok(None)`: a dead bus must abort init, not silently fall
/// through to a cert handshake that would also fail. Capability failures
/// (`VidUnavailable` etc.) still fold to `Ok(None)` — proven by the sibling
/// routing tests; this one pins the transport-fault exception.
#[test]
fn route_unlock_propagates_scsi_transport_fault() {
let mut scsi = NoopTransport;
register_unlocker(Box::new(
FakeUnlocker::new("SCSIVNDR", Arc::new(AtomicBool::new(false)))
.with_scsi_err(crate::error::E_SCSI_ERROR),
));
let got = route_unlock(&mut scsi, &fake_id("SCSIVNDR"));
assert!(
got.is_err(),
"a transport fault during unlock aborts init (propagates Err)"
);
assert_eq!(
got.unwrap_err().code(),
crate::error::E_SCSI_ERROR,
"propagated error is the canonical transport-error code"
);
}
/// `unlocker_set_max_read_speed` consults the FIRST matching unlocker's
/// `set_max_read_speed`. A matching unlocker is invoked; a non-match is a
/// safe no-op (nothing invoked, `Ok(())`).
///
/// Distinct vendor ids keep this independent of the other registry tests
/// despite the process-wide shared registry.
#[test]
fn unlocker_set_max_read_speed_routes_match_else_noop() {
let mut scsi = NoopTransport;
let speed_ran = Arc::new(AtomicBool::new(false));
register_unlocker(Box::new(
FakeUnlocker::new("SPEEDVND", Arc::new(AtomicBool::new(false)))
.with_speed(speed_ran.clone()),
));
// Matching identity → set_max_read_speed invoked.
unlocker_set_max_read_speed(&mut scsi, &fake_id("SPEEDVND")).unwrap();
assert!(
speed_ran.load(Ordering::SeqCst),
"set_max_read_speed() invoked on match"
);
// No matching unlocker → Ok(()), nothing invoked (safe no-op).
speed_ran.store(false, Ordering::SeqCst);
unlocker_set_max_read_speed(&mut scsi, &fake_id("NOSPEEDV")).unwrap();
assert!(
!speed_ran.load(Ordering::SeqCst),
"no match → safe no-op, nothing invoked"
);
}
/// `matching_name` reports the FIRST matching unlocker's name without
/// running it (drive-info "is this drive supported?" before any unlock),
/// and returns `None` for an unknown drive. `registered_count` counts the
/// registered unlockers — pinning the two introspection helpers the routing
/// tests never touch.
///
/// The registry is process-wide and other unlock tests register into it
/// concurrently, so the count is only asserted to be MONOTONIC across this
/// test's own registration (never an exact delta) — registering an unlocker
/// can only grow the count, never shrink it.
#[test]
fn matching_name_and_registered_count_introspection() {
let before = registered_count();
register_unlocker(Box::new(FakeUnlocker::new(
"NAMEVNDR",
Arc::new(AtomicBool::new(false)),
)));
// Registering an unlocker can only grow the count (other tests may also
// be registering concurrently, so this is a monotonic check, not a
// delta-of-exactly-one).
assert!(
registered_count() > before,
"registered_count grows after register_unlocker"
);
// A matching identity reports the unlocker's name — and `matches`
// is consulted WITHOUT running unlock_drive (introspection only).
assert_eq!(
matching_name(&fake_id("NAMEVNDR")).as_deref(),
Some("fake"),
"matching_name reports the supporting unlocker"
);
// An identity no registered unlocker matches → None (unsupported).
assert!(
matching_name(&fake_id("ZZNOMTCH")).is_none(),
"matching_name is None for an unsupported drive"
);
}
/// Registration order is preserved and the FIRST matching unlocker wins:
/// when two unlockers both match the same identity, `route_unlock` runs the
/// one registered earlier and never consults the later one. The routing
/// docs promise "registration order; stops at the first whose `matches` is
/// true" — this is the only test that registers two overlapping matchers to
/// prove the ordering rather than a single-match no-op.
#[test]
fn route_unlock_first_registered_match_wins() {
let mut scsi = NoopTransport;
// Two unlockers that BOTH match vendor "DUPEVNDR"; the first registered
// must be the one that runs.
let first_ran = Arc::new(AtomicBool::new(false));
let second_ran = Arc::new(AtomicBool::new(false));
register_unlocker(Box::new(FakeUnlocker::new("DUPEVNDR", first_ran.clone())));
register_unlocker(Box::new(FakeUnlocker::new("DUPEVNDR", second_ran.clone())));
let matched = route_unlock(&mut scsi, &fake_id("DUPEVNDR")).unwrap();
assert_eq!(
matched.as_ref().map(|(n, _)| n.as_str()),
Some("fake"),
"a match was routed"
);
assert!(
first_ran.load(Ordering::SeqCst),
"the FIRST-registered matching unlocker ran"
);
assert!(
!second_ran.load(Ordering::SeqCst),
"the later-registered unlocker was never consulted (first-match-wins)"
);
}
}
+215
View File
@@ -0,0 +1,215 @@
//! Bridges libfreemkv's drive layer to the `freemkv-unlock` crate: one generic
//! SCSI-transport adapter, identity/host-cert mapping, and the dispatch that
//! news up `all_unlockers()` and runs the first matching one. libfreemkv names
//! no individual unlocker — it only calls this bridge.
use freemkv_unlock as fu;
/// Map libfreemkv's drive identity to the unlock contract's `DriveId`.
fn to_fu_drive_id(drive_id: &crate::identity::DriveId) -> fu::DriveId {
fu::DriveId {
vendor_id: drive_id.vendor_id.clone(),
product_revision: drive_id.product_revision.clone(),
vendor_specific: drive_id.vendor_specific.clone(),
firmware_date: drive_id.firmware_date.clone(),
}
}
/// Name of the unlocker that claims this drive by identity (drive-info "is this
/// drive supported?" display), or `None`. A pure lookup — does NOT touch the
/// drive or unlock anything.
pub(crate) fn unlocker_name(drive_id: &crate::identity::DriveId) -> Option<&'static str> {
fu::unlocker_name(&to_fu_drive_id(drive_id))
}
/// Adapt libfreemkv's `ScsiTransport` to the unlock crate's transport contract.
struct ScsiAdapter<'a>(&'a mut dyn crate::scsi::ScsiTransport);
impl fu::scsi::ScsiTransport for ScsiAdapter<'_> {
fn execute(
&mut self,
cdb: &[u8],
dir: fu::scsi::DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> fu::scsi::Result<fu::scsi::ScsiResult> {
let d = match dir {
fu::scsi::DataDirection::None => crate::scsi::DataDirection::None,
fu::scsi::DataDirection::FromDevice => crate::scsi::DataDirection::FromDevice,
fu::scsi::DataDirection::ToDevice => crate::scsi::DataDirection::ToDevice,
};
match self.0.execute(cdb, d, data, timeout_ms) {
Ok(r) => Ok(fu::scsi::ScsiResult {
status: r.status,
bytes_transferred: r.bytes_transferred,
sense: r.sense,
}),
// libfreemkv's transport returns Err for ANY non-zero SCSI status —
// i.e. a normal drive CHECK CONDITION (ILLEGAL_REQUEST, etc.), NOT
// only a transport-layer fault. Preserve the real status AND the
// parsed sense across the seam: the AACS handshake's wedge guard
// bails on an ILLEGAL_REQUEST sense (so it stops hammering the drive),
// and its diagnosis distinguishes a cert rejection from a dead bus by
// the same status/sense. Collapsing everything to 0xFF/None defeated
// both. Reconstruct the 32-byte sense buffer at the offsets the
// unlock crate reads (sense_key@2 low-nibble, asc@12, ascq@13); a
// genuine transport fault (status 0xFF, no sense) maps through
// unchanged.
Err(e) => {
// A SCSI status (CHECK CONDITION or a 0xFF transport fault the
// drive layer already tagged) carries its real status + sense via
// extract_scsi_context. Any OTHER error variant is a non-SCSI
// transport/IO-layer fault — ioctl(SG_IO) == -1 (ENODEV/EIO on an
// unplugged bridge) or the fd is gone — i.e. a DEAD BUS, not a
// drive rejection; surface the transport-failure status so the
// unlock crate bails instead of hammering a wedged device.
// (Keying off `sense.is_none()` would be wrong: a CHECK CONDITION
// whose sense didn't parse is a rejection, not a transport fault.)
let (status, sense) = match &e {
crate::error::Error::ScsiError { .. }
| crate::error::Error::DiscRead { .. } => {
crate::drive::extract_scsi_context(&e)
}
_ => (crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE, None),
};
let sense_buf = sense.map(|s| {
let mut b = [0u8; 32];
b[2] = s.sense_key & 0x0F;
b[12] = s.asc;
b[13] = s.ascq;
b
});
Err(fu::scsi::ScsiError {
status,
sense: sense_buf,
})
}
}
}
}
/// Map libfreemkv's host certs (keysource-collected) to the unlock contract's.
pub(crate) fn map_host_certs(certs: &[crate::aacs::HostCert]) -> Vec<fu::HostCert> {
certs
.iter()
.map(|c| fu::HostCert {
private_key: c.private_key,
certificate: c.certificate.clone(),
private_key_v2: c.private_key_v2,
certificate_v2: c.certificate_v2.clone(),
})
.collect()
}
/// News up the unlockers, build the context for `kind`, and run the FIRST
/// matching one — returning its `Result` so the caller can both consume what it
/// learned (vid / bus_key / drive_unlocked) AND render the specific failure
/// (the AACS cert path maps the `UnlockError` to its outcome trace). `Err(
/// NotApplicable)` when nothing matched. `host_certs` are collected by the
/// caller — lazily, only for AACS; pass `&[]` for the drive-prep / CSS kinds.
pub(crate) fn run_unlockers(
scsi: &mut dyn crate::scsi::ScsiTransport,
drive_id: &crate::identity::DriveId,
kind: fu::DiscKind,
host_certs: &[fu::HostCert],
) -> std::result::Result<fu::Unlocked, fu::UnlockError> {
let id = to_fu_drive_id(drive_id);
let ctx = fu::UnlockCtx::new(&id, kind, host_certs);
let mut adapter = ScsiAdapter(scsi);
for u in fu::all_unlockers() {
if u.matches(&ctx) {
return u.unlock(&mut adapter, &ctx);
}
}
Err(fu::UnlockError::NotApplicable)
}
/// The names of every REGISTERED unlocker, in dispatch order. Registry-driven —
/// sourced from `all_unlockers()`, so adding/removing an unlocker updates every
/// report with no other change (no hardcoded names). The per-unlocker "did it
/// run this rip" outcome is computed by the caller, which has the disc + drive
/// runtime state this crate cannot see.
pub(crate) fn unlocker_names() -> Vec<&'static str> {
fu::all_unlockers().iter().map(|u| u.name()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use freemkv_unlock::scsi::ScsiTransport as _; // brings `execute` into scope
/// A fake libfreemkv transport whose `execute` always fails with a
/// freshly-built error (`crate::error::Error` isn't `Clone` — `io::Error`).
struct ErrTransport<F>(F);
impl<F: FnMut() -> crate::error::Error + Send> crate::scsi::ScsiTransport for ErrTransport<F> {
fn execute(
&mut self,
_cdb: &[u8],
_dir: crate::scsi::DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> std::result::Result<crate::scsi::ScsiResult, crate::error::Error> {
Err((self.0)())
}
}
fn adapt(e: impl FnMut() -> crate::error::Error + Send) -> fu::scsi::ScsiError {
let mut t = ErrTransport(e);
let mut adapter = ScsiAdapter(&mut t);
let mut buf = [0u8; 0];
adapter
.execute(&[0u8; 12], fu::scsi::DataDirection::None, &mut buf, 1_000)
.expect_err("error path")
}
/// A CHECK CONDITION carrying sense crosses the seam with status + parsed
/// sense intact, so the unlock crate's ILLEGAL_REQUEST wedge guard can fire.
#[test]
fn check_condition_preserves_status_and_sense() {
let err = adapt(|| crate::error::Error::ScsiError {
opcode: 0xA3,
status: 0x02,
sense: Some(crate::scsi::ScsiSense {
sense_key: 0x05,
asc: 0x24,
ascq: 0x00,
}),
});
assert_eq!(err.status, 0x02);
let sense = err.sense.expect("sense preserved");
assert!(fu::scsi::ScsiSense::from_buf(&sense).is_illegal_request());
}
/// A drive-tagged transport fault (status 0xFF) crosses unchanged.
#[test]
fn scsi_transport_fault_maps_unchanged() {
let err = adapt(|| crate::error::Error::ScsiError {
opcode: 0,
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
assert_eq!(err.status, 0xFF);
assert!(err.sense.is_none());
}
/// A non-SCSI IO fault (ioctl SG_IO == -1: ENODEV/EIO) is a dead bus —
/// surfaced as 0xFF so the unlock crate bails instead of hammering it.
#[test]
fn io_error_maps_to_transport_failure() {
let err = adapt(|| crate::error::Error::IoError {
source: std::io::Error::from(std::io::ErrorKind::NotConnected),
});
assert_eq!(err.status, crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE);
assert!(err.sense.is_none());
}
/// Device-gone (fd closed) likewise maps to the transport-failure status.
#[test]
fn device_not_found_maps_to_transport_failure() {
let err = adapt(|| crate::error::Error::DeviceNotFound {
path: "/dev/sg9".into(),
});
assert_eq!(err.status, 0xFF);
assert!(err.sense.is_none());
}
}
-888
View File
@@ -1,888 +0,0 @@
//! Disc sector verification — read every sector and classify health.
use crate::disc::{Chapter, DiscTitle};
use crate::progress::{PassProgress, Progress};
use crate::sector::SectorSource;
use std::time::{Duration, Instant};
/// Pause before the single retry attempt on a failed sector, letting a
/// drive that briefly went NOT READY spin back up. Slept in
/// [`RETRY_POLL_INTERVAL`] increments so a cancel request (the progress
/// callback returning `false`) is observed within that interval rather
/// than after the full pause.
const RETRY_PAUSE: Duration = Duration::from_secs(2);
/// Polling cadence while sleeping out [`RETRY_PAUSE`].
const RETRY_POLL_INTERVAL: Duration = Duration::from_millis(250);
/// Sleep up to `total`, returning early with `false` if `cancelled`
/// observes a stop request mid-pause. Returns `true` if the full pause
/// elapsed without cancellation.
fn cancellable_pause(total: Duration, cancelled: &mut dyn FnMut() -> bool) -> bool {
let deadline = Instant::now() + total;
loop {
if cancelled() {
return false;
}
let now = Instant::now();
if now >= deadline {
return true;
}
std::thread::sleep(RETRY_POLL_INTERVAL.min(deadline - now));
}
}
/// Health status of a single sector read.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SectorStatus {
/// Read succeeded on first attempt, under 500ms
Good,
/// Read succeeded but took longer than expected (>500ms per sector)
Slow,
/// Read failed initially but succeeded on retry
Recovered,
/// Read failed all attempts — unrecoverable
Bad,
}
/// A contiguous range of bad or slow sectors.
#[derive(Debug, Clone)]
pub struct SectorRange {
pub start_lba: u32,
pub count: u32,
pub status: SectorStatus,
/// Byte offset from start of title
pub byte_offset: u64,
}
/// Result of a disc verification scan.
#[derive(Debug, Clone)]
pub struct VerifyResult {
pub total_sectors: u64,
pub good: u64,
pub slow: u64,
pub recovered: u64,
pub bad: u64,
/// Contiguous ranges of non-good sectors
pub ranges: Vec<SectorRange>,
pub elapsed_secs: f64,
}
impl VerifyResult {
/// Percentage of sectors that are fully readable (good + slow + recovered).
///
/// Returns `100.0` when there are no sectors. The subtraction is
/// saturating so a caller-constructed `VerifyResult` with `bad` greater
/// than `total_sectors` cannot wrap in release builds (it pins at 0%).
pub fn readable_pct(&self) -> f64 {
if self.total_sectors == 0 {
return 100.0;
}
self.total_sectors.saturating_sub(self.bad) as f64 / self.total_sectors as f64 * 100.0
}
/// True if every sector read first-attempt clean — no bad, no
/// recovered-on-retry, and no slow sectors. A `Recovered` or `Slow`
/// sector means data was eventually returned but the surface is
/// degraded, so `is_perfect()` is intentionally stricter than
/// "fully readable" (which [`readable_pct`](Self::readable_pct)
/// reports by counting recovered + slow as readable).
pub fn is_perfect(&self) -> bool {
self.bad == 0 && self.recovered == 0 && self.slow == 0
}
/// Map a bad sector range to a chapter timestamp.
pub fn chapter_at_offset(
chapters: &[Chapter],
byte_offset: u64,
duration_secs: f64,
total_bytes: u64,
) -> Option<(usize, f64)> {
if total_bytes == 0 || chapters.is_empty() {
return None;
}
let time_secs = byte_offset as f64 / total_bytes as f64 * duration_secs;
let mut chapter_idx = 0;
for (i, ch) in chapters.iter().enumerate() {
if ch.time_secs <= time_secs {
chapter_idx = i;
} else {
break;
}
}
Some((chapter_idx + 1, time_secs))
}
}
/// Verify all sectors in a title's extents.
///
/// Reads in batches for speed, falling back to single-sector reads on a
/// batch failure. Each sector that fails its single read gets one retry
/// after a [`RETRY_PAUSE`] cool-down (cancellable — see below).
///
/// `batch_sectors` is the read granularity in 2048-byte sectors; a value
/// of `0` is treated as `1` (a `0` batch would make no forward progress).
///
/// `on_progress` is both the progress sink and the cancellation channel:
/// returning `false` from [`Progress::report`] requests an early stop. The
/// stop is honoured between sectors *and* mid-retry-pause, so a verify of a
/// disc with many bad sectors stays responsive instead of blocking the full
/// pause per sector. Timing classification uses a 500 ms-per-sector
/// threshold to mark a successful-but-slow read as [`SectorStatus::Slow`].
pub fn verify_title(
reader: &mut dyn SectorSource,
title: &DiscTitle,
batch_sectors: u16,
on_progress: Option<&dyn Progress>,
) -> VerifyResult {
let start = Instant::now();
let mut good: u64 = 0;
let mut slow: u64 = 0;
let mut recovered: u64 = 0;
let mut bad: u64 = 0;
let mut ranges: Vec<SectorRange> = Vec::new();
let mut sectors_done: u64 = 0;
let mut byte_offset: u64 = 0;
// A zero batch size would never advance `offset` -> infinite loop.
// Clamp to at least one sector per read.
let batch_sectors = batch_sectors.max(1);
let total_sectors: u64 = title.extents.iter().map(|e| e.sector_count as u64).sum();
let mut buf = vec![0u8; batch_sectors as usize * 2048];
'outer: for ext in &title.extents {
let mut offset: u32 = 0;
while offset < ext.sector_count {
let remaining = ext.sector_count - offset;
let count = remaining.min(batch_sectors as u32) as u16;
// `start_lba + offset` stays within the extent's own LBA span by
// construction, but a crafted/corrupt extent can push the sum past
// u32::MAX; saturate rather than wrap (release) or panic (debug).
let lba = ext.start_lba.saturating_add(offset);
let bytes = count as usize * 2048;
let batch_start = Instant::now();
let batch_ok = reader
.read_sectors(lba, count, &mut buf[..bytes], false)
.is_ok();
let batch_ms = batch_start.elapsed().as_millis();
if batch_ok {
// Batch succeeded — classify by speed
let status = if batch_ms > count as u128 * 500 {
SectorStatus::Slow
} else {
SectorStatus::Good
};
match status {
SectorStatus::Good => good += count as u64,
SectorStatus::Slow => {
slow += count as u64;
// Merge with the previous range when contiguous and the
// same status, mirroring the per-sector path so a run of
// slow batches coalesces into one range instead of one
// entry per batch.
let merged = ranges.last_mut().is_some_and(|last| {
if last.status == SectorStatus::Slow
&& last.start_lba.saturating_add(last.count) == lba
{
last.count = last.count.saturating_add(count as u32);
true
} else {
false
}
});
if !merged {
ranges.push(SectorRange {
start_lba: lba,
count: count as u32,
status: SectorStatus::Slow,
byte_offset,
});
}
}
_ => {}
}
sectors_done += count as u64;
if let Some(cb) = on_progress {
let pp = crate::progress::PassProgress {
kind: crate::progress::PassKind::Verify,
work_done: sectors_done,
work_total: total_sectors,
bytes_good_total: (good + slow + recovered) * 2048,
bytes_unreadable_total: bad * 2048,
bytes_pending_total: 0,
bytes_retryable_total: 0,
bytes_total_disc: total_sectors * 2048,
disc_duration_secs: Some(title.duration_secs),
bytes_bad_in_main_title: 0,
main_title_duration_secs: Some(title.duration_secs),
main_title_size_bytes: Some(total_sectors * 2048),
};
if !cb.report(&pp) {
break 'outer;
}
}
} else {
// Batch failed — test each sector individually
for i in 0..count {
let sector_lba = lba.saturating_add(i as u32);
let sector_offset = i as usize * 2048;
let sector_byte_offset = byte_offset + i as u64 * 2048;
let s1 = Instant::now();
let first_ok = reader
.read_sectors(
sector_lba,
1,
&mut buf[sector_offset..sector_offset + 2048],
false,
)
.is_ok();
let s1_ms = s1.elapsed().as_millis();
let status = if first_ok && s1_ms <= 500 {
good += 1;
SectorStatus::Good
} else if first_ok {
slow += 1;
SectorStatus::Slow
} else {
// Retry once more after a brief cool-down. The pause is
// cancellable via the progress callback so a long run of
// bad sectors doesn't pin the thread for 2s each with no
// way to stop. If cancelled mid-pause, skip the retry and
// count the sector as bad, then bail out of the scan.
let cancelled_during_pause = !cancellable_pause(RETRY_PAUSE, &mut || {
on_progress.is_some_and(|cb| {
!cb.report(&PassProgress {
kind: crate::progress::PassKind::Verify,
work_done: sectors_done,
work_total: total_sectors,
bytes_good_total: (good + slow + recovered) * 2048,
bytes_unreadable_total: bad * 2048,
bytes_pending_total: 0,
bytes_retryable_total: 0,
bytes_total_disc: total_sectors * 2048,
disc_duration_secs: Some(title.duration_secs),
bytes_bad_in_main_title: 0,
main_title_duration_secs: Some(title.duration_secs),
main_title_size_bytes: Some(total_sectors * 2048),
})
})
});
if cancelled_during_pause {
bad += 1;
break 'outer;
}
if reader
.read_sectors(
sector_lba,
1,
&mut buf[sector_offset..sector_offset + 2048],
false,
)
.is_ok()
{
recovered += 1;
SectorStatus::Recovered
} else {
bad += 1;
SectorStatus::Bad
}
};
if status != SectorStatus::Good {
// Merge with previous range if contiguous and same status
if let Some(last) = ranges.last_mut() {
if last.status == status
&& last.start_lba.saturating_add(last.count) == sector_lba
{
last.count += 1;
} else {
ranges.push(SectorRange {
start_lba: sector_lba,
count: 1,
status,
byte_offset: sector_byte_offset,
});
}
} else {
ranges.push(SectorRange {
start_lba: sector_lba,
count: 1,
status,
byte_offset: sector_byte_offset,
});
}
}
sectors_done += 1;
if let Some(cb) = on_progress {
let pp = crate::progress::PassProgress {
kind: crate::progress::PassKind::Verify,
work_done: sectors_done,
work_total: total_sectors,
bytes_good_total: (good + slow + recovered) * 2048,
bytes_unreadable_total: bad * 2048,
bytes_pending_total: 0,
bytes_retryable_total: 0,
bytes_total_disc: total_sectors * 2048,
disc_duration_secs: Some(title.duration_secs),
bytes_bad_in_main_title: 0,
main_title_duration_secs: Some(title.duration_secs),
main_title_size_bytes: Some(total_sectors * 2048),
};
if !cb.report(&pp) {
break 'outer;
}
}
}
}
offset += count as u32;
byte_offset += bytes as u64;
}
}
VerifyResult {
total_sectors,
good,
slow,
recovered,
bad,
ranges,
elapsed_secs: start.elapsed().as_secs_f64(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::disc::{ContentFormat, DiscTitle, Extent};
use crate::error::Error;
/// Synthetic source: every read succeeds, filling the buffer with zeros.
struct AlwaysGood;
impl SectorSource for AlwaysGood {
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize, Error> {
let n = count as usize * 2048;
buf[..n].fill(0);
Ok(n)
}
}
/// Synthetic source: every read fails.
struct AlwaysBad;
impl SectorSource for AlwaysBad {
fn read_sectors(
&mut self,
_lba: u32,
_count: u16,
_buf: &mut [u8],
_recovery: bool,
) -> Result<usize, Error> {
Err(Error::ScsiError {
opcode: 0x28,
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
sense: None,
})
}
}
fn title_with_extent(start_lba: u32, sector_count: u32) -> DiscTitle {
DiscTitle {
playlist: String::new(),
playlist_id: 0,
duration_secs: 0.0,
size_bytes: 0,
clips: Vec::new(),
streams: Vec::new(),
chapters: Vec::new(),
extents: vec![Extent {
start_lba,
sector_count,
}],
content_format: ContentFormat::BdTs,
codec_privates: Vec::new(),
}
}
#[test]
fn batch_zero_does_not_hang() {
// A zero batch size must be clamped to 1 internally; otherwise the
// inner `while offset < sector_count` loop never advances. The
// assertion only matters because the test returns at all.
let title = title_with_extent(0, 4);
let mut src = AlwaysGood;
let r = verify_title(&mut src, &title, 0, None);
assert_eq!(r.total_sectors, 4);
assert_eq!(r.good, 4);
assert!(r.is_perfect());
}
#[test]
fn all_good_is_perfect() {
let title = title_with_extent(100, 10);
let mut src = AlwaysGood;
let r = verify_title(&mut src, &title, 4, None);
assert_eq!(r.good, 10);
assert_eq!(r.bad, 0);
assert!(r.ranges.is_empty());
assert_eq!(r.readable_pct(), 100.0);
}
#[test]
fn cancel_during_retry_pause_stops_promptly() {
// AlwaysBad forces the single-sector retry path, which pauses before
// its retry. The progress callback requests stop on its first call,
// so the pause must return early and the scan must bail out rather
// than sleeping RETRY_PAUSE * sector_count. A generous wall-clock
// bound (well under a single full pause) proves cancellation worked.
let title = title_with_extent(0, 8);
let mut src = AlwaysBad;
let cb = |_p: &PassProgress| false; // always request stop
let started = Instant::now();
let r = verify_title(&mut src, &title, 4, Some(&cb));
assert!(
started.elapsed() < RETRY_PAUSE,
"verify did not honour cancel during retry pause"
);
// Cancelled mid-first-bad-sector: exactly one sector counted bad.
assert_eq!(r.bad, 1);
}
#[test]
fn readable_pct_saturates_on_inconsistent_counts() {
let r = VerifyResult {
total_sectors: 10,
good: 0,
slow: 0,
recovered: 0,
bad: 9999, // impossible in practice; must not wrap
ranges: Vec::new(),
elapsed_secs: 0.0,
};
assert_eq!(r.readable_pct(), 0.0);
}
#[test]
fn high_lba_extent_does_not_overflow() {
// An extent whose start_lba + offset would exceed u32::MAX must
// saturate, not panic in debug builds.
let title = title_with_extent(u32::MAX - 1, 4);
let mut src = AlwaysGood;
let r = verify_title(&mut src, &title, 2, None);
assert_eq!(r.total_sectors, 4);
}
#[test]
fn no_progress_callback_still_completes_on_bad_sectors() {
// Without a callback there is no cancel signal, so each bad sector
// takes the full RETRY_PAUSE. Keep the extent tiny (1 sector) so the
// test stays fast while still exercising the bad+retry path.
let title = title_with_extent(0, 1);
let mut src = AlwaysBad;
let r = verify_title(&mut src, &title, 1, None);
assert_eq!(r.bad, 1);
assert_eq!(r.ranges.len(), 1);
assert_eq!(r.ranges[0].status, SectorStatus::Bad);
}
// ── New comprehensive tests ────────────────────────────────────────────────
/// A SectorSource that fails exactly `fail_count` calls, then succeeds.
struct FailFirst {
remaining_fails: usize,
}
impl SectorSource for FailFirst {
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize, Error> {
if self.remaining_fails > 0 {
self.remaining_fails -= 1;
Err(Error::ScsiError {
opcode: 0x28,
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
sense: None,
})
} else {
let n = count as usize * 2048;
buf[..n].fill(0);
Ok(n)
}
}
}
fn title_with_two_extents(s1: u32, c1: u32, s2: u32, c2: u32) -> DiscTitle {
DiscTitle {
playlist: String::new(),
playlist_id: 0,
duration_secs: 0.0,
size_bytes: 0,
clips: Vec::new(),
streams: Vec::new(),
chapters: Vec::new(),
extents: vec![
Extent {
start_lba: s1,
sector_count: c1,
},
Extent {
start_lba: s2,
sector_count: c2,
},
],
content_format: ContentFormat::BdTs,
codec_privates: Vec::new(),
}
}
/// readable_pct on a fully-good result is 100.0 (not NaN or < 100).
/// Mutation: changing the formula to `(total - bad - 1) / total` makes this fail.
#[test]
fn readable_pct_all_good_is_exactly_100() {
let r = VerifyResult {
total_sectors: 100,
good: 100,
slow: 0,
recovered: 0,
bad: 0,
ranges: Vec::new(),
elapsed_secs: 0.0,
};
assert_eq!(r.readable_pct(), 100.0);
}
/// readable_pct with zero total_sectors must return 100.0, not NaN/inf
/// from a division by zero.
/// Spec: the doc-comment explicitly states "Returns 100.0 when there are no sectors."
/// Mutation: removing the `if self.total_sectors == 0` guard makes this panic or NaN.
#[test]
fn readable_pct_empty_disc_is_100() {
let r = VerifyResult {
total_sectors: 0,
good: 0,
slow: 0,
recovered: 0,
bad: 0,
ranges: Vec::new(),
elapsed_secs: 0.0,
};
assert_eq!(r.readable_pct(), 100.0);
}
/// readable_pct counts slow and recovered as readable (not bad).
/// Spec: doc says "good + slow + recovered" are readable.
/// Mutation: omitting `slow` or `recovered` from the numerator reduces the result.
#[test]
fn readable_pct_counts_slow_and_recovered_as_readable() {
// 6 of 10 sectors are "lost" (bad); 4 are readable (slow + recovered).
let r = VerifyResult {
total_sectors: 10,
good: 0,
slow: 2,
recovered: 2,
bad: 6,
ranges: Vec::new(),
elapsed_secs: 0.0,
};
// saturating_sub(6) = 4; 4/10 = 40.0
let got = r.readable_pct();
assert!((got - 40.0).abs() < 1e-9, "expected 40.0, got {got}");
}
/// is_perfect() is false when there are slow sectors, even if bad == 0.
/// Spec: doc says "no bad, no recovered-on-retry, and no slow sectors".
/// Mutation: removing `self.slow == 0` from is_perfect() makes this pass when it should fail.
#[test]
fn is_perfect_false_when_slow_present() {
let r = VerifyResult {
total_sectors: 10,
good: 9,
slow: 1,
recovered: 0,
bad: 0,
ranges: Vec::new(),
elapsed_secs: 0.0,
};
assert!(!r.is_perfect());
}
/// is_perfect() is false when there are recovered sectors.
/// Mutation: removing `self.recovered == 0` from is_perfect() makes this fail.
#[test]
fn is_perfect_false_when_recovered_present() {
let r = VerifyResult {
total_sectors: 10,
good: 9,
slow: 0,
recovered: 1,
bad: 0,
ranges: Vec::new(),
elapsed_secs: 0.0,
};
assert!(!r.is_perfect());
}
/// is_perfect() is false when there are bad sectors.
/// Mutation: removing `self.bad == 0` from is_perfect() makes this fail.
#[test]
fn is_perfect_false_when_bad_present() {
let r = VerifyResult {
total_sectors: 5,
good: 4,
slow: 0,
recovered: 0,
bad: 1,
ranges: Vec::new(),
elapsed_secs: 0.0,
};
assert!(!r.is_perfect());
}
/// chapter_at_offset returns None when chapters is empty.
/// Mutation: removing the `chapters.is_empty()` check makes this return Some.
#[test]
fn chapter_at_offset_empty_chapters_returns_none() {
let result = VerifyResult::chapter_at_offset(&[], 1024, 120.0, 100_000);
assert!(result.is_none());
}
/// chapter_at_offset returns None when total_bytes == 0 (division-by-zero guard).
/// Mutation: removing the `total_bytes == 0` guard makes this panic or NaN.
#[test]
fn chapter_at_offset_zero_total_bytes_returns_none() {
use crate::disc::Chapter;
let chapters = vec![Chapter {
time_secs: 0.0,
name: String::new(),
}];
let result = VerifyResult::chapter_at_offset(&chapters, 1024, 120.0, 0);
assert!(result.is_none());
}
/// chapter_at_offset maps byte offset to the correct chapter index (1-based).
/// Spec: time_secs = byte_offset / total_bytes * duration; chapter = last chapter
/// whose time_secs <= computed time.
/// Mutation: off-by-one on chapter_idx (using `i` instead of `chapter_idx`) changes result.
#[test]
fn chapter_at_offset_selects_correct_chapter() {
use crate::disc::Chapter;
// 3 chapters at 0s, 30s, 60s; disc is 120s, 120,000 bytes.
let chapters = vec![
Chapter {
time_secs: 0.0,
name: String::new(),
},
Chapter {
time_secs: 30.0,
name: String::new(),
},
Chapter {
time_secs: 60.0,
name: String::new(),
},
];
let total_bytes: u64 = 120_000;
let duration = 120.0_f64;
// Byte 90,000 → time 90.0s → chapter 3 (index 2, 1-based = 3).
let (ch, t) =
VerifyResult::chapter_at_offset(&chapters, 90_000, duration, total_bytes).unwrap();
assert_eq!(ch, 3, "expected chapter 3, got {ch}");
assert!((t - 90.0).abs() < 1e-6, "expected t≈90.0, got {t}");
// Byte 0 → time 0s → chapter 1 (first chapter).
let (ch0, _) =
VerifyResult::chapter_at_offset(&chapters, 0, duration, total_bytes).unwrap();
assert_eq!(ch0, 1);
}
/// A contiguous sequence of bad sectors in one extent must be coalesced
/// into a single SectorRange (not one entry per sector).
/// Mutation: removing the range-merge logic produces many small ranges.
#[test]
fn contiguous_bad_sectors_coalesce_into_one_range() {
// 4-sector extent, all bad. Batch size 1 forces per-sector path.
// With cancel-on-second-bad we'd stop early; we must let all 4 sectors
// run to check coalescing. Use no callback so they all complete.
// But AlwaysBad + no callback is slow (RETRY_PAUSE per sector × 4).
// Limit to 2 sectors to keep wall-clock reasonable.
let title = title_with_extent(100, 2);
let mut src = AlwaysBad;
// no callback → full 2 × RETRY_PAUSE, but only 2 sectors
let r = verify_title(&mut src, &title, 1, None);
// Both sectors are bad and contiguous; they must merge into one range.
assert_eq!(r.bad, 2);
assert_eq!(
r.ranges.len(),
1,
"contiguous bad sectors must coalesce: got {} ranges",
r.ranges.len()
);
assert_eq!(r.ranges[0].start_lba, 100);
assert_eq!(r.ranges[0].count, 2);
assert_eq!(r.ranges[0].status, SectorStatus::Bad);
}
/// A batch that fails, then each individual sector succeeds on the first
/// per-sector read, is counted as Good (not Recovered).
/// Mutation: counting per-sector-first-ok as Recovered instead of Good flips this.
#[test]
fn batch_fail_then_per_sector_ok_counts_as_good() {
// First read fails (batch), then all per-sector reads succeed.
let mut src = FailFirst { remaining_fails: 1 };
let title = title_with_extent(0, 2);
let r = verify_title(&mut src, &title, 2, None);
// Both sectors should be Good (or Slow if the read was slow — but
// FailFirst returns instantly, so they should be Good).
assert_eq!(r.bad, 0);
assert_eq!(r.recovered, 0);
// Good ≥ 1: at least one sector was read clean on the per-sector path.
assert!(r.good >= 1, "expected some good sectors, got {}", r.good);
}
/// A recovered sector (batch fails, per-sector fails, retry succeeds)
/// appears in ranges with SectorStatus::Recovered.
/// This is exercised by FailFirst with remaining_fails=2: batch fails (1),
/// first per-sector attempt fails (2), retry succeeds (0 remaining).
/// Mutation: mapping recovered to Good removes the Recovered range entry.
#[test]
fn recovered_sector_appears_in_ranges_as_recovered() {
// Batch read of 1 sector: fail once (batch), fail once (per-sector),
// then succeed (retry). That yields Recovered status.
let mut src = FailFirst { remaining_fails: 2 };
let title = title_with_extent(0, 1);
// Provide a callback that never cancels so the retry pause can run.
// But we want the test to be fast — FailFirst returns immediately,
// so the cancel-during-pause path won't be hit. However, we still
// sleep RETRY_PAUSE here (2s). Use no-cancel callback.
let noop_cb = |_: &crate::progress::PassProgress| true;
let r = verify_title(&mut src, &title, 1, Some(&noop_cb));
assert_eq!(r.recovered, 1, "sector should be Recovered");
assert_eq!(r.bad, 0);
assert_eq!(r.ranges.len(), 1);
assert_eq!(r.ranges[0].status, SectorStatus::Recovered);
}
/// total_sectors sums across multiple extents correctly.
/// Mutation: only summing the first extent makes total wrong.
#[test]
fn total_sectors_sums_multiple_extents() {
let title = title_with_two_extents(0, 3, 100, 5);
let mut src = AlwaysGood;
let r = verify_title(&mut src, &title, 4, None);
assert_eq!(r.total_sectors, 8, "expected 3+5=8 sectors");
assert_eq!(r.good, 8);
}
/// SectorRange byte_offset is zero for the very first sector of the first extent.
/// Mutation: starting byte_offset at 2048 instead of 0 shifts all offsets.
#[test]
fn sector_range_byte_offset_starts_at_zero_for_first_sector() {
// AlwaysBad with 1 sector: the range for LBA 0 must have byte_offset=0.
let title = title_with_extent(0, 1);
let mut src = AlwaysBad;
let r = verify_title(&mut src, &title, 1, None);
assert_eq!(r.ranges.len(), 1);
assert_eq!(
r.ranges[0].byte_offset, 0,
"first sector byte_offset must be 0"
);
}
/// SectorRange byte_offset for the second sector is 2048.
/// Spec: sector size = 2048 bytes on Blu-ray (ISO 9660 / ECMA-119 §7).
/// Mutation: computing offset as `i * 2049` changes the second sector offset.
#[test]
fn sector_range_byte_offset_increments_by_2048() {
// Force the second sector to be bad: fail twice (first two reads
// attempt LBA 0 in batch, then individually). That is complex.
// Simpler: use a source that only fails on LBA 1.
struct BadOnLba1;
impl SectorSource for BadOnLba1 {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize, Error> {
if lba == 1 && count == 1 {
Err(Error::ScsiError {
opcode: 0x28,
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
sense: None,
})
} else if lba == 0 && count == 2 {
// Batch of 2 fails when it includes LBA 1 — simulate batch
// failure so we fall to per-sector.
Err(Error::ScsiError {
opcode: 0x28,
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
sense: None,
})
} else {
let n = count as usize * 2048;
buf[..n].fill(0);
Ok(n)
}
}
}
// Extent: LBA 0..1 (2 sectors), batch size 2 so we get a batch attempt first.
let title = title_with_extent(0, 2);
let mut src = BadOnLba1;
let noop_cb = |_: &crate::progress::PassProgress| true;
let r = verify_title(&mut src, &title, 2, Some(&noop_cb));
// We expect exactly one bad sector at LBA 1.
assert_eq!(r.bad, 1);
let bad_range = r
.ranges
.iter()
.find(|rng| rng.status == SectorStatus::Bad)
.unwrap();
// LBA 1 is the second sector → byte_offset = 1 * 2048 = 2048.
assert_eq!(
bad_range.byte_offset, 2048,
"second sector byte_offset must be 2048, got {}",
bad_range.byte_offset
);
}
/// cancellable_pause returns true when it completes the full duration
/// without cancellation.
/// Mutation: returning `false` unconditionally makes callers think they were cancelled.
#[test]
fn cancellable_pause_returns_true_when_not_cancelled() {
// Use a very short duration so the test runs fast.
let tiny = Duration::from_millis(10);
let result = cancellable_pause(tiny, &mut || false);
assert!(result, "not-cancelled pause must return true");
}
/// cancellable_pause returns false immediately when the callback requests cancel.
/// Mutation: ignoring the callback return and sleeping the full duration breaks this.
#[test]
fn cancellable_pause_returns_false_when_cancelled() {
let long = Duration::from_secs(60);
let started = Instant::now();
let result = cancellable_pause(long, &mut || true);
assert!(!result, "cancelled pause must return false");
// Must return well within the first poll interval, not after 60s.
assert!(started.elapsed() < Duration::from_secs(1));
}
}
+1
View File
@@ -275,6 +275,7 @@ fn patch_block_sectors_zero_does_not_busy_spin() {
progress: None,
halt: Some(halt.clone()),
key_fetch: None,
fast_capture: false,
};
let outcome = disc.patch(&mut reader, &iso_path, &opts);
+116 -26
View File
@@ -397,10 +397,14 @@ fn profile_02_all_medium_error() {
);
assert!(!pr.halted, "02_all_medium halted");
// Upper bound: every sector probed individually + a few batch-drop
// and skip-escalation attempts. 16 sectors × ~3 visits ≈ 50.
// and skip-escalation attempts, PLUS scatter-recovery on each hard
// single sector (up to SCATTER_MAX_ATTEMPTS fresh tries, each a
// recalibration read + a re-read = +6 reads/sector). Still strictly
// bounded — the guard exists to catch an UNBOUNDED retry loop, which
// would be in the hundreds.
assert!(
trace_len <= 80,
"02_all_medium trace_len={trace_len} exceeds 80"
trace_len <= 200,
"02_all_medium trace_len={trace_len} exceeds 200"
);
}
@@ -463,9 +467,13 @@ fn profile_03_alternating_good_bad() {
"03_alternating expected NonTrimmed remainder, got bytes_pending=0"
);
assert!(!pr.halted, "03_alternating halted");
// Efficiency guard (catches runaway, not the tier-2 roster). The 8
// permanently-bad sectors are now additionally probed by the tier-2
// marginal specialists (SlowSpin/FuaRetry/SlowFua/CachePrime/Oscillate/
// SpeedSweep) before being left NonTrimmed — bounded, finite extra reads.
assert!(
trace_len <= 120,
"03_alternating trace_len={trace_len} exceeds 120"
trace_len <= 220,
"03_alternating trace_len={trace_len} exceeds 220"
);
}
@@ -636,9 +644,13 @@ fn profile_06_deep_pit() {
"06_deep_pit bytes_pending expected > 0"
);
assert!(!pr.halted, "06_deep_pit halted");
// Bounded as in profile 02: the deep pit's hard single sectors each get
// scatter-recovery (up to SCATTER_MAX_ATTEMPTS recalibrate + re-read
// tries) on top of the baseline probe/skip walk. Still bounded — a
// runaway loop would be in the hundreds.
assert!(
trace_len <= 120,
"06_deep_pit trace_len={trace_len} exceeds 120"
trace_len <= 180,
"06_deep_pit trace_len={trace_len} exceeds 180"
);
}
@@ -691,31 +703,28 @@ fn profile_07_medium_then_good() {
trace,
);
// GOLDEN: patch's cache-priming (`prime_cache`) issues 3 throwaway
// single-sector reads at lba-3, lba-2, lba-1 (NOT lba itself) before
// each count==1 recovery read. In the default REVERSE patch pass,
// lba 108's prime reads lba 105 (consuming step 0 = fail) and lba 107's
// prime reads lba 105 again (consuming step 1 = fail), so when the real
// recovery read for lba 105 occurs its script step is 2 (= ok).
// prime_cache(105) itself reads 102, 103, 104 — it does NOT advance
// lba 105's own counter. Net effect: patch fully recovers the range in
// one pass thanks to priming, even though the script said "fails on
// first two attempts."
//
// This is the documented cache-prime behavior (`disc/patch.rs`
// ~line 398, "Proven 2026-05-07 with dd-as-oracle: 8/8 sectors
// recoverable when primed vs 6/8 cold"). The golden pins it.
// GOLDEN (handler-chain engine): with the script "fail, fail, ok" per bad
// sector, each bad sector needs three reads to recover. The chain re-reads a
// sector across successive handlers — linear reverse/forward (each narrows a
// failed batch to per-sector reads) then bisect — so every sector in the
// cluster is read enough times to consume its two failing steps and reach
// the Ok step WITHIN one pass. So all 256 sectors recover here; none defer.
// (The old batch-halving loop reached only 254; the chain is strictly
// better because more handlers re-touch each sector.)
assert_eq!(
stats.bytes_good,
capacity_sectors as u64 * 2048,
"07_medium_then_good bytes_good — cache-prime should consume \
the failing script steps so the real read sees Ok"
256 * 2048,
"07_medium_then_good bytes_good (handler chain re-reads each sector \
across handlers, consuming the fail,fail,ok script for the whole cluster)"
);
assert_eq!(
stats.bytes_unreadable, 0,
"07_medium_then_good bytes_unreadable"
"07_medium_then_good bytes_unreadable (NonTrimmed, never terminal in one pass)"
);
assert_eq!(
stats.bytes_pending, 0,
"07_medium_then_good bytes_pending (whole cluster recovered in one pass)"
);
assert_eq!(stats.bytes_pending, 0, "07_medium_then_good bytes_pending");
assert!(!pr.halted, "07_medium_then_good halted");
assert!(
trace_len <= 100,
@@ -810,3 +819,84 @@ fn profile_08_batch_fail_singles_ok() {
// If the unification ever proceeds, the next step is to add a clock
// injection point in `handle_read_failure` and extend this fixture
// with the wedge/NOT_READY profiles too.
// ──────── Handler chain recovers re-readable sectors inside a bad block ────────
//
// A bad range holds one genuinely-dead sector surrounded by readable ones. The
// handler chain's linear pass narrows a failed batch to per-sector reads, so it
// recovers EVERY re-readable sector and leaves ONLY the dead sector NonTrimmed —
// strictly better than the old fast-capture path, which left the whole failed
// 32-block untouched. (`fast_capture` is now inert: the chain supersedes it. The
// breadth-first "fast on all ranges, then escalate" ORDERING it once provided is
// a scheduling concern for the handler scheduler, tracked separately.)
//
// The load-bearing invariant is unchanged: NO data is dropped. A still-bad
// sector becomes NonTrimmed (pending, retried by a later pass), NEVER Unreadable.
#[test]
fn handler_chain_recovers_readable_sectors_leaving_only_dead_pending() {
let capacity_sectors: u32 = 256;
let (mut reader, trace) = ScriptedSectorReader::new(capacity_sectors);
// One bad sector at LBA 130 — inside the LOW 32-sector block of the range.
reader.always(
130,
ScriptStep::Err {
sense_key: 3,
asc: 0x11,
ascq: 0x05,
},
);
let total_bytes = capacity_sectors as u64 * SECTOR_SIZE as u64;
let disc = synthetic_disc(capacity_sectors);
let tmp = tempfile::NamedTempFile::new().unwrap();
let iso_path = tmp.path().to_path_buf();
drop(tmp);
// 64-sector NonTrimmed range [128,192); everything else already Finished.
let nontrimmed = [(128 * 2048, 64 * 2048)];
let finished = [
(0, 128 * 2048),
(192 * 2048, (capacity_sectors as u64 - 192) * 2048),
];
prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed);
let opts = libfreemkv::disc::PatchOptions {
decrypt: false,
block_sectors: Some(32),
full_recovery: true,
reverse: true,
wedged_threshold: 50,
progress: None,
halt: None,
key_fetch: None,
fast_capture: true,
};
disc.patch(&mut reader, &iso_path, &opts)
.expect("fast-capture patch must not error");
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
let stats = Mapfile::load(&map_path).unwrap().stats();
// The clean sectors of [128,192) all recover; only the one always-dead
// sector (LBA 130) stays NonTrimmed — NOT Unreadable. The chain narrows the
// failed batch to per-sector reads, so 63 of the 64 range sectors come back.
// Conservation: 255 good + 1 still-pending = the full 256, nothing lost.
assert_eq!(
stats.bytes_unreadable, 0,
"recovery must never mark Unreadable in a pass"
);
assert_eq!(
stats.bytes_pending, 2048,
"only the single always-dead sector (LBA 130) stays NonTrimmed"
);
assert_eq!(
stats.bytes_good,
255 * 2048,
"every sector except the one dead LBA is recovered"
);
let _ = trace; // read trace retained by the fixture; no ordering assertion here
let _ = std::fs::remove_file(&iso_path);
let _ = std::fs::remove_file(&map_path);
}