diff --git a/src/error.rs b/src/error.rs index 937ff04..19254a8 100644 --- a/src/error.rs +++ b/src/error.rs @@ -60,6 +60,7 @@ pub const E_NO_STREAMS: u16 = 6009; pub const E_HALTED: u16 = 6010; pub const E_MAPFILE_INVALID: u16 = 6011; pub const E_UDF_BUFFER_TOO_SMALL: u16 = 6012; +pub const E_UDF_NOT_FILESYSTEM: u16 = 6013; // AACS (7xxx) pub const E_AACS_NO_KEYS: u16 = 7000; @@ -272,6 +273,14 @@ pub enum Error { UdfNotFound { path: String, }, + /// The reader was addressable but the bytes are structurally NOT a UDF + /// filesystem — a deterministic tag/format mismatch (e.g. no Anchor Volume + /// Descriptor Pointer at sector 256, no partition descriptor, no File Set + /// Descriptor). Distinct from [`Error::DiscRead`] (a transient I/O fault): + /// this is a stable property of the media, not something a retry fixes. Lets + /// callers (notably FMTS key resolution) treat "not a UDF/FMTS disc" as a + /// clean negative while still failing loud on a real read fault. + UdfNotFilesystem, /// A `SectorSource` caller passed a destination buffer smaller than one /// 2048-byte sector. A contract violation on the public reader API — /// returned instead of panicking on the slice. @@ -564,6 +573,7 @@ impl Error { Error::MplsParse => E_MPLS_PARSE, Error::ClpiParse => E_CLPI_PARSE, Error::UdfNotFound { .. } => E_UDF_NOT_FOUND, + Error::UdfNotFilesystem => E_UDF_NOT_FILESYSTEM, Error::UdfBufferTooSmall => E_UDF_BUFFER_TOO_SMALL, Error::DiscTitleRange { .. } => E_DISC_TITLE_RANGE, Error::IfoParse => E_IFO_PARSE, @@ -1275,6 +1285,7 @@ mod tests { E_HALTED, E_MAPFILE_INVALID, E_UDF_BUFFER_TOO_SMALL, + E_UDF_NOT_FILESYSTEM, E_AACS_NO_KEYS, E_AACS_CERT_SHORT, E_AACS_AGID_ALLOC, diff --git a/src/mux/driver.rs b/src/mux/driver.rs index 176b0e0..eb418b1 100644 --- a/src/mux/driver.rs +++ b/src/mux/driver.rs @@ -111,10 +111,6 @@ pub enum MuxInput<'a> { format: crate::disc::ContentFormat, /// Decryption keys for the title (`DecryptKeys::None` for raw/clear). keys: DecryptKeys, - /// Optional pre-resolved AACS key map. Carried for forward-compat and - /// the live path; the file highway re-derives its own map from - /// `keys`/`key_fetch` inside [`build_iso_pipeline`]. - key_map: Option>, /// Optional read-time key fetch closure (banked by `resolve_keys`). key_fetch: Option, }, @@ -280,7 +276,6 @@ pub fn mux_stream( title, format, keys, - key_map: _, key_fetch, } => { let reader = FileSectorSource::open(path)?; @@ -1307,7 +1302,6 @@ mod tests { title, format: crate::disc::ContentFormat::BdTs, keys: DecryptKeys::None, - key_map: None, key_fetch: None, }, "null://", @@ -1620,7 +1614,7 @@ mod tests { 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 mut session = DiscSession::from_parts_for_test(Some(disc), Some(reader), None); let opts = MuxOptions { skip_errors: false, // a DecryptFailed must PROPAGATE, not zero-fill @@ -1670,7 +1664,7 @@ mod tests { }]; let disc = aacs_session_disc(title, unit_key); // reader: None — never staged. - let mut session = DiscSession::from_parts_for_test(disc, None, None); + let mut session = DiscSession::from_parts_for_test(Some(disc), None, None); let opts = MuxOptions { skip_errors: false, diff --git a/src/mux/mp4/audio.rs b/src/mux/mp4/audio.rs index e61868c..97921ea 100644 --- a/src/mux/mp4/audio.rs +++ b/src/mux/mp4/audio.rs @@ -239,8 +239,12 @@ const DTS_SFREQ: [u32; 16] = [ 48_000, 8_000, 16_000, 32_000, 48_000, 48_000, 11_025, 22_050, 44_100, 48_000, 48_000, 12_000, 24_000, 48_000, 96_000, 192_000, ]; -/// DTS core base channel count per `AMODE` (0..=9); higher AMODEs are rare on disc. -const DTS_AMODE_CH: [u8; 10] = [1, 2, 2, 2, 2, 3, 3, 4, 4, 5]; +/// DTS core base channel count per `AMODE` (all 16 defined values). Matches the +/// reference `ff_dca_channels[16]` table (ETSI TS 102 114) that the decodability +/// gate in `dts.rs` (`DTS_AMODE_COUNT`) also uses, so a spec-legal DTS-ES / 6.1 / +/// 7.1 core (AMODE 13→7, 14/15→8) is DECLARED with its true channel count in the +/// mp4 AudioSampleEntry / `ddts` box rather than a truncated 6. +const DTS_AMODE_CH: [u8; 16] = [1, 2, 2, 2, 2, 3, 3, 4, 4, 5, 6, 6, 6, 7, 8, 8]; /// Decoded DTS core parameters needed for the `ddts` box. struct DtsConfig { @@ -557,6 +561,37 @@ mod tests { assert_eq!(&e[4..8], b"dtsh"); } + #[test] + fn dts_high_amode_channel_counts_are_declared() { + // The 16-entry DTS_AMODE_CH must declare the true core channel count for the + // spec-legal high AMODEs that now pass the decodability gate: AMODE 13→7, + // 14→8, 15→8 (ETSI TS 102 114 / ff_dca_channels). The old 10-entry table + // fell through `unwrap_or(6)` → every one of these was declared as 6. + // + // Frame layout (mirrors dts_core_5_1_and_ddts): SFREQ=13 (48k), LFF=0 (no + // LFE) so `channels` is the bare base count. AMODE is split across + // f[7] low nibble (amode>>2) and f[8] top 2 bits (amode&3). + // f[8] = (amode&3)<<6 | 13<<2 = ... (keeps SFREQ=13) + let frame = |f7: u8, f8: u8| { + vec![ + 0x7F, 0xFE, 0x80, 0x01, 0x00, 0x05, 0xF2, f7, f8, 0x00, 0x00, 0x00, + ] + }; + // AMODE 13 → base 7 channels. + let c = parse_dts(&frame(0xF3, 0x74)).expect("amode 13 parses"); + assert_eq!(c.amode, 13); + assert!(!c.lfe); + assert_eq!(c.channels, 7, "AMODE 13 core is 7 channels, not 6"); + // AMODE 14 → base 8 channels. + let c = parse_dts(&frame(0xF3, 0xB4)).expect("amode 14 parses"); + assert_eq!(c.amode, 14); + assert_eq!(c.channels, 8, "AMODE 14 core is 8 channels, not 6"); + // AMODE 15 → base 8 channels. + let c = parse_dts(&frame(0xF3, 0xF4)).expect("amode 15 parses"); + assert_eq!(c.amode, 15); + assert_eq!(c.channels, 8, "AMODE 15 core is 8 channels, not 6"); + } + #[test] fn sample_entry_samplerate_does_not_overflow_at_96k() { // 96 kHz > 65535: the 16.16 integer part must saturate, not wrap to garbage. diff --git a/src/mux/mp4/read.rs b/src/mux/mp4/read.rs index 6100bc5..f889a13 100644 --- a/src/mux/mp4/read.rs +++ b/src/mux/mp4/read.rs @@ -201,6 +201,15 @@ impl Mp4Reader { let durations = find_box(stbl, b"stts") .map(|b| parse_stts(b, n)) .unwrap_or_default(); + if durations.is_empty() { + // Samples exist but there is no decoding-time table: `stts` is + // mandatory in a valid stbl (ISO/IEC 14496-12 §8.6.1). Without it + // every sample would take dur=0 → all-zero, identical timestamps, + // collapsing the whole track onto one instant. Drop the track + // rather than emit degenerate timing (mirrors the stco/stsc + // guards above); an all-tracks-dropped file fails Mp4Invalid below. + continue; + } let ctts = find_box(stbl, b"ctts") .map(|b| parse_ctts(b, n)) .unwrap_or_default(); @@ -1216,11 +1225,21 @@ mod tests { p.extend_from_slice(&0u32.to_be_bytes()); // sample_desc_idx mp4_box(b"stsc", &p) }; + let stts = { + // Mandatory time-to-sample box: 1 entry → 1 sample × 1000 ticks. + let mut p = Vec::new(); + p.extend_from_slice(&[0, 0, 0, 0]); // version+flags + p.extend_from_slice(&1u32.to_be_bytes()); // entry_count + p.extend_from_slice(&1u32.to_be_bytes()); // sample_count + p.extend_from_slice(&1000u32.to_be_bytes()); // sample_delta + mp4_box(b"stts", &p) + }; let mut stbl = Vec::new(); stbl.extend_from_slice(&stsd); stbl.extend_from_slice(&stsz); stbl.extend_from_slice(&stco); stbl.extend_from_slice(&stsc); + stbl.extend_from_slice(&stts); let minf = mp4_box(b"minf", &mp4_box(b"stbl", &stbl)); let mut mdia = Vec::new(); mdia.extend_from_slice(&mdhd); @@ -1319,11 +1338,20 @@ mod tests { p.extend_from_slice(&0u32.to_be_bytes()); // sample_desc_idx mp4_box(b"stsc", &p) }; + let stts = { + let mut p = Vec::new(); + p.extend_from_slice(&[0, 0, 0, 0]); // version+flags + p.extend_from_slice(&1u32.to_be_bytes()); // entry_count + p.extend_from_slice(&1u32.to_be_bytes()); // sample_count + p.extend_from_slice(&1000u32.to_be_bytes()); // sample_delta + mp4_box(b"stts", &p) + }; let mut stbl = Vec::new(); stbl.extend_from_slice(&stsd); stbl.extend_from_slice(&stsz); stbl.extend_from_slice(&stco); stbl.extend_from_slice(&stsc); + stbl.extend_from_slice(&stts); let minf = mp4_box(b"minf", &mp4_box(b"stbl", &stbl)); let mut mdia = Vec::new(); mdia.extend_from_slice(&mdhd); @@ -1397,6 +1425,15 @@ mod tests { p.extend_from_slice(&0u32.to_be_bytes()); // sample_desc_idx mp4_box(b"stsc", &p) }; + let stts = { + // Mandatory time-to-sample box: 3 entries' worth via one run (3×1000). + let mut p = Vec::new(); + p.extend_from_slice(&[0, 0, 0, 0]); // version+flags + p.extend_from_slice(&1u32.to_be_bytes()); // entry_count + p.extend_from_slice(&3u32.to_be_bytes()); // sample_count + p.extend_from_slice(&1000u32.to_be_bytes()); // sample_delta + mp4_box(b"stts", &p) + }; let mut stbl = Vec::new(); stbl.extend_from_slice(&stsd); stbl.extend_from_slice(&stsz); @@ -1406,6 +1443,9 @@ mod tests { if omit != b"stsc" { stbl.extend_from_slice(&stsc); } + if omit != b"stts" { + stbl.extend_from_slice(&stts); + } let minf = mp4_box(b"minf", &mp4_box(b"stbl", &stbl)); let mut mdia = Vec::new(); mdia.extend_from_slice(&mdhd); @@ -1446,6 +1486,23 @@ mod tests { ); } + /// A track with samples (`stsz`) but no time-to-sample table (`stts`, mandatory + /// per ISO/IEC 14496-12 §8.6.1) must be DROPPED — without it every sample takes + /// dur=0, collapsing the whole track onto one instant (all-zero timestamps). + /// With it the only track, the file fails `Mp4Invalid`. + /// Mutation check: delete the `if durations.is_empty() { continue; }` guard and + /// `from_reader` returns `Ok` (all-zero-timestamp samples), flipping this to FAIL. + #[test] + fn missing_stts_drops_track_all_dropped_is_invalid() { + use std::io::Cursor; + let moov = mp4_box(b"moov", &audio_trak_missing(b"stts")); + let rd = Mp4Reader::from_reader(Cursor::new(moov), "no-stts".into()); + assert!( + rd.is_err(), + "a track with stsz but no stts must be dropped; all-dropped → Mp4Invalid" + ); + } + /// Sanity companion: the SAME builder WITH both tables present yields a valid, /// indexed single-track file — proving the two Err results above come from the /// missing table, not from some unrelated defect in the fixture builder. diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 6f7512e..1d989f2 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -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 { + 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)"); + } } diff --git a/src/session.rs b/src/session.rs index a41f43b..0f86f44 100644 --- a/src/session.rs +++ b/src/session.rs @@ -381,9 +381,12 @@ impl DiscSession { /// 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. + /// + /// `disc` is an `Option` so a test can construct a session that has NOT been + /// scanned (`None`) to exercise the `resolve_keys` "called before scan" guard. #[cfg(test)] pub(crate) fn from_parts_for_test( - disc: Disc, + disc: Option, reader: Option>, key_fetch: Option, ) -> DiscSession { @@ -391,7 +394,7 @@ impl DiscSession { drive: None, device: "test://session".to_string(), spec: KeySpec::default(), - disc: Some(disc), + disc, reader, key_fetch, } @@ -733,4 +736,22 @@ mod tests { "the disc is left untouched" ); } + + /// `resolve_keys` called before `scan` (disc slot still `None`) must return the + /// clean typed `DeviceNotReady` guard, never reach the `.expect("disc present + /// (checked above)")` below it and panic. + /// + /// Mutation: change the `if self.disc.is_none()` guard to `.expect()`/panic + /// (e.g. drop the early return) → this test panics instead of getting an Err. + #[test] + fn resolve_keys_before_scan_is_clean_device_not_ready() { + let mut session = DiscSession::from_parts_for_test(None, None, None); + let err = session + .resolve_keys(factory_of(|| HasUnitKey([1; 16]))) + .expect_err("resolve_keys before scan must error, not panic"); + assert!( + matches!(err, Error::DeviceNotReady { .. }), + "expected DeviceNotReady, got {err:?}" + ); + } } diff --git a/src/udf.rs b/src/udf.rs index 1655b31..2219643 100644 --- a/src/udf.rs +++ b/src/udf.rs @@ -731,11 +731,9 @@ pub fn read_filesystem(reader: &mut dyn SectorSource) -> Result { let tag_id = u16::from_le_bytes([avdp[0], avdp[1]]); if tag_id != 2 { - return Err(Error::DiscRead { - sector: 256, - status: None, - sense: None, - }); + // Sector 256 read fine but carries no Anchor Volume Descriptor Pointer: + // this is deterministically not a UDF disc, not a transient read fault. + return Err(Error::UdfNotFilesystem); } // Main VDS extent location: bytes [16:20] = LBA, [20:24] = length @@ -776,11 +774,8 @@ pub fn read_filesystem(reader: &mut dyn SectorSource) -> Result { } if partition_start == 0 { - return Err(Error::DiscRead { - sector: 0, - status: None, - sense: None, - }); + // No Partition Descriptor found in the VDS: structurally not a UDF disc. + return Err(Error::UdfNotFilesystem); } // Step 3: Parse partition maps from LVD to find metadata partition @@ -871,11 +866,9 @@ pub fn read_filesystem(reader: &mut dyn SectorSource) -> Result { let fsd_tag = u16::from_le_bytes([fsd[0], fsd[1]]); if fsd_tag != 256 { - return Err(Error::DiscRead { - sector: metadata_start as u64, - status: None, - sense: None, - }); + // The metadata sector read fine but carries no File Set Descriptor: + // structurally not a UDF disc, not a transient read fault. + return Err(Error::UdfNotFilesystem); } // Root Directory ICB: long_ad at FSD offset 400