libfreemkv: 10-phase release audit fixes (v1.5.2..HEAD)

Multi-round audit of the decrypt/AACS/mux-codec refactor. Fixes, in
descending severity:

- mux/mp4/read.rs: bound untrusted-input allocations. `sample_budget`
  now also capped by file_len (a fixed-size stsz claiming count=u32::MAX
  can't inflate the Vec<SampleRef> past the file's own size); trak scan
  capped at MAX_TRACKS matches; find_box() takes only the first match
  (cap=1) instead of materializing every match. Removes dead find_boxes
  wrapper.
- disc/mod.rs: merge_content_key_ranges now UNIONS same-key overlapping
  ranges (coverage-preserving) instead of dropping the non-overlapping
  tail, which silently left encrypted LBAs uncovered -> ciphertext
  passthrough in the whole-disc sweep/patch map. Different-key overlap
  (malformed) still dropped to keep the set disjoint.
- sector/decrypting.rs: remove dead unit_key_idx field + with_unit_key_idx
  setter (vestigial from the pre-keymap trial-decrypt design; AACS is
  map-only now). Fix stale docs.
- decrypt.rs / resolve.rs / error.rs / extract.rs: doc/comment drift from
  the refactor (AacsKeyMap positive-map semantics, resolve_mux_key_map doc
  reattachment, decrypt_sectors_in_content legacy-alias, E_MP4_INVALID
  meaning, multi-CPS orphan by-design note).

Test coverage (all mutation-verified real):
- DTS NeedMore force-flush buffer bound; FLAC/MPEG-audio PTS carry-forward;
  mp4 mdhd timescale=0 divide-by-zero guard, MAX_TRACKS cap, sample-count
  file_len bound, MAX_ALLOC_BYTES cap under inflated file_len.
- resolve_fmts_key_map: extracted filter_addressable_segments,
  resolve_tie_phase, fill_base_key_gaps as pure behavior-preserving
  helpers, each unit-tested (segment filter, phase-tie arms, gap-fill
  gaplessness over every extent).
This commit is contained in:
Matthew Jackson
2026-07-23 22:09:04 -07:00
parent 8ac18fa631
commit b9568242df
10 changed files with 894 additions and 100 deletions
+45
View File
@@ -1473,6 +1473,51 @@ mod tests {
);
}
#[test]
fn needmore_past_cap_force_flushes_to_bound_buffer() {
// A crafted DTS-HD stream whose extension substream declares a size
// larger than what is (ever) buffered keeps `next_core_boundary` in a
// sustained NeedMore state (a candidate boundary that is never fully
// buffered). Once `buf` exceeds MAX_AU_BYTES the NeedMore force-flush
// safety valve must fire — mirroring the None arm — so the buffer can't
// grow without bound. WITHOUT the guard the parser would `break` and
// retain everything, emitting nothing.
let mut parser = DtsParser::new();
let core = make_dts_core(512);
// Short-form EXSS header declaring the maximum 16-bit size (65536 bytes);
// we buffer only a truncated prefix of it, so the extension is never
// "fully buffered" and the candidate boundary stays NeedMore.
let full_ext = make_exss(65536, None);
assert_eq!(exss_frame_size(&full_ext), Some(65536));
// Land the total buffer in (MAX_AU_BYTES, core_size + declared_ext_size):
// 65600 > 65536 fires the cap; 65600 < 512 + 65536 = 66048 keeps NeedMore.
let total = 65600usize;
let mut data = core.clone();
data.extend_from_slice(&full_ext[..total - core.len()]);
assert!(data.len() > MAX_AU_BYTES, "buffer must exceed the AU cap");
assert!(
data.len() < core.len() + 65536,
"extension must not be fully buffered (sustained NeedMore)"
);
assert!(
matches!(next_core_boundary(&data, core.len()), NextCore::NeedMore),
"the framing decision at this buffer size is NeedMore past the cap"
);
let frames = parser.parse(&make_pes(data, Some(90000)));
assert_eq!(
frames.len(),
1,
"NeedMore past the AU cap must force-emit, not stall and balloon the buffer"
);
assert!(
parser.buf.is_empty(),
"the forced flush drains the buffer instead of growing it unbounded"
);
}
#[test]
fn codec_private_none() {
let parser = DtsParser::new();
+16
View File
@@ -177,6 +177,22 @@ mod tests {
assert_eq!(p.dropped_frames(), 0);
}
#[test]
fn pes_without_pts_carries_last_timestamp_not_zero() {
// A PES with no PTS (legal for audio, e.g. after a discontinuity) must
// carry the last known timestamp forward — resetting to 0 would corrupt
// A/V sync. Mirrors the adts.rs guard test.
let mut p = FlacParser::new();
p.parse(&make_pes(make_flac_frame(100), Some(90000)));
let f = p.parse(&make_pes(make_flac_frame(100), None));
assert_eq!(f.len(), 1);
assert_eq!(
f[0].pts_ns,
pts_to_ns(90000),
"carried forward, not reset to 0"
);
}
#[test]
fn corrupt_frame_is_dropped() {
let mut p = FlacParser::new();
+16
View File
@@ -167,6 +167,22 @@ mod tests {
assert_eq!(p.dropped_frames(), 0);
}
#[test]
fn pes_without_pts_carries_last_timestamp_not_zero() {
// A PES with no PTS (legal for audio, e.g. after a discontinuity) must
// carry the last known timestamp forward — resetting to 0 would corrupt
// A/V sync. Mirrors the adts.rs guard test.
let mut p = MpegAudioParser::new();
p.parse(&make_pes(mp3_frame(400), Some(90000)));
let f = p.parse(&make_pes(mp3_frame(400), None));
assert_eq!(f.len(), 1);
assert_eq!(
f[0].pts_ns,
pts_to_ns(90000),
"carried forward, not reset to 0"
);
}
#[test]
fn reserved_version_field_is_dropped() {
// version field = 01 (reserved) → rejected. byte1 = 111_01_01_1 = 0xEB