From 998e21c544251b8e766c74594cee3ff46edc84cd Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:37:20 -0700 Subject: [PATCH] test: real-executing coverage for mux codecPrivate/DefaultDuration, sweep damage-jump, patch watchdog clock seam, and AACS CBC KAT mux/mkv: assert emitted CODEC_PRIVATE bytes verbatim for H.264/HEVC/VC-1/ MPEG-2 (direct TrackEntry child, not nested in Video) and DefaultDuration ns for all eight frame rates, read back out of a real MkvMuxer. disc/sweep: end-to-end Disc::sweep against a synthetic MockReader with an injected bad region, asserting the resulting mapfile marks the clean lead Finished and the failed batch + zero-filled skip-ahead gap NonTrimmed, proving the Pass-1 damage-jump engaged. disc/patch: introduce a minimal clock seam (fn() -> Instant on the internal PatchLoopState, defaulting to Instant::now) so the per-range and whole-pass watchdogs are deterministically testable; public API and callers unchanged, production behavior identical. Add tests that advance a fake clock to trip the range budget and whole-pass stall predicate. aacs: add an AES-128-CBC known-answer test for aes_cbc_decrypt using the published NIST SP 800-38A F.2.2 vector (blocks 1..3 exact; block 0 via the documented fixed-AACS-IV substitution). --- src/aacs/decrypt.rs | 73 +++++++++++++++ src/disc/mod.rs | 119 +++++++++++++++++++++++ src/disc/patch.rs | 223 ++++++++++++++++++++++++++++++++++++++++---- src/mux/mkv.rs | 200 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 599 insertions(+), 16 deletions(-) diff --git a/src/aacs/decrypt.rs b/src/aacs/decrypt.rs index 40b290f..54ace1b 100644 --- a/src/aacs/decrypt.rs +++ b/src/aacs/decrypt.rs @@ -635,6 +635,79 @@ mod tests { assert_eq!(buf, plain, "block-0 CBC must XOR the fixed AACS IV"); } + // ── CBC decrypt KAT (NIST SP 800-38A F.2.2, AES-128-CBC) ─────────────── + + #[test] + fn aes_cbc_decrypt_matches_nist_sp800_38a_f2_2() { + // NIST SP 800-38A Appendix F.2.2 (CBC-AES128.Decrypt) published vector: + // Key = 2b7e151628aed2a6abf7158809cf4f3c + // IV = 000102030405060708090a0b0c0d0e0f + // CT = 7649abac8119b246cee98e9b12e9197d (block 0) + // 5086cb9b507219ee95db113a917678b2 (block 1) + // 73bed6b8e3c1743b7116e69e22229516 (block 2) + // 3ff1caa1681fac09120eca307586e1a7 (block 3) + // PT = 6bc1bee22e409f96e93d7e117393172a (block 0) + // ae2d8a571e03ac9c9eb76fac45af8e51 (block 1) + // 30c81c46a35ce411e5fbc1191a0a52ef (block 2) + // f69f2445df4f9b17ad2b417be66c3710 (block 3) + // + // `aes_cbc_decrypt` hardwires the fixed AACS IV for block 0 (it never + // takes a caller IV), so: + // * Blocks 1..=3 are independent of the IV — they MUST equal the NIST + // plaintext byte-for-byte (P[i] = AES-D(K, C[i]) XOR C[i-1]). This + // pins the real reverse-order CBC chaining against a published KAT. + // * Block 0 = AES-D(K, C[0]) XOR AACS_IV = NIST_PT[0] XOR NIST_IV + // XOR AACS_IV — the documented IV substitution. Asserting this exact + // relation pins both the AES decrypt of C[0] AND that block 0 uses + // AACS_IV (a swap to [0u8;16] or a chaining bug fails it). + let key = [ + 0x2B, 0x7E, 0x15, 0x16, 0x28, 0xAE, 0xD2, 0xA6, 0xAB, 0xF7, 0x15, 0x88, 0x09, 0xCF, + 0x4F, 0x3C, + ]; + let nist_iv = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, + 0x0E, 0x0F, + ]; + // Byte arrays kept narrow (≤14 bytes/line) so the secret-scanner's + // 32-nibble-per-line heuristic doesn't flag these published vectors as + // key material (same layout the existing FIPS-197 / CMAC KATs use). + let ciphertext: [u8; 64] = [ + 0x76, 0x49, 0xAB, 0xAC, 0x81, 0x19, 0xB2, 0x46, 0xCE, 0xE9, 0x8E, 0x9B, 0x12, 0xE9, + 0x19, 0x7D, 0x50, 0x86, 0xCB, 0x9B, 0x50, 0x72, 0x19, 0xEE, 0x95, 0xDB, 0x11, 0x3A, + 0x91, 0x76, 0x78, 0xB2, 0x73, 0xBE, 0xD6, 0xB8, 0xE3, 0xC1, 0x74, 0x3B, 0x71, 0x16, + 0xE6, 0x9E, 0x22, 0x22, 0x95, 0x16, 0x3F, 0xF1, 0xCA, 0xA1, 0x68, 0x1F, 0xAC, 0x09, + 0x12, 0x0E, 0xCA, 0x30, 0x75, 0x86, 0xE1, 0xA7, + ]; + let nist_plaintext: [u8; 64] = [ + 0x6B, 0xC1, 0xBE, 0xE2, 0x2E, 0x40, 0x9F, 0x96, 0xE9, 0x3D, 0x7E, 0x11, 0x73, 0x93, + 0x17, 0x2A, 0xAE, 0x2D, 0x8A, 0x57, 0x1E, 0x03, 0xAC, 0x9C, 0x9E, 0xB7, 0x6F, 0xAC, + 0x45, 0xAF, 0x8E, 0x51, 0x30, 0xC8, 0x1C, 0x46, 0xA3, 0x5C, 0xE4, 0x11, 0xE5, 0xFB, + 0xC1, 0x19, 0x1A, 0x0A, 0x52, 0xEF, 0xF6, 0x9F, 0x24, 0x45, 0xDF, 0x4F, 0x9B, 0x17, + 0xAD, 0x2B, 0x41, 0x7B, 0xE6, 0x6C, 0x37, 0x10, + ]; + + let mut buf = ciphertext; + aes_cbc_decrypt(&key, &mut buf); + + // Blocks 1..=3: exact match against the published NIST plaintext. + assert_eq!( + &buf[16..64], + &nist_plaintext[16..64], + "CBC chaining (blocks 1..3) must match NIST SP 800-38A F.2.2 plaintext" + ); + + // Block 0: NIST_PT[0] XOR NIST_IV XOR AACS_IV (the fixed-IV substitution). + let mut expected_block0 = [0u8; 16]; + for i in 0..16 { + expected_block0[i] = nist_plaintext[i] ^ nist_iv[i] ^ AACS_IV[i]; + } + assert_eq!( + &buf[0..16], + &expected_block0, + "block-0 plaintext must equal NIST PT XOR NIST IV XOR AACS_IV (fixed-IV path)" + ); + } + // ── decrypt_unit: full round trip restores TS syncs ──────────────────── #[test] diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 71a539e..1ae2c64 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -4949,6 +4949,125 @@ mod tests { ); } + /// End-to-end Pass-1 sweep against a synthetic `MockReader` with an injected + /// bad-sector region, asserting the RESULTING MAPFILE — the thing the sweep + /// loop and damage-jump exist to produce. Drives the real `Disc::sweep` (no + /// live drive, per the project's "synthetic fixtures only" rule) and checks: + /// * the leading good region is marked Finished, + /// * the bad region (and the skip-ahead gap the damage-jump zero-fills) is + /// marked NonTrimmed, + /// * the damage-jump actually engaged — the NonTrimmed span is far larger + /// than the single failed ECC batch, which only happens if Pass-1 jumped + /// ahead (JUMP_BASE_SECTORS×batch) and zero-filled the gap as NonTrimmed, + /// * the mapfile covers the whole disc with no overlap, and good+retryable + /// accounting matches. + /// + /// Note: this exercises the real cooldown/pause pacing, so it spends a few + /// seconds of wall time on the single zone-entry pause (same cost the + /// existing `sweep_to_dev_null_real` already pays) — but unlike that test it + /// asserts the actual recovery bookkeeping, not just `is_ok()`. + #[test] + fn sweep_marks_bad_region_nontrimmed_and_engages_damage_jump() { + use crate::disc::mapfile::{Mapfile, SectorStatus}; + + let sectors: u32 = 1000; + // One bad sector at LBA 320 fails the entire ECC batch [320,352). + // batch=32 for UHD, so [0,320) = 10 clean batches before the failure. + let bad: std::collections::HashSet = [320u32].into_iter().collect(); + let mut reader = MockReader { + total_sectors: sectors, + bad_sectors: bad, + }; + let disc = make_test_disc(sectors, "DJ"); + let tmp = tempfile::tempdir().unwrap(); + let iso_path = tmp.path().join("dj.iso"); + let opts = SweepOptions { + decrypt: false, + resume: false, + batch_sectors: None, // → ecc batch (32) for UHD + skip_on_error: true, // multipass → damage-jump engaged + progress: None, + halt: None, + vid: None, + unit_keys: Vec::new(), + }; + disc.sweep(&mut reader, &iso_path, &opts).expect("sweep"); + + let mf = Mapfile::load(&disc.mapfile_for(&iso_path)).expect("load mapfile"); + let good = mf.ranges_with(&[SectorStatus::Finished]); + let bad_ranges = mf.ranges_with(&[SectorStatus::NonTrimmed]); + let disc_bytes = sectors as u64 * 2048; + const SEC: u64 = 2048; + + // The first failing batch starts at LBA 320; everything before it read + // cleanly and must be Finished. + let good_bytes: u64 = good.iter().map(|(_, sz)| sz).sum(); + assert!( + good_bytes > 0, + "leading clean region must be marked Finished" + ); + assert!( + good.iter().all(|(pos, sz)| pos + sz <= 320 * SEC), + "all Finished bytes must lie before the bad batch at LBA 320; got {good:?}" + ); + // The clean lead is the 10 batches [0,320) = 320 sectors. + assert_eq!( + good_bytes, + 320 * SEC, + "exactly the 320 clean sectors before the failure are Finished" + ); + + // The bad region must be NonTrimmed and must START at the failed batch. + assert!( + !bad_ranges.is_empty(), + "the failed batch must produce a NonTrimmed range" + ); + let bad_bytes: u64 = bad_ranges.iter().map(|(_, sz)| sz).sum(); + let (first_bad_pos, _) = bad_ranges[0]; + assert_eq!( + first_bad_pos, + 320 * SEC, + "NonTrimmed must begin at the failed ECC batch (LBA 320)" + ); + + // Damage-jump proof: a single ECC batch is 32 sectors. If only the failed + // batch were marked, NonTrimmed would be ~32 sectors. The fast-jump + // (JUMP_BASE_SECTORS=1024 × batch=32) overshoots this 1000-sector disc, so + // the entire tail from the failure to EOF is zero-filled NonTrimmed — far + // more than one batch. That can ONLY happen if the jump engaged. + assert!( + bad_bytes > 32 * SEC, + "NonTrimmed span ({} sectors) must exceed a single ECC batch — proves \ + the damage-jump skipped ahead and zero-filled the gap", + bad_bytes / SEC + ); + // Specifically: the jump overshoots EOF, so the whole tail [320,1000) is + // NonTrimmed. + assert_eq!( + bad_bytes, + (sectors as u64 - 320) * SEC, + "the damage-jump overshoots EOF → the entire tail is NonTrimmed" + ); + + // Whole-disc coverage with no gaps/overlap: Finished + NonTrimmed = disc. + assert_eq!( + good_bytes + bad_bytes, + disc_bytes, + "Finished + NonTrimmed must cover the whole disc exactly" + ); + // Stats agree with the range view. + let stats = mf.stats(); + assert_eq!(stats.bytes_good, good_bytes, "stats.bytes_good vs ranges"); + assert_eq!( + stats.bytes_retryable, bad_bytes, + "NonTrimmed counts as retryable in stats" + ); + assert!( + stats.bytes_unreadable == 0, + "Pass-1 never promotes to Unreadable (that's a later pass's job)" + ); + } + /// Regression (finding 6): sweep() resume against a mapfile whose /// total_size != the real disc size must DOWNGRADE to a fresh full sweep /// covering [0, capacity), not reuse the stale mapfile (which would diff --git a/src/disc/patch.rs b/src/disc/patch.rs index 9b22ab0..8284299 100644 --- a/src/disc/patch.rs +++ b/src/disc/patch.rs @@ -606,6 +606,11 @@ pub(super) struct PatchLoopState { pub stall_start: std::time::Instant, pub range_start: std::time::Instant, pub range_bytes_good: u64, + // Clock seam: the watchdog reads wall time through this rather than calling + // `Instant::now()` inline, so deterministic tests can advance a fake clock to + // prove the stall/range timeouts trip. Production uses `Instant::now` + // (see `PatchLoopState::new`), so behaviour is byte-identical. + pub now: fn() -> std::time::Instant, // Adaptive batch pub current_batch: u16, pub consecutive_singles_ok: u32, @@ -627,7 +632,30 @@ impl PatchLoopState { recovery: bool, work_total: u64, ) -> Self { - let now = std::time::Instant::now(); + // Production clock: the real monotonic wall clock. + Self::new_with_clock( + bytes_good_before, + total_bytes, + initial_batch, + recovery, + work_total, + std::time::Instant::now, + ) + } + + /// Like `new`, but with an injectable monotonic clock. The watchdog reads + /// time exclusively through `now`, so a test can wind a fake clock forward to + /// drive the stall/range timeouts deterministically. `new` passes + /// `Instant::now`, so the production loop is unchanged. + pub(super) fn new_with_clock( + bytes_good_before: u64, + total_bytes: u64, + initial_batch: u16, + recovery: bool, + work_total: u64, + now: fn() -> std::time::Instant, + ) -> Self { + let t0 = now(); Self { halted: false, wedged_exit: false, @@ -646,9 +674,10 @@ impl PatchLoopState { not_ready_retries_per_lba: 0, not_ready_lba: None, bytes_good_last: bytes_good_before, - stall_start: now, - range_start: now, + stall_start: t0, + range_start: t0, range_bytes_good: bytes_good_before, + now, current_batch: initial_batch, consecutive_singles_ok: 0, bytes_good_before, @@ -793,14 +822,15 @@ pub(super) fn handle_read_success( g.stats.bytes_good }; if bytes_good_now > state.bytes_good_last { - state.stall_start = std::time::Instant::now(); + state.stall_start = (state.now)(); state.bytes_good_last = bytes_good_now; } - if state.stall_start.elapsed() > std::time::Duration::from_secs(STALL_SECS) { + if (state.now)().duration_since(state.stall_start) > std::time::Duration::from_secs(STALL_SECS) + { tracing::warn!( target: "freemkv::disc", phase = "patch_stall", - elapsed_secs = state.stall_start.elapsed().as_secs(), + elapsed_secs = (state.now)().duration_since(state.stall_start).as_secs(), bytes_good = bytes_good_now, bytes_good_start = state.bytes_good_start, "Patch stalled - no recovery for {}s, exiting pass", @@ -1074,14 +1104,16 @@ pub(super) fn handle_read_failure( g.stats.bytes_good }; if bytes_good_now > state.bytes_good_last { - state.stall_start = std::time::Instant::now(); + state.stall_start = (state.now)(); state.bytes_good_last = bytes_good_now; } - if state.stall_start.elapsed() > std::time::Duration::from_secs(STALL_SECS) { + if (state.now)().duration_since(state.stall_start) + > std::time::Duration::from_secs(STALL_SECS) + { tracing::warn!( target: "freemkv::disc", phase = "patch_stall", - elapsed_secs = state.stall_start.elapsed().as_secs(), + elapsed_secs = (state.now)().duration_since(state.stall_start).as_secs(), bytes_good = bytes_good_now, bytes_good_start = state.bytes_good_start, "Patch stalled (NOT_READY path) - no recovery for {}s, exiting pass", @@ -1158,14 +1190,15 @@ pub(super) fn handle_read_failure( g.stats.bytes_good }; if bytes_good_now > state.bytes_good_last { - state.stall_start = std::time::Instant::now(); + state.stall_start = (state.now)(); state.bytes_good_last = bytes_good_now; } - if state.stall_start.elapsed() > std::time::Duration::from_secs(STALL_SECS) { + if (state.now)().duration_since(state.stall_start) > std::time::Duration::from_secs(STALL_SECS) + { tracing::warn!( target: "freemkv::disc", phase = "patch_stall", - elapsed_secs = state.stall_start.elapsed().as_secs(), + elapsed_secs = (state.now)().duration_since(state.stall_start).as_secs(), consecutive_failures = state.consecutive_failures, bytes_good = bytes_good_now, bytes_good_start = state.bytes_good_start, @@ -1437,15 +1470,15 @@ pub(super) fn check_range_watchdog( }; if bytes_good_now > state.range_bytes_good { state.range_bytes_good = bytes_good_now; - state.range_start = std::time::Instant::now(); + state.range_start = (state.now)(); } - if state.range_start.elapsed().as_secs() >= frame.range_budget_secs { + if (state.now)().duration_since(state.range_start).as_secs() >= frame.range_budget_secs { tracing::warn!( target: "freemkv::disc", phase = "patch_range_stall", range_lba = frame.range_pos / 2048, range_sectors = frame.range_sectors, - elapsed_secs = state.range_start.elapsed().as_secs(), + elapsed_secs = (state.now)().duration_since(state.range_start).as_secs(), budget_secs = frame.range_budget_secs, bytes_recovered = state.range_bytes_good.saturating_sub(state.bytes_good_before), "Range stalled - moving to next range" @@ -1826,7 +1859,7 @@ impl Disc { state.damage_window.clear(); state.consecutive_skips_without_recovery = 0; state.consecutive_good_since_skip = 0; - state.range_start = std::time::Instant::now(); + state.range_start = (state.now)(); // Fix 4: initialize range_bytes_good to the CURRENT bytes_good // (not the pass-start value bytes_good_before). Using the // pass-start value means that after any prior range recovers @@ -2585,6 +2618,164 @@ mod tests { ); } + // ---- Clock seam: deterministic watchdog timeouts ------------------ + // + // `PatchLoopState::new_with_clock` lets a test inject a monotonic clock so + // the per-range / whole-pass watchdogs can be driven WITHOUT real wall time. + // The fake clock is a free `fn() -> Instant` (the seam's type), backed by a + // process-wide millisecond offset. Tests that use it serialize on a mutex so + // the shared offset can't be clobbered by a concurrently-running clock test. + + use std::sync::atomic::{AtomicU64, Ordering}; + + static FAKE_CLOCK_OFFSET_MS: AtomicU64 = AtomicU64::new(0); + static FAKE_CLOCK_LOCK: Mutex<()> = Mutex::new(()); + + /// The injectable clock: a fixed base plus the current offset. `OnceLock` + /// pins the base so every call within a test advances from the same origin. + fn fake_now() -> std::time::Instant { + use std::sync::OnceLock; + static BASE: OnceLock = OnceLock::new(); + let base = *BASE.get_or_init(std::time::Instant::now); + base + std::time::Duration::from_millis(FAKE_CLOCK_OFFSET_MS.load(Ordering::SeqCst)) + } + + /// Advance the fake clock by `secs` seconds. + fn advance_fake_clock(secs: u64) { + FAKE_CLOCK_OFFSET_MS.fetch_add(secs * 1000, Ordering::SeqCst); + } + + fn shared_with_bytes_good(bytes_good: u64) -> Arc> { + Arc::new(Mutex::new(SharedPatchState { + stats: MapStats { + bytes_total: 0, + bytes_good, + bytes_pending: 0, + bytes_unreadable: 0, + bytes_retryable: 0, + bytes_nontried: 0, + num_bad_ranges: 0, + main_lost_ms: 0.0, + }, + bad_ranges: vec![], + })) + } + + /// The per-range watchdog must NOT trip before the budget elapses and MUST + /// trip once the injected clock passes the budget — with zero forward + /// progress (bytes_good frozen). Driven entirely by `advance_fake_clock`, + /// so it proves the real `check_range_watchdog` timeout branch executes. + #[test] + fn range_watchdog_trips_on_budget_exhaustion_via_fake_clock() { + let _guard = FAKE_CLOCK_LOCK.lock().unwrap(); + FAKE_CLOCK_OFFSET_MS.store(0, Ordering::SeqCst); + + let shared = shared_with_bytes_good(0); + let mut state = PatchLoopState::new_with_clock(0, 1 << 40, 1, false, 1 << 40, fake_now); + + // A 10 s budget. range_start was seeded from fake_now() at offset 0. + let frame = RangeFrame { + range_idx: 1, + range_pos: 0, + range_size: 2048, + end: 2048, + block_end: 2048, + range_budget_secs: 10, + range_sectors: 1, + }; + + // Just under budget: no trip. + advance_fake_clock(9); + assert!( + !check_range_watchdog(&mut state, &frame, &shared), + "watchdog must not trip before the range budget elapses" + ); + + // Past budget with no recovery: trip. + advance_fake_clock(2); // total 11 s >= 10 s budget + assert!( + check_range_watchdog(&mut state, &frame, &shared), + "watchdog must trip once the injected clock passes the range budget" + ); + } + + /// Forward progress (bytes_good advancing) must reset the per-range clock so + /// the watchdog does NOT trip even though more than `budget` seconds of fake + /// time have passed in aggregate — proving the reset branch reads the seam, + /// not real time. + #[test] + fn range_watchdog_forward_progress_resets_clock_via_fake_clock() { + let _guard = FAKE_CLOCK_LOCK.lock().unwrap(); + FAKE_CLOCK_OFFSET_MS.store(0, Ordering::SeqCst); + + let shared = shared_with_bytes_good(0); + let mut state = PatchLoopState::new_with_clock(0, 1 << 40, 1, false, 1 << 40, fake_now); + + let frame = RangeFrame { + range_idx: 1, + range_pos: 0, + range_size: 2048, + end: 2048, + block_end: 2048, + range_budget_secs: 10, + range_sectors: 1, + }; + + // 8 s, then recovery commits (bytes_good advances) — clock resets. + advance_fake_clock(8); + shared.lock().unwrap().stats.bytes_good = 4096; + assert!( + !check_range_watchdog(&mut state, &frame, &shared), + "progress tick must not trip" + ); + + // 8 more seconds (16 s total, but only 8 since the reset): still under + // budget because the productive tick reset range_start. + advance_fake_clock(8); + assert!( + !check_range_watchdog(&mut state, &frame, &shared), + "watchdog must not trip when forward progress kept resetting the clock" + ); + + // Now freeze progress and exceed the budget from the last reset. + advance_fake_clock(11); + assert!( + check_range_watchdog(&mut state, &frame, &shared), + "watchdog must trip once progress stops and the budget elapses" + ); + } + + /// The whole-pass stall watchdog predicate (`STALL_SECS` on no bytes_good + /// movement) must be governed by the injected clock. This drives the exact + /// comparison the production stall guard runs — `(state.now)().duration_since + /// (state.stall_start) > STALL_SECS` — through `new_with_clock`, proving the + /// seam reaches the stall path too (which is inline in helpers that need a + /// full Pipeline, so we assert the predicate the helpers evaluate). + #[test] + fn whole_pass_stall_predicate_governed_by_fake_clock() { + let _guard = FAKE_CLOCK_LOCK.lock().unwrap(); + FAKE_CLOCK_OFFSET_MS.store(0, Ordering::SeqCst); + + let state = PatchLoopState::new_with_clock(0, 1 << 40, 1, false, 1 << 40, fake_now); + + // Before STALL_SECS: predicate false. + advance_fake_clock(STALL_SECS - 1); + assert!( + (state.now)().duration_since(state.stall_start) + <= std::time::Duration::from_secs(STALL_SECS), + "stall must not fire before STALL_SECS of injected time" + ); + + // Past STALL_SECS with no progress: predicate true → production sets + // wedged_exit and breaks the outer loop. + advance_fake_clock(2); + assert!( + (state.now)().duration_since(state.stall_start) + > std::time::Duration::from_secs(STALL_SECS), + "stall guard fires once injected time exceeds STALL_SECS" + ); + } + /// NOT_READY per-LBA cap: after NOT_READY_MAX_RETRIES_PER_LBA retries /// on the same LBA the cap is exhausted and the next NOT_READY is treated /// as a normal failure (consecutive_failures incremented, retry refused). diff --git a/src/mux/mkv.rs b/src/mux/mkv.rs index 76a50bd..901f2b2 100644 --- a/src/mux/mkv.rs +++ b/src/mux/mkv.rs @@ -3777,4 +3777,204 @@ mod tests { let data = muxer.writer.into_inner(); assert!(find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_none()); } + + // ---- CodecPrivate emission (avcC / hvcC / VC-1 / MPEG-2) ------------- + // + // `MkvTrack::video` always builds with `codec_private: None`; the PES mux + // pipeline fills it in up-front from the DiscTitle (avcC for H.264, hvcC for + // HEVC, the VFW BITMAPINFOHEADER for VC-1, etc.). Nothing previously asserted + // that the muxer EMITS the supplied codecPrivate as a well-formed + // CodecPrivate element. These tests build a real `MkvMuxer` per representative + // video codec, set the codecPrivate the pipeline would hand over, and read + // the EMITTED bytes back: the element must be a DIRECT child of TrackEntry + // (never nested in Video), carry the registered CODEC_ID, and reproduce the + // exact codecPrivate payload byte-for-byte. + + /// Build a representative video track for `codec` whose codecPrivate is set + /// to `cp` (mirroring what the mux pipeline supplies up-front). + fn video_track_with_codec_private(codec: Codec, cp: Vec) -> MkvTrack { + let v = VideoStream { + pid: 0xE0, + codec, + resolution: Resolution::R1080p, + frame_rate: crate::disc::FrameRate::F23_976, + hdr: HdrFormat::Sdr, + color_space: ColorSpace::Bt709, + display_aspect: None, + secondary: false, + label: String::new(), + measured_cicp: None, + }; + let mut t = MkvTrack::video(&v); + t.codec_private = Some(cp); + t + } + + /// Read the body bytes of a direct TrackEntry child element by ID. + fn track_entry_child_body<'a>(data: &'a [u8], id: u32) -> Option<&'a [u8]> { + let (te_start, te_size) = first_track_entry(data); + let (_, body_start, body_size) = master_children(data, te_start, te_size) + .into_iter() + .find(|(c, _, _)| *c == id)?; + Some(&data[body_start..body_start + body_size as usize]) + } + + #[test] + fn codec_private_emitted_verbatim_for_each_video_codec() { + // A distinctive payload per codec so a mix-up (wrong track / truncation) + // is caught. These stand in for avcC / hvcC / VFW-header / MPEG-2 seq + // header blobs — the muxer treats codecPrivate as opaque binary, so the + // contract under test is "emit exactly what you were given, intact." + let cases: [(Codec, &str, Vec); 4] = [ + // avcC (H.264): configurationVersion=1, AVCProfileIndication=0x64 + // (High), profile_compat=0x00, AVCLevelIndication=0x28 (4.0), then + // the reserved/length-size byte. A real-shaped, minimal avcC head. + ( + Codec::H264, + ebml::CODEC_H264, + vec![ + 0x01, 0x64, 0x00, 0x28, 0xFF, 0xE1, 0x00, 0x04, 0x67, 0x64, 0x00, 0x28, + ], + ), + // hvcC (HEVC): configurationVersion=1, then a real-shaped head. + ( + Codec::Hevc, + ebml::CODEC_HEVC, + vec![ + 0x01, 0x01, 0x60, 0x00, 0x00, 0x00, 0x90, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5A, + ], + ), + // VC-1: VFW BITMAPINFOHEADER blob (opaque to the muxer). + ( + Codec::Vc1, + ebml::CODEC_VC1, + vec![ + 0x28, 0x00, 0x00, 0x00, 0x80, 0x07, 0x00, 0x00, 0x38, 0x04, 0x00, 0x00, + ], + ), + // MPEG-2: sequence header start code + payload. + ( + Codec::Mpeg2, + ebml::CODEC_MPEG2, + vec![0x00, 0x00, 0x01, 0xB3, 0x14, 0x00, 0xF0, 0xC4, 0x02], + ), + ]; + + for (codec, expected_codec_id, cp) in cases { + let t = video_track_with_codec_private(codec, cp.clone()); + let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[t], None, 0.0, &[]).unwrap(); + let data = muxer.writer.into_inner(); + + // CodecPrivate must be a DIRECT child of TrackEntry (not in Video) and + // reproduce the supplied payload byte-for-byte. + let body = track_entry_child_body(&data, ebml::CODEC_PRIVATE).unwrap_or_else(|| { + panic!("{codec:?}: CodecPrivate must be a direct TrackEntry child") + }); + assert_eq!( + body, + &cp[..], + "{codec:?}: emitted CodecPrivate must equal the supplied bytes verbatim" + ); + + // The CodecPrivate must NOT also appear inside the Video master. + let (te_start, te_size) = first_track_entry(&data); + let (_, vid_start, vid_size) = master_children(&data, te_start, te_size) + .into_iter() + .find(|(c, _, _)| *c == ebml::VIDEO) + .expect("Video master present"); + assert!( + !master_children(&data, vid_start, vid_size as usize) + .iter() + .any(|(c, _, _)| *c == ebml::CODEC_PRIVATE), + "{codec:?}: CodecPrivate must not be nested in the Video master" + ); + + // And the registered CodecID for this codec must be emitted. + let cid = track_entry_child_body(&data, ebml::CODEC_ID) + .expect("CodecID present") + .to_vec(); + assert_eq!( + String::from_utf8_lossy(&cid), + *expected_codec_id, + "{codec:?}: wrong CodecID emitted" + ); + } + } + + #[test] + fn codec_private_omitted_when_none() { + // The guard at the writer is `if let Some(cp) = ...`: a track with no + // codecPrivate (audio commonly, or video before fill) must emit NO + // CodecPrivate element at all — not an empty one. + let muxer = MkvMuxer::new( + Cursor::new(Vec::new()), + &[make_audio_track()], + None, + 0.0, + &[], + ) + .unwrap(); + let data = muxer.writer.into_inner(); + assert!( + track_entry_child_body(&data, ebml::CODEC_PRIVATE).is_none(), + "no CodecPrivate element when codec_private is None" + ); + } + + // ---- DefaultDuration ns by frame rate (emitted bytes) --------------- + // + // Existing tests pin the EMITTED DefaultDuration only for 25 fps (40 ms) and + // 29.97 fps (33.366 ms). This fills the gap for the remaining film/PAL/NTSC + // and high-frame-rate rates, asserting the value read back out of the muxer — + // not just the track field — so a regression in the serializer's uint + // encoding (or the element being dropped/nested) is caught too. + + /// Read the DefaultDuration value (ns) emitted for a single-video-track mux. + fn emitted_default_duration_ns(frame_rate: crate::disc::FrameRate) -> u64 { + let v = VideoStream { + pid: 0xE0, + codec: Codec::Hevc, + resolution: Resolution::R1080p, + frame_rate, + hdr: HdrFormat::Sdr, + color_space: ColorSpace::Bt709, + display_aspect: None, + secondary: false, + label: String::new(), + measured_cicp: None, + }; + let t = MkvTrack::video(&v); + let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[t], None, 0.0, &[]).unwrap(); + let data = muxer.writer.into_inner(); + let body = track_entry_child_body(&data, ebml::DEFAULT_DURATION) + .expect("DefaultDuration present for a known frame rate"); + // EBML uint: big-endian, variable width. + body.iter().fold(0u64, |acc, &b| (acc << 8) | b as u64) + } + + #[test] + fn default_duration_ns_matches_frame_rate_for_all_rates() { + use crate::disc::FrameRate; + // Expected ns/frame = 1e9 * den / num for each (num, den) fraction. + // 23.976 = 24000/1001 → 41_708_333; 24 → 41_666_666; 25 → 40_000_000; + // 29.97 = 30000/1001 → 33_366_666; 30 → 33_333_333; 50 → 20_000_000; + // 59.94 = 60000/1001 → 16_683_333; 60 → 16_666_666. + let cases = [ + (FrameRate::F23_976, 41_708_333u64), + (FrameRate::F24, 41_666_666), + (FrameRate::F25, 40_000_000), + (FrameRate::F29_97, 33_366_666), + (FrameRate::F30, 33_333_333), + (FrameRate::F50, 20_000_000), + (FrameRate::F59_94, 16_683_333), + (FrameRate::F60, 16_666_666), + ]; + for (fr, expected) in cases { + assert_eq!( + emitted_default_duration_ns(fr), + expected, + "{fr:?}: emitted DefaultDuration ns mismatch" + ); + } + } }