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).
This commit is contained in:
Matthew Jackson
2026-06-25 21:37:20 -07:00
parent 7f55271adb
commit 998e21c544
4 changed files with 599 additions and 16 deletions
+73
View File
@@ -635,6 +635,79 @@ mod tests {
assert_eq!(buf, plain, "block-0 CBC must XOR the fixed AACS IV"); 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 ──────────────────── // ── decrypt_unit: full round trip restores TS syncs ────────────────────
#[test] #[test]
+119
View File
@@ -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<u32> = [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 /// Regression (finding 6): sweep() resume against a mapfile whose
/// total_size != the real disc size must DOWNGRADE to a fresh full sweep /// total_size != the real disc size must DOWNGRADE to a fresh full sweep
/// covering [0, capacity), not reuse the stale mapfile (which would /// covering [0, capacity), not reuse the stale mapfile (which would
+207 -16
View File
@@ -606,6 +606,11 @@ pub(super) struct PatchLoopState {
pub stall_start: std::time::Instant, pub stall_start: std::time::Instant,
pub range_start: std::time::Instant, pub range_start: std::time::Instant,
pub range_bytes_good: u64, 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 // Adaptive batch
pub current_batch: u16, pub current_batch: u16,
pub consecutive_singles_ok: u32, pub consecutive_singles_ok: u32,
@@ -627,7 +632,30 @@ impl PatchLoopState {
recovery: bool, recovery: bool,
work_total: u64, work_total: u64,
) -> Self { ) -> 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 { Self {
halted: false, halted: false,
wedged_exit: false, wedged_exit: false,
@@ -646,9 +674,10 @@ impl PatchLoopState {
not_ready_retries_per_lba: 0, not_ready_retries_per_lba: 0,
not_ready_lba: None, not_ready_lba: None,
bytes_good_last: bytes_good_before, bytes_good_last: bytes_good_before,
stall_start: now, stall_start: t0,
range_start: now, range_start: t0,
range_bytes_good: bytes_good_before, range_bytes_good: bytes_good_before,
now,
current_batch: initial_batch, current_batch: initial_batch,
consecutive_singles_ok: 0, consecutive_singles_ok: 0,
bytes_good_before, bytes_good_before,
@@ -793,14 +822,15 @@ pub(super) fn handle_read_success<R: SectorSource + ?Sized>(
g.stats.bytes_good g.stats.bytes_good
}; };
if bytes_good_now > state.bytes_good_last { 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; 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!( tracing::warn!(
target: "freemkv::disc", target: "freemkv::disc",
phase = "patch_stall", 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 = bytes_good_now,
bytes_good_start = state.bytes_good_start, bytes_good_start = state.bytes_good_start,
"Patch stalled - no recovery for {}s, exiting pass", "Patch stalled - no recovery for {}s, exiting pass",
@@ -1074,14 +1104,16 @@ pub(super) fn handle_read_failure<R: SectorSource + ?Sized>(
g.stats.bytes_good g.stats.bytes_good
}; };
if bytes_good_now > state.bytes_good_last { 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; 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!( tracing::warn!(
target: "freemkv::disc", target: "freemkv::disc",
phase = "patch_stall", 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 = bytes_good_now,
bytes_good_start = state.bytes_good_start, bytes_good_start = state.bytes_good_start,
"Patch stalled (NOT_READY path) - no recovery for {}s, exiting pass", "Patch stalled (NOT_READY path) - no recovery for {}s, exiting pass",
@@ -1158,14 +1190,15 @@ pub(super) fn handle_read_failure<R: SectorSource + ?Sized>(
g.stats.bytes_good g.stats.bytes_good
}; };
if bytes_good_now > state.bytes_good_last { 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; 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!( tracing::warn!(
target: "freemkv::disc", target: "freemkv::disc",
phase = "patch_stall", 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, consecutive_failures = state.consecutive_failures,
bytes_good = bytes_good_now, bytes_good = bytes_good_now,
bytes_good_start = state.bytes_good_start, 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 { if bytes_good_now > state.range_bytes_good {
state.range_bytes_good = bytes_good_now; 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!( tracing::warn!(
target: "freemkv::disc", target: "freemkv::disc",
phase = "patch_range_stall", phase = "patch_range_stall",
range_lba = frame.range_pos / 2048, range_lba = frame.range_pos / 2048,
range_sectors = frame.range_sectors, 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, budget_secs = frame.range_budget_secs,
bytes_recovered = state.range_bytes_good.saturating_sub(state.bytes_good_before), bytes_recovered = state.range_bytes_good.saturating_sub(state.bytes_good_before),
"Range stalled - moving to next range" "Range stalled - moving to next range"
@@ -1826,7 +1859,7 @@ impl Disc {
state.damage_window.clear(); state.damage_window.clear();
state.consecutive_skips_without_recovery = 0; state.consecutive_skips_without_recovery = 0;
state.consecutive_good_since_skip = 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 // Fix 4: initialize range_bytes_good to the CURRENT bytes_good
// (not the pass-start value bytes_good_before). Using the // (not the pass-start value bytes_good_before). Using the
// pass-start value means that after any prior range recovers // 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<std::time::Instant> = 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<Mutex<SharedPatchState>> {
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 /// 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 /// on the same LBA the cap is exhausted and the next NOT_READY is treated
/// as a normal failure (consecutive_failures incremented, retry refused). /// as a normal failure (consecutive_failures incremented, retry refused).
+200
View File
@@ -3777,4 +3777,204 @@ mod tests {
let data = muxer.writer.into_inner(); let data = muxer.writer.into_inner();
assert!(find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_none()); 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<u8>) -> 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<u8>); 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"
);
}
}
} }