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:
+1
-1
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 —
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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]));
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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]);
|
||||
|
||||
+430
-33
@@ -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 {
|
||||
// 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).saturating_sub(16)
|
||||
} else {
|
||||
(size32 as u64).saturating_sub(8)
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -8,8 +8,9 @@
|
||||
//! when `hdr.timeout` expires, and by the time the ioctl returns the
|
||||
//! kernel has already done what it can.
|
||||
//!
|
||||
//! This matches what every reference project does: MakeMKV (8 s sync
|
||||
//! ioctl), sg_dd (60 s sync ioctl), the kernel default for SCSI block
|
||||
//! This matches established practice for optical/SCSI I/O: a single
|
||||
//! synchronous ioctl with a bounded per-command timeout (commonly in the
|
||||
//! 8–60 s range), consistent with the Linux kernel default for SCSI block
|
||||
//! devices (30 s `/sys/.../timeout`).
|
||||
//!
|
||||
//! Pre-0.13.20 we ran an async `write() + poll(1.5s) + close-on-timeout +
|
||||
|
||||
+17
-14
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user