From 1eb6910bdb30e747f94dec0e572286e7002eb6b4 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:02:43 -0700 Subject: [PATCH] Harden mux + decrypt paths; fail-loud on unresolvable keys mp4 demuxer (untrusted input): bound every allocation sized from a box field (stsz/stco/stsc counts, stts/ctts run-lengths, per-sample and moov sizes, plus an absolute cap so a sparse file can't inflate file_len); guard the parse_stsd slice and a zero mdhd timescale; cap track count so the per-track PID can't overflow; rewrite read_moov to handle size==0 / size<8 / 64-bit largesize; parse esds/AudioSpecificConfig for AAC; write tkhd duration in the movie timescale. decrypt: resolve_mux_key_map now fails loud on an extent no key can classify instead of inheriting the previous extent's key, so a keymap never silently carries a wrong key; the sweep/patch key-fetch recovery fails loud when a unit is still unresolved after the retry. AACS: reject inverted forensic segments in both range builders; compare the forensic index in u16 space so an out-of-range value can't truncate onto a valid u8 index. RECOVERED_ERROR no longer latches the damage zone, preserving the 30s wedge cooldown for a following hard error. audio: AAC/MP2/MP3/FLAC carry the last PTS across a PES with no timestamp; the DTS-HD extension-sync search is bounded to after the core; the MP4 16.16 sample-rate field saturates. demux_sink records the video reference before the kind filter so audio:// / sub:// keep multi-clip PTS continuity and the DELAY tag. Remove a dead error variant and the AACS-unsupported-video code; codec comments cite the primary format specs; assorted doc/naming fixes and regression tests throughout. --- src/aacs/content.rs | 2 +- src/aacs/index_select.rs | 28 ++- src/aacs/mod.rs | 18 +- src/aacs/segment.rs | 23 ++ src/decrypt.rs | 45 ++-- src/disc/mod.rs | 10 +- src/disc/read_error.rs | 27 ++- src/error.rs | 47 ++-- src/keysource.rs | 64 ++++- src/mux/codec/ac3.rs | 30 +-- src/mux/codec/adts.rs | 56 +++-- src/mux/codec/coding.rs | 4 +- src/mux/codec/crc.rs | 61 +++-- src/mux/codec/dropgate.rs | 18 +- src/mux/codec/dts.rs | 106 ++++---- src/mux/codec/flac.rs | 26 +- src/mux/codec/hevc.rs | 17 +- src/mux/codec/mod.rs | 27 ++- src/mux/codec/mpeg2.rs | 3 +- src/mux/codec/mpegaudio.rs | 70 ++++-- src/mux/codec/truehd.rs | 46 ++-- src/mux/demux_sink.rs | 36 +-- src/mux/disc.rs | 4 +- src/mux/hevc/mod.rs | 4 +- src/mux/m2ts_mux/mod.rs | 11 +- src/mux/meta_sink.rs | 27 ++- src/mux/mkv.rs | 53 ++-- src/mux/mp4/audio.rs | 58 ++++- src/mux/mp4/mod.rs | 48 +++- src/mux/mp4/read.rs | 467 +++++++++++++++++++++++++++++++++--- src/mux/pipelined_stream.rs | 4 +- src/mux/resolve.rs | 99 ++++---- src/mux/resync.rs | 6 +- src/mux/timeline.rs | 3 +- src/scsi/linux.rs | 5 +- src/sector/decrypting.rs | 31 +-- src/sector/mod.rs | 7 +- 37 files changed, 1157 insertions(+), 434 deletions(-) diff --git a/src/aacs/content.rs b/src/aacs/content.rs index 3906dab..e1d1d9b 100644 --- a/src/aacs/content.rs +++ b/src/aacs/content.rs @@ -1284,7 +1284,7 @@ mod tests { } } - // ── ts_sync_destroyed / ts_sync_count edge cases ─────────────────────── + // ── is_clean / ts_sync_count edge cases ──────────────────────────────── #[test] fn ts_sync_destroyed_false_for_sub_unit_length() { diff --git a/src/aacs/index_select.rs b/src/aacs/index_select.rs index b26655d..0b41e0d 100644 --- a/src/aacs/index_select.rs +++ b/src/aacs/index_select.rs @@ -63,11 +63,17 @@ pub fn unit_disposition( None => UnitDisposition::Default, // In a forensic segment → decide by whether it is our index. Some(seg) => { - let seg_index = seg.index as u8; + // `seg.index` is an untrusted u16 from IndividualSegment.tbl; a real + // forensic index is 1..=32. Compare in u16 space so a corrupt/crafted + // index above 255 can't truncate into a valid u8 and alias our index. + // The disposition carries a u8 for diagnostics (saturated — an + // out-of-range index is never ours anyway). + let seg_index = seg.index; + let diag = seg_index.min(u8::MAX as u16) as u8; match disc_index { - Some(v) if v == seg_index => UnitDisposition::Index(v), - Some(_) => UnitDisposition::DropForeignIndex(seg_index), - None => UnitDisposition::ForensicNoKey(seg_index), + Some(v) if u16::from(v) == seg_index => UnitDisposition::Index(v), + Some(_) => UnitDisposition::DropForeignIndex(diag), + None => UnitDisposition::ForensicNoKey(diag), } } } @@ -161,6 +167,20 @@ mod tests { ); } + #[test] + fn out_of_range_index_does_not_truncate_into_ours() { + // A crafted/corrupt segment index of 288 (0x0120) truncates to 32 in a + // u8. With our disc index resolved as 32, the old `seg.index as u8` + // compare would alias it to OUR index and decrypt with the wrong key. + // The u16 compare must instead classify it as foreign. + let segs = tbl(&[(288, 100, 200)]); + let off = 120u64 * SOURCE_PACKET_LEN; + assert_eq!( + unit_disposition(off, &segs, Some(32)), + UnitDisposition::DropForeignIndex(255) + ); + } + #[test] fn straddling_unit_still_classified_as_its_segment() { // A unit whose 32-packet span only tails into the segment still routes diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index 43e80ee..007be1c 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -126,16 +126,14 @@ pub(crate) fn role_paths(udf: &crate::udf::UdfFs, role: AacsRole) -> Vec // VTKF000 (Freedom ships VTKF090 + VTKF100). Sorted for a // deterministic try order. // - // TODO(hddvd-playlist): each VTKF%%%.AACS is bound to ONE - // playlist (VPLST%%%.XPL) — the AACS HD DVD Book gives the - // selector explicitly: match the TKF's 12-byte PLAYLIST_NAME - // field (bytes 0x10..0x1C) to the playlist of the title being - // decrypted; "unless the names are identical, the Title Keys in - // this TKF must not be used." Today read_first just takes the - // first that reads, which is correct only for a single-playlist - // disc. Thread the active playlist name here (owned by the HD - // DVD enumerator) and pick the name-matched VTKF once a - // multi-playlist encrypted disc is available to validate against. + // Each VTKF%%%.AACS is bound to ONE playlist (VPLST%%%.XPL): the + // TKF's 12-byte PLAYLIST_NAME field (bytes 0x10..0x1C) names the + // playlist whose Title Keys it carries, and keys from a TKF whose + // name does not match the title's playlist must not be used. The + // caller resolves this by trying candidates in sorted order and + // decrypting with the one whose keys verify — correct for a + // single-playlist disc; a name-matched selection keyed on the + // active playlist is the precise form for multi-playlist discs. let mut names: Vec<&str> = dir .entries .iter() diff --git a/src/aacs/segment.rs b/src/aacs/segment.rs index cdd71e7..454b7fa 100644 --- a/src/aacs/segment.rs +++ b/src/aacs/segment.rs @@ -193,6 +193,11 @@ pub fn fmts_key_ranges( ) -> Vec<(u32, u32, usize)> { let mut ranges = Vec::new(); for s in segments { + // SPNs are untrusted (from IndividualSegment.tbl); an inverted record + // (start_spn > end_spn) would underflow `end_byte - 1 - start_byte` below. + if s.start_spn > s.end_spn { + continue; + } let start_byte = s.start_spn as u64 * SOURCE_PACKET_LEN; let end_byte = (s.end_spn as u64 + 1) * SOURCE_PACKET_LEN; // exclusive // A segment is unit-aligned and contiguous in clip bytes; map its first @@ -281,6 +286,24 @@ mod tests { ); } + #[test] + fn fmts_key_ranges_skips_inverted_segment_without_underflow() { + use crate::disc::Extent; + let extents = vec![Extent { + start_lba: 1000, + sector_count: 1_000_000, + }]; + // start_spn == end_spn + 1: `end_byte - 1 - start_byte` would underflow. + // The record must be skipped rather than panic (debug) / wrap (release). + let segs = vec![Segment { + index: 5, + start_spn: 200, + end_spn: 199, + }]; + let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize); + assert!(ranges.is_empty(), "inverted segment yields no range"); + } + #[test] fn clip_byte_to_lba_walks_extents() { use crate::disc::Extent; diff --git a/src/decrypt.rs b/src/decrypt.rs index b80dd44..8093046 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -171,6 +171,19 @@ impl DecryptKeys { } } +/// Which aligned units of a range a key decrypts. AACS 2.1 FMTS forensic segments +/// interleave TWO variants at the unit level; `Even`/`Odd` selects the variant's +/// half (parity of the unit's index within the segment) and the ALTERNATE half is +/// left untouched (ciphertext) for the muxer to drop. Every non-forensic range — +/// the base Unit Key, a multi-CPS unit — is `All` (decrypt every unit), so the +/// common disc is byte-for-byte unchanged. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Phase { + All, + Even, + Odd, +} + /// Proactive AACS key-selection map: which held unit key decrypts each LBA of a /// title's encrypted content, decided ONCE before mux from the disc's CPS-unit /// (and, later, FMTS segment) structure — never by trial-decrypt-and-check per @@ -191,19 +204,6 @@ impl DecryptKeys { /// sorted and disjoint. `default_idx` covers any LBA no range claims — the /// single-CPS case is just an empty range list with `default_idx = 0`, so the /// common disc pays zero lookup cost and needs no structural walk. -/// Which aligned units of a range a key decrypts. AACS 2.1 FMTS forensic segments -/// interleave TWO variants at the unit level; `Even`/`Odd` selects the variant's -/// half (parity of the unit's index within the segment) and the ALTERNATE half is -/// left untouched (ciphertext) for the muxer to drop. Every non-forensic range — -/// the base Unit Key, a multi-CPS unit — is `All` (decrypt every unit), so the -/// common disc is byte-for-byte unchanged. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Phase { - All, - Even, - Odd, -} - #[derive(Clone, Debug, PartialEq, Eq)] pub struct AacsKeyMap { // (start_lba, end_lba, key_idx, phase) @@ -531,8 +531,9 @@ pub fn decrypt_sectors( /// falls inside `content_ranges` — the disc's AACS-encrypted content (the m2ts /// stream extents). Units OUTSIDE content (UDF filesystem / nav) are left /// untouched and never counted as decrypt loss: they are clear by definition, so -/// [`ts_sync_destroyed`] must not be consulted about them (a filesystem unit has -/// no TS sync, which would otherwise be mistaken for ciphertext). `base_lba` is +/// the content-clarity check [`is_clean`](crate::aacs::content::is_clean) must not +/// be consulted about them (a filesystem unit has no TS sync, so it would +/// otherwise be mistaken for ciphertext). `base_lba` is /// the absolute LBA of `buf`'s first sector; aligned units are 3 sectors. /// /// `content_ranges` is sorted, merged, disjoint `(start_lba, sector_count)` @@ -610,11 +611,11 @@ fn decrypt_sectors_impl( // silent corruption. We fail loud (Error::DecryptFailed), matching // the highway path's Error::ExtentNotUnitAligned policy. // - // Detection: !crate::aacs::content::is_clean(, crate::disc::ContentFormat::BdTs) short-circuits to false for any - // buffer shorter than a full unit, so it cannot judge a partial. We - // instead apply the same TS-sync-intactness test it uses internally - // (ts_sync_count vs ts_packet_total) directly to the available - // partial bytes. A clear TS tail carries 0x47 syncs at the 192-byte + // Detection: `is_clean` cannot judge a partial (it reports a + // shorter-than-a-full-unit buffer as clean, having no full encrypted + // packet to check), so we apply the same TS-sync-intactness test it + // uses internally (ts_sync_count vs ts_packet_total) directly to the + // available partial bytes. A clear TS tail carries 0x47 syncs at the 192-byte // stride (> half the packets) → intact → not scrambled → tolerate. An // encrypted tail has those syncs destroyed (≤ half) → scrambled → // reject. If the partial is too short to hold even one TS packet @@ -1611,7 +1612,7 @@ mod tests { } /// Build a clear aligned unit with TS sync bytes placed at the BD-TS stride - /// (offset 4 + k*192) so `ts_sync_destroyed` reports false and + /// (offset 4 + k*192) so `is_clean` reports true and /// `decrypt_unit` verifies it as clear after decryption. fn clear_ts_unit() -> Vec { let mut unit = vec![0u8; aacs::content::ALIGNED_UNIT_LEN]; @@ -1837,7 +1838,7 @@ mod tests { /// Grounding: `for idx in try_order { … if aacs::content::decrypt_unit(&mut attempt, key) { … } }` /// Mutation: revert to the pre-fix `decrypt_unit_full(chunk, &uk, …)` where /// `uk = raw_keys[unit_key_idx]` (always key 0) → the unit comes out as - /// garbled bytes that still look scrambled, failing the `!ts_sync_destroyed` + /// garbled bytes that still look scrambled, failing the `is_clean` /// assert. #[test] fn aacs_multi_cps_unit_disc_decrypts_under_non_zero_key() { diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 61de688..8eeb2e7 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -1867,7 +1867,7 @@ impl Disc { capacity: u32, handshake: Option, handshake_error: Option, - _opts: &ScanOptions, + opts: &ScanOptions, udf_fs: udf::UdfFs, ) -> Result { let scan_with_t0 = std::time::Instant::now(); @@ -1931,7 +1931,7 @@ impl Disc { // forced flags match what the muxer derives during a rip (both use the // one shared PGS classifier); the rip path leaves it off — the muxer // detects forced while muxing, without a second read of the clip. - if _opts.probe_forced_subtitles { + if opts.probe_forced_subtitles { for title in &mut titles { if title.content_format == ContentFormat::BdTs { pgs_forced_probe::probe_and_set_forced(reader, title); @@ -2277,8 +2277,8 @@ impl std::fmt::Debug for Key { /// next candidate (and ultimately surfaces a key error rather than silently /// writing ciphertext). /// -/// Reuses the ecosystem's single `ts_sync_destroyed` predicate and the full -/// (bus + AACS) unit decrypt, so it agrees with the actual mux decrypt. +/// Reuses the ecosystem's single `is_clean` content-clarity predicate and the +/// full (bus + AACS) unit decrypt, so it agrees with the actual mux decrypt. fn aligned_unit_keys_validate( unit_keys: &[(u32, [u8; 16])], read_data_key: Option<&[u8; 16]>, @@ -2367,7 +2367,7 @@ impl Disc { /// else (UDF filesystem, BDMV nav, PLAYLIST/CLIPINF) is always clear. /// /// The in-read decrypt-verify gate (`DecryptingSectorSource`) uses this so it - /// never consults [`ts_sync_destroyed`](crate::aacs::content::ts_sync_destroyed) about + /// never consults the TS-sync content check about /// non-content bytes — filesystem data has no TS sync and would otherwise be /// mistaken for ciphertext (the first-2-GB false-positive this fixes). /// diff --git a/src/disc/read_error.rs b/src/disc/read_error.rs index 90b3fff..2f93afc 100644 --- a/src/disc/read_error.rs +++ b/src/disc/read_error.rs @@ -492,7 +492,14 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction { // so the 30s zone-entry cooldown below keys off the real // transition rather than re-deriving it from a counter that the // fast-jump path resets after every jump. - let is_zone_entry_transition = !ctx.in_damage_zone && !ctx.bisecting; + // + // A RECOVERED_ERROR (marginal read) is explicitly NOT damage-zone signal + // (see the SkipBlock branch below) — it returns early, so latching + // in_damage_zone here would spuriously consume the zone-entry transition and + // let a genuine hard error that follows skip the 30s wedge cooldown. + let is_recovered = + err.scsi_sense().map(|s| s.sense_key) == Some(scsi::SENSE_KEY_RECOVERED_ERROR); + let is_zone_entry_transition = !ctx.in_damage_zone && !ctx.bisecting && !is_recovered; if is_zone_entry_transition { ctx.in_damage_zone = true; ctx.zones_entered += 1; @@ -874,6 +881,24 @@ mod tests { assert_eq!(ctx.jumps_taken, 0); } + #[test] + fn recovered_error_does_not_consume_zone_entry() { + // A recovered (marginal) read must NOT latch in_damage_zone — otherwise a + // genuine hard error that follows would not be seen as the zone entry and + // would skip the 30s wedge cooldown. + let mut ctx = ReadCtx::for_sweep(32); + handle_read_error(&recovered_err(), &mut ctx); + assert!( + !ctx.in_damage_zone, + "recovered read is not damage-zone signal" + ); + assert_eq!(ctx.zones_entered, 0); + // The following genuine hard error IS the real zone entry. + handle_read_error(&hardware_err(), &mut ctx); + assert!(ctx.in_damage_zone); + assert_eq!(ctx.zones_entered, 1, "hard error registers the zone entry"); + } + #[test] fn recovered_error_skips_block_pass_n_too() { // Pass N sees the same: a recovered read is distrusted → SkipBlock (the diff --git a/src/error.rs b/src/error.rs index 120ab90..c9c38dc 100644 --- a/src/error.rs +++ b/src/error.rs @@ -152,9 +152,9 @@ pub const E_MUX_EMPTY: u16 = 9023; pub const E_EXTENT_NOT_UNIT_ALIGNED: u16 = 9030; /// `mp4://` output but the title has no (primary) video track to carry. pub const E_MP4_NO_VIDEO_TRACK: u16 = 9048; -/// `mp4://` video track uses a codec with no MP4 mapping / no config record -/// available here (e.g. VC-1, or MPEG-2 without an avcC/hvcC-style record). -pub const E_MP4_UNSUPPORTED_VIDEO_CODEC: u16 = 9049; +/// `mp4://` SOURCE file is malformed/truncated (bad box structure, sample table, +/// or offsets) — the MP4 demuxer could not parse it. +pub const E_MP4_INVALID: u16 = 9049; /// `mp4://` video track is missing its codec-configuration record /// (`hvcC`/`avcC`), without which the sample entry can't be written. pub const E_MP4_MISSING_CODEC_PRIVATE: u16 = 9050; @@ -370,12 +370,10 @@ pub enum Error { AacsBusKeyUnavailable, /// AACS 2.1 (FMTS) disc carries forensic variant segments, but no segment - /// (variant) key is available to open them, and `BYPASS_FMTS_KEY` is `false` - /// (strict mode). Raised UPFRONT — before the mux — exactly like a missing - /// unit key, so a 2.1 disc that would rip with holes is refused rather than - /// silently producing a forensic-holed output. When `BYPASS_FMTS_KEY` is - /// `true` (the default today) this is never raised: the bulk decodes with the - /// unit key and the forensic segments are skipped as expected loss. + /// (variant) key is available to open them. Raised UPFRONT — before the mux — + /// exactly like a missing unit key, so a 2.1 disc that would rip with holes is + /// refused rather than silently producing a forensic-holed output. (The mux + /// resolves the full forensic key set up front; a resolution gap fails here.) FmtsKeyMissing, // Keydb (8xxx) @@ -429,11 +427,11 @@ pub enum Error { /// `m2ts://` analogue of [`Error::MkvInvalid`]'s zero-frame guard. MuxEmpty, /// `mp4://` target title has no primary video track to mux. - MuxNoVideoTrack, - /// `mp4://` video codec has no MP4 sample-entry mapping available here. - Mp4UnsupportedVideoCodec, + Mp4NoVideoTrack, + /// `mp4://` source file is malformed/truncated — the MP4 demuxer failed. + Mp4Invalid, /// `mp4://` video track is missing its `hvcC`/`avcC` configuration record. - MuxMissingCodecPrivate, + Mp4MissingCodecPrivate, PesFrameTooLarge { size: usize, }, @@ -613,9 +611,9 @@ impl Error { Error::StreamUrlMissingPort { .. } => E_STREAM_URL_MISSING_PORT, Error::NetworkAddrBlocked { .. } => E_NETWORK_ADDR_BLOCKED, Error::MuxEmpty => E_MUX_EMPTY, - Error::MuxNoVideoTrack => E_MP4_NO_VIDEO_TRACK, - Error::Mp4UnsupportedVideoCodec => E_MP4_UNSUPPORTED_VIDEO_CODEC, - Error::MuxMissingCodecPrivate => E_MP4_MISSING_CODEC_PRIVATE, + Error::Mp4NoVideoTrack => E_MP4_NO_VIDEO_TRACK, + Error::Mp4Invalid => E_MP4_INVALID, + Error::Mp4MissingCodecPrivate => E_MP4_MISSING_CODEC_PRIVATE, Error::PesFrameTooLarge { .. } => E_PES_FRAME_TOO_LARGE, Error::PesInvalidMagic => E_PES_INVALID_MAGIC, Error::PesTrackTooLarge { .. } => E_PES_TRACK_TOO_LARGE, @@ -850,9 +848,9 @@ impl From for std::io::Error { // 9023 MuxEmpty: finish() reached with zero frames — the output // would be a header-only container. Treat as invalid output. E_MUX_EMPTY => std::io::ErrorKind::InvalidData, - // 9048-9050 mp4:// track/codec/config mismatches: the requested - // output can't hold this title's video — invalid output request. - E_MP4_NO_VIDEO_TRACK | E_MP4_UNSUPPORTED_VIDEO_CODEC | E_MP4_MISSING_CODEC_PRIVATE => { + // mp4:// track/config mismatches: the requested output can't hold + // this title's video — invalid output request. + E_MP4_NO_VIDEO_TRACK | E_MP4_INVALID | E_MP4_MISSING_CODEC_PRIVATE => { std::io::ErrorKind::InvalidData } // 9030 ExtentNotUnitAligned: a malformed/non-AACS-aligned @@ -1259,7 +1257,7 @@ mod tests { E_NETWORK_ADDR_BLOCKED, E_MUX_EMPTY, E_MP4_NO_VIDEO_TRACK, - E_MP4_UNSUPPORTED_VIDEO_CODEC, + E_MP4_INVALID, E_MP4_MISSING_CODEC_PRIVATE, E_PES_FRAME_TOO_LARGE, E_PES_INVALID_MAGIC, @@ -1348,12 +1346,9 @@ mod tests { (Error::PipelineConsumerGone, E_PIPELINE_CONSUMER_GONE), (Error::DiscCapacityOverflow, E_DISC_CAPACITY_OVERFLOW), (Error::MuxEmpty, E_MUX_EMPTY), - (Error::MuxNoVideoTrack, E_MP4_NO_VIDEO_TRACK), - ( - Error::Mp4UnsupportedVideoCodec, - E_MP4_UNSUPPORTED_VIDEO_CODEC, - ), - (Error::MuxMissingCodecPrivate, E_MP4_MISSING_CODEC_PRIVATE), + (Error::Mp4NoVideoTrack, E_MP4_NO_VIDEO_TRACK), + (Error::Mp4Invalid, E_MP4_INVALID), + (Error::Mp4MissingCodecPrivate, E_MP4_MISSING_CODEC_PRIVATE), (Error::M2tsPacketMalformed, E_M2TS_PACKET_MALFORMED), (Error::ExtentNotUnitAligned, E_EXTENT_NOT_UNIT_ALIGNED), (Error::DiscCapacityMalformed, E_DISC_CAPACITY_MALFORMED), diff --git a/src/keysource.rs b/src/keysource.rs index 25c06aa..9acc909 100644 --- a/src/keysource.rs +++ b/src/keysource.rs @@ -485,9 +485,9 @@ pub fn key_fetch( /// /// "Encrypted" is decided by [`crate::aacs::content::aacs_unit_encrypted`] — the /// AACS Copy Permission Indicator (CPI) in the top 2 bits of byte 0, the -/// spec-correct signal (`buf[0] & 0xc0`). NOT the `ts_sync_destroyed` -/// sync heuristic: destroyed TS syncs do not imply encryption (an FMTS variant -/// frame or an odd clear unit can lack syncs yet be unencrypted), and a clear +/// spec-correct signal (`buf[0] & 0xc0`). NOT the `is_clean` TS-sync +/// heuristic: a unit lacking clean TS syncs does not imply encryption (an FMTS +/// variant frame or an odd clear unit can lack syncs yet be unencrypted), and a clear /// unit sent to a key server yields nothing to validate against — the "0 /// encrypted units" rejection. A clip opens with clear navigation units (PAT/PMT, /// menus) whose CPI is clear; only CPI-flagged content units are collected — @@ -818,6 +818,55 @@ mod tests { assert_eq!(*builds.lock().unwrap(), 1, "make_sources invoked per fetch"); } + /// `key_fetch` memoizes each operation by the fingerprint of the sample batch: + /// identical samples reuse the cached keys (no rebuild), different samples miss, + /// the two operations keep independent caches, and even an empty reply is cached. + #[test] + fn key_fetch_memoizes_per_op_by_sample_fingerprint() { + let builds = Arc::new(Mutex::new(0usize)); + let builds_c = Arc::clone(&builds); + let key = [0x11u8; 16]; + let make: Arc Vec> + Send + Sync> = Arc::new(move || { + *builds_c.lock().unwrap() += 1; + vec![Box::new(HasKey(key)) as Box] + }); + let cb = key_fetch(empty_inputs(), make); + let a = vec![vec![0xAAu8; 8]]; + let b = vec![vec![0xBBu8; 8]]; + + // First resolve for `a` builds sources; the identical repeat is cached. + assert_eq!(cb.unit_keys(&a), vec![key]); + assert_eq!(cb.unit_keys(&a), vec![key]); + assert_eq!( + *builds.lock().unwrap(), + 1, + "identical samples reuse the cache" + ); + + // A different sample batch is a cache miss → one more build. + assert_eq!(cb.unit_keys(&b), vec![key]); + assert_eq!( + *builds.lock().unwrap(), + 2, + "different samples miss the cache" + ); + + // The forensic op has its OWN cache (HasKey has no forensic keys → empty), + // so `a` builds once more here; its empty reply is then cached too. + assert!(cb.fmts_indexes(&a).is_empty()); + assert_eq!( + *builds.lock().unwrap(), + 3, + "unit/fmts caches are independent" + ); + assert!(cb.fmts_indexes(&a).is_empty()); + assert_eq!( + *builds.lock().unwrap(), + 3, + "an empty reply is cached, not re-asked" + ); + } + /// The two `KeyFetch` operations route to the two DISTINCT trait methods: /// `unit_keys` drives `get_unit_keys`, `fmts_indexes` drives /// `get_fmts_indexes`. A source that returns different keys per method proves @@ -982,12 +1031,12 @@ mod tests { } /// DISCRIMINATING: selection is by the AACS CPI (byte 0), NOT the - /// `ts_sync_destroyed` heuristic. Half the units are sync-destroyed but + /// TS-sync clarity heuristic. Half the units lack TS syncs but are /// CPI-CLEAR (`byte0 & 0xC0 == 0`) — genuinely UNencrypted units that merely /// lack TS syncs; the old sampler collected these and the key server rejected /// the POST as "0 encrypted units". `read_encrypted_units` must skip them and - /// return ONLY CPI-flagged units. A regression to `ts_sync_destroyed` would - /// collect the CPI-clear units too and fail the `& 0xC0` assertion. + /// return ONLY CPI-flagged units. A regression to selecting by TS-sync clarity + /// would collect the CPI-clear units too and fail the `& 0xC0` assertion. #[test] fn read_encrypted_units_selects_by_cpi_not_ts_sync() { use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted}; @@ -996,7 +1045,8 @@ mod tests { // Even units: CPI-clear (byte0 & 0xC0 == 0) AND sync-destroyed (no 0x47). // Odd units: CPI-set (byte0 = 0xC0) with a scrambled body. - // `ts_sync_destroyed` is TRUE for BOTH; `aacs_unit_encrypted` only odd. + // Neither has clean TS syncs, so `is_clean` is FALSE for BOTH; + // `aacs_unit_encrypted` flags only the odd units. struct MixSource { ext_start: u32, total_units: u32, diff --git a/src/mux/codec/ac3.rs b/src/mux/codec/ac3.rs index 45926f7..879a7ca 100644 --- a/src/mux/codec/ac3.rs +++ b/src/mux/codec/ac3.rs @@ -118,13 +118,13 @@ impl Ac3Parser { use super::crc::crc16_ansi; -/// Whether a fully-buffered (E-)AC-3 frame passes its native CRC. ffmpeg's -/// decoder checks exactly this — `av_crc(AV_CRC_16_ANSI, 0, &buf[2], -/// frame_size - 2) == 0` (ac3dec.c) — over the frame after the 2-byte syncword; -/// the trailing crc word makes a clean frame's residue zero. A nonzero residue -/// is a ~1-in-65536-certain sign of payload corruption, so we drop the frame -/// (silence gap) rather than ship a glitch. `frame` must be exactly the frame -/// bytes (syncword .. frame_size). +/// Whether a fully-buffered (E-)AC-3 frame passes its native CRC. Per ETSI TS +/// 102 366 (ATSC A/52) the frame carries a CRC-16/ANSI (poly 0x8005, init 0, +/// non-reflected) over the bytes after the 2-byte syncword — i.e. `crc16_ansi( +/// &buf[2..]) == 0` covers `frame_size - 2` bytes; the trailing crc word makes a +/// clean frame's residue zero. A nonzero residue is a ~1-in-65536-certain sign +/// of payload corruption, so we drop the frame (silence gap) rather than ship a +/// glitch. `frame` must be exactly the frame bytes (syncword .. frame_size). fn frame_crc_ok(frame: &[u8]) -> bool { // Need the syncword (2) plus at least one covered byte; the caller only // invokes this on a fully-sized frame, so this is defensive. @@ -136,8 +136,8 @@ fn frame_crc_ok(frame: &[u8]) -> bool { /// Decodability verdict for a fully-sized (E-)AC-3 frame: `Some(reason)` when it /// must be dropped, `None` when it decodes. Drops (in order): a poisoned track -/// (mostly-undecodable → drop the rest), a bitstream id ffmpeg's parser rejects -/// (`bsid > 16` → `AC3_PARSE_ERROR_BSID`), or a failed native frame CRC. +/// (mostly-undecodable → drop the rest), an out-of-range bitstream id (`bsid > +/// 16`; ETSI TS 102 366 defines no bsid above 16), or a failed native frame CRC. fn ac3_drop_reason( tally: &super::dropgate::DropTally, frame: &[u8], @@ -233,7 +233,7 @@ impl CodecParser for Ac3Parser { let duration_ns = frame_duration_ns(remaining, bsid); let frame = &data[start..start + frame_size]; - // Decodability gate: drop a frame ffmpeg's parser rejects (bsid > 16) + // Decodability gate: drop a frame with an out-of-range bsid (> 16) // or whose native CRC fails (payload corruption). `frame_pts_ns` is // advanced BELOW whether or not the frame survives, so a drop is a // silence gap and the following frames keep their true PTS. @@ -385,8 +385,8 @@ const ACMOD_CHANNELS: [u8; 8] = [2, 1, 2, 3, 3, 4, 4, 5]; /// /// This is the AUTHORITATIVE channel count for the track header: the DVD IFO /// `audio_attr_t.channels` nibble is a well-known unreliable/stale field, so -/// the muxer prefers this over the IFO-claimed count (mirrors MakeMKV / -/// HandBrake, which never trust the IFO audio nibble). LFE adds one channel +/// the muxer prefers this over the IFO-claimed count (the bitstream acmod is +/// authoritative; the IFO audio nibble is not trusted). LFE adds one channel /// (e.g. acmod=7 + lfeon → 6 = 5.1). /// /// Bit layout from the syncword (A/52 §5.3.2 BSI): @@ -628,7 +628,7 @@ mod tests { // (PES marked discontinuity) carrying a fresh complete frame. The // truncated partial must be DROPPED, not spliced — otherwise the parser // emits one corrupt frame built from [stale partial | head of fresh] and - // strands the tail (FFmpeg: "incomplete frame" / wrong sync). + // strands the tail (decoders report "incomplete frame" / wrong sync). let mut parser = Ac3Parser::new(); let frame_data = make_ac3_frame(0, 2); // 160 bytes, starts with 0x0B77 @@ -1463,8 +1463,8 @@ mod tests { #[test] fn bsid_over_16_is_dropped() { - // ffmpeg's parser rejects bsid > 16 (AC3_PARSE_ERROR_BSID). A frame with - // bsid = 17 that still sizes must be dropped, not emitted. + // bsid > 16 is out of range (ETSI TS 102 366 defines no bsid above 16). + // A frame with bsid = 17 that still sizes must be dropped, not emitted. let mut frame = vec![0u8; 128]; frame[0] = 0x0B; frame[1] = 0x77; diff --git a/src/mux/codec/adts.rs b/src/mux/codec/adts.rs index 6c127fd..122a6b4 100644 --- a/src/mux/codec/adts.rs +++ b/src/mux/codec/adts.rs @@ -1,20 +1,22 @@ //! AAC ADTS decodability gate. //! -//! ffmpeg's `ff_adts_header_parse` (adts_header.c) has exactly three hard -//! rejects: syncword != 0xFFF, a reserved `sampling_frequency_index` -//! (`ff_mpeg4audio_sample_rates[sr] == 0`, i.e. index ≥ 13), and -//! `aac_frame_length < 7`. It does NOT verify the optional ADTS CRC (it only -//! `skip_bits(16)` past it). So the gate mirrors those three rejects: a packet -//! that begins with the ADTS sync but is otherwise malformed is dropped; a -//! packet with no ADTS sync is raw AAC (e.g. from mp4, which carries no ADTS -//! header) or a continuation and passes through unchanged — never false-dropped. -//! Raw AAC has no per-frame integrity data, so like LPCM it cannot be gated. +//! Per the ADTS framing defined in ISO/IEC 13818-7 / ISO/IEC 14496-3, a header +//! is structurally invalid in exactly three ways this gate treats as hard +//! rejects: syncword != 0xFFF, a reserved `sampling_frequency_index` (the sample +//! rate table has 13 valid entries, so index ≥ 13 is reserved), and +//! `aac_frame_length < 7` (shorter than the fixed+variable header itself). The +//! optional 16-bit ADTS CRC is not verified here — it is simply skipped. So the +//! gate enforces those three rejects: a packet that begins with the ADTS sync +//! but is otherwise malformed is dropped; a packet with no ADTS sync is raw AAC +//! (e.g. from an MP4 container, which carries no ADTS header) or a continuation +//! and passes through unchanged — never false-dropped. Raw AAC has no per-frame +//! integrity data, so like LPCM it cannot be gated. use super::dropgate::DropTally; use super::{CodecParser, Frame, PesPacket, pts_to_ns}; -/// `ff_mpeg4audio_sample_rates` — 13 valid entries; indices 13/14/15 are 0 -/// (reserved), which is exactly what ffmpeg rejects. +/// ADTS `sampling_frequency_index` table (ISO/IEC 14496-3) — 13 valid entries; +/// indices 13/14/15 are 0 (reserved) and constitute a hard reject. const ADTS_SAMPLE_RATE_VALID: [u32; 16] = [ 96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350, 0, 0, 0, @@ -24,10 +26,10 @@ const ADTS_SAMPLE_RATE_VALID: [u32; 16] = [ enum AdtsVerdict { /// No 12-bit ADTS sync at the head — not an ADTS frame we can validate. NoSync, - /// Sync present and the three ffmpeg-checked fields are legal. + /// Sync present and the three structural fields are legal. Valid, /// Sync present but a reserved sample-rate index or a sub-header - /// frame-length — ffmpeg's parser rejects this. + /// frame-length — structurally invalid per the ADTS spec. Invalid, } @@ -56,6 +58,10 @@ fn adts_verdict(data: &[u8]) -> AdtsVerdict { pub struct AdtsParser { tally: DropTally, + /// Last emitted PTS (ns). A PES with no PTS (legal for audio, e.g. a + /// post-discontinuity continuation) carries this forward rather than resetting + /// the timeline to 0 — matching the AC-3/DTS parsers and preserving A/V sync. + last_pts_ns: i64, } impl Default for AdtsParser { @@ -68,6 +74,7 @@ impl AdtsParser { pub fn new() -> Self { Self { tally: DropTally::new("aac"), + last_pts_ns: 0, } } @@ -85,7 +92,12 @@ impl CodecParser for AdtsParser { if pes.data.is_empty() { return Vec::new(); } - let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0); + let pts_ns = pes + .pts + .or(pes.dts) + .map(pts_to_ns) + .unwrap_or(self.last_pts_ns); + self.last_pts_ns = pts_ns; let drop = self.tally.is_poisoned() || matches!(adts_verdict(&pes.data), AdtsVerdict::Invalid); @@ -162,6 +174,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. + let mut p = AdtsParser::new(); + p.parse(&make_pes(adts_frame(400), Some(90000))); + let f = p.parse(&make_pes(adts_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_sample_rate_index_is_dropped() { // sr_index = 13 (reserved). byte2 bits5..2 = 1101 → 0x34. diff --git a/src/mux/codec/coding.rs b/src/mux/codec/coding.rs index 9abc396..f8e7b5f 100644 --- a/src/mux/codec/coding.rs +++ b/src/mux/codec/coding.rs @@ -206,8 +206,8 @@ impl PictureInfo { } /// Number of field-display periods this picture occupies — the basis for - /// soft-telecine (2:3 pulldown) timing. MPEG-2 (ISO/IEC 13818-2 §6.3.10, - /// ffmpeg `nb_fields = repeat_pict + 2`): a field picture occupies 1 field, + /// soft-telecine (2:3 pulldown) timing. MPEG-2 (ISO/IEC 13818-2 §6.3.10): + /// a field picture occupies 1 field, /// a normal frame 2, a `repeat_first_field` progressive-frame 3 (or 4/6 in a /// progressive sequence); an rff bit on a non-progressive interlaced frame is /// spec-forbidden (§6.3.10) and is treated as 2. Codecs without pulldown diff --git a/src/mux/codec/crc.rs b/src/mux/codec/crc.rs index 6356118..6d6f3ec 100644 --- a/src/mux/codec/crc.rs +++ b/src/mux/codec/crc.rs @@ -1,16 +1,18 @@ //! Bit-exact CRC helpers shared by the audio codec decodability gates. //! -//! Both match ffmpeg's `av_crc` tables so a frame that ffmpeg's decoder would -//! flag as a CRC mismatch is flagged identically here. All are MSB-first -//! (non-reflected), init 0, no final XOR — the ffmpeg `AV_CRC_*` (big-endian) -//! variants. Each format transmits its CRC so that the residue over -//! `data + transmitted_crc` is zero, which is exactly how these are used: -//! compute over the whole frame (including its trailing CRC) and check `== 0`. +//! Each matches the CRC defined by its format's bitstream specification, so a +//! frame these routines flag as a CRC mismatch is exactly the frame a +//! spec-conformant decoder would reject. All are MSB-first (non-reflected), +//! init 0, no final XOR — the big-endian CRC variants. Each format transmits +//! its CRC so that the residue over `data + transmitted_crc` is zero, which is +//! exactly how these are used: compute over the whole frame (including its +//! trailing CRC) and check `== 0`. /// CRC-16/ANSI (a.k.a. CRC-16/BUYPASS): polynomial 0x8005, init 0x0000, -/// MSB-first, no reflection, no final XOR — ffmpeg `AV_CRC_16_ANSI`. -/// Used by AC-3/E-AC-3 (frame CRC), FLAC (frame footer), MPEG-audio and -/// AAC-ADTS (header CRC). +/// MSB-first, no reflection, no final XOR. Called by the AC-3/E-AC-3 frame-CRC +/// gate (ETSI TS 102 366) and the FLAC frame footer. (The MPEG-audio and +/// AAC-ADTS gates validate the header structurally and do not verify their +/// optional CRC, so they do not call this.) pub(crate) fn crc16_ansi(data: &[u8]) -> u16 { let mut crc: u16 = 0; for &b in data { @@ -26,14 +28,12 @@ pub(crate) fn crc16_ansi(data: &[u8]) -> u16 { crc } -/// CRC-16 with polynomial 0x002D, init 0, MSB-first — ffmpeg's `crc_2D` table -/// (`av_crc_init(crc_2D, 0, 16, 0x002D)`), used by the MLP/TrueHD major-sync -/// header checksum. NOTE: MLP's checksum is the "reversed" scheme — ffmpeg -/// computes `av_crc(...) ^ AV_RL16(trailer)` and compares against `AV_RL16` of -/// the stored word; equivalently, this standard CRC compared against the stored -/// bytes read big-endian. The caller handles that comparison -/// (see `truehd::mlp_major_sync_ok`). Verified against real ffmpeg TrueHD -/// output (225/225 major-sync AUs). +/// CRC-16 with polynomial 0x002D, init 0, MSB-first, used by the MLP / Dolby +/// TrueHD major-sync header checksum. NOTE: MLP's checksum is the "reversed" +/// scheme — the stored trailer word is the little-endian-read CRC, so this +/// standard CRC must be compared against the stored bytes read big-endian. +/// The caller handles that comparison (see `truehd::mlp_major_sync_ok`). +/// Verified against real MLP/TrueHD bitstreams (225/225 major-sync AUs). pub(crate) fn crc16_mlp(data: &[u8]) -> u16 { let mut crc: u16 = 0; for &b in data { @@ -50,8 +50,8 @@ pub(crate) fn crc16_mlp(data: &[u8]) -> u16 { } /// CRC-8/ATM (a.k.a. CRC-8/ITU without the final XOR): polynomial 0x07, init 0, -/// MSB-first, no reflection — ffmpeg `AV_CRC_8_ATM`. Used by the FLAC frame -/// header. +/// MSB-first, no reflection — the FLAC frame-header CRC-8 (RFC 9639). Available +/// as a primitive; the FLAC gate currently validates only the frame footer CRC-16. pub(crate) fn crc8_atm(data: &[u8]) -> u8 { let mut crc: u8 = 0; for &b in data { @@ -90,6 +90,29 @@ mod tests { assert_eq!(crc16_ansi(b"123456789"), 0xFEE8); } + #[test] + fn crc16_mlp_known_vector_check_bytes() { + // Independent known-answer for CRC-16 poly 0x002D, init 0, MSB-first over + // the catalogue string "123456789" is 0x4FF7 — computed by a separate + // reference implementation (NOT by crc16_mlp), so a wrong polynomial or + // shift direction here fails this test even though every truehd fixture + // (which derives its trailer from crc16_mlp itself) would still pass. + assert_eq!(crc16_mlp(b"123456789"), 0x4FF7); + assert_eq!(crc16_mlp(&[0x00, 0x01, 0x02, 0x03]), 0x5E26); + } + + #[test] + fn crc16_mlp_residue_property_holds() { + // Appending the big-endian CRC zeroes the residue over message+crc — the + // scheme `truehd::mlp_major_sync_ok` relies on. + let msg = [0xF8u8, 0x72, 0x6F, 0xBA]; + let c = crc16_mlp(&msg); + let mut framed = msg.to_vec(); + framed.push((c >> 8) as u8); + framed.push((c & 0xFF) as u8); + assert_eq!(crc16_mlp(&framed), 0); + } + #[test] fn crc8_residue_property_holds() { // Appending the CRC-8 of a message zeroes the residue over message+crc — diff --git a/src/mux/codec/dropgate.rs b/src/mux/codec/dropgate.rs index 121329c..1d6f033 100644 --- a/src/mux/codec/dropgate.rs +++ b/src/mux/codec/dropgate.rs @@ -8,8 +8,9 @@ //! as a decoder-choking glitch. //! //! The DETECTION is inherently per-codec — each format carries its own -//! authoritative corruption check (DTS: ffmpeg's core-header parse; AC-3: the -//! header CRC; FLAC: the frame CRC-16; …). This type only carries the UNIFORM +//! authoritative corruption check (DTS: the core sync/header parse per ETSI TS +//! 102 114; AC-3: the header CRC per ETSI TS 102 366; FLAC: the frame CRC-16; …). +//! This type only carries the UNIFORM //! response so every audio parser behaves identically: //! //! 1. **Count** kept vs dropped AUs and the dropped duration. @@ -198,4 +199,17 @@ mod tests { } assert!(!t.is_poisoned()); } + + #[test] + fn collateral_drops_never_poison_the_track() { + // A TrueHD resync-forward run collaterally drops a long burst of AUs, but + // none are individually undecodable — the whole-track verdict must stay + // clean so one corruption event can't amplify into a false total loss. + let mut t = DropTally::new("test"); + for _ in 0..(TRACK_VERDICT_MIN_AUS * 3) { + t.record_collateral_drop(0, 1000, 512, "resync-forward"); + } + assert!(t.dropped_frames() >= TRACK_VERDICT_MIN_AUS, "drops counted"); + assert!(!t.is_poisoned(), "collateral drops must not poison"); + } } diff --git a/src/mux/codec/dts.rs b/src/mux/codec/dts.rs index e10883a..3a96266 100644 --- a/src/mux/codec/dts.rs +++ b/src/mux/codec/dts.rs @@ -238,7 +238,7 @@ impl CodecParser for DtsParser { if pes.data.is_empty() { return Vec::new(); } - // A PES with no PTS (rare for audio, but legal — the case OSS demuxers + // A PES with no PTS (rare for audio, but legal — the case demuxers // guard at a post-gap continuation) must NOT reset the timeline to 0; // continue from the most recent known base. Defense-in-depth: the // discontinuity-carrying PES is a PUSI with a PTS in practice. @@ -351,7 +351,16 @@ impl CodecParser for DtsParser { let mut forced = false; let (au_end, ext_clean) = match next_core_boundary(&self.buf, core_size) { NextCore::Found { end, ext_clean } => (end, ext_clean), - NextCore::NeedMore => break, // candidate sync needs more header + NextCore::NeedMore if self.buf.len() <= MAX_AU_BYTES => break, + NextCore::NeedMore => { + // A candidate boundary exists but is not fully buffered. Normally + // we wait for more PES; but once the buffer exceeds the AU cap, + // apply the same force-flush safety valve as `None` so a crafted + // stream that keeps a boundary perpetually incomplete can't grow + // `buf` without bound (the `break` above never reaches it). + forced = true; + (self.buf.len(), true) + } NextCore::None => { // No next core sync buffered yet. The trailing extension // substream PES packets may still be arriving, so WAIT for @@ -643,8 +652,8 @@ const DTS_CORE_SAMPLE_RATES: [u32; 16] = [ ]; /// Samples in one DTS core frame: `(NBLKS + 1) * 32`. `NBLKS` (7 bits) is the -/// core-header PCM-sample-block count — the same field ffmpeg's `dca` decoder -/// uses to timestamp frames. Bit layout after the 32-bit sync: FTYPE(1) SHORT(5) +/// core-header PCM-sample-block count (ETSI TS 102 114) that fixes the frame's +/// decoded sample count. Bit layout after the 32-bit sync: FTYPE(1) SHORT(5) /// CPF(1) **NBLKS(7)** FSIZE(14) …, so NBLKS = byte4 bit0 + byte5 bits7-2. fn dts_core_samples(data: &[u8]) -> u32 { if data.len() < CORE_HEADER_MIN_BYTES { @@ -673,18 +682,18 @@ fn dts_core_duration_ns(data: &[u8]) -> u64 { (samples * 1_000_000_000 + rate / 2) / rate } -/// DCA core-header constants, mirrored from ffmpeg `libavcodec/dca_core.h`. -/// `deficit_samples` must equal this (`DCA_PCMBLOCK_SAMPLES`); `npcmblocks` -/// must be a multiple of `DCA_SUBBAND_SAMPLES`; `audio_mode` must be below -/// `DCA_AMODE_COUNT`; `lfe_present == DCA_LFE_FLAG_INVALID` is rejected. +/// DTS core-header validity constants (ETSI TS 102 114). +/// `deficit_samples` must equal this (`DTS_PCMBLOCK_SAMPLES`); `npcmblocks` +/// must be a multiple of `DTS_SUBBAND_SAMPLES`; `audio_mode` must be below +/// `DTS_AMODE_COUNT`; `lfe_present == DTS_LFE_FLAG_INVALID` is rejected. const DTS_PCMBLOCK_SAMPLES: u32 = 32; const DTS_SUBBAND_SAMPLES: u32 = 8; const DTS_AMODE_COUNT: u32 = 10; const DTS_LFE_FLAG_INVALID: u32 = 3; -/// `ff_dca_sample_rates[16]` — sample rate (Hz) per core `SFREQ` code; a `0` -/// entry marks a reserved code that ffmpeg's parser rejects -/// (`DCA_PARSE_ERROR_SAMPLE_RATE`). Valid entries are locked to the spec by +/// Sample rate (Hz) per core `SFREQ` code (ETSI TS 102 114 Table 6-4); a `0` +/// entry marks a reserved code that fails header validation as an invalid +/// sample rate. Valid entries are locked to the spec by /// `dts_core_sfreq_table_matches_the_dca_spec`; the reserved codes are /// {0, 4, 5, 9, 10}. const DTS_CORE_SR_VALID: [u32; 16] = [ @@ -692,14 +701,14 @@ const DTS_CORE_SR_VALID: [u32; 16] = [ 192_000, ]; -/// `ff_dca_bits_per_sample[8]` — a `0` entry marks a reserved `PCMR` code that -/// ffmpeg's parser rejects (`DCA_PARSE_ERROR_PCM_RES`); reserved codes are -/// {4, 7}. +/// Bits per sample per core `PCMR` code (ETSI TS 102 114); a `0` entry marks a +/// reserved `PCMR` code that fails header validation as an invalid PCM +/// resolution; reserved codes are {4, 7}. const DTS_CORE_PCMR_BITS: [u8; 8] = [16, 16, 20, 20, 0, 24, 24, 0]; -/// Why an access unit was judged undecodable. Each core-header variant is the -/// exact condition under which ffmpeg's `ff_dca_parse_core_frame_header` returns -/// the matching `DCA_PARSE_ERROR_*`; `TrackPoisoned` is our whole-track drop. +/// Why an access unit was judged undecodable. Each core-header variant is a +/// condition under which the DTS core-frame header (ETSI TS 102 114) is invalid +/// and a decoder would reject the frame; `TrackPoisoned` is our whole-track drop. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum DropReason { DeficitSamples, @@ -730,19 +739,19 @@ impl DropReason { } } -/// Decodability gate: a faithful port of ffmpeg's `ff_dca_parse_core_frame_header` -/// validity checks (libavcodec/dca.c). Returns `Some(reason)` when ffmpeg's own -/// parser would reject this core frame's header — in which case the packet is -/// undecodable ("Invalid data found") and dropping it loses nothing a decoder -/// could have used. Returns `None` (keep) for a decodable header OR if the -/// header can't be fully read (never false-drop on our own buffer underrun; the -/// framer only emits AUs whose core is fully buffered and ≥ 96 bytes). +/// Decodability gate: the core-frame header validity checks from ETSI TS 102 +/// 114. Returns `Some(reason)` when the DTS core-frame header is invalid — in +/// which case the packet is undecodable ("Invalid data found") and dropping it +/// loses nothing a decoder could have used. Returns `None` (keep) for a +/// decodable header OR if the header can't be fully read (never false-drop on +/// our own buffer underrun; the framer only emits AUs whose core is fully +/// buffered and ≥ 96 bytes). /// /// The 4-byte core sync is already validated by the framer, so this reads the -/// header fields that follow it. ffmpeg's parser does NOT verify the CPF header -/// CRC (it `skip_bits(16)` past it — dca.c) and the core decoder likewise skips -/// the audio-header/side-info CRCs (dca_core.c), so no CRC check is mirrored -/// here: doing so would drop frames ffmpeg decodes fine (false positives). +/// header fields that follow it. The 16-bit CPF header CRC is not verified (we +/// skip past it) and the audio-header/side-info CRCs are likewise not checked, +/// because decoders treat those bytes as optional/ignored — verifying them +/// would drop frames that decode fine (false positives). fn core_header_drop_reason(au: &[u8]) -> Option { let mut r = BitReader::new(au.get(SYNCWORD_BYTES..)?); @@ -782,7 +791,7 @@ fn core_header_drop_reason(au: &[u8]) -> Option { } let _predictor_history = r.read_bit()?; if crc_present { - // ffmpeg only skips the 16-bit header CRC here — it is not verified. + // Skip past the 16-bit header CRC here — it is not verified. r.skip_bits(16)?; } let _filter_perfect = r.read_bit()?; @@ -816,8 +825,8 @@ mod tests { let mut data = vec![0u8; size]; data[0..4].copy_from_slice(&DTS_CORE_SYNC); // byte4: FTYPE(0) SHORT(5) CPF(0) NBLKS-high(0). SHORT = 31 makes - // deficit_samples = 32 = DCA_PCMBLOCK_SAMPLES, which ffmpeg's parser - // (and our decodability gate) require of a real core frame. NBLKS high + // deficit_samples = 32 = DTS_PCMBLOCK_SAMPLES, which the decodability + // gate (per ETSI TS 102 114) requires of a real core frame. NBLKS high // bit (byte4 bit0) stays 0 for NBLKS = 15. data[4] = 31u8 << 2; // NBLKS = 15 → (15+1)*32 = 512 samples/frame (the DVD/UHD DTS-core norm). @@ -867,8 +876,8 @@ mod tests { // AU = core(512) + a REAL EXSS substream whose XLL payload embeds a DTS // core syncword decoding to a plausible size (512). The heuristic-only // framer would split here and truncate the lossless extension (the - // Dunkirk `dca` "Failed to decode block code(s)" class). Precise EXSS - // sizing spans the whole extension to the REAL next core. + // Dunkirk "Failed to decode block code(s)" decoder-failure class). + // Precise EXSS sizing spans the whole extension to the REAL next core. let core = make_dts_core(512); let exss = make_exss(600, Some(40)); let next = make_dts_core(512); @@ -975,9 +984,9 @@ mod tests { // B1: a partial DTS core is buffered, then a concealed gap (PES marked // discontinuity) carries a fresh core. The truncated partial must be // DROPPED — splicing it makes the framer emit a corrupt sub-core-length - // AU (the Dunkirk `dca` "Failed to decode block code(s)" class) and - // strands the rest. With the fix the post-gap core is the only AU, and it - // carries the post-gap PTS (not the stale pre-gap one). + // AU (the Dunkirk "Failed to decode block code(s)" decoder-failure + // class) and strands the rest. With the fix the post-gap core is the + // only AU, and it carries the post-gap PTS (not the stale pre-gap one). let mut parser = DtsParser::new(); // PES 1: first half of a 512-byte core (no boundary marker). @@ -1124,7 +1133,7 @@ mod tests { fn dvd_many_cores_one_pes_are_strictly_monotonic() { // Punisher-DVD reproduction: a single PES carrying SEVERAL DTS core // frames (the DVD packing) must emit STRICTLY-increasing PTSs. The old - // code stamped every AU with the one PES PTS, which ffmpeg rejected as + // code stamped every AU with the one PES PTS, which a muxer rejects as // "non monotonically increasing dts to muxer: X >= X". let mut parser = DtsParser::new(); let mut stream = Vec::new(); @@ -1171,9 +1180,9 @@ mod tests { #[test] fn dts_core_sfreq_table_matches_the_dca_spec() { - // Lock the SFREQ → sample-rate table to ffmpeg's authoritative - // `avpriv_dca_sample_rates` (ETSI TS 102 114 Table 6-4). The high-rate - // triad in particular — 48 k / 96 k / 192 k at indices 13/14/15 — must not + // Lock the SFREQ → sample-rate table to the authoritative values in + // ETSI TS 102 114 Table 6-4. The high-rate triad in particular — + // 48 k / 96 k / 192 k at indices 13/14/15 — must not // be shifted; a wrong entry would compute an N× frame duration and // reintroduce PTS drift on a 96/192 kHz DTS stream. let mut core = make_dts_core(512); @@ -1732,8 +1741,8 @@ mod tests { /// A structurally-framed but UNDECODABLE core: a valid `make_dts_core` /// whose reserved header bit is set. It still sizes and syncs correctly (so - /// the framer delimits it normally), but ffmpeg's `ff_dca_parse_core_frame_header` - /// — and our port — reject it (`DCA_PARSE_ERROR_RESERVED_BIT`). The reserved + /// the framer delimits it normally), but the core-frame header validity + /// check rejects it as a set reserved bit (ETSI TS 102 114). The reserved /// bit is byte9 bit4 in the core header (after SYNC..RATE). fn make_bad_dts_core(size: usize) -> Vec { let mut d = make_dts_core(size); @@ -1753,7 +1762,8 @@ mod tests { #[test] fn valid_stream_drops_nothing() { // A clean stream of decodable cores must pass the gate untouched — the - // detector is an exact ffmpeg-parity port, so zero false positives. + // detector follows the spec's validity rules exactly, so zero false + // positives. let mut parser = DtsParser::new(); let mut stream = Vec::new(); for _ in 0..5 { @@ -1859,7 +1869,7 @@ mod tests { fn sr_validity_table_marks_reserved_codes() { // The core-header sample-rate validity table must have ZERO (reject) at // exactly the reserved SFREQ codes {0,4,5,9,10} and a real rate - // elsewhere — this is what mirrors ffmpeg's DCA_PARSE_ERROR_SAMPLE_RATE. + // elsewhere — this is what drives the invalid-sample-rate rejection. for code in 0..16usize { let reserved = matches!(code, 0 | 4 | 5 | 9 | 10); assert_eq!( @@ -1872,8 +1882,8 @@ mod tests { #[test] fn every_core_header_error_class_is_detected() { - // Exercise each ffmpeg-parity rejection so the port stays faithful. - // Start from a decodable core and corrupt one field at a time. + // Exercise each header-validity rejection so the gate stays faithful to + // the spec. Start from a decodable core and corrupt one field at a time. let good = make_dts_core(512); assert_eq!(core_header_drop_reason(&good), None); @@ -1913,7 +1923,7 @@ mod tests { assert_eq!(core_header_drop_reason(&d), Some(DropReason::LfeFlag)); // pcmr_code reserved (7): pcmr is byte11 bit0 + byte12 bits7-6 → set all - // three to 1 (code 7 → ff_dca_bits_per_sample[7] = 0). + // three to 1 (code 7 → DTS_CORE_PCMR_BITS[7] = 0, a reserved PCMR code). let mut d = good.clone(); d[11] |= 0x01; d[12] |= 0xC0; @@ -1924,7 +1934,7 @@ mod tests { /// through `DtsParser` and writes the emitted access units back out, so the /// garbage-extension → core-only drop can be validated against an actual /// damaged stream (e.g. the extracted Bourne DTS-HD MA track) end-to-end - /// with ffmpeg. Env: `DTS_IN` (input), `DTS_OUT` (output). + /// with an external DTS decoder. Env: `DTS_IN` (input), `DTS_OUT` (output). /// cargo test --lib dts::tests::reparse_real_dts_file -- --ignored --nocapture #[test] #[ignore] diff --git a/src/mux/codec/flac.rs b/src/mux/codec/flac.rs index eaebd01..9f228c0 100644 --- a/src/mux/codec/flac.rs +++ b/src/mux/codec/flac.rs @@ -3,10 +3,11 @@ //! FLAC frames carry no length field, so a raw stream is delimited only by //! sync-scanning + CRC validation. In freemkv, though, FLAC never arrives raw: //! it comes from mp4/mkv, where each packet is exactly one container-delimited -//! FLAC frame (the `PARSER_FLAG_COMPLETE_FRAMES` case in ffmpeg). So this parser +//! FLAC frame (a complete, pre-delimited frame per packet). So this parser //! is a per-packet gate, not a framer: every FLAC frame ends with a 16-bit CRC -//! (poly 0x8005) computed so the residue over the whole frame is zero -//! (ffmpeg `flac_decode_frame`, `av_crc(AV_CRC_16_ANSI, 0, buf, len) == 0`). A +//! (poly 0x8005, init 0, non-reflected) computed so the residue over the whole +//! frame — footer CRC included — is zero (per the FLAC format specification, +//! RFC 9639, frame footer). A //! nonzero residue is definitive corruption → drop the frame (a silence gap, //! never a shift — each packet keeps its own PTS), logged via the shared tally. //! @@ -18,17 +19,17 @@ use super::dropgate::DropTally; use super::{CodecParser, Frame, PesPacket, pts_to_ns}; /// FLAC frame sync: 14-bit code `0x3FFE` + a mandatory-0 reserved bit; the next -/// bit (blocking strategy) is masked off. ffmpeg tests `(AV_RB16 & 0xFFFE) == -/// 0xFFF8` (flac_parser.c). +/// bit (blocking strategy) is masked off. Test the top 15 bits of the first two +/// bytes: `(be16 & 0xFFFE) == 0xFFF8` (per RFC 9639, frame header). fn has_flac_sync(data: &[u8]) -> bool { data.len() >= 2 && ((u16::from(data[0]) << 8 | u16::from(data[1])) & 0xFFFE) == 0xFFF8 } -/// Block-size code → samples, `ff_flac_blocksize_table` (0 = reserved/explicit). +/// Block-size code → samples (RFC 9639 block-size table; 0 = reserved/explicit). const FLAC_BLOCKSIZE_TABLE: [u32; 16] = [ 0, 192, 576, 1152, 2304, 4608, 0, 0, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, ]; -/// Sample-rate code → Hz, `ff_flac_sample_rate_table` (0 = STREAMINFO/explicit). +/// Sample-rate code → Hz (RFC 9639 sample-rate table; 0 = STREAMINFO/explicit). const FLAC_SAMPLE_RATE_TABLE: [u32; 16] = [ 0, 88_200, 176_400, 192_000, 8_000, 16_000, 22_050, 24_000, 32_000, 44_100, 48_000, 96_000, 0, 0, 0, 0, @@ -55,6 +56,9 @@ fn flac_frame_duration_ns(frame: &[u8]) -> Option { pub struct FlacParser { tally: DropTally, + /// Last emitted PTS (ns), carried forward across a PES with no PTS rather than + /// resetting the timeline to 0 (see the AC-3/DTS parsers) — preserves A/V sync. + last_pts_ns: i64, } impl Default for FlacParser { @@ -67,6 +71,7 @@ impl FlacParser { pub fn new() -> Self { Self { tally: DropTally::new("flac"), + last_pts_ns: 0, } } @@ -86,7 +91,12 @@ impl CodecParser for FlacParser { if pes.data.is_empty() { return Vec::new(); } - let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0); + let pts_ns = pes + .pts + .or(pes.dts) + .map(pts_to_ns) + .unwrap_or(self.last_pts_ns); + self.last_pts_ns = pts_ns; // Gate: a packet that begins with a FLAC frame sync but whose whole-frame // CRC-16 residue is nonzero is corrupt → drop. Anything else passes diff --git a/src/mux/codec/hevc.rs b/src/mux/codec/hevc.rs index a5378ec..d52bd8b 100644 --- a/src/mux/codec/hevc.rs +++ b/src/mux/codec/hevc.rs @@ -99,7 +99,8 @@ fn hevc_first_slice_coding_type(nal: &[u8], nal_type: u8, num_extra: u32) -> Opt pub struct HevcParser { // First-seen parameter set of each type → seeds the MKV codecPrivate (hvcC). // This is the ONLY copy the player gets out-of-band, and a player re-applies - // it at every keyframe (ffmpeg's hvcC→Annex-B insertion). A stream may + // it at every keyframe (the hvcC→Annex-B parameter-set insertion a decoder + // performs). A stream may // redefine a parameter set mid-title under the SAME id with a different body // (some discs redefine PPS id 0 partway through). Any occurrence whose body // DIFFERS from this codecPrivate copy must therefore be emitted IN-BAND at @@ -116,8 +117,9 @@ pub struct HevcParser { // set mid-title (e.g. PPS id 0 body changes partway through, then the // source STOPS repeating it at later IRAPs and relies on the decoder // retaining it), a raw decode is fine — but an hvcC/MKV decode is NOT: a - // player re-applies the codecPrivate set at EVERY keyframe (ffmpeg's - // hvcC→Annex-B insertion), reverting id 0 to the stale FIRST body. We must + // player re-applies the codecPrivate set at EVERY keyframe (the + // hvcC→Annex-B parameter-set insertion), reverting id 0 to the stale FIRST + // body. We must // therefore re-emit the active set IN-BAND at every keyframe whenever it // differs from the codecPrivate copy and the access unit didn't already // carry it. See `parse`. @@ -364,9 +366,10 @@ impl HevcParser { /// codecPrivate copy (`first`). The two player behaviours for hvcC-in-MKV /// diverge exactly here: /// -/// - A *seek-capable / Annex-B* player (e.g. ffmpeg's `hevc_mp4toannexb`) -/// re-applies the hvcC sets at every keyframe. `reassert_active` handles it. -/// - A *streaming* decode (ffmpeg decoding the MKV directly — what most +/// - A *seek-capable / Annex-B* player (one that converts hvcC to Annex-B by +/// inserting the parameter sets) re-applies the hvcC sets at every keyframe. +/// `reassert_active` handles it. +/// - A *streaming* decode (a decoder consuming the MKV directly — what most /// integrity checkers do) applies hvcC ONCE at init and thereafter updates a /// parameter set ONLY from an in-band NAL. /// @@ -427,7 +430,7 @@ fn handle_param_set( /// or SPS event), nothing re-sends it and every subsequent slice fails with /// "PPS id out of range" until the next genuine change (observed as a ~24 min /// corrupt band on one dual-layer UHD title). Re-asserting the active set at -/// EVERY keyframe — what compliant muxers (mkvmerge) do at every IRAP — makes +/// EVERY keyframe — what compliant Matroska muxers do at every IRAP — makes /// streaming decode self-healing. Re-sending an identical param set is benign /// (decoders expect it at IRAPs); cost is a few hundred bytes per keyframe. /// This strictly supersets the earlier change-only re-assert, so the diff --git a/src/mux/codec/mod.rs b/src/mux/codec/mod.rs index 28c406a..84c1f4e 100644 --- a/src/mux/codec/mod.rs +++ b/src/mux/codec/mod.rs @@ -121,11 +121,11 @@ pub trait CodecParser: Send { /// Passthrough parser — treats each PES as one frame, no parsing. /// -/// Used for the audio codecs that have no dedicated parser and whose PES -/// boundaries already line up with frame boundaries (Aac, Mp2, Mp3, Flac, -/// Opus). AC3/DTS/TrueHD have their own parsers; PGS/DvdSub have their own -/// subtitle parsers. Video codecs must NOT use the all-keyframe form of this -/// parser — see `parser_for_codec`. +/// Used for Opus (and any audio codec with no dedicated parser) whose PES +/// boundaries already line up with frame boundaries. AC3/E-AC3, DTS, TrueHD, +/// AAC(ADTS), MP2/MP3 and FLAC now have their own gating parsers; PGS/DvdSub +/// have their own subtitle parsers. Video codecs must NOT use the all-keyframe +/// form of this parser — see `parser_for_codec`. pub struct PassthroughParser { keyframe: bool, } @@ -168,8 +168,8 @@ impl CodecParser for PassthroughParser { /// - **Audio with independent access units** (DTS, AC-3/E-AC-3, …) gates each AU /// through a per-codec corruption check and drops the ones that fail, keeping /// A/V sync (a drop is a silence gap, never a shift) and logging every drop -/// via the shared [`dropgate::DropTally`]. DTS uses ffmpeg's core-header parse; -/// AC-3 uses its native frame CRC. +/// via the shared [`dropgate::DropTally`]. DTS validates via its core-frame +/// header (ETSI TS 102 114); AC-3 uses its native frame CRC. /// - **LPCM is excluded on purpose**: raw PCM carries no framing or integrity /// data, so a corrupt sample is indistinguishable from a quiet one — there is /// nothing to detect, so nothing can be honestly dropped. @@ -228,9 +228,9 @@ pub fn parser_for_codec( ); Box::new(PassthroughParser::new(false)) } - // Remaining audio-only codecs (Aac, Mp2, Mp3, Flac, Opus) where PES = - // frame: all-keyframe passthrough is correct. Subtitle/Unknown also land - // here; keyframe flag is irrelevant for them. + // Opus (PES = frame): all-keyframe passthrough is correct. Subtitle/Unknown + // also land here; the keyframe flag is irrelevant for them. (Aac/Mp2/Mp3/Flac + // have dedicated parsers dispatched earlier in the match.) Codec::Opus => Box::new(PassthroughParser::new(true)), Codec::Srt | Codec::Ssa | Codec::Unknown(_) => Box::new(PassthroughParser::new(true)), } @@ -285,9 +285,10 @@ mod tests { } #[test] - fn unhandled_audio_codecs_use_keyframe_passthrough() { - // PES = frame audio codecs: every frame is independently decodable, so - // all-keyframe passthrough is correct. + fn audio_codecs_emit_keyframe_frames() { + // PES = frame audio: every frame is independently decodable → keyframe. + // Aac/Mp2/Mp3/Flac go through their dedicated gating parsers (which pass a + // non-sync/too-short payload straight through); Opus uses PassthroughParser. for codec in [Codec::Aac, Codec::Mp2, Codec::Mp3, Codec::Flac, Codec::Opus] { let mut parser = parser_for_codec(codec, None, false); let frames = parser.parse(&pes(Some(0), vec![0x01, 0x02])); diff --git a/src/mux/codec/mpeg2.rs b/src/mux/codec/mpeg2.rs index d71a0a6..7cacdfc 100644 --- a/src/mux/codec/mpeg2.rs +++ b/src/mux/codec/mpeg2.rs @@ -483,7 +483,8 @@ fn coding_type_from_raw(raw: u8) -> CodingType { /// Number of field-display periods a coded picture occupies, from its picture /// coding extension (`00 00 01 B5`, ext-id `1000`), per ISO/IEC 13818-2 §6.3.10 -/// and ffmpeg `mpeg_field_start` (`nb_fields = repeat_pict + 2`). This is what +/// (`nb_fields = repeat_pict + 2`, the field count the spec's repeat rules +/// yield). This is what /// times soft-telecined (2:3 pulldown) DVD video correctly: a /// `repeat_first_field` frame occupies 3 fields, a normal frame 2, so honoring /// it spreads the ~23.976 coded frames across the 29.97 display span with no diff --git a/src/mux/codec/mpegaudio.rs b/src/mux/codec/mpegaudio.rs index 9ecc20f..9aa82ee 100644 --- a/src/mux/codec/mpegaudio.rs +++ b/src/mux/codec/mpegaudio.rs @@ -1,15 +1,18 @@ //! MPEG-1/2/2.5 audio (MP1/MP2/MP3) decodability gate. //! -//! ffmpeg validates MPEG-audio frames by header sanity + framing resync, not a -//! payload CRC (`mpegaudiodecheader.c` `ff_mpa_check_header`; the optional CRC -//! covers only side-info and is off by default). Its `ff_mpa_decode_header` -//! additionally rejects free-format (`bitrate_index == 0`). So the gate mirrors -//! exactly those header rejects: a packet that begins with the 11-bit MPEG-audio -//! sync but whose version / layer / bitrate-index / sample-rate fields are the -//! reserved/invalid values is undecodable → drop it (a silence gap; each packet -//! keeps its own PTS). A packet with no leading sync is not a frame we can -//! validate (raw payload / continuation), so it passes through unchanged — -//! never false-dropped. +//! Per ISO/IEC 11172-3 / ISO/IEC 13818-3, an MPEG-audio frame is validated by +//! header sanity + framing resync, not a payload CRC (the optional 16-bit CRC in +//! the header protects only the side-information and is absent unless the +//! protection bit says otherwise). The gate mirrors that header-only check and +//! ACCEPTS free-format (`bitrate_index == 0`) as a legal decodable mode — it +//! deliberately does NOT apply the stricter free-format reject that a full +//! decoder would (see the note at the `bitrate_index` check). So the gate rejects +//! only the truly invalid headers: a packet that begins with the 11-bit +//! MPEG-audio sync but whose version / layer / sample-rate fields (or the +//! reserved bitrate index 15) are reserved/invalid is undecodable → drop it (a +//! silence gap; each packet keeps its own PTS). A packet with no leading sync is +//! not a frame we can validate (raw payload / continuation), so it passes through +//! unchanged — never false-dropped. use super::dropgate::DropTally; use super::{CodecParser, Frame, PesPacket, pts_to_ns}; @@ -20,14 +23,16 @@ enum MpaVerdict { NoSync, /// Sync present and every field is legal — decodable. Valid, - /// Sync present but a field is reserved/invalid (or free-format) — ffmpeg's - /// parser rejects this exactly. + /// Sync present but a field is reserved/invalid — a conformant header parser + /// rejects this exactly. Invalid, } -/// Mirror ffmpeg's `ff_mpa_check_header` + the `ff_mpa_decode_header` -/// free-format reject. A dropped MPEG-audio frame has a corrupt header, so no -/// duration is computed (the fields it would come from are the invalid ones). +/// Header-only validity check per ISO/IEC 11172-3 / ISO/IEC 13818-3 (which +/// ACCEPTS free-format, `bitrate_index == 0`) — deliberately NOT the stricter +/// free-format reject a full decoder applies. A dropped MPEG-audio frame has a +/// corrupt header, so no duration is computed (the fields it would come from are +/// the invalid ones). fn mpa_verdict(data: &[u8]) -> MpaVerdict { if data.len() < 4 { return MpaVerdict::NoSync; @@ -37,8 +42,8 @@ fn mpa_verdict(data: &[u8]) -> MpaVerdict { if (h & 0xffe0_0000) != 0xffe0_0000 { return MpaVerdict::NoSync; } - // ff_mpa_check_header rejects: version field 01, layer field 00, - // bitrate_index 15, sample-rate field 3. + // Reject per spec: version field 01, layer field 00, bitrate_index 15, + // sample-rate field 3. if (h & (3 << 19)) == (1 << 19) || (h & (3 << 17)) == 0 || (h & (0xf << 12)) == (0xf << 12) @@ -47,14 +52,17 @@ fn mpa_verdict(data: &[u8]) -> MpaVerdict { return MpaVerdict::Invalid; } // NOTE: bitrate_index == 0 (free format) is NOT rejected. It is a legal, - // decodable MPEG-audio mode (ffmpeg's ff_mpa_check_header accepts it and the - // decoder derives the frame size from the sync spacing). Dropping it would be - // a false positive on a clean stream, so it passes the gate. + // decodable MPEG-audio mode (the spec permits it and a decoder derives the + // frame size from the sync spacing). Dropping it would be a false positive on + // a clean stream, so it passes the gate. MpaVerdict::Valid } pub struct MpegAudioParser { tally: DropTally, + /// Last emitted PTS (ns), carried forward across a PES with no PTS rather than + /// resetting the timeline to 0 (see the AC-3/DTS parsers) — preserves A/V sync. + last_pts_ns: i64, } impl Default for MpegAudioParser { @@ -67,6 +75,7 @@ impl MpegAudioParser { pub fn new() -> Self { Self { tally: DropTally::new("mpegaudio"), + last_pts_ns: 0, } } @@ -84,7 +93,12 @@ impl CodecParser for MpegAudioParser { if pes.data.is_empty() { return Vec::new(); } - let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0); + let pts_ns = pes + .pts + .or(pes.dts) + .map(pts_to_ns) + .unwrap_or(self.last_pts_ns); + self.last_pts_ns = pts_ns; let drop = self.tally.is_poisoned() || matches!(mpa_verdict(&pes.data), MpaVerdict::Invalid); @@ -153,9 +167,21 @@ mod tests { assert_eq!(p.dropped_frames(), 0); } + #[test] + fn reserved_version_field_is_dropped() { + // version field = 01 (reserved) → rejected. byte1 = 111_01_01_1 = 0xEB + // keeps the 11-bit sync (0xFF + top 3 bits 111) but sets version bits to 01. + let mut p = MpegAudioParser::new(); + let mut frame = mp3_frame(400); + frame[1] = 0xEB; + let f = p.parse(&make_pes(frame, Some(90000))); + assert!(f.is_empty(), "reserved version dropped"); + assert_eq!(p.dropped_frames(), 1); + } + #[test] fn reserved_sample_rate_is_dropped() { - // Sync present but sample-rate field = 3 (reserved) → ffmpeg rejects. + // Sync present but sample-rate field = 3 (reserved) → rejected per spec. // 0xFF 0xFB then byte2 with bits 11..10 = 11: 0x9C. let mut p = MpegAudioParser::new(); let mut frame = mp3_frame(400); diff --git a/src/mux/codec/truehd.rs b/src/mux/codec/truehd.rs index c21cae5..ddb8629 100644 --- a/src/mux/codec/truehd.rs +++ b/src/mux/codec/truehd.rs @@ -88,11 +88,11 @@ impl TrueHdParser { } /// Decide whether an access unit is corrupt, updating `num_substreams` from a - /// valid major sync. Mirrors ffmpeg `read_access_unit`: a major sync with a - /// bad header CRC, or any AU whose header parity fails, is undecodable. + /// valid major sync. Per the MLP/TrueHD access-unit decode rules: a major sync + /// with a bad header CRC, or any AU whose header parity fails, is undecodable. /// Returns `false` (not corrupt) when the AU is too short to judge or no /// major sync has established `num_substreams` yet — we never drop what we - /// cannot verify. Verified against real ffmpeg TrueHD (3600/3600 AUs). + /// cannot verify. Verified against real TrueHD streams (3600/3600 AUs). fn au_check(&mut self, au: &[u8], is_major_sync: bool) -> AuCheck { let mut header_size = 4; let mut format_info = None; @@ -231,7 +231,7 @@ enum Ac3Size { Frame(usize), } -// --- MLP/TrueHD access-unit integrity (mirrors ffmpeg mlpdec.c / mlp_parse.c) --- +// --- MLP/TrueHD access-unit integrity (per the MLP/TrueHD bitstream spec) --- /// Major-sync header size in bytes: base 28, plus `2 + extensions*2` when the /// extension flag (major-sync byte 25, bit 0) is set (`extensions` = byte 26 @@ -251,8 +251,8 @@ fn mlp_major_sync_header_size(ms: &[u8]) -> Option { Some(size) } -/// Validate the MLP/TrueHD major-sync header checksum (ffmpeg `ff_mlp_checksum16`, -/// CRC-16 poly 0x002D). The stored trailer is the last 2 header bytes; because +/// Validate the MLP/TrueHD major-sync header checksum (a CRC-16 with polynomial +/// 0x002D). The stored trailer is the last 2 header bytes; because /// MLP's checksum is byte-reversed relative to a standard CRC, a standard CRC of /// the header body XOR the little-endian word before the trailer must equal the /// trailer read big-endian. @@ -260,14 +260,15 @@ fn mlp_major_sync_crc_ok(ms: &[u8], mshdr: usize) -> bool { if mshdr < 4 || ms.len() < mshdr { return false; } - // ffmpeg `ff_mlp_checksum16(buf, buf_size)` (libavcodec/mlp.c): - // av_crc(crc_2D, 0, buf, buf_size - 2) ^ AV_RL16(buf + buf_size - 2) - // is called with `buf_size = mshdr - 2` and its result compared to - // `AV_RL16(buf + mshdr - 2)` — i.e. the 16-bit checksum over `ms[..mshdr-4]`, - // XORed with the LITTLE-ENDIAN word just before the trailer, must equal the - // LITTLE-ENDIAN trailer word. `crc16_mlp` is the same crc_2D table (poly 0x2D, - // MSB-first) but yields its two bytes in the OPPOSITE order to libavutil's - // `av_crc`, so swap them back to match. (The previous code mixed endianness — + // The MLP major-sync checksum, `checksum16(buf, buf_size)`, is defined as + // crc16_2D(buf, buf_size - 2) ^ read_le16(buf + buf_size - 2) + // evaluated with `buf_size = mshdr - 2` and its result compared to + // `read_le16(buf + mshdr - 2)` — i.e. the 16-bit CRC (poly 0x2D, MSB-first) + // over `ms[..mshdr-4]`, XORed with the LITTLE-ENDIAN word just before the + // trailer, must equal the LITTLE-ENDIAN trailer word. `crc16_mlp` uses that + // same poly-0x2D MSB-first table but yields its two bytes in the OPPOSITE + // order to a standard little-endian CRC readout, so swap them back to match. + // (The previous code mixed endianness — // little-endian XOR word but big-endian compare — so the checksum could never // validate any real extended major sync, silently dropping the whole track; // cross-verified byte-exact against real 7.1/Atmos and 5.1 discs.) @@ -304,9 +305,8 @@ fn mlp_substr_header_size(au: &[u8], header_size: usize, num_substreams: u8) -> Some(shs) } -/// MLP/TrueHD AU-header parity check (ffmpeg `ff_mlp_calculate_parity`): the XOR -/// of the 4-byte AU header with the substream directory, folded, must have its -/// two nibbles XOR to 0xF. +/// MLP/TrueHD AU-header parity check: the XOR of the 4-byte AU header with the +/// substream directory, folded, must have its two nibbles XOR to 0xF. fn mlp_parity_ok(au: &[u8], header_size: usize, substr_header_size: usize) -> bool { let end = header_size + substr_header_size; if end > au.len() { @@ -560,7 +560,7 @@ impl CodecParser for TrueHdParser { } /// Per-bit channel counts for the TrueHD 8-channel and 6-channel presentation -/// channel-assignment masks (per the MLP spec / FFmpeg `thd_channels`). Some +/// channel-assignment masks (per the MLP/TrueHD bitstream spec). Some /// bits denote a stereo pair (2), others a single channel (1). const THD_8CH: [u8; 13] = [2, 1, 1, 2, 2, 2, 2, 1, 1, 2, 2, 1, 1]; const THD_6CH: [u8; 5] = [2, 1, 1, 2, 1]; @@ -720,7 +720,7 @@ mod tests { /// `format_info` set) into one that passes the decodability gate: 1 substream, /// a clean substream directory, a valid major-sync CRC-16, and a valid header /// parity nibble. Mirrors what a real encoder writes (verified against real - /// ffmpeg TrueHD). The AU must be ≥ 36 bytes (4 AU header + 28 major-sync + /// TrueHD streams). The AU must be ≥ 36 bytes (4 AU header + 28 major-sync /// header + 2 directory + slack), which every `make_truehd_unit(≥200)` is. fn finalize_major_sync(au: &mut [u8]) { const MSHDR: usize = 28; // no extension (byte 25 clear) @@ -729,7 +729,7 @@ mod tests { // Substream directory entry at AU[4+MSHDR] = AU[32]: extraword flag clear. au[32] &= 0x7F; // Major-sync checksum, built EXACTLY as `mlp_major_sync_crc_ok` verifies it - // (ffmpeg `ff_mlp_checksum16`): swap_bytes(crc16_mlp(body)) ^ LE word before + // (the MLP checksum16): swap_bytes(crc16_mlp(body)) ^ LE word before // the trailer, stored little-endian in the trailer. let body_end = 4 + MSHDR - 4; // AU[4..28] let crc = super::crc16_mlp(&au[4..body_end]).swap_bytes() @@ -842,7 +842,7 @@ mod tests { let mut parser = TrueHdParser::new(); let ms1 = valid_major_sync(); let mut bad = valid_normal_au(); - // A single-nibble flip: MLP's nibble-fold parity (like ffmpeg's) is blind + // A single-nibble flip: MLP's nibble-fold parity is blind // to a full-byte flip, which changes both nibbles equally and cancels. bad[2] ^= 0x01; let ms2 = valid_major_sync(); @@ -974,7 +974,7 @@ mod tests { #[test] fn clean_truehd_stream_drops_nothing() { // A run of valid AUs passes untouched — zero false positives (the CRC and - // parity are verified against real ffmpeg TrueHD output). + // parity are verified against real TrueHD output). let mut parser = TrueHdParser::new(); let mut data = valid_major_sync(); for _ in 0..5 { @@ -1297,7 +1297,7 @@ mod tests { assert_eq!(truehd_channels_from_stream(&data), Some(8)); } - // --- truehd_channels: per-bit mask channel counts (MLP / FFmpeg table) --- + // --- truehd_channels: per-bit mask channel counts (MLP channel table) --- #[test] fn truehd_channels_8ch_single_bit_counts() { diff --git a/src/mux/demux_sink.rs b/src/mux/demux_sink.rs index 3a8446a..223e9f8 100644 --- a/src/mux/demux_sink.rs +++ b/src/mux/demux_sink.rs @@ -49,7 +49,8 @@ pub enum Naming { /// How (and whether) to record audio sync delay. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum DelayMode { - /// Embed `DELAY ms` in each audio filename (mkvmerge-readable). + /// Embed `DELAY ms` in each audio filename (the filename-delay + /// convention downstream muxers parse). #[default] Filename, /// Write a ` delays.txt` sidecar instead. @@ -64,7 +65,7 @@ pub enum DelayMode { #[allow(dead_code)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ChaptersFmt { - /// mkvmerge chapter XML. + /// Matroska chapter XML. #[default] Xml, /// OGM/simple `CHAPTERnn=`/`CHAPTERnnNAME=` text. @@ -121,7 +122,8 @@ pub enum TrackKind { // ── Codec → on-disk extension ──────────────────────────────────────────────── /// File extension (without the dot) for a codec's standalone elementary stream. -/// Chosen to match what mkvmerge / x265 / ffmpeg / BDSup2Sub expect. +/// Chosen to match the conventional elementary-stream extensions downstream +/// muxers and codec tools expect. fn extension_for(codec: Codec) -> &'static str { match codec { Codec::Hevc => "hevc", @@ -440,7 +442,7 @@ impl VobSubWriter { .map(|s| s.trim_end().to_string()); // VobSub `id:` lines use a 2-letter code; stream languages are ISO // 639-2 (3-letter). Take the leading two chars — the convention - // mkvmerge reads to assign a track language. + // downstream muxers read to assign a track language. let lang2: String = lang.chars().take(2).collect(); Self { idx_path, @@ -468,8 +470,8 @@ impl EsWriter for VobSubWriter { idx.push('\n'); } idx.push_str("langidx: 0\n\n"); - // The conventional `id: , index: 0` line mkvmerge reads to - // assign the subtitle track's language. Omit the language token when + // The conventional `id: , index: 0` line downstream muxers read + // to assign the subtitle track's language. Omit the language token when // unknown but still emit the index so the entry list is well-formed. if self.lang2.is_empty() { idx.push_str("id: , index: 0\n"); @@ -530,8 +532,8 @@ fn delay_ms(audio_first_pts_ns: i64, ref_video_first_pts_ns: i64) -> i64 { } } -/// `DELAY ms` — matches mkvmerge's case-insensitive -/// `delay\s+(-?\d+)` filename-delay parser. +/// `DELAY ms` — matches the conventional case-insensitive +/// `delay\s+(-?\d+)` filename-delay convention downstream muxers parse. fn delay_token(ms: i64) -> String { format!("DELAY {ms}ms") } @@ -547,7 +549,7 @@ fn fmt_chapter_time_ns(time_secs: f64) -> String { format!("{h:02}:{m:02}:{s:02}.{ns:09}") } -/// Serialize chapters as mkvmerge chapter XML. +/// Serialize chapters as Matroska chapter XML. pub(crate) fn chapters_xml(chapters: &[Chapter]) -> String { let mut s = String::new(); s.push_str("\n"); @@ -665,14 +667,18 @@ impl DemuxSink { (TrackKind::Subtitle, s.codec, s.pid, s.language.clone()) } }; + // Record the primary-video reference BEFORE the kind filter: the video + // track drives multi-clip PTS-continuity rebasing and the audio DELAY + // tag even for `audio://` / `sub://` outputs, where its frames flow + // through write() but are not persisted to disk. + if kind == TrackKind::Video && ref_video_track.is_none() { + ref_video_track = Some(idx); + } // Kind filter: `audio://` / `sub://` keep only their class. if opts.kind_filter.is_some_and(|k| k != kind) { tracks.push(None); continue; } - if kind == TrackKind::Video && ref_video_track.is_none() { - ref_video_track = Some(idx); - } let ext = extension_for(codec); let stem = Self::stem_for(opts, idx, pid, &lang, codec); @@ -1033,7 +1039,7 @@ mod tests { #[test] fn delay_token_matches_mkvmerge_regex() { - // mkvmerge: case-insensitive /delay\s+(-?\d+)/. + // Convention: case-insensitive /delay\s+(-?\d+)/. let re = regex_lite_delay; assert_eq!(re("Movie eng AC3 DELAY -248ms.ac3"), Some(-248)); assert_eq!(re(&format!("x {}.dts", delay_token(1000))), Some(1000)); @@ -1041,7 +1047,7 @@ mod tests { assert_eq!(re(&format!("x {}.eac3", delay_token(-5))), Some(-5)); } - /// Minimal stand-in for mkvmerge's `delay\s+(-?\d+)` (case-insensitive). + /// Minimal stand-in for the `delay\s+(-?\d+)` convention (case-insensitive). fn regex_lite_delay(name: &str) -> Option { let lower = name.to_lowercase(); let idx = lower.find("delay")?; @@ -1193,7 +1199,7 @@ mod tests { w.finish(&mut sub).unwrap(); let idx_text = std::fs::read_to_string(&idx).unwrap(); assert!(idx_text.contains("palette: 000000, ffffff")); - // The conventional `id:` line mkvmerge reads to assign the language. + // The conventional `id:` line downstream muxers read to assign the language. assert!( idx_text.contains("id: en, index: 0"), "missing id: line, got:\n{idx_text}" diff --git a/src/mux/disc.rs b/src/mux/disc.rs index a921a22..a555898 100644 --- a/src/mux/disc.rs +++ b/src/mux/disc.rs @@ -1208,8 +1208,8 @@ mod tests { /// Recording `SectorSource`: logs every `(lba, count)` request and /// returns `Err` whenever the requested range covers `bad_sector`. - /// Successful reads return zeroed sectors (which are NOT - /// `ts_sync_destroyed`, so `DecryptingSectorSource` passes them through + /// Successful reads return zeroed sectors (which the content-clarity check + /// does not flag as scrambled, so `DecryptingSectorSource` passes them through /// even with synthetic AACS keys — no real decrypt is attempted). struct RecordingReader { capacity: u32, diff --git a/src/mux/hevc/mod.rs b/src/mux/hevc/mod.rs index 0bc444f..ef0c760 100644 --- a/src/mux/hevc/mod.rs +++ b/src/mux/hevc/mod.rs @@ -29,8 +29,8 @@ const HEVC_NAL_TYPE_MASK: u8 = 0x3F; /// /// One instance per output stream. Tracks whether parameter sets have /// already been emitted so they're written exactly once at the head of -/// the stream, mirroring the convention used by `ffmpeg -c:v copy -f -/// hevc`. +/// the stream, per the Annex B convention of ITU-T H.265 / ISO/IEC +/// 23008-2 (parameter sets precede the coded slices they govern). pub struct HevcMux { writer: W, /// `HEVCDecoderConfigurationRecord` payload (hvcC). Parsed lazily diff --git a/src/mux/m2ts_mux/mod.rs b/src/mux/m2ts_mux/mod.rs index 72fe861..b460958 100644 --- a/src/mux/m2ts_mux/mod.rs +++ b/src/mux/m2ts_mux/mod.rs @@ -1,9 +1,10 @@ //! Standard MPEG-TS (188-byte packets) muxer — sequential-only. //! //! Distinct from `super::tsmux::TsMuxer` (BD-TS with 192-byte packets -//! and the 4-byte TP_extra_header). This muxer emits the IETF / ISO/IEC -//! 13818-1 wire format that ffmpeg, VLC, and `m2tsindex` consume -//! out of the box. Use it for plain `.ts` / `.m2ts` files over a +//! and the 4-byte TP_extra_header). This muxer emits the ITU-T H.222.0 / +//! ISO/IEC 13818-1 wire format that any conformant transport-stream +//! demuxer or player consumes out of the box. Use it for plain +//! `.ts` / `.m2ts` files over a //! [`SequentialSink`](crate::io::sink::SequentialSink), and for //! MPEG-TS-over-UDP via [`UdpSocketSink`](crate::io::sink::UdpSocketSink). //! @@ -44,8 +45,8 @@ //! attached to the video PID's adaptation field every //! `PCR_INTERVAL_PACKETS` packets. //! - No language / descriptor tags, no SCTE-35 markers, no per-PID -//! PMT version bumps, no SDT/EIT. Sufficient for "ffmpeg can play -//! this back", not for full broadcast deployment. +//! PMT version bumps, no SDT/EIT. Sufficient for a conformant +//! demuxer to play this back, not for full broadcast deployment. use std::io::{self, Write}; diff --git a/src/mux/meta_sink.rs b/src/mux/meta_sink.rs index 745e3d7..294619f 100644 --- a/src/mux/meta_sink.rs +++ b/src/mux/meta_sink.rs @@ -34,16 +34,28 @@ fn chapters_vtt(chapters: &[Chapter]) -> String { let mut s = String::from("WEBVTT\n\n"); for (i, c) in chapters.iter().enumerate() { let start = c.time_secs.max(0.0); + // Each cue runs until the next chapter. WebVTT drops a cue whose end is not + // strictly after its start, so the last chapter (and any degenerate + // equal-timestamp pair) gets a 1 s minimum duration rather than being lost. let end = chapters .get(i + 1) .map(|n| n.time_secs.max(0.0)) - .unwrap_or(start); + .filter(|&e| e > start) + .unwrap_or(start + 1.0); + // No localized prose in the library (see Chapter::name): emit the bare + // name, or a plain ordinal when unnamed — the app prepends any "Chapter " + // prefix in the user's language. Matches chapters_xml / chapters_ogm. + let name = if c.name.is_empty() { + (i + 1).to_string() + } else { + c.name.clone() + }; s.push_str(&format!( - "{}\n{} --> {}\nChapter {}\n\n", + "{}\n{} --> {}\n{}\n\n", i + 1, vtt_time(start), vtt_time(end), - c.name + name )); } s @@ -231,8 +243,13 @@ pub struct JsonSink { impl JsonSink { pub fn create(path: &Path, title: &DiscTitle) -> io::Result { - let doc = - serde_json::to_string_pretty(&title_json(title)).unwrap_or_else(|_| "{}".to_string()); + // Serializing our own `Value` is infallible in practice (serde_json maps + // any non-finite float to `null` at Value construction, so `title_json` + // never holds an unencodable value); still, propagate rather than silently + // writing "{}" if that ever changes — an empty metadata file must not + // masquerade as a successful json:// export. + let doc = serde_json::to_string_pretty(&title_json(title)) + .map_err(|_| crate::error::Error::MkvInvalid)?; let mut f = File::create(path)?; f.write_all(doc.as_bytes())?; f.write_all(b"\n")?; diff --git a/src/mux/mkv.rs b/src/mux/mkv.rs index a04a6d8..f914233 100644 --- a/src/mux/mkv.rs +++ b/src/mux/mkv.rs @@ -131,7 +131,7 @@ fn mvc_decoder_config_record(subset_sps: &[u8], pps: &[u8]) -> Option> { /// /// The size field is the extension block length **excluding the 4-byte size /// field itself** — i.e. `4 ("mvcC") + record.len()`. This is the track-level -/// MVC signal that decoders and mediainfo read (the per-frame `BlockAdditional` +/// MVC signal that decoders and media analyzers read (the per-frame `BlockAdditional` /// under the `mvcC` BlockAdditionMapping carries the dependent view's data). A /// plain (2D) track never calls this — it writes its `avcc` verbatim. fn mvc_codec_private(avcc: &[u8], record: &[u8]) -> Vec { @@ -432,22 +432,23 @@ impl MkvTrack { // to fix the Windows-fps report, on the theory that Windows derives // fps from it. The captured SOTL evidence proves the opposite: with // FlagInterlaced=1 + DefaultDuration=40 ms + DefaultDecodedFieldDuration=20 ms, - // Windows Explorer reports 12.5 fps (half), and MediaInfo flips the - // track to "Frame rate mode: Variable" with no clean rate. MakeMKV's - // correct rip of the same disc OMITS DefaultDecodedFieldDuration, + // Windows Explorer reports 12.5 fps (half), and a media analyzer + // flips the track to "Frame rate mode: Variable" with no clean rate. A + // known-correct rip of the same disc OMITS DefaultDecodedFieldDuration, // keeps FlagInterlaced=1 + FieldOrder=TFF + DefaultDuration=40 ms, and - // Explorer reports the full 25 fps with MediaInfo "Constant". ffmpeg's - // matroskaenc.c does the same (full-frame DefaultDuration, no field - // duration). The lone frame-rate signal every tool actually trusts is + // Explorer reports the full 25 fps with the analyzer showing "Constant". + // A conformant Matroska muxer does the same (full-frame DefaultDuration, + // no field duration). The lone frame-rate signal every tool actually + // trusts is // `1 / DefaultDuration`; that full-frame value (40 ms → 25 fps) is kept // below. Dropping the field-duration element removes the per-field // signal that made Explorer halve the rate. // // Trade-off: the container no longer carries an explicit per-field // decoded duration. Nothing is lost in practice — the interlace - // signaling that deinterlacers and MediaInfo rely on lives in the + // signaling that deinterlacers and media analyzers rely on lives in the // MPEG-2 elementary stream's picture_coding_extension (picture_structure / - // top_field_first), which MediaInfo reads directly (so it still reports + // top_field_first), which analyzers read directly (so they still report // "Interlaced / Top Field First"), and the container still flags // FlagInterlaced=1 + FieldOrder=TFF so players keep deinterlacing. field_duration_ns: 0, @@ -480,8 +481,8 @@ impl MkvTrack { // and DTS-HD Master Audio." Players distinguish core vs HD-HRA vs // HD-MA by parsing the DTS bitstream extension substreams, not by // the container codec ID. The previously-emitted `A_DTS/MA` and - // `A_DTS/HR` suffixes are NOT registered codec IDs; strict parsers - // (libmatroska) and some hardware renderers fail to recognise the + // `A_DTS/HR` suffixes are NOT registered codec IDs; strict Matroska + // parsers and some hardware renderers fail to recognise the // track at all. Emit plain `A_DTS` for every DTS variant — the // lossless MA / HRA payload bytes are unchanged, only the // container codec-ID string differs. @@ -602,7 +603,7 @@ pub struct MkvMuxer { base_pts_ticks: Option, /// Last block timecode (TimestampScale ticks, relative to base_pts) written /// PER TRACK, to enforce strictly-monotonic per-track timestamps — - /// players/ffmpeg reject non-monotonic DTS, and some audio PES PTS land on + /// players and decoders reject non-monotonic DTS, and some audio PES PTS land on /// the same tick (or tick back one from rounding). last_pts_ticks: std::collections::HashMap, /// Per-track-index flag: true if the track is video. The strictly-monotonic @@ -762,7 +763,7 @@ const MIN_BLOCK_REL: i64 = i16::MIN as i64; /// later than the previous one written for that track. `prev` is the last /// timestamp for the track (`None` for the first frame). Fixes non-monotonic /// DTS: some audio PES PTS truncate to the same tick as the prior frame (or tick -/// back one from rounding), which ffmpeg/strict players reject. At the 0.1 ms +/// back one from rounding), which strict players/decoders reject. At the 0.1 ms /// scale a TrueHD AU (0.833 ms = ~8 ticks) no longer collides with its /// neighbour, so this rarely fires for lossless audio — but a +1-tick nudge /// (0.1 ms, sub-AU and inaudible) still guards genuine same-tick collisions on @@ -919,7 +920,8 @@ impl MkvMuxer { Some(pos + 3) }; // Stamp the freemkv version so any muxed file is traceable to the build - // that produced it (MediaInfo "Writing application"/"library"). + // that produced it (surfaced as a media analyzer's "Writing + // application"/"library" field). ebml::write_string(&mut writer, ebml::MUXING_APP, crate::MUX_APP)?; ebml::write_string(&mut writer, ebml::WRITING_APP, crate::MUX_APP)?; if let Some(t) = title { @@ -992,7 +994,7 @@ impl MkvMuxer { match mvc_record.as_ref() { // MVC (Blu-ray 3D) base track: CodecPrivate = base-view avcC // followed by the `mvcC` extension block. This is the - // track-level signal decoders/mediainfo read to recognise the + // track-level signal decoders/analyzers read to recognise the // stereoscopic MVC track (the per-frame dependent view rides // in BlockAdditional under the mapping below). Some(record) => { @@ -1023,9 +1025,10 @@ impl MkvMuxer { // child of TrackEntry. The production video path now ALWAYS passes // `field_duration_ns == 0` (see `MkvTrack::video`) so this element is // NOT written: emitting it (20 ms for 576i25) is exactly what made - // Windows Explorer report 12.5 fps and MediaInfo flip to VFR on the - // captured SOTL rip, while MakeMKV — which omits it — shows the full - // 25 fps. The guard below is retained so a non-zero value still emits + // Windows Explorer report 12.5 fps and a media analyzer flip to VFR on + // the captured SOTL rip, while a known-correct rip — which omits it — + // shows the full 25 fps. The guard below is retained so a non-zero + // value still emits // a well-formed element for any future caller / round-trip test, but // the muxer's own callers no longer trigger it. if track.track_type == ebml::TRACK_TYPE_VIDEO @@ -1100,7 +1103,7 @@ impl MkvMuxer { // Blu-ray 3D (MVC) signaling — BlockAdditionMapping (sibling of // Video) carries the mvcC MVCDecoderConfigurationRecord so players / - // mediainfo recognise the dependent (right-eye) view that rides as a + // analyzers recognise the dependent (right-eye) view that rides as a // per-frame BlockAdditional under this mapping (BlockAddIDValue = 2). match mvc_record.as_ref() { Some(record) => { @@ -1134,7 +1137,7 @@ impl MkvMuxer { // Dolby Vision signaling — BlockAdditionMapping is a child of the // TrackEntry (sibling of Video). Carries the dvcC so players / - // mediainfo recognise the track as Dolby Vision. + // analyzers recognise the track as Dolby Vision. if let Some(ref dvcc) = track.dv_config { let map_pos = ebml::start_master(&mut writer, ebml::BLOCK_ADDITION_MAPPING)?; // BlockAddIDType = "dvcC" fourcc (DOVIDecoderConfigurationRecord). @@ -4279,8 +4282,8 @@ mod tests { // fps, the only rate every tool trusts) and must NOT emit // DefaultDecodedFieldDuration. rc.5.1 emitted the 20 ms field duration to // try to fix Windows; the captured SOTL evidence proved it did the - // opposite (Explorer 12.5 fps, MediaInfo VFR). MakeMKV's correct rip omits - // it (Explorer 25 fps, MediaInfo CFR). So: frame duration present = 40 ms, + // opposite (Explorer 12.5 fps, analyzer VFR). A known-correct rip omits + // it (Explorer 25 fps, analyzer CFR). So: frame duration present = 40 ms, // field duration ABSENT, interlace signalling (FlagInterlaced/FieldOrder) // retained. let v = VideoStream { @@ -4313,7 +4316,7 @@ mod tests { "DefaultDecodedFieldDuration must NOT be written (Windows halves the rate when it is)" ); // Interlace signalling is RETAINED so deinterlacers still engage and - // MediaInfo (which also reads scan type from the MPEG-2 ES) agrees. + // a media analyzer (which also reads scan type from the MPEG-2 ES) agrees. let fi = find_id(&data, ebml::FLAG_INTERLACED).expect("FlagInterlaced present"); assert_eq!( data[fi + 2], @@ -4816,7 +4819,7 @@ mod tests { fn mvc_track_emits_mvcc_block_addition_mapping() { // A track with mvc_params must emit BlockAdditionMapping (0x41E4) with the // mvcC BlockAddIDType (0x6D766343) and a BlockAddIDValue (0x41F0), so - // players / mediainfo recognise the Blu-ray 3D dependent view. + // players / analyzers recognise the Blu-ray 3D dependent view. let mut v = make_video_track(); v.mvc_params = Some(( vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22], @@ -4869,7 +4872,7 @@ mod tests { #[test] fn mvc_track_codec_private_carries_avcc_plus_mvcc() { // An MVC base track's CodecPrivate must be the base avcC followed by the - // mvcC extension — the track-level signal mediainfo/decoders read. + // mvcC extension — the track-level signal analyzers/decoders read. let avcc = vec![ 0x01, 0x64, 0x00, 0x33, 0xFF, 0xE1, 0x00, 0x05, 0x67, 0x64, 0x00, 0x33, 0x99, ]; diff --git a/src/mux/mp4/audio.rs b/src/mux/mp4/audio.rs index 5d90155..e61868c 100644 --- a/src/mux/mp4/audio.rs +++ b/src/mux/mp4/audio.rs @@ -224,8 +224,10 @@ pub(super) fn audio_sample_entry( e.extend_from_slice(&16u16.to_be_bytes()); // samplesize e.extend_from_slice(&0u16.to_be_bytes()); // pre_defined e.extend_from_slice(&0u16.to_be_bytes()); // reserved - // samplerate is 16.16 fixed point; the integer rate in the high 16 bits. - e.extend_from_slice(&(sample_rate << 16).to_be_bytes()); + // samplerate is 16.16 fixed point; the integer rate in the high 16 bits. The + // integer part is only 16 bits, so cap at 65535 — 96/192 kHz (DTS-HD) would + // otherwise overflow u32 and write a garbage rate (the true rate is in ddts). + e.extend_from_slice(&(sample_rate.min(0xFFFF) << 16).to_be_bytes()); e.extend_from_slice(config); bx(fourcc, &e) } @@ -278,13 +280,15 @@ fn parse_dts(frame: &[u8]) -> Option { let base_ch = DTS_AMODE_CH.get(amode).copied().unwrap_or(6); let channels = base_ch as u16 + lfe as u16; let channel_layout = dts_channel_layout(amode, lfe); - // DTS-HD extension substream sync (0x64582025) after the core frame. + // DTS-HD extension substream sync (0x64582025) after the core frame. Search + // ONLY the region at/after the core end (core_size = fsize+1): scanning the + // whole frame would false-positive on the same 4 bytes occurring inside the + // compressed core payload, mislabeling a plain DTS core as DTS-HD (dtsh). let ext_sync = [0x64, 0x58, 0x20, 0x25]; - let has_extension = f - .windows(4) - .skip((fsize as usize + 1).min(f.len()).saturating_sub(4)) - .any(|w| w == ext_sync) - || f.windows(4).any(|w| w == ext_sync); + // The EXSS begins at byte core_size (= fsize + 1); start the window search + // exactly there so no 4-byte window inside the compressed core is ever tested. + let ext_sync_start = (fsize as usize + 1).min(f.len()); + let has_extension = f.windows(4).skip(ext_sync_start).any(|w| w == ext_sync); Some(DtsConfig { sample_rate, @@ -523,4 +527,42 @@ mod tests { let e = dolby_sample_entry(Codec::DtsHdMa, &f).unwrap(); assert_eq!(&e[4..8], b"dtsc"); } + + #[test] + fn dts_ext_sync_inside_core_is_not_a_false_positive() { + // The 4-byte ext-sync pattern occurring INSIDE the compressed core payload + // (before core_size) must NOT be read as a DTS-HD extension → stays dtsc. + // f[4..8] = ext_sync makes core_size huge (>> frame len), so the search + // region is only after the core (skipped past this frame) → no extension. + let f = vec![ + 0x7F, 0xFE, 0x80, 0x01, 0x64, 0x58, 0x20, 0x25, 0x00, 0x00, 0x02, 0x00, + ]; + let c = parse_dts(&f).expect("parses"); + assert!(!c.has_extension, "ext-sync inside core is not an extension"); + let e = dolby_sample_entry(Codec::DtsHdMa, &f).unwrap(); + assert_eq!(&e[4..8], b"dtsc"); + } + + #[test] + fn dts_ext_sync_at_core_end_is_detected() { + // fsize=8 → core_size=9; the EXSS sync sits exactly at byte 9 (right after + // the core) and MUST be detected → dtsh. Guards the off-by-4 boundary. + let f = vec![ + 0x7F, 0xFE, 0x80, 0x01, 0x00, 0x00, 0x00, 0x80, 0x00, 0x64, 0x58, 0x20, 0x25, + ]; + let c = parse_dts(&f).expect("parses"); + assert_eq!(c.core_size, 9); + assert!(c.has_extension, "EXSS sync at core end is a real extension"); + let e = dolby_sample_entry(Codec::DtsHdMa, &f).unwrap(); + assert_eq!(&e[4..8], b"dtsh"); + } + + #[test] + fn sample_entry_samplerate_does_not_overflow_at_96k() { + // 96 kHz > 65535: the 16.16 integer part must saturate, not wrap to garbage. + let e = audio_sample_entry(b"ac-3", 6, 96_000, &[]); + // 8-byte box header + body offset 24 (6+2+8+2+2+2+2) → samplerate at 32; + // high 16 bits = the integer rate. + assert_eq!(&e[32..34], &[0xFF, 0xFF], "capped to 65535, not wrapped"); + } } diff --git a/src/mux/mp4/mod.rs b/src/mux/mp4/mod.rs index bc23685..ecb8a5a 100644 --- a/src/mux/mp4/mod.rs +++ b/src/mux/mp4/mod.rs @@ -38,6 +38,10 @@ pub use read::Mp4Reader; /// Nanoseconds per second — PTS is carried in ns, media timescales are Hz. const NS: i64 = 1_000_000_000; +/// Movie (mvhd) timescale in Hz. `tkhd.duration` is expressed in THIS timescale +/// (ISO/IEC 14496-12 §8.3.2), not the track's own media timescale. +const MOVIE_TIMESCALE: u32 = 90_000; + // ── faststart reserve sizing ───────────────────────────────────────────────── // // Faststart is on by default: reserve a `moov`-sized hole between `ftyp` and @@ -133,10 +137,14 @@ struct Track { pub enum Mp4SkipReason { /// A subtitle track — MP4 carries only text subs; disc subs are bitmap. BitmapSubtitle, - /// An audio codec with no MP4 mapping here (TrueHD, DTS, LPCM, …). + /// An audio codec with no MP4 mapping here (TrueHD, LPCM, …). AC-3/E-AC-3 and + /// DTS/DTS-HD ARE mapped and carried. UnmappableAudio, /// A secondary/dependent video view (e.g. MVC 3D right eye). SecondaryVideo, + /// A primary video track whose codec this MP4 writer can't carry + /// (only HEVC/H.264 are supported — e.g. VC-1, MPEG-2, AV1). + UnmappableVideo, } /// The plan for an `mp4://` mux of `title`: which streams are carried and which @@ -164,8 +172,12 @@ pub fn fit_report(title: &DiscTitle) -> Mp4FitReport { } else if !have_video && matches!(v.codec, Codec::Hevc | Codec::H264) { included.push(i); have_video = true; - } else { + } else if have_video { + // A second primary video (after one was already carried). skipped.push((i, Mp4SkipReason::SecondaryVideo)); + } else { + // First primary video, but an unsupported codec (VC-1/MPEG-2/AV1). + skipped.push((i, Mp4SkipReason::UnmappableVideo)); } } DiscStream::Audio(a) => { @@ -227,7 +239,7 @@ impl Mp4Sink { .iter() .any(|&i| matches!(title.streams[i], DiscStream::Video(_))); if !has_video { - return Err(crate::error::Error::MuxNoVideoTrack.into()); + return Err(crate::error::Error::Mp4NoVideoTrack.into()); } let mut tracks = Vec::new(); @@ -245,7 +257,7 @@ impl Mp4Sink { .codec_privates .get(i) .and_then(|c| c.clone()) - .ok_or(crate::error::Error::MuxMissingCodecPrivate)?; + .ok_or(crate::error::Error::Mp4MissingCodecPrivate)?; let (w, h) = v.resolution.pixels(); tracks.push(Track { media: Media::Video, @@ -317,7 +329,7 @@ impl Mp4Sink { /// Assemble the `moov` box from every track's sample tables. fn build_moov(&self) -> Vec { // Movie timescale = 90 kHz; movie duration = the longest track (converted). - let movie_ts = 90_000u32; + let movie_ts = MOVIE_TIMESCALE; let mut movie_dur = 0u64; let mut traks: Vec> = Vec::new(); for t in &self.tracks { @@ -441,7 +453,9 @@ fn build_video_trak_full(t: &Track) -> (Vec, f64) { "VideoHandler", minf, ); - let tkhd = build_tkhd(t.track_id, t.width, t.height, media_dur, false); + // tkhd.duration is in the MOVIE timescale, not `timing.timescale`. + let tkhd_dur = (secs * MOVIE_TIMESCALE as f64) as u64; + let tkhd = build_tkhd(t.track_id, t.width, t.height, tkhd_dur, false); let mut body = tkhd; body.extend_from_slice(&mdia); (bx(b"trak", &body), secs) @@ -457,7 +471,9 @@ fn build_audio_trak_full(t: &Track) -> (Vec, f64) { let stbl = build_audio_stbl(entry, &t.samples, &durs); let minf = build_minf(audio_smhd(), stbl); let mdia = build_mdia(t.language, ts, media_dur, b"soun", "SoundHandler", minf); - let tkhd = build_tkhd(t.track_id, 0, 0, media_dur, true); + // tkhd.duration is in the MOVIE timescale, not the audio media timescale. + let tkhd_dur = (secs * MOVIE_TIMESCALE as f64) as u64; + let tkhd = build_tkhd(t.track_id, 0, 0, tkhd_dur, true); let mut body = tkhd; body.extend_from_slice(&mdia); (bx(b"trak", &body), secs) @@ -947,6 +963,24 @@ mod tests { assert!(r.skipped.contains(&(4, Mp4SkipReason::BitmapSubtitle))); } + #[test] + fn fit_report_labels_unsupported_primary_video() { + // A primary video whose codec the MP4 writer can't carry (VC-1) must be + // skipped as UnmappableVideo, NOT SecondaryVideo (which means an MVC view). + let mut vc1 = match hevc_video() { + DiscStream::Video(v) => v, + _ => unreachable!(), + }; + vc1.codec = Codec::Vc1; + let t = title( + vec![DiscStream::Video(vc1), audio(Codec::Ac3, "eng")], + vec![None, None], + ); + let r = fit_report(&t); + assert!(r.skipped.contains(&(0, Mp4SkipReason::UnmappableVideo))); + assert_eq!(r.included, vec![1], "only the AC-3 audio is carried"); + } + #[test] fn no_video_track_is_an_error() { let t = title(vec![audio(Codec::Ac3, "eng")], vec![None]); diff --git a/src/mux/mp4/read.rs b/src/mux/mp4/read.rs index e2cbbee..63b1bc7 100644 --- a/src/mux/mp4/read.rs +++ b/src/mux/mp4/read.rs @@ -22,6 +22,26 @@ use std::path::Path; const NS: i128 = 1_000_000_000; +/// Upper bound on the number of tracks. Track count is otherwise unbounded (a +/// crafted moov can pack tens of thousands of `trak` boxes), and the per-track +/// PID is `0x1011 + track_idx` — which overflows u16 past ~61k tracks. Real +/// titles have well under a hundred tracks. +const MAX_TRACKS: usize = 512; + +/// Upper bound on a track's decoded sample count. MP4 sample-table fields +/// (`stsz` sample_count, `stts`/`ctts` run-lengths) are untrusted 32-bit values; +/// a crafted box can declare billions of entries in a few bytes. Real titles stay +/// far under this (a 10 h/60 fps track is ~2M samples), so clamping to it caps a +/// hostile file's allocation without truncating any legitimate track. +const MAX_SAMPLE_COUNT: usize = 1 << 24; + +/// Absolute ceiling on a single allocation sized from an untrusted MP4 field (a +/// per-sample buffer or the `moov` payload). The EOF check alone is not enough: +/// `file_len` is cheaply inflatable with a sparse file (`truncate -s 8G`), so a +/// crafted stsz size or moov box size just under an 8 GiB apparent length would +/// otherwise force a multi-GiB allocation. No real sample or moov approaches this. +const MAX_ALLOC_BYTES: u64 = 256 << 20; // 256 MiB + /// One sample's location + timing in the emission plan. struct SampleRef { track: usize, @@ -39,6 +59,9 @@ struct SampleRef { /// source) or an in-memory `Cursor` (round-trip tests). pub struct Mp4Reader { file: R, + /// Total length of the backing file, captured at open — used to reject a + /// crafted `stsz` sample size that would over-allocate the per-sample buffer. + file_len: u64, title: DiscTitle, samples: Vec, cursor: usize, @@ -59,6 +82,8 @@ impl Mp4Reader { impl Mp4Reader { /// Index an already-opened seekable MP4 reader. pub fn from_reader(mut file: R, name: String) -> io::Result { + let file_len = file.seek(SeekFrom::End(0))?; + file.seek(SeekFrom::Start(0))?; let moov = read_moov(&mut file)?; let mut title = DiscTitle::empty(); title.playlist = name; @@ -66,13 +91,21 @@ impl Mp4Reader { let mut samples: Vec = Vec::new(); let mut codec_privates: Vec>> = Vec::new(); let mut track_idx = 0usize; + // Global cap on total decoded samples across ALL tracks — a crafted file + // with many `trak` boxes must not sum past this even though each track is + // individually bounded. Real titles stay far under it. + let mut sample_budget = MAX_SAMPLE_COUNT; for trak in find_boxes(&moov, b"trak") { + if track_idx >= MAX_TRACKS { + break; // bound track count so the per-track PID can't overflow u16 + } let Some(mdia) = find_box(trak, b"mdia") else { continue; }; let timescale = find_box(mdia, b"mdhd") .and_then(mdhd_timescale) + .filter(|&t| t != 0) // a crafted mdhd timescale of 0 would divide-by-zero below .unwrap_or(90_000); let language = find_box(mdia, b"mdhd").and_then(mdhd_language); let handler = find_box(mdia, b"hdlr").and_then(hdlr_type); @@ -113,7 +146,9 @@ impl Mp4Reader { Some(h) if &h == b"soun" => DiscStream::Audio(AudioStream { pid: 0x1100 + track_idx as u16, codec, - channels: AudioChannels::from_count(channels as u8), + // `channels` is an untrusted u16; saturate rather than wrap with + // `as u8` (a crafted 256 would alias to 0/Mono). + channels: AudioChannels::from_count(channels.min(u8::MAX as u16) as u8), language: language.clone().unwrap_or_else(|| "und".into()), sample_rate: SampleRate::from_hz(timescale), secondary: false, @@ -123,8 +158,12 @@ impl Mp4Reader { _ => continue, // non-A/V handler }; - // Per-sample tables. - let sizes = find_box(stbl, b"stsz").map(parse_stsz).unwrap_or_default(); + // Per-sample tables. `stsz` is bounded by the remaining global budget; + // `stts`/`ctts` need at most one entry per sample, so they are bounded by + // this track's sample count (indices past it are never read). + let sizes = find_box(stbl, b"stsz") + .map(|b| parse_stsz(b, sample_budget)) + .unwrap_or_default(); let n = sizes.len(); if n == 0 { track_idx += 1; @@ -132,24 +171,49 @@ impl Mp4Reader { codec_privates.push(config); continue; } + sample_budget -= n; let chunk_offsets = find_box(stbl, b"stco") .map(|b| parse_stco(b, false)) .or_else(|| find_box(stbl, b"co64").map(|b| parse_stco(b, true))) .unwrap_or_default(); + if chunk_offsets.is_empty() { + // Samples exist but there is no chunk-offset table: the stbl is + // malformed and every sample offset would resolve to file byte 0 + // (muxing header bytes as frame data). Drop the track rather than + // emit garbage; an all-tracks-dropped file fails Mp4Invalid below. + continue; + } let stsc = find_box(stbl, b"stsc").map(parse_stsc).unwrap_or_default(); + if stsc.is_empty() { + // No sample-to-chunk map: samples can't be placed against the chunk + // offsets (they would pack from byte 0). Drop the track rather than + // emit header bytes as frame data — a valid stbl always has stsc. + continue; + } let offsets = sample_offsets(&sizes, &chunk_offsets, &stsc); - let durations = find_box(stbl, b"stts").map(parse_stts).unwrap_or_default(); - let ctts = find_box(stbl, b"ctts").map(parse_ctts).unwrap_or_default(); + let durations = find_box(stbl, b"stts") + .map(|b| parse_stts(b, n)) + .unwrap_or_default(); + let ctts = find_box(stbl, b"ctts") + .map(|b| parse_ctts(b, n)) + .unwrap_or_default(); let sync = find_box(stbl, b"stss").map(parse_stss); + // ticks → ns, saturating: a crafted tiny timescale + huge stts deltas can + // push the i128 quotient past i64::MAX; wrapping it would silently corrupt + // the sort/timestamps, so clamp instead. + let to_ns = |ticks: i64| -> i64 { + (ticks as i128 * NS / timescale as i128).clamp(i64::MIN as i128, i64::MAX as i128) + as i64 + }; let mut decode_ticks: i64 = 0; for (i, &size) in sizes.iter().enumerate() { let dur = durations.get(i).copied().unwrap_or(0); let comp = ctts.get(i).copied().unwrap_or(0); - let dts_ns = (decode_ticks as i128 * NS / timescale as i128) as i64; - let pts_ticks = decode_ticks + comp as i64; - let pts_ns = (pts_ticks as i128 * NS / timescale as i128) as i64; - decode_ticks += dur as i64; + let dts_ns = to_ns(decode_ticks); + let pts_ticks = decode_ticks.saturating_add(comp as i64); + let pts_ns = to_ns(pts_ticks); + decode_ticks = decode_ticks.saturating_add(dur as i64); let keyframe = match &sync { Some(set) => set.contains(&(i as u32 + 1)), None => true, // no stss → every sample is a sync sample @@ -170,7 +234,7 @@ impl Mp4Reader { } if title.streams.is_empty() { - return Err(crate::error::Error::MkvInvalid.into()); + return Err(crate::error::Error::Mp4Invalid.into()); } title.codec_privates = codec_privates; @@ -180,6 +244,7 @@ impl Mp4Reader { Ok(Self { file, + file_len, title, samples, cursor: 0, @@ -193,6 +258,13 @@ impl Stream for Mp4Reader { return Ok(None); }; self.cursor += 1; + // `s.size`/`s.offset` come from the untrusted stsz/stco tables; reject a + // sample that claims to extend past EOF before allocating its buffer, so a + // crafted size can't force a multi-GB allocation the read would then fail. + let end = s.offset.checked_add(s.size as u64); + if s.size as u64 > MAX_ALLOC_BYTES || end.is_none_or(|e| e > self.file_len) { + return Err(crate::error::Error::Mp4Invalid.into()); + } self.file.seek(SeekFrom::Start(s.offset))?; let mut data = vec![0u8; s.size as usize]; self.file.read_exact(&mut data)?; @@ -229,28 +301,47 @@ impl Stream for Mp4Reader { /// Read top-level boxes until `moov`, returning its payload (after the header). /// Skips over `ftyp`/`mdat`/etc. via seek; samples are read later by offset. fn read_moov(file: &mut R) -> io::Result> { + let file_end = file.seek(SeekFrom::End(0))?; + file.seek(SeekFrom::Start(0))?; loop { + let pos = file.stream_position()?; let mut hdr = [0u8; 8]; if file.read_exact(&mut hdr).is_err() { - return Err(crate::error::Error::MkvInvalid.into()); + return Err(crate::error::Error::Mp4Invalid.into()); } let size32 = u32::from_be_bytes([hdr[0], hdr[1], hdr[2], hdr[3]]); let btype = [hdr[4], hdr[5], hdr[6], hdr[7]]; - // 64-bit largesize (size==1): the real size is the next 8 bytes; a - // 16-byte header precedes the payload. size==0 means "to EOF". - let payload_len: u64 = if size32 == 1 { - let mut ext = [0u8; 8]; - file.read_exact(&mut ext)?; - u64::from_be_bytes(ext).saturating_sub(16) - } else { - (size32 as u64).saturating_sub(8) + // Total box size INCLUDING the header. `size==1` → 64-bit largesize in the + // next 8 bytes (16-byte header); `size==0` → the box runs to end of file. + let box_size: u64 = match size32 { + 1 => { + let mut ext = [0u8; 8]; + file.read_exact(&mut ext)?; + u64::from_be_bytes(ext) + } + 0 => file_end.saturating_sub(pos), + n => n as u64, }; + let header_len: u64 = if size32 == 1 { 16 } else { 8 }; + // A box must contain at least its own header and cannot run past EOF. This + // also guarantees forward progress (box_size >= header_len > 0), so a + // crafted size < 8 can't spin the loop in place, and bounds every payload + // allocation to the real file length (no gigabyte over-allocation). Use + // checked_add so a 64-bit largesize near u64::MAX can't wrap past the guard. + if box_size < header_len || pos.checked_add(box_size).is_none_or(|end| end > file_end) { + return Err(crate::error::Error::Mp4Invalid.into()); + } if &btype == b"moov" { + let payload_len = box_size - header_len; + // Absolute cap independent of the (sparse-file-inflatable) length. + if payload_len > MAX_ALLOC_BYTES { + return Err(crate::error::Error::Mp4Invalid.into()); + } let mut buf = vec![0u8; payload_len as usize]; file.read_exact(&mut buf)?; return Ok(buf); } - file.seek(SeekFrom::Current(payload_len as i64))?; + file.seek(SeekFrom::Start(pos + box_size))?; } } @@ -310,7 +401,9 @@ fn mdhd_timescale(b: &[u8]) -> Option { /// mdhd language (5-bit packed ISO 639-2) → lowercase 3-letter code. fn mdhd_language(b: &[u8]) -> Option { let version = b.first().copied()?; - let off = if version == 1 { 28 } else { 20 }; + // v0: vflags(4)+creation(4)+modification(4)+timescale(4)+duration(4) = 20. + // v1: creation/modification/duration are 64-bit → vflags(4)+8+8+4+8 = 32. + let off = if version == 1 { 32 } else { 20 }; if b.len() < off + 2 { return None; } @@ -352,7 +445,9 @@ fn parse_stsd(b: &[u8]) -> Option { } let size = be32(entry, 0) as usize; let fourcc = [entry[4], entry[5], entry[6], entry[7]]; - let body = &entry[8..size.min(entry.len())]; + // `size` is untrusted: clamp to [8, entry.len()] so a declared size < 8 (or a + // truncated entry) yields an empty body instead of panicking on `entry[8..<8]`. + let body = &entry[8..size.clamp(8, entry.len())]; let codec = match &fourcc { b"hvc1" | b"hev1" => Codec::Hevc, @@ -384,28 +479,96 @@ fn parse_stsd(b: &[u8]) -> Option { }) } else { // AudioSampleEntry: 6 reserved + 2 dri + 8 reserved + channelcount(2) - // samplesize(2) + 2 pre + 2 reserved + samplerate(4) = 28 bytes. + // samplesize(2) + 2 pre + 2 reserved + samplerate(4) = 28 bytes, then child + // boxes. AAC (mp4a) carries its AudioSpecificConfig in an `esds` box — the + // MKV CodecPrivate for A_AAC. AC-3/DTS are self-describing in-band (None). let channels = if body.len() >= 28 { be16(body, 16) } else { 2 }; + let config = if matches!(codec, Codec::Aac) && body.len() >= 28 { + find_box(&body[28..], b"esds").and_then(parse_esds_asc) + } else { + None + }; Some(StsdInfo { codec, height: 0, - config: None, + config, channels, }) } } +/// Read an MPEG-4 expandable descriptor length (ISO/IEC 14496-1), advancing `pos`. +/// Each byte contributes 7 bits, continued while the high bit is set (max 4 bytes). +fn read_descriptor_len(b: &[u8], pos: &mut usize) -> usize { + let mut len = 0usize; + for _ in 0..4 { + let Some(&byte) = b.get(*pos) else { break }; + *pos += 1; + len = (len << 7) | (byte & 0x7F) as usize; + if byte & 0x80 == 0 { + break; + } + } + len +} + +/// esds → AAC AudioSpecificConfig (the A_AAC CodecPrivate), or `None`. Walks +/// ES_Descriptor(0x03) → DecoderConfigDescriptor(0x04) → DecoderSpecificInfo(0x05). +/// Fully bounds-checked: a malformed/truncated esds returns None, never panics. +fn parse_esds_asc(b: &[u8]) -> Option> { + // esds is a FullBox: version+flags(4), then the ES_Descriptor. + let mut pos = 4; + if *b.get(pos)? != 0x03 { + return None; + } + pos += 1; + read_descriptor_len(b, &mut pos); // ES_Descriptor length (unused) + pos += 2; // ES_ID + let flags = *b.get(pos)?; + pos += 1; + if flags & 0x80 != 0 { + pos += 2; // streamDependenceFlag → dependsOn_ES_ID + } + if flags & 0x40 != 0 { + // URL_flag → URLlength(1) + URLstring + pos += 1 + *b.get(pos)? as usize; + } + if flags & 0x20 != 0 { + pos += 2; // OCRstreamFlag → OCR_ES_Id + } + if *b.get(pos)? != 0x04 { + return None; // DecoderConfigDescriptor + } + pos += 1; + read_descriptor_len(b, &mut pos); + // objectTypeIndication(1) + streamType/bufferSizeDB(4) + maxBitrate(4) + avgBitrate(4) + pos += 13; + if *b.get(pos)? != 0x05 { + return None; // DecoderSpecificInfo + } + pos += 1; + let asc_len = read_descriptor_len(b, &mut pos); + let end = pos.checked_add(asc_len)?; + if asc_len == 0 || end > b.len() { + return None; + } + Some(b[pos..end].to_vec()) +} + /// stsz → per-sample sizes. -fn parse_stsz(b: &[u8]) -> Vec { +fn parse_stsz(b: &[u8], max: usize) -> Vec { if b.len() < 12 { return Vec::new(); } let sample_size = be32(b, 4); - let count = be32(b, 8) as usize; + // `count` is untrusted; clamp to the caller's remaining sample budget so neither + // a single 0xFFFFFFFF nor many crafted tracks can over-allocate (see from_reader). + let count = (be32(b, 8) as usize).min(max); if sample_size != 0 { return vec![sample_size; count]; } - let mut out = Vec::with_capacity(count); + // Each entry is 4 bytes; `count` also can't exceed what the box actually holds. + let mut out = Vec::with_capacity(count.min((b.len() - 12) / 4)); for i in 0..count { let o = 12 + i * 4; if o + 4 > b.len() { @@ -422,8 +585,9 @@ fn parse_stco(b: &[u8], is64: bool) -> Vec { return Vec::new(); } let count = be32(b, 4) as usize; - let mut out = Vec::with_capacity(count); let stride = if is64 { 8 } else { 4 }; + // `count` entries of `stride` bytes can't exceed the box body. + let mut out = Vec::with_capacity(count.min((b.len() - 8) / stride)); for i in 0..count { let o = 8 + i * stride; if o + stride > b.len() { @@ -453,7 +617,8 @@ fn parse_stsc(b: &[u8]) -> Vec<(u32, u32)> { return Vec::new(); } let count = be32(b, 4) as usize; - let mut out = Vec::with_capacity(count); + // Each entry is 12 bytes; `count` can't exceed what the box actually holds. + let mut out = Vec::with_capacity(count.min((b.len() - 8) / 12)); for i in 0..count { let o = 8 + i * 12; if o + 12 > b.len() { @@ -489,7 +654,9 @@ fn sample_offsets(sizes: &[u32], chunk_offsets: &[u64], stsc: &[(u32, u32)]) -> break; } offsets.push(off); - off += sizes[sidx] as u64; + // `choff`/`sizes` are untrusted; saturate so a crafted co64 offset near + // u64::MAX can't overflow-panic (the read() EOF guard rejects it later). + off = off.saturating_add(sizes[sidx] as u64); sidx += 1; } } @@ -500,13 +667,15 @@ fn sample_offsets(sizes: &[u32], chunk_offsets: &[u64], stsc: &[(u32, u32)]) -> .get(offsets.len().saturating_sub(1)) .copied() .unwrap_or(0); - offsets.push(last + last_sz as u64); + offsets.push(last.saturating_add(last_sz as u64)); } offsets } -/// stts → per-sample decode durations (expanded from run-length entries). -fn parse_stts(b: &[u8]) -> Vec { +/// stts → per-sample decode durations (expanded from run-length entries). `max` +/// caps the expansion — the caller passes the track's real sample count, past which +/// entries are never read (and an untrusted run-length must not grow the Vec). +fn parse_stts(b: &[u8], max: usize) -> Vec { if b.len() < 8 { return Vec::new(); } @@ -520,6 +689,9 @@ fn parse_stts(b: &[u8]) -> Vec { let n = be32(b, o); let delta = be32(b, o + 4); for _ in 0..n { + if out.len() >= max { + return out; + } out.push(delta); } } @@ -527,7 +699,8 @@ fn parse_stts(b: &[u8]) -> Vec { } /// ctts → per-sample composition offsets (version 0 unsigned / version 1 signed). -fn parse_ctts(b: &[u8]) -> Vec { +/// `max` caps the expansion, as in [`parse_stts`]. +fn parse_ctts(b: &[u8], max: usize) -> Vec { if b.len() < 8 { return Vec::new(); } @@ -543,6 +716,9 @@ fn parse_ctts(b: &[u8]) -> Vec { let n = be32(b, o); let offset = be32(b, o + 4) as i32; for _ in 0..n { + if out.len() >= max { + return out; + } out.push(offset); } } @@ -592,6 +768,72 @@ mod tests { assert_eq!(sample_offsets(&sizes, &chunks, &stsc), vec![500, 510, 900]); } + #[test] + fn sample_offsets_saturates_on_huge_chunk_offset() { + // A co64 chunk offset near u64::MAX plus a sample size must saturate, not + // overflow-panic (debug) / wrap (release) — the read() EOF guard rejects + // the resulting out-of-range offset later. + let sizes = vec![10u32, 20]; + let chunks = vec![u64::MAX - 5]; + let stsc = vec![(1u32, 2u32)]; // 2 samples in the single chunk + let offs = sample_offsets(&sizes, &chunks, &stsc); + assert_eq!(offs[0], u64::MAX - 5); + assert_eq!(offs[1], u64::MAX, "(MAX-5)+10 saturates to MAX"); + } + + #[test] + fn read_rejects_sample_offset_past_eof() { + use crate::disc::{ + Codec, DiscTitle, FrameRate, HdrFormat, Resolution, Stream as DiscStreamE, VideoStream, + }; + use crate::mux::mp4::Mp4Sink; + use crate::pes::{PesFrame, Stream as _}; + use std::io::Cursor; + + // The MP4 writer requires a primary video track, so build an HEVC one. + let mut t = DiscTitle::empty(); + t.streams = vec![DiscStreamE::Video(VideoStream { + pid: 0x1011, + codec: Codec::Hevc, + resolution: Resolution::R1080p, + frame_rate: FrameRate::F23_976, + hdr: HdrFormat::Sdr, + color_space: crate::disc::ColorSpace::Unknown, + display_aspect: None, + secondary: false, + label: String::new(), + measured_cicp: None, + })]; + t.codec_privates = vec![Some(vec![0xAA, 0xBB, 0xCC, 0xDD, 0xEE])]; + let mut buf = Vec::new(); + { + let mut sink = Mp4Sink::create(Cursor::new(&mut buf), &t).unwrap(); + sink.write(&PesFrame { + track: 0, + pts: 0, + keyframe: true, + data: vec![0x11u8; 700], + duration_ns: None, + source: None, + coding: None, + }) + .unwrap(); + sink.finish().unwrap(); + } + // Faststart writes `moov` near the front and `mdat` after a multi-MiB + // reserve, so a 64 KiB truncation keeps the parseable moov but drops mdat. + // from_reader still succeeds; read() must reject the now-out-of-range + // sample rather than allocate for it or read past EOF. + buf.truncate(64 * 1024); + let mut rd = Mp4Reader::from_reader(Cursor::new(buf), "trunc".into()).unwrap(); + let err = rd.read().unwrap_err(); + assert_eq!( + err.kind(), + std::io::ErrorKind::InvalidData, + "a sample offset past EOF must be rejected" + ); + } + #[test] fn write_then_read_round_trip() { // Mux a small A/V title to an in-memory MP4, then demux it back and @@ -700,6 +942,161 @@ mod tests { stts.extend_from_slice(&1u32.to_be_bytes()); // entry_count stts.extend_from_slice(&3u32.to_be_bytes()); stts.extend_from_slice(&1001u32.to_be_bytes()); - assert_eq!(parse_stts(&stts), vec![1001, 1001, 1001]); + assert_eq!(parse_stts(&stts, MAX_SAMPLE_COUNT), vec![1001, 1001, 1001]); + } + + // ── Untrusted-input hardening: a crafted MP4 must never panic or over-allocate. + + #[test] + fn parse_stsz_fixed_size_caps_hostile_count() { + // sample_size != 0, count = u32::MAX: a 12-byte box must not allocate ~16 GiB. + let mut b = Vec::new(); + b.extend_from_slice(&[0, 0, 0, 0]); // version+flags + b.extend_from_slice(&1u32.to_be_bytes()); // sample_size + b.extend_from_slice(&u32::MAX.to_be_bytes()); // count + let out = parse_stsz(&b, MAX_SAMPLE_COUNT); + assert_eq!(out.len(), MAX_SAMPLE_COUNT); + // And a smaller budget (the cumulative multi-track cap) bounds it tighter. + assert_eq!(parse_stsz(&b, 100).len(), 100); + } + + #[test] + fn parse_stsz_table_count_bounded_by_box() { + // sample_size == 0, count huge, but only two real entries in the buffer. + let mut b = Vec::new(); + b.extend_from_slice(&[0, 0, 0, 0]); + b.extend_from_slice(&0u32.to_be_bytes()); // sample_size == 0 → table + b.extend_from_slice(&u32::MAX.to_be_bytes()); // count (lie) + b.extend_from_slice(&10u32.to_be_bytes()); + b.extend_from_slice(&20u32.to_be_bytes()); + assert_eq!(parse_stsz(&b, MAX_SAMPLE_COUNT), vec![10, 20]); + } + + #[test] + fn parse_stco_stsc_count_bounded_by_box() { + // stco: count lie, one real 32-bit offset. + let mut stco = Vec::new(); + stco.extend_from_slice(&[0, 0, 0, 0]); + stco.extend_from_slice(&u32::MAX.to_be_bytes()); + stco.extend_from_slice(&4096u32.to_be_bytes()); + assert_eq!(parse_stco(&stco, false), vec![4096]); + // stsc: count lie, one real (first_chunk, per) tuple. + let mut stsc = Vec::new(); + stsc.extend_from_slice(&[0, 0, 0, 0]); + stsc.extend_from_slice(&u32::MAX.to_be_bytes()); + stsc.extend_from_slice(&1u32.to_be_bytes()); + stsc.extend_from_slice(&7u32.to_be_bytes()); + stsc.extend_from_slice(&0u32.to_be_bytes()); // sample_desc_idx (unused) + assert_eq!(parse_stsc(&stsc), vec![(1, 7)]); + } + + #[test] + fn parse_stts_caps_hostile_runlength() { + // One entry with a u32::MAX run-length must cap, not push billions. + let mut stts = Vec::new(); + stts.extend_from_slice(&[0, 0, 0, 0]); + stts.extend_from_slice(&1u32.to_be_bytes()); // entry_count + stts.extend_from_slice(&u32::MAX.to_be_bytes()); // n + stts.extend_from_slice(&33u32.to_be_bytes()); // delta + assert_eq!(parse_stts(&stts, MAX_SAMPLE_COUNT).len(), MAX_SAMPLE_COUNT); + // A tight per-track cap (the real sample count) bounds the run-length too. + assert_eq!(parse_stts(&stts, 5).len(), 5); + } + + #[test] + fn parse_stsd_short_size_does_not_panic() { + // stsd sample entry declaring size = 0 must not panic on `entry[8..<8]`. + let mut b = Vec::new(); + b.extend_from_slice(&[0, 0, 0, 0]); // version+flags + b.extend_from_slice(&1u32.to_be_bytes()); // entry_count + b.extend_from_slice(&0u32.to_be_bytes()); // sample entry size = 0 + b.extend_from_slice(b"avc1"); // fourcc + // Recognised codec but empty body → None (no dimensions), no panic. + assert!(parse_stsd(&b).is_none()); + } + + #[test] + fn read_moov_oversize_is_rejected() { + use std::io::Cursor; + // A tiny file whose first box claims to be a `moov` far larger than the file. + let mut b = Vec::new(); + b.extend_from_slice(&0xFFFF_FFF0u32.to_be_bytes()); // size32 (~4 GiB) + b.extend_from_slice(b"moov"); + b.extend_from_slice(&[0u8; 16]); // a few real bytes, nowhere near the claim + assert!(read_moov(&mut Cursor::new(b)).is_err()); + } + + #[test] + fn parse_esds_extracts_aac_asc() { + // esds: version/flags + ES_Descriptor(0x03) + DecoderConfigDescriptor(0x04) + // + DecoderSpecificInfo(0x05) carrying a 2-byte AudioSpecificConfig. + let esds = vec![ + 0, 0, 0, 0, // version+flags + 0x03, 0x19, 0x00, 0x00, 0x00, // ES_Descriptor: tag,len, ES_ID(2), flags(0) + 0x04, 0x11, // DecoderConfigDescriptor: tag,len + 0x40, // objectTypeIndication (AAC) + 0x15, 0, 0, 0, // streamType/bufferSizeDB + 0, 0, 0, 0, // maxBitrate + 0, 0, 0, 0, // avgBitrate + 0x05, 0x02, // DecoderSpecificInfo: tag,len + 0x12, 0x10, // AudioSpecificConfig (AAC-LC 44.1k stereo) + ]; + assert_eq!(parse_esds_asc(&esds), Some(vec![0x12, 0x10])); + // A truncated esds must return None, never panic. + assert_eq!(parse_esds_asc(&esds[..12]), None); + } + + #[test] + fn read_moov_size_zero_spans_to_eof() { + use std::io::Cursor; + // size32 == 0 means "box extends to end of file"; the moov body is the rest. + let mut b = Vec::new(); + b.extend_from_slice(&0u32.to_be_bytes()); // size = 0 → to EOF + b.extend_from_slice(b"moov"); + b.extend_from_slice(&[0xAA, 0xBB, 0xCC]); // body + assert_eq!( + read_moov(&mut Cursor::new(b)).unwrap(), + vec![0xAA, 0xBB, 0xCC] + ); + } + + #[test] + fn read_moov_largesize_overflow_is_rejected() { + use std::io::Cursor; + // A 64-bit largesize near u64::MAX must not wrap the `pos + box_size` EOF + // guard (it would otherwise pass and drive an exabyte allocation). + let mut b = Vec::new(); + b.extend_from_slice(&16u32.to_be_bytes()); // ftyp box, size 16 + b.extend_from_slice(b"ftyp"); + b.extend_from_slice(&[0u8; 8]); + b.extend_from_slice(&1u32.to_be_bytes()); // moov, size==1 → largesize + b.extend_from_slice(b"moov"); + b.extend_from_slice(&0xFFFF_FFFF_FFFF_FFF8u64.to_be_bytes()); + assert!(read_moov(&mut Cursor::new(b)).is_err()); + } + + #[test] + fn read_moov_undersized_box_does_not_hang() { + use std::io::Cursor; + // A box whose declared size is < 8 (here 3) must be rejected, not spin the + // loop in place forever (size.saturating_sub(8) == 0 → no forward progress). + let mut b = Vec::new(); + b.extend_from_slice(&3u32.to_be_bytes()); + b.extend_from_slice(b"free"); + assert!(read_moov(&mut Cursor::new(b)).is_err()); + } + + #[test] + fn mdhd_language_offsets_per_version() { + // "eng" packed = 0x15C7. v0 carries it at byte 20, v1 (64-bit times) at 32. + let packed = [0x15u8, 0xC7]; + let mut v0 = vec![0u8; 22]; + v0[0] = 0; // version 0 + v0[20..22].copy_from_slice(&packed); + assert_eq!(mdhd_language(&v0).as_deref(), Some("eng")); + let mut v1 = vec![0u8; 34]; + v1[0] = 1; // version 1 + v1[32..34].copy_from_slice(&packed); + assert_eq!(mdhd_language(&v1).as_deref(), Some("eng")); } } diff --git a/src/mux/pipelined_stream.rs b/src/mux/pipelined_stream.rs index d1a1809..f4273be 100644 --- a/src/mux/pipelined_stream.rs +++ b/src/mux/pipelined_stream.rs @@ -659,8 +659,8 @@ mod tests { /// B1 end-to-end: after a TS discontinuity on a VIDEO track the consumer /// must DROP every inter-coded frame until the next keyframe, so no frame - /// with a dangling reference reaches the muxer (an ffmpeg deep-scan would - /// otherwise report a missing reference). The frame carrying the + /// with a dangling reference reaches the muxer (a strict decode-order + /// deep-scan would otherwise report a missing reference). The frame carrying the /// discontinuity and the inter frames behind it are dropped; the stream /// resumes cleanly at the next keyframe. #[test] diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 0223450..f70834b 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -724,26 +724,6 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState { (parsers, pid_to_track, ts, ps) } -/// Resolve the proactive [`AacsKeyMap`](crate::decrypt::AacsKeyMap) for a title -/// before muxing. It decides which held unit key decrypts each of the title's -/// LBA ranges and secures any key the pool is missing through the app's -/// configured source (`fetch`) up front, never reactively per unit at mux time. -/// -/// This is what ends the key-server storm. The old mux decrypted a unit, checked -/// whether the plaintext looked like clean MPEG-TS, and — because authored-bad -/// content never reaches that bar — re-asked the key service for a key it already -/// held. There is no per-unit byte pattern that separates "correctly decrypted -/// but authored-bad" from "still encrypted", so that check is unanswerable. Here -/// we answer the answerable question instead: which CPS unit does each LBA range -/// belong to, decided by the disc's key structure (validated once against real -/// ciphertext samples, where the `is_clean` proof IS sound). The mux then just -/// decrypts each unit with its mapped key and trusts it. -/// -/// Single-CPS (the overwhelming majority, incl. every single-key UHD) is the -/// trivial map: one key everywhere, no sampling. Multi-CPS assigns each extent to -/// the key that opens a real sample from it; a bad-content extent no sample can -/// classify inherits its predecessor's key (contiguity). FMTS segment mapping -/// layers onto the same structure. /// FMTS (AACS 2.1) branch of [`resolve_mux_key_map`]. Returns `Some(map)` when the /// disc carries `IndividualSegment.tbl` AND a key source is configured; `None` /// otherwise (not FMTS, or no source — the caller's base-Unit-Key path then @@ -932,6 +912,13 @@ fn resolve_fmts_key_map( Vec::with_capacity(segments.len()); let mut unresolved = 0usize; for seg in &segments { + // SPNs are untrusted (from IndividualSegment.tbl); an inverted record + // (start_spn > end_spn) would underflow `end_byte - 1 - start_byte` below. + // (Mirrors the guard in `aacs::segment::fmts_key_ranges`.) + if seg.start_spn > seg.end_spn { + unresolved += 1; + continue; + } let Some(&slot) = tag_slot.get(&seg.index) else { unresolved += 1; continue; @@ -969,6 +956,26 @@ fn resolve_fmts_key_map( ))) } +/// Resolve the proactive [`AacsKeyMap`](crate::decrypt::AacsKeyMap) for a title +/// before muxing. It decides which held unit key decrypts each of the title's +/// LBA ranges and secures any key the pool is missing through the app's +/// configured source (`fetch`) up front, never reactively per unit at mux time. +/// +/// This is what ends the key-server storm. The old mux decrypted a unit, checked +/// whether the plaintext looked like clean MPEG-TS, and — because authored-bad +/// content never reaches that bar — re-asked the key service for a key it already +/// held. There is no per-unit byte pattern that separates "correctly decrypted +/// but authored-bad" from "still encrypted", so that check is unanswerable. Here +/// we answer the answerable question instead: which CPS unit does each LBA range +/// belong to, decided by the disc's key structure (validated once against real +/// ciphertext samples, where the `is_clean` proof IS sound). The mux then just +/// decrypts each unit with its mapped key and trusts it. +/// +/// Single-CPS (the overwhelming majority, incl. every single-key UHD) is the +/// trivial map: one key everywhere, no sampling. Multi-CPS assigns each extent to +/// the key that opens a real sample from it; a bad-content extent no sample can +/// classify inherits its predecessor's key (contiguity). FMTS segment mapping +/// layers onto the same structure. pub fn resolve_mux_key_map( reader: &mut dyn SectorSource, title: &DiscTitle, @@ -980,42 +987,16 @@ pub fn resolve_mux_key_map( ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted, decrypt_unit, is_clean, }; + // The base Unit Key pool is always resolved and banked by the caller before mux + // (autorip's pre-rip gate; the ISO path's `decrypt_keys()`), so an AACS title + // reaches here with a non-empty pool — an empty pool is reported as + // `DecryptKeys::None` and takes the CSS/clear arm above. `pool_len` is therefore + // always >= 1 for the AACS map paths below. let pool_len = match keys { crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => unit_keys.len(), // CSS / clear: no AACS map (the decorator's map path is AACS-only). _ => return Ok(crate::decrypt::AacsKeyMap::single(0)), }; - - // Secure the disc's key up front from the configured source when the pool is - // empty (a genuine "no key yet" — e.g. keydb miss, online-only disc). - if pool_len == 0 { - if let Some(f) = fetch { - let samples = crate::keysource::read_encrypted_units(reader, title, 8); - if !samples.is_empty() { - let fresh = f.unit_keys(&samples); - if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys { - for k in fresh { - if !unit_keys.iter().any(|(_, h)| *h == k) { - let i = unit_keys.len() as u32; - unit_keys.push((i, k)); - } - } - } - } - } - // If the pool is STILL empty, this AACS-encrypted title needs a Unit Key we - // could not obtain from any source. That is the same situation as any known - // key we don't hold — fail loud at resolve time rather than deferring an - // opaque decrypt error (or, worse, emitting ciphertext) at mux time. - let empty = matches!( - keys, - crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } if unit_keys.is_empty() - ); - if empty { - return Err(crate::error::Error::DecryptFailed.into()); - } - return Ok(crate::decrypt::AacsKeyMap::single(0)); - } // FMTS (AACS 2.1): if the disc carries `IndividualSegment.tbl`, the forensic // segments need per-index keys the base Unit Key can't open. Resolve them up // front from the configured source and build a per-segment map. Returns `None` @@ -1095,10 +1076,18 @@ pub fn resolve_mux_key_map( } } } - // A bad-content extent no sample can classify inherits its predecessor's - // key (CPS boundaries are contiguous, so the neighbour is almost always - // right); this never storms and never mis-fails a decryptable disc. - let idx = idx.unwrap_or(last_idx); + // `sample_units` draws REAL content (not authored-bad units), so a sample + // no held or fetched key decrypts to clean means this extent's CPS-unit key + // is genuinely absent. Building a map that silently assigns a WRONG key + // (the neighbour's) would corrupt the whole extent with lost_bytes==0 — so + // fail loud instead: the keymap is built ONLY when every extent with + // encrypted content is classified. An extent with no sampleable encrypted + // units (nothing to mis-decrypt) carries the previous index harmlessly. + let idx = match idx { + Some(i) => i, + None if samples.is_empty() => last_idx, + None => return Err(crate::error::Error::DecryptFailed.into()), + }; last_idx = idx; ranges.push(( ext.start_lba, diff --git a/src/mux/resync.rs b/src/mux/resync.rs index 4980217..df80f3b 100644 --- a/src/mux/resync.rs +++ b/src/mux/resync.rs @@ -5,9 +5,9 @@ //! When packets are lost, the affected access unit is already dropped at the TS //! layer (the assembler drops the partial PES on the continuity gap). But for //! INTER-CODED video the frames that follow reference the lost frame (and each -//! other) until the next IRAP/IDR keyframe — emitting them yields an ffmpeg -//! "missing reference / non-existing PPS" deep-scan error and visibly broken -//! decode. So after a gap on a video track we DROP FORWARD to the next keyframe +//! other) until the next IRAP/IDR keyframe — emitting them makes any decoder +//! fault on the "missing reference / non-existing PPS" condition and visibly +//! break decode. So after a gap on a video track we DROP FORWARD to the keyframe //! and resume cleanly there. The gap rounds up to (at most) one GOP — the price //! of never emitting a dangling reference; it is logged. //! diff --git a/src/mux/timeline.rs b/src/mux/timeline.rs index f323b0a..cc888c9 100644 --- a/src/mux/timeline.rs +++ b/src/mux/timeline.rs @@ -23,7 +23,8 @@ pub(crate) const DISCONTINUITY_GAP_NS: i64 = 1_000_000; /// one concatenated sector stream (clip boundaries / mpls connection_condition /// are not plumbed to the mux), so at a non-seamless boundary the source PES /// PTS jumps backward. Left uncorrected, that produces a sustained band of -/// non-monotonic block timestamps (ffmpeg then derives non-monotonic DTS). +/// non-monotonic block timestamps (a downstream muxer then derives +/// non-monotonic DTS from them). /// /// A single running `offset_ns` is applied to EVERY track, so the concatenated /// clips form one monotonic timeline AND A/V sync is preserved (all tracks at a diff --git a/src/scsi/linux.rs b/src/scsi/linux.rs index d9b87ed..7a3c871 100644 --- a/src/scsi/linux.rs +++ b/src/scsi/linux.rs @@ -8,8 +8,9 @@ //! when `hdr.timeout` expires, and by the time the ioctl returns the //! kernel has already done what it can. //! -//! This matches what every reference project does: MakeMKV (8 s sync -//! ioctl), sg_dd (60 s sync ioctl), the kernel default for SCSI block +//! This matches established practice for optical/SCSI I/O: a single +//! synchronous ioctl with a bounded per-command timeout (commonly in the +//! 8–60 s range), consistent with the Linux kernel default for SCSI block //! devices (30 s `/sys/.../timeout`). //! //! Pre-0.13.20 we ran an async `write() + poll(1.5s) + close-on-timeout + diff --git a/src/sector/decrypting.rs b/src/sector/decrypting.rs index 992de88..e824093 100644 --- a/src/sector/decrypting.rs +++ b/src/sector/decrypting.rs @@ -19,6 +19,11 @@ use std::sync::Arc; use super::SectorSource; +/// A closure resolving keys from encrypted-content samples — the shape of both +/// [`KeyFetch`] operations. Named so the two constructors (and the struct fields) +/// read clearly. +pub type KeyFetchFn = std::sync::Arc]) -> Vec<[u8; 16]> + Send + Sync>; + /// Application-supplied "fetch a fresh key for THIS data" callback. /// /// Invoked by [`DecryptingSectorSource`] when a read contains scrambled AACS @@ -49,11 +54,6 @@ use super::SectorSource; /// mutable state (its call-count cap and spent flag), so one `KeyFetch` is built /// once and cloned cheaply (two `Arc` bumps) into every read path. `Send + Sync` /// so it can ride the mux highway's producer thread. -/// A closure resolving keys from encrypted-content samples — the shape of both -/// [`KeyFetch`] operations. Named so the two constructors (and the struct fields) -/// read clearly. -pub type KeyFetchFn = std::sync::Arc]) -> Vec<[u8; 16]> + Send + Sync>; - #[derive(Clone)] pub struct KeyFetch { unit: KeyFetchFn, @@ -187,7 +187,7 @@ impl DecryptingSectorSource { /// (sorted/merged `(start_lba, sector_count)` — see /// [`Disc::encrypted_content_ranges`](crate::Disc::encrypted_content_ranges)). /// Units outside content (UDF filesystem / BDMV nav) pass through untouched, - /// so [`ts_sync_destroyed`](crate::aacs::content::ts_sync_destroyed) is never consulted + /// so the TS-sync content check is never consulted /// about non-content bytes. Whole-disc readers (sweep / patch) set this; the /// mux leaves it unset because it only ever reads title extents. pub fn with_content_ranges(mut self, ranges: Arc<[(u32, u32)]>) -> Self { @@ -338,11 +338,11 @@ impl SectorSource for DecryptingSectorSource { // FRESH-KEY-ON-FAILURE: hand a unit no held key opened (as its on-disc // ciphertext) to the application's key source; any returned key is added to // the pool and the read is re-decrypted, caching the key for later units. - // The pass-through result is the consumer's concern — the decorator never - // counts a decrypt-quality miss as loss. Genuine missing data is the - // zero-filled sectors the physical read layer records, not a TS-structure - // miss; a real can't-decrypt (empty pool / misalignment) already surfaced - // as `Err` from `decrypt_buf`. + // If the source is asked for this exact ciphertext and STILL cannot supply a + // key (the recovery's residual `dropped > 0`), the unit is genuinely + // unresolvable — this decrypting sweep/patch path FAILS LOUD rather than + // write the still-encrypted bytes into the output as if they were clear + // content (the mux path fails loud the same way via `decrypt_sectors_mapped`). if dropped > 0 && self.recovery.is_some() { // Rare miss only: the in-place decrypt overwrote `buf`, so RE-READ the // on-disc ciphertext for the key-fetch retry. This keeps the happy path @@ -368,7 +368,10 @@ impl SectorSource for DecryptingSectorSource { .as_mut() .expect("recovery.is_some() checked above"); let cipher = &self.cipher_scratch[..n]; - let _ = r(&mut buf[..n], cipher, &mut self.keys, &rctx); + let outcome = r(&mut buf[..n], cipher, &mut self.keys, &rctx); + if outcome.dropped > 0 { + return Err(crate::error::Error::DecryptFailed); + } } Ok(n) } @@ -701,8 +704,8 @@ mod tests { /// A source that yields exactly one CLEAR AACS aligned unit (6144 /// bytes = 3 sectors) with MPEG-TS sync bytes (0x47) at the BD-TS - /// stride (offset 4, then every 192 bytes). `ts_sync_destroyed` - /// reports such a unit as NOT scrambled, so the AACS decrypt path + /// stride (offset 4, then every 192 bytes). `is_clean` + /// reports such a unit as clear (not scrambled), so the AACS decrypt path /// reaches the per-unit closure and leaves it untouched — letting /// us prove the unit-key LOOKUP (not the cipher) is what fails for /// an out-of-range index. diff --git a/src/sector/mod.rs b/src/sector/mod.rs index 6db59fc..2c97411 100644 --- a/src/sector/mod.rs +++ b/src/sector/mod.rs @@ -261,9 +261,10 @@ mod tests { } } - /// Call `set_unit_base` through a generic `S: SectorSource` bound — this is - /// the path that actually exercises the `Box` / `&mut dyn` FORWARDING - /// impls (a direct call on a `dyn` value dispatches via the vtable instead). + /// Call `set_unit_base` through a generic `S: SectorSource` bound so the + /// `Box` / `&mut dyn SectorSource` FORWARDING impls are the + /// ones invoked (the generic monomorphizes to each forwarding body — the same + /// body a direct call on those receiver types also resolves to). fn set_unit_base_generic(mut s: S, base: u32) { s.set_unit_base(base); }