diff --git a/src/aacs/derive.rs b/src/aacs/derive.rs index 561bcff..7de4b1f 100644 --- a/src/aacs/derive.rs +++ b/src/aacs/derive.rs @@ -203,13 +203,14 @@ pub fn derive_media_key_and_pk_from_dk( let p_uv = &uvs[1 + 5 * uvs_idx..]; let u_mask_shift = uvs[5 * uvs_idx]; // byte before the UV value - if u_mask_shift & 0xC0 != 0 { - break; // device revoked - } - // Shifts of 32..=63 (0x20..=0x3F pass the 0xC0 mask above) would - // panic in debug / wrap to a wrong mask in release. The MKB byte - // is disc-controlled, so a crafted/corrupt MKB must not crash the - // ripper: skip an out-of-range slot rather than `<<` it. + // `num_uvs` was computed via `take_while(.. c[0] & 0xC0 == 0)`, so + // every iterated slot already has its revoked-marker bits clear — no + // inner `& 0xC0` re-check is needed (it would be unreachable). + // + // Shifts of 32..=63 (0x20..=0x3F) have those bits clear but would + // panic in debug / wrap to a wrong mask in release. The MKB byte is + // disc-controlled, so a crafted/corrupt MKB must not crash the ripper: + // skip an out-of-range slot rather than `<<` it. if u_mask_shift >= 32 { continue; } diff --git a/src/aacs/resolve.rs b/src/aacs/resolve.rs index ec02aed..42f9cc7 100644 --- a/src/aacs/resolve.rs +++ b/src/aacs/resolve.rs @@ -106,8 +106,12 @@ pub fn resolve_keys_with_reason( pub(crate) fn classify_resolve_failure(ctx: &ResolveContext<'_>) -> ResolveFailure { let has_vid = *ctx.volume_id != [0u8; 16]; let providers = super::provider::Providers(ctx.providers); - let has_derivation_material = - !providers.device_keys().is_empty() || !providers.processing_keys().is_empty(); + // Media keys are also derivation material: with an MK you can derive the VUK + // once you have the VID, so a media-keys-only provider that is merely missing + // the VID is VidUnavailable, not NoMaterial. + let has_derivation_material = !providers.device_keys().is_empty() + || !providers.processing_keys().is_empty() + || !providers.media_keys().is_empty(); if !has_vid && has_derivation_material { ResolveFailure::VidUnavailable } else { diff --git a/src/aacs/variant.rs b/src/aacs/variant.rs index 85d8d51..e743f02 100644 --- a/src/aacs/variant.rs +++ b/src/aacs/variant.rs @@ -201,14 +201,11 @@ pub fn walk_processing_key( // parse stops, no inner re-check needed. let u_mask_shift = uvs[5 * uvs_idx]; - if u_mask_shift & 0xC0 != 0 { - break; - } - // 0x20..=0x3F (32..=63) pass the 0xC0 revoked-marker check but are - // out of range for a u32 shift. `wrapping_shl` would silently - // compute shift % 32 (e.g. 32 → no shift → 0xFFFF_FFFF), matching a - // wrong uv slot and deriving a wrong key. Disc-controlled byte: - // skip the slot instead. + // 0x20..=0x3F (32..=63) have their revoked-marker bits clear (so they + // pass the take_while above) but are out of range for a u32 shift. + // `wrapping_shl` would silently compute shift % 32 (e.g. 32 → no shift + // → 0xFFFF_FFFF), matching a wrong uv slot and deriving a wrong key. + // Disc-controlled byte: skip the slot instead. if u_mask_shift >= 32 { continue; } diff --git a/src/disc/bluray.rs b/src/disc/bluray.rs index 9788e6b..2d25e94 100644 --- a/src/disc/bluray.rs +++ b/src/disc/bluray.rs @@ -85,13 +85,18 @@ impl Disc { for play_item in &parsed.play_items { let clip_dur = play_item.out_time.saturating_sub(play_item.in_time) as f64 / 45000.0; let mut pkt_count: u32 = 0; - let first_ref = seen_clips.insert(play_item.clip_id.clone()); let clpi_path = format!("/BDMV/CLIPINF/{}.clpi", play_item.clip_id); if let Ok(clpi_data) = udf_fs.read_file(reader, &clpi_path) { if let Ok(clip_info) = clpi::parse(&clpi_data) { pkt_count = clip_info.source_packet_count; + // Mark the clip seen ONLY after its .clpi parses — a transient + // read/parse failure on the first PlayItem referencing a clip + // must not permanently suppress its extents/size for a later + // PlayItem referencing the same clip that succeeds. + let first_ref = seen_clips.insert(play_item.clip_id.clone()); + // Only fetch/push the physical extents and add to the // total size the first time this clip_id is seen. if first_ref { diff --git a/src/disc/patch.rs b/src/disc/patch.rs index a7f2603..e53cfa9 100644 --- a/src/disc/patch.rs +++ b/src/disc/patch.rs @@ -473,9 +473,7 @@ pub(super) fn recovery_read( /// gets recorded NonTrimmed. Pure data structure — no I/O — so each phase /// helper is unit-testable by asserting the residual `SubRanges`. /// -/// Foundation for the phased `recover_section` orchestrator; not yet wired -/// into the live loop (see the deferral note in the #50 work). -#[cfg_attr(not(test), allow(dead_code))] +/// The residue tracker used by the phased `recover_section` orchestrator. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub(super) struct SubRanges { /// (pos, len) pairs, sorted by pos, non-overlapping, all non-zero len. @@ -1461,13 +1459,22 @@ impl Disc { if !bad.is_empty() { let n: usize = bad.len(); for (lba, cnt) in bad { - let _ = m.record( + if let Err(e) = m.record( lba as u64 * 2048, cnt as u64 * 2048, mapfile::SectorStatus::NonTrimmed, + ) { + tracing::warn!( + lba, + "reverify downgrade: mapfile record failed ({e}) — unit may stay mismarked as good" + ); + } + } + if let Err(e) = m.flush() { + tracing::warn!( + "reverify downgrade: mapfile flush failed ({e}) — downgrade not persisted; a resume could mismark it good" ); } - let _ = m.flush(); // The re-verify ran AFTER `pipe.finish()` snapshotted // `summary.stats`, so those stats still count the just- // downgraded units as good. Refresh from the mapfile so @@ -1547,14 +1554,13 @@ mod tests { assert!(on("true")); } - /// Transport failure (status=0xFF, USB-bridge crash) must be recognised by - /// the gate `handle_read_failure` now checks FIRST, so it aborts the pass - /// (wedged_exit + BreakOuter) instead of treating the bridge crash as an - /// ordinary bad sector and hammering the crashed device for up to the - /// per-range watchdog budget. `handle_read_failure` is not unit-testable in - /// isolation, so this guards the classification predicate the production - /// early-return keys off, and the contrast that an ordinary read error is - /// NOT misclassified as a transport failure. + /// Transport failure (status=0xFF, USB-bridge crash) must be recognised and + /// abort the pass, rather than being treated as an ordinary bad sector and + /// hammering the crashed device for up to the per-range watchdog budget. The + /// transport-failure classification predicate is not unit-testable in + /// isolation, so this guards the predicate the production early-return keys + /// off, and the contrast that an ordinary read error is NOT misclassified as + /// a transport failure. #[test] fn transport_failure_is_recognised_for_patch_abort() { use crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE; diff --git a/src/disc/read_error.rs b/src/disc/read_error.rs index 6584a17..4efcdd1 100644 --- a/src/disc/read_error.rs +++ b/src/disc/read_error.rs @@ -196,22 +196,10 @@ impl ReadCtx { /// threshold is loose so we don't bail too early on a range that /// has scattered good sectors mixed in. /// - /// `damage_threshold_pct = 6` mirrors `disc/patch.rs`'s - /// `PASSN_DAMAGE_THRESHOLD_PCT`. Pass N triggers the damage-skip - /// at half the density Pass 1 uses (Pass 1 = 12%) because the - /// patch loop's whole job is to chip away at bad ranges — being - /// more eager to skip clustered bad sectors converges faster on - /// the recoverable good sectors inside a range. The patch-side - /// `compute_damage_skip` reads its threshold directly from - /// `PASSN_DAMAGE_THRESHOLD_PCT`, which is an alias for this crate's - /// `PATCH_DAMAGE_THRESHOLD_PCT`, so the two are always in sync. - /// The patch loop's damage-skip is not yet unified with `handle_read_error`'s - /// jump path. (v0.20.8 unification attempt found the unification - /// itself blocked on the size-aware `range_remaining/4` cap that - /// lives in `compute_damage_skip` but not in - /// `handle_read_error::JumpAhead` — see - /// `tests/passn_handler_ab.rs` for the A/B fixture that pins - /// the divergence point.) + /// `damage_threshold_pct = 6` is looser than Pass 1 (12%): Pass N triggers + /// the damage-skip at half Pass 1 density because the patch loop exists to chip + /// away at bad ranges, so being more eager to skip clustered bad sectors + /// converges faster on the recoverable good sectors inside a range. pub fn for_patch(batch: u16) -> Self { Self { batch, @@ -443,8 +431,8 @@ const WEDGE_ABORT_THRESHOLD: u64 = 16; const WEDGE_PASS_N_SKIP_SECTORS: u64 = 64; /// Single source of truth for the Pass-N damage-window threshold. -/// Both [`ReadCtx::for_patch`] and `disc::patch::compute_damage_skip` -/// reference this constant so the two damage-skip paths cannot drift. +/// [`ReadCtx::for_patch`] reads this constant for the Pass-N damage-skip +/// threshold. /// /// 6% means: with a 16-entry sliding window, the damage-skip fires /// once 1 out of 16 recent reads has failed. Pass 1 uses a 12% diff --git a/src/io/pipeline.rs b/src/io/pipeline.rs index 1c2c7af..fa258d1 100644 --- a/src/io/pipeline.rs +++ b/src/io/pipeline.rs @@ -449,7 +449,10 @@ impl Pipeline { } else { // Benign per-item OK: trace-level (L4) only; the // apply-side rolling summary carries throughput. - tracing::trace!("Pipeline send: OK in {:.3}ms", elapsed.as_micros()); + tracing::trace!( + "Pipeline send: OK in {:.3}ms", + elapsed.as_secs_f64() * 1000.0 + ); } } Ok(()) @@ -464,7 +467,10 @@ impl Pipeline { std::any::type_name::() ); } else { - tracing::debug!("Pipeline send: failed after {:.3}ms", elapsed.as_micros()); + tracing::debug!( + "Pipeline send: failed after {:.3}ms", + elapsed.as_secs_f64() * 1000.0 + ); } } Err(e.0) diff --git a/src/keysource.rs b/src/keysource.rs index ab8f244..c6c03f8 100644 --- a/src/keysource.rs +++ b/src/keysource.rs @@ -77,7 +77,7 @@ pub trait ResolveCtx { fn mkb(&self) -> Result<&[u8], Error>; /// The disc's encrypted title keys, parsed from `Unit_Key_RO.inf` the same /// way the library's resolver parses them ([`crate::aacs::inf::parse_unit_key_ro`]), - /// in on-disc order. Feed straight into [`crate::aacs::boil::uk_from_vuk`]. + /// in on-disc order. Feed straight into [`crate::aacs::derive::decrypt_unit_key`]. fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error>; /// Up to `n` encrypted on-disc content sample units, for a source that /// validates a candidate server-side against real ciphertext. @@ -167,8 +167,8 @@ impl ResolveCtx for DiscInputsCtx<'_> { /// holds, orchestrates the derivation down to Unit Keys using the library's /// boil-down crypto primitives — never re-implementing AES. A source that holds /// pre-decrypted Unit Keys returns them directly; one that holds a VUK calls -/// [`crate::aacs::boil::uk_from_vuk`]; one that holds device keys calls -/// [`crate::aacs::boil::mk_from_dk`] → [`crate::aacs::boil::vuk_from_mk`] → `uk_from_vuk`. +/// [`crate::aacs::derive::decrypt_unit_key`]; one that holds device keys calls +/// [`crate::aacs::derive::derive_media_key_from_dk`] → [`crate::aacs::derive::derive_vuk`] → `decrypt_unit_key`. /// /// Returning an empty `Vec` means "no key for this disc from this source"; an /// `Err` means the source itself failed (I/O, parse, network). The caller @@ -222,7 +222,7 @@ pub fn resolve_and_apply( /// success — so a wrong/partial key set is rejected and the loop continues. /// /// CPS-unit numbering: a source returns Unit Keys carrying the POSITIONAL index -/// from [`crate::aacs::boil::uk_from_vuk`]; the library's canonical CPS-unit number is +/// from [`crate::aacs::derive::decrypt_unit_key`]; the library's canonical CPS-unit number is /// `position + 1` (matching [`crate::aacs::inf::parse_unit_key_ro`]'s `(i + 1)`), so /// the committed `AacsState.unit_keys` is byte-identical to the library-resolved /// path. The number is cosmetic for descramble (the decrypt path strips it and diff --git a/src/mux/demux_thread.rs b/src/mux/demux_thread.rs index dbace30..29d8aec 100644 --- a/src/mux/demux_thread.rs +++ b/src/mux/demux_thread.rs @@ -204,6 +204,14 @@ impl DemuxThread { } } else { let _ = recycle_tx.send(buf); + // No demuxer (a BdTs title with zero streams): still send + // an empty batch so an early consumer disconnect is + // detected here too, exactly like the ts/ps branches above. + // Without it this worker reads the whole disc even after + // the consumer has dropped. + if tx.send(DemuxBatch::Ts(Vec::new())).is_err() { + return; + } } } // Flush tail packets at EOF. @@ -479,7 +487,7 @@ mod tests { #[test] fn no_demuxer_configured_still_recycles_and_eofs() { // With neither ts nor ps set, the worker must still recycle buffers - // and terminate with Eof — never emit a spurious Ts/Ps batch. + // and terminate with Eof — also forward an empty batch per buffer for disconnect detection. let (pf_tx, pf_rx) = bounded::>>(4); let (rc_tx, rc_rx) = bounded::>(4); let (_dt, rx) = DemuxThread::spawn_zero_copy(pf_rx, rc_tx, (), None, None, None).unwrap(); @@ -492,8 +500,16 @@ mod tests { drop(pf_tx); let batches = collect_batches(&rx, Duration::from_secs(5)); - assert_eq!(batches.len(), 1, "only the Eof sentinel"); - assert!(matches!(batches[0], DemuxBatch::Eof)); + // The no-demuxer branch now forwards an empty Ts batch per buffer for + // early consumer-disconnect detection (same rationale as the ts/ps + // branches), then the Eof sentinel. + assert_eq!( + batches.len(), + 2, + "empty Ts disconnect-probe batch, then Eof" + ); + assert!(matches!(batches[0], DemuxBatch::Ts(ref v) if v.is_empty())); + assert!(matches!(batches[1], DemuxBatch::Eof)); } #[test] diff --git a/src/mux/disc.rs b/src/mux/disc.rs index 691cd01..721d9e1 100644 --- a/src/mux/disc.rs +++ b/src/mux/disc.rs @@ -1520,11 +1520,15 @@ mod tests { "read at lba {lba} is not unit-aligned (offset {} % {ALIGN} != 0)", lba - ext_start ); - // Non-tail reads must be a whole number of units; the only - // permitted short read is the final partial unit (here COUNT is a - // multiple of ALIGN, so every read should be unit-multiple unless - // it shrank below one unit — which is itself a single unit). - let _ = count; + // Non-tail reads must be a whole number of units; the only permitted + // short read is a final partial unit (below one unit). Assert it + // rather than documenting it — a mid-stream non-unit-multiple read + // would straddle AACS unit boundaries and decrypt under the wrong + // alignment. + assert!( + count as u32 % ALIGN == 0 || (count as u32) < ALIGN, + "read count {count} is neither a whole number of units nor a sub-unit tail" + ); } // At least one error was skipped (the bad unit) and a SectorSkipped diff --git a/src/mux/ts.rs b/src/mux/ts.rs index 95c3d3e..91d4379 100644 --- a/src/mux/ts.rs +++ b/src/mux/ts.rs @@ -167,6 +167,10 @@ impl PesAssembler { self.buffer.clear(); self.active = false; self.header_remaining = 0; + // A dropped partial PES is a gap in the elementary stream — flag + // it so the NEXT completed PES carries a discontinuity, matching + // every other partial-drop path in this file (lines 395/479/504). + self.pending_discontinuity = true; return; } self.buffer.extend_from_slice(data); diff --git a/src/sector/prefetched.rs b/src/sector/prefetched.rs index d2b962c..42b3424 100644 --- a/src/sector/prefetched.rs +++ b/src/sector/prefetched.rs @@ -358,7 +358,6 @@ impl PrefetchedSectorSource { // and suppresses `self`'s own `Drop` (which would otherwise // double-`join`), leaving NO extra live endpoint behind. This // is the panic-free equivalent of the `Option::take` approach. - let total = self.total_sectors; let me = std::mem::ManuallyDrop::new(self); // SAFETY: `me` is `ManuallyDrop`, so none of these fields will // be dropped by `me`. Each `ptr::read` performs exactly one @@ -367,7 +366,7 @@ impl PrefetchedSectorSource { let producer = unsafe { std::ptr::read(&me.producer) }; let rx = unsafe { std::ptr::read(&me.rx) }; let recycle = unsafe { std::ptr::read(&me.recycle_tx) }; - (rx, recycle, PrefetchShell { producer, total }) + (rx, recycle, PrefetchShell { producer }) } } @@ -376,8 +375,6 @@ impl PrefetchedSectorSource { /// producer, even though the channels have been peeled off. pub struct PrefetchShell { producer: Option>, - #[allow(dead_code)] - total: u32, } impl Drop for PrefetchShell { diff --git a/src/udf.rs b/src/udf.rs index 63526c1..3c22d97 100644 --- a/src/udf.rs +++ b/src/udf.rs @@ -325,53 +325,6 @@ impl UdfFs { Ok(merged) } - /// All sector ranges that contain data (metadata + all files including STREAM). - /// For full disc-to-ISO dumps — reads only allocated sectors, skips gaps. - pub fn all_sector_ranges(&self, reader: &mut dyn SectorSource) -> Result> { - let mut ranges = Vec::new(); - - // UDF structure sectors - let meta_end = self.metadata_start.saturating_add(self.metadata_sectors); - ranges.push((0, meta_end)); - - // Walk entire tree including STREAM directories - self.collect_all_file_ranges(reader, &self.root, &mut ranges)?; - - // Merge overlapping/adjacent ranges and sort - ranges.sort_by_key(|r| r.0); - let merged = merge_ranges(&ranges); - Ok(merged) - } - - fn collect_all_file_ranges( - &self, - reader: &mut dyn SectorSource, - entry: &DirEntry, - ranges: &mut Vec<(u32, u32)>, - ) -> Result<()> { - for child in &entry.entries { - if child.is_dir { - self.collect_all_file_ranges(reader, child, ranges)?; - } else { - // Include the ICB sector - ranges.push((self.meta_to_abs(child.meta_lba)?, 1)); - - // Include ALL file data extents (large m2ts files have many) - if let Ok(extents) = self.read_icb_extents(reader, child.meta_lba) { - for (data_lba, data_len) in extents { - let abs_start = match self.partition_start.checked_add(data_lba) { - Some(v) => v, - None => continue, - }; - let sector_count = (data_len as u64).div_ceil(2048) as u32; - ranges.push((abs_start, sector_count)); - } - } - } - } - Ok(()) - } - fn collect_file_ranges( &self, reader: &mut dyn SectorSource,