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} (the spec per-AMODE channel table / 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 9b3e281f4d
commit b79ff71b43
7 changed files with 248 additions and 32 deletions
+37 -2
View File
@@ -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.
+57
View File
@@ -201,6 +201,15 @@ impl<R: Read + Seek> Mp4Reader<R> {
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.