mux: thread halt into live AACS key-map resolution; cover Session arm
Round-2 follow-ups to 6d6e60f (inline base-map resolve on the live
single-pass Session/Live mux arms).
Fix 1 (halt threading) — the inline resolve chain sampled ciphertext off
the LIVE drive with no cancel token, so an operator /api/stop during key
resolution was not honored (the FMTS probe can issue hundreds of reads,
each able to stall to the 60s SCSI recovery timeout — violating the
"don't hammer a struggling live drive" rule). Add an optional
`halt: Option<&Halt>` to `resolve_mux_key_map`, `resolve_fmts_key_map`,
`resolve_inline_base_map`, and `Disc::resolve_content_key_map`, and poll
it at each loop boundary (FMTS anchor + per-index probe loops, multi-CPS
extent loop) — returning Err(Halted) promptly. Live/Session arms pass the
driver's halt; sweep/patch pass their own token (via Halt::from_arc);
file-backed probe/ISO callers pass None. Tested with a pre-cancelled halt
(Err Halted, no extent sampling) and a None-halt no-abort case;
mutation-verified (dropping the extent-loop check → Ok, not Err).
Fix 2 (Session-arm coverage) — the MuxInput::Session arm ran the same
resolve→install→decrypt sequence as Live but had NO end-to-end test
(DiscSession only exposed open(), which needs live hardware). Add a
#[cfg(test)] DiscSession::from_parts_for_test (injected reader + scanned
disc, no Drive), an end-to-end AACS decrypt test through the Session arm
(mutation-verified: dropping with_key_map → mux aborts), and a
missing-reader clean-error (not panic) test.
Fix 3 (cleanups) — io_error_code: remove the unreachable typed-Error
downcast branch (From<Error> for io::Error stringifies; no path builds an
io::Error holding a typed Error), keeping the stringify parse is_halt /
is_skippable_title_stub rely on. Add a resolve_keys_for test covering the
largest-title sampling branch. Document the patch wedge-exit coverage gap
(TODO) in passn_handler_ab.rs.
This commit is contained in:
+1
-1
@@ -217,7 +217,7 @@ impl Disc {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
DecryptKeys::Aacs { .. } => Some(std::sync::Arc::new(
|
DecryptKeys::Aacs { .. } => Some(std::sync::Arc::new(
|
||||||
self.resolve_content_key_map(reader, &mut base_keys, None)?,
|
self.resolve_content_key_map(reader, &mut base_keys, None, opts.halt.as_ref())?,
|
||||||
)),
|
)),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|||||||
+11
-2
@@ -2401,11 +2401,18 @@ impl Disc {
|
|||||||
reader: &mut dyn SectorSource,
|
reader: &mut dyn SectorSource,
|
||||||
keys: &mut crate::decrypt::DecryptKeys,
|
keys: &mut crate::decrypt::DecryptKeys,
|
||||||
fetch: Option<&crate::sector::KeyFetch>,
|
fetch: Option<&crate::sector::KeyFetch>,
|
||||||
|
halt: Option<&crate::halt::Halt>,
|
||||||
) -> Result<crate::decrypt::AacsKeyMap> {
|
) -> Result<crate::decrypt::AacsKeyMap> {
|
||||||
let mut ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)> = Vec::new();
|
let mut ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)> = Vec::new();
|
||||||
for title in &self.titles {
|
for title in &self.titles {
|
||||||
let map =
|
let map = crate::mux::resolve_mux_key_map(
|
||||||
crate::mux::resolve_mux_key_map(reader, title, keys, fetch, self.content_format)?;
|
reader,
|
||||||
|
title,
|
||||||
|
keys,
|
||||||
|
fetch,
|
||||||
|
self.content_format,
|
||||||
|
halt,
|
||||||
|
)?;
|
||||||
ranges.extend_from_slice(map.ranges());
|
ranges.extend_from_slice(map.ranges());
|
||||||
}
|
}
|
||||||
Ok(crate::decrypt::AacsKeyMap::from_ranges_phased(
|
Ok(crate::decrypt::AacsKeyMap::from_ranges_phased(
|
||||||
@@ -3206,10 +3213,12 @@ impl Disc {
|
|||||||
// separate content gate is needed. CSS keeps the content-gated
|
// separate content gate is needed. CSS keeps the content-gated
|
||||||
// self-descramble path (the map path is AACS-only).
|
// self-descramble path (the map path is AACS-only).
|
||||||
let key_map = if opts.decrypt && decrypt_is_aacs {
|
let key_map = if opts.decrypt && decrypt_is_aacs {
|
||||||
|
let halt = opts.halt.clone().map(crate::halt::Halt::from_arc);
|
||||||
Some(std::sync::Arc::new(self.resolve_content_key_map(
|
Some(std::sync::Arc::new(self.resolve_content_key_map(
|
||||||
reader,
|
reader,
|
||||||
&mut keys,
|
&mut keys,
|
||||||
opts.key_fetch.as_ref(),
|
opts.key_fetch.as_ref(),
|
||||||
|
halt.as_ref(),
|
||||||
)?))
|
)?))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|||||||
@@ -1339,10 +1339,12 @@ impl Disc {
|
|||||||
// via the map (identical to `Disc::sweep`). CSS keeps the content-gated
|
// via the map (identical to `Disc::sweep`). CSS keeps the content-gated
|
||||||
// self-descramble path. (Multipass patch is `--raw`, so decrypt is a no-op.)
|
// self-descramble path. (Multipass patch is `--raw`, so decrypt is a no-op.)
|
||||||
let key_map = if opts.decrypt && decrypt_is_aacs {
|
let key_map = if opts.decrypt && decrypt_is_aacs {
|
||||||
|
let halt = opts.halt.clone().map(crate::halt::Halt::from_arc);
|
||||||
Some(std::sync::Arc::new(self.resolve_content_key_map(
|
Some(std::sync::Arc::new(self.resolve_content_key_map(
|
||||||
reader,
|
reader,
|
||||||
&mut keys,
|
&mut keys,
|
||||||
opts.key_fetch.as_ref(),
|
opts.key_fetch.as_ref(),
|
||||||
|
halt.as_ref(),
|
||||||
)?))
|
)?))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|||||||
+6
-8
@@ -882,15 +882,13 @@ pub type Result<T> = std::result::Result<T, Error>;
|
|||||||
/// The numeric error code carried by an [`io::Error`](std::io::Error) that was
|
/// The numeric error code carried by an [`io::Error`](std::io::Error) that was
|
||||||
/// produced from an [`Error`], or `None` if it carries none.
|
/// produced from an [`Error`], or `None` if it carries none.
|
||||||
///
|
///
|
||||||
/// Two shapes are recognised: an `io::Error` that still wraps the typed
|
/// [`From<Error> for io::Error`] is the ONLY path from a typed [`Error`] to an
|
||||||
/// [`Error`] (via `io::Error::new(kind, Error)`), and the round-tripped form
|
/// `io::Error` in this crate, and it stringifies (`io::Error::new(kind, msg)`
|
||||||
/// produced by [`From<Error> for io::Error`] whose message is the `Error`'s
|
/// where `msg` is the `Error`'s `E<code>[: …]` [`Display`](std::fmt::Display)
|
||||||
/// `E<code>[: …]` [`Display`](std::fmt::Display) string.
|
/// string) rather than boxing the typed value — no code path constructs an
|
||||||
|
/// `io::Error` that still holds a `crate::error::Error` via `get_ref`. So the
|
||||||
|
/// only recognised shape is the round-tripped `E<code>` message prefix.
|
||||||
fn io_error_code(e: &std::io::Error) -> Option<u16> {
|
fn io_error_code(e: &std::io::Error) -> Option<u16> {
|
||||||
// Direct: the io::Error still holds the typed Error.
|
|
||||||
if let Some(err) = e.get_ref().and_then(|r| r.downcast_ref::<Error>()) {
|
|
||||||
return Some(err.code());
|
|
||||||
}
|
|
||||||
// Round-tripped: `From<Error> for io::Error` stringifies as "E<code>[: …]".
|
// Round-tripped: `From<Error> for io::Error` stringifies as "E<code>[: …]".
|
||||||
let s = e.to_string();
|
let s = e.to_string();
|
||||||
let digits = s.strip_prefix('E')?;
|
let digits = s.strip_prefix('E')?;
|
||||||
|
|||||||
+155
-1
@@ -344,6 +344,7 @@ pub fn mux_stream(
|
|||||||
session.key_fetch(),
|
session.key_fetch(),
|
||||||
format,
|
format,
|
||||||
opts.raw,
|
opts.raw,
|
||||||
|
Some(halt),
|
||||||
)?;
|
)?;
|
||||||
let mut stream = crate::mux::DiscStream::new(
|
let mut stream = crate::mux::DiscStream::new(
|
||||||
reader,
|
reader,
|
||||||
@@ -395,6 +396,7 @@ pub fn mux_stream(
|
|||||||
None,
|
None,
|
||||||
format,
|
format,
|
||||||
opts.raw,
|
opts.raw,
|
||||||
|
Some(halt),
|
||||||
)?,
|
)?,
|
||||||
};
|
};
|
||||||
// INLINE `DiscStream` — the same constructor the `Session` arm uses,
|
// INLINE `DiscStream` — the same constructor the `Session` arm uses,
|
||||||
@@ -514,11 +516,17 @@ fn resolve_inline_base_map(
|
|||||||
fetch: Option<&KeyFetch>,
|
fetch: Option<&KeyFetch>,
|
||||||
format: crate::disc::ContentFormat,
|
format: crate::disc::ContentFormat,
|
||||||
raw: bool,
|
raw: bool,
|
||||||
|
halt: Option<&crate::halt::Halt>,
|
||||||
) -> std::io::Result<Option<Arc<AacsKeyMap>>> {
|
) -> std::io::Result<Option<Arc<AacsKeyMap>>> {
|
||||||
if raw || !matches!(keys, DecryptKeys::Aacs { .. }) {
|
if raw || !matches!(keys, DecryptKeys::Aacs { .. }) {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let map = resolve_mux_key_map(reader, title, keys, fetch, format)?;
|
// Thread the driver's cancel token into the live-drive key resolution: the
|
||||||
|
// resolve chain samples ciphertext off the LIVE reader (the FMTS probe can do
|
||||||
|
// hundreds of reads, each able to stall to the SCSI recovery timeout), so an
|
||||||
|
// operator `/api/stop` mid-resolution must be honored here — not only once the
|
||||||
|
// read loop starts.
|
||||||
|
let map = resolve_mux_key_map(reader, title, keys, fetch, format, halt)?;
|
||||||
Ok(Some(Arc::new(map)))
|
Ok(Some(Arc::new(map)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1547,6 +1555,149 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a synthetic single-CPS AACS `Disc` carrying `unit_key` and one
|
||||||
|
/// title whose sole extent is the encrypted unit at LBA 0..3 — the disc a
|
||||||
|
/// `MuxInput::Session` mux scans off a live drive, minus the hardware.
|
||||||
|
fn aacs_session_disc(title: DiscTitle, unit_key: [u8; 16]) -> crate::disc::Disc {
|
||||||
|
crate::disc::Disc {
|
||||||
|
volume_id: "TEST".into(),
|
||||||
|
meta_title: None,
|
||||||
|
format: crate::DiscFormat::Uhd,
|
||||||
|
capacity_sectors: 0,
|
||||||
|
capacity_bytes: 0,
|
||||||
|
layers: 1,
|
||||||
|
titles: vec![title],
|
||||||
|
region: crate::disc::DiscRegion::Free,
|
||||||
|
aacs: Some(crate::disc::AacsState {
|
||||||
|
version: crate::aacs::mkb::AACS_MAJOR_UHD,
|
||||||
|
bus_encryption: false,
|
||||||
|
mkb_version: None,
|
||||||
|
disc_hash: "0xabc".into(),
|
||||||
|
key_source: crate::disc::KeyOrigin::KeyDb,
|
||||||
|
vuk: None,
|
||||||
|
unit_keys: vec![(0, unit_key)],
|
||||||
|
read_data_key: None,
|
||||||
|
volume_id: [0u8; 16],
|
||||||
|
uk_ro: Vec::new(),
|
||||||
|
mkb: Vec::new(),
|
||||||
|
}),
|
||||||
|
css: None,
|
||||||
|
encrypted: true,
|
||||||
|
aacs_error: None,
|
||||||
|
css_error: None,
|
||||||
|
content_format: crate::ContentFormat::BdTs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// END-TO-END decrypt on the live single-pass `MuxInput::Session` path — the
|
||||||
|
/// exact shape of `freemkv rip disc://…mkv`. The `Session` arm runs the SAME
|
||||||
|
/// sequence as `Live` (take_reader → resolve_inline_base_map → DiscStream →
|
||||||
|
/// with_key_map), but until now had NO end-to-end coverage because a real
|
||||||
|
/// `DiscSession` needs a live `Drive`. Using the `#[cfg(test)]`
|
||||||
|
/// `from_parts_for_test` constructor, a genuinely-AACS-encrypted unit muxed
|
||||||
|
/// through the `Session` arm must resolve+install the base key map itself and
|
||||||
|
/// DECRYPT to a valid audio PES.
|
||||||
|
///
|
||||||
|
/// Mutation: dropping `stream = stream.with_key_map(map)` (or the
|
||||||
|
/// resolve_inline_base_map call) in the `Session` arm leaves the reader
|
||||||
|
/// mapless → the content batch cannot decrypt → the mux aborts (`Err`) and
|
||||||
|
/// `out.completed` is never reached.
|
||||||
|
#[test]
|
||||||
|
fn mux_input_session_aacs_without_caller_map_resolves_and_decrypts() {
|
||||||
|
use crate::disc::Extent;
|
||||||
|
use crate::session::DiscSession;
|
||||||
|
|
||||||
|
let unit_key = [0x5Au8; 16];
|
||||||
|
let reader = Box::new(AacsUnitReader {
|
||||||
|
unit: encrypted_audio_unit(&unit_key),
|
||||||
|
capacity: 2048,
|
||||||
|
});
|
||||||
|
let mut title = aac_audio_title(0x1100);
|
||||||
|
title.extents = vec![Extent {
|
||||||
|
start_lba: 0,
|
||||||
|
sector_count: 3,
|
||||||
|
}];
|
||||||
|
let disc = aacs_session_disc(title, unit_key);
|
||||||
|
// No caller key_fetch: a single-CPS disc resolves its base map with the
|
||||||
|
// banked unit key alone (the FMTS/multi-CPS fetch path is not exercised).
|
||||||
|
let mut session = DiscSession::from_parts_for_test(disc, Some(reader), None);
|
||||||
|
|
||||||
|
let opts = MuxOptions {
|
||||||
|
skip_errors: false, // a DecryptFailed must PROPAGATE, not zero-fill
|
||||||
|
batch_sectors: 3, // one aligned unit per read
|
||||||
|
raw: false,
|
||||||
|
send_deadline: Some(Duration::from_secs(60)),
|
||||||
|
};
|
||||||
|
let halt = Halt::new();
|
||||||
|
let out = mux_stream(
|
||||||
|
MuxInput::Session {
|
||||||
|
session: &mut session,
|
||||||
|
title_index: 0,
|
||||||
|
},
|
||||||
|
"null://",
|
||||||
|
&opts,
|
||||||
|
&halt,
|
||||||
|
Arc::new(NoopEvents),
|
||||||
|
)
|
||||||
|
.expect(
|
||||||
|
"a plain AACS Session mux must resolve+install its base key map and DECRYPT \
|
||||||
|
(no map → the content batch fails to decrypt and the mux aborts)",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.completed,
|
||||||
|
"the decrypted audio PES drained and finalised — proves the unit decrypted"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.bytes_written > 0,
|
||||||
|
"decrypted payload bytes reached the sink"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `MuxInput::Session` whose reader was never staged (`take_reader()` →
|
||||||
|
/// `None`) must surface a clean typed error, NOT panic — the boundary-audit
|
||||||
|
/// Q2 contract. Guards the `ok_or_else(|| Error::DeviceNotReady …)` in the
|
||||||
|
/// `Session` arm against a regression to `.expect()`/`.unwrap()`.
|
||||||
|
#[test]
|
||||||
|
fn mux_input_session_missing_reader_is_clean_error_not_panic() {
|
||||||
|
use crate::disc::Extent;
|
||||||
|
use crate::session::DiscSession;
|
||||||
|
|
||||||
|
let unit_key = [0x5Au8; 16];
|
||||||
|
let mut title = aac_audio_title(0x1100);
|
||||||
|
title.extents = vec![Extent {
|
||||||
|
start_lba: 0,
|
||||||
|
sector_count: 3,
|
||||||
|
}];
|
||||||
|
let disc = aacs_session_disc(title, unit_key);
|
||||||
|
// reader: None — never staged.
|
||||||
|
let mut session = DiscSession::from_parts_for_test(disc, None, None);
|
||||||
|
|
||||||
|
let opts = MuxOptions {
|
||||||
|
skip_errors: false,
|
||||||
|
batch_sectors: 3,
|
||||||
|
raw: false,
|
||||||
|
send_deadline: Some(Duration::from_secs(60)),
|
||||||
|
};
|
||||||
|
let halt = Halt::new();
|
||||||
|
let err = mux_stream(
|
||||||
|
MuxInput::Session {
|
||||||
|
session: &mut session,
|
||||||
|
title_index: 0,
|
||||||
|
},
|
||||||
|
"null://",
|
||||||
|
&opts,
|
||||||
|
&halt,
|
||||||
|
Arc::new(NoopEvents),
|
||||||
|
)
|
||||||
|
.expect_err("a missing staged reader must be a clean error, not a panic");
|
||||||
|
// The device-name-carrying DeviceNotReady (code E4xxx) round-trips through
|
||||||
|
// io::Error; assert it is NOT a decrypt/other-shaped failure.
|
||||||
|
assert!(
|
||||||
|
err.to_string().starts_with('E'),
|
||||||
|
"expected a typed libfreemkv error (E<code>…), got: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The shared `resolve_inline_base_map` helper's gating: an AACS key set
|
/// The shared `resolve_inline_base_map` helper's gating: an AACS key set
|
||||||
/// yields a map (Some); CSS/clear/None and `raw` yield None (CSS self-cracks
|
/// yields a map (Some); CSS/clear/None and `raw` yield None (CSS self-cracks
|
||||||
/// in `DiscStream::new`; raw is ciphertext passthrough). Guards the Session
|
/// in `DiscStream::new`; raw is ciphertext passthrough). Guards the Session
|
||||||
@@ -1580,6 +1731,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
crate::disc::ContentFormat::BdTs,
|
crate::disc::ContentFormat::BdTs,
|
||||||
false,
|
false,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("resolve must not error for a single-CPS AACS disc");
|
.expect("resolve must not error for a single-CPS AACS disc");
|
||||||
assert!(map.is_some(), "AACS non-raw must resolve a base map");
|
assert!(map.is_some(), "AACS non-raw must resolve a base map");
|
||||||
@@ -1598,6 +1750,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
crate::disc::ContentFormat::BdTs,
|
crate::disc::ContentFormat::BdTs,
|
||||||
true,
|
true,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("raw resolve is a no-op");
|
.expect("raw resolve is a no-op");
|
||||||
assert!(map_raw.is_none(), "raw must NOT resolve a map");
|
assert!(map_raw.is_none(), "raw must NOT resolve a map");
|
||||||
@@ -1612,6 +1765,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
crate::disc::ContentFormat::MpegPs,
|
crate::disc::ContentFormat::MpegPs,
|
||||||
false,
|
false,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("clear/CSS resolve is a no-op");
|
.expect("clear/CSS resolve is a no-op");
|
||||||
assert!(map_none.is_none(), "CSS/clear must NOT resolve an AACS map");
|
assert!(map_none.is_none(), "CSS/clear must NOT resolve an AACS map");
|
||||||
|
|||||||
+154
-6
@@ -458,6 +458,9 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
|||||||
&mut probe_keys,
|
&mut probe_keys,
|
||||||
opts.key_fetch.as_ref(),
|
opts.key_fetch.as_ref(),
|
||||||
disc.content_format,
|
disc.content_format,
|
||||||
|
// File-backed, bounded probe (best-effort `.ok()`);
|
||||||
|
// no live drive to protect from a stuck stop here.
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.ok()
|
.ok()
|
||||||
.map(std::sync::Arc::new),
|
.map(std::sync::Arc::new),
|
||||||
@@ -769,10 +772,23 @@ fn resolve_fmts_key_map(
|
|||||||
keys: &mut crate::decrypt::DecryptKeys,
|
keys: &mut crate::decrypt::DecryptKeys,
|
||||||
fetch: Option<&crate::sector::KeyFetch>,
|
fetch: Option<&crate::sector::KeyFetch>,
|
||||||
format: ContentFormat,
|
format: ContentFormat,
|
||||||
|
halt: Option<&crate::halt::Halt>,
|
||||||
) -> io::Result<Option<crate::decrypt::AacsKeyMap>> {
|
) -> io::Result<Option<crate::decrypt::AacsKeyMap>> {
|
||||||
use crate::aacs::content::{ALIGNED_UNIT_LEN, aacs_unit_encrypted, decrypt_unit, is_clean};
|
use crate::aacs::content::{ALIGNED_UNIT_LEN, aacs_unit_encrypted, decrypt_unit, is_clean};
|
||||||
use crate::aacs::segment::{clip_byte_to_lba, parse_individual_segments};
|
use crate::aacs::segment::{clip_byte_to_lba, parse_individual_segments};
|
||||||
|
|
||||||
|
// Cooperative cancel: this probes the LIVE drive across up to a few hundred
|
||||||
|
// `read_sectors` (the anchor + per-index probe loops), each able to stall to
|
||||||
|
// the SCSI recovery timeout. An operator `/api/stop` during forensic key
|
||||||
|
// resolution must be honored at each loop boundary rather than blocking until
|
||||||
|
// the whole probe completes (hard rule: don't hammer a struggling live drive).
|
||||||
|
let check_halt = || -> io::Result<()> {
|
||||||
|
if halt.is_some_and(|h| h.is_cancelled()) {
|
||||||
|
return Err(crate::error::Error::Halted.into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
|
||||||
// Load the segment map; absent → not an FMTS disc.
|
// Load the segment map; absent → not an FMTS disc.
|
||||||
let Ok(udf) = crate::udf::read_filesystem(reader) else {
|
let Ok(udf) = crate::udf::read_filesystem(reader) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -855,6 +871,7 @@ fn resolve_fmts_key_map(
|
|||||||
.filter(|s| s.index == 1)
|
.filter(|s| s.index == 1)
|
||||||
.take(MAX_ANCHOR_ATTEMPTS)
|
.take(MAX_ANCHOR_ATTEMPTS)
|
||||||
{
|
{
|
||||||
|
check_halt()?;
|
||||||
for phase_off in [0usize, 1usize] {
|
for phase_off in [0usize, 1usize] {
|
||||||
let Some(batch) = read_phase_batch(reader, seg, phase_off) else {
|
let Some(batch) = read_phase_batch(reader, seg, phase_off) else {
|
||||||
continue; // read fault on this phase — try the other / next segment
|
continue; // read fault on this phase — try the other / next segment
|
||||||
@@ -911,6 +928,7 @@ fn resolve_fmts_key_map(
|
|||||||
let mut phase_of_index: std::collections::HashMap<u16, crate::decrypt::Phase> =
|
let mut phase_of_index: std::collections::HashMap<u16, crate::decrypt::Phase> =
|
||||||
std::collections::HashMap::new();
|
std::collections::HashMap::new();
|
||||||
for (i, k) in index_keys.iter().enumerate() {
|
for (i, k) in index_keys.iter().enumerate() {
|
||||||
|
check_halt()?;
|
||||||
let tag = (i + 1) as u16;
|
let tag = (i + 1) as u16;
|
||||||
let Some(seg) = segments.iter().find(|s| s.index == tag) else {
|
let Some(seg) = segments.iter().find(|s| s.index == tag) else {
|
||||||
continue; // no segment carries this index on this feature — skip
|
continue; // no segment carries this index on this feature — skip
|
||||||
@@ -1132,6 +1150,7 @@ pub fn resolve_mux_key_map(
|
|||||||
keys: &mut crate::decrypt::DecryptKeys,
|
keys: &mut crate::decrypt::DecryptKeys,
|
||||||
fetch: Option<&crate::sector::KeyFetch>,
|
fetch: Option<&crate::sector::KeyFetch>,
|
||||||
format: ContentFormat,
|
format: ContentFormat,
|
||||||
|
halt: Option<&crate::halt::Halt>,
|
||||||
) -> io::Result<crate::decrypt::AacsKeyMap> {
|
) -> io::Result<crate::decrypt::AacsKeyMap> {
|
||||||
use crate::aacs::content::{
|
use crate::aacs::content::{
|
||||||
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted, decrypt_unit, is_clean,
|
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted, decrypt_unit, is_clean,
|
||||||
@@ -1153,7 +1172,7 @@ pub fn resolve_mux_key_map(
|
|||||||
// front from the configured source and build a per-segment map. Returns `None`
|
// front from the configured source and build a per-segment map. Returns `None`
|
||||||
// when the disc is not FMTS, or no key source is configured (then the base UK
|
// when the disc is not FMTS, or no key source is configured (then the base UK
|
||||||
// path below applies and the forensic units garble → demux drops them).
|
// path below applies and the forensic units garble → demux drops them).
|
||||||
if let Some(map) = resolve_fmts_key_map(reader, title, keys, fetch, format)? {
|
if let Some(map) = resolve_fmts_key_map(reader, title, keys, fetch, format, halt)? {
|
||||||
return Ok(map);
|
return Ok(map);
|
||||||
}
|
}
|
||||||
if pool_len == 1 {
|
if pool_len == 1 {
|
||||||
@@ -1204,6 +1223,11 @@ pub fn resolve_mux_key_map(
|
|||||||
let mut ranges: Vec<(u32, u32, usize)> = Vec::with_capacity(title.extents.len());
|
let mut ranges: Vec<(u32, u32, usize)> = Vec::with_capacity(title.extents.len());
|
||||||
let mut last_idx = 0usize;
|
let mut last_idx = 0usize;
|
||||||
for ext in &title.extents {
|
for ext in &title.extents {
|
||||||
|
// Cooperative cancel between extents: multi-CPS sampling reads real
|
||||||
|
// content units off the live drive, so honor an operator stop here too.
|
||||||
|
if halt.is_some_and(|h| h.is_cancelled()) {
|
||||||
|
return Err(crate::error::Error::Halted.into());
|
||||||
|
}
|
||||||
let samples = sample_units(reader, ext.start_lba, ext.sector_count);
|
let samples = sample_units(reader, ext.start_lba, ext.sector_count);
|
||||||
// Snapshot the current pool for the pure `pick` closure.
|
// Snapshot the current pool for the pure `pick` closure.
|
||||||
let pool: Vec<(u32, [u8; 16])> = match keys {
|
let pool: Vec<(u32, [u8; 16])> = match keys {
|
||||||
@@ -1319,11 +1343,15 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
|||||||
// with its KNOWN key and trusts it: no per-unit `is_clean` verdict, no reactive
|
// with its KNOWN key and trusts it: no per-unit `is_clean` verdict, no reactive
|
||||||
// key-fetch, no key-server storm. A unit that decrypts to broken TS is the
|
// key-fetch, no key-server storm. A unit that decrypts to broken TS is the
|
||||||
// muxer's problem, exactly as before. AACS-only; CSS self-cracks per region.
|
// muxer's problem, exactly as before. AACS-only; CSS self-cracks per region.
|
||||||
let key_map =
|
let key_map = match &keys {
|
||||||
match &keys {
|
crate::decrypt::DecryptKeys::Aacs { .. } => Some(std::sync::Arc::new(resolve_mux_key_map(
|
||||||
crate::decrypt::DecryptKeys::Aacs { .. } => Some(std::sync::Arc::new(
|
&mut reader,
|
||||||
resolve_mux_key_map(&mut reader, &title, &mut keys, fetch.as_ref(), format)?,
|
&title,
|
||||||
)),
|
&mut keys,
|
||||||
|
fetch.as_ref(),
|
||||||
|
format,
|
||||||
|
halt.as_ref(),
|
||||||
|
)?)),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
// The map IS the title's read plan: it says which CPS unit / forensic segment
|
// The map IS the title's read plan: it says which CPS unit / forensic segment
|
||||||
@@ -1794,6 +1822,126 @@ mod tests {
|
|||||||
assert!(ps.is_none());
|
assert!(ps.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Fix 1: halt threading into live-drive key resolution ───────────────
|
||||||
|
|
||||||
|
/// A counting `SectorSource` over zeros. `touched_extent` flags whether any
|
||||||
|
/// read landed in the title's extent region (LBA >= 1000); the UDF probe only
|
||||||
|
/// reads near LBA 256 (small `capacity`), so a hit there means the expensive
|
||||||
|
/// per-extent `sample_units` loop ran.
|
||||||
|
struct HaltCountSource {
|
||||||
|
reads: u32,
|
||||||
|
touched_extent: bool,
|
||||||
|
}
|
||||||
|
impl SectorSource for HaltCountSource {
|
||||||
|
fn capacity_sectors(&self) -> u32 {
|
||||||
|
512 // keeps the UDF secondary anchor well below the extent region
|
||||||
|
}
|
||||||
|
fn read_sectors(
|
||||||
|
&mut self,
|
||||||
|
lba: u32,
|
||||||
|
count: u16,
|
||||||
|
buf: &mut [u8],
|
||||||
|
_recovery: bool,
|
||||||
|
) -> crate::error::Result<usize> {
|
||||||
|
self.reads += 1;
|
||||||
|
if lba >= 1000 {
|
||||||
|
self.touched_extent = true;
|
||||||
|
}
|
||||||
|
let want = count as usize * 2048;
|
||||||
|
buf[..want].fill(0);
|
||||||
|
Ok(want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `resolve_mux_key_map` on the multi-CPS live path must honor a pre-cancelled
|
||||||
|
/// halt PROMPTLY — `Err(Halted)` at the first extent boundary, before sampling
|
||||||
|
/// any extent's ciphertext — rather than reading through every extent. This is
|
||||||
|
/// the round-2 Fix 1 guard: the resolve chain runs on the LIVE drive (each
|
||||||
|
/// `read_sectors` can stall to the SCSI recovery timeout), so an operator Stop
|
||||||
|
/// during key resolution must interrupt it.
|
||||||
|
///
|
||||||
|
/// Mutation: dropping the `halt.is_some_and(...) → Err(Halted)` check in the
|
||||||
|
/// multi-CPS extent loop makes the resolve run the sampling reads and return
|
||||||
|
/// `Ok(map)` (zeros sample to no encrypted units → carry key 0), so
|
||||||
|
/// `expect_err` fails AND `touched_extent` flips true.
|
||||||
|
#[test]
|
||||||
|
fn resolve_mux_key_map_honors_pre_cancelled_halt() {
|
||||||
|
use crate::halt::Halt;
|
||||||
|
let mut title = DiscTitle::empty();
|
||||||
|
title.extents = vec![
|
||||||
|
Extent {
|
||||||
|
start_lba: 1000,
|
||||||
|
sector_count: 300,
|
||||||
|
},
|
||||||
|
Extent {
|
||||||
|
start_lba: 5000,
|
||||||
|
sector_count: 300,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
// Multi-CPS (pool_len = 2) → the extent-sampling loop is the resolve path
|
||||||
|
// (pool_len == 1 would short-circuit to content_map before any read).
|
||||||
|
let mut keys = DecryptKeys::Aacs {
|
||||||
|
unit_keys: vec![(0, [0x11u8; 16]), (1, [0x22u8; 16])],
|
||||||
|
read_data_key: None,
|
||||||
|
format: ContentFormat::BdTs,
|
||||||
|
};
|
||||||
|
let mut reader = HaltCountSource {
|
||||||
|
reads: 0,
|
||||||
|
touched_extent: false,
|
||||||
|
};
|
||||||
|
let halt = Halt::new();
|
||||||
|
halt.cancel(); // pre-cancelled: the very first extent boundary must bail
|
||||||
|
|
||||||
|
let err = super::resolve_mux_key_map(
|
||||||
|
&mut reader,
|
||||||
|
&title,
|
||||||
|
&mut keys,
|
||||||
|
None,
|
||||||
|
ContentFormat::BdTs,
|
||||||
|
Some(&halt),
|
||||||
|
)
|
||||||
|
.expect_err("a pre-cancelled halt must abort key resolution");
|
||||||
|
assert!(crate::error::is_halt(&err), "expected Halted, got: {err}");
|
||||||
|
assert!(
|
||||||
|
!reader.touched_extent,
|
||||||
|
"extent sampling must be skipped on a pre-cancelled halt (a read landed \
|
||||||
|
in the extent region — the halt check was not honored)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `None` halt (no token) must NOT abort — the resolve runs to completion.
|
||||||
|
/// Guards against a mutation that treats `None` as cancelled.
|
||||||
|
#[test]
|
||||||
|
fn resolve_mux_key_map_none_halt_does_not_abort() {
|
||||||
|
let mut title = DiscTitle::empty();
|
||||||
|
title.extents = vec![Extent {
|
||||||
|
start_lba: 1000,
|
||||||
|
sector_count: 300,
|
||||||
|
}];
|
||||||
|
let mut keys = DecryptKeys::Aacs {
|
||||||
|
unit_keys: vec![(0, [0x11u8; 16]), (1, [0x22u8; 16])],
|
||||||
|
read_data_key: None,
|
||||||
|
format: ContentFormat::BdTs,
|
||||||
|
};
|
||||||
|
let mut reader = HaltCountSource {
|
||||||
|
reads: 0,
|
||||||
|
touched_extent: false,
|
||||||
|
};
|
||||||
|
// No halt token → resolution proceeds and samples the extent (zeros → no
|
||||||
|
// encrypted unit → carries key 0), returning Ok.
|
||||||
|
let map = super::resolve_mux_key_map(
|
||||||
|
&mut reader,
|
||||||
|
&title,
|
||||||
|
&mut keys,
|
||||||
|
None,
|
||||||
|
ContentFormat::BdTs,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("no halt → resolution completes");
|
||||||
|
assert!(reader.touched_extent, "the extent WAS sampled with no halt");
|
||||||
|
assert!(!map.ranges().is_empty(), "a map is produced for the extent");
|
||||||
|
}
|
||||||
|
|
||||||
// ── build_iso_pipeline: end-to-end highway wiring ──────────────────────
|
// ── build_iso_pipeline: end-to-end highway wiring ──────────────────────
|
||||||
|
|
||||||
/// An in-memory SectorSource that serves a fixed byte image. Reads beyond
|
/// An in-memory SectorSource that serves a fixed byte image. Reads beyond
|
||||||
|
|||||||
@@ -370,6 +370,32 @@ impl DiscSession {
|
|||||||
pub fn take_reader(&mut self) -> Option<Box<dyn SectorSource>> {
|
pub fn take_reader(&mut self) -> Option<Box<dyn SectorSource>> {
|
||||||
self.reader.take()
|
self.reader.take()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test-only constructor: build a session over an INJECTED reader + already-
|
||||||
|
/// scanned disc WITHOUT opening a live [`Drive`]. `DiscSession::open` needs
|
||||||
|
/// real hardware, so this is the only way to exercise the
|
||||||
|
/// [`MuxInput::Session`](crate::mux::MuxInput::Session) mux arm (take_reader →
|
||||||
|
/// resolve_inline_base_map → DiscStream → with_key_map) and
|
||||||
|
/// [`Self::resolve_keys`]'s title-sampling branch against a synthetic reader.
|
||||||
|
///
|
||||||
|
/// The drive slot stays `None` (a `MuxInput::Session` mux never touches it —
|
||||||
|
/// it reads through the staged `reader`); `device` carries a sentinel path so
|
||||||
|
/// the driver's missing-reader error still has a name.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn from_parts_for_test(
|
||||||
|
disc: Disc,
|
||||||
|
reader: Option<Box<dyn SectorSource>>,
|
||||||
|
key_fetch: Option<KeyFetch>,
|
||||||
|
) -> DiscSession {
|
||||||
|
DiscSession {
|
||||||
|
drive: None,
|
||||||
|
device: "test://session".to_string(),
|
||||||
|
spec: KeySpec::default(),
|
||||||
|
disc: Some(disc),
|
||||||
|
reader,
|
||||||
|
key_fetch,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scan an ISO image's structure from a file path, returning the scanned
|
/// Scan an ISO image's structure from a file path, returning the scanned
|
||||||
@@ -616,6 +642,71 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A counting reader over zeros — records the highest LBA sampled so the test
|
||||||
|
/// can prove the LARGEST title's extent (not the small one) was read.
|
||||||
|
struct SamplingReader {
|
||||||
|
reads: u32,
|
||||||
|
max_lba: u32,
|
||||||
|
}
|
||||||
|
impl SectorSource for SamplingReader {
|
||||||
|
fn capacity_sectors(&self) -> u32 {
|
||||||
|
100_000
|
||||||
|
}
|
||||||
|
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8], _: bool) -> Result<usize> {
|
||||||
|
self.reads += 1;
|
||||||
|
self.max_lba = self.max_lba.max(lba);
|
||||||
|
let want = count as usize * 2048;
|
||||||
|
buf[..want].fill(0);
|
||||||
|
Ok(want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `resolve_keys_for` samples the LARGEST title's ciphertext through the
|
||||||
|
/// reader when a source is configured (the `session.rs:90` sampling branch).
|
||||||
|
/// The other tests use a title-less disc (no sampling read), so this branch
|
||||||
|
/// was uncovered. With two titles and a non-empty source, the sampling read
|
||||||
|
/// fires against the LARGER title's extent.
|
||||||
|
#[test]
|
||||||
|
fn resolve_keys_for_samples_largest_title_through_reader() {
|
||||||
|
use crate::disc::{DiscTitle, Extent};
|
||||||
|
let mut disc = aacs_disc();
|
||||||
|
let mut small = DiscTitle::empty();
|
||||||
|
small.size_bytes = 1_000;
|
||||||
|
small.extents = vec![Extent {
|
||||||
|
start_lba: 100,
|
||||||
|
sector_count: 300,
|
||||||
|
}];
|
||||||
|
let mut large = DiscTitle::empty();
|
||||||
|
large.size_bytes = 9_000_000;
|
||||||
|
large.extents = vec![Extent {
|
||||||
|
start_lba: 9_000,
|
||||||
|
sector_count: 300,
|
||||||
|
}];
|
||||||
|
disc.titles = vec![small, large];
|
||||||
|
let mut reader = SamplingReader {
|
||||||
|
reads: 0,
|
||||||
|
max_lba: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Non-empty source ⇒ the sampling read is NOT skipped.
|
||||||
|
let resolved = resolve_keys_for(&mut reader, &mut disc, factory_of(|| HasUnitKey([1; 16])));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
reader.reads > 0,
|
||||||
|
"the largest title was sampled via the reader"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
reader.max_lba >= 9_000,
|
||||||
|
"sampling read the LARGER title's extent (lba>=9000), not the small one \
|
||||||
|
(max_lba={})",
|
||||||
|
reader.max_lba
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
resolved.key_fetch.is_some(),
|
||||||
|
"an AACS disc still retains a read-time fetch"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A non-AACS disc (CSS / unencrypted — `inputs()` is `None`): resolution is a
|
/// A non-AACS disc (CSS / unencrypted — `inputs()` is `None`): resolution is a
|
||||||
/// no-op. Empty trace, NO fetch, disc untouched. This is the out-of-the-box
|
/// no-op. Empty trace, NO fetch, disc untouched. This is the out-of-the-box
|
||||||
/// CSS/None path that must keep working with no keydb.
|
/// CSS/None path that must keep working with no keydb.
|
||||||
|
|||||||
@@ -814,6 +814,18 @@ fn profile_08_batch_fail_singles_ok() {
|
|||||||
/// Run a single-always-bad-sector (LBA 130, inside a NonTrimmed [128,192)
|
/// Run a single-always-bad-sector (LBA 130, inside a NonTrimmed [128,192)
|
||||||
/// range) patch pass with the given failure step and return the final map
|
/// range) patch pass with the given failure step and return the final map
|
||||||
/// stats. 256-sector synthetic disc; everything outside the range is Finished.
|
/// stats. 256-sector synthetic disc; everything outside the range is Finished.
|
||||||
|
///
|
||||||
|
/// TODO(coverage gap): these HARDWARE_ERROR / ILLEGAL_REQUEST cases assert only
|
||||||
|
/// the persistent-sense RECOVERY CONTRACT (never Unreadable, byte conservation,
|
||||||
|
/// dead sector stays pending) — they do NOT exercise the patch WEDGE-EXIT path.
|
||||||
|
/// A single dead sector in one range structurally cannot reach either exit:
|
||||||
|
/// `WEDGE_ABORT_THRESHOLD=16` needs 16 CONSECUTIVE wedge-family senses within a
|
||||||
|
/// range, and the `wedged_threshold=50` exit additionally needs `range_idx > 0`
|
||||||
|
/// (a prior range already processed). No test anywhere asserts
|
||||||
|
/// `PatchOutcome::wedged_exit == true`. A real wedge-exit fixture (a first
|
||||||
|
/// throwaway range, then a second range of >=16 sectors that ALL always-fail
|
||||||
|
/// with HARDWARE_ERROR, in reverse mode) is a separate, larger synthetic build;
|
||||||
|
/// left out here rather than bent into this shared single-sector helper.
|
||||||
fn single_dead_sector_patch_stats(step: ScriptStep) -> libfreemkv::disc::mapfile::MapStats {
|
fn single_dead_sector_patch_stats(step: ScriptStep) -> libfreemkv::disc::mapfile::MapStats {
|
||||||
let capacity_sectors: u32 = 256;
|
let capacity_sectors: u32 = 256;
|
||||||
let (mut reader, _trace) = ScriptedSectorReader::new(capacity_sectors);
|
let (mut reader, _trace) = ScriptedSectorReader::new(capacity_sectors);
|
||||||
|
|||||||
Reference in New Issue
Block a user