Fix read-fault misclassification, DTS AMODE channel table, and untestable guards

- resolve_fmts_key_map: distinguish a genuinely-not-FMTS disc from a
  transient live-drive read fault. read_filesystem now returns the new
  Error::UdfNotFilesystem for a deterministic tag/format mismatch (no AVDP,
  no partition descriptor, no FSD); resolve maps only UdfNotFilesystem (fs)
  and UdfNotFound (.tbl absent) to Ok(None), and PROPAGATES DiscRead / other
  I/O faults so a marginal AACS 2.1 disc fails loud instead of silently
  dropping forensic content under a base-Unit-Key-only map.

- DTS_AMODE_CH (mp4/audio.rs): extend 10→16 entries
  {1,2,2,2,2,3,3,4,4,5,6,6,6,7,8,8} (ff_dca_channels / ETSI TS 102 114) so
  the spec-legal high AMODEs that now pass the decodability gate declare
  their true channelcount (AMODE 13→7, 14/15→8) instead of a truncated 6.

- session.rs resolve_keys "called before scan" guard is now testable:
  from_parts_for_test takes Option<Disc>; added a test that a disc-less
  session returns a clean DeviceNotReady Err rather than panicking.

- mp4/read.rs: a track with samples but a missing/malformed stts (mandatory
  per ISO/IEC 14496-12) is dropped rather than emitting all-zero timestamps,
  matching the existing stco/stsc guards; all-tracks-dropped → Mp4Invalid.

- Remove the inert MuxInput::Iso.key_map field (the Iso path re-derives its
  map inside build_iso_pipeline); the live path keeps Live.key_map.

All four fixes are mutation-verified.
This commit is contained in:
Matthew Jackson
2026-07-24 09:59:33 -07:00
parent 7ba43d9c02
commit eb0aa3556e
7 changed files with 248 additions and 32 deletions
+110 -5
View File
@@ -789,12 +789,26 @@ fn resolve_fmts_key_map(
Ok(())
};
// Load the segment map; absent → not an FMTS disc.
let Ok(udf) = crate::udf::read_filesystem(reader) else {
return Ok(None);
// Load the segment map. Distinguish a genuine "not an FMTS disc" negative
// from a transient live-drive I/O fault: swallowing the latter into Ok(None)
// would fall through to a base-Unit-Key-only map, garble the forensic units,
// let the demux drop them, and complete the mux with NO error — silently
// losing forensic content, contradicting this function's fail-loud contract.
// - `UdfNotFilesystem`: bytes read fine but are not a UDF disc (deterministic
// tag/format mismatch) → genuinely not FMTS → Ok(None).
// - `UdfNotFound`: the disc is UDF but has no `IndividualSegment.tbl`
// → genuinely not FMTS → Ok(None).
// - any other error (notably `DiscRead`): a read fault → propagate so the
// rip fails loud / can be retried rather than dropping forensic content.
let udf = match crate::udf::read_filesystem(reader) {
Ok(u) => u,
Err(crate::error::Error::UdfNotFilesystem) => return Ok(None),
Err(e) => return Err(e.into()),
};
let Ok(tbl) = udf.read_file(reader, "/AACS/IndividualSegment.tbl") else {
return Ok(None);
let tbl = match udf.read_file(reader, "/AACS/IndividualSegment.tbl") {
Ok(t) => t,
Err(crate::error::Error::UdfNotFound { .. }) => return Ok(None),
Err(e) => return Err(e.into()),
};
let Some(segments) = parse_individual_segments(&tbl) else {
return Ok(None);
@@ -2834,4 +2848,95 @@ mod tests {
"the fetched key is appended at pool index 2 and keys the extent"
);
}
// ── Fix 1: read-fault vs genuinely-not-FMTS in resolve_fmts_key_map ──────
/// A SectorSource whose every read is a transient I/O fault (`DiscRead`),
/// modelling a marginal live drive stalling while `resolve_fmts_key_map`
/// probes the UDF metadata / segment table.
struct FaultSource;
impl SectorSource for FaultSource {
fn capacity_sectors(&self) -> u32 {
1_000_000
}
fn read_sectors(
&mut self,
lba: u32,
_count: u16,
_buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
Err(crate::error::Error::DiscRead {
sector: lba as u64,
status: None,
sense: None,
})
}
}
/// A transient `DiscRead` fault while reading the UDF metadata for the segment
/// table must PROPAGATE (fail loud / retryable), NOT be swallowed into the
/// not-FMTS `Ok(None)` fall-through — otherwise a marginal AACS 2.1 disc would
/// silently drop its forensic content under a base-Unit-Key-only map and the
/// mux would report success.
///
/// Mutation: revert the read_filesystem arm to `let Ok(udf) = ... else { return
/// Ok(None) }` → this returns `Ok(None)` and the assert fails.
#[test]
fn resolve_fmts_key_map_read_fault_propagates() {
let mut reader = FaultSource;
let title = multi_cps_title(1000, 30);
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0x01u8; 16])],
read_data_key: None,
format: ContentFormat::BdTs,
};
let got = super::resolve_fmts_key_map(
&mut reader,
&title,
&mut keys,
None,
ContentFormat::BdTs,
None,
);
let err = got.expect_err("a transient DiscRead must fail loud, never Ok(None)");
let expected = std::io::Error::from(crate::error::Error::DiscRead {
sector: 256,
status: None,
sense: None,
})
.to_string();
assert_eq!(
err.to_string(),
expected,
"the DiscRead fault must propagate"
);
}
/// A reader whose bytes are structurally NOT a UDF disc (all zeros → no AVDP at
/// sector 256 → `UdfNotFilesystem`) is genuinely not FMTS: it must map to the
/// clean `Ok(None)` negative, NOT fail loud. Guards against Fix 1 over-reaching
/// and rejecting benign non-FMTS discs.
#[test]
fn resolve_fmts_key_map_not_udf_is_clean_none() {
// CipherSource with no registered units reads as all zeros everywhere, so
// read_filesystem sees tag_id 0 at sector 256 → UdfNotFilesystem.
let mut reader = CipherSource { units: Vec::new() };
let title = multi_cps_title(1000, 30);
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0x01u8; 16])],
read_data_key: None,
format: ContentFormat::BdTs,
};
let got = super::resolve_fmts_key_map(
&mut reader,
&title,
&mut keys,
None,
ContentFormat::BdTs,
None,
)
.expect("a structurally non-UDF disc is a clean not-FMTS negative");
assert!(got.is_none(), "not a UDF/FMTS disc → Ok(None)");
}
}