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.
This commit is contained in:
Matthew Jackson
2026-07-23 12:02:43 -07:00
parent e380e3b7c8
commit 1eb6910bdb
37 changed files with 1157 additions and 434 deletions
+1 -1
View File
@@ -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() {
+24 -4
View File
@@ -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
+8 -10
View File
@@ -126,16 +126,14 @@ pub(crate) fn role_paths(udf: &crate::udf::UdfFs, role: AacsRole) -> Vec<String>
// 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()
+23
View File
@@ -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;
+23 -22
View File
@@ -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<u8> {
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() {
+5 -5
View File
@@ -1867,7 +1867,7 @@ impl Disc {
capacity: u32,
handshake: Option<HandshakeResult>,
handshake_error: Option<Error>,
_opts: &ScanOptions,
opts: &ScanOptions,
udf_fs: udf::UdfFs,
) -> Result<Self> {
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).
///
+26 -1
View File
@@ -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
+21 -26
View File
@@ -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<Error> 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),
+57 -7
View File
@@ -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<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync> = Arc::new(move || {
*builds_c.lock().unwrap() += 1;
vec![Box::new(HasKey(key)) as Box<dyn KeySource>]
});
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,
+15 -15
View File
@@ -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;
+42 -14
View File
@@ -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.
+2 -2
View File
@@ -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
+42 -19
View File
@@ -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 —
+16 -2
View File
@@ -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");
}
}
+58 -48
View File
@@ -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<DropReason> {
let mut r = BitReader::new(au.get(SYNCWORD_BYTES..)?);
@@ -782,7 +791,7 @@ fn core_header_drop_reason(au: &[u8]) -> Option<DropReason> {
}
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<u8> {
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]
+18 -8
View File
@@ -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<i64> {
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
+10 -7
View File
@@ -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
+14 -13
View File
@@ -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]));
+2 -1
View File
@@ -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
+48 -22
View File
@@ -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);
+23 -23
View File
@@ -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<usize> {
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() {
+21 -15
View File
@@ -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 <n>ms` in each audio filename (mkvmerge-readable).
/// Embed `DELAY <n>ms` in each audio filename (the filename-delay
/// convention downstream muxers parse).
#[default]
Filename,
/// Write a `<base> 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: <lang2>, index: 0` line mkvmerge reads to
// assign the subtitle track's language. Omit the language token when
// The conventional `id: <lang2>, 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 <signed-int>ms` — matches mkvmerge's case-insensitive
/// `delay\s+(-?\d+)` filename-delay parser.
/// `DELAY <signed-int>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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\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<i64> {
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}"
+2 -2
View File
@@ -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,
+2 -2
View File
@@ -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<W: Write> {
writer: W,
/// `HEVCDecoderConfigurationRecord` payload (hvcC). Parsed lazily
+6 -5
View File
@@ -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};
+22 -5
View File
@@ -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<Self> {
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")?;
+28 -25
View File
@@ -131,7 +131,7 @@ fn mvc_decoder_config_record(subset_sps: &[u8], pps: &[u8]) -> Option<Vec<u8>> {
///
/// 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<u8> {
@@ -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<W: Write + Seek> {
base_pts_ticks: Option<i64>,
/// 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<usize, i64>,
/// 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<W: Write + Seek> MkvMuxer<W> {
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<W: Write + Seek> MkvMuxer<W> {
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<W: Write + Seek> MkvMuxer<W> {
// 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<W: Write + Seek> MkvMuxer<W> {
// 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<W: Write + Seek> MkvMuxer<W> {
// 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,
];
+50 -8
View File
@@ -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<DtsConfig> {
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");
}
}
+41 -7
View File
@@ -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<W: Write + Seek> Mp4Sink<W> {
.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<W: Write + Seek> Mp4Sink<W> {
.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<W: Write + Seek> Mp4Sink<W> {
/// Assemble the `moov` box from every track's sample tables.
fn build_moov(&self) -> Vec<u8> {
// 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<u8>> = Vec::new();
for t in &self.tracks {
@@ -441,7 +453,9 @@ fn build_video_trak_full(t: &Track) -> (Vec<u8>, 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<u8>, 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]);
+432 -35
View File
@@ -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<R: Read + Seek> {
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<SampleRef>,
cursor: usize,
@@ -59,6 +82,8 @@ impl Mp4Reader<File> {
impl<R: Read + Seek> Mp4Reader<R> {
/// Index an already-opened seekable MP4 reader.
pub fn from_reader(mut file: R, name: String) -> io::Result<Self> {
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<R: Read + Seek> Mp4Reader<R> {
let mut samples: Vec<SampleRef> = Vec::new();
let mut codec_privates: Vec<Option<Vec<u8>>> = 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<R: Read + Seek> Mp4Reader<R> {
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<R: Read + Seek> Mp4Reader<R> {
_ => 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<R: Read + Seek> Mp4Reader<R> {
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<R: Read + Seek> Mp4Reader<R> {
}
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<R: Read + Seek> Mp4Reader<R> {
Ok(Self {
file,
file_len,
title,
samples,
cursor: 0,
@@ -193,6 +258,13 @@ impl<R: Read + Seek + Send> Stream for Mp4Reader<R> {
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<R: Read + Seek + Send> Stream for Mp4Reader<R> {
/// 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<R: Read + Seek>(file: &mut R) -> io::Result<Vec<u8>> {
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<u32> {
/// mdhd language (5-bit packed ISO 639-2) → lowercase 3-letter code.
fn mdhd_language(b: &[u8]) -> Option<String> {
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<StsdInfo> {
}
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<StsdInfo> {
})
} 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<Vec<u8>> {
// 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<u32> {
fn parse_stsz(b: &[u8], max: usize) -> Vec<u32> {
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<u64> {
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<u32> {
/// 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<u32> {
if b.len() < 8 {
return Vec::new();
}
@@ -520,6 +689,9 @@ fn parse_stts(b: &[u8]) -> Vec<u32> {
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<u32> {
}
/// ctts → per-sample composition offsets (version 0 unsigned / version 1 signed).
fn parse_ctts(b: &[u8]) -> Vec<i32> {
/// `max` caps the expansion, as in [`parse_stts`].
fn parse_ctts(b: &[u8], max: usize) -> Vec<i32> {
if b.len() < 8 {
return Vec::new();
}
@@ -543,6 +716,9 @@ fn parse_ctts(b: &[u8]) -> Vec<i32> {
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"));
}
}
+2 -2
View File
@@ -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]
+44 -55
View File
@@ -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,
+3 -3
View File
@@ -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.
//!
+2 -1
View File
@@ -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
+3 -2
View File
@@ -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
//! 860 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 +
+17 -14
View File
@@ -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<dyn Fn(&[Vec<u8>]) -> 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<dyn Fn(&[Vec<u8>]) -> Vec<[u8; 16]> + Send + Sync>;
#[derive(Clone)]
pub struct KeyFetch {
unit: KeyFetchFn,
@@ -187,7 +187,7 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
/// (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<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
// 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<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
.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.
+4 -3
View File
@@ -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<dyn>` / `&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<dyn SectorSource>` / `&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<S: SectorSource>(mut s: S, base: u32) {
s.set_unit_base(base);
}