diff --git a/.github/workflows/comment-guard.yml b/.github/workflows/comment-guard.yml new file mode 100644 index 00000000..742c20ab --- /dev/null +++ b/.github/workflows/comment-guard.yml @@ -0,0 +1,15 @@ +name: comment-guard + +# Fails if any inline `//` comment block runs longer than 3 lines. Rustdoc +# (`///`, `//!`) is exempt. Keeps explanatory comments terse; long rationale +# belongs in a doc comment or a design note, not an inline wall. + +on: [push, pull_request] + +jobs: + comment-guard: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Run comment-guard + run: python3 ci/comment-guard.py diff --git a/.github/workflows/doc-links.yml b/.github/workflows/doc-links.yml new file mode 100644 index 00000000..c19ec91d --- /dev/null +++ b/.github/workflows/doc-links.yml @@ -0,0 +1,32 @@ +name: doc-links + +# Fails on broken intra-doc links in `///`/`//!` doc comments (e.g. a `[Type]` +# link to a renamed/removed item). Catches doc drift that `cargo build` misses. +# +# libfreemkv path-deps ../freemkv-unlock on the branch tip, so — like ci.yml — +# both repos are checked out into sibling subdirectories for the path dep to +# resolve. + +on: [push, pull_request] + +jobs: + doc-links: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + path: libfreemkv + - uses: actions/checkout@v7 + with: + repository: freemkv/freemkv-unlock + ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}" + path: freemkv-unlock + - uses: dtolnay/rust-toolchain@1.98.0 + - uses: Swatinem/rust-cache@v2 + with: + workspaces: libfreemkv + - name: cargo doc (deny broken intra-doc links) + working-directory: libfreemkv + env: + RUSTDOCFLAGS: "-D rustdoc::broken_intra_doc_links" + run: cargo doc --no-deps --document-private-items --lib --bins diff --git a/ci/comment-guard.py b/ci/comment-guard.py new file mode 100644 index 00000000..5949b58a --- /dev/null +++ b/ci/comment-guard.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Fail if any inline `//` comment block runs longer than MAX_LINES. + +Rustdoc (`///`, `//!`) is exempt — long API docs are fine. Only consecutive +non-doc `//` lines are counted; a blank line, code, or a doc line ends the run. +Block comments (`/* */`) are not inspected. Scans src/ and tests/ under the cwd. +""" +import sys +from pathlib import Path + +MAX_LINES = 3 + + +def is_inline_comment(line: str) -> bool: + s = line.lstrip() + return s.startswith("//") and not s.startswith("///") and not s.startswith("//!") + + +def violations(path: Path): + """Yield (start_line, length) for each inline run longer than MAX_LINES.""" + out = [] + run_start = None + run_len = 0 + for i, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1): + if is_inline_comment(line): + if run_start is None: + run_start = i + run_len += 1 + else: + if run_len > MAX_LINES: + out.append((run_start, run_len)) + run_start, run_len = None, 0 + if run_len > MAX_LINES: + out.append((run_start, run_len)) + return out + + +def main(argv): + root = Path(argv[1]) if len(argv) > 1 else Path.cwd() + files = sorted(p for d in ("src", "tests") for p in (root / d).rglob("*.rs")) + total = 0 + for f in files: + for start, length in violations(f): + rel = f.relative_to(root) + print(f"{rel}:{start}: inline comment block is {length} lines (max {MAX_LINES})") + total += 1 + if total: + print(f"\ncomment-guard: {total} inline comment block(s) exceed {MAX_LINES} lines.") + print("Trim to the essential why, or split the code so the comment isn't needed.") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/src/aacs/content.rs b/src/aacs/content.rs index f3d51a5e..95487857 100644 --- a/src/aacs/content.rs +++ b/src/aacs/content.rs @@ -14,7 +14,7 @@ use super::crypto::AACS_IV; // ── AACS constants ────────────────────────────────────────────────────────── -/// Size of an AACS aligned unit (3 × 2048-byte sectors). [BD] §3.10.1. +/// Size of an AACS aligned unit (3 × 2048-byte sectors). `[BD]` §3.10.1. pub const ALIGNED_UNIT_LEN: usize = 6144; /// An AACS aligned unit spans this many 2048-byte sectors (3). @@ -82,7 +82,7 @@ const PS_SCRAMBLE_MASK: u8 = 0x30; /// /// - `BdTs` (BD / UHD / FMTS Transport Stream): the Copy Permission Indicator in /// the top 2 bits of byte 0 — the first `TP_extra_header` byte, always left -/// clear (the first 16 bytes of every unit are the unencrypted SEED). [BD] +/// clear (the first 16 bytes of every unit are the unencrypted SEED). `[BD]` /// §3.10.2. `(buf[0] & 0xC0) == 0` → clear; non-zero → bytes `16..6144` are /// AES-CBC encrypted. /// - `MpegPs` (HD-DVD `.evo`): the MPEG-2 `PES_scrambling_control` flag — see @@ -99,10 +99,9 @@ pub fn aacs_unit_encrypted(unit: &[u8], format: crate::disc::ContentFormat) -> b } match format { ContentFormat::BdTs => (unit[0] & 0xC0) != 0, - // UNVERIFIED-HDDVD-DECRYPT (1 of 2): the PES_scrambling_control location - // for HD-DVD `.evo` is derived from spec, never confirmed against a real - // ENCRYPTED HD DVD (we only have decrypted rips). If HD-DVD ripping ever - // misbehaves, verify this flag byte/mask against a genuine encrypted unit. + // UNVERIFIED-HDDVD-DECRYPT (1 of 2): PES_scrambling_control location for + // HD-DVD `.evo` is spec-derived, never confirmed against a real encrypted + // disc (only decrypted rips available). Verify against a real one if ripping misbehaves. ContentFormat::MpegPs => (unit[PS_SCRAMBLE_OFF] & PS_SCRAMBLE_MASK) != 0, } } @@ -137,12 +136,9 @@ pub fn aacs_unit_seed_encrypted(unit: &[u8], format: crate::disc::ContentFormat) /// /// Like the flag itself this is only meaningful at the clip-FILE-anchored boundary. pub fn aacs_unit_needs_decrypt(unit: &[u8], format: crate::disc::ContentFormat) -> bool { - // "Still needs the key applied" = flagged encrypted AND not yet structurally - // clean. There is ONE definition of clean — [`is_clean`] (the min(E,4) proof - // floor: E>4 needs any 4 synced, E<=4 needs all present). Never a second - // threshold: the old >50% majority false-flagged a bad-encoded-but-OPENED - // unit as still-scrambled, so the mux re-sampled it to the key service every - // batch (the storm) and could re-apply the key over already-clear bytes. + // "Still needs key" = flagged encrypted AND not structurally clean per the + // ONE definition, [`is_clean`]'s min(E,4) proof floor. Never a second threshold: + // the old >50% majority false-flagged bad-but-opened units, causing re-sample storms. aacs_unit_encrypted(unit, format) && !is_clean(unit, format) } @@ -271,11 +267,9 @@ fn is_clean_ps(unit: &[u8]) -> bool { return false; } const PACK_START: [u8; 4] = [0x00, 0x00, 0x01, 0xBA]; - // Skip pack 0 (offset 0): its start bytes lie in the clear 16-byte seed — - // present for ANY key (and byte 0 may carry the Blu-ray-style CPI bits), so - // it proves nothing about decryption. This mirrors `is_clean_ts` skipping - // packet 0. Packs 1 and 2 (offsets 2048 / 4096) are in the encrypted region - // and are what discriminate the key (64 bits of proof). + // Skip pack 0: its start bytes lie in the clear 16-byte seed, present for ANY + // key, so it proves nothing (mirrors `is_clean_ts` skipping packet 0). Packs 1 + // and 2 (offsets 2048/4096) are in the encrypted region and discriminate the key. let mut o = SECTOR_BYTES; while o + 4 <= ALIGNED_UNIT_LEN { if unit[o..o + 4] != PACK_START { @@ -302,7 +296,7 @@ fn is_clean_ps(unit: &[u8]) -> bool { /// to "decrypt applies the key and nothing else". The only guard kept here is the /// length check, since the crypto is defined only over a whole 6144-byte unit. /// -/// Block Key = AES-128E(Kcu, seed) ⊕ seed ([BD] §3.10.1 Fig 3-8: encrypt the clear +/// Block Key = AES-128E(Kcu, seed) ⊕ seed (`[BD]` §3.10.1 Fig 3-8: encrypt the clear /// 16-byte seed under the CPS Unit Key, XOR the seed back in — the trailing ⊕seed /// is load-bearing); then AES-128-CBC decrypt bytes 16..6144 under the AACS IV. /// Source-zero padding packets (all 192 bytes zero on disc) are restored to zero: @@ -395,12 +389,9 @@ pub fn encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool { /// Remove bus encryption from an aligned unit (AACS 2.0 / UHD). /// Bus encryption uses read_data_key, decrypting bytes 16..2048 of each 2048-byte sector. pub(crate) fn decrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) { - // Expand the key schedule ONCE for the whole unit. `read_data_key` is - // loop-invariant here (and constant for the entire disc), but calling - // `aes_cbc_decrypt` per sector rebuilt the AES-128 schedule per sector — three - // expansions per 6144-byte aligned unit, i.e. ~29 million redundant expansions - // over a 90 GB read on a stock drive, on the per-unit decrypt hot path. - // Measured by `decrypt_bus_expands_the_read_data_key_once_per_unit`. + // Expand the key schedule ONCE for the whole unit: `read_data_key` is + // loop-invariant, but per-sector `aes_cbc_decrypt` rebuilt it 3x per unit + // (~29M redundant expansions over a 90GB read) on the decrypt hot path. let cipher = crate::aacs::crypto::new_cipher_for(read_data_key); for sector_start in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) { if sector_start + SECTOR_BYTES > unit.len() { @@ -457,11 +448,6 @@ mod tests { assert_eq!(unit, clear, "round trip must be byte-exact"); } - /// The documented padding asymmetry actually holds: `decrypt_unit` restores - /// all-zero-ON-DISC packets to zero, but an all-zero PLAINTEXT packet enciphers - /// to non-zero bytes, so it is not mistaken for padding and still round-trips. - /// This is the one place the two functions are deliberately not symmetric, so - /// the claim is worth pinning rather than asserting in prose alone. /// A slice too short to encrypt must SAY so. The caller has already set the /// container's encrypted flag by the time it calls this (the contract requires /// flag-before-crypto, since the header is the key seed), so a silent no-op @@ -487,6 +473,11 @@ mod tests { assert!(encrypt_unit(&mut exact, &key), "a full unit must encrypt"); } + /// The documented padding asymmetry actually holds: `decrypt_unit` restores + /// all-zero-ON-DISC packets to zero, but an all-zero PLAINTEXT packet enciphers + /// to non-zero bytes, so it is not mistaken for padding and still round-trips. + /// This is the one place the two functions are deliberately not symmetric, so + /// the claim is worth pinning rather than asserting in prose alone. #[test] fn encrypt_unit_round_trips_all_zero_plaintext_packets() { let key = [0xA5u8; 16]; @@ -546,11 +537,9 @@ mod tests { #[test] fn is_unit_aligned_lba_below_base_is_well_defined() { - // Latent-trap contract (rc.5.2 audit #5): a read never starts before its - // extent base, but if `lba < unit_base` the result must be well-defined, - // NOT the `wrapping_sub` underflow that — because 2^32 ≡ 1 (mod 3) — - // would falsely report alignment. `saturating_sub` clamps to offset 0, - // which is a unit boundary, so any `lba <= unit_base` reads as aligned. + // Latent-trap contract (rc.5.2 audit #5): if `lba < unit_base` the result must + // be well-defined, NOT `wrapping_sub` underflow (2^32 ≡ 1 mod 3 falsely + // reports alignment). `saturating_sub` clamps to 0, a unit boundary. assert!( is_unit_aligned(99, 100), "lba just below base must not wrap" @@ -569,11 +558,9 @@ mod tests { #[test] fn clear_unit_is_not_flagged_encrypted() { - // `decrypt_unit` is now PURE (applies the key unconditionally); the - // "leave a clear unit untouched" policy lives at the caller's gate, - // `aacs_unit_encrypted` / `aacs_unit_needs_decrypt`. A clear TS unit - // (byte-0 CPI bits clear, syncs intact) must report NOT-encrypted so the - // caller never hands it to decrypt_unit. + // `decrypt_unit` is PURE (applies the key unconditionally); "leave a clear + // unit untouched" lives at the caller's gate. A clear TS unit (CPI bits + // clear, syncs intact) must report NOT-encrypted so the caller skips decrypt_unit. let ts = crate::disc::ContentFormat::BdTs; let mut unit = vec![0u8; ALIGNED_UNIT_LEN]; let mut off = 4; @@ -597,10 +584,8 @@ mod tests { #[test] fn ts_packet_total_no_off_by_one() { - // The maximum sync count is exactly the number of stride - // positions the counting loop visits (offset 4, 196, ...), i.e. - // len / 192, NOT (len - 4) / 192 + 1. For the 6144-byte aligned unit - // the loop checks offsets 4..=5956 → 32 positions. + // Max sync count = stride positions visited (offset 4, 196, ...) = len/192, + // NOT (len-4)/192+1. For the 6144-byte unit, offsets 4..=5956 → 32 positions. let unit = vec![0u8; ALIGNED_UNIT_LEN]; assert_eq!(ts_packet_total(&unit), 32); // Confirm the loop visits exactly that many stride positions. @@ -612,10 +597,9 @@ mod tests { #[test] fn is_clean_min4_proof_floor() { - // ONE rule: a unit is clean iff `synced >= min(E, 4)` over the ENCRYPTED - // (non-padding) packets — E>4 needs any 4, E<=4 needs all present. Build - // NON-ZERO payloads (real content, not padding) so every packet counts - // toward E; place `n` TS syncs among packets 1..31 (packet 0 is skipped). + // ONE rule: clean iff `synced >= min(E, 4)` over ENCRYPTED (non-padding) + // packets — E>4 needs any 4, E<=4 needs all present. Build NON-ZERO payloads + // so every packet counts toward E; place syncs among packets 1..31 (0 skipped). let unit_with = |synced: usize| { let mut unit: Vec = (0..ALIGNED_UNIT_LEN) .map(|i| ((i * 7 + 1) as u8) | 1) @@ -686,11 +670,9 @@ mod tests { let original = vec![0x42u8; 128]; // 8 blocks let mut data = original.clone(); - // Encrypt with the REAL production primitive. This test previously - // defined a local `fn aes_cbc_encrypt` that SHADOWED it, so it round- - // tripped a copy of the algorithm against itself and never exercised - // `crypto::aes_cbc_encrypt` at all — a mutation to the shipped function - // could not fail it. + // Encrypt with the REAL production primitive. This test previously defined a + // local `fn aes_cbc_encrypt` that SHADOWED it, round-tripping a copy of the + // algorithm against itself — a mutation to the shipped function couldn't fail it. aes_cbc_encrypt(&key, &mut data); assert_ne!(data, original); // should be different after encrypt @@ -797,14 +779,8 @@ mod tests { } // ── Padding-aware IsDecryptable (fragment-tail recovery) ─────────────── - // - // A content fragment can end mid-unit, with the disc zero-padding the rest - // of the aligned unit to the next fragment (proven on a real disc: ~11 real - // video packets + source-zero pad). `decrypt_unit` must accept such a unit - // — its real packets decrypt; the source-zero tail is padding, not content - // — while still REJECTING a unit whose undecryptable tail is non-zero (a - // genuine misread / wrong key). The discriminator is the SOURCE bytes of the - // failing packets: zero ⇒ padding (still decryptable), non-zero ⇒ bad data. + // A fragment can end mid-unit, zero-padded to the next fragment. `decrypt_unit` + // must accept a zero tail (padding) but REJECT a non-zero tail (misread/wrong key). /// Encrypt a full clear unit under `unit_key`, then overwrite the tail (from /// packet `keep` onward) with `fill`. `0x00` models disc fragment padding; a @@ -859,10 +835,8 @@ mod tests { #[test] fn fragment_tail_with_nonzero_garbage_decrypts_the_real_prefix() { // Same shape, but the tail is NON-zero garbage. The crypto still recovers - // the 11 real packets; the garbage tail is whatever it is (the muxer drops - // it on sync-loss, and a genuine bad sector is caught by the physical read - // layer / mapfile — never by TS structure). Ground truth: the 11 real - // packets came back. + // the 11 real packets; the muxer drops the garbage tail on sync-loss (a + // genuine bad sector is caught by the read layer/mapfile, never TS structure). let key = [0x5Au8; 16]; let mut unit = tail_filled_unit(&key, 11, 0xC3); decrypt_unit(&mut unit, &key); @@ -876,16 +850,8 @@ mod tests { } // ── Defect-tolerant "did a key OPEN this unit?" verdict ───────────────── - // - // The authored-bad-packet bug: a commercial disc carries the odd bad TS - // packet (a pressing/encoding defect, or an AACS 2.1 forensic-variant frame) - // — one non-conforming packet inside an otherwise perfectly-decrypted 6144 - // unit. The OLD strict per-packet acceptance rejected the WHOLE unit over - // that single packet, so the mux concealed ~all of it as NULL and 21/22 good - // video packets were destroyed and tallied as loss ("false corruption"). - // The read/decrypt path must instead ACCEPT the unit (the key opened it) and - // pass the defective packet through VERBATIM for the muxer to drop. TS-sync - // conformance is a muxer concern, never a decryption verdict. + // Authored-bad-packet bug: one non-conforming packet made OLD strict acceptance + // reject the whole unit, destroying good video. Must ACCEPT and pass defects verbatim. /// Build a unit that decrypts to 32 content packets, with `defect_pkts` /// marked authored-bad (a non-`0x47` at the sync position + non-zero payload @@ -998,11 +964,9 @@ mod tests { #[test] fn is_clean_dispatches_by_container_format() { use crate::disc::ContentFormat; - // Clean HD-DVD `.evo` Program-Stream unit: pack_start_code `00 00 01 BA` - // at each 2048-byte pack boundary (0/2048/4096) — the layout validated - // against real decrypted retail HD-DVD `.evo`. Offset 0 is - // the clear-seed freebie; the packs at 2048/4096 are encrypted and are - // what actually discriminate a key. + // Clean HD-DVD `.evo` Program-Stream unit: pack_start_code `00 00 01 BA` at + // each 2048-byte pack boundary (0/2048/4096), validated against real retail rips. + // Offset 0 is the clear-seed freebie; packs at 2048/4096 are what discriminate a key. let mut ps = vec![0u8; ALIGNED_UNIT_LEN]; for off in [0usize, 2048, 4096] { ps[off..off + 4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); @@ -1085,10 +1049,9 @@ mod tests { #[test] fn sparse_tail_needs_all_present_packets_min_e_4() { - // End-of-clip fragment tail: `min(E,4)` scales to the packets that exist, - // so a sparse unit needs ALL of its (few) encrypted packets — a wrong key - // gives 0, so this is a strong proof with no wrong-key hole, and a valid - // 1-packet tail is never false-rejected. + // End-of-clip fragment tail: `min(E,4)` scales to existing packets, so a + // sparse unit needs ALL its (few) encrypted packets. Wrong key gives 0 (no + // hole), and a valid 1-packet tail is never false-rejected. assert!( is_clean_ts(&decrypted_shape(1, 1, false)), "E=1: the one packet syncs -> open" @@ -1182,12 +1145,9 @@ mod tests { #[test] fn aes_ecb_matches_fips197_known_answer() { - // FIPS-197 Appendix C.1 AES-128 KAT: - // key = 000102030405060708090a0b0c0d0e0f - // plaintext = 00112233445566778899aabbccddeeff - // ciphertext= 69c4e0d86a7b0430d8cdb78070b4c55a - // This pins the AES primitive against a published vector — a wrong - // cipher (or a key/plaintext byte-order slip) fails it. + // FIPS-197 Appendix C.1 AES-128 KAT (key/plaintext/ciphertext = code arrays + // below: key 000102..0f, pt 001122..ff, ct 69c4e0..5a). + // Pins the AES primitive against a published vector. let key = [ 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, @@ -1231,7 +1191,7 @@ mod tests { } /// The single-expansion refactor must be byte-identical: bus encryption - /// ([C] §4.2) covers bytes 16..2048 of every 2048-byte sector, so a + /// (`[C]` §4.2) covers bytes 16..2048 of every 2048-byte sector, so a /// three-sector aligned unit round-trips through the forward direction /// sector by sector and `decrypt_bus` must recover it exactly. #[test] @@ -1260,7 +1220,7 @@ mod tests { /// The published `iv0` bytes, INDEPENDENT of the production constant. /// - /// [C] §2.1.2 fixes one default CBC IV for every AACS AES-CBC operation. + /// `[C]` §2.1.2 fixes one default CBC IV for every AACS AES-CBC operation. /// Both IV tests below used to compute their expected value from /// `crypto::AACS_IV` itself, so the constant was asserted against itself and /// NOTHING in the suite pinned its bytes: swapping `AACS_IV` for `[0u8; 16]` @@ -1274,7 +1234,7 @@ mod tests { 0x78, ]; - /// Pins the fixed AACS CBC IV ([C] §2.1.2 `iv0`) against a literal, so a + /// Pins the fixed AACS CBC IV (`[C]` §2.1.2 `iv0`) against a literal, so a /// change to `crypto::AACS_IV` fails HERE rather than silently shipping. #[test] fn aacs_iv_matches_published_iv0() { @@ -1286,13 +1246,9 @@ mod tests { #[test] fn cbc_decrypt_first_block_xors_aacs_iv() { - // CBC: P[0] = AES-D(K, C[0]) XOR IV, and the IV is the fixed AACS - // constant (not zero). Encrypt a single block forward with the PUBLISHED - // iv0 literal, then confirm aes_cbc_decrypt recovers it — proving the IV - // the production code uses on block 0 is exactly that value. Building the - // fixture from `IV0_PUBLISHED` rather than from `AACS_IV` is what makes - // the claim real: a mutation that swaps AACS_IV for [0u8;16] now makes the - // recovered block wrong. + // CBC: P[0] = AES-D(K, C[0]) XOR IV, IV being the fixed AACS constant. The + // fixture is built from the PUBLISHED iv0 literal (not `AACS_IV`), so swapping + // AACS_IV for [0u8;16] makes the recovered block wrong — proving block 0's IV. let key = [0x24u8; 16]; let plain = [0x5Au8; 16]; // Forward CBC for one block: C = AES-E(K, P XOR IV). @@ -1310,30 +1266,9 @@ mod tests { #[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 iv0 = NIST_PT[0] XOR NIST_IV XOR iv0 — - // the documented IV substitution. Asserting this exact relation pins - // both the AES decrypt of C[0] AND that block 0 uses iv0. The expected - // value is built from the `IV0_PUBLISHED` literal, NOT from - // `crypto::AACS_IV`: computing it from the production constant made - // the change cancel out of the triple XOR, so a swap to [0u8;16] still - // passed. It now fails. + // NIST SP 800-38A F.2.2 (CBC-AES128.Decrypt) KAT (vectors = code arrays below). + // `aes_cbc_decrypt` hardwires iv0 for block 0: blocks 1..=3 pin reverse-order + // chaining; block 0 = NIST_PT[0] XOR NIST_IV XOR the `IV0_PUBLISHED` literal. let key = [ 0x2B, 0x7E, 0x15, 0x16, 0x28, 0xAE, 0xD2, 0xA6, 0xAB, 0xF7, 0x15, 0x88, 0x09, 0xCF, 0x4F, 0x3C, @@ -1457,9 +1392,8 @@ mod tests { #[test] fn cpi_gate_set_flag_decrypts_and_needs_decrypt_is_idempotent() { // A CPI-set encrypted unit decrypts with the right key. CPI lives in the - // plaintext header, so it survives decryption — `aacs_unit_encrypted` - // still reports true afterward, but `aacs_unit_needs_decrypt` flips to - // false (syncs restored), keeping the re-decrypt paths idempotent. + // plaintext header, so `aacs_unit_encrypted` stays true after decryption, but + // `aacs_unit_needs_decrypt` flips false (syncs restored) — re-decrypt is idempotent. let ts = crate::disc::ContentFormat::BdTs; let key = [0x5au8; 16]; let mut unit = clear_unit(); @@ -1485,10 +1419,9 @@ mod tests { #[test] fn decrypt_bus_roundtrips_per_sector_skipping_first_16_bytes() { - // Bus encryption CBC-encrypts bytes 16..2048 of EACH 2048-byte sector - // (3 sectors per aligned unit), leaving the first 16 plaintext. Build - // the forward transform, then confirm decrypt_bus inverts it and - // leaves each sector's first 16 bytes untouched. + // Bus encryption CBC-encrypts bytes 16..2048 of EACH 2048-byte sector (3 per + // aligned unit), leaving the first 16 plaintext. Confirm decrypt_bus inverts the + // forward transform and leaves each sector's first 16 bytes untouched. let rdk = [0x13u8; 16]; let mut unit = vec![0u8; ALIGNED_UNIT_LEN]; // Fill with a recognisable pattern. @@ -1573,7 +1506,7 @@ mod tests { /// `aacs_unit_seed_encrypted` is the flag reader for a PARTIAL unit — the /// guard that stops a truncated encrypted fragment from being emitted as /// clear content. It reads ONLY the two Copy Permission Indicator bits - /// ([BD] §3.10.2, byte 0 bits 6-7); the remaining six bits are + /// (`[BD]` §3.10.2, byte 0 bits 6-7); the remaining six bits are /// `TP_extra_header` arrival-timestamp bits and carry no encryption /// meaning. /// diff --git a/src/aacs/crypto.rs b/src/aacs/crypto.rs index 9a50c142..ee1c4148 100644 --- a/src/aacs/crypto.rs +++ b/src/aacs/crypto.rs @@ -1,4 +1,4 @@ -//! AACS common cryptographic primitives — [C] Chapter 2 / §3.2.2. +//! AACS common cryptographic primitives — `[C]` Chapter 2 / §3.2.2. //! //! Source: `[C]` = AACS Introduction and Common Cryptographic Elements Book, //! Rev 0.953. The shared low-level building blocks — AES-128 ECB E/D, AES-G, @@ -10,19 +10,14 @@ use aes::Aes128; use aes::cipher::{Array, BlockCipherDecrypt, BlockCipherEncrypt, KeyInit}; -/// Fixed IV used by AACS for all AES-CBC operations. [C] §2.1.2 (default CBC IV, `iv0`). +/// Fixed IV used by AACS for all AES-CBC operations. `[C]` §2.1.2 (default CBC IV, `iv0`). pub(crate) const AACS_IV: [u8; 16] = [ 0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78, ]; // Per-thread count of AES-128 key schedules built through `new_cipher`. -// Test-only instrumentation: an AES-128 key expansion is 10 round-key -// derivations, and the CBC helpers here run on the per-aligned-unit decrypt hot -// path of a whole disc read, so "how many times was the schedule built for one -// loop-invariant key" is a property worth asserting rather than reasoning about. -// THREAD-LOCAL, not a global atomic: `cargo test` runs tests concurrently, so a -// shared counter would see every other test's expansions. See -// `content::tests::decrypt_bus_expands_the_read_data_key_once_per_unit`. +// Test-only: lets tests assert the CBC decrypt hot path builds one schedule +// per loop-invariant key. Thread-local, not atomic, since `cargo test` runs concurrently. #[cfg(test)] thread_local! { pub(crate) static KEY_EXPANSIONS: std::cell::Cell = const { std::cell::Cell::new(0) }; @@ -35,14 +30,14 @@ pub(crate) fn new_cipher_for(key: &[u8; 16]) -> Aes128 { } /// Build an AES-128 key schedule. The single construction site for the CBC -/// helpers, so [`KEY_EXPANSIONS`] can count them under test. +/// helpers, so `KEY_EXPANSIONS` can count them under test. fn new_cipher(key: &[u8; 16]) -> Aes128 { #[cfg(test)] KEY_EXPANSIONS.with(|c| c.set(c.get() + 1)); Aes128::new(&(*key).into()) } -/// AES-128-ECB encrypt a single 16-byte block. [C] §2.1.1 (`AES-128E`). +/// AES-128-ECB encrypt a single 16-byte block. `[C]` §2.1.1 (`AES-128E`). pub(crate) fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { let cipher = Aes128::new(&(*key).into()); let mut block: Array = (*data).into(); @@ -52,7 +47,7 @@ pub(crate) fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { out } -/// AES-128-ECB decrypt a single 16-byte block. [C] §2.1.1 (`AES-128D`). +/// AES-128-ECB decrypt a single 16-byte block. `[C]` §2.1.1 (`AES-128D`). pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { let cipher = Aes128::new(&(*key).into()); let mut block: Array = (*data).into(); @@ -63,7 +58,7 @@ pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { } /// AES-128-CBC ENCRYPT in place under the fixed [`AACS_IV`] — the forward -/// direction of [`aes_cbc_decrypt`], and its exact inverse. [C] §2.1.2 +/// direction of [`aes_cbc_decrypt`], and its exact inverse. `[C]` §2.1.2 /// (`AES-128CBCE`). /// /// Precondition: `data.len()` is a multiple of 16; the assert @@ -95,7 +90,7 @@ pub(crate) fn aes_cbc_encrypt(key: &[u8; 16], data: &mut [u8]) { } } -/// AES-128-CBC DECRYPT in-place with the fixed AACS IV. [C] §2.1.2 +/// AES-128-CBC DECRYPT in-place with the fixed AACS IV. `[C]` §2.1.2 /// (`AES-128CBCD`). /// /// Precondition: `data.len()` is a multiple of 16. Any trailing partial @@ -124,7 +119,7 @@ pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) { /// Split out of [`aes_cbc_decrypt`] so a caller that decrypts several regions /// under one loop-invariant key expands the schedule once. `decrypt_bus` /// ([`super::content::decrypt_bus`]) is that caller: bus encryption -/// ([C] §4.2 / the AACS 2.0 Read Data Key) covers bytes 16..2048 of EVERY +/// (`[C]` §4.2 / the AACS 2.0 Read Data Key) covers bytes 16..2048 of EVERY /// 2048-byte sector, so a 6144-byte aligned unit is three regions under one /// `read_data_key` — three key schedules where one suffices, on the per-unit /// decrypt hot path of a whole 90 GB read. @@ -150,7 +145,7 @@ pub(crate) fn cbc_decrypt_blocks(cipher: &Aes128, data: &mut [u8]) { } } -/// AES-G(x1, x2) = AES-128D(x1, x2) XOR x2. [C] §2.1.3 (note: uses AES-128**D**). +/// AES-G(x1, x2) = AES-128D(x1, x2) XOR x2. `[C]` §2.1.3 (note: uses AES-128**D**). /// /// The Media Key Variant chain uses AES-G to derive both the variant /// number (`Kvn = AES-G(Kp, Nonce)`) and the Volume Unique Key @@ -165,14 +160,14 @@ pub(crate) fn aes_g(x1: &[u8; 16], x2: &[u8; 16]) -> [u8; 16] { out } -/// AACS-G3 seed constant (`s0`). [C] §3.2.2. +/// AACS-G3 seed constant (`s0`). `[C]` §3.2.2. pub(crate) const AESG3_SEED: [u8; 16] = [ 0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9, ]; -/// AACS-G3: derive a subkey from a parent key. [C] §3.2.2 (Triple AES Generator: +/// AACS-G3: derive a subkey from a parent key. `[C]` §3.2.2 (Triple AES Generator: /// left=`D(k,s0)⊕s0` inc 0, pk=`D(k,s0+1)⊕(s0+1)` inc 1, right=`D(k,s0+2)⊕(s0+2)` inc 2). -/// seed[15] += inc, then AES-DEC(key, seed) XOR seed. +/// `seed[15] += inc`, then AES-DEC(key, seed) XOR seed. /// /// Shared with [`super::variant`] (its variant chain runs the same SD /// tree); a single definition keeps the two walks byte-identical. @@ -190,7 +185,7 @@ pub(crate) fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] { mod tests { use super::*; - /// The AACS-G3 seed `s0`, transcribed independently from [C] §3.2.2 rather + /// The AACS-G3 seed `s0`, transcribed independently from `[C]` §3.2.2 rather /// than read from [`AESG3_SEED`] — a test that sourced the seed from the /// production constant would assert that constant against itself and would /// still pass if it were edited. @@ -216,7 +211,7 @@ mod tests { /// finding Media Keys, with no error to say why. /// /// Pinned through the spec relation rather than a re-implementation: - /// [C] §3.2.2 defines `AES-G3` as `AES-128D(k, s) XOR s` for + /// `[C]` §3.2.2 defines `AES-G3` as `AES-128D(k, s) XOR s` for /// `s = s0 + inc` (added into the last seed byte), so applying the /// FORWARD primitive [`aes_ecb_encrypt`] — a different function from the /// one under test — to `aesg3(k, inc) XOR s` must reproduce `s` exactly. @@ -242,7 +237,7 @@ mod tests { } } - /// The Triple Generator's three outputs ([C] §3.2.2: left = inc 0, the + /// The Triple Generator's three outputs (`[C]` §3.2.2: left = inc 0, the /// Processing Key = inc 1, right = inc 2) are the two child node keys and /// the Processing Key of ONE tree node. They must be three different keys — /// if `inc` were ignored, a descent would revisit its own parent and the diff --git a/src/aacs/derive.rs b/src/aacs/derive.rs index 22d8a43d..b888c2de 100644 --- a/src/aacs/derive.rs +++ b/src/aacs/derive.rs @@ -1,5 +1,5 @@ //! Media-key derivation: DK/PK → Media Key via the subset-difference tree. -//! [C] §3.2.2–§3.2.5. +//! `[C]` §3.2.2–§3.2.5. use super::crypto::*; use super::inf::*; @@ -71,11 +71,11 @@ pub(crate) fn try_pk_against_tables( /// Validate a processing key against a cvalue/UV pair. /// Returns the Media Key if valid. /// -/// Steps (media key: [C] §3.2.4; verify relation: [C] §3.2.5.1.4): -/// 1. `mk = AES-128D(pk, cvalue)` [C] §3.2.4 -/// 2. `mk[12..16] ^= uv` (4 bytes XOR into the last 4 bytes only) [C] §3.2.4 -/// 3. `dec_vd = AES-128D(mk, mk_dv)` [C] §3.2.5.1.4 -/// 4. If `dec_vd[0..8] == 01 23 45 67 89 AB CD EF` → valid. [C] §3.2.5.1.4 +/// Steps (media key: `[C]` §3.2.4; verify relation: `[C]` §3.2.5.1.4): +/// 1. `mk = AES-128D(pk, cvalue)` `[C]` §3.2.4 +/// 2. `mk[12..16] ^= uv` (4 bytes XOR into the last 4 bytes only) `[C]` §3.2.4 +/// 3. `dec_vd = AES-128D(mk, mk_dv)` `[C]` §3.2.5.1.4 +/// 4. If `dec_vd[0..8] == 01 23 45 67 89 AB CD EF` → valid. `[C]` §3.2.5.1.4 pub(crate) fn validate_processing_key( pk: &[u8; 16], cvalue: &[u8], @@ -105,7 +105,7 @@ pub(crate) fn validate_processing_key( None } -/// Compute v_mask from a UV value. [C] §3.2.3. Shared with [`super::variant`]. +/// Compute v_mask from a UV value. `[C]` §3.2.3. Shared with [`super::variant`]. pub(super) fn calc_v_mask(uv: u32) -> u32 { let mut v_mask: u32 = 0xFFFF_FFFF; while (uv & !v_mask) == 0 && v_mask != 0 { @@ -115,27 +115,22 @@ pub(super) fn calc_v_mask(uv: u32) -> u32 { } /// Derive processing key from device key using subset-difference tree traversal. -/// [C] §3.2.4 (device-tree descent, MSB-branch, terminal PK). Shared with [`super::variant`]. +/// `[C]` §3.2.4 (device-tree descent, MSB-branch, terminal PK). Shared with [`super::variant`]. pub(super) fn calc_pk_from_dk( dk: &[u8; 16], uv: u32, v_mask: u32, dev_key_v_mask: u32, ) -> [u8; 16] { - // Descend from the device node to the record node, following the record's - // `uv` bits. At each level only the child we descend INTO is needed (the - // sibling is computed but never used), and the Processing Key is the - // `aesg3(.,1)` of the FINAL node — so we derive ONE child per level and the - // PK once at the end, instead of left/pk/right at every level. Identical - // result, ~3x fewer block ops. (left child = `aesg3(node,0)`, right = `,2`.) + // Descend device node -> record node by the record's `uv` bits. Only the child + // descended into matters, so derive ONE child per level (left=`aesg3(node,0)`, + // right=`,2`) and the PK=`aesg3(.,1)` once at the end — ~3x fewer block ops. let mut node = *dk; let mut current_v_mask = dev_key_v_mask; - // The subset-difference tree is at most 32 levels deep (u32 mask), so the - // walk must converge in <= 32 steps. The arithmetic `>> 1` sign-extends - // current_v_mask, so a v_mask coarser than dev_key_v_mask (reachable from - // a crafted/corrupt MKB) would otherwise saturate at 0xFFFF_FFFF and spin - // forever — bound the loop to keep a bad disc from hanging the rip thread. + // Tree is <=32 levels deep (u32 mask). The arithmetic `>> 1` sign-extends, so a + // v_mask coarser than dev_key_v_mask (crafted/corrupt MKB) would saturate to + // 0xFFFF_FFFF and spin forever; bound to 32 steps so a bad disc can't hang the rip. let mut steps = 0u32; while current_v_mask != v_mask { if steps >= 32 { @@ -203,14 +198,9 @@ pub fn derive_media_key_and_pk_from_dk( let p_uv = &uvs[1 + 5 * uvs_idx..]; let u_mask_shift = uvs[5 * uvs_idx]; // byte before the UV value - // `num_uvs` was computed via `take_while(.. c[0] & 0xC0 == 0)`, so - // every iterated slot already has its revoked-marker bits clear — no - // inner `& 0xC0` re-check is needed (it would be unreachable). - // - // Shifts of 32..=63 (0x20..=0x3F) have those bits clear but would - // panic in debug / wrap to a wrong mask in release. The MKB byte is - // disc-controlled, so a crafted/corrupt MKB must not crash the ripper: - // skip an out-of-range slot rather than `<<` it. + // `num_uvs` used `take_while(.. c[0] & 0xC0 == 0)`, so revoked-marker bits + // are already clear (no re-check). But shifts 32..=63 panic/wrap on `<<` and + // the byte is disc-controlled, so skip an out-of-range slot rather than shift. if u_mask_shift >= 32 { continue; } @@ -262,7 +252,7 @@ pub fn derive_media_key_and_pk_from_dk( /// 16 bytes) cannot be walked — the walk needs its tree node. This finds that /// node empirically: for each MKB subset-difference record, it tries the device /// at the record's node AND at every ancestor v-position (the device may sit one -/// or more levels ABOVE the record, descending via AES-G to reach it), deriving +/// or more levels ABOVE the record, descending via AES-G3 to reach it), deriving /// the candidate Processing Key DIRECTLY (one [`calc_pk_from_dk`] per candidate, /// no full re-walk) and checking it validates against that record's cvalue. /// @@ -284,16 +274,14 @@ pub fn recover_dk_position(mkb: &[u8], key: &[u8; 16]) -> Option { .count(); let n_cv = cvalues.len() / 16; - // Hoisted ONCE for the whole scan: the Processing Key the device produces if - // it sits EXACTLY at a record (zero descent) is `AES-G3(key, 1)` — it does - // not depend on the record, so the zero-descent probe of every slot reuses - // this single value instead of re-deriving it per slot. + // Hoisted once: the zero-descent Processing Key (device sits exactly at a record) + // is `AES-G3(key, 1)`, independent of the record, so every slot's zero-descent + // probe reuses this instead of re-deriving it per slot. let pk_zero_descent = aesg3(key, 1); - // The slots are independent, so the scan parallelises — a UHD MKB has ~181k - // slots (~26s single-threaded). `find_map_any` returns the first matching - // node found by any thread and cancels the rest; a valid MKB has exactly one - // matching subset-difference, so which thread finds it is immaterial. + // Slots are independent so the scan parallelises (~181k slots, ~26s serial on a + // UHD MKB). `find_map_any` returns the first match and cancels the rest; a valid + // MKB has exactly one matching subset-difference, so which thread finds it is moot. use rayon::prelude::*; let found = (0..num_uvs.min(n_cv)).into_par_iter().find_map_any(|i| { let u_mask_shift = uvs[5 * i]; @@ -415,7 +403,7 @@ pub mod probe { // ── Volume key: Media Key + Volume ID → VUK → unit keys ────────────────────── -/// Derive VUK from Media Key and Volume ID. [PR] §3.3 / [BD] §3.3 +/// Derive VUK from Media Key and Volume ID. `[PR]` §3.3 / `[BD]` §3.3 /// (`Kvu = AES-G(Km, IDv)`; AES-G uses AES-128D): /// VUK = AES-128-ECB-DECRYPT(media_key, volume_id) XOR volume_id pub fn derive_vuk(media_key: &[u8; 16], volume_id: &[u8; 16]) -> [u8; 16] { @@ -426,8 +414,8 @@ pub fn derive_vuk(media_key: &[u8; 16], volume_id: &[u8; 16]) -> [u8; 16] { vuk } -/// Decrypt an encrypted unit key using the VUK (AES-128-ECB). [PR] §3.5 -/// (Title Key unwrap `Kt = AES-128D(Ku, Kte)`); the BD "CPS Unit Key" synonym is [BD] §3.9.3. +/// Decrypt an encrypted unit key using the VUK (AES-128-ECB). `[PR]` §3.5 +/// (Title Key unwrap `Kt = AES-128D(Ku, Kte)`); the BD "CPS Unit Key" synonym is `[BD]` §3.9.3. pub fn decrypt_unit_key(vuk: &[u8; 16], encrypted_uk: &[u8; 16]) -> [u8; 16] { aes_ecb_decrypt(vuk, encrypted_uk) } @@ -792,7 +780,7 @@ mod resolve_candidate_tests { /// /// This file holds the whole subset-difference walk and had five tests for it. /// There are no published AACS test vectors, but none are needed: the AACS -/// relations ([C] §3.2.3–§3.2.5) are invertible, so a valid MKB for a CHOSEN +/// relations (`[C]` §3.2.3–§3.2.5) are invertible, so a valid MKB for a CHOSEN /// key can be constructed with `aes_ecb_encrypt` and the same `aesg3` the walk /// uses as its node function. That is what `plant_mkb` below does — no real /// key material, and the assertions check the DERIVED Media Key, not any @@ -802,7 +790,7 @@ mod position_recovery_tests { use super::*; use crate::aacs::crypto::aes_ecb_encrypt; - /// [C] §3.2.5.1.4 Verify-Media-Key plaintext prefix. + /// `[C]` §3.2.5.1.4 Verify-Media-Key plaintext prefix. const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]; /// An MKB record: 1-byte type + BE24 total length (header included) + body. @@ -834,7 +822,7 @@ mod position_recovery_tests { /// /// `uv = 0x0400` (lowest set bit 10) with `u_mask_shift = 12` is chosen so a /// gating device node exists: `resolve_dk_node` flips one bit `b < 12`, and - /// the walk's gate ([C] §3.2.4) needs that bit inside `v_mask` + /// the walk's gate (`[C]` §3.2.4) needs that bit inside `v_mask` /// (`0xFFFF_FFFF << 11`) and outside `u_mask` (`0xFFFF_FFFF << 12`) — i.e. /// `b == 11`. `uv` is kept under 0x10000 because `DeviceKey::node` is a u16. fn plant_mkb() -> Planted { @@ -956,7 +944,7 @@ mod position_recovery_tests { } /// `resolve_dk_node` picks the `device_number` that passes the walk's - /// subset-difference gate ([C] §3.2.4). `None` here strands a key whose + /// subset-difference gate (`[C]` §3.2.4). `None` here strands a key whose /// position was already successfully recovered, so it is the last step of /// position recovery and fails the same way: usable key, discarded. /// @@ -1015,22 +1003,12 @@ mod position_recovery_tests { ); } - // ════════════════════════════════════════════════════════════════════ - // A MULTI-SLOT MKB where the device key sits ABOVE the matching slot. - // - // `plant_mkb` above is a ONE-slot, ZERO-descent fixture: the matching - // subset-difference is at index 0 and the device sits exactly on it. That - // leaves two whole behaviours of `recover_dk_position` unexercised — - // * slot INDEXING (`uvs[5*i]`, `uvs[1 + 5*i]`, `cvalues[i*16..]`), which - // is the identity permutation when i is always 0, and - // * the DESCENT branch, where the device is an ancestor of the slot and - // the candidate position is walked up bit by bit — - // so an MKB whose keyed slot is index 2 of 3, opened by a device one level - // above it, is what pins them. - // ════════════════════════════════════════════════════════════════════ + // A MULTI-SLOT MKB where the device key sits ABOVE the matching slot. Unlike + // `plant_mkb`'s one-slot zero-descent fixture, this pins slot INDEXING and the + // DESCENT branch: keyed slot is index 2 of 3, opened by a device one level above. /// v-masks for the fixture's two positions, written as literals from - /// [C] §3.2.3 (`v_mask` is all-ones above the LOWEST set bit of `uv`, i.e. + /// `[C]` §3.2.3 (`v_mask` is all-ones above the LOWEST set bit of `uv`, i.e. /// `0xFFFF_FFFF << (uv.trailing_zeros() + 1)`) rather than computed with /// `calc_v_mask`, which is itself under test. const UV_SLOT: u32 = 0x0000_9400; // lowest set bit 10 @@ -1039,7 +1017,7 @@ mod position_recovery_tests { const V_MASK_ANCESTOR: u32 = 0xFFFF_F000; // 0xFFFF_FFFF << 12 const U_MASK_SHIFT: u8 = 16; - /// `calc_v_mask` implements [C] §3.2.3. Every subset-difference gate and + /// `calc_v_mask` implements `[C]` §3.2.3. Every subset-difference gate and /// every descent in the walk is masked by its result, so a wrong mask makes /// the walk match the wrong slots (or none) — pinned here against literal /// expectations, not against a re-computation. @@ -1088,10 +1066,8 @@ mod position_recovery_tests { 0xBE, 0xBF, ]; - // The Processing Key the device produces after descending from - // UV_ANCESTOR to the slot. Built with the same descent the walk uses - // (as `plant_mkb` builds its cvalue with the same `aesg3`), but anchored - // to the FIXED ancestor position above — so a walk that computes a + // Processing Key from descending UV_ANCESTOR -> slot, via the same descent the + // walk uses but anchored to the FIXED ancestor above — so a walk computing a // different candidate position derives a different Kp and fails. let pk = calc_pk_from_dk(&dkey, UV_SLOT, V_MASK_SLOT, V_MASK_ANCESTOR); @@ -1191,20 +1167,9 @@ mod position_recovery_tests { assert!(recover_dk_position(&p.mkb, &stranger).is_none()); } - // ════════════════════════════════════════════════════════════════════ - // A FOUR-LEVEL descent taking both branches. - // - // The fixtures above descend zero levels and one level (left). The tree - // walk's per-level branch decision — [C] §3.2.4: descend RIGHT - // (`aesg3(.,2)`) when the slot's `uv` has the level's bit set, LEFT - // (`aesg3(.,0)`) when it is clear, terminal Processing Key `aesg3(.,1)` — - // is only pinned by a descent that takes both branches more than once. - // - // The expected Processing Key here is written out as an EXPLICIT chain of - // `aesg3` calls, not computed with `calc_pk_from_dk`: a fixture built by - // the function under test moves with it, and every mutation of the descent - // would stay self-consistent. - // ════════════════════════════════════════════════════════════════════ + // A FOUR-LEVEL descent taking both branches, pinning the per-level decision ([C] + // §3.2.4: RIGHT `aesg3(.,2)` if uv bit set, else LEFT `aesg3(.,0)`, PK `aesg3(.,1)`). + // Expected PK is an EXPLICIT `aesg3` chain, not `calc_pk_from_dk` (which would drift). /// Slot `uv` for the four-level fixture: bits 8, 6 and 4 set. Lowest set /// bit 4 → the descent reads bits 8, 7, 6, 5 (set, clear, set, clear). @@ -1232,13 +1197,8 @@ mod position_recovery_tests { 0xDE, 0xDF, ]; - // [C] §3.2.4, written out level by level. Descending from the ancestor - // node to the slot reads UV_SLOT4's bits 8, 7, 6, 5 in that order: - // bit 8 = 1 → right child, aesg3(., 2) - // bit 7 = 0 → left child, aesg3(., 0) - // bit 6 = 1 → right child, aesg3(., 2) - // bit 5 = 0 → left child, aesg3(., 0) - // and the Processing Key is aesg3(final_node, 1). + // [C] §3.2.4 level by level: ancestor -> slot reads UV_SLOT4 bits 8,7,6,5 = + // 1,0,1,0 -> right(2),left(0),right(2),left(0), then PK = aesg3(final_node, 1). let n1 = aesg3(&dkey, 2); let n2 = aesg3(&n1, 0); let n3 = aesg3(&n2, 2); @@ -1320,13 +1280,9 @@ mod position_recovery_tests { ); } - // ════════════════════════════════════════════════════════════════════ - // MALFORMED MKBs: a truncated cvalue table, and a revoked-marker slot. - // - // The MKB is disc-controlled data. Both of these shapes are reachable from - // a corrupt or crafted disc, and in both the walk must decline to derive a - // key rather than index past the end of a record. - // ════════════════════════════════════════════════════════════════════ + // MALFORMED MKBs: a truncated cvalue table, and a revoked-marker slot. Both are + // reachable from a corrupt/crafted (disc-controlled) MKB, and in both the walk must + // decline to derive a key rather than index past the end of a record. /// Assemble an MKB from an explicit slot list and cvalue table. /// `slots` is `(u_mask_shift, uv)` per subset-difference entry. @@ -1424,7 +1380,7 @@ mod position_recovery_tests { } /// The `0xC0` revoked marker in a slot's `u_mask_shift` byte TERMINATES the - /// subset-difference table ([C] §3.2.5.1.5). Slots after it are not part of + /// subset-difference table (`[C]` §3.2.5.1.5). Slots after it are not part of /// the index and must not be walked — a walk that ran past the marker would /// derive keys from entries the MKB has explicitly ended. /// @@ -1490,7 +1446,7 @@ mod position_recovery_tests { } /// A device key applies to a subset-difference only when BOTH gates hold - /// ([C] §3.2.4): its u-mask must equal the slot's, AND its `uv` must agree + /// (`[C]` §3.2.4): its u-mask must equal the slot's, AND its `uv` must agree /// with the slot's under the device's v-mask. A key filed with the wrong /// `u_mask_shift` describes a different region of the tree and must not be /// used, even though its tree position would otherwise line up — accepting @@ -1524,7 +1480,7 @@ mod position_recovery_tests { } /// `validate_processing_key` XORs the slot's 4-byte `uv` into `mk[12..16]` - /// ([C] §3.2.4 step 2). XOR, not OR: the operation must be reversible, and + /// (`[C]` §3.2.4 step 2). XOR, not OR: the operation must be reversible, and /// it must be able to CLEAR a bit the AES output set. A `uv` and a Media Key /// that share set bits in those four bytes are what tell the two apart. #[test] @@ -1580,7 +1536,7 @@ mod position_recovery_tests { /// `probe::aes_dec` is the single AACS verify primitive a reproduction /// harness has: every claim such a harness makes about a Media Key is - /// `aes_dec(km, mk_dv)` starting with the [C] §3.2.5.1.4 magic. A body + /// `aes_dec(km, mk_dv)` starting with the `[C]` §3.2.5.1.4 magic. A body /// returning a fixed block makes the harness answer the SAME way for every /// key and every disc — either "nothing verifies" or, if the constant /// happened to start with the magic, "everything verifies", which is the @@ -1647,20 +1603,9 @@ mod position_recovery_tests { ); } - // ════════════════════════════════════════════════════════════════════ - // An ODD subset-difference `uv` — the depth-0 slot. - // - // `recover_dk_position`'s descent starts at `uv_r.trailing_zeros() + 1`. - // Every other fixture in this module uses an even `uv` (lowest set bit 4, - // 10 or 11), so `trailing_zeros()` was never 0 and the descent never began - // at level 1. That left the whole depth-0 case unexecuted: a slot whose `uv` - // is odd sits at the very bottom of the subset-difference tree, and it is a - // perfectly legal MKB shape. - // - // It is also the arithmetic boundary of the loop bound: at `p == 0` the - // `+ 1` is the only thing keeping `(p - 1)` — an unsigned underflow — off - // the range expression. - // ════════════════════════════════════════════════════════════════════ + // An ODD subset-difference `uv` — the legal depth-0 slot. Descent starts at + // `uv_r.trailing_zeros() + 1`; other fixtures use even `uv`, so this alone exercises + // `trailing_zeros() == 0` and the `p == 0` boundary where `+ 1` blocks a `(p-1)` underflow. /// Slot `uv` with bits 8, 6, 4 AND 0 set: lowest set bit 0, so /// `trailing_zeros() == 0` and the descent must start at level 1. @@ -1673,7 +1618,7 @@ mod position_recovery_tests { /// An MKB with a single ODD-`uv` slot, keyed by a device one level above it. /// /// The expected Processing Key is written as the EXPLICIT `aesg3` chain the - /// one-level descent produces ([C] §3.2.4): from the ancestor, bit 1 of + /// one-level descent produces (`[C]` §3.2.4): from the ancestor, bit 1 of /// `UV_ODD` is CLEAR, so the walk takes the left child (`aesg3(.,0)`) and /// then terminates with `aesg3(.,1)`. Not built with `calc_pk_from_dk` — /// a fixture built by the walk moves with the walk's own mutations. diff --git a/src/aacs/index_select.rs b/src/aacs/index_select.rs index d91b4c7d..725bca75 100644 --- a/src/aacs/index_select.rs +++ b/src/aacs/index_select.rs @@ -63,11 +63,9 @@ pub fn unit_disposition( None => UnitDisposition::Default, // In a forensic segment → decide by whether it is our index. Some(seg) => { - // `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). + // `seg.index` is untrusted (real range 1..=32); compare in u16 space so a + // crafted index above 255 can't truncate into a valid u8 and alias ours. + // Diagnostics carry a saturated u8 since out-of-range is never ours. let seg_index = seg.index; let diag = seg_index.min(u8::MAX as u16) as u8; match disc_index { @@ -169,10 +167,9 @@ 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. + // Index 288 (0x0120) truncates to 32 in a u8, which would alias our + // resolved index 32 under the old `as u8` compare; u16 compare must + // classify it as foreign instead. let segs = tbl(&[(288, 100, 200)]); let off = 120u64 * SOURCE_PACKET_LEN; assert_eq!( diff --git a/src/aacs/inf.rs b/src/aacs/inf.rs index b6d19d25..7d64ce23 100644 --- a/src/aacs/inf.rs +++ b/src/aacs/inf.rs @@ -67,6 +67,7 @@ pub fn disc_hash_hex(hash: &[u8; 20]) -> String { /// Parse Unit_Key_RO.inf from raw bytes. /// /// Format (from AACS spec): +/// ```text /// [0..4] BE32: offset to key storage area (uk_pos) /// [16] app_type (1 = BD-ROM) /// [17] num_bdmv_dir @@ -81,6 +82,7 @@ pub fn disc_hash_hex(hash: &[u8; 20]) -> String { /// [uk_pos+48..] encrypted keys, 16 bytes each /// AACS 1.0: 48-byte stride /// AACS 2.0 / 2.1: 64-byte stride (48 + 16 extra) +/// ``` pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option { if data.len() < 20 { return None; @@ -135,18 +137,16 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option u16 { if cps >= 1 && cps as usize <= num_uk { cps - 1 @@ -382,10 +382,8 @@ pub fn parse_content_cert(data: &[u8]) -> Option { return None; } - // Content Certificate layout (per the AACS content-cert format): - // [0] certificate type (0x00 = AACS1, 0x10 = AACS2) - // [1] bit7 bus_encryption_enabled_flag (`p[1] >> 7`) - // [14..20] cc_id (6 bytes) (`p + 14`) + // Content Certificate layout: [0] cert type (0x00 AACS1/0x10 AACS2), + // [1] bit7 bus_encryption_enabled_flag, [14..20] cc_id (6 bytes). let version = if data[0] == 0x00 { AacsVersion::V10 } else { @@ -444,10 +442,9 @@ mod vtkf_tests { let data = synth_vtkf(&[k1, k2, k3]); let ukf = parse_vtkf(&data).expect("valid VTKF must parse"); - // Exactly the three present entries — the empty slots and the trailing - // 16-byte TKF MAC are NOT mistaken for keys. Critically, k2/k3 are read - // at the 36-byte stride (offsets 0xA4, 0xC8); the old 32-byte stride - // misread them from inside the previous entry's binding MAC. + // Exactly the three present entries — empty slots and the trailing 16-byte + // TKF MAC are not mistaken for keys. k2/k3 are read at the 36-byte stride + // (0xA4, 0xC8); the old 32-byte stride misread them from the prior MAC. assert_eq!(ukf.encrypted_keys.len(), 3); assert_eq!( ukf.encrypted_keys[0], diff --git a/src/aacs/mkb.rs b/src/aacs/mkb.rs index 0feec5ea..ac495927 100644 --- a/src/aacs/mkb.rs +++ b/src/aacs/mkb.rs @@ -1,4 +1,4 @@ -//! AACS Media Key Block — [C] Chapter 3. +//! AACS Media Key Block — `[C]` Chapter 3. //! //! The MKB record format (framing walker, the `MkbRecord` view, record-body //! finders), the MKBType / AACS-generation classification, and MKB-file @@ -7,10 +7,8 @@ //! still live side by side pending a follow-up that collapses them. // ── MKB record types ([C] Chapter 3) ────────────────────────────────────── -// The ONE canonical set. Every record-type comparison in the `aacs` module -// references these, so a type byte is never a bare literal scattered across -// files (the `0x0c` variant-data record in particular used to appear in several -// hand-rolled forms). +// The ONE canonical set; every record-type comparison in `aacs` references these +// so a type byte is never a bare literal scattered across files. /// Type-and-Version — carries the 32-bit MKBType / AACS generation. pub(crate) const REC_TYPE_AND_VERSION: u8 = 0x10; @@ -157,7 +155,7 @@ impl AacsVersion { } /// Find Verify Media Key Record (type 0x81 for AACS 1.0, 0x86 for AACS 2.0/2.1) in MKB. -/// 0x81: [C] §3.2.5.1.4. 0x86 (AACS 2.x): [RE] — not in the public spec (from real 2.x MKBs). +/// 0x81: `[C]` §3.2.5.1.4. 0x86 (AACS 2.x): `[RE]` — not in the public spec (from real 2.x MKBs). pub(crate) fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> { // Verify-Media-Key record (0x81 for AACS 1.0, 0x86 for AACS 2.x): mk_dv is // the 16 bytes at record offset 4 (body offset 0). Needs rec_len >= 20. @@ -189,12 +187,12 @@ pub(crate) fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> { } } -/// Find Subset-Difference records (type 0x04) in MKB. [C] §3.2.5.1.5. +/// Find Subset-Difference records (type 0x04) in MKB. `[C]` §3.2.5.1.5. pub(crate) fn mkb_find_subdiff_records(mkb: &[u8]) -> Option> { find_record_body(mkb, 0x04) } -/// Find the Media Key Data Record (cvalues table) in an MKB. [C] §3.2.4 / §3.2.5.1.7. +/// Find the Media Key Data Record (cvalues table) in an MKB. `[C]` §3.2.4 / §3.2.5.1.7. /// /// The cvalue table is record type `0x05` (Media Key Data) on BOTH AACS /// 1.0 and AACS 2.x MKBs — its 16-byte cvalue entries are 1:1 with the @@ -331,7 +329,7 @@ impl MkbType { } /// The raw 32-bit MKBType field from the Type-and-Version record (0x10), bytes -/// 4-7. `None` if no 0x10 record is present. [C] §3.2.5.1.1 Table 3-2. +/// 4-7. `None` if no 0x10 record is present. `[C]` §3.2.5.1.1 Table 3-2. pub fn mkb_type_raw(mkb: &[u8]) -> Option { // Type-and-Version record (0x10): the 32-bit MKBType is bytes 4-7 (body // offset 0). Needs rec_len >= 8 (4 header + 4 type). @@ -516,7 +514,7 @@ mod tests { assert_eq!(recs[1].body, vec![0x55; 16]); } - /// `mkb_type_raw` reports the 32-bit MKBType field verbatim ([C] §3.2.5.1.1 + /// `mkb_type_raw` reports the 32-bit MKBType field verbatim (`[C]` §3.2.5.1.1 /// Table 3-2), including a value this build does not recognise — the caller /// uses it to tell "unknown MKB generation" from "no Type record at all". /// All four bytes must come from the record body; reading any of them from diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index 4228b604..1cf89215 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -122,18 +122,9 @@ pub(crate) fn role_paths(udf: &crate::udf::UdfFs, role: AacsRole) -> Vec AacsRole::Mkb => v.push(format!("/{d}/MKBROM.AACS")), AacsRole::ContentCert => v.push(format!("/{d}/CONTENT_CERT.AACS")), AacsRole::UnitKey => { - // Glob VTKF*.AACS — the title-key filename is not fixed at - // VTKF000 (Freedom ships VTKF090 + VTKF100). Sorted for a - // deterministic try order. - // - // 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. + // Glob VTKF*.AACS (not fixed at VTKF000; Freedom ships VTKF090 + + // VTKF100), sorted for a deterministic try order. Each is bound to + // ONE playlist; caller tries in order, uses whichever's keys verify. let mut names: Vec<&str> = dir .entries .iter() @@ -157,7 +148,7 @@ pub(crate) fn role_paths(udf: &crate::udf::UdfFs, role: AacsRole) -> Vec /// /// `read` performs the actual per-path read (full file or bounded prefix), so /// callers share the same first-present walk regardless of read style. Returns -/// [`Error::AacsNoKeys`] if no candidate is present. Generic over the path +/// [`Error::AacsNoKeys`](crate::Error::AacsNoKeys) if no candidate is present. Generic over the path /// element (`&str` or owned `String`) so it accepts the `Vec` that /// [`role_paths`] builds from the discovered HD DVD directory. pub(crate) fn read_first(candidates: &[S], mut read: F) -> crate::error::Result> @@ -173,15 +164,9 @@ where Err(crate::error::Error::AacsNoKeys) } -// The module structure IS the public API — consumers import from the owning -// module directly (e.g. `aacs::content::decrypt_unit`, `aacs::mkb::MkbType`, -// `aacs::derive::{derive_vuk, resolve_candidate}`, `aacs::resolve::resolve_keys_v2`). -// The `derive::probe` reproduction harness stays reachable via its module path. -// -// A small set of flat re-exports is kept for the typed key primitives and the -// content-decrypt entry points that downstream key-source crates import through -// the `aacs::` path. These are the stable, load-bearing names; keeping them here -// lets those crates track the module refactor without a lockstep re-pin. +// The module structure IS the public API — consumers import from the owning module +// (e.g. `aacs::content::decrypt_unit`). A small set of flat re-exports below is kept +// for typed key primitives and content-decrypt entry points that downstream crates rely on. pub use content::ALIGNED_UNIT_LEN; pub use derive::derive_vuk; pub use types::{DeviceKey, HostCert, MediaKey, ProcessingKey, UnitKey, Vid, Vuk}; @@ -199,19 +184,16 @@ mod tests { #[test] fn aligned_unit_len_is_three_2048_byte_sectors() { - // ALIGNED_UNIT_LEN is the AACS aligned-unit size: 3 × 2048 = 6144. - // Re-exported from decrypt; pin the value here so the public constant - // and the spec stay in lockstep. + // ALIGNED_UNIT_LEN is the AACS aligned-unit size (3 × 2048 = 6144); + // pin it here so the public constant tracks the spec. assert_eq!(ALIGNED_UNIT_LEN, 6144); assert_eq!(ALIGNED_UNIT_LEN, 3 * 2048); } #[test] fn version_strides_are_reexported_and_distinct() { - // The three AACS generations are part of the public surface, and the - // V10 (48) vs V20/V21 (64) stride distinction is the load-bearing - // difference. Confirm the enum re-export is usable and the variants - // are distinct values. + // V10 (48) vs V20/V21 (64) stride is the load-bearing distinction; + // confirm the enum re-export is usable and variants are distinct. assert_ne!(AacsVersion::V10, AacsVersion::V20); assert_ne!(AacsVersion::V20, AacsVersion::V21); } @@ -236,11 +218,8 @@ mod tests { } // ── HD DVD AACS directory / filename discovery ──────────────────────── - // - // The HD DVD AACS dir name and title-key filename are authoring-specific - // and were previously hardcoded to `/ANY!/VTKF000.AACS`. These verify the - // discovery replacement against both real-disc shapes: Freedom (`AAC!` + - // `VTKF090`/`VTKF100`) and a BD/UHD disc (no HD DVD dir). + // Dir/filename are authoring-specific (previously hardcoded to `/ANY!/VTKF000.AACS`); + // verify against Freedom (`AAC!` + `VTKF090`/`VTKF100`) and a BD/UHD disc (no HD DVD dir). #[test] fn role_paths_discovers_hddvd_dir_and_globs_all_vtkf_variants() { diff --git a/src/aacs/resolve.rs b/src/aacs/resolve.rs index 0afc224b..ad774c44 100644 --- a/src/aacs/resolve.rs +++ b/src/aacs/resolve.rs @@ -5,10 +5,9 @@ use super::derive::*; use super::inf::*; use super::mkb::*; -// // Canonical form is `1003` (low 16 bits `0x1003` is a fixed marker). // Types 3/4/10 are from the AACS Common Cryptographic Elements spec (0.953, -// §3.2.5.1.1); the Category-C 2.0/2.1 values are the standard MKB type constants. +// §3.2.5.1.1); Category-C 2.0/2.1 values are the standard MKB type constants. // ── Full VUK resolution chain ─────────────────────────────────────────────── @@ -136,13 +135,13 @@ pub(crate) fn classify_resolve_failure(ctx: &ResolveContext<'_>) -> ResolveFailu } /// AACS 1.0 key resolution. Parses `Unit_Key_RO.inf` with 48-byte -/// stride. Tries paths 1 → 4 in order. +/// stride. Tries paths 1 → 5 in order (via `resolve_keys_classical`). pub fn resolve_keys_v1(ctx: &ResolveContext<'_>) -> Option { resolve_keys_classical(ctx, AacsVersion::V10) } /// AACS 2.0 key resolution. Parses `Unit_Key_RO.inf` with 64-byte -/// stride. Tries paths 1 → 4 in order. When paths 3/4 succeed against +/// stride. Tries paths 1 → 5 in order. When paths 3/4 succeed against /// an MKB carrying Variant records (`0x2d` / `0x2f`), the result's /// `version` is upgraded to [`AacsVersion::V21`] — derivation still /// runs through the classical V2 path; the V21-specific Variant chain @@ -210,9 +209,8 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option { if has_vid { // Path 1: Variant chain (V21's analogue of classical Path 1's DK - // derivation). Derive the Processing Key from device keys first (DK → PK - // via the variant walk), then run the PK → Km variant primitive and - // derive the per-disc VUK from Km + VID. + // derivation): DK → PK via variant walk, then PK → Km, then VUK + // from Km + VID. if let Some(mkb) = ctx.mkb { let recs = super::mkb::walk_mkb(mkb); let all_dks = providers.device_keys(); @@ -242,10 +240,9 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option { // Path 3: pre-computed MK + matching VID → derived VUK. // Short-circuit: first provider with a matching VID wins. if let Some(entry) = providers.lookup_disc_by_vid(ctx.volume_id) { - // The entry already matched by VID and derive_vuk needs only mk + - // ctx.volume_id, so a provider that matches by VID without - // populating disc_id (e.g. a webservice) must not have its MK - // dropped — gate on the MK alone. + // Already matched by VID and derive_vuk needs only mk + volume_id, so a + // provider matching by VID without disc_id (e.g. a webservice) must not + // lose its MK — gate on the MK alone. if let Some(mk) = entry.media_key { let vuk = derive_vuk(&mk, ctx.volume_id); tracing::debug!(target: "freemkv::disc", phase = "resolve_keys_v21_path3_hit", "MK+VID entry matched volume_id"); @@ -371,13 +368,9 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt } tracing::debug!(target: "freemkv::disc", phase = "resolve_keys_path2_miss", pk_count = all_pks.len(), "PK derivation failed"); - // Path 2.5: MK-pool brute. keydb stores Media Keys per-disc, but an - // MK is MKB-scoped (shared across a pressing/MKB-family). A disc - // whose own hash/VID isn't keyed can still resolve if ANY stored MK - // verifies against its MKB. Try every distinct MK via km_verifies; - // a UNIQUE pass is this disc's Km → derive VUK (needs VID) → UK. - // One AES-D + magic check per candidate (cheap). mk_dv is hoisted - // out of the loop so the MKB is not re-walked per candidate. + // Path 2.5: MK-pool brute. An MK is MKB-scoped, so a disc whose own + // hash/VID isn't keyed can still resolve if ANY stored MK verifies (a + // UNIQUE pass via km_verifies is this disc's Km); mk_dv is hoisted out. let mks = providers.media_keys(); let chosen_mk = mkb_find_mk_dv(mkb).and_then(|mk_dv| { unique_verifying_mk(&mks, |mk| { @@ -398,10 +391,9 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt // Path 3: pre-computed MK + matching VID → derived VUK. // Short-circuit: first provider with a matching VID wins. if let Some(entry) = providers.lookup_disc_by_vid(ctx.volume_id) { - // The entry already matched by VID and derive_vuk needs only mk + - // ctx.volume_id, so a provider that matches by VID without - // populating disc_id (e.g. a webservice) must not have its MK - // dropped — gate on the MK alone. + // Already matched by VID and derive_vuk needs only mk + volume_id, so a + // provider matching by VID without disc_id (e.g. a webservice) must not + // lose its MK — gate on the MK alone. if let Some(mk) = entry.media_key { let vuk = derive_vuk(&mk, ctx.volume_id); tracing::debug!(target: "freemkv::disc", phase = "resolve_keys_path3_hit", "MK+VID entry matched volume_id"); @@ -586,11 +578,9 @@ mod tests { #[test] fn derive_media_key_from_dk_survives_out_of_range_u_mask_shift() { - // Regression: a crafted/corrupt MKB with a Subset-Difference - // u_mask_shift of 32..=63 (passes the 0xC0 revoked-marker check but - // overflows `0xFFFF_FFFF << shift`) used to panic in debug / compute a - // wrong mask in release. The walk must now skip the bad slot and - // return cleanly (no panic) on disc-controlled bytes. + // Regression: a u_mask_shift of 32..=63 (passes the 0xC0 revoked-marker + // check but overflows `0xFFFF_FFFF << shift`) used to panic in debug / + // compute a wrong mask in release. The walk must skip the bad slot. let mut mkb: Vec = Vec::new(); // 0x81 record: 4-byte header + 16-byte mk_dv body (rec_len = 20). mkb.extend_from_slice(&[0x81, 0x00, 0x00, 0x14]); @@ -616,14 +606,9 @@ mod tests { #[test] fn test_decrypt_unit_key_from_vuk() { - // VUK → encrypted unit key → unit key roundtrip. The keydb-sourced - // variant of this test (which scanned a real KEYDB for VUK + unit - // keys) moved to freemkv-keysources; this rebuilt version exercises - // the same AES-G primitive (decrypt_unit_key ∘ aes_ecb_encrypt under a - // VUK) with directly-constructed material, so it needs no parser and - // keeps the crypto covered in libfreemkv. `aes_ecb_encrypt` is - // pub(crate), reachable here but not from keysources — the reason this - // half stays. + // VUK → encrypted unit key → unit key roundtrip. The keydb-sourced variant + // moved to freemkv-keysources; this exercises the same AES-G primitive + // with directly-constructed material — `aes_ecb_encrypt` is pub(crate). use super::super::crypto::aes_ecb_encrypt; let vuk = [0x5Au8; 16]; // A few representative "decrypted" unit keys. @@ -743,11 +728,9 @@ mod tests { #[test] fn trim_mkb_never_zeroes_an_unrecognised_mkb() { - // Regression: the 0.31.0 read_aacs_inputs path truncated the MKB to - // mkb_content_len() unconditionally. For an MKB whose first record the - // parser can't read, mkb_content_len() returns 0 → an unconditional - // truncate zeroed the MKB, so autorip sent an EMPTY MKB to the key - // service (or skipped the request). trim_mkb must leave it intact. + // Regression: 0.31.0 truncated the MKB to mkb_content_len() unconditionally. + // For an unparseable first record, mkb_content_len() returns 0, so autorip + // sent an EMPTY MKB to the key service. trim_mkb must leave it intact. let unrecognised = vec![0xFFu8; 4096]; // first "rec_type" 0xFF, rec_len huge → content_len 0 assert_eq!( mkb_content_len(&unrecognised), @@ -809,11 +792,9 @@ mod tests { #[test] fn probe_try_pk_against_tables_accepts_planted_pk_rejects_corrupt() { - // Lock in the terminal PK scan (`try_pk_against_tables`) used by the - // production PK path (`derive_media_key_from_pk`). Plant a terminal PK - // whose derived Media Key satisfies a synthetic verify record; confirm - // the scan ACCEPTS it against caller-supplied SD/cvalue tables and - // REJECTS a 1-byte corruption. + // Lock in the terminal PK scan (`try_pk_against_tables`) used by + // `derive_media_key_from_pk`: a planted PK whose derived MK satisfies a + // synthetic verify record must be ACCEPTED; a 1-byte corruption REJECTED. use super::super::crypto::aes_ecb_encrypt as enc; let pk: [u8; 16] = [ @@ -855,11 +836,9 @@ mod tests { #[test] fn validate_processing_key_round_trip_with_nonzero_uv() { - // Synthesise a (pk, uv, mk, cvalue, mk_dv) tuple that satisfies the - // AACS PK-validation relation, then confirm validate_processing_key - // recovers mk. Catches the bugs that landed pre-fix: - // * uv XOR step was missing → mk wrong whenever uv != 0 - // * AES-128E + 12-zero check instead of AES-128D + magic + // Synthesise a (pk, uv, mk, cvalue, mk_dv) tuple satisfying the AACS + // PK-validation relation; confirms validate_processing_key recovers mk. + // Catches pre-fix bugs: missing uv XOR, and AES-128E+zero-check vs 128D+magic. use super::super::crypto::{aes_ecb_decrypt as dec, aes_ecb_encrypt as enc}; let pk: [u8; 16] = [ @@ -872,10 +851,8 @@ mod tests { ]; let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00]; - // cvalue is what AES-128E(pk, mk') gives, where mk' = mk with the - // last-4-bytes-uv XOR pre-undone: - // mk_raw[12..16] = mk[12..16] XOR uv (so the validate step XORs - // uv back in and recovers mk). + // cvalue is AES-128E(pk, mk') where mk' = mk with last-4-bytes-uv XOR + // pre-undone, so the validate step XORs uv back in and recovers mk. let mut mk_raw = mk; for a in 0..4 { mk_raw[12 + a] ^= uv[a]; @@ -907,16 +884,9 @@ mod tests { assert!(validate_processing_key(&pk, &cvalue, &wrong_uv, &mk_dv).is_none()); } - // ── MKB cvalue-record selection (issue #259 / #281) ───────────────── - // - // The cvalue (Media Key Data) table is record 0x05; the - // Subset-Difference index is record 0x04 (the standard AACS MKB layout: - // 0x05 = cvalues, 0x04 = subset-difference index). Record 0x07 - // (Explicit Subset-Difference Record) is NOT the cvalue table. On real - // in-drive AACS 2.x UHD MKBs 0x07 is small (~96 entries) while the 0x05 - // table is large (181270 entries, 1:1 with 0x04). An earlier - // `mkb_find_cvalues` preferred 0x07, which under-tested the SD walk and - // broke the DK→walk path. The selector must prefer 0x05. + // ── MKB cvalue-record selection (issue #259 / #281): cvalue (Media Key Data) + // is record 0x05, not 0x07 (Explicit SD, small ~96 entries vs 0x05's 181270). + // An earlier `mkb_find_cvalues` wrongly preferred 0x07, breaking DK→walk. /// Build a 4-byte MKB record header (type + 3-byte big-endian total /// length, header included) and append `body`. @@ -1012,12 +982,9 @@ mod tests { #[test] fn real_aacs2_samples_select_large_0x05_not_small_0x07() { - // Real in-drive AACS 2.x UHD MKBs carry BOTH a small 0x07 - // Explicit-Subset-Difference record (96 16-byte entries) AND the - // large 0x05 Media Key Data / cvalue table (181270 entries, 1:1 - // with the 0x04 index). The production selector must return the - // LARGE 0x05 body, not the small 0x07 one. This is the exact - // regression #259 found. Skips when no sample dir is present. + // Real AACS 2.x UHD MKBs carry BOTH a small 0x07 record (96 entries) AND + // the large 0x05 cvalue table (181270 entries); selector must return the + // LARGE 0x05 body — the exact regression #259. Skips if no sample dir. let samples = [ "sample-a/MKB_RO.inf", "sample-b/MKB_RO.inf", @@ -1116,19 +1083,15 @@ mod tests { #[test] fn resolve_keys_skips_paths_2_through_4_when_vid_is_zero() { - // No VID -> paths 2/3/4 cannot succeed. The function must - // return None WITHOUT touching the MKB / device keys, so we - // can pass an MKB that would otherwise cause expensive - // derivation work — it must not be consumed. + // No VID -> paths 2/3/4 cannot succeed. Must return None WITHOUT touching + // the MKB/device keys, so an MKB that would cause expensive derivation + // work can be passed here and must not be consumed. let uk_ro = minimal_unit_key_ro(); let zero_vid = [0u8; 16]; - // A provider carrying a dummy processing key but NO disc entry that - // matches this disc. `disc_entry: None` preserves the negative-miss - // the test asserts: with VID=0, paths 1/2/3 are skipped and the - // path-4/5 hash lookup must MISS (a SuppliedKey returns its - // disc_entry unconditionally, so the planted entry would WRONGLY hit - // path 4 — None keeps the miss). + // Dummy processing key, NO matching disc entry: `disc_entry: None` keeps + // the path-4/5 hash lookup a miss (SuppliedKey returns disc_entry + // unconditionally, so a planted entry would WRONGLY hit path 4). let keydb = SuppliedKey { device_keys: Vec::new(), processing_keys: vec![[0u8; 16]], @@ -1153,10 +1116,9 @@ mod tests { #[test] fn resolve_keys_path4_still_runs_when_vid_is_zero() { - // Path 4 (disc-hash → VUK) doesn't need VID. Confirm the - // short-circuit doesn't block it: install a keydb entry whose - // disc_hash matches the fixture's hash, with a known VUK, and - // verify resolve_keys returns it with key_source = 4. + // Path 4 (disc-hash → VUK) doesn't need VID; confirm the short-circuit + // doesn't block it: install a keydb entry whose disc_hash matches the + // fixture with a known VUK, verify resolve_keys returns key_source = 4. let uk_ro = minimal_unit_key_ro(); let hash = disc_hash(&uk_ro); // `find_disc` lowercases the incoming hash; the entry map is @@ -1195,10 +1157,9 @@ mod tests { #[test] fn resolve_keys_path5_uses_keydb_unit_keys_when_vuk_absent() { - // Path 5: an entry with no VUK but with pre-decrypted unit - // keys matching the disc's CPS-unit numbering decrypts the - // disc directly. Covers the ~4,572 U-only KEYDB entries - // (mostly MKBv76+ UHDs) that the resolver previously ignored. + // Path 5: an entry with no VUK but pre-decrypted unit keys matching the + // disc's CPS-unit numbering decrypts directly. Covers the ~4,572 U-only + // KEYDB entries (mostly MKBv76+ UHDs) the resolver previously ignored. let uk_ro = minimal_unit_key_ro(); let hash = disc_hash(&uk_ro); let hash_hex = disc_hash_hex(&hash).to_lowercase(); @@ -1237,11 +1198,9 @@ mod tests { } #[test] fn resolve_keys_path5_rejects_partial_unit_key_coverage() { - // If the disc declares a CPS unit that's not in the KEYDB - // entry's unit_keys, path 5 must NOT half-decrypt the disc. - // The match function returns None and the resolver falls - // through to None overall (no other paths available in this - // setup). + // If the disc declares a CPS unit not in the KEYDB entry's unit_keys, path + // 5 must NOT half-decrypt: match returns None and the resolver falls + // through to None overall (no other paths available here). let uk_ro = minimal_unit_key_ro(); let hash = disc_hash(&uk_ro); let hash_hex = disc_hash_hex(&hash).to_lowercase(); @@ -1295,13 +1254,9 @@ mod tests { ); // This disc's inf (its hash will NOT be in keydb). let uk_ro = minimal_unit_key_ro(); - // The sibling's MK is lifted directly into the MK pool: a KeyDb - // aggregated per-disc media_keys into media_keys(), but SuppliedKey - // does NOT harvest its disc_entry's media_key — it has an explicit - // media_keys field. `disc_entry: None` preserves the miss on this - // disc's own hash/VID (the sibling matches neither), so ONLY the - // MK-pool brute (km_verifies) can resolve it — exactly the path under - // test. + // The sibling's MK is lifted into the MK pool via SuppliedKey's explicit + // media_keys field; `disc_entry: None` keeps this disc's own hash/VID a + // miss, so ONLY the MK-pool brute (km_verifies) can resolve it. let keydb = SuppliedKey { device_keys: Vec::new(), processing_keys: Vec::new(), @@ -1407,9 +1362,7 @@ mod tests { data[1] = 0x01; assert!(!parse_content_cert(&data).unwrap().bus_encryption); } - // ════════════════════════════════════════════════════════════════════ - // Hardening additions - // ════════════════════════════════════════════════════════════════════ + // ════════════════ Hardening additions ════════════════ // ── VUK derivation: spec relation VUK = AES-D(MK, VID) XOR VID ───────── #[test] fn derive_vuk_matches_spec_relation_explicitly() { @@ -1467,11 +1420,9 @@ mod tests { } #[test] fn stride_v10_is_48_v20_is_64_and_picks_distinct_keys() { - // AACS 1.0 stride = 48, AACS 2.0/2.1 stride = 64 (aacs/inf.rs). - // Lay keys at 64-byte stride. Parsing at V20 stride must pick exactly - // those keys; parsing the SAME bytes at V10 (48) stride would read the - // wrong (intermediate) bytes for key 2 onward — proving the stride - // selector matters. + // AACS 1.0 stride = 48, 2.0/2.1 = 64. Keys laid at 64-byte stride: V20 + // parsing must pick exactly those keys, while V10 (48) parsing the SAME + // bytes would read wrong intermediate bytes for key 2 onward. let data = build_unit_key_ro(2, 64); let v20 = parse_unit_key_ro(&data, AacsVersion::V20).unwrap(); assert_eq!(v20.encrypted_keys.len(), 2); @@ -1533,11 +1484,9 @@ mod tests { } #[test] fn parse_unit_key_ro_rejects_when_keys_run_off_end() { - // Finding #5: 3 keys declared but the buffer holds only 2 strides plus - // 8 trailing bytes (not a full 3rd 16-byte key). The extraction loop - // breaks at the buffer end (never reading OOB), and the post-loop - // length check rejects the short list with None — a truncated/malformed - // .inf must NOT be silently accepted with fewer keys than declared. + // Finding #5: 3 keys declared but buffer holds only 2 strides + 8 spare + // bytes. Extraction loop breaks at buffer end (never OOB), and the + // post-loop length check rejects the short list rather than accepting fewer. let uk_pos = 0x60usize; let stride = 48usize; // Room for keys at uk_pos+48 and uk_pos+48+48, then only 8 spare bytes @@ -1579,10 +1528,9 @@ mod tests { } #[test] fn parse_unit_key_ro_title_cps_mapping_first_play_top_menu_then_titles() { - // [20..22] first_play, [22..24] top_menu, [24..26] num_titles, then - // per-title 2-byte pad + 2-byte CPS unit at 26 + i*4 + 2. Each on-disc - // 1-based CPS number in `1..=num_uk` is validated and converted to a - // 0-based key index (per the AACS Unit_Key_RO format); an out-of-range number → 0. + // [20..22] first_play, [22..24] top_menu, [24..26] num_titles, then per-title + // 2-byte pad + 2-byte CPS unit at 26 + i*4 + 2. Each on-disc 1-based CPS + // number in `1..=num_uk` converts to a 0-based key index; out-of-range → 0. let mut data = build_unit_key_ro(4, 64); // num_uk = 4 → CPS 1..=4 valid data[20..22].copy_from_slice(&1u16.to_be_bytes()); // first_play CPS 1 data[22..24].copy_from_slice(&2u16.to_be_bytes()); // top_menu CPS 2 @@ -2046,17 +1994,9 @@ mod tests { #[test] fn derive_media_key_and_pk_from_dk_returns_intermediate_pk() { - // Regression: a classical DK boil must yield the intermediate - // Processing Key, not just the Media Key. The key service banks the - // PK lineage (DK·PK·MK·VUK·UK); before the `_and_pk_` form existed it - // recovered the MK here but lost the PK silently. - // - // Build a minimal classical MKB (no 0x82/0x83) with: - // - 0x04 Subset-Difference: u_mask_shift=3, uv=0x00000002 - // - 0x05 cvalues: one cvalue C planted so AES-D(Kp, C) XOR uv == mk - // - 0x86 Verify Media Key: mk_dv = AES-E(mk, magic || pad) - // and a DK with node=4, uv=2, u_mask_shift=3 so dev_key_v_mask == - // v_mask: the calc_pk_from_dk loop is a no-op and Kp == aesg3(dk, 1). + // Regression: a classical DK boil must yield the intermediate PK too + // (key service banks DK·PK·MK·VUK·UK; before `_and_pk_` it lost the PK). + // Builds a minimal MKB with a DK whose calc_pk_from_dk walk is a no-op. use super::super::crypto::aes_ecb_encrypt as enc; let dk_bytes: [u8; 16] = [ @@ -2126,12 +2066,8 @@ mod tests { } // ── resolve_keys_with_reason / classify_resolve_failure ──────────────── - // - // The rc.6 E7017/E7022 split is also exercised end-to-end through the - // `ensure_decryptable` gate in `disc/mod.rs`. These tests pin the - // *classifier* directly at the aacs::resolve seam and cover the branches the - // gate test does not: VID-present (must never be VidUnavailable), the - // processing-keys-only material path, and the version dispatch / Ok path. + // Also exercised end-to-end via `ensure_decryptable` in `disc/mod.rs`; these + // pin the *classifier* directly, covering branches that gate test doesn't. /// A `SuppliedKey` provider with the given derivation material and no /// disc-keyed entry. Mirrors the construction the gate test uses, lifted to diff --git a/src/aacs/segment.rs b/src/aacs/segment.rs index 8d8ee5d9..20f57df2 100644 --- a/src/aacs/segment.rs +++ b/src/aacs/segment.rs @@ -181,7 +181,8 @@ pub fn clip_byte_to_lba(extents: &[crate::disc::Extent], clip_byte: u64) -> Opti /// to clean TS and garbles the other ~40 (the second interleaved half), which the /// demux then drops — yielding one coherent stream. Ranges outside every segment /// are left for the map's default (the ordinary Unit Key). A segment that straddles -/// a UDF extent boundary is emitted as one range per whole-sector slice it covers. +/// a UDF extent boundary is skipped entirely, falling back to the Unit Key rather +/// than emitting a wrong span. /// /// The result feeds [`AacsKeyMap::from_ranges`](crate::decrypt::AacsKeyMap::from_ranges) /// with the Unit-Key index as the default — the same structure the CPS map uses, @@ -200,12 +201,9 @@ pub fn fmts_key_ranges( } 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 - // and last sector to LBAs. Segments are ~480 KB and extents are GB-sized, - // so a segment almost never crosses an extent boundary — but if the two - // ends land in different extents (non-contiguous LBAs), skip rather than - // emit a wrong span; the units there fall to the Unit Key (garble+drop), - // never a mis-decrypt. + // Map first/last sector to LBAs. Segments (~480 KB) rarely cross the + // GB-sized extent boundary, but if the two ends land in different + // extents, skip rather than emit a wrong span (falls back to Unit Key). let (Some(a), Some(b)) = ( clip_byte_to_lba(extents, start_byte), clip_byte_to_lba(extents, end_byte - 1), diff --git a/src/aacs/types.rs b/src/aacs/types.rs index 3414a360..094e27db 100644 --- a/src/aacs/types.rs +++ b/src/aacs/types.rs @@ -111,17 +111,9 @@ pub struct DiscEntry { pub unit_keys: Vec<(u32, [u8; 16])>, } -// ── Redacting `Debug` impls ────────────────────────────────────────────────── -// -// Every type above carries AACS secret material (device keys, host PRIVATE keys, -// media/volume/processing/unit keys). `#[derive(Debug)]` would print those bytes -// verbatim, so a stray `debug!("{:?}", …)` or a panic message would leak the -// keys. These hand-written impls print only NON-secret shape (presence, lengths, -// tree coordinates, indices) — never key bytes. `decrypt::DecryptKeys` follows -// the same policy by omitting `Debug` entirely; here we keep `Debug` because -// these are `PartialEq`/`Eq` value types used in `assert_eq!` and nested inside -// other `#[derive(Debug)]` structs, so the trait must exist — just not leak. -// Guarded by `redaction_tests` below. +// ── Redacting `Debug` impls: types above carry AACS secrets; derived `Debug` +// would leak key bytes via `debug!`/panics. These print only non-secret shape, +// kept (not omitted) since used in `assert_eq!`. See `redaction_tests`. impl std::fmt::Debug for DeviceKey { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -270,11 +262,9 @@ mod unit_key_tests { mod redaction_tests { use super::*; - // Sentinel key byte 0xD5 = decimal 213. A derived `Debug` prints `[u8;N]` - // as decimal, so a leaked key surfaces the substring "213"; the redacting - // impls must not. No non-secret field below is 213, so "213" appearing means - // key bytes leaked. Each type must also carry a "redacted" marker (or omit - // the secret entirely) so re-adding `#[derive(Debug)]` fails this test. + // Sentinel byte 0xD5 = decimal 213: a derived `Debug` prints `[u8;N]` as + // decimal, so a leak surfaces "213" (no non-secret field is 213). Each type + // must also carry a "redacted" marker so re-adding `#[derive(Debug)]` fails. const S: u8 = 0xD5; fn assert_redacted(what: &str, dbg: &str) { diff --git a/src/aacs/variant.rs b/src/aacs/variant.rs index 7e907da0..c06a89e3 100644 --- a/src/aacs/variant.rs +++ b/src/aacs/variant.rs @@ -54,11 +54,9 @@ use super::crypto::{aes_ecb_decrypt, aes_g}; use super::mkb::*; use super::types::DeviceKey; -// The MKB record types this chain selects — `REC_MEDIA_KEY_VARIANT_DATA` -// (`0x0c`, the per-slot C table), `REC_VARIANT_DATA_AND_NONCE` (`0x2d`, VARIANTS -// + tail Nonce), `REC_VKD_TABLE` (`0x2f`), the subset-difference / cvalue records -// (`0x04` / `0x05` / `0x07`), and the verify records (`0x81` / `0x86`) — are the -// canonical set in [`super::mkb`], in scope here via the `use super::mkb::*` glob. +// MKB record types this chain selects: `REC_MEDIA_KEY_VARIANT_DATA` (`0x0c`), +// `REC_VARIANT_DATA_AND_NONCE` (`0x2d`), `REC_VKD_TABLE` (`0x2f`), the +// subset-difference/cvalue and verify records — canonical set in [`super::mkb`]. // ── Public constants ────────────────────────────────────────────────────── @@ -137,10 +135,9 @@ pub(crate) fn variant_key_data(records: &[MkbRecord]) -> Option<&[u8]> { // ── Subset-difference walk that exposes (Kp, uv) ────────────────────────── -// `calc_v_mask` and `calc_pk_from_dk` (and the AES-G3 seed step they ride -// on) are shared with the classical walk in [`super::derive`] — a single -// definition keeps the variant SD tree byte-identical to the classical one. -// (`aesg3` itself is imported separately in the test module.) +// `calc_v_mask`/`calc_pk_from_dk` (and the AES-G3 seed step) are shared with +// the classical walk in [`super::derive`], keeping the variant SD tree +// byte-identical. `aesg3` itself is imported separately in the test module. use super::derive::{calc_pk_from_dk, calc_v_mask}; /// Outcome of a subset-difference walk against an MKB. Carries the @@ -190,10 +187,11 @@ fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> { /// separate on purpose and select MKB records in DELIBERATELY different /// order: /// -/// - cvalues: this variant walk tries record `0x07`-then-`0x05`; the -/// classical walk tries `0x05`-then-`0x07`. On a variant MKB the -/// small `0x07` Explicit-Subset-Difference record carries the -/// cvalue the Precursor chain consumes, whereas a classical UHD MKB +/// - cvalues: this variant walk tries record `0x0c`-then-`0x07`-then-`0x05` +/// (`0x0c` is the real-disc per-uv cvalue source; `0x07`/`0x05` are +/// fixture fallbacks); the classical walk tries `0x05`-then-`0x07`. On a +/// variant MKB the small `0x07` Explicit-Subset-Difference record carries +/// the cvalue the Precursor chain consumes, whereas a classical UHD MKB /// keeps its 1:1 cvalue table in the large `0x05` record (see the /// note on [`super::derive::probe::mkb_cvalues`]). They must NOT be /// unified to one order — each is correct for its own MKB shape. @@ -211,10 +209,8 @@ pub fn walk_processing_key( ) -> Option { let mk_dv = mkb_find_mk_dv(records)?; let uvs = mkb_find_body(records, REC_SUBSET_DIFFERENCE)?; - // Variant cvalue source: a real variant MKB carries its per-uv cvalue table - // in record `0x0c` (confirmed 46,101×16, one per `0x04` subset-difference - // slot). Fall back to `0x07`/`0x05` for the synthetic fixtures and any MKB - // shape that keeps its cvalues there. + // Real variant MKBs carry per-uv cvalues in record `0x0c` (46,101x16, one + // per `0x04` slot); fall back to `0x07`/`0x05` for synthetic fixtures. let cvalues = mkb_find_body(records, REC_MEDIA_KEY_VARIANT_DATA) .or_else(|| mkb_find_body(records, REC_EXPLICIT_SUBSET_DIFF)) .or_else(|| mkb_find_body(records, REC_MEDIA_KEY_DATA))?; @@ -229,17 +225,13 @@ pub fn walk_processing_key( for uvs_idx in 0..num_uvs { let p_uv = &uvs[1 + 5 * uvs_idx..]; - // `num_uvs` was computed by `take_while(.. (c[0] & 0xC0) == 0)`, so - // every chunk in `0..num_uvs` already has its revoked-marker bits - // clear — that `take_while` is the single authoritative place the - // parse stops, no inner re-check needed. + // `num_uvs` came from `take_while(.. (c[0] & 0xC0) == 0)`, so every + // chunk in `0..num_uvs` already has clear revoked-marker bits. let u_mask_shift = uvs[5 * uvs_idx]; - // 0x20..=0x3F (32..=63) have their revoked-marker bits clear (so they - // pass the take_while above) but are out of range for a u32 shift. - // `wrapping_shl` would silently compute shift % 32 (e.g. 32 → no shift - // → 0xFFFF_FFFF), matching a wrong uv slot and deriving a wrong key. - // Disc-controlled byte: skip the slot instead. + // 0x20..=0x3F pass the take_while but are out of range for a u32 + // shift; `wrapping_shl` would silently wrap (shift % 32) and match + // a wrong uv slot. Disc-controlled byte: skip the slot instead. if u_mask_shift >= 32 { continue; } @@ -281,12 +273,9 @@ pub fn walk_processing_key( } let dec_vd = aes_ecb_decrypt(&km_candidate, &mk_dv); const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]; - // On a classical (non-variant) MKB this magic must - // match. On a variant MKB it won't — `km_candidate` - // is really Kmp and the magic check is moot. We - // still gate the walk on cvalue indexing being - // sane; the chain itself enforces the variant - // semantics downstream. + // On classical MKBs this magic must match. On variant MKBs + // it won't — `km_candidate` is really Kmp, so the magic + // check is moot; the chain enforces semantics downstream. let classical_ok = dec_vd[..8] == VERIFY_MAGIC; let variant_present = is_variant_mkb(records); if !(classical_ok || variant_present) { @@ -375,10 +364,9 @@ impl std::error::Error for MediaKeyVariantError {} /// silent bad key). fn variants_for_uv(records: &[MkbRecord], sd_slot_index: usize) -> Option { let body = variant_data_record(records)?; - // The VARIANTS table is the leading bytes; the 16-byte Kvn Nonce is packed at - // the TAIL (see [`variant_nonce`]). Bound the read to the table region so a - // near-end slot can never read Nonce bytes as a VARIANTS entry. NO leading - // header (measured: a v70 `0x2d` body = 46_100*2 + 16 = 92_216). + // VARIANTS table is the leading bytes; the 16-byte Kvn Nonce is packed at the + // TAIL (see [`variant_nonce`]). Bound reads to the table region so a near-end + // slot never reads Nonce bytes (no header: v70 `0x2d` body = 46_100*2+16). const NONCE: usize = 16; let table_len = body.len().checked_sub(NONCE)?; let off = sd_slot_index.checked_mul(2)?; @@ -532,10 +520,9 @@ pub fn derive_media_key_variant( } let nonce = variant_nonce(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?; let vkd_table = variant_key_data(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?; - // C for the Kmp step is the per-subset-difference `0x0c` table (one 16-byte - // C per slot) — the SAME source and index `walk_processing_key` uses. `0x2d` - // holds VARIANTS + Nonce, NOT C. Fall back to `0x07`/`0x05` for the synthetic - // fixtures that keep a single cvalue there. + // C for Kmp is the per-slot `0x0c` table, same source/index as + // `walk_processing_key` uses; `0x2d` holds VARIANTS + Nonce, not C. Fall + // back to `0x07`/`0x05` for synthetic fixtures with a single cvalue. let cvalues = mkb_find_body(mkb_records, REC_MEDIA_KEY_VARIANT_DATA) .or_else(|| mkb_find_body(mkb_records, REC_EXPLICIT_SUBSET_DIFF)) .or_else(|| mkb_find_body(mkb_records, REC_MEDIA_KEY_DATA)) @@ -672,11 +659,9 @@ mod tests { #[test] fn calc_pk_from_dk_terminates_on_nonconvergent_mask() { - // Regression for the unbounded-loop hang: pick a (dev_key_v_mask, - // v_mask) pair the arithmetic `>> 1` walk can never reconcile. - // dev_key_v_mask has the MSB set, so `>> 1` sign-extends and the - // mask saturates at 0xFFFF_FFFF, never reaching a coarser v_mask. - // The 32-step bound must let this return rather than spin forever. + // Regression for the unbounded-loop hang: dev_key_v_mask has the MSB + // set, so the `>> 1` walk sign-extends and saturates at 0xFFFF_FFFF, + // never reaching v_mask. The 32-step bound must still return. let dk = [0x11u8; 16]; let pk = calc_pk_from_dk(&dk, 0x0000_0002, 0x0000_0000, 0xFFFF_FFFE); // Bounded exit yields *some* key; we only assert it terminated. @@ -844,9 +829,8 @@ mod tests { mkb.extend_from_slice(&[0x04, 0x00, 0x00, 0x09]); mkb.extend_from_slice(&[0x03, 0x00, 0x00, 0x00, 0x02]); - // Pick a known DK; with dk.uv == MKB.uv (==2) and - // dk.u_mask_shift == MKB.u_mask_shift (==3), dev_key_v_mask - // equals the MKB's v_mask and the calc_pk_from_dk loop is a + // Known DK with dk.uv == MKB.uv (==2) and dk.u_mask_shift == MKB's + // (==3): dev_key_v_mask == MKB's v_mask, so calc_pk_from_dk is a // no-op — Kp = aesg3(dk, 1). let dk_bytes: [u8; 16] = [ 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, @@ -862,12 +846,9 @@ mod tests { aes_d_result[15] ^= 0x02; let c_block = aes_ecb_encrypt(&kp, &aes_d_result); - // cvalues record (0x07): the per-SD C the chain reads for `Kmp`. This - // fixture has no `0x0c`, so both the walk and the chain fall back to - // `0x07` — plant the computed `c_block` HERE so `AES-D(Kp, C) XOR uv == - // Kmp` and the chosen `kmp15` bit lands. On a variant MKB the per-match - // magic check fails, but `variant_present` is true, so the walk still - // returns the match. + // cvalues record (0x07): no `0x0c` here, so walk and chain both fall + // back to `0x07` — plant `c_block` so AES-D(Kp,C)^uv == Kmp. Magic + // check fails on variant MKBs, but `variant_present` lets it match. mkb.extend_from_slice(&[0x07, 0x00, 0x00, 0x14]); mkb.extend_from_slice(&c_block); @@ -939,14 +920,9 @@ mod tests { #[test] fn walk_mkb_be24_middle_byte_is_honored() { - // A record longer than 255 bytes needs the MIDDLE BE24 byte: total - // length 0x00_0110 (272) is `[0x00, 0x01, 0x10]`, so a parser reading - // only the low byte sees 0x10. The HIGH byte of this length is zero, so - // this test says nothing about the `<< 16` term — that is pinned - // separately by `mkb::tests::mkb_records_honors_the_high_byte_of_the_be24_length`, - // which uses a 0x01_0004 record. (Renamed from - // `walk_mkb_be24_high_byte_is_honored`, which claimed coverage this body - // does not deliver.) + // Needs the MIDDLE BE24 byte: length 0x00_0110 (272) is [0x00,0x01,0x10], + // so a low-byte-only reader sees 0x10. HIGH byte is zero here; that term + // is pinned by `mkb::tests::mkb_records_honors_the_high_byte_of_the_be24_length`. let total = 0x0110usize; // 272 let mut mkb = vec![0x10, 0x00, 0x01, 0x10]; mkb.resize(total, 0xAB); @@ -1049,10 +1025,9 @@ mod tests { #[test] fn chain_yields_no_key_for_non_covering_pk() { - // A complete variant MKB but a Processing Key that covers no slot → no - // Km verifies → an error (never a key). A non-covering key resolves to - // ProcessingKeyUnavailable, or to a correction-mode classification if its - // Kmp happens to set the soft/online bit — either way, no key is emitted. + // Complete variant MKB, but PK covers no slot → no Km verifies → error. + // Resolves to ProcessingKeyUnavailable, or a correction-mode + // classification if Kmp sets the soft/online bit; either way, no key. let (recs, _dk, _, _) = synthetic_variant_setup(0x00); let out = derive_media_key_variant(&recs, &[0x11; 16]); assert!(out.is_err(), "non-covering PK must not yield a Media Key"); @@ -1062,9 +1037,8 @@ mod tests { #[test] fn chain_reports_mkb_incomplete_when_nonce_missing() { - // Build a variant MKB (still variant via 0x2f, and a DK can walk it) - // but WITHOUT the 0x2d record that carries C + the trailing Nonce → - // MkbIncomplete at the variant_nonce `?`. + // Variant MKB (still variant via 0x2f, DK can walk it) but WITHOUT the + // 0x2d record carrying C + Nonce → MkbIncomplete at variant_nonce `?`. let (recs, _dk, kp, _) = synthetic_variant_setup(0x00); // Reconstruct bytes without the 0x2d record. let mut mkb = Vec::new(); @@ -1088,10 +1062,9 @@ mod tests { #[test] fn walk_processing_key_skips_shift_32_to_63_without_panic() { - // A subset-difference u_mask_shift in 0x20..=0x3F passes the 0xC0 - // revoke check but is out of range for a u32 shift. The walk must skip - // the slot (continue) and not panic / not match a wrong uv. With only - // that one bad slot, no match → None. + // u_mask_shift in 0x20..=0x3F passes the 0xC0 revoke check but is out + // of u32-shift range. Walk must skip the slot without panicking; with + // only this bad slot present, result is None. let mut mkb = vec![ 0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D, ]; @@ -1211,22 +1184,9 @@ mod tests { assert_eq!(err, MediaKeyVariantError::SoftCorrectionRequired); } - // ════════════════════════════════════════════════════════════════════ - // A COMPLETE variant MKB — the AACS 2.1 happy path - // - // Every other test in this module asserts an ERROR classification, so - // until now no test ever drove `derive_media_key_variant` to a Media - // Key. That left the whole success path — the VARIANTS lookup, the VKD - // selection, the final `Km` unwrap and the verify gate — pinned by - // nothing: a body that answered a constant for any of those steps still - // produced the same errors these tests expect. - // - // No real key material is involved. Every AACS 2.1 relation in the chain - // is invertible, so the fixture below picks a Media Key and a Processing - // Key and computes the MKB records that connect them, exactly as - // `derive::position_recovery_tests::plant_mkb` does for the classical - // chain. - // ════════════════════════════════════════════════════════════════════ + // A COMPLETE variant MKB — the AACS 2.1 happy path. Every other test here + // asserts an ERROR, leaving the success path (VARIANTS/VKD lookup, Km + // unwrap, verify gate) unpinned; fixture inverts the chain, no real keys. /// A planted variant MKB and the values it was built from. struct PlantedVariant { @@ -1278,11 +1238,9 @@ mod tests { 0x2B, 0x7E, 0x15, 0x16, 0x28, 0xAE, 0xD2, 0xA6, 0xAB, 0xF7, 0x15, 0x88, 0x09, 0xCF, 0x4F, 0x3C, ]; - // `uv = 2` puts its only non-zero byte at index 15, so byte 15 is the ONE - // position where the `Km`/`Kmp` uv-XOR is observable. Its 0x02 bit is - // deliberately CLEAR: with the bit set, `km[15] ^= 2` and `km[15] |= 2` - // agree (the XOR would only be clearing a bit the OR re-sets) and an - // OR-for-XOR substitution in the final step would be invisible. + // `uv = 2` has its only non-zero byte at index 15, the one position where + // the uv-XOR is observable. Its 0x02 bit is deliberately CLEAR: if set, + // `^= 2` and `|= 2` would agree, hiding an OR-for-XOR substitution bug. let km: [u8; 16] = [ 0xC0, 0xC1, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xCB, 0xCC, 0xCD, 0xCE, 0xCD, @@ -1613,9 +1571,8 @@ mod tests { ); // Wrong C block: EVERY one-bit neighbour must fail to produce a key. - // (Which classification it lands in depends on the two condition bits - // the perturbed Kmp happens to carry — the property being pinned is - // that none of the 128 reaches `Ok`.) + // Classification varies with the perturbed Kmp's condition bits; the + // property pinned here is that none of the 128 reaches `Ok`. for byte in 0..16usize { for bit in 0..8u32 { let mut c_bad = p.c_block; @@ -1687,20 +1644,9 @@ mod tests { ); } - // ════════════════════════════════════════════════════════════════════ - // A MULTI-SLOT variant MKB driven by a real DEVICE KEY - // - // `walk_processing_key` is the DK -> Kp step that feeds the whole 2.1 - // chain. Every existing test of it either asserts `None` (out-of-range - // shift, uv == 0) or asserts only that SOME match came back — none pins - // WHICH Processing Key, cvalue or slot index it returns. And every one of - // them uses a SINGLE-slot MKB, where the slot index is 0: all the - // `uvs[1 + 5*idx]` / `cvalues[idx*16..]` stride arithmetic multiplies by - // zero and any stride at all gives the same answer. - // - // This fixture puts the covering slot at index 1, behind a decoy at - // index 0, so the strides are load-bearing. - // ════════════════════════════════════════════════════════════════════ + // A MULTI-SLOT variant MKB driven by a real DEVICE KEY. Prior tests used + // single-slot MKBs (index 0), where stride arithmetic multiplies by zero; + // this puts the covering slot at index 1 behind a decoy, so strides matter. /// A two-slot variant MKB whose SECOND slot is opened by a device key. struct PlantedWalk { @@ -1718,7 +1664,7 @@ mod tests { /// Positions follow the same reasoning as the classical /// `derive::position_recovery_tests::plant_mkb`: `uv = 0x0400` /// (`u_mask_shift = 12`) with a device node of `0x0C00` satisfies the - /// [C] §3.2.4 gate — equal under `u_mask = 0xFFFF_F000`, different under + /// `[C]` §3.2.4 gate — equal under `u_mask = 0xFFFF_F000`, different under /// `v_mask = 0xFFFF_F800`. The device key's own `uv` equals the slot's, so /// `dev_key_v_mask == v_mask` and [`calc_pk_from_dk`] descends zero levels: /// `Kp = AES-G3(dk, 1)`, written out explicitly below rather than taken from @@ -1763,10 +1709,9 @@ mod tests { vd[..8].copy_from_slice(&VERIFY_MAGIC); let mk_dv = aes_ecb_encrypt(&km, &vd); - // ── C blocks. Both are built so `Kmp[15]` has the 0x02 / 0x04 condition - // bits CLEAR, so both slots run the default-KCD path to completion and - // the decoy is rejected by the terminal verify gate rather than - // short-circuiting into a correction-mode classification. + // C blocks: both keep `Kmp[15]`'s 0x02/0x04 bits CLEAR, so both slots + // run the default-KCD path and the decoy is rejected by the terminal + // verify gate instead of short-circuiting into correction-mode. let c_for = |kmp: &[u8; 16], uv: u32| -> [u8; 16] { let mut c_plain = *kmp; for (b, u) in c_plain[12..16].iter_mut().zip(uv.to_be_bytes()) { @@ -1864,7 +1809,7 @@ mod tests { /// them — and produced a Processing Key that opens nothing. /// /// The expected `Kp` is written as the explicit `AES-G3(dk, 1)` zero-descent - /// relation from [C] §3.2.4, not taken from the walk's own output. + /// relation from `[C]` §3.2.4, not taken from the walk's own output. #[test] fn walk_processing_key_returns_the_covering_slots_key_cvalue_and_index() { let p = plant_walk_variant_mkb(); @@ -1893,7 +1838,7 @@ mod tests { ); } - /// The gate the walk applies is [C] §3.2.4's subset-difference test, and a + /// The gate the walk applies is `[C]` §3.2.4's subset-difference test, and a /// device key that fails it must get NO match. Pinned across all four /// coordinates the gate reads — node, uv, u_mask_shift and the key bytes — /// because a body that dropped any half of the gate would hand back a @@ -2000,7 +1945,7 @@ mod tests { } /// The classical-magic escape hatch. On a NON-variant MKB the walk must - /// return a match only when `AES-D(Kmp, mk_dv)` opens with the [C] §3.2.5.1.4 + /// return a match only when `AES-D(Kmp, mk_dv)` opens with the `[C]` §3.2.5.1.4 /// verify magic; on a variant MKB that relation does not hold (the walk /// yields a Precursor) and the presence of `0x2d`/`0x2f` is what lets the /// match through to the chain's own terminal gate. @@ -2036,7 +1981,7 @@ mod tests { /// The OTHER half of `classical_ok || variant_present`: a non-variant MKB /// whose cvalue really does open the Verify-Media-Key magic must yield a - /// match, and the [C] §3.2.4 relation that produces the candidate — AES-D(Kp, + /// match, and the `[C]` §3.2.4 relation that produces the candidate — AES-D(Kp, /// cvalue) with `uv` XORed into the LOW FOUR BYTES — must be computed /// exactly. /// @@ -2059,11 +2004,9 @@ mod tests { ]; // Zero descent ([C] §3.2.4), written out as the primitive relation. let kp = aesg3(&dkey, 1); - // `uv = 0x0400` puts its only non-zero byte at index 14, so byte 14 is - // the ONE position where the `uv` XOR is observable at all. Its 0x04 bit - // is deliberately CLEAR here: with the bit set, `km_candidate[14] |= - // 0x04` and `^= 0x04` agree (the XOR would only be clearing a bit the OR - // re-sets), and an OR-for-XOR substitution would be invisible. + // `uv = 0x0400` has its only non-zero byte at index 14, the one position + // where the uv XOR is observable. Its 0x04 bit is CLEAR: if set, `|= 0x04` + // and `^= 0x04` would agree, hiding an OR-for-XOR substitution bug. let mk: [u8; 16] = [ 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x7B, 0x7C, 0x7D, 0x7A, 0x7F, diff --git a/src/bdnav/vm.rs b/src/bdnav/vm.rs index b55d34e1..87fec281 100644 --- a/src/bdnav/vm.rs +++ b/src/bdnav/vm.rs @@ -111,11 +111,9 @@ pub(crate) fn resolve( mobjs: &[MovieObject], is_feature: &dyn Fn(u16) -> bool, ) -> Option { - // Enter at First-Play when it is an HDMV object that exists; otherwise - // abstain (BD-J boundary, or a `0xffff` "no object"). We deliberately do not - // chase the Top-Menu program here — its "Play Movie" target usually lives in - // the IG-stream button navigation commands inside the menu `.m2ts`, not in - // `MovieObject.bdmv` (future work). + // Enter at First-Play when it is an HDMV object that exists; otherwise abstain + // (BD-J boundary, or `0xffff` "no object"). We don't chase Top-Menu's "Play + // Movie" target here — it lives in menu .m2ts IG button commands (future work). let start = match index.first_play { PlaybackObj::Hdmv { id_ref } if (id_ref as usize) < mobjs.len() => id_ref as usize, _ => return None, @@ -379,11 +377,9 @@ mod tests { #[test] fn set_to_psr_is_refused() { - // A SET MOVE to PSR6 must be a no-op (PSR stores are refused by spec). - // Program: MOVE PSR6 <- 5; CMP PSR6 == 5; PlayPL 100 else 200. - // With the write refused, PSR6 stays at its power-on 0, so the compare is - // false and control skips to PlayPL 200. If the illegal PSR write took - // effect, the compare would be true and PlayPL 100 would win. + // SET MOVE to PSR6 must be a no-op (PSR stores are refused by spec). + // Program: MOVE PSR6<-5; CMP PSR6==5; PlayPL 100 else 200. Refused write + // keeps PSR6 at 0 -> PlayPL 200; an illegal write would wrongly pick 100. let set_psr6 = cmd((2 << 5) | (2 << 3), 0x40, 0, 0x01, 0x8000_0006, 5); let cmp_psr6_eq_5 = cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, 0x8000_0006, 5); let d = build(&[&[set_psr6, cmp_psr6_eq_5, play_pl(100), play_pl(200)]]); @@ -448,11 +444,9 @@ mod tests { #[test] fn swap_immediate_operand_is_not_written_back() { - // Seed GPR[5]=77, then SWAP dst=GPR0 (register) with src=IMMEDIATE 5 - // (imm_op1=0, imm_op2=1). The imm gate must refuse using the immediate - // as a write target, so GPR[5] — the register the literal 5 would alias - // — keeps 77; only GPR0 receives the immediate. Dropping either - // `if !c.imm_op1` / `if !c.imm_op2` guard makes an assertion fail. + // SWAP dst=GPR0 with src=IMMEDIATE 5 (imm_op1=0, imm_op2=1) must refuse + // writing to the aliased register: GPR[5] stays 77, only GPR0 gets 5. + // Dropping the `imm_op1`/`imm_op2` guard makes this assertion fail. let swap_imm = cmd((2 << 5) | (2 << 3), 0x40, 0, 0x02, 0, 5); let d = build(&[&[set_move_gpr(5, 77), swap_imm]]); let mobjs = mobj::parse(&d).unwrap(); diff --git a/src/clpi.rs b/src/clpi.rs index 8e994ed6..7b0f70e0 100644 --- a/src/clpi.rs +++ b/src/clpi.rs @@ -55,10 +55,9 @@ pub fn parse(data: &[u8]) -> Result { 0 }; - // Parse ProgramInfo (per-stream language + codec). Best-effort: - // malformed program_info doesn't fail the parse, just gives an - // empty streams list. EP map is unaffected — sector-range lookups - // continue to work. + // Parse ProgramInfo (per-stream language + codec), best-effort: malformed + // program_info doesn't fail the parse, just yields an empty streams list. + // EP map is unaffected — sector-range lookups still work. let streams = if prog_info_start > 0 && prog_info_start + 6 < data.len() { parse_program_info(&data[prog_info_start..]) } else { @@ -172,16 +171,9 @@ mod tests { /// Build a minimal CLPI binary. /// `cpi_data` is the raw CPI section bytes (starting with the 4-byte CPI length). fn build_clpi(source_packet_count: u32, cpi_data: Option<&[u8]>) -> Vec { - // We need at least 60 bytes for the header area. - // Offsets: - // 0..4: "HDMV" - // 4..8: "0200" - // 8..12: seq_info_start (unused, set to 0) - // 12..16: prog_info_start (unused, set to 0) - // 16..20: cpi_start - // 20..40: reserved/padding - // 40..56: ClipInfo section area (length + stuff before source_packet_count) - // 56..60: source_packet_count + // Need at least 60 bytes for the header area: "HDMV"/"0200" magic, + // seq_info_start/prog_info_start (unused here), cpi_start, reserved + // padding, then the ClipInfo section ending in source_packet_count. let cpi_start: u32 = if cpi_data.is_some() { 60 } else { 0 }; diff --git a/src/css/keyless.rs b/src/css/keyless.rs index 88d58997..9dd327be 100644 --- a/src/css/keyless.rs +++ b/src/css/keyless.rs @@ -224,10 +224,9 @@ pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> { return None; } - // Runaway guard: a single sector's crack is a bounded 2^16 LFSR search and - // should finish in well under a second on any modern CPU. If it ever - // exceeds ~2s wall-clock, something pathological is happening — log it so a - // hang is never silent. + // Runaway guard: this crack is a bounded 2^16 LFSR search, done well under 1s + // on any modern CPU. If it ever exceeds ~2s, something pathological is + // happening — log it so a hang is never silent. let crack_t0 = std::time::Instant::now(); let result = crack_title_key_inner(sector); @@ -280,21 +279,13 @@ pub(crate) fn attack_crib(sector: &[u8]) -> Option<[u8; 10]> { // Need at least a few repeated bytes and at least one full cycle. if best_plen > 3 && best_p > 0 && best_plen / best_p >= 2 { - // The known plaintext is the periodic run continuing past 0x80. The - // crib starts at `0x80 - (best_plen/best_p)*best_p` and continues - // through the encrypted region; the bytes at and after 0x80 are the - // predicted plaintext (the pattern repeats with period best_p). + // Crib starts at `0x80 - cycles*best_p`; bytes at/after 0x80 are + // predicted by the run repeating with period best_p. let cycles = best_plen / best_p; let plain_start = 0x80 - cycles * best_p; - // Each predicted byte is the run sample one or more periods back: - // `sec[plain_start + (i % best_p)]`. For in-run offsets - // (`plain_start + i < 0x80`) the run is exactly periodic, so this - // equals `sec[plain_start + i]`; for offsets at/after 0x80 the raw - // byte is ciphertext, so we MUST wrap within the period rather than - // read it. (Reading `&sec[plain_start..+10]` directly — as before — - // pulled ciphertext into the crib whenever the run covered fewer than - // 10 bytes before 0x80, producing false-negative key recovery.) + // Must wrap within the period (not read `&sec[plain_start..+10]` + // directly), since past 0x80 the raw byte is ciphertext, not plaintext. let mut plain = [0u8; 10]; for (i, p) in plain.iter_mut().enumerate() { *p = sector[plain_start + (i % best_p)]; @@ -359,14 +350,9 @@ mod tests { let mut plaintext = vec![0u8; SECTOR_BYTES]; plaintext[FLAG_BYTE] = 0x10; - // A clean periodic run occupying the tail of the cleartext header - // (RUN_START..0x80) and continuing into the encrypted region. This - // mirrors a real VOB: a periodic data run just before the scrambled - // part. The run must NOT overlap the seed bytes (0x54..0x59), or the - // the crib detector would break mid-run. The phase is anchored to - // offset 0 so the run is consistent across the 0x80 boundary. - // Just above the seed (0x54..0x59); gives a 39-byte run (0x59..0x80) - // — enough for >=2 cycles of every tested period (<=19). + // Periodic run over the cleartext header tail (RUN_START..0x80) into + // the encrypted region, mirroring a real VOB. Must not overlap the seed + // bytes (0x54..0x59); RUN_START=0x59 gives >=2 cycles of every period. const RUN_START: usize = 0x59; let pat: Vec = (0..period) .map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A) @@ -754,11 +740,9 @@ mod tests { None, "scramble bits clear → no crack, even though one would succeed" ); - // `attack_crib` carries its own copy of the same gate, and it is the one - // that actually stops the crack (`crack_title_key`'s is defensive - // duplication). The crib doubles as the decrypt path's cached-key - // oracle, so a widened mask there would hand that path a "predicted - // plaintext" for sectors that were never scrambled. + // `attack_crib` carries its own copy of the same gate and is the one that + // actually stops the crack (`crack_title_key`'s is defensive duplication); + // it also doubles as the decrypt path's cached-key oracle. assert_eq!( attack_crib(&periodic), None, @@ -845,14 +829,9 @@ mod tests { ); } - // ── attack_crib: known-answer vectors ────────────────────────────────── - // - // `attack_crib` is BOTH the cracker's known plaintext and the decrypt - // path's "did the cached key descramble correctly?" oracle. Until now it - // was only ever exercised end-to-end through `crack_title_key`, on a - // fixture whose periodic run covered 39 bytes (0x59..0x80) — long enough - // that the run start, the cycle count and the `i % best_p` wrap were all - // slack. A crib that silently drifts costs a rip its title key. + // ── attack_crib: known-answer vectors ────────────────────── + // Both the cracker's known plaintext and the decrypt path's cached-key + // oracle; was only exercised indirectly, leaving run/cycle/wrap slack. /// Build a sector whose clear header ends in a `period`-length repeating /// run of exactly `run_len` bytes immediately before 0x80. @@ -1011,10 +990,8 @@ mod tests { for b in sector[ENCRYPTED_START..].iter_mut() { *b = 0xFF; } - // The FLAG byte sits inside the header at 0x14, so it interrupts the - // pattern there; re-lay it and accept that 0x14 breaks the run — the - // scan still reaches offset 0x15 - 1 = 0x14 going backwards, i.e. - // j = 0x7f - 0x14 = 0x6b, well short of the bound. Instead put the + // The FLAG byte at 0x14 would interrupt the pattern, but j only needs to + // reach 0x7f - 0x14 = 0x6b, well short of the bound. So instead put the // scramble flag bits into a byte value that IS the pattern's. sector[FLAG_BYTE] = pat[FLAG_BYTE % period]; assert_ne!( @@ -1081,11 +1058,9 @@ mod tests { "{n} plaintext bytes is fewer than the ten the cipher iterates" ); } - // Exactly ten of each is ACCEPTED as far as the search — the boundary is - // `< 10`, not `<= 10`. (Whether this particular keystream has a seed is - // immaterial; what must not happen is an early `None` from the guard.) - // Proven through the round-trip fixture, whose inputs are exactly ten - // bytes and which does recover its key. + // Exactly ten of each is ACCEPTED — the boundary is `< 10`, not `<= 10`. + // What must not happen is an early `None` from the guard; proven via the + // round-trip fixture, whose inputs are exactly ten bytes. let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF]; let (sector, _) = synth_sector(&title_key, &seed, &PES); assert_eq!( diff --git a/src/css/mod.rs b/src/css/mod.rs index fa34893d..475da612 100644 --- a/src/css/mod.rs +++ b/src/css/mod.rs @@ -167,9 +167,8 @@ pub(crate) fn resolve_dvd_title_key( halt: Option<&crate::halt::Halt>, ) -> std::io::Result<()> { // `--raw` = deliberate ciphertext passthrough: never crack or descramble, and - // never hard-fail on scrambled-uncrackable — the user asked for the scrambled - // bytes. (In raw mode the caller hands us `None` on purpose; without this - // guard we'd install a real key and silently DECRYPT, or abort a raw mux.) + // never hard-fail on scrambled-uncrackable. Caller hands us `None` on + // purpose; without this guard we'd silently DECRYPT or abort a raw mux. if raw { return Ok(()); } @@ -179,12 +178,9 @@ pub(crate) fn resolve_dvd_title_key( // `halt` threads the caller's cancellation token so /api/stop can // interrupt a long crack scan (the old scan-time crack honored it too). let outcome = crack_key_outcome(reader, extents, batch_sectors, halt); - // A cancelled crack breaks out early, so its outcome is a TRUNCATED scan - // — not a real verdict. Interpreting it would either hard-fail a good disc - // as `ScrambledUncracked` (quarantining staging on a Stop) or, worse, - // read a half-scanned title as `Unencrypted` and mux scrambled bytes as - // plaintext. Surface the cancellation as `Halted` so the caller takes its - // graceful-stop path instead of trusting the partial outcome. + // A cancelled crack's outcome is a TRUNCATED scan, not a real verdict — + // surface it as `Halted` rather than trusting a partial `Unencrypted` + // or `ScrambledUncracked` and taking the wrong path. if halt.map(|h| h.is_cancelled()).unwrap_or(false) { return Err(crate::error::Error::Halted.into()); } @@ -213,13 +209,9 @@ fn crack_key_scan( batch_sectors: u16, halt: Option<&crate::halt::Halt>, ) -> CrackOutcome { - // Batch the reads: a live optical drive at 1 sector/read is glacial, and the - // crack only needs to FIND one scrambled sector whose 0x80 plaintext matches - // a known PES header. `batch_sectors` MUST be sized to the source — a drive - // rejects a READ(10) larger than its per-command max (DVD = 16) and - // `Drive::read` does not chunk, so an over-large batch fails every read and - // scans nothing. Callers pass `detect_max_batch_sectors(device_path)` for a - // live drive, a file-safe value for an image, or 1 to force per-sector. + // Batch the reads: a live drive at 1 sector/read is glacial. `batch_sectors` + // MUST be sized to the source — a drive rejects a READ(10) larger than its + // per-command max, and `Drive::read` does not chunk an over-large batch. let batch = (batch_sectors.max(1)) as u32; // Record the LBA span the key is being cracked from so the per-title mux // path can tell whether a later title lives in the same VTS (overlaps the @@ -233,18 +225,13 @@ fn crack_key_scan( let max_tries = 50_000u32; let mut buf = vec![0u8; batch as usize * 2048]; let mut hb = crate::progress::Heartbeat::new("css_crack"); - // Track whether ANY scrambled sector was observed. If we exhaust the scan - // budget having seen scrambled data but never recovered a key, the content - // is encrypted-but-uncrackable — a HARD failure the caller must surface, - // NOT silently treat as unencrypted (which would mux scrambled MPEG as - // plaintext → garbage at exit 0). See `CrackOutcome::ScrambledUncracked`. + // Track whether ANY scrambled sector was observed: if the budget is + // exhausted with scrambled data seen but no key recovered, that's a HARD + // failure, not "unencrypted". See `CrackOutcome::ScrambledUncracked`. let mut saw_scrambled = false; - // A read rejected with sense `05/6F/03` ("scrambled sector without - // authentication") is positive proof of CSS encryption — never collapse it - // to "unencrypted". A run of consecutive locked reads means the bus-auth - // gate is shut (it is global, so reads are all-or-nothing), so the scan - // early-bails. `consecutive_locked` resets on any readable batch, so a - // crackable title (gate open) never trips it. + // Sense `05/6F/03` is positive proof of CSS encryption. Consecutive locked + // reads mean the (global) bus-auth gate is shut, so the scan early-bails; + // this resets on any readable batch, so an open-gate title never trips it. let mut saw_locked = false; let mut consecutive_locked = 0u32; @@ -273,49 +260,32 @@ fn crack_key_scan( } let n = (ext.sector_count - i).min(batch); let want = n as usize * 2048; - // How far the cursor advances. Set from the bytes actually READ on - // the Ok path so a short read is RETRIED from where it stopped - // rather than skipped: bounding only the inspection (which is what - // stops stale buffer bytes being scanned) would otherwise leave - // those sectors unexamined, quietly shrinking the crack's coverage - // on exactly the damaged media where a key is hardest to find. + // Set from bytes actually READ on the Ok path so a short read is + // RETRIED from where it stopped, not skipped — else those sectors + // go unexamined on exactly the damaged media a key needs most. let mut advance = n; match reader.read_sectors(ext.start_lba + i, n as u16, &mut buf[..want], true) { Ok(got) => { // A readable batch: the gate is open — reset the locked run. consecutive_locked = 0; - // Inspect only what was actually READ. The trait returns the - // byte count and a source may return Ok with fewer bytes - // than asked (a recovery read over a damaged region), while - // `buf` is reused across batches — so the tail still holds - // the PREVIOUS batch's sectors. Scanning those means - // cracking a key from data belonging to a different extent, - // and possibly a different VTS, while `crack_span` records - // the CURRENT one: a key that opens nothing here gets - // installed and its wrong descrambles are only partly caught - // by the per-sector crib. + // Inspect only what was READ: `buf` is reused across + // batches, so its tail may hold the PREVIOUS batch's + // sectors, risking a key crack for the wrong extent/VTS. let usable = (got / 2048).min(n as usize); // At least one, so a source returning Ok(0) cannot spin here. advance = (usable as u32).max(1); if usable == 0 { - // Nothing was inspected, so the per-sector `tried` - // charge below never runs — but the cursor still moves - // one sector (the `.max(1)` above). Charge that sector - // to the budget, or `tried` stays frozen and the loop - // is bounded only by the disc-declared - // `ext.sector_count`: the anti-grind budget stops - // applying to exactly the misbehaving source it exists - // for. Mirrors the `Err` arm's `tried += n`, which - // likewise charges an uninspected advance. + // Nothing inspected, but the cursor still moves one + // sector — charge it here or `tried` stays frozen + // (mirrors the `Err` arm's `tried += n`). tried += 1; } for s in 0..usable { tried += 1; let sect = &buf[s * 2048..(s + 1) * 2048]; - // Use the HARDENED pack-gated check (Fix 3): a clear stub - // sector with stray bits at 0x14 must NOT count as - // scramble evidence, or a genuinely-unencrypted title - // would falsely report ScrambledUncracked (a false E7023). + // HARDENED pack-gated check: a clear stub sector with + // stray bits at 0x14 must NOT count as scramble evidence, + // or an unencrypted title falsely reports E7023. if is_scrambled_pack(sect) { saw_scrambled = true; if let Some(key) = keyless::crack_title_key(sect) { @@ -331,9 +301,8 @@ fn crack_key_scan( } } // A failed batch still counts toward the budget so a damaged - // region can't loop forever. A CSS-locked failure (`05/6F/03`) - // proves encryption and, in a long enough run, means the read - // gate is shut — track it and early-bail rather than grind. + // region can't loop forever. A CSS-locked failure proves + // encryption; a long enough run means the gate is shut. Err(e) => { tried += n; if e.scsi_sense().is_some_and(|s| s.is_css_locked()) { @@ -351,29 +320,9 @@ fn crack_key_scan( } } - // Budget exhausted / extents walked / early-bailed with no key recovered. - // The disc is ENCRYPTED-but-uncracked (a hard failure) when EITHER a - // scrambled sector was actually seen, OR every read was CSS-locked - // (`05/6F/03`), itself proof of scrambling. Only a scan that saw neither a - // scrambled sector nor a CSS-lock is genuinely unencrypted. - // - // A prior revision made this conditional on a caller-supplied - // `fail_on_locked: bool`, documented as "false on the per-VTS re-crack, so - // a lapsed-AGID locked read returns None instead of killing a genuinely - // crackable title." That parameter never had an observable effect: its - // ONLY non-test caller with `false` was [`crack_key`], whose `Option` - // return collapses `ScrambledUncracked` and `Unencrypted` alike to `None` - // via [`CrackOutcome::into_state`] — so the branch this comment describes - // was unreachable from the moment it was introduced (see the crate's audit - // notes for the git-archaeology). The one production caller that DOES - // observe the `Cracked` / `Unencrypted` / `ScrambledUncracked` split for a - // per-VTS re-crack (`Disc::decrypt_keys_for_title`) has always gone - // through [`crack_key_outcome`], which hardcoded this to always-hard-fail. - // Reconnecting the soft variant there would mean a locked read on a live - // drive gets reported as `Unencrypted` (`title_is_clear = true`) and the - // title is muxed with NO key — precisely the silent-garbage failure mode - // `CrackOutcome` exists to prevent. So the parameter is removed rather - // than revived: the scan is unconditionally hard-fail-on-locked. + // ENCRYPTED-but-uncracked (hard failure) if a scrambled sector was seen or + // every read was CSS-locked; only a scan seeing neither is unencrypted. A + // prior "soft" `fail_on_locked` toggle was removed as dead/unsafe. if saw_scrambled || saw_locked { CrackOutcome::ScrambledUncracked } else { @@ -429,7 +378,7 @@ pub fn descramble_sector(state: &CssState, sector: &mut [u8]) { /// seam it is dispatched from, and the `usize` is that seam's legacy /// always-zero loss count (see [`crate::decrypt::decrypt_sectors`]). /// -/// This section used to document an [`Error::DecryptFailed`] for the case where +/// This section used to document an [`Error::DecryptFailed`](crate::Error::DecryptFailed) for the case where /// a sector's crib rejects the cached key and the re-crack from that sector also /// fails. That behaviour was tried and REVERTED, for the reason set out at the /// `None =>` arm below: crib mismatch plus crack failure is the signature of a @@ -437,20 +386,9 @@ pub fn descramble_sector(state: &CssState, sector: &mut [u8]) { /// unrippable. The arm descrambles with the cached key and returns `Ok`. pub fn descramble_region(buf: &mut [u8], title_key: &mut [u8; 5]) -> crate::error::Result { for chunk in buf.chunks_mut(2048) { - // `is_scrambled_pack`, NOT the looser `is_scrambled`. The raw flag test - // is only safe once a caller has committed to a title's VOB data, where - // every sector is a pack and byte 0x14 always means what it says. This - // function is handed arbitrary regions of a disc, so it also sees IFO, - // UDF and ISO 9660 sectors — raw structures where byte 0x14 is whatever - // that format stores there. - // - // Measured: `VIDEO_TS.IFO` on one disc holds 0x15 at offset 0x14 of its - // second sector. The raw test read bits 4-5 as "scrambled", descrambled - // it, and destroyed 1912 of 2048 bytes. That sector carries TT_SRPT, so - // the title table went with it: the disc enumerated 38 titles and an - // image decrypted from it enumerated 10, silently, at exit 0. Requiring - // the pack start code first costs nothing — a genuinely scrambled VOB - // sector always has it, and no IFO sector does. + // `is_scrambled_pack`, NOT the looser `is_scrambled`: this sees arbitrary + // regions (IFO/UDF/ISO 9660) where raw byte 0x14 isn't a reliable flag. + // Measured: an IFO misread this way was destroyed, dropping titles 38→10. if chunk.len() < 2048 || !is_scrambled_pack(chunk) { continue; } @@ -475,28 +413,9 @@ pub fn descramble_region(buf: &mut [u8], title_key: &mut [u8; 5]) -> crate::erro lfsr::descramble_sector(title_key, chunk); } None => { - // The re-crack found nothing. Descramble with the CACHED - // key anyway — it is the best available evidence, and this - // is very probably still the right key. - // - // `attack_crib` is a heuristic, not a proof. It finds a - // periodic run in the unscrambled header and predicts that - // the run continues past 0x80. When that prediction does - // not hold, the crib check reports a mismatch even though - // the cached key is correct, and the re-crack from this - // sector then fails BECAUSE the crib was never valid. - // Crib mismatch + crack failure is therefore the signature - // of a crib false positive, not of a stale key. - // - // Round 9 read this as "descrambling with a key we just - // proved stale" and made it Error::DecryptFailed to match - // the AACS path. That was wrong on both counts: the key is - // not proven stale, and CSS is not AACS — an AACS unit key - // either opens a unit or does not, whereas a CSS title key - // is recovered from data whose recoverability varies sector - // by sector. Real DVDs hit this constantly; the change made - // a real disc unrippable and was caught by the real-media - // acceptance gate, not by any unit test. + // Re-crack found nothing; descramble with the CACHED key + // anyway — mismatch+failure signals a crib false positive, + // not a stale key (`DecryptFailed` here made discs unrippable). lfsr::descramble_sector(title_key, chunk); } } @@ -649,13 +568,9 @@ mod tests { let out = descramble_region(&mut sector, &mut key) .expect("a crib false positive must NOT fail the rip"); - // The "no loss term" contract belongs to the SEAM, not to this - // function: `decrypt_sectors`' `usize` is a legacy always-zero count - // that the CSS arm feeds from here. Asserting `out == 0` on - // `descramble_region` alone only restates its single `Ok(0)` return — - // a body replaced by `Ok(0)` satisfies it just as well. Assert it one - // level up, where the value is actually assembled and returned, so the - // arm dispatch and the plumbing are exercised too. + // "No loss term" belongs to the SEAM, not this function: assert it one + // level up (at `decrypt_sectors`) where the value is assembled and + // returned, so the arm dispatch and plumbing are exercised too. assert_eq!(out, 0); let mut seam_sector = sector; let mut seam_keys = crate::decrypt::DecryptKeys::Css { title_key: key }; @@ -950,13 +865,9 @@ mod tests { buf[base..base + 2048].copy_from_slice(sector); } _ => { - // Real DVD video sectors always open with the MPEG-PS - // pack-start code; `is_scrambled_pack` requires it before - // trusting the 0x14 scramble bits, so the fixture must - // include it for a `flag_byte` of 0x30 to register as - // scrambled. `stream_id` (byte 0x11) defaults to 0x00 (a - // scramblable pack); an HD-DVD RDI nav pack sets it to - // private_stream_2 (0xBF), which the 0x11 exclusion drops. + // `is_scrambled_pack` requires the MPEG-PS pack-start code + // before trusting 0x14; `stream_id` defaults to 0x00 + // (scramblable) vs. an RDI nav pack's excluded 0xBF. buf[base..base + 4].copy_from_slice(&PACK_START); buf[base + 0x11] = self.stream_id; buf[base + 0x14] = self.flag_byte; @@ -967,13 +878,9 @@ mod tests { } } - // ── Short reads: the branch nothing exercised ───────────────────────── - // - // `crack_key_scan` passes `recovery = true`, which is precisely the mode - // where a `SectorSource` may return Ok with fewer bytes than asked. Every - // source in this module returned the full request, so `usable`, `advance` - // and the `.max(1)` anti-spin guard were dead code under test: reverting - // `advance` to `n`, or dropping the `.max(1)`, left the whole suite green. + // ── Short reads: the branch nothing exercised ────────── + // `recovery = true` means a `SectorSource` may return Ok with fewer bytes + // than asked; no source here did, so `usable`/`advance`/`.max(1)` were dead. /// A short batch is RE-READ from where it stopped, not skipped. Skipping /// would quietly shrink the crack's coverage on exactly the damaged media diff --git a/src/css/tables.rs b/src/css/tables.rs index e05885c3..0d3d81e9 100644 --- a/src/css/tables.rs +++ b/src/css/tables.rs @@ -4,7 +4,8 @@ //! Content Scramble System. They are mathematical constants derived from //! the CSS specification, published in academic literature since 1999. -/// Table 1: byte substitution used in key mangling and sector seed processing. +/// Table 1: content-cipher substitution table, `plain = TAB1[cipher] ^ keystream` +/// (used by descramble/recover in lfsr.rs and keyless.rs). pub const TAB1: [u8; 256] = [ 0x33, 0x73, 0x3b, 0x26, 0x63, 0x23, 0x6b, 0x76, 0x3e, 0x7e, 0x36, 0x2b, 0x6e, 0x2e, 0x66, 0x7b, 0xd3, 0x93, 0xdb, 0x06, 0x43, 0x03, 0x4b, 0x96, 0xde, 0x9e, 0xd6, 0x0b, 0x4e, 0x0e, 0x46, 0x9b, @@ -88,7 +89,7 @@ pub const TAB3: [u8; 512] = [ 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, ]; -/// Table 4: LFSR0 byte permutation (used in initialization and output). +/// Table 4: permutes the LFSR0 output byte (`o0 = TAB4[o0]`). pub const TAB4: [u8; 256] = [ 0x00, 0x80, 0x40, 0xc0, 0x20, 0xa0, 0x60, 0xe0, 0x10, 0x90, 0x50, 0xd0, 0x30, 0xb0, 0x70, 0xf0, 0x08, 0x88, 0x48, 0xc8, 0x28, 0xa8, 0x68, 0xe8, 0x18, 0x98, 0x58, 0xd8, 0x38, 0xb8, 0x78, 0xf8, @@ -111,7 +112,7 @@ pub const TAB4: [u8; 256] = [ /// Table 5: LFSR1 output permutation used in the keystream combiner. /// `TAB5[i] == TAB4[i] ^ 0xFF` (bitwise complement of the TAB4 bit-reversal /// table). Applied on the normal descramble/recrypt path (lfsr.rs) as well as -/// in the key-recovery fallback (crack.rs). +/// in the key-recovery fallback ([`super::keyless`]). pub const TAB5: [u8; 256] = [ 0xff, 0x7f, 0xbf, 0x3f, 0xdf, 0x5f, 0x9f, 0x1f, 0xef, 0x6f, 0xaf, 0x2f, 0xcf, 0x4f, 0x8f, 0x0f, 0xf7, 0x77, 0xb7, 0x37, 0xd7, 0x57, 0x97, 0x17, 0xe7, 0x67, 0xa7, 0x27, 0xc7, 0x47, 0x87, 0x07, diff --git a/src/decrypt.rs b/src/decrypt.rs index 8bd0dde3..55a27e8c 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -72,14 +72,9 @@ static DECRYPT_POOL: RwLock>> = RwLock::new(None); pub fn set_decrypt_threads(n: usize) { let clamped = n.min(MAX_THREADS); DECRYPT_THREADS.store(clamped, Ordering::Relaxed); - // Drop the existing pool. Next decrypt_pool() call rebuilds with - // the new resolved thread count. - // - // Recover the guard on poisoning, exactly as `decrypt_pool` does. Skipping - // the swap on a poisoned lock silently kept the STALE pool alive while the - // atomic above already reported the new thread count, so the setting appeared - // to take effect and never did. The pool Arc is immutable once stored, so a - // prior panic cannot have left it half-written. + // Drop the existing pool so the next decrypt_pool() call rebuilds with the + // new thread count. Recover the guard on poisoning like `decrypt_pool` does — + // skipping the swap would keep the STALE pool alive, making the setting no-op. let mut guard = DECRYPT_POOL.write().unwrap_or_else(|e| e.into_inner()); *guard = None; } @@ -100,10 +95,9 @@ fn decrypt_pool() -> Option> { return Some(Arc::clone(pool)); } } - // Slow path: build a new one under the write lock. Recover the guard - // on poisoning (a prior panic) rather than propagating a secondary - // panic — we simply rebuild. Double-check after acquiring in case - // another caller built it first. + // Slow path: build a new one under the write lock, recovering the guard on + // poisoning (a prior panic) rather than propagating a secondary panic — we + // simply rebuild. Double-check after acquiring in case another caller won. let mut guard = DECRYPT_POOL.write().unwrap_or_else(|e| e.into_inner()); if let Some(pool) = guard.as_ref() { return Some(Arc::clone(pool)); @@ -128,10 +122,9 @@ pub fn decrypt_threads() -> usize { if explicit > 0 { return explicit; } - // Resolve the `FREEMKV_THREADS` env var + `available_parallelism()` ONCE and - // cache it — this runs on the per-buffer decrypt hot path, and a getenv + - // String alloc + parallelism syscall per call is pure overhead. The explicit - // `set_decrypt_threads` override above still takes effect dynamically. + // Resolve `FREEMKV_THREADS` + `available_parallelism()` ONCE and cache it: + // this runs on the per-buffer decrypt hot path, so a getenv/alloc/syscall + // per call is pure overhead (the `set_decrypt_threads` override still works). static DEFAULT_THREADS: std::sync::OnceLock = std::sync::OnceLock::new(); *DEFAULT_THREADS.get_or_init(|| { let env = std::env::var("FREEMKV_THREADS") @@ -208,11 +201,9 @@ fn unit_is_our_phase(unit_lba: u32, range_start: u32, unit_sectors: u32, phase: Phase::Even => false, Phase::Odd => true, }; - // `saturating_sub` and `max(1)`: both inputs come from the key map, which is - // built from disc structure. A unit below its own range start, or a zero - // unit size, means the map is malformed — that must not panic (debug - // overflow / divide-by-zero) inside a library used by a long-running - // service. Unit 0 of the range is even, which is the safe default. + // `saturating_sub`/`max(1)` guard against a malformed key map (unit below its + // range start, or zero unit size) — must not panic (overflow/div-by-zero) in + // a long-running service. Unit 0 of the range is even, the safe default. let unit_ix = unit_lba.saturating_sub(range_start) / unit_sectors.max(1); (unit_ix % 2 == 1) == want_odd } @@ -402,18 +393,6 @@ impl AacsKeyMap { } } -/// Decrypt a buffer of sectors in-place using a resolved [`AacsKeyMap`] — the -/// mux's TRUSTED decrypt. `base_lba` is the absolute LBA of `buf`'s first sector; -/// each aligned unit (3 sectors) is decrypted with the key the map assigns to its -/// LBA. There is NO key trial and NO `is_clean` verdict: the map already decided -/// the key from disc structure, so we apply it and move on — a unit that decrypts -/// to authored-bad TS passes through for the muxer to drop, never re-fetched. -/// -/// Only [`DecryptKeys::Aacs`] uses a map (CSS self-cracks per region inside -/// [`decrypt_sectors`]; `None` is clear) — other variants are a no-op here so the -/// decorator can dispatch uniformly. A map index outside the held pool is a -/// fail-loud [`Error::DecryptFailed`]: the resolver's job is to guarantee every -/// selectable index is present, so a gap here is a resolver bug, not silent loss. /// Decrypt `buf` with a resolved AACS key map. Thin wrapper over /// [`decrypt_span`] — the map is the AACS scheme's input, not a second /// orchestrator. @@ -427,12 +406,20 @@ pub(crate) fn decrypt_sectors_mapped( decrypt_span(buf, &mut keys, base_lba, Some(map), None).map(|_| ()) } -/// AACS scheme step: apply `map`'s per-unit keys to `buf`. +/// AACS scheme step: apply `map`'s per-unit keys to `buf`, in-place. `base_lba` +/// is the absolute LBA of `buf`'s first sector; each aligned unit (3 sectors) is +/// decrypted with the key the map assigns to its LBA. There is NO key trial and +/// NO `is_clean` verdict: the map already decided the key from disc structure, +/// so this applies it and moves on — a unit that decrypts to authored-bad TS +/// passes through for the muxer to drop, never re-fetched. /// /// A SCHEME, not a policy. It reports what it could not open by returning /// `Err(DecryptFailed)`; the decision that an unopenable unit must never be /// emitted belongs to [`decrypt_span`], which is the one place that decides it -/// for every scheme. +/// for every scheme. A map index outside the held pool is likewise a fail-loud +/// [`Error::DecryptFailed`](crate::Error::DecryptFailed): the resolver's job is +/// to guarantee every selectable index is present, so a gap here is a resolver +/// bug, not silent loss. fn apply_aacs_map( buf: &mut [u8], keys: &DecryptKeys, @@ -462,23 +449,16 @@ fn apply_aacs_map( } } - // Cheap safety net for the "map must be right" model: with a correct - // phase-aware map, every CORRECT-PHASE forensic unit decrypts to clean TS, so - // this never fires in the happy path — but a map bug (wrong phase/key for a - // segment) surfaces as a loud DecryptFailed instead of silent corruption. Only - // forensic (Even/Odd) ranges are verified; base / multi-CPS (All) stays - // trust-only, so the common disc is byte-for-byte unchanged. + // Cheap safety net: a correct map always decrypts CORRECT-PHASE forensic + // units to clean TS, so this never fires happy-path — a map bug surfaces as + // loud DecryptFailed, not silent corruption. Only forensic ranges are verified. let verify_failed = std::sync::atomic::AtomicBool::new(false); let decrypt_one = |idx_in_buf: usize, chunk: &mut [u8]| { if chunk.len() != unit_len { - // Trailing partial unit (buffer/region tail shorter than a whole unit). - // Normally a genuinely-clear content tail (source-zero padding or a - // short final fragment) — leave as-is. But a partial that is BOTH inside - // a mapped (encrypted) range AND flagged encrypted in its clear seed is - // an encrypted unit split across a boundary: a CBC fragment we cannot - // decrypt, so emitting it verbatim would ship ciphertext as clear. Fail - // loud instead (restores the guard the removed `decrypt_sectors` had). + // Trailing partial unit: normally a genuinely-clear tail, left as-is. But + // one that is BOTH inside a mapped range AND flagged encrypted is a CBC + // fragment we cannot decrypt — fail loud instead of shipping it as clear. let unit_lba = base_lba.saturating_add((idx_in_buf as u32) * unit_sectors); if map.entry_for(unit_lba).is_some() && aacs::content::aacs_unit_seed_encrypted(chunk, format) @@ -488,20 +468,9 @@ fn apply_aacs_map( return; } let unit_lba = base_lba.saturating_add((idx_in_buf as u32) * unit_sectors); - // No range covers this LBA. That is expected for clear filesystem / nav - // on a whole-disc read — but "the map has no key here" and "there is - // nothing to decrypt here" are different statements, and only the - // second makes passing the unit through correct. - // - // An ENCRYPTED unit outside every range is content we cannot key: on a - // multi-CPS disc that is an orphan clip referenced by no playlist, so - // it sits in no title extent and therefore in no range. Emitting it - // verbatim ships ciphertext where plaintext is meant to be, and extract - // then counts those bytes as good and reports the file complete. - // - // The split-unit branch immediately above already draws exactly this - // distinction. This one did not, so it was reached before the - // `aacs_unit_encrypted` gate below ever ran. + // No range covers this LBA — expected for clear filesystem/nav, but an + // ENCRYPTED unit outside every range is an orphan clip we cannot key; + // emitting it verbatim would ship ciphertext as clear content. let Some((key_idx, phase, range_start)) = map.entry_for(unit_lba) else { if aacs::content::aacs_unit_seed_encrypted(chunk, format) { verify_failed.store(true, std::sync::atomic::Ordering::Relaxed); @@ -568,8 +537,8 @@ fn apply_aacs_map( /// reader was built without installing its map (a bug), so it fails loud rather /// than apply a guessed key. /// -/// `unit_key_idx` and `content` are legacy parameters kept so the CSS / `None` -/// wrapper signatures stay stable; they are ignored (the CSS arm self-gates on its +/// `unit_key_idx` is a legacy parameter kept so the CSS / `None` wrapper +/// signature stays stable; it is ignored (the CSS arm self-gates on its /// per-sector scramble flag). Returns `Err` if decryption was expected but /// impossible; never produces silently corrupted output. The `usize` return is a /// legacy unverified-byte count that is always `0` for the CSS / `None` arms. @@ -629,12 +598,9 @@ fn decrypt_span( let dropped: usize = match keys { DecryptKeys::None => 0, DecryptKeys::Aacs { .. } => { - // AACS decrypts EXCLUSIVELY through a resolved key map: the map keys - // every content unit up front and a missing key fails at RESOLVE - // time. No map here means an AACS reader was built without - // installing one — the old trial-decrypt path (try each held key, - // keep the first-tried plaintext on a miss) is gone precisely - // because it silently applied wrong keys. + // AACS decrypts EXCLUSIVELY via a resolved key map (missing key fails at + // RESOLVE time). No map here means the reader was built without one — the + // old trial-decrypt path is gone: it silently applied wrong keys on a miss. let Some(map) = map else { return Err(crate::error::Error::DecryptFailed); }; @@ -642,12 +608,9 @@ fn decrypt_span( 0 } DecryptKeys::Css { title_key } => { - // CSS SELF-recovers: the title key changes per VOB region and is - // re-cracked constantly, but always FROM THE DATA ITSELF — no external - // input. So the whole descramble-and-rekey is self-contained here (see - // `css::descramble_region`), and CSS does not need the post-decrypt - // recovery seam that AACS key-fetch / FMTS segment-skip use (those DO - // consume external inputs a `decrypt_sectors` caller cannot supply). + // CSS SELF-recovers: the title key changes per VOB region and is re-cracked + // constantly, but always FROM THE DATA ITSELF (see `css::descramble_region`), + // so it needs none of the external-input recovery seam AACS key-fetch uses. css::descramble_region(buf, title_key)? } }; @@ -1030,11 +993,9 @@ mod tests { /// scramble flag. fn make_css_sector(title_key: &[u8; 5], seed: &[u8; 5], body_fill: u8) -> (Vec, Vec) { let mut sector = vec![body_fill; 2048]; - // A real scrambled DVD sector is an MPEG-2 PS pack, so it begins with - // the pack start code. The descrambler requires it before trusting - // byte 0x14 — without it this fixture is a sector shape that cannot - // occur on a disc, and the test would pass while the production gate - // rejected every sector like it. + // A real scrambled DVD sector is an MPEG-2 PS pack starting with the pack + // start code; the descrambler requires it before trusting byte 0x14. Without + // it the fixture is a shape that can't occur on disc, masking a rejecting gate. sector[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); sector[0x14] = 0x30; // scramble flag (bits 4-5) sector[0x54..0x59].copy_from_slice(seed); @@ -1437,10 +1398,9 @@ mod tests { use crate::disc::Extent; let us = (aacs::content::ALIGNED_UNIT_LEN / 2048) as u32; // 3 - // Case A — range_start is itself unaligned (1001 % 3 == 2) and the extent - // begins on it, so unit offsets are 0, 3, 6, ... Under this shape the - // formula `(lba + range_start) / us` shifts every index by an ODD amount - // and inverts the kept half. + // Case A — range_start is itself unaligned (1001 % 3 == 2), so unit offsets + // are 0, 3, 6, ... Under this shape `(lba + range_start) / us` shifts every + // index by an ODD amount and inverts the kept half. let ext = vec![Extent { start_lba: 1001, sector_count: 12, @@ -1462,11 +1422,9 @@ mod tests { units are the even-indexed ones counting from the range start" ); - // Case B — the extent begins one unit-remainder away from the range start - // (1001 - 1000 = 1), so offsets are 1, 4, 7, 10. Here `(lba - range_start) - // * us` inverts the halves instead: the division is what maps a byte - // offset onto a unit index, and multiplying happens to preserve parity - // only when the offset is already a multiple of the unit size. + // Case B — the extent begins one unit-remainder from the range start (1001 - + // 1000 = 1), so offsets are 1, 4, 7, 10. Here `(lba - range_start) * us` + // inverts the halves: multiplying only preserves parity for aligned offsets. let ext = vec![Extent { start_lba: 1001, sector_count: 12, @@ -1876,22 +1834,16 @@ mod tests { } // The index must be RANGE-RELATIVE: `(lba - start)`, not `(lba + start)`. - // Most value pairs give the same parity either way, so pin one where - // they genuinely disagree: (5-1)/2 = 2 (even, ours) but + // Pin a case where they genuinely disagree: (5-1)/2 = 2 (even, ours) but // (5+1)/2 = 3 (odd, not ours). assert!( unit_is_our_phase(5, 1, 2, Phase::Even), "the index must be measured from the range start" ); - // The `/ unit_sectors` -> `* unit_sectors` mutant is EQUIVALENT here, - // and deliberately not chased. Proof: aligned offsets are exact - // multiples of the unit size, so offset = k*u for unit index k. - // Dividing gives k; multiplying gives k*u^2, whose parity is - // parity(k)*parity(u^2) = parity(k) whenever u is ODD. `unit_sectors` - // is `ALIGNED_UNIT_LEN / 2048` = 3, a compile-time constant, so u is - // always odd and the two agree on every reachable input. Only an even - // unit size would separate them, and none exists. + // The `/ unit_sectors` -> `* unit_sectors` mutant is EQUIVALENT here and + // deliberately not chased: aligned offsets are k*u, and dividing vs. + // multiplying give the same parity since `unit_sectors` (=3) is always odd. assert!(!unit_is_our_phase(33, 30, 3, Phase::Even)); } diff --git a/src/diag.rs b/src/diag.rs index e0e25c08..d9ea9c2f 100644 --- a/src/diag.rs +++ b/src/diag.rs @@ -92,12 +92,9 @@ pub fn hdr_str(h: HdrFormat) -> &'static str { } } -// `channel_count` and `sample_rate_hz` lived here as a third copy of the -// AudioChannels/SampleRate mappings. They were the only HONEST copy — returning -// 0 for Unknown where the canonical accessors fabricated 6 channels at 48 kHz — -// and their only caller was the trace line below, in this same file. The -// canonical accessors are honest now, so the duplicates are gone rather than -// left to drift a fourth time. +// `channel_count`/`sample_rate_hz` used to live here as a third, HONEST copy of +// the AudioChannels/SampleRate mappings (returning 0 for Unknown where the +// canonical accessors fabricated 6ch/48kHz). Canonical accessors are honest now. // ── DVD cell-category dump (from the IFO scan, pre-lowering) ───────────────── @@ -110,9 +107,8 @@ pub fn hdr_str(h: HdrFormat) -> &'static str { pub fn dvd_cell_row(idx: usize, cell: &crate::ifo::DvdCell, dropped: bool) -> String { let c = CellCategory::decode(cell.category); // Per-cell keep/skip REASON (self-sufficient bug log): a dropped cell is a - // leading secondary angle/interleave block piece; a kept cell is either the - // first feature cell or genuine feature content. This makes the - // leading-cell-filter decision auditable from the log without the disc. + // leading secondary angle/interleave piece; a kept cell is the first feature + // cell or genuine content. Makes the leading-cell-filter decision auditable. let verdict = if dropped { "DROP(leading-secondary-block-piece)" } else if c.is_secondary_block_piece() { @@ -482,11 +478,9 @@ pub fn dump_disc(disc: &Disc) { ); } - // Titles disqualified from main-feature candidacy for carrying no video — - // the streamless obfuscation decoys the `has-video` gate demotes. Logged so - // a field bug report shows WHY a long/large playlist was not picked (the - // gap that let an undiagnosable E6008 loop on a real UHD title's decoy - // playlist `00245`). + // Titles disqualified from main-feature candidacy for carrying no video — the + // streamless obfuscation decoys the `has-video` gate demotes. Logged so a field + // bug report shows WHY a playlist wasn't picked (the gap behind E6008 on `00245`). for (ti, t) in disc.titles.iter().enumerate() { if !t.has_video() { tracing::debug!( @@ -673,10 +667,9 @@ mod tests { .collect(), ..DiscTitle::empty() }; - // A 40-clip 8 GB title beats a 1-clip 1 GB title: the comparator's - // primary key among disc-fitting titles is LARGEST SIZE. "fewest clips" - // would predict the opposite, so the drifted string described a rule - // the comparator does not implement. + // A 40-clip 8 GB title beats a 1-clip 1 GB title: the comparator's primary + // key among disc-fitting titles is LARGEST SIZE, not "fewest clips" (the + // drifted string described a rule the comparator does not implement). let many_clips_big = sized(8_000_000_000, 40); let one_clip_small = sized(1_000_000_000, 1); assert_eq!( diff --git a/src/dirimage/encode.rs b/src/dirimage/encode.rs index b4be6cd2..cab9da22 100644 --- a/src/dirimage/encode.rs +++ b/src/dirimage/encode.rs @@ -459,10 +459,9 @@ fn file_entry( s[160..168].copy_from_slice(&unique_id.to_le_bytes()); s[168..172].copy_from_slice(&0u32.to_le_bytes()); // length of EAs let l_ad = extents.len() * 8; - // A short AD is 8 bytes and the entry has 2048 - 176 = 1872 bytes for - // them, i.e. 234 extents — over 200 GiB at the per-AD ceiling. Beyond - // that an Allocation Extent Descriptor chain would be required; refuse - // rather than write a truncated list. + // A short AD is 8 bytes, leaving room for 234 extents (~200 GiB) before an + // Allocation Extent Descriptor chain would be needed; refuse rather than + // write a truncated list. if 176 + l_ad > SECTOR { return Err(Error::DirImageTooLarge); } @@ -500,10 +499,9 @@ fn push_fid(buf: &mut Vec, name: &str, icb_lba: u32, is_dir: bool, is_parent chars |= 0x08; } fid[18] = chars; - // The planner refuses any name whose encoding exceeds what this byte can - // hold (`layout::MAX_CS0_NAME_BYTES`), so this cannot wrap in practice. The - // assert states the invariant where it is relied on rather than trusting a - // check three files away; a wrap here would desynchronise the directory. + // Planner enforces `layout::MAX_CS0_NAME_BYTES` so this can't wrap in + // practice; the assert states that invariant where it's relied on + // instead of trusting a check three files away. debug_assert!( l_fi <= u8::MAX as usize, "FID name length must fit one byte" @@ -603,11 +601,9 @@ fn write_dir(out: &mut MetaSectors, layout: &Layout, dir: &DirNode) -> Result<() fix_fid_tag_locations(&mut fids, dir.data_lba); debug_assert_eq!(fids.len(), dir.data_bytes as usize); - // A directory's link count is 1 (its own FID in the parent) plus one for - // each child directory's parent FID pointing back at it. - // The planner caps subdirectory fan-out (`layout::MAX_SUBDIRS`) so this - // cannot overflow; saturating rather than wrapping keeps a future change to - // that cap from silently producing a wrong count. + // Link count is 1 (own FID in parent) plus one per child dir's parent FID. + // `layout::MAX_SUBDIRS` bounds fan-out so this can't overflow; saturating + // guards against a future change to that cap silently miscounting. let link_count = (dir.dirs.len() as u16).saturating_add(1); let fe = file_entry( true, diff --git a/src/dirimage/layout.rs b/src/dirimage/layout.rs index 8bfb6996..e581d3e4 100644 --- a/src/dirimage/layout.rs +++ b/src/dirimage/layout.rs @@ -218,18 +218,14 @@ fn walk(dir: &Path, disc_path: &str, depth: u32, entries: &mut usize) -> Result< if is_excluded(&name) { continue; } - // `file_type()` does NOT follow symlinks; `metadata()` does. A symlink - // to a file is materialized as that file (a legitimate way to assemble - // a folder), a symlink to a directory is skipped — following one can - // loop, and UDF has no link this maps onto. + // `file_type()` does NOT follow symlinks; `metadata()` does. A symlink to + // a file is materialized as that file; a symlink to a directory is + // skipped — following one can loop, and UDF has no link this maps onto. let ft = entry.file_type().map_err(Error::from)?; let child_path = format!("{}/{}", disc_path.trim_end_matches('/'), name); - // A File Identifier Descriptor records the encoded name length in ONE - // byte. A longer name would wrap that field and desynchronise every - // later entry in the directory, so refuse while planning rather than - // encode something unreadable. 255 ASCII bytes is a legal name on - // ext4/APFS/NTFS and already exceeds this once the CS0 compression - // byte is added, so it is reachable without anything exotic. + // A FID records the encoded name length in ONE byte; a longer name would + // wrap it and desynchronise the directory, so refuse while planning. 255 + // ASCII bytes (legal on ext4/APFS/NTFS) already exceeds it with CS0 added. if crate::dirimage::encode::encode_cs0(&name).len() > MAX_CS0_NAME_BYTES { return Err(Error::DirNameTooLong { path: child_path }); } @@ -237,29 +233,13 @@ fn walk(dir: &Path, disc_path: &str, depth: u32, entries: &mut usize) -> Result< if *entries > MAX_ENTRIES { return Err(Error::DirImageTooLarge); } - // Key the uniqueness check on the name AS THE READER WILL SEE IT, by - // round-tripping through the very encoder and parser that will be used. - // - // The host name is not that name. `parse_udf_name` trims leading and - // trailing whitespace and drops any code unit `char::from_u32` rejects - // (a lone surrogate half, i.e. every non-BMP character the encoder - // emits as a surrogate pair). So " A.M2TS" and "A.M2TS", or "A.M2TS" - // and "A.M2TS", are distinct hosts that collapse to ONE name on read — - // and `find`/`read_file` take the first match, so a title silently - // resolves to the wrong file's extents and muxes the wrong bytes at - // exit 0. Comparing the raw host names cannot see that; deriving the - // key from the same two functions cannot drift from it. + // Key uniqueness on the name AS THE READER WILL SEE IT (round-trip through + // the same encoder/parser it uses): `parse_udf_name` trims/drops chars, so + // distinct hosts (" A.M2TS" vs "A.M2TS") can collapse and `find` mismatches. let as_read = crate::udf::parse_udf_name(&crate::dirimage::encode::encode_cs0(&name)); - // Nothing left after the reader is done with it — a name made entirely - // of characters `parse_udf_name` drops, e.g. a sidecar folder named - // with a single emoji. The entry would exist in the image and be - // addressable by nothing. - // - // SKIP it, do not fail the plan. Failing turned an irrelevant extra - // file into an un-rippable folder that 1.6.0 handled fine (the - // unreachable entry merely sat there, harming nothing), and reported it - // as a collision with a file that does not exist. It is also not - // pushed to `names`, so it cannot collide with anything either. + // Nothing left after the reader drops it (e.g. a name of a single emoji): + // it would exist in the image but be addressable by nothing. SKIP rather + // than fail the plan (1.6.0 handled such extras fine); not pushed to `names`. if as_read.is_empty() { tracing::warn!( target: "freemkv::dirimage", @@ -270,10 +250,9 @@ fn walk(dir: &Path, disc_path: &str, depth: u32, entries: &mut usize) -> Result< } names.push(as_read.to_ascii_uppercase()); if ft.is_dir() { - // A directory's File Entry records its link count in 16 bits: one - // per child directory plus one for its own entry in the parent. - // The global entry cap alone permits a single directory holding - // more than that, which would wrap the count. + // Link count (child dirs + 1) is stored in 16 bits; the global entry + // cap alone permits a single directory holding more than that, which + // would wrap the count. if dirs.len() >= MAX_SUBDIRS { return Err(Error::DirImageFanout { path: disc_path.to_string(), @@ -283,30 +262,16 @@ fn walk(dir: &Path, disc_path: &str, depth: u32, entries: &mut usize) -> Result< } else { let meta = match std::fs::metadata(entry.path()) { Ok(m) => m, - // A broken symlink or a file that vanished between readdir and - // stat: skip it rather than plan an extent that cannot be read. - // Anything else — permission denied, an I/O error on the host - // volume — is NOT a missing file, and skipping it would drop a - // real file out of the image with nothing reported. + // A broken symlink or a file gone between readdir and stat: skip it + // rather than plan an unreadable extent. Anything else (e.g. perm + // denied) is NOT a missing file, so skipping it would drop it silently. Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, Err(e) => return Err(Error::from(e)), }; if !meta.is_file() { - // Not a plain file after following the link. The common case is - // a SYMLINK TO A DIRECTORY — a normal way to keep 40 GB of - // streams off the system disk — and `entry.file_type()` above - // reports the link, not the target, so such a subtree never - // enters `dirs` and is silently absent from the image. - // - // The result was the worst class this release exists to close: - // BDMV/PLAYLIST and CLIPINF still synthesize, so the folder - // scans and enumerates titles, every clip resolves to no - // extents, and the mux takes its clean-EOF path and writes a - // near-empty MKV at exit 0. - // - // It is still skipped rather than followed — following link - // targets invites cycles and escapes from the folder — but it - // is now SAID, at the same volume as the name-skip above. + // Not a plain file after following the link: commonly a SYMLINK TO A + // DIRECTORY, silently vanishing before (clips → no extents, near-empty + // MKV at exit 0). Still skipped, not followed, but now SAID. tracing::warn!( target: "freemkv::dirimage", path = %child_path, @@ -328,10 +293,9 @@ fn walk(dir: &Path, disc_path: &str, depth: u32, entries: &mut usize) -> Result< } } - // `UdfFs::find_dir` / `read_file` match path components case-insensitively, - // so two entries in one directory differing only in case are - // indistinguishable to every consumer — the second would silently shadow - // the first. Only reachable on a case-sensitive host volume. + // `find_dir`/`read_file` match path components case-insensitively, so two + // entries differing only in case are indistinguishable to every consumer — + // the second would silently shadow the first. Only on a case-sensitive host. names.sort(); for pair in names.windows(2) { if pair[0] == pair[1] { @@ -428,12 +392,9 @@ fn be_u32(buf: &[u8], off: usize) -> Option { fn read_head(path: &Path, n: usize) -> Result> { use std::io::Read; let mut buf = vec![0u8; n]; - // `read_exact`, not `read`: a single `read` may legally return fewer bytes - // than asked on a network or FUSE mount — and a NAS-hosted backup is the - // normal case for this feature. A short buffer sends every placement offset - // through `unwrap_or(0)`, recording NO constraint, so the planner and the - // reader disagree about where a VOB begins and the rip reads the wrong - // sectors for a whole title at exit 0. + // `read_exact`, not `read`: a single `read` may legally return fewer bytes on + // a network/FUSE mount (a NAS-hosted backup is normal here). A short buffer + // records NO constraint, so the rip reads the wrong sectors at exit 0. let mut f = std::fs::File::open(path).map_err(Error::from)?; f.read_exact(&mut buf).map_err(Error::from)?; Ok(buf) @@ -482,10 +443,9 @@ fn place_video_ts(vts: &mut DirNode, start: u32) -> Result { // Required start blocks, resolved as each IFO is placed. let mut menu_req: std::collections::HashMap = std::collections::HashMap::new(); let mut title_req: std::collections::HashMap = std::collections::HashMap::new(); - // Groups whose IFO has already been placed. Kept separately from the - // constraint maps because an IFO that declares no offsets inserts into - // neither, so keying the duplicate check on those maps would miss a second - // IFO for the same set — which is the collision the check exists for. + // Groups whose IFO has already been placed, kept separately from the + // constraint maps: an IFO declaring no offsets inserts into neither, so + // keying the duplicate check on those maps would miss the collision. let mut seen_ifo: std::collections::HashSet = std::collections::HashSet::new(); let mut cursor = start; @@ -517,11 +477,9 @@ fn place_video_ts(vts: &mut DirNode, start: u32) -> Result { { let head = read_head(&vts.files[i].host, 0xC8)?; let menu = be_u32(&head, 0xC0).unwrap_or(0); - // One group per title set. Two files whose names differ only in - // how the number is written — `VTS_01_0.IFO` and `VTS_1_0.IFO` — - // parse to the same group, and the second insert would overwrite - // the first's constraint, placing a VOB at an address the IFO the - // reader uses does not point to. Refuse instead of picking one. + // One group per title set: `VTS_01_0.IFO` and `VTS_1_0.IFO` parse to + // the same group, and a second insert would overwrite the first's + // constraint, placing a VOB where the reader's IFO doesn't point. if !seen_ifo.insert(c.group) { return Err(Error::DirNameCollision { host: vts.files[i].disc_path.clone(), @@ -530,12 +488,9 @@ fn place_video_ts(vts: &mut DirNode, start: u32) -> Result { if menu != 0 { menu_req.insert(c.group, lba.saturating_add(menu)); } - // Only a VTS IFO carries a title VOBS pointer at 0xC4. In the VMG - // (`VIDEO_TS.IFO`) that offset is TT_SRPT, the title search pointer - // table — a sector offset INSIDE the IFO, not a pointer to another - // file, so treating it as one would place `VIDEO_TS.VOB` at a - // meaningless address. (`ifo.rs::parse_vmg` reads the same field as - // TT_SRPT; the two must not drift.) + // Only a VTS IFO carries a title VOBS pointer at 0xC4; in the VMG that + // offset is TT_SRPT (a sector offset INSIDE the IFO, not a file + // pointer). `ifo.rs::parse_vmg` reads the same field; must not drift. if c.group > 0 { let title = be_u32(&head, 0xC4).unwrap_or(0); if title != 0 { @@ -723,13 +678,9 @@ pub(super) fn plan(root: &Path) -> Result { .position(|d| d.name.eq_ignore_ascii_case("VIDEO_TS")) { cursor = place_video_ts(&mut tree.dirs[idx], cursor)?; - // `place_video_ts` places only the FILES directly inside VIDEO_TS, - // because only those carry the IFO-relative constraints. Anything in a - // subdirectory of VIDEO_TS still needs data placed: without this its - // File Entry is written with the file's real size but no extents, so it - // appears in the tree at full length and reads back as nothing, at - // exit 0. The same folder under BDMV/ was always placed correctly, - // which is what made the gap easy to miss. + // `place_video_ts` places only the FILES directly inside VIDEO_TS (the ones + // with IFO-relative constraints); subdirectories still need placing, or + // their File Entry has real size but no extents and reads as nothing. for sub in tree.dirs[idx].dirs.iter_mut() { place_generic(sub, &mut cursor)?; } @@ -989,11 +940,9 @@ mod tests { /// plainly that it does NOT exercise `walk`. #[test] fn the_subdir_cap_refuses_a_folder_with_too_many_subdirectories() { - // Executes the guard, rather than restating the constant. A directory's - // File Entry records its link count in 16 bits — one per child - // directory plus one for its own entry in the parent — so exceeding it - // would wrap the count and produce an image whose directory structure - // lies about itself. + // Executes the guard rather than restating the constant: link count (child + // dirs + 1) is 16 bits, so exceeding it wraps and the image lies about + // its own directory structure. let dir = std::env::temp_dir().join(format!("fmkv-fanout-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); diff --git a/src/dirimage/mod.rs b/src/dirimage/mod.rs index 4a3013e4..fe6e8a76 100644 --- a/src/dirimage/mod.rs +++ b/src/dirimage/mod.rs @@ -127,18 +127,9 @@ impl DirImage { let mut files = Vec::with_capacity(nodes.len()); let mut ranges = Vec::new(); for (idx, node) in nodes.iter().enumerate() { - // Carry the plan-time mtime ONLY for files whose CONTENT the plan - // read — the DVD IFOs, whose bytes 0xC0/0xC4 decide where every VOB - // is placed (`layout::place_video_ts` -> `read_head`). - // - // For every other file the plan depends on the SIZE alone, and size - // is already checked. Comparing mtime on those buys nothing and - // costs real false positives: disc backups commonly live on - // exFAT/FAT32, which stores local time, so a long rip spanning a - // DST transition sees a whole-hour shift on a file nobody touched - // and would abort hours in, blaming a change that did not happen. - // The multi-gigabyte VOBs are exactly the files a long rip re-opens - // after the handle cache evicts them. + // Carry plan-time mtime ONLY for files whose CONTENT the plan read (DVD + // IFOs, whose 0xC0/0xC4 bytes place every VOB); others depend only on + // SIZE, and comparing mtime there false-positives on exFAT/FAT32 + DST. let content_sensitive = node .disc_path .rsplit('.') @@ -228,16 +219,9 @@ impl DirImage { } let f = File::open(&self.files[file].host).map_err(Error::from)?; let md = f.metadata().map_err(Error::from)?; - // Size AND mtime. Size alone is content-blind, and this plan depends on - // content: a DVD's VOB placement comes from bytes 0xC0/0xC4 of its IFO, - // and an IFO rewritten in place keeps its length because IFOs occupy a - // whole number of sectors. The size check would pass while every title - // extent pointed at the wrong sectors — corrupt video behind an intact - // structure, reported complete at exit 0. - // - // Only compared when both sides have a timestamp; a platform or - // filesystem that reports none simply falls back to the size check - // rather than failing every read. + // Size AND mtime: size alone is content-blind, and VOB placement depends on + // IFO bytes 0xC0/0xC4 — an IFO rewritten in place keeps its sector-aligned + // length, so size alone would miss it. mtime only compared if both present. let changed_size = md.len() != self.files[file].size; let changed_mtime = match (self.files[file].mtime, md.modified().ok()) { (Some(planned), Some(live)) => planned != live, @@ -271,19 +255,9 @@ impl DirImage { h.seek(SeekFrom::Start(at)).map_err(Error::from)?; let res = h.read_exact(&mut out[..want]); if res.is_ok() { - // Release the window just read, every time. - // - // The ISO source accumulates and drops in chunks because it reads - // one file linearly, so a running start offset always names the - // bytes it has consumed. Reads here jump between files, so there is - // no single cursor to accumulate against — an accumulated byte - // count paired with one read's offset names 1/Nth of what was - // actually consumed and leaves the rest pinned, which is how the - // first version of this got it wrong. - // - // Dropping per read costs one advisory syscall per batch (4-16 MiB), - // which is nothing against the read itself, and it is correct - // regardless of how reads interleave across files. + // Release the window just read, every time (unlike the ISO source's + // accumulate-and-drop, which relies on one linear cursor — reads here + // jump between files, so an accumulated count would leave bytes pinned). if let Some((_, fh)) = self.open.iter().find(|(i, _)| *i == file) { drop_window(fh, at, want as u64); } @@ -319,18 +293,14 @@ impl SectorSource for DirImage { return Err(Error::UdfBufferTooSmall); } buf[..need].fill(0); - // Walk the request in RUNS, not sector by sector. A mux batch is 8192 - // sectors and almost always lands entirely inside one stream file's - // extent; per-sector seek+read would issue 8192 syscalls for what is - // one 16 MiB sequential read. + // Walk the request in RUNS, not sector by sector: a mux batch (8192 + // sectors) almost always lands in one extent, so per-sector seek+read + // would cost 8192 syscalls for what is one 16 MiB sequential read. let mut i = 0u32; while i < count as u32 { - // Checked: callers saturate their LBAs (`disc/dvd.rs` builds a cell - // start as `vob_start_sector.saturating_add(cell.first_sector)`, and - // the prefetcher adds an offset the same way), so a crafted IFO can - // present a request at the very top of the address space. Wrapping - // here would fold `at` back to a LOW sector and hand the muxer a - // different file's bytes with nothing reported. + // Checked: callers saturate their LBAs, so a crafted IFO can present a + // request at the top of the address space. Wrapping here would fold + // `at` to a LOW sector, handing the muxer a different file's bytes. let Some(at) = lba.checked_add(i) else { break; }; diff --git a/src/dirimage/tests.rs b/src/dirimage/tests.rs index bacc5739..2977d7c0 100644 --- a/src/dirimage/tests.rs +++ b/src/dirimage/tests.rs @@ -545,10 +545,8 @@ fn scan_image_enumerates_a_bdmv_folder() { let (s, _, _) = bdmv_scratch(); let mut img = DirImage::open(s.path()).unwrap(); let cap = img.capacity_sectors(); - // The playlist here is filler, so no titles are expected — what is being - // asserted is that the scan reaches the BD enumerator at all (it would - // return `UdfNotFilesystem` if the synthesized volume did not parse) and - // reports the structure it found. + // Playlist is filler, no titles expected; this asserts the scan reaches the + // BD enumerator at all (would return `UdfNotFilesystem` if unparseable). let disc = crate::disc::Disc::scan_image(&mut img, cap, &crate::disc::ScanOptions::default()) .expect("scan_image must accept a synthesized BDMV image"); assert!(!disc.encrypted, "a decrypted folder has no AACS directory"); @@ -686,15 +684,8 @@ fn an_aacs_folder_with_scrambled_content_is_rejected() { } // ── The OTHER door: `dir://` through the PES input path ───────────────────── -// -// `session::scan_dir` (covered above) and `mux::resolve::input("dir://…")` are -// two doors into the same folder, and they once disagreed: a folder that ripped -// fine through one failed through the other, because only `scan_dir` re-judged -// the tree-shape encryption verdict from CONTENT. The fix was to share -// `session::apply_folder_encryption_verdict` between them — see its doc -// comment, which names this exact failure. Nothing tested the second door, so -// dropping the `is_folder` argument at `mux::resolve`'s call site restored the -// bug silently. +// `scan_dir` and `mux::resolve::input("dir://…")` once disagreed: only `scan_dir` +// re-judged the verdict from CONTENT. Fixed via `apply_folder_encryption_verdict`. /// The two doors must AGREE. Same folder, same verdict, same extents. /// @@ -976,10 +967,9 @@ fn dump_vts_ifo_reads_for_an_image() { let p = format!("/VIDEO_TS/VTS_{n:02}_0.IFO"); match fs.read_file(&mut img, &p) { Ok(b) => { - // Hash the CONTENT, not just the length: two IFOs of equal - // size and magic can still differ, and comparing only length - // and magic is what made an earlier pass of this diagnostic - // wrongly report the images as identical. + // Hash the CONTENT, not just length: two IFOs of equal size and + // magic can still differ, which is why an earlier pass of this + // diagnostic wrongly reported the images as identical. let mut h: u64 = 0xcbf2_9ce4_8422_2325; for byte in &b { h ^= *byte as u64; diff --git a/src/disc/bluray.rs b/src/disc/bluray.rs index ffb1a546..ea55893d 100644 --- a/src/disc/bluray.rs +++ b/src/disc/bluray.rs @@ -117,26 +117,17 @@ impl Disc { let mut extents = Vec::new(); let mut total_size: u64 = 0; // Set when any clip resolves to a STREAM/SSIF/.ssif — a Blu-ray 3D - // interleaved stream carrying both the base (left) and MVC dependent - // (right) views. Drives reading the SSIF for both eyes and adding the - // dependent-view stream below. + // interleaved stream carrying both base and MVC dependent views. Drives + // reading the SSIF for both eyes and adding the dependent-view below. let mut is_3d = false; let mut clips = Vec::with_capacity(parsed.play_items.len()); - // BD playlists legally reference the same .m2ts clip_id from - // multiple PlayItems (multi-angle, seamless splits, looped - // segments). The physical extents and packet count must be - // counted ONCE per unique clip — mux reads extents in order, so - // a duplicate would mux the A/V twice and inflate size_bytes. - // Per-PlayItem Clip entries (differing in/out times) still get - // recorded. + // BD playlists legally reference the same .m2ts clip_id from multiple + // PlayItems; extents/packet count must be counted ONCE per unique clip + // (mux reads in order) or a duplicate mux's the A/V twice, inflating size. let mut seen_clips: std::collections::HashSet = std::collections::HashSet::new(); - // Byte offset of the next extent within the TITLE'S FEED — the - // concatenation of `extents` in the order the mux reads them. Each - // clip's span is recorded so a frame's source offset identifies its - // clip by lookup rather than by guessing from timestamps, which is - // ambiguous inside an overlap. A clip referenced a SECOND time pushes - // no extents (see `first_ref`), so it reuses the span of its first - // reference — the same bytes, read once. + // Byte offset of the next extent within the TITLE'S FEED (the concatenation + // of `extents` in read order). Each clip's span is recorded so a frame's + // source offset identifies its clip by lookup, not by ambiguous timestamps. let mut feed_pos: u64 = 0; let mut spans: std::collections::HashMap = std::collections::HashMap::new(); @@ -145,32 +136,22 @@ impl Disc { let clip_dur = play_item.out_time.saturating_sub(play_item.in_time) as f64 / 45000.0; let clpi_path = format!("/BDMV/CLIPINF/{}.clpi", play_item.clip_id); - // A `.clpi` that cannot be read or parsed is NOT a benign miss. - // `duration_ticks` was already summed from the PlayItems above, so - // the title keeps claiming its full runtime; skipping this block - // (what the old `if let Ok(..) && let Ok(..)` did) pushed no - // extents and added nothing to `total_size`, so the title shipped - // with the clip's bytes silently absent — and, because the `if let` - // DISCARDED the error, without a single log line to say so. Same - // classification as the extent resolver below: drop the title, and - // log the error's OWN code. + // A `.clpi` that cannot be read or parsed is NOT a benign miss: + // `duration_ticks` already claims the full runtime, so drop the + // title instead of silently shipping it missing bytes. let clip_info = match udf_fs .read_file(reader, &clpi_path) .and_then(|clpi_data| clpi::parse(&clpi_data)) { Ok(info) => info, - // The operator's Stop, not a disc defect. Every remaining - // command fails the same way once the drive's flag is set, so - // classifying it as an unresolvable clip would drop each - // remaining playlist in turn and hand back a truncated title - // list at success. + // The operator's Stop, not a disc defect: every remaining command + // fails the same way once the drive's flag is set, so classifying + // it as unresolvable would truncate the title list at success. Err(Error::Halted) => return Err(Error::Halted), Err(e) => { - // The REAL code, not a fixed one: a scratched CLIPINF - // sector (DiscRead), a missing `.clpi` (UdfNotFound) and a - // malformed one (ClpiParse) are different populations, and - // flattening them would send anyone triaging the first - // after the third. + // The REAL code, not a fixed one: DiscRead, UdfNotFound and + // ClpiParse are different populations, and flattening them + // would send anyone triaging the first after the third. tracing::warn!( target: "freemkv::disc", playlist = ?filename, @@ -182,12 +163,9 @@ impl Disc { }; let pkt_count: u32 = clip_info.source_packet_count; - // The clip is marked seen only after its .clpi parses. That - // ordering used to matter because a transient failure on the first - // PlayItem referencing a clip must not permanently suppress its - // extents/size for a later PlayItem referencing the same clip; - // a failure now drops the whole title, so the ordering is kept - // simply because it is still the correct place for it. + // The clip is marked seen only after its .clpi parses. That ordering + // used to matter because a transient failure must not permanently + // suppress extents for a later PlayItem; kept as still-correct. let first_ref = seen_clips.insert(play_item.clip_id.clone()); // Only fetch/push the physical extents and add to the @@ -195,54 +173,13 @@ impl Disc { if first_ref { total_size += pkt_count as u64 * 192; - // Get stream file extents from UDF allocation descriptors. - // Dual-layer discs split files across layers — UDF knows the real layout. - // - // The clip's stream file is normally `.m2ts`, but AACS 2.1 - // (FMTS) discs name the main feature `.fmts` and 3D discs - // use `.ssif` (see [`CLIP_STREAM_EXTS`]). A normal `.m2ts` - // clip is unchanged — the fallback only runs when `.m2ts` - // is absent, which is exactly when `file_extents` errors. - // 3D discs interleave the left (base) and right (MVC - // dependent) views in STREAM/SSIF/.ssif — note the - // SSIF/ subdir. Prefer it when present: the SSIF is one - // transport stream carrying BOTH eyes on distinct PIDs, - // so muxing it captures the full 3D. 2D clips fall back to - // the base .m2ts / .fmts as before. + // Get stream file extents from UDF allocation descriptors (dual- + // layer discs split files across layers). Normally `.m2ts`; AACS 2.1 + // uses `.fmts`, 3D interleaves both eyes in STREAM/SSIF/.ssif. let ssif = format!("/BDMV/STREAM/SSIF/{}.ssif", play_item.clip_id); - // A clip stream that carries an unrecorded (never-written) - // extent cannot be turned into a truthful read plan — see - // `UdfFs::file_extents`. Track it separately from an - // ordinary "file absent" error: absence is what the - // extension fallback exists for, whereas a hole means the - // bytes this title needs do not exist on the disc. - // - // ABSENCE is the only benign failure. A missing `.ssif` is - // the ordinary case (every 2D disc), and the extension - // fallback below exists precisely for it. Every OTHER - // error means the bytes this title needs could not be - // resolved: a scratched sector under the clip's ICB - // (DiscRead), an allocation-descriptor chain that never - // terminated (UdfAdChainTooLong), a file whose data is - // embedded rather than extent-mapped (UdfEmbeddedData). - // Those used to fall through to the not-found path, so the - // clip contributed zero extents while `total_size` and the - // play-item timing still counted it — a title advertising - // its full runtime with a piece silently missing, and no - // log line anywhere. - // - // `Halted` is NOT a disc defect and must not be treated as - // one either. It is the operator cancelling: once the flag is - // set, EVERY drive command returns it, so classifying it here - // would drop each remaining playlist in turn and hand back a - // truncated title list at success — the same shape this - // refusal exists to prevent, wearing a cancel. It used to be - // merely EXEMPTED (neither classified nor propagated, because - // this function returned `Option`); exempting it is no longer - // enough now that there is a channel. A cancel landing on - // `file_extents` would otherwise return a title with this - // clip's bytes silently missing — the flagship defect shape, - // wearing a cancel. It is propagated below instead. + // ABSENCE (`UdfNotFound`) is the only benign failure — the fallback + // exists for it. DiscRead/UdfAdChainTooLong/UdfEmbeddedData mean + // bytes are unresolved; `Halted` is the operator cancelling, propagated. let mut unresolved: Option = None; let mut halted = false; let mut note = |e: &Error| { @@ -271,26 +208,19 @@ impl Disc { }) } }; - // Propagate the cancel BEFORE the classification below: a - // halted `file_extents` resolves nothing, so the title would - // otherwise be dropped (or, with a partial resolve, emitted - // short) and the scan would carry on as if the disc were at - // fault. + // Propagate the cancel BEFORE the classification below, or the + // title is dropped (or emitted short) and the scan carries on + // as if the disc were at fault. if halted { return Err(Error::Halted); } - // Nothing resolved AND a hole was the reason: drop the - // whole title. Letting the clip contribute no extents - // (the ordinary not-found path) would emit a title whose - // feed is silently missing this clip's runtime while its - // durations, spans and size still count it — data loss - // wearing the shape of a normal rip. + // Nothing resolved AND a hole was the reason: drop the whole + // title, or the clip contributes no extents while durations/size + // still count it — data loss wearing the shape of a normal rip. match (&file_exts, unresolved) { (None, Some(code)) => { - // The REAL code, not a fixed one. A scratched sector - // (E6000) and an over-long AD chain (E6016) logged as - // E6017 would send anyone triaging them after authoring - // holes and hide the population that actually exists. + // The REAL code, not a fixed one: e.g. E6000 vs E6016 + // logged as E6017 would hide the population that exists. tracing::warn!( target: "freemkv::disc", playlist = ?filename, @@ -299,32 +229,9 @@ impl Disc { ); return Ok(None); } - // A non-absence failure that the FALLBACK then papered - // over. `unresolved` was recorded and then thrown away: - // the `if let (None, Some(code))` above is the only reader - // of it, so when `/BDMV/STREAM/SSIF/.ssif` failed - // with `DiscRead` / `UdfAdChainTooLong` / `UdfEmbeddedData` - // but the `.m2ts` beside it resolved, the code was dropped - // on the floor. The title then shipped BASE-VIEW 2D off a - // disc that carries 3D, at rc=0, with not one log line — - // the operator gets a rip that looks complete and is - // missing the dependent view, and there is nothing in the - // journal to explain it or to distinguish it from a disc - // that was only ever 2D. - // - // This site's own doctrine, three paragraphs up, is - // "ABSENCE is the only benign failure" — `note()` already - // filters `UdfNotFound` out, so anything left in - // `unresolved` is by construction NOT benign. Reaching - // here means it was silently tolerated anyway. - // - // It is LOGGED, not refused. The fallback genuinely - // produced a truthful read plan for the base view, so the - // title is complete as 2D; dropping it would trade a - // degraded rip for no rip, which is the worse of the two - // and is not what the refusal above is for. The line - // carries the error's OWN code for the same reason as - // every other site in this file. + // A non-absence failure the FALLBACK papered over: `.ssif` failed + // non-benignly but `.m2ts` resolved. LOGGED, not refused — the + // fallback IS a truthful 2D read plan; dropping trades it for none. (Some(_), Some(code)) => { tracing::warn!( target: "freemkv::disc", @@ -337,32 +244,9 @@ impl Disc { } _ => {} } - // KNOWN GAP, deliberately left open: `file_extents` can also - // return `Ok(vec![])`, or a vector every entry of which the - // `sectors > 0 && lba > 0` filter below discards (zero-length - // placeholder ADs — see `UdfFs::file_extents`). That clip then - // contributes no extents and no span while `total_size` and - // the play-item timing still count it: the same shape the - // refusal above exists to prevent, reached by a different - // route. It is NOT closed with a post-loop "every unique - // clip_id must appear in `spans`" invariant because it is not - // settled that an empty-but-Ok resolve is always a defect - // rather than a legitimate healthy-disc state, and dropping - // healthy titles is a worse failure than the residual gap. - // Recorded here so the next audit finds the decision instead - // of re-deriving it. - // - // ASYMMETRY, DELIBERATE: `disc::hddvd` CLOSES this same hole. - // Two things differ there. It already keeps an `unusable` set, - // so refusing costs nothing new (here it would mean inventing - // a post-loop "every clip_id must appear in `spans`" - // invariant). And the consequence is worse: an HD-DVD feature - // is COMPOSED from parts, so a missing part is spliced out of - // a title that keeps claiming the full runtime, whereas here - // the clip is one PlayItem of an otherwise whole title. Same - // hole, different price — see the long note at the - // `extents.is_empty()` branch in `hddvd.rs`. If BD ever grows - // an equivalent set, revisit this together with that one. + // KNOWN GAP, deliberately left open: an empty-but-Ok `file_extents` + // leaves the clip with no extents while size/timing still count it — + // not closed since it's unclear that's always a defect (see hddvd.rs). if let Some(file_exts) = file_exts { let span_start = feed_pos; for (lba, sectors) in file_exts { @@ -423,21 +307,13 @@ impl Disc { // not special-cased here. display_aspect: None, secondary: s.secondary, - // No user-facing English in the library (numeric-code - // rule): the Dolby Vision enhancement layer is signalled - // structurally (secondary video + DolbyVision hdr) and - // the CLI/UI render the localized descriptor. `label` - // stays empty for disc video streams. + // No user-facing English (numeric-code rule): Dolby Vision + // is signalled structurally (secondary + DolbyVision hdr); + // `label` stays empty for disc video streams. label: String::new(), - // TODO(spec): for 1080i HEVC/H.264/VC-1 titles, surface - // the measured field order (H.264/HEVC pic_struct, VC-1 - // pulldown) from the codec parser instead of the TFF - // fallback; needs the parser→title channel (see dvd.rs). - // TODO(spec): prefer the HEVC/H.264 VUI colour_description - // (measured CICP) over this MPLS playlist-nibble guess - // once the parser surfaces it through the output title. - // `None` keeps the enum fallback. (HDR MaxCLL/Mastering - // metadata is a separate task and intentionally not here.) + // TODO(spec): surface measured field order instead of the TFF + // fallback (needs a parser→title channel, see dvd.rs); prefer + // measured VUI CICP over this MPLS nibble guess once surfaced. measured_cicp: None, })), 2 | 5 => { @@ -487,22 +363,9 @@ impl Disc { }) .collect(); - // 3D: add the MVC dependent (right-eye) video stream. The base STN table - // lists only the left-eye video; the dependent view is a second video - // PID (stream_type 0x20) carried in the SSIF. The on-disc PAT/PMT are - // AACS-encrypted (unreadable pre-key) and the base STN omits the - // dependent view (it lives in the MPLS STN_table_SS), so we use the - // BD-3D PID convention: dependent = base-view video PID + 1 - // (e.g. 0x1011 -> 0x1012). Reading the SSIF (above) provides its packets. - // - // Limitation: `is_3d` latches per PLAYLIST, not per clip. A playlist that - // mixed a 3D clip (has an SSIF) with a 2D clip (no SSIF) would tag the - // whole title 3D; the 2D clip's frames then mux as plain Blocks (no - // dependent PID → no BlockAdditional) under a track that still advertises - // the mvcC mapping. That output is valid (per-frame BlockAdditional is - // optional) but over-claims 3D for those frames. Real 3D main-feature - // playlists are single-clip or uniformly 3D, so this is not exercised; - // per-clip 3D would need per-clip stream sets (a larger change). + // 3D: add the MVC dependent (right-eye) stream, PID = base PID + 1 (base + // STN omits it; lives in MPLS STN_table_SS). `is_3d` latches per + // PLAYLIST, so a mixed 3D/2D playlist over-claims 3D for 2D frames. if is_3d && let Some(base) = streams.iter().find_map(|s| match s { Stream::Video(v) => Some(v.clone()), @@ -523,16 +386,9 @@ impl Disc { } } - // Convert marks to chapters. mark_type == 1 is an entry-mark - // (chapter); type 2 is a link point and type 0 is reserved, so - // neither is a chapter. - // - // Each mark's timestamp is in the timebase of the PlayItem it - // references (play_item_ref). The chapter's position on the - // muxed timeline is the summed duration of every preceding - // PlayItem plus the mark's offset within its own PlayItem. Using - // play_items[0].in_time for every mark would misplace chapters in - // multi-PlayItem playlists. + // Convert marks to chapters: mark_type 1 is an entry-mark; 2/0 aren't. + // Each mark's timestamp is in its own PlayItem's timebase, so position + // sums preceding durations plus offset (not play_items[0].in_time). let chapters: Vec = parsed .marks .iter() @@ -632,11 +488,8 @@ impl Disc { mod tests { use super::*; use crate::udf::fixture::*; - // --------------------------------------------------------------- - // MPLS builder (BD-ROM PlayList spec). Mirrors the layout the - // `mpls::parse` consumer reads (header@0, PlayList@playlist_start, - // PlayListMark@mark_start). Offsets cited against mpls.rs. - // --------------------------------------------------------------- + // MPLS builder (BD-ROM PlayList spec). Mirrors the layout `mpls::parse` + // reads (header@0, PlayList@playlist_start, PlayListMark@mark_start). struct PiSpec { clip_id: [u8; 5], @@ -771,11 +624,8 @@ mod tests { buf } - // --------------------------------------------------------------- // CLPI builder. `clpi::parse` reads "HDMV" magic, prog_info_start@12, - // cpi_start@16, source_packet_count@56. Zeroing prog_info/cpi starts - // disables those sections cleanly. - // --------------------------------------------------------------- + // cpi_start@16, source_packet_count@56. Zeroing prog_info/cpi disables them. fn build_clpi(source_packet_count: u32) -> Vec { let mut d = vec![0u8; 60]; @@ -857,9 +707,8 @@ mod tests { ); } - /// Build a full BDMV tree with one STREAM/.m2ts (Long-AD ICB) and one - /// CLPINF/.clpi, returning the navigable UdfFs plus a populated disc. - /// This is the canonical 0.31.0 extent-assembly fixture. + /// Build the minimal fixture: an empty `BDMV/` directory (no STREAM, + /// no CLPINF), returning the navigable UdfFs plus a populated disc. fn make_min_fs(disc: &mut MemDisc) -> udf::UdfFs { // Empty BDMV/PLAYLIST so directory navigation in parse_playlist's // clip lookups still works even when no clip files exist. @@ -1363,17 +1212,9 @@ mod tests { }; build_udf_skeleton(&mut disc, 10); lay_dir(&mut disc, &root); - // Rewrite the .m2ts ICB (laid at PART_START + 100 by `lay_dir`) - // with a two-descriptor short-AD list: - // AD0: ECMA-167 4/14.14.1.1 type 1 (allocated, NOT recorded), - // length 0, at LBA 4999 — a zero-length descriptor that - // SURVIVES `read_icb_extents` (only a zero-length TYPE 0 - // descriptor is the AD-list terminator), so it reaches - // `file_extents` as an extent of div_ceil(0, 2048) = 0 - // sectors. This is the shape a crafted disc uses to put a - // readable-looking but empty range into a title's extent - // list. - // AD1: type 0, 4096 bytes at LBA 5000 — the real content. + // Rewrite the .m2ts ICB with a two-descriptor short-AD list: AD0 is + // a type-1 (allocated, not recorded) zero-length descriptor at LBA + // 4999 that survives `read_icb_extents`; AD1 is the real content. let mut icb = build_file_icb(4096, 5000, false); icb[212..216].copy_from_slice(&16u32.to_le_bytes()); // l_ad: two short ADs icb[216..220].copy_from_slice(&0x4000_0800u32.to_le_bytes()); // type 1, 2048 bytes @@ -1384,9 +1225,8 @@ mod tests { udf::read_filesystem(&mut disc).expect("fs") }; // The fixture must really carry the unrecorded descriptor, or the - // behaviour under test is never reached. `file_extents_addressing` - // shows what is there: the hole in its byte-space position, followed - // by the real content. + // behaviour under test is never reached: the hole's byte-space position, + // then the real content. assert_eq!( udf.file_extents_addressing(&mut disc, "/BDMV/STREAM/00001.m2ts") .expect("extents"), @@ -1467,10 +1307,9 @@ mod tests { }; build_udf_skeleton(&mut disc, 10); lay_dir(&mut disc, &root); - // Corrupt ONLY the descriptor tag, leaving a structurally valid - // ICB behind it. That is what an unreadable/garbled sector looks - // like to the parser, and it is deliberately NOT an unrecorded - // extent — the point is the error class the old code ignored. + // Corrupt ONLY the descriptor tag, leaving a structurally valid ICB + // behind it: an unreadable/garbled sector, deliberately NOT an + // unrecorded extent — the point is the error class the old code ignored. let mut icb = build_file_icb(4096, 5000, false); icb[0..2].copy_from_slice(&999u16.to_le_bytes()); disc.put_bytes(PART_START + 100, &icb); @@ -2561,10 +2400,9 @@ mod tests { &[], &[], ); - // ONE sector is cancelled: the .m2ts ICB (metadata LBA 100), which is - // precisely and only where `file_extents` looks. The .clpi (ICB 102, - // data 8000) still reads, so the clip is sized and the earlier - // CLIPINF arm is not the thing under test here. + // ONE sector is cancelled: the .m2ts ICB (LBA 100), exactly where + // `file_extents` looks. The .clpi still reads, so the earlier CLIPINF + // arm is not the thing under test here. let mut reader = HaltingReader { inner: &mut disc, halt_range: PART_START + 100..PART_START + 101, diff --git a/src/disc/dvd.rs b/src/disc/dvd.rs index 49bff957..d365417b 100644 --- a/src/disc/dvd.rs +++ b/src/disc/dvd.rs @@ -42,12 +42,9 @@ impl Disc { Err(_) => return Ok((Vec::new(), None)), }; - // Follow the disc's First-Play navigation the way a player would, to see - // which title it selects as the feature (issue #40). Read-only, and any - // parse error / interactive-menu entry / non-convergence returns `None` - // — a strict improvement: it never removes a title, only re-ranks toward - // the nav-chosen one. The `(vtsn, vts_ttn)` it yields is matched to the - // scanned title below to recover its `playlist_id`. + // Follow the disc's First-Play navigation like a player would, to find the + // nav-chosen feature title (issue #40). Read-only; any failure/non-convergence + // yields `None`, so this only re-ranks, never removes, a title. let nav_target = crate::dvdnav::resolve_main_title(reader, udf_fs); let mut nav_feature: Option = None; @@ -74,50 +71,27 @@ impl Disc { crate::ifo::TvSystem::Pal => ColorSpace::Bt470bg, crate::ifo::TvSystem::Ntsc => ColorSpace::Smpte170m, }, - // DVD pixels are anamorphic 720x480/576; the real display shape - // is the IFO aspect flag, not the pixel grid. Carry it so the - // MKV muxer writes a correct 16:9 / 4:3 DisplayWidth/Height - // instead of the square-pixel 3:2 / 5:4 it would otherwise emit. + // DVD pixels are anamorphic 720x480/576; real display shape comes from the + // IFO aspect flag, not the pixel grid. Carry it so the MKV muxer emits correct + // 16:9/4:3 DisplayWidth/Height instead of the wrong square-pixel 3:2/5:4. display_aspect: Some(match ts.video.aspect { crate::ifo::DvdAspect::R16x9 => (16, 9), crate::ifo::DvdAspect::R4x3 => (4, 3), }), secondary: false, label: String::new(), - // TODO(spec): populate from the MPEG-2 picture coding extension - // once the codec parser surfaces it. `Mpeg2Parser` already reads - // `top_field_first` (mux/codec/mpeg2.rs `picture_nb_fields`) but - // tracks are built from the IFO scan BEFORE any frame is parsed; - // wiring it requires a CodecParser accessor surfaced through - // PipelinedPesStream/DiscStream into the output title (mirroring - // the existing `codec_private` handshake). Until then `None` - // means the muxer falls back to TFF (correct for ~all DVDs). - // TODO(spec): DVD MPEG-2 carries no VUI; the colour signalling is - // the sequence_display_extension colour_description when present. - // Surface it from `Mpeg2Parser` (same handshake as above) and set - // this so a disc that states e.g. BT.601-625 colour overrides the - // PAL/NTSC guess in `color_space`. `None` uses the enum fallback. + // TODO(spec): populate top_field_first + measured colour from Mpeg2Parser once + // tracks are built after frame parsing -- needs a CodecParser->title channel + // like `codec_private`. Until wired, `None` falls back to TFF / PAL-NTSC guess. measured_cicp: None, }); - // TODO(spec): DefaultDuration is derived from the declared IFO - // frame_rate (25 / 29.97). A soft-telecined 23.976-in-29.97 DVD then - // reports 29.97 fps instead of the true 23.976 film rate. The pulldown - // cadence is detectable from the parser's per-picture `nb_fields` - // (repeat_first_field) — when most frames are 3-field-then-2-field - // 2:3 pulldown the film rate is frame_rate × 4/5. Emitting the film - // DefaultDuration needs the parser to report the measured cadence - // through the same parser→title channel as `top_field_first` above; - // left as a follow-up to avoid a speculative rate change here. + // TODO(spec): DefaultDuration comes from IFO frame_rate, so a soft-telecined + // 23.976-in-29.97 DVD reports 29.97 instead of true film rate. Cadence is + // detectable via nb_fields (2:3 pulldown); needs same channel as top_field_first above. - // Map DvdAudioAttr to Stream::Audio. The PID is derived from the - // stream's REAL on-wire private_stream_1 sub-stream id (assigned - // by per-codec ordinal in the IFO scan) via the same - // `dvd_audio_pid` table the demuxer's `PsPacket::dvd_pid` uses, - // so a mixed-codec title (AC-3 + DTS + LPCM) routes correctly - // instead of colliding on 0xBD00. Streams carried as a regular - // MPEG-audio PES (MP1/MP2, no sub-id) fall back to a distinct - // 0xBD00+ordinal PID — disjoint from the 0xBD80+ canonical audio - // space — though they are not routed via `dvd_pid` today. + // Map DvdAudioAttr to Stream::Audio. PID derives from the on-wire private_stream_1 + // sub-id via the same `dvd_audio_pid` table the demuxer uses, so mixed-codec + // titles route correctly instead of colliding; MP1/MP2 (no sub-id) use 0xBD00+ordinal. let audio_streams: Vec = ts .audio_streams .iter() @@ -144,9 +118,8 @@ impl Disc { for (vts_title_idx, dvd_title) in ts.titles.iter().enumerate() { title_number += 1; - // If the nav executor picked this title (identified by its VTS - // number and 1-based position within the set — the same - // `vts_ttn` convention used below), remember its global + // If the nav executor picked this title (by VTS number + 1-based position, + // the same `vts_ttn` convention used below), remember its global // `playlist_id` so the caller can promote it in ranking. if let Some(rt) = nav_target && rt.vtsn == ts.vts_number @@ -160,19 +133,9 @@ impl Disc { // per-cell IFO detail. No-op unless freemkv::diag is enabled. crate::diag::dump_dvd_cells(ts.vts_number, title_number, dvd_title); - // Feature start cell. Prefer the DVD nav-VM resolver, which - // PARKED (#40, menu-at-start playback). The "menu at the start" - // symptom (seen on a real disc) was a sector-mapping fault — the absolute - // VOB rebase in `ifo::parse_vts` (`vob_start_sector = - // file_start_lba + vtstt_vobs`) — NOT a navigation problem, so - // feature-start resolution is unnecessary for correct rips. The - // nav resolver + verified VM decoder (`dvdnav`) are kept compiled - // but deliberately bypassed; flip `USE_NAV_RESOLVER` to re-enable - // once the nav executor is finished. The fallback is the - // structural leading-cell filter (`feature_start_cell`), which - // drops leading scene-index / interleaved-angle sub-block cells - // and is a no-op for a normal feature (category 0x00 on cell 0). - // See `dvdnav::resolve_feature_start`. + // Feature-start nav resolution is PARKED (#40): the "menu at start" symptom + // was actually a sector-mapping fault in `ifo::parse_vts`'s VOB rebase, not + // navigation. Bypassed via `USE_NAV_RESOLVER = false`; fallback is the filter. const USE_NAV_RESOLVER: bool = false; let feature_start = if USE_NAV_RESOLVER { crate::dvdnav::resolve_feature_start( @@ -220,14 +183,9 @@ impl Disc { let size_bytes: u64 = extents.iter().map(|e| e.sector_count as u64 * 2048).sum(); - // Build pre-formatted VobSub `.idx` codec_data (size: + palette: - // lines) for VobSub subtitle streams. The `size:` line carries - // the coded video frame the subpicture was authored against - // (720x480 NTSC / 720x576 PAL) so players place and scale the - // bitmap correctly. - // format_palette guards on (0, 0) and omits its `size:` line, - // so an unresolved resolution degrades to a palette-only .idx - // rather than one claiming a 0x0 frame. + // Build pre-formatted VobSub `.idx` codec_data. The `size:` line carries the + // coded video frame (720x480 NTSC / 720x576 PAL) so players place/scale the + // subpicture correctly; format_palette omits it on unresolved (0,0). let (vid_w, vid_h) = ts.video.resolution.pixels().unwrap_or((0, 0)); let codec_data = dvd_title .palette @@ -260,10 +218,9 @@ impl Disc { streams.extend(audio_streams.iter().cloned()); streams.extend(subtitle_streams); - // Chapter times are absolute from the PGC start. When leading - // cells are dropped the muxed video shifts earlier by exactly - // their total duration, so shift the chapter marks too (clamping - // any that fell inside the dropped head to 0). + // Chapter times are absolute from the PGC start. When leading cells are + // dropped, the muxed video shifts earlier by their total duration, so shift + // chapter marks too (clamping any that fell inside the dropped head to 0). let chapters: Vec = dvd_title .chapter_times .iter() @@ -289,10 +246,9 @@ impl Disc { } } - // Polled again AFTER the loop: a cancel raised during the IFO reads - // that `parse_vmg` performs per title set has nothing left to poll, - // so without this a partially enumerated disc could still be handed - // back as success. + // Polled again AFTER the loop: a cancel raised during per-title-set IFO reads in + // `parse_vmg` has nothing left to poll otherwise, so without this a partially + // enumerated disc could still be handed back as success. if halt.is_some_and(|h| h.is_cancelled()) { return Err(Error::Halted); } @@ -306,11 +262,9 @@ mod tests { use crate::sector::SectorSource; use std::collections::HashMap; - // --------------------------------------------------------------- // In-memory disc + minimal UDF image (single physical partition, // metadata_start == partition_start). Offsets cited against // udf.rs::read_filesystem / ECMA-167. - // --------------------------------------------------------------- const PART_START: u32 = 3000; @@ -624,10 +578,9 @@ mod tests { /// `Err(Error::Halted)`. #[test] fn halted_ifo_read_is_not_reported_as_a_shorter_disc() { - // Two title sets: VTS_01's IFO data at PART_START+6000, VTS_02's at - // PART_START+7000. Both ICBs sit far below, so the filesystem - // metadata resolves and only the second title set's CONTENT is - // cancelled — the truncation case. + // Two title sets: VTS_01's IFO at PART_START+6000, VTS_02's at PART_START+7000. + // Both ICBs sit far below, so filesystem metadata resolves fine and only the + // second title set's CONTENT read is cancelled — the truncation case. let mut disc = MemDisc::new(); let vmg = build_vmg(&[(1, 1, 1), (1, 2, 1)]); let vts1 = build_vts(100, 0x00, &[], &[], &[(0, 9)], false); @@ -1052,12 +1005,9 @@ mod tests { fn scan_dvd_titles_mixed_audio_codecs_distinct_pids() { let mut disc = MemDisc::new(); let vmg = build_vmg(&[(1, 1, 1)]); - // audio b0: coding_mode is (b0 >> 5) & 7. AC-3 = 0 → b0=0x00. - // DTS = 6 → b0 = 6<<5 = 0xC0. b1 channels nibble = (channels-1) in bits - // 2-0: a REAL 5.1 layout is 5 (0x05), a REAL 2.0 is 1 (0x01). The old - // fixture used 0x10/0x50 (both decode to 1 channel) — a placeholder that - // would pass even against code mishandling channel counts. Pin real - // layouts: AC-3 5.1 eng, DTS 2.0 fra. + // audio b0: coding_mode = (b0>>5)&7 (AC-3=0->0x00, DTS=6->0xC0). b1 channels nibble + // = (channels-1) in bits 2-0 (5.1=0x05, 2.0=0x01). Old fixture used 0x10/0x50 + // (both -> 1ch), a placeholder that passed even with broken channel handling. let vts = build_vts( 0, 0x00, @@ -1110,10 +1060,9 @@ mod tests { 2, "DTS 2.0 nibble must decode to 2 channels" ); - // PIDs route via the positional sub-id table: AC-3 @ pos 0 → 0x80 → - // 0xBD80, DTS @ pos 1 → 0x89 → 0xBD89 (the shared audio-stream number - // in the low nibble, NOT a per-codec ordinal). Distinct AND the exact - // canonical wire PIDs the demux routes on. + // PIDs route via the positional sub-id table: AC-3 @ pos 0 -> 0x80 -> 0xBD80, DTS @ + // pos 1 -> 0x89 -> 0xBD89 (shared audio-stream number in the low nibble, NOT a + // per-codec ordinal). Distinct AND the exact canonical wire PIDs the demux routes on. assert_eq!(audios[0].pid, 0xBD80, "AC-3 @ pos 0 → 0xBD80"); assert_eq!(audios[1].pid, 0xBD89, "DTS @ pos 1 → 0xBD89"); assert_ne!(audios[0].pid, audios[1].pid); @@ -1301,11 +1250,9 @@ mod tests { #[test] fn scan_dvd_titles_numbering_increments_per_title() { let mut disc = MemDisc::new(); - // Two titles in VTS 1 (title nums 1 and 2). num_pgcs must cover - // pgc_index = vts_title - 1, so we need >=2 PGC entries; our - // build_vts only emits 1 PGC. So the second title's PGC index (1) - // exceeds num_pgcs (1) and is skipped. To exercise numbering we use - // two separate VTS sets instead. + // Two titles in VTS 1 (nums 1,2): pgc_index = vts_title-1 needs >=2 PGC entries, but + // build_vts only emits 1, so the second title's PGC index (1) exceeds num_pgcs (1) + // and is skipped. Use two separate VTS sets instead to exercise numbering. let vmg = build_vmg(&[(1, 1, 1), (1, 2, 1)]); let vts1 = build_vts(100, 0x00, &[], &[], &[(0, 9)], false); let vts2 = build_vts(200, 0x00, &[], &[], &[(0, 19)], false); @@ -1501,9 +1448,8 @@ mod tests { let mut disc = MemDisc::new(); let vmg = build_vmg(&[(2, 1, 1)]); // Cell 0: leading scene-index/angle sub-block (cat 0x90), 5s, sectors 0..9. - // Cell 1: feature start (cat 0x00), 59s, sectors 100..199. - // Cell 2: feature (cat 0x00), 59s, sectors 300..399. - // Programs: prog0 → cell 1 (feature start), prog1 → cell 3. + // Cell 1: feature start (cat 0x00), 59s, 100..199. Cell 2: feature, 59s, 300..399. + // Programs: prog0 -> cell 1 (feature start), prog1 -> cell 3. let vts = build_vts_cells( 1000, 0x00, diff --git a/src/disc/dvd_audio_probe.rs b/src/disc/dvd_audio_probe.rs index 96d9432c..a7051b74 100644 --- a/src/disc/dvd_audio_probe.rs +++ b/src/disc/dvd_audio_probe.rs @@ -75,11 +75,9 @@ pub fn probe_ac3_substream_channels(ps_bytes: &[u8]) -> BTreeMap { if !(0x80..=0x87).contains(&sub) { continue; } - // The PS demux strips the 4-byte AC-3 sub-header but does not align to a - // frame. Walk EVERY 0x0B77 sync in this sub-stream's payload, decode - // each frame's channel count, and keep the largest — the sub-stream's - // real (main-mix) channel capability. See the doc comment above for why - // the first frame alone is unreliable. + // The PS demux strips the AC-3 sub-header but doesn't align to a frame, so + // walk every 0x0B77 sync in the payload and keep the largest decoded + // channel count — the sub-stream's real main-mix capability (see doc above). if let Some(ch) = max_substream_channels(&p.data) { let slot = found.entry(sub).or_insert(0); *slot = (*slot).max(ch); @@ -202,7 +200,7 @@ pub fn remap_audio_pids(streams: &mut [Stream], probed: &BTreeMap) -> us /// /// `reader` MUST yield PLAINTEXT VOB bytes (i.e. a `DecryptingSectorSource` on a /// CSS disc) — probing scrambled sectors yields no AC-3 syncs and is a safe -/// no-op. Returns the number of audio streams whose PID changed. +/// no-op. Remaps `title`'s AC-3 streams in place; returns nothing. pub fn probe_and_remap( reader: &mut S, title: &mut crate::disc::DiscTitle, @@ -500,12 +498,9 @@ mod tests { #[test] fn max_substream_channels_unmappable_size_steps_forward_by_two() { let mut real = ac3_frame(2, false); - // Overwrite the (unchecked) CRC bytes of the real frame — these double - // as byte4/byte5 of the bogus header 2 bytes earlier, at absolute - // offset 4: byte4 = 0xC0 (fscod=3 reserved -> ac3_frame_size == 0, - // unmappable), byte5 = 0xF8 (bsid=31 >= 11 -> acmod_channels == None, - // so the bogus header itself never contributes a spurious channel - // count). + // Overwrite the real frame's (unchecked) CRC bytes, which double as byte4/5 + // of the bogus header at offset 4: 0xC0 (fscod=3 -> ac3_frame_size == 0, + // unmappable) and 0xF8 (bsid=31 -> acmod_channels == None, no spurious count). real[2] = 0xC0; real[3] = 0xF8; let mut data = vec![0xAA, 0xAA, 0xAA, 0xAA]; // offsets 0..4, no sync diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index cf0c7f29..0d3a50a3 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -102,11 +102,9 @@ impl AacsCertUnlocker<'_> { .ok() .and_then(|m| aacs::mkb::mkb_version(&m)); - // Host certs are keysource-served, never compiled in — unioned from the - // explicit `DriveCredentials` and the key-source layer. With ZERO certs - // the cert route cannot run: NoHostCert (folded to AacsNoHostCert by the - // caller, preserving the graceful path-1 disc-hash → VUK fallback). This - // is detected here, where the MKB generation is still in hand. + // Host certs are keysource-served, never compiled in — unioned from + // `DriveCredentials` and the key-source layer. With ZERO certs, NoHostCert + // (folded to AacsNoHostCert) preserves the graceful disc-hash → VUK fallback. let host_certs = Disc::collect_host_certs(self.opts, mkb_gen); if host_certs.is_empty() { tracing::warn!( @@ -117,10 +115,9 @@ impl AacsCertUnlocker<'_> { return Err(CertUnlockFailure::NoHostCert { mkb: mkb_gen }); } - // Hand the collected certs to the AACS unlocker. The cert-route bus - // removal depends on the read_data_key, NOT a drive unlock. The - // borrow checker can't split `session` across `scsi_mut()` + `&drive_id` - // through method calls, so clone the (cheap) identity first. + // Hand the collected certs to the AACS unlocker; the cert-route bus removal + // depends on read_data_key, NOT a drive unlock. Borrow checker can't split + // `session` across `scsi_mut()` + `&drive_id`, so clone the cheap identity. let drive_id = session.drive_id.clone(); let fu_certs = crate::unlock_bridge::map_host_certs(&host_certs); let (_, unlock_res) = crate::unlock_bridge::run_bus( @@ -251,35 +248,13 @@ impl Disc { /// read_data_key for bus decryption must still use the cert path, so an /// unlocker with no OEM VID capability returns `None` and we fall through /// to cert auth unchanged. - /// Collect every AACS host cert the caller carries, from BOTH the explicit - /// [`DriveCredentials`] and the key-source layer - /// ([`crate::KeySource::host_certs`] across each source), unioned. Host certs - /// are keysource-served, never compiled in; this is the one place the OEM - /// cert route gathers them. An empty result is the graceful no-cert signal - /// (the caller turns it into [`Error::AacsNoHostCert`]). - /// `mkb` is the disc's MKB generation when known, forwarded to each source's - /// [`crate::KeySource::host_certs`] so a source MAY return only - /// generation-appropriate certs (the default ignores it). - fn collect_host_certs( - opts: &ScanOptions, - mkb: Option, - ) -> Vec { - // Delegates to the shared cert primitive (the external freemkv-unlock-aacs - // plugin uses the same one). Kept as a thin Disc method so the existing - // collect_host_certs_* unit tests and call sites are unchanged. - crate::aacs::host_certs::collect_host_certs(opts, mkb) - } - fn do_handshake_cert( session: &mut crate::drive::Drive, opts: &ScanOptions, ) -> (Option, Option) { // OEM VID shortcut: a matching unlocker stashed the disc's Volume ID at - // drive `init()` (the new `unlock()` folds in the old `read_volume_id`). - // Use it and SKIP the cert handshake — the OEM path - // decouples the VID from the host cert + HRL. It yields no - // `read_data_key`; a bus-encrypted disc that needs the bus key is caught - // by the bus-key gate in `resolve_vid_only`. + // drive `init()`; use it and SKIP the cert handshake. It yields no + // `read_data_key`, caught by the bus-key gate in `resolve_vid_only`. if let Some(volume_id) = session.oem_vid() { tracing::debug!( target: "freemkv::disc", @@ -307,12 +282,9 @@ impl Disc { "No drive-unlocker Volume ID; running the in-tree AACS host-certificate handshake (AacsCertUnlocker)." ); - // Cert path: the in-tree `AacsCertUnlocker` peer absorbs the host-cert - // mutual-auth. It collects host certs from the key sources + credentials, - // runs `aacs_authenticate` per cert (wedge-guarded), and on success reads - // the VID + read_data_key. Its `UnlockError` is folded back to the same - // `Error` variants this function has always surfaced, so `scan_with`'s - // error rendering and the path-1 disc-hash → VUK fallback are unchanged. + // Cert path: `AacsCertUnlocker` collects host certs, runs + // `aacs_authenticate` per cert, and on success reads VID + read_data_key. + // Its `UnlockError` folds back to this function's usual `Error` variants. let unlocker = AacsCertUnlocker { opts }; match unlocker.authenticate(session) { Ok(hs) => (Some(hs), None), @@ -328,6 +300,25 @@ impl Disc { } } + /// Collect every AACS host cert the caller carries, from BOTH the explicit + /// [`DriveCredentials`] and the key-source layer + /// ([`crate::KeySource::host_certs`] across each source), unioned. Host certs + /// are keysource-served, never compiled in; this is the one place the OEM + /// cert route gathers them. An empty result is the graceful no-cert signal + /// (the caller turns it into [`Error::AacsNoHostCert`]). + /// `mkb` is the disc's MKB generation when known, forwarded to each source's + /// [`crate::KeySource::host_certs`] so a source MAY return only + /// generation-appropriate certs (the default ignores it). + fn collect_host_certs( + opts: &ScanOptions, + mkb: Option, + ) -> Vec { + // Delegates to the shared cert primitive (the external freemkv-unlock-aacs + // plugin uses the same one). Kept as a thin Disc method so the existing + // collect_host_certs_* unit tests and call sites are unchanged. + crate::aacs::host_certs::collect_host_certs(opts, mkb) + } + /// Build a keys-free AACS state that carries only the Volume ID (+ version /// metadata), for callers that resolve Unit Keys out-of-band and have /// disabled the local keydb. The VID is on-disc content read during the @@ -355,36 +346,17 @@ impl Disc { .as_deref() .and_then(aacs::inf::parse_content_cert); let bus_encryption = cc.as_ref().map(|c| c.bus_encryption).unwrap_or(false); - // No-cert default = UHD (V20 stride), matching `read_aacs_version` so the - // scanned `AacsState.version` and the out-of-band fetch agree. A wrong - // stride on the main resolve path fails loudly (sample validation) rather - // than silently, so the conservative V20 default is safe here too. + // No-cert default = UHD (V20 stride), matching `read_aacs_version` so + // scanned/out-of-band versions agree; the main path fails loudly on a + // wrong stride, so this conservative default is safe here too. let version = cc .as_ref() .map(|c| c.version.major()) .unwrap_or(aacs::mkb::AACS_MAJOR_UHD); - // Bus-encryption gate (wrong-keys guard). A bus-encrypted disc (Content - // Certificate bus-encryption bit set) carries bus encryption on its - // sectors, which MUST be removed before any AACS key can decrypt them. - // There are TWO ways it gets removed, and bus encryption is unremovable - // only when NEITHER succeeded: - // 1. An unlocker unlocked the drive → it serves CLEAR content - // (`drive_unlocked`). This is the common live-drive case and yields - // no `read_data_key` — it doesn't need one. - // 2. The AACS host-certificate cert-auth handshake produced the bus key - // (`read_data_key`). - // The old gate credited ONLY (2), so a SUCCESSFUL drive unlock (VID - // present, `read_data_key: None`, `drive_unlocked: true`) tripped it and - // blocked ALL key resolution — including the online source — even though - // the drive was serving clear content. That was the bug. - // - // Also skipped when `handshake = None` (file-backed/ISO scans — bus - // encryption already removed at read time) and when `bus_encryption` is - // false (AACS 1.0 BD is not bus-encrypted). - // ONE question — "is AACS bus encryption gone?" — asked of the single - // `bus_encryption_removed` predicate, which OWNS every case (never had it, - // file/ISO, drive unlock, cert bus key). The gate enumerates nothing. + // Bus-encryption gate: removed by a drive unlock (`drive_unlocked`) or a + // cert handshake `read_data_key`. The old gate credited only the latter, + // wrongly blocking key resolution after a successful drive unlock. if !bus_encryption_removed(bus_encryption, handshake) { let (rdk_err, has_vid) = handshake .map(|h| (h.read_data_key_err, handshake_has_volume_id(h))) @@ -400,20 +372,9 @@ impl Disc { ); return Err(Error::AacsBusKeyUnavailable); } - // Read the MKB record stream via the SAME bounded reader the - // out-of-band `read_aacs_inputs` uses (`read_mkb_content`: a prefix-grow - // read + trim), NOT a full `read_file`. MKB_RO/RW is allocated to a - // fixed ~128 MiB of zero padding, and a full `read_file` of it FAILS on - // file-backed / large readers — which left `a.mkb` empty here, silently - // breaking online key resolution: `Disc::inputs()` shipped `mkb=0` to - // the decode service and it 404'd, while autorip's separate - // `read_aacs_inputs` path (this same helper) worked. One reader now, so - // `Disc::inputs()` is the single complete source of AACS inputs. - // A read ERROR is surfaced (logged), not silently emptied: an empty MKB - // here is invisible until an online key service rejects the request, so - // a transient I/O hiccup must not masquerade as "no MKB". We still - // continue with an empty MKB (disc-hash-keyed keydb lookups don't need - // it), but the cause is now on the log. + // Use the SAME bounded reader as `read_aacs_inputs` (`read_mkb_content`), + // NOT `read_file` (fails on the ~128 MiB zero-padded MKB_RO/RW, silently + // emptying `a.mkb`). A read ERROR is logged; we still continue with an empty MKB. let mkb_bytes = match Self::read_mkb_content(reader, udf_fs) { Ok(m) => m, Err(e) => { @@ -484,11 +445,8 @@ mod tests { ); } - // --------------------------------------------------------------- - // In-memory disc + minimal UDF image with a single physical - // partition (metadata_start == partition_start). Offsets cited - // against udf.rs::read_filesystem / ECMA-167. - // --------------------------------------------------------------- + // In-memory disc + minimal UDF image with a single physical partition + // (metadata_start == partition_start), per udf.rs::read_filesystem / ECMA-167. const PART_START: u32 = 4000; @@ -856,12 +814,9 @@ mod tests { assert_eq!(st.read_data_key, Some(rdk)); } - // --------------------------------------------------------------- - // OEM bus-key gate: a bus-encrypted disc scanned on a LIVE drive - // (handshake present) with no read_data_key must HARD-ERROR - // (AacsBusKeyUnavailable) rather than silently yield garbage. The - // three non-regressing cases must still succeed. - // --------------------------------------------------------------- + // OEM bus-key gate: a bus-encrypted disc on a LIVE drive with no + // read_data_key must HARD-ERROR (AacsBusKeyUnavailable); three + // non-regressing cases must still succeed. fn disc_with_cert(cert_type: u8, bus_encryption: bool) -> (MemDisc, udf::UdfFs) { let mut disc = MemDisc::new(); @@ -965,8 +920,8 @@ mod tests { assert_eq!(st.read_data_key, None); } - /// Unit_Key_RO.inf is read from /AACS/DUPLICATE when the primary copy - /// is absent (encrypt.rs `.or_else(|_| read_file(DUPLICATE/...))`). + /// `handshake_has_volume_id` treats an all-zero Volume ID as absent and + /// any non-zero Volume ID as present. #[test] fn handshake_has_volume_id_reports_presence_not_absence() { let with_vid = HandshakeResult { @@ -1017,21 +972,12 @@ mod tests { assert!(matches!(err, Error::AacsBusKeyUnavailable)); } - // --------------------------------------------------------------- - // Tests: read_vid_oem (response parsing). The OEM path issues a - // READ_BUFFER CDB and parses a 36-byte response; we can't easily - // fixture a real Drive, but the response-shape contract (3-byte - // signature 00 22 00, VID at [4..20]) is documented and worth a - // direct guard via a fake transport. Skipped here because Drive - // construction requires a live transport; the parsing branches are + // Tests: read_vid_oem (response parsing) are skipped here since Drive + // construction requires a live transport; parsing branches are // exercised through `read_vid_oem`'s callers in integration. - // --------------------------------------------------------------- - // --------------------------------------------------------------- - // Tests: collect_host_certs — the OEM cert route's cert-gathering. - // Unions DriveCredentials with the key-source layer; empty means - // the route fails gracefully (AacsNoHostCert), never panics. - // --------------------------------------------------------------- + // Tests: collect_host_certs — unions DriveCredentials with the key-source + // layer; empty means the route fails gracefully (AacsNoHostCert). fn fake_cert(tag: u8) -> aacs::types::HostCert { aacs::types::HostCert { @@ -1109,11 +1055,8 @@ mod tests { assert_eq!(tags, vec![1, 2, 3]); } - // --------------------------------------------------------------- - // AacsCertUnlocker outcome mapping: UnlockError → Error (preserving - // the legacy do_handshake_cert surface) and → UnlockOutcome (the - // structured trace step). No English in either. - // --------------------------------------------------------------- + // AacsCertUnlocker outcome mapping: UnlockError → Error (legacy surface) + // and → UnlockOutcome (structured trace step). No English in either. #[test] fn unlock_error_maps_to_legacy_error_variants() { diff --git a/src/disc/extract.rs b/src/disc/extract.rs index 4a7ed96b..9ff1057f 100644 --- a/src/disc/extract.rs +++ b/src/disc/extract.rs @@ -190,28 +190,9 @@ impl Disc { // disc-wide keys for every file. let mut base_keys = self.decrypt_keys(); - // AACS key map for the extract, chosen by CPS-unit count: - // - // * SINGLE CPS (the overwhelming majority, incl. every single-key UHD): one - // Unit Key opens EVERY encrypted unit on the disc — content in a parsed - // title AND an orphan clip that no playlist references. A blanket key-0 - // map over the whole LBA space is exact and covers orphans; clear - // filesystem/nav (encrypted-flag off) passes through untouched. - // - // * MULTI-CPS: each clip is protected by a different Unit Key, so a blanket - // key-0 map would mis-decrypt every secondary-CPS file into garbage - // (silently, since Phase::All is trust-only). Build the EXACT per-CPS - // content map instead (each title's extents → the CPS key that opens a - // real sample from it), up front before the decorator takes the reader. A - // content unit whose key the pool lacks fails loud at resolve (extract has - // no CPS/forensic fetch source), never emits a wrong-key garble. - // - // KNOWN LIMITATION (by design): an orphan encrypted clip on a multi-CPS - // disc — referenced by no playlist, so in no title extent — is in no range - // and passes through as ciphertext. There is no correct key to apply (its - // CPS unit is unknown without a playlist reference), and blind trial-decrypt - // is exactly what this keymap-only model removes. Single-CPS is unaffected - // (the blanket key-0 map above covers orphans). + // AACS key map, by CPS-unit count. Single-CPS: one Unit Key opens + // everything, so a blanket key-0 map covers even orphan clips. Multi-CPS + // builds an exact per-title map instead (a blanket map would mis-decrypt). let key_map = match &base_keys { DecryptKeys::Aacs { unit_keys, .. } if unit_keys.len() <= 1 => { @@ -225,10 +206,9 @@ impl Disc { _ => None, }; - // ── Phase 2: stream each file through the decrypting decorator ──── - // The decorator owns its inner source for its lifetime. We hand it a - // borrowing wrapper (so the caller keeps `reader`), swap keys per CSS - // VTS group via `set_keys`; AACS/None keep `base_keys` throughout. + // Phase 2: stream each file through the decrypting decorator, which owns a + // borrowing wrapper so the caller keeps `reader`. Keys swap per CSS VTS + // group via `set_keys`; AACS/None keep `base_keys` throughout. let mut dec = DecryptingSectorSource::new(Borrowed(reader), base_keys.clone()); if let Some(map) = key_map { dec = dec.with_key_map(map); @@ -270,10 +250,8 @@ impl Disc { } } - // A unit that fails to decrypt fails the read loud (extract runs - // non-tolerate), so extract_one_file already zero-filled it and - // counted it in bytes_unreadable — one 'lost' bucket covers both - // media damage and decrypt failure. + // A unit that fails to decrypt fails the read loud; extract_one_file + // zero-fills it into bytes_unreadable, the same bucket as media damage. let (fr, halted) = extract_one_file( &mut dec, dest, @@ -321,40 +299,24 @@ impl Disc { dec: &mut DecryptingSectorSource, base_keys: &DecryptKeys, ) -> Result { - // Gather the title VOB extents for this VTS (VTS_xx_1.VOB .. _9.VOB; - // VTS_xx_0.VOB is the menu and is clear, so excluded from the crack). - // Gather this VTS's title VOBs BY NAME, ascending. - // - // `planned` comes from walking the UDF directory, which yields File - // Identifier Descriptors in on-disc order — authoring order, with no - // guarantee it matches playback order. Relying on it would leave the - // crack starting wherever the disc's directory happened to list first. - // - // DVD-Video numbers a title set's VOBs in playback order by spec - // (VTS_xx_1.VOB .. VTS_xx_9.VOB, single digit), so ascending name IS - // playback order and it is deterministic regardless of directory - // layout. That matters because ORDER decides correctness here: see the - // note below on the shared crack budget. + // Gather this VTS's title VOBs (VTS_xx_1..9.VOB, _0.VOB menu excluded) BY + // NAME, ascending. Directory order is authoring order not playback order, + // but DVD-Video numbers VOBs in playback order, so name-sort is correct. let mut files: Vec<&PlannedFile> = planned .iter() .filter(|pf| { vts_group_of(&pf.disc_name).as_deref() == Some(vts) && is_title_vob(&pf.disc_name) }) .collect(); - // Case-INSENSITIVE, matching `vts_group_of`/`is_title_vob`, which - // selected these files case-insensitively. A byte-wise sort put - // 'V' (0x56) before 'v' (0x76), so a set holding `vts_01_1.vob` - // alongside `VTS_01_2.VOB` — reachable on a case-sensitive volume — - // started the crack at part 2 and could exhaust the budget in a clear - // run, which is the whole failure this ordering exists to prevent. + // Case-INSENSITIVE, matching the selection above. A byte-wise sort could + // start the crack at the wrong part on a case-sensitive volume, exhausting + // the budget in a clear run — the exact failure this ordering prevents. files.sort_by_key(|f| f.disc_name.to_ascii_uppercase()); let mut extents: Vec = Vec::new(); for pf in files { - // Unrecorded extents hold no bytes the VOB ever wrote, so they can - // carry no scrambled sector for the crack to work from — feeding - // them in spends the shared sector budget on media that is not part - // of the title. + // Unrecorded extents hold no bytes the VOB ever wrote and carry no + // scrambled sector, so feeding them in wastes the shared crack budget. for ext in pf.extents.iter().filter(|e| e.recorded) { extents.push(crate::disc::Extent { start_lba: ext.lba, @@ -365,26 +327,9 @@ impl Disc { if extents.is_empty() { return Ok(base_keys.clone()); } - // PLAYBACK ORDER — do NOT sort. This is the 1.5.1 garbage bug, and it - // grew back here in a new code path: the comment this replaced said it - // "matched the scan heuristic", but that heuristic WAS the bug and was - // already fixed in `Disc::decrypt_keys_for_title`, which documents the - // rule ("PLAYBACK ORDER, never largest-cell-first") and pins it with - // `decrypt_keys_for_title_scans_playback_order_not_largest_first`. - // - // Why order decides correctness: a CSS DVD's biggest cell opens with a - // long CLEAR run, and `crack_key`'s sector budget is shared across the - // whole extent list. Starting at the largest cell can exhaust the budget - // without ever MEETING a scrambled sector — and CSS recovers the title - // key from scrambled data itself, so the scan has to reach some. The - // crack then returns None, this function falls back to `base_keys`, and - // every VOB in the VTS is descrambled with the wrong key: corrupt PES - // behind an intact header, written out as a complete extract at exit 0. - // - // The files above are ordered by name and each file's own extents stay - // in file order, which together is playback order for a DVD title set. - // Crack against the raw (still-scrambled) inner reader, NOT the - // decrypting view — `crack_key` runs the descrambler itself. + // PLAYBACK ORDER — do NOT sort (the 1.5.1 garbage bug: see + // `Disc::decrypt_keys_for_title`'s "never largest-cell-first" rule). + // Crack against the raw inner reader, NOT the decrypting view. match crate::css::crack_key_outcome(dec.inner_mut(), &extents, 64, None) { crate::css::CrackOutcome::Cracked(state) => Ok(DecryptKeys::Css { title_key: state.title_key, @@ -393,24 +338,9 @@ impl Disc { // and any key descrambles it as a no-op. The disc-wide key is the // right answer, and this is the ONLY case that ever was. crate::css::CrackOutcome::Unencrypted => Ok(base_keys.clone()), - // Scrambled sectors WERE seen and no key came out. Reusing the - // disc-wide key here writes corrupt PES behind an intact header and - // reports a complete extract at exit 0. - // - // `CssKeyMissing` is the per-TITLE code rather than the disc-level - // `CssNoDiscKey` because the two are treated differently elsewhere: - // it is what `error::is_skippable_title_stub` matches, and the MUX - // path really does skip a title on it and carry on with the rest of - // the disc. - // - // EXTRACT does not skip. This error is `?`-propagated by the only - // caller, out of the loop over every planned file, so one - // uncrackable VTS aborts the whole extract — including files from - // sibling VTS groups that would have cracked. That is deliberate: - // aborting loudly is the safe failure here, and pinned by - // `a_scrambled_vts_that_cannot_be_cracked_fails_instead_of_borrowing_a_key`. - // Do not read the code choice as a promise that extraction - // continues past this point. + // Scrambled sectors WERE seen and no key came out. `CssKeyMissing` + // (unlike MUX, which skips on it) is `?`-propagated here, aborting + // the whole extract rather than reusing the wrong key silently. crate::css::CrackOutcome::ScrambledUncracked => Err(Error::CssKeyMissing), } } @@ -476,34 +406,9 @@ fn plan_tree( let safe = sanitize_component(&entry.name)?; let child_rel = host_rel.join(&safe); let child_disc = format!("{disc_path}/{}", entry.name); - // Collision: two distinct disc paths → same host FILE. The key must - // model the HOST's namespace, not the disc's, and the two differ twice - // over: - // - // * CASE. macOS APFS and Windows NTFS are case-insensitive by default, - // so `Movie` and `movie` are one file there. Keyed by the - // case-preserving path they were two entries, nothing collided, and - // the second extraction overwrote the first with both files still - // reported `complete: true`. - // * The `.partial` SUFFIX. `extract_one_file` streams through - // `.partial` before renaming, so a file's temp path lives in - // the same namespace as every other file's final path: a disc - // holding both `X` and `X.partial` planned two distinct final names, - // and extracting `X` then truncated the real `X.partial`. - // - // Only files get the `.partial` alias — directories are created - // directly and never stream through a temp name — but it is checked - // against every entry's primary key, so a directory `X.partial` beside - // a file `X` is caught too. The alias stores the OWNING file's disc - // path, so the `prev != child_disc` test keeps its meaning: a repeated - // identical disc path is not a collision, two different ones are. - // - // The fold is `to_lowercase`, which closes the ASCII and simple-Unicode - // case classes. It is NOT full case folding, and it does NOT normalize: - // APFS also unifies NFC/NFD, so `é` recorded as U+00E9 and as - // `e`+U+0301 remain two keys here and one file there. That residual is - // a false NEGATIVE (a missed collision, never a spurious one), so this - // is a strict improvement rather than a complete model of the host. + // Collision: two distinct disc paths → same host FILE (case-insensitive + // fold of `Movie`/`movie`, or `.partial` sharing the final-name namespace + // so `X`/`X.partial` collide too). Fold is ASCII/simple-Unicode only. let mut register = |key: PathBuf| -> Result<()> { let folded = key.to_string_lossy().to_lowercase(); if let Some(prev) = seen_hosts.insert(folded, child_disc.clone()) @@ -608,14 +513,9 @@ fn extract_one_file( if written >= pf.size { break; } - // ECMA-167 4/14.14.1.1 type 1: allocated but NOT recorded. The extent - // is part of the file's byte space and its contents are defined to be - // zeros, so write the zeros WITHOUT reading the media — those sectors - // hold nothing this file ever wrote (on an AACS disc, ciphertext that - // decrypts to noise). This mirrors `UdfFs::read_file_limited`, which - // takes the same decision from the same flag. Skipping the extent - // entirely instead would slide every later extent's bytes down by the - // hole's length. + // ECMA-167 4/14.14.1.1 type 1: allocated but NOT recorded. Write zeros + // WITHOUT reading the media (mirrors `UdfFs::read_file_limited`) — skipping + // the extent entirely would slide every later extent's bytes down. if !recorded { let sectors = (byte_len as u64).div_ceil(SECTOR_BYTES_U64); let hole_bytes = (sectors * SECTOR_BYTES_U64).min(pf.size.saturating_sub(written)); @@ -640,26 +540,16 @@ fn extract_one_file( } continue; } - // Anchor AACS unit alignment at THIS extent's start (clip-anchored - // gate, not absolute LBA 0 and NOT the file's first extent). A file - // may span multiple extents (fragmented / Long-AD / continuation ICB - // allocation); each extent's start LBA is arbitrary and generally not - // a multiple of 3 sectors from the previous extent. The decrypt-on- - // read gate (`is_unit_aligned(lba, unit_base)`) measures every read's - // LBA relative to `unit_base`, so the base MUST be re-anchored per - // extent — otherwise the first read of every later extent fails the - // gate and the whole extent is zero-filled as a (false) hole. Matches - // the per-extent re-anchoring in the mux read paths - // (`mux/disc.rs`, `sector/prefetched.rs`). No-op for CSS / None. + // Anchor AACS unit alignment at THIS extent's start, not LBA 0 or the + // file's first extent: a fragmented extent's start LBA is arbitrary, so + // the gate must re-anchor per extent or a later read false-holes. No-op for CSS/None. dec.set_unit_base(abs_lba); let sectors = (byte_len as u64).div_ceil(SECTOR_BYTES_U64) as u32; let mut sector_off: u32 = 0; while sector_off < sectors { - // AACS: read whole units (see `whole_unit_batch`). The tail batch may - // be a 1–2 sector partial unit, which `decrypt_sectors` handles via - // its trailing-partial contract: a clear partial stays clear (AACS - // leaves the final short unit unencrypted on disc), a scrambled - // partial fails loud as DecryptFailed. + // AACS: read whole units (see `whole_unit_batch`). A 1-2 sector tail + // partial is handled by `decrypt_sectors`'s trailing-partial contract: + // clear stays clear, scrambled fails loud as DecryptFailed. let batch = whole_unit_batch(sectors - sector_off); let lba = abs_lba + sector_off; let want = batch as usize * SECTOR_BYTES; @@ -686,8 +576,7 @@ fn extract_one_file( let cont = report(opts, *done_bytes, *done_unreadable, total_bytes); sector_off += batch; if opts.cancelled(cont) { - // Leave the `.partial`; do NOT rename. The aggregate run - // records the halt and this file stays incomplete. + // Leave the `.partial`; do NOT rename — this file stays incomplete. return Ok((fr, true)); } if written >= pf.size { @@ -709,9 +598,8 @@ fn extract_one_file( /// [`READ_BATCH_SECTORS`] and rounded DOWN to a whole number of 3-sector units /// UNLESS it is the extent's final (possibly short) tail — the tail always /// begins on a unit boundary, so a 1–2 sector partial there is handled by -/// `decrypt_sectors`' trailing-partial contract. Shared by `extract_one_file` -/// (write) and `verify_one_clip` (dead-range) so this rounding rule lives in -/// exactly one place. +/// `decrypt_sectors`' trailing-partial contract. Used by `extract_one_file` +/// so this rounding rule lives in exactly one place. fn whole_unit_batch(remaining: u32) -> u32 { let mut batch = remaining.min(READ_BATCH_SECTORS); if batch >= AACS_UNIT_SECTORS && batch < remaining { @@ -773,11 +661,9 @@ fn finalize_file( f.set_len(size).map_err(|e| Error::DirWriteFailed { errno: e.raw_os_error(), })?; - // `set_len` is a separate kernel op on this second handle (opened solely to - // truncate); the content fsync above was on the now-dropped writer handle. - // Without an fsync here, a crash between `set_len` and the rename can leave - // the file at its pre-truncation length — e.g. a sparse/over-read tail keeps - // its oversized form. Sync the new metadata (length) before publishing. + // `set_len` is a separate kernel op on this second handle; without an fsync + // here, a crash between `set_len` and the rename can leave the file at its + // pre-truncation length. Sync the new metadata (length) before publishing. f.sync_all().map_err(|e| Error::DirWriteFailed { errno: e.raw_os_error(), })?; @@ -803,11 +689,9 @@ fn report(opts: &ExtractOptions, done: u64, unreadable: u64, total: u64) -> bool kind: crate::progress::PassKind::Mux, work_done: done, work_total: total, - // `done` counts good AND zero-filled-unreadable bytes together; - // split them so a consumer driven only by the live progress - // channel sees a holed extraction as holed rather than as a clean - // climb to 100%. The final ExtractResult already carries the true - // split — this used to pin unreadable at 0 and call every byte good. + // `done` counts good AND unreadable bytes together; split them so a + // live-progress-only consumer sees a holed extraction as holed, + // not a clean climb to 100% (this used to pin unreadable at 0). bytes_good_total: done.saturating_sub(unreadable), bytes_unreadable_total: unreadable, bytes_pending_total: 0, @@ -843,10 +727,8 @@ fn available_space(dir: &Path) -> Option { if rc != 0 { return None; } - // `statvfs` field integer widths differ by platform (e.g. macOS `u64`, - // Linux glibc `u64`/`c_ulong`); cast both to `u64` for the product. The - // cast is a no-op where the field is already `u64` — allow the lint so the - // same source stays portable. + // `statvfs` field integer widths differ by platform; cast both to `u64` + // for the product (no-op where already `u64` — allow lint for portability). #[allow(clippy::unnecessary_cast, clippy::useless_conversion)] let avail = (st.f_bavail as u64).saturating_mul(st.f_frsize as u64); Some(avail) @@ -946,13 +828,9 @@ fn sanitize_component(name: &str) -> Result { host: name.to_string(), }); } - // Windows reserved device names (case-insensitive, base name before any - // extension). `NUL` silently discards all writes on Windows, so a disc file - // literally named `NUL.cfg` (legal on UDF/Linux) must NOT pass through - // verbatim — but it also must not abort the whole extraction. Substitute by - // prefixing `_`, which can never itself collide with another reserved name - // and is host-legal everywhere. The substituted name still flows through the - // caller's host-path collision check. + // Windows reserved device names (case-insensitive, base before extension). + // `NUL` silently discards writes on Windows, so `NUL.cfg` (legal on + // UDF/Linux) must not pass through verbatim; prefix `_` instead of aborting. let base = trimmed.split('.').next().unwrap_or(trimmed); if is_windows_reserved(base) { return Ok(format!("_{trimmed}")); @@ -1016,14 +894,9 @@ mod tests { use crate::css::lfsr; use std::collections::HashMap; - // ── Self-contained in-memory UDF fixture toolkit ────────────────────── - // Modeled on the bluray.rs / dvd.rs test fixtures: a `MemDisc` SectorSource - // backed by an explicit absolute-LBA map, plus recursive UDF builders. - // PART_START == metadata_start so file/ICB LBAs share one address space - // (single partition map). Encrypted content is faked at the buffer level - // (CSS via `lfsr::scramble_sector`); AACS-content tests use clear bytes - // through `DecryptKeys::None`, since per-unit AACS decrypt is already - // exhaustively tested in `decrypt.rs` / `decrypting.rs`. + // ── Self-contained in-memory UDF fixture toolkit ────────────────── + // Modeled on the bluray.rs / dvd.rs fixtures: a `MemDisc` SectorSource + // backed by an absolute-LBA map; AACS tests use clear bytes. const PART_START: u32 = 2000; @@ -2094,12 +1967,9 @@ mod tests { fn scrambled_vob(title_key: [u8; 5], marker: u8) -> (Vec, Vec) { let seed = [0x11u8, 0x22, 0x33, 0x44, marker]; let mut plain = vec![0u8; 2048]; - // The crack scan's hardened `is_scrambled_pack` gate requires the - // MPEG-PS pack-start signature before it will even ATTEMPT a key - // crack (see `css::is_scrambled_pack`) -- without it, - // `resolve_vts_key` silently falls back to `base_keys` for BOTH - // VTS groups regardless of which extents were gathered, masking - // this exact regression. + // The crack scan's `is_scrambled_pack` gate requires the MPEG-PS pack- + // start signature before attempting a crack; without it, `resolve_vts_key` + // falls back to `base_keys` for both groups, masking this regression. plain[0x00..0x04].copy_from_slice(&crate::css::PACK_START); // Pack start code: a real scrambled sector is an MPEG-2 PS pack, and // the descrambler requires it before trusting byte 0x14. @@ -2142,10 +2012,8 @@ mod tests { let out = TmpDir::new("css_two_vts"); let mut d = clear_disc(); d.content_format = crate::disc::ContentFormat::MpegPs; - // Disc-wide key deliberately matches NEITHER VTS's real key: if the - // per-VTS group filter is broken and a crack attempt fails (or is - // skipped), falling back to this key must still mismatch, so the - // fallback path can never accidentally mask a broken filter. + // Disc-wide key deliberately matches NEITHER VTS's real key, so a + // broken filter falling back to it can never accidentally mask itself. d.css = Some(crate::css::CssState { title_key: [0xFFu8; 5], crack_span: None, @@ -2202,10 +2070,8 @@ mod tests { lfsr::scramble_sector(&key_1, &mut scrambled); (plain, scrambled) }; - // Scrambled — the pack header and the 0x14 flag are set, so the scan - // SEES ciphertext — but the cleartext region carries no periodic run, - // so the repeating crib the key recovery relies on never forms and no - // key is recoverable. + // Scrambled — pack header and 0x14 flag set, so the scan SEES ciphertext — + // but the cleartext region has no periodic run, so no key is recoverable. let uncrackable = { let mut sect = vec![0u8; 2048]; sect[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); @@ -2322,10 +2188,8 @@ mod tests { // with sector_off > 0. const SECTORS_A: u32 = 1600; const LBA_A: u32 = 5000; - // The disc region immediately following extent A's true end. Must - // NEVER be read as part of extent A: sized to cover a full erroneous - // READ_BATCH_SECTORS second batch starting right after extent A's - // real tail. + // The disc region immediately following extent A's true end. Must NEVER + // be read as part of extent A: sized to cover a full erroneous second batch. const LBA_FILLER: u32 = LBA_A + SECTORS_A; const SECTORS_FILLER: u32 = 1472; // Extent B: the file's real second extent, at a completely different diff --git a/src/disc/hddvd.rs b/src/disc/hddvd.rs index 4dc5874e..65e5dae6 100644 --- a/src/disc/hddvd.rs +++ b/src/disc/hddvd.rs @@ -107,10 +107,9 @@ fn parse_vti_clip_order(vti: &[u8]) -> Vec { count += 1; } } - // Pick the largest residue bucket (the clip table). On a size tie, break - // deterministically by the bucket's smallest offset — `HashMap` iteration - // order is randomized, so `max_by_key` alone could pick a different bucket - // run-to-run on identical bytes. + // Pick the largest residue bucket (the clip table). On a size tie, break by the + // bucket's smallest offset, since `HashMap` iteration order is randomized and + // `max_by_key` alone could pick a different bucket run-to-run on identical bytes. let Some(mut best) = buckets .into_values() .max_by_key(|g| (g.len(), std::cmp::Reverse(g.iter().map(|(o, _)| *o).min()))) @@ -175,8 +174,10 @@ fn sniff_audio_codec(es: &[u8]) -> Option { /// Demux the head of an `.evo` clip (through the disc's [`SectorSource`]) and /// build one [`Stream`] per distinct elementary stream found: the video track -/// (mapped to the canonical [`DVD_VIDEO_PID`]) and every DD+ audio sub-stream -/// (mapped via [`dvd_audio_pid`]). Codec is sniffed from the demuxed ES bytes. +/// (mapped to the canonical [`DVD_VIDEO_PID`](crate::mux::ps::DVD_VIDEO_PID) for +/// plain 0xE0-0xEF video, or the extended-stream-id PID for HD-DVD VC-1) and +/// every DD+ audio sub-stream (mapped via [`dvd_audio_pid`]). Codec is sniffed +/// from the demuxed ES bytes. /// /// Mirrors the stream construction in `Disc::scan_dvd_titles`; resolution / /// language / channels use sane HD-DVD defaults (the muxer reads the true pixel @@ -196,11 +197,9 @@ fn probe_evo_streams( ) -> Result> { let mut demux = PsDemuxer::new(); let mut video: Vec = Vec::new(); - // Routing PID of the video track, captured from the first video PES seen: - // `DVD_VIDEO_PID` for a plain 0xE0-0xEF stream (a real disc's H.264 on 0xE2), - // or `0xFD00 | stream_id_extension` for an HD-DVD extended-stream-id video - // (a real disc's VC-1 on 0xFD ext 0x55). Kept in lockstep with `PsPacket::dvd_pid` - // so the emitted `Stream` PID matches what the demuxer routes at mux time. + // Routing PID of the video track, from the first video PES seen: `DVD_VIDEO_PID` + // for a plain 0xE0-0xEF stream, or `0xFD00 | stream_id_extension` for HD-DVD + // extended-stream-id video. Kept in lockstep with `PsPacket::dvd_pid`. let mut video_pid: Option = None; // sub_id -> ES sample, ordered so audio tracks surface in sub-id order. let mut audio: BTreeMap> = BTreeMap::new(); @@ -210,11 +209,9 @@ fn probe_evo_streams( let mut lba = ext.start_lba; let mut left = ext.sector_count; while left > 0 && remaining > 0 { - // 1 MiB read chunks (512 sectors) keep buffers small. - // Poll before every chunk, as the PGS probe does: a clip's probe - // budget is 16 MiB of blocking reads, and on marginal media each - // one can sit in the drive's retry path, so per-clip granularity - // alone would leave a Stop waiting on a dead disc. + // 1 MiB chunks (512 sectors). Poll every chunk, not just per-clip: + // a 16 MiB probe can sit in the drive's retry path on marginal + // media, and per-clip granularity alone would leave a Stop waiting. if halt.is_some_and(|h| h.is_cancelled()) { return Err(crate::error::Error::Halted); } @@ -222,10 +219,9 @@ fn probe_evo_streams( let mut buf = vec![0u8; n as usize * crate::consts::SECTOR_BYTES]; match reader.read_sectors(lba, n, &mut buf, false) { Ok(_) => {} - // A live-drive Stop surfaces HERE, not through the token: - // `Drive::checked_exec` fails every command with `Halted` once - // the flag is set. Swallowing it (as any other read error is - // swallowed) reports an un-probed clip as a probed one. + // A live-drive Stop surfaces HERE, via `Drive::checked_exec` + // failing with `Halted`. Swallowing it like other read errors + // would report an un-probed clip as probed. Err(crate::error::Error::Halted) => { return Err(crate::error::Error::Halted); } @@ -244,12 +240,9 @@ fn probe_evo_streams( } let mut streams = Vec::new(); - // Emit the video stream only when the codec was actually identified from the - // sampled head. Guessing (e.g. defaulting to H.264) would tag a VC-1 — or a - // still-encrypted — clip with the wrong codec, so the mux applies the wrong - // parser and produces a corrupt track; dropping it is the honest outcome - // (matches the audio path below), and a real clear clip always carries its - // sequence header / SPS at the head, so this never fires on a normal disc. + // Only emit video once the codec is actually identified from the sampled + // head; guessing would tag a VC-1 (or still-encrypted) clip wrong and feed + // it the wrong parser. A real clear clip always carries its header early. if let (Some(pid), Some(codec)) = (video_pid, sniff_video_codec(&video)) { streams.push(Stream::Video(VideoStream { pid, @@ -343,18 +336,9 @@ fn collect_es( } } -// ─────────────────────── Advanced-Content playlist (XPL) ────────────────── -// -// HD-DVD Advanced Content ships an authoritative playlist at -// `ADV_OBJ/VPLST000.XPL` (DVD-Forum `HDDVDVideo/Playlist` XML). It is the real -// player playlist: a `` of ``s, each naming its -// `<PrimaryAudioVideoClip>` clips (an EVO, via its `.MAP` sidecar) in playback -// order with title-time in/out points, a `titleDuration`, a `displayName`, and a -// `<ChapterList>`. Parsing it gives authoritative title composition — clips, -// duration, name, chapters — instead of the `feature*` clip-name heuristic, plus -// the per-clip title-time offsets needed to splice a layer-break split -// (`FEATURE_1` + `FEATURE_2`) onto one continuous timeline. Parsed with a real -// XML parser (`roxmltree`), not a hand-rolled scanner — the XPL is genuine XML. +// ─── Advanced-Content playlist (XPL) ─── +// `ADV_OBJ/VPLST000.XPL` is the real player playlist: `<Title>`s naming clips +// in order with in/out points, duration, name, chapters — parsed as real XML. /// One clip reference inside an XPL `<Title>`: the resolved `.evo` name (lower /// case) and the clip's placement on the title timeline, in seconds. @@ -694,16 +678,9 @@ fn parse_xpl_titles(xpl: &[u8]) -> Vec<XplTitle> { .descendants() .filter(|n| local(n, "PrimaryAudioVideoClip")) { - // Bounded by MAX_XPL_CLIPS_PER_TITLE. `descendants()` is deliberate - // — a real playlist wraps its clips in `<PrimaryAudioVideoClipList>` - // or an `ApplicationSegment`, so `children()` would miss them — but - // it also means a clip nested inside N ancestor `<Title>`s is - // collected N times over, and the depth guard still allows 32 such - // ancestors. `break` rather than dropping surplus: unlike the - // directory scan (which must keep reading to find the `.vti`), - // nothing later in THIS loop is needed, and stopping bounds the walk - // as well as the allocation. Chapters are collected by a separate - // iterator below, so they are unaffected. + // Bounded by MAX_XPL_CLIPS_PER_TITLE. `descendants()`, not + // `children()`, is needed to reach wrapped clips, but that also + // means nested `<Title>`s multiply hits, so `break` bounds the walk. if clips.len() >= MAX_XPL_CLIPS_PER_TITLE { break; } @@ -779,25 +756,18 @@ fn compose_xpl_titles( // a crafted one can name a single `.evo` from all MAX_XPL_TITLES titles. let mut probes = EvoProbeCache::default(); for t in xpl_titles { - // A clip that exists but has no truthful read plan poisons every - // title that names it: composing around it would emit a title short - // by that clip's bytes while its durations and chapter offsets still - // assume them. + // A clip with no truthful read plan poisons every title naming it: + // composing around it emits a title short by that clip's bytes while + // its durations and chapter offsets still assume them. if t.clips.iter().any(|c| unusable.contains(&c.evo)) { continue; } let mut extents = Vec::new(); let mut size_bytes = 0u64; let mut parts = Vec::new(); - // A crafted `<PrimaryAudioVideoClip>` list can name the same `.evo` - // any number of times (XML has no fixed element count, unlike MPLS's - // binary PlayItem count). Mirrors bluray.rs's - // `first_ref = seen_clips.insert(...)` gate (`:113`/`:117`): push a - // clip's physical extents and size only the first time its key is - // seen, so a clip named N times contributes its extent list ONCE - // instead of N times over. The analogous key here is the `.evo` - // filename — this format has no clip_id, and `.evo` is what - // `clip_extents` is already keyed by. + // A crafted clip list can name the same `.evo` any number of times. + // Mirrors bluray.rs's `seen_clips.insert(...)` gate: push a clip's + // extents/size only the first time its `.evo` key is seen. let mut seen_evos: std::collections::HashSet<&str> = std::collections::HashSet::new(); for c in &t.clips { let Some((orig, size, exts)) = clip_extents.get(&c.evo) else { @@ -893,11 +863,9 @@ impl Disc { } let lower = e.name.to_ascii_lowercase(); if lower.ends_with(HDDVD_CLIP_EXT) { - // Bounded by MAX_HDDVD_CLIPS: each clip taken here costs an ICB - // read to resolve its extents and (in the fallback path below) a - // full probe_evo_streams pass. Surplus entries are DROPPED rather - // than `break`ing the loop, so a crafted disc cannot hide the - // `.vti` navigation file behind a wall of `.evo` names. + // Bounded by MAX_HDDVD_CLIPS. Surplus entries are DROPPED, not + // `break`ed on, so a crafted disc can't hide the `.vti` behind + // a wall of `.evo` names (each clip costs an ICB read + probe). if clips.len() < MAX_HDDVD_CLIPS { clips.push((e.name.clone(), e.size)); } @@ -906,18 +874,9 @@ impl Disc { } } - // Authored clip order from the VTI clip table (empty if no VTI). - // - // The VTI name came from `ts_dir.entries`, so a failed read here is a - // real I/O error (a scratched sector under the `.vti`), never an absent - // file. `.ok()` used to flatten the two — dropping the authored order - // with no diagnostic, so a split feature then composed from the per-clip - // heuristic and the operator was never told the authored order existed - // but could not be read. The clip-extent arms below log every read - // failure with its own code; this one now does too. The fallback itself - // is unchanged (no order => per-clip, exactly as an unauthored disc), - // because there is nothing to compose from without the table. Logging is - // exempt from the no-English rule (errors stay numeric). + // Authored clip order from the VTI clip table (empty if no VTI). The + // name came from `ts_dir.entries`, so a failed read is a real I/O + // error, not an absent file — log it rather than silently falling back. let order: Vec<String> = match vti_name { None => Vec::new(), Some(n) => match udf_fs.read_file(reader, &format!("/HVDVD_TS/{n}")) { @@ -936,12 +895,9 @@ impl Disc { // Resolve each clip's physical extents once, keyed by lower-case name. let mut clip_extents: BTreeMap<String, (String, u64, Vec<Extent>)> = BTreeMap::new(); - // Clips that EXIST but cannot be given a truthful read plan, because - // the file carries an unrecorded (never-written) extent — see - // `UdfFs::file_extents`. Kept apart from "did not resolve": a title - // built without one of these is short by that clip's runtime while - // still claiming its duration and size, which is data loss shaped - // like an ordinary rip. Any title naming one is dropped instead. + // Clips that EXIST but have no truthful read plan (unrecorded extent, + // see `UdfFs::file_extents`). Composing around one silently shorts a + // title's runtime, so any title naming one is dropped instead. let mut unusable: std::collections::HashSet<String> = std::collections::HashSet::new(); for (name, size) in &clips { if halt.is_some_and(|h| h.is_cancelled()) { @@ -960,21 +916,9 @@ impl Disc { } } Err(crate::error::Error::UdfUnrecordedExtent { .. }) => { - // Say so. Marking the clip unusable drops every title that - // names it, and in the fallback case the OTHER part of a - // split feature is still offered standalone — so half a - // movie is presented as a whole one. Silently returning Ok - // with the feature missing is the shape of data loss this - // refusal exists to prevent, pointed the other way. - // - // The Blu-ray half of this same fix already warns; this - // file had no diagnostics at all. Logging is exempt from - // the crate's no-English rule (errors stay numeric). - // - // Named constant, not the literal 6017 this used to carry: - // a hardcoded code in the very file whose sibling arm was - // changed to stop doing that is the next drift waiting to - // happen, and the literal cannot follow a renumbering. + // Marking unusable drops every title naming this clip; + // silently composing without it would present half a movie + // as a whole one. Named constant, not the old literal 6017. tracing::warn!( target: "freemkv::disc", clip = ?name, @@ -984,22 +928,9 @@ impl Disc { unusable.insert(name.to_ascii_lowercase()); } Err(crate::error::Error::Halted) => return Err(crate::error::Error::Halted), - // EVERY other failure means the same thing: no truthful read - // plan for this clip. A scratched sector under its ICB - // (DiscRead), an allocation-descriptor chain that never - // terminated (UdfAdChainTooLong), a file whose data is - // embedded rather than extent-mapped (UdfEmbeddedData) — all - // of them used to land in a bare `Err(_) => {}`: no log, and - // the clip NOT marked unusable, so a split feature still - // composed from FEATURE_1 alone and presented half a movie as - // a whole one. That is the very outcome the arm above was - // written to prevent, reachable through every error but one. - // - // The code logged is the error's own. Reusing 6017 here would - // account a scratched disc as an authoring hole. - // - // (`UdfNotFound` needs no special case: these names came from - // `ts_dir.entries`, so the lookup cannot miss.) + // EVERY other failure (scratched ICB, dead AD chain, embedded + // data, ...) means no truthful read plan; used to fall through + // a bare `Err(_) => {}`, unmarked and unlogged. Err(e) => { tracing::warn!( target: "freemkv::disc", @@ -1010,42 +941,9 @@ impl Disc { unusable.insert(name.to_ascii_lowercase()); } } - // A clip that resolved with NO usable extent is unusable too, and - // for exactly the same reason as the `Err` arms above. - // - // Round 1 accounted for every `Err` from `file_extents` and left - // this route open: `Ok` can still yield an empty AD list, or a list - // every entry of which the `sectors > 0 && lba > 0` filter above - // discards (a zero-length placeholder AD, or one pointing at LBA - // 0 — see `UdfFs::file_extents`). The clip then entered NEITHER - // `clip_extents` NOR `unusable`, and nothing was logged. The - // composer's `any(|n| unusable.contains(..))` guard below therefore - // missed it and the `filter(|n| clip_extents.contains_key(..))` - // beside it quietly deleted the part: a `FEATURE_2.EVO` of size 0 - // next to a healthy `FEATURE_1.EVO` composed a FEATURE title out of - // part one alone, still advertising the whole runtime, at rc=0, - // silently. That is strictly WORSE than the `Err` case this guard - // was built for — half a movie presented as a whole one — and it is - // reachable from an ordinary zero-byte file, no crafting needed. - // - // WHY THE BLU-RAY HALF DELIBERATELY DIFFERS. `bluray.rs` documents - // this same `Ok`-but-empty hole as a KNOWN GAP and leaves it open, - // and that decision still stands there — not here. Two things are - // different. (1) Cost: BD has no `unusable` set, so closing it - // there means a post-loop "every clip_id must appear in `spans`" - // invariant that DROPS the title, and it is not settled that an - // empty-but-Ok resolve is always a defect rather than a legitimate - // healthy-disc state; dropping healthy titles is worse than the - // gap. Here the set already exists, so refusing costs nothing new. - // (2) Consequence: on BD the clip is one PlayItem of a title that - // is otherwise whole; on HD-DVD the authored feature is COMPOSED - // from parts, so the missing one is silently spliced out of a title - // that keeps claiming the full runtime. Same hole, different price. - // The asymmetry is deliberate; it is written down here so the next - // audit reads a decision instead of finding an oversight. - // - // `insert` returning false means an `Err` arm above already logged - // this clip — no second line for one clip. + // `Ok` with NO usable extent (empty/filtered AD list) is unusable + // too: else a zero-byte `FEATURE_2.EVO` beside a healthy part one + // silently composed a whole-runtime title missing half the movie. if extents.is_empty() { if unusable.insert(name.to_ascii_lowercase()) { // Not the neighbouring 6017: an empty AD list is not an @@ -1063,11 +961,9 @@ impl Disc { } } - // Authoritative composition from the Advanced-Content playlist - // (`ADV_OBJ/VPLST*.XPL`): real per-title clip lists, durations, names, - // chapters, and title-time offsets. This is the primary path; the - // clip-name heuristic below is the fallback when the playlist is absent - // or unparseable (or resolves to no on-disc clips). + // Authoritative composition from `ADV_OBJ/VPLST*.XPL`, if present and + // parseable. The clip-name heuristic below is the fallback when it's + // absent, unparseable, or resolves to no on-disc clips. if let Some(xpl) = read_adv_obj_xpl(reader, udf_fs) { let composed = compose_xpl_titles( reader, @@ -1081,12 +977,9 @@ impl Disc { } } - // Feature clips, in authored order, that actually resolved to extents. - // If ANY authored feature part has no truthful read plan, the composed - // feature title would silently omit that part's bytes — emit no - // composed feature at all rather than a short one. (The per-clip - // titles below are unaffected: each stands alone and an unusable clip - // simply yields no title.) + // Feature clips, in authored order, that resolved to extents. If ANY + // authored feature part has no truthful read plan, emit no composed + // feature at all rather than one silently short that part's bytes. let feature: Vec<String> = if order .iter() .filter(|n| is_feature_clip(n)) @@ -1325,11 +1218,9 @@ mod tests { #[test] fn parse_vti_clip_order_is_deterministic_on_a_bucket_size_tie() { - // Two residue buckets of EQUAL size must resolve to the SAME winner every - // call — `HashMap` iteration is randomized, so a `max_by_key` without a - // deterministic tie-break could pick a different bucket run-to-run on - // identical bytes. Build a VTI whose stray `.EVO` names tie the real - // table's bucket count, then assert the result is stable across calls. + // Two equal-size buckets must resolve to the SAME winner every call: + // `HashMap` iteration is randomized, so `max_by_key` without a + // deterministic tie-break could pick a different bucket run-to-run. let mut vti = vec![0u8; 0x600]; vti[..HDDVD_VTI_MAGIC.len()].copy_from_slice(HDDVD_VTI_MAGIC); let put = |v: &mut Vec<u8>, off: usize, name: &str| { @@ -1420,10 +1311,9 @@ mod tests { #[test] fn scan_hddvd_composes_split_feature_into_one_title() { - // A disc whose feature is FEATURE_1 + FEATURE_2 (a layer-break split), plus - // a TRAILER extra. The VTI names them in authored order; the scan must - // JOIN the two feature parts into one title (so the largest-title pick is - // the whole movie) and keep the trailer as its own title. + // FEATURE_1 + FEATURE_2 (a layer-break split) plus a TRAILER extra; + // the scan must JOIN the feature parts into one title and keep the + // trailer separate. let mut disc = MemDisc::new(); let vti = synthetic_vti(&["FEATURE_1.EVO", "FEATURE_2.EVO", "TRAILER.EVO"]); let files = vec![ @@ -1720,11 +1610,9 @@ mod tests { assert_eq!(sniff_video_codec(&[0x00, 0x00, 0x01, 0x61, 0x9A]), None); assert_eq!(sniff_video_codec(&[0xDE, 0xAD, 0xBE, 0xEF]), None); - // Overlap regression: a picture_start_code (0x00) whose payload begins - // with 00 00 must advance a full 4 bytes so the code byte isn't re-read - // as the start of a new marker. Here the picture is followed by a real - // MPEG-2 sequence header — the scan must reach it cleanly and return - // Mpeg2 (and, critically, not be confused by the 1-byte overlap). + // Overlap regression: a picture_start_code (0x00) with payload 00 00 + // must advance a full 4 bytes so the code byte isn't re-read as a new + // marker; the real MPEG-2 sequence header after it must still be found. assert_eq!( sniff_video_codec(&[0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0xB3, 0x2D]), Some(Codec::Mpeg2) @@ -1987,10 +1875,9 @@ mod tests { #[test] fn collect_es_routes_only_vc1_0xfd_to_video() { use crate::mux::ps::hddvd_extended_pid; - // The 0xFD guard: only the VC-1 extension (0x55) is video. An HD-audio - // 0xFD sub-stream (e.g. 0x72) that arrives FIRST must NOT stamp video_pid - // with its PID or pollute the video sample — else the real video track is - // lost. (Routing 0xFD audio to its own track is deferred.) + // The 0xFD guard: only VC-1 (ext 0x55) is video. An HD-audio 0xFD + // sub-stream (e.g. 0x72) arriving FIRST must not stamp video_pid or + // pollute the video sample, else the real video track is lost. let mut video = Vec::new(); let mut video_pid: Option<u16> = None; let mut audio = BTreeMap::new(); @@ -3150,10 +3037,9 @@ mod tests { }; build_udf_skeleton(&mut disc, 10); lay_dir(&mut disc, &root); - // Blank FEATURE_2's ICB (laid at PART_START + 101). Its descriptor tag - // is then 0, neither 261 nor 266 — what the parser sees when the sector - // holding an ICB cannot be read back intact. Deliberately NOT an - // unrecorded extent: that class was already handled. + // Blank FEATURE_2's ICB: descriptor tag becomes 0 (neither 261 nor + // 266), what the parser sees when an ICB sector can't be read back + // intact. Deliberately not an unrecorded extent — that's covered elsewhere. disc.put_bytes(PART_START + 101, &[0u8; 2048]); let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); assert!( @@ -3490,10 +3376,9 @@ mod tests { let budget = |cap: usize| { cap as u64 * u64::from(EVO_PROBE_SECTORS) * crate::consts::SECTOR_BYTES as u64 }; - // BOTH title-composition paths pay one probe per item, and a crafted - // disc reaches either one: the directory fallback when `/ADV_OBJ` is - // omitted, the playlist path when it is present. Capping only one moves - // the amplification next door. + // Both paths pay one probe per item, and a crafted disc reaches + // either: the directory fallback when `/ADV_OBJ` is omitted, the + // playlist path when present. Capping only one moves the amplification. for (name, cap) in [ ("MAX_HDDVD_CLIPS", MAX_HDDVD_CLIPS), ("MAX_XPL_TITLES", MAX_XPL_TITLES), diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 0333c942..b2466cf4 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -496,9 +496,6 @@ pub struct Extent { pub sector_count: u32, } -/// Union a set of extents into sorted, merged, disjoint `(start_lba, -/// sector_count)` ranges — the pure, testable core of -/// [`Disc::encrypted_content_ranges`]. Reuses [`crate::udf::merge_ranges`]. /// Is this disc structurally AACS-encrypted — i.e. does it carry an AACS /// directory? /// @@ -518,6 +515,9 @@ pub(crate) fn aacs_dir_present(udf_fs: &crate::udf::UdfFs) -> bool { udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some() } +/// Union a set of extents into sorted, merged, disjoint `(start_lba, +/// sector_count)` ranges — the pure, testable core of +/// [`Disc::encrypted_content_ranges`]. Reuses [`crate::udf::merge_ranges`]. fn merged_extents<'a>(extents: impl Iterator<Item = &'a Extent>) -> Vec<(u32, u32)> { let mut ranges: Vec<(u32, u32)> = extents.map(|e| (e.start_lba, e.sector_count)).collect(); ranges.sort_by_key(|r| r.0); @@ -567,12 +567,9 @@ pub(crate) fn correct_truehd_channels(reader: &mut dyn SectorSource, title: &mut return; } let mut buf = vec![0u8; n as usize * 2048]; - // Anchor the AACS unit-alignment gate to the title's encrypted-region start - // before probing. Without this a `DecryptingSectorSource` falls back to an - // absolute `start_lba % 3` gate; a non-3-aligned `ext.start_lba` then trips - // DecryptFailed on the very first probe read, so the TrueHD channel count is - // never corrected and Atmos / 7.1 is silently understated as 5.1. No-op for - // CSS / unencrypted sources (set_unit_base default is a no-op). + // Anchor the AACS unit-alignment gate to the title's start before probing; + // otherwise DecryptingSectorSource's absolute `start_lba % 3` gate trips + // DecryptFailed and TrueHD channels never get corrected. No-op for CSS/unencrypted. reader.set_unit_base(ext.start_lba); if reader .read_sectors(ext.start_lba, n, &mut buf, true) @@ -678,11 +675,9 @@ pub fn bytes_bad_in_title(title: &DiscTitle, bad_ranges: &[(u64, u64)]) -> u64 { if bad_ranges.is_empty() || title.extents.is_empty() { return 0; } - // Overlap each bad range against every extent individually. A single - // bounding box (first extent start → last extent end) would count - // bad sectors in inter-extent gaps (other titles' data, BDMV - // metadata) as bad bytes in this title, over-counting lost_ms for - // titles with non-contiguous clips. + // Overlap each bad range against every extent individually — a single + // bounding box would count inter-extent gaps (other titles' data, BDMV + // metadata) as bad bytes, over-counting lost_ms for non-contiguous titles. let mut total: u64 = 0; for ext in &title.extents { let es = (ext.start_lba as u64) * 2048; @@ -706,13 +701,9 @@ fn byte_offset_in_title(lba: u32, title: &DiscTitle) -> Option<u64> { use crate::consts::SECTOR_BYTES_U64; let mut cumulative = 0u64; for ext in &title.extents { - // Saturating, like every other extent-end computation in the crate - // (`bytes_bad_in_title`, `crack_key_scan`'s crack span, - // `DiscStream::fill_extents`). ECMA-167 logical block numbers are - // 32-bit, so a malformed UDF/IFO extent near the top of that space - // makes a plain `+` overflow — a debug-build PANIC inside a library, - // and a release-build wrap to a tiny end LBA that silently reports the - // offset as outside the title. + // Saturating like other extent-end computations (ECMA-167 LBAs are + // 32-bit); a malformed extent near u32::MAX would otherwise panic in + // debug or wrap to a tiny end LBA in release, hiding the true offset. let ext_end = ext.start_lba.saturating_add(ext.sector_count); if lba >= ext.start_lba && lba < ext_end { return Some(cumulative + (lba - ext.start_lba) as u64 * SECTOR_BYTES_U64); @@ -882,9 +873,8 @@ impl Codec { match ct { c::HEVC => Codec::Hevc, // 0x1B base-view AVC and 0x20 MVC dependent-view (Blu-ray 3D right - // eye) are both H.264. Mapping 0x20 to video is what makes the PMT - // scan enumerate the dependent view as a second H.264 stream (its - // own PID in the SSIF) instead of dropping it — the basis of 3D. + // eye) are both H.264; mapping 0x20 to video lets the PMT scan + // enumerate the dependent view as its own SSIF stream — the basis of 3D. c::H264 | c::H264_MVC => Codec::H264, c::VC1 => Codec::Vc1, c::MPEG2_VIDEO => Codec::Mpeg2, @@ -895,23 +885,13 @@ impl Codec { c::AC3 => Codec::Ac3, c::AC3_PLUS | c::AC3_PLUS_SECONDARY => Codec::Ac3Plus, c::LPCM => Codec::Lpcm, - // 0xA2 is the SECONDARY DTS-HD audio stream (Blu-ray Disc - // Read-Only Format part 3, stream_coding_type table): DTS Express / - // DTS-HD LBR, a LOSSY low-bitrate extension carried alongside the - // primary track for picture-in-picture and BD-J mixing — exactly - // parallel to 0xA1 (secondary E-AC-3) on the Dolby side. It is NOT - // DTS-HD Master Audio, which has its own primary code 0x86; mapping - // it there advertised a lossless track for lossy content, so the - // muxer's stream metadata and every label derived from it claimed a - // quality the bitstream does not carry. The crate has no distinct - // DTS Express variant, so it is represented by the LOSSY DTS-HD - // member. + // 0xA2 is the SECONDARY DTS-HD stream: DTS Express / DTS-HD LBR, a + // LOSSY low-bitrate extension (parallel to 0xA1 secondary E-AC-3), NOT + // DTS-HD MA (0x86); mapping it as lossless would misreport quality. c::DTS_HD_SECONDARY => Codec::DtsHdHr, - // PG (0x90) = Presentation Graphics (subtitles). IG (0x91, menus) - // and TEXT_SUBTITLE (0x92) are distinct HDMV coding types and are - // NOT PG subtitle streams; only PG maps to Pgs. IG falls through to - // Unknown so the PMT/STN walker drops it rather than surfacing a - // bogus PGS subtitle track for a menu ES. + // PG (0x90) = Presentation Graphics (subtitles). IG (0x91, menus) and + // TEXT_SUBTITLE (0x92) are distinct types and fall through to Unknown + // so the PMT/STN walker drops them instead of faking a PGS track. c::PG => Codec::Pgs, ct => Codec::Unknown(ct), } @@ -977,7 +957,7 @@ impl Resolution { } } - /// Pixel dimensions (width, height). `(0, 0)` when the resolution is + /// Pixel dimensions (width, height). `None` when the resolution is /// [`Resolution::Unknown`] — "no dimensions", not a guess. /// /// The SD frames are the DVD-Video coded pictures (ITU-R BT.601 525/60 and @@ -1137,13 +1117,9 @@ impl AudioChannels { AudioChannels::Surround51 => 6, AudioChannels::Surround61 => 7, AudioChannels::Surround71 => 8, - // 0, not 6. An unknown layout has no channel count, and returning a - // plausible one made every caller responsible for remembering to - // check the variant first — a trap, and one this crate walked into: - // the json:// sink reported a confident 5.1 for audio its own - // neighbouring fields called "unknown". 0 is the value Matroska and - // the sinks already coerce Unknown to, and unlike 6 it is obviously - // wrong if it ever reaches output. + // 0, not a plausible guess like 6 — a fake count once let the json:// + // sink report a confident 5.1 for audio its own fields called "unknown". + // 0 is what Matroska/sinks already coerce Unknown to, and is obviously wrong. AudioChannels::Unknown => 0, } } @@ -1304,9 +1280,8 @@ impl std::str::FromStr for ColorSpace { } // ─── FromStr impls — single source of truth via ALL_* arrays ─────────────── -// -// Each enum defines a const array of (str, variant) pairs. Display, FromStr, -// and id() all derive from this one table — no string appears twice. +// Each enum defines a const array of (str, variant) pairs; Display, FromStr, +// and id() all derive from this one table, so no string appears twice. macro_rules! enum_str { ($name:ident, $default:expr, [ $( ($s:expr, $v:expr) ),* $(,)? ]) => { @@ -1318,11 +1293,9 @@ macro_rules! enum_str { for (s, v) in $name::ALL { if v == self { return f.write_str(s); } } - // The only variant not in ALL is the Unknown fallback (kept out - // of ALL so FromStr("unknown") round-trips to it via $default - // without ALL gaining a duplicate key). Display it visibly as - // "unknown" rather than an empty string, which produced blank - // metadata in labels and logs. + // Unknown is kept out of ALL so FromStr("unknown") round-trips via + // $default without a duplicate key; display it visibly rather than + // an empty string, which produced blank metadata in labels/logs. f.write_str("unknown") } } @@ -1559,10 +1532,9 @@ pub struct AacsState { pub mkb: Vec<u8>, } -// Redacting `Debug`: `AacsState` is crate-root re-exported and reachable via the -// public `Disc.aacs` field; it carries VUK / unit keys / read-data (bus) key / -// volume id / raw .inf + MKB. Print only non-secret shape; redact every -// key/secret field. Guarded by `aacs_state_and_key_debug_are_redacted`. +// Redacting `Debug`: `AacsState` is public via `Disc.aacs` and carries VUK / +// unit keys / bus key / volume id / raw .inf + MKB. Print only non-secret shape; +// redact every key/secret field. Guarded by `aacs_state_and_key_debug_are_redacted`. impl std::fmt::Debug for AacsState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("AacsState") @@ -1721,10 +1693,9 @@ impl Disc { /// Shared setup for both identify() and scan(). fn read_udf(session: &mut Drive) -> Result<(u32, udf::BufferedSectorReader<'_>, udf::UdfFs)> { let capacity = Self::read_capacity(session).unwrap_or_else(|e| { - // A READ CAPACITY failure (transient drive spin-up, SCSI error) - // must not be silently treated as a 0-sector disc: capacity=0 - // skews the layer heuristic (always 1 layer) and title ordering. - // Recovery is unchanged (we still proceed with 0), but surface it. + // A READ CAPACITY failure must not be silently treated as a 0-sector + // disc: capacity=0 skews the layer heuristic (always 1 layer) and + // title ordering. Recovery is unchanged, but surface it. tracing::warn!( target: "freemkv::scan", error = %e, @@ -1753,13 +1724,9 @@ impl Disc { /// The session must be open and unlocked (`Drive::open` handles this). /// All disc reads use standard READ(10) via UDF — no vendor SCSI commands. pub fn scan(session: &mut Drive, opts: &ScanOptions) -> Result<Self> { - // AACS handshake (Blu-ray/UHD). Acquires the Volume ID via the - // cert-based mutual-auth handshake (the OEM route); drive unlock - // itself runs separately behind the pluggable `Unlocker` seam. - // AACS is Blu-ray/UHD only. A DVD uses CSS — skip the AACS handshake - // entirely (the drive already classified the disc as DVD at init), so a - // DVD never issues AACS OEM-VID / cert SCSI against the drive before the - // CSS bus-auth runs. + // AACS handshake (Blu-ray/UHD) acquires the Volume ID via cert-based + // mutual-auth; drive unlock runs separately behind the `Unlocker` seam. + // DVD uses CSS, so skip AACS entirely — no OEM-VID/cert SCSI before bus-auth. let (handshake, handshake_error) = if session.disc_is_dvd() { (None, None) } else { @@ -1772,18 +1739,9 @@ impl Disc { // (BD/UHD speed is set by drive unlock/init, but DVD needs explicit SET CD SPEED) session.set_speed(0xFFFF); - // CSS bus-auth unlock — run BEFORE any scrambled-sector read. - // On a CSS-enforcing drive (e.g. the BU40N) the UDF metadata prefetch - // below reaches small/menu VOB extents that are themselves CSS-scrambled; - // without the bus-auth handshake first, each of those reads is rejected - // with sense 05/6F/03 ("read of scrambled sector without authentication") - // after a full drive round-trip — ~13s of pure waste on a real disc, and - // the prefetch caches nothing. The handshake needs no title info (it takes - // no extents), is self-guarding to DVD media, and is non-fatal on failure, - // so run it as soon as the drive has classified the disc as a DVD. The - // per-VTS title key is NOT recovered here (or anywhere in scan) — it is - // cracked keylessly at read/mux time via `css::resolve_dvd_title_key`; - // this only unlocks the drive's read gating so those reads can happen. + // CSS bus-auth unlock — run BEFORE any scrambled-sector read, else the UDF + // metadata prefetch below hits scrambled VOB extents (sense 05/6F/03). + // Title keys are cracked later via `css::resolve_dvd_title_key` instead. if session.disc_is_dvd() { tracing::info!(target: "freemkv::scan", "phase: CSS — bus-auth unlock (pre-scan)"); let drive_id = session.drive_id.clone(); @@ -1824,16 +1782,9 @@ impl Disc { )?; tracing::info!(target: "freemkv::scan", titles = disc.titles.len(), format = ?disc.content_format, "phase: titles parsed"); - // No CSS key recovery at scan time. DVD CSS keys are per-VTS/per-title, - // and the descramble path re-cracks each title's key keylessly from that - // title's own extents at the moment its sectors are read/decrypted — so a - // single up-front "disc key" is meaningless (it's not valid for the other - // titles and every title re-derives its key at read time anyway). The - // scan's only CSS responsibility is the bus-auth unlock above, which opens - // the drive's read gating so the sweep can read scrambled sectors at all. - // Detection is likewise moot: encrypted or not, a DVD muxes identically — - // the read-time descrambler cracks a title key if the sectors are - // scrambled and is a no-op if they are clear. + // No CSS key recovery at scan time: DVD CSS keys are per-title and are + // re-cracked keylessly at read/decrypt time, so an up-front "disc key" + // is meaningless. Scan's only CSS job is the bus-auth unlock above. // `disc.css` is intentionally never set at scan time now (per-title CSS // keys are cracked at read/mux time), so log the format the scan actually @@ -1879,10 +1830,9 @@ impl Disc { /// disc cracked from the drive, which is precisely what the duplicate was /// free to do. Keep the derivation here, shared, so it cannot recur. fn image_crack_extents(titles: &[DiscTitle]) -> &[Extent] { - // Prefer the first title that has BOTH video and extents — the main - // feature after `main_feature_order` — so the CSS crack scans the movie, - // not a streamless decoy that happens to carry extents. Falls back to the - // first title with extents (unchanged behaviour) when none has video. + // Prefer the first title with BOTH video and extents (the main feature) + // so the CSS crack scans the movie, not a streamless decoy. Falls back + // to the first title with extents when none has video. titles .iter() .find(|t| t.has_probable_video() && !t.extents.is_empty()) @@ -1901,24 +1851,13 @@ impl Disc { let udf_fs = udf::read_filesystem(reader)?; let mut disc = Self::scan_with(reader, capacity, None, None, opts, udf_fs)?; - // CSS for a raw (still-scrambled) DVD image: recover the title key from - // the scrambled movie data itself (known-plaintext attack), same as the - // live-drive path — but with no SCSI auth/unlock (an image is already - // readable). This lets the CLI mux a RAW CSS ISO, not only a - // pre-decrypted one. A pre-decrypted image has its scramble flags clear, - // so `crack_key` finds no crackable sector and the disc stays in the - // clear. AACS images go through KEYDB VUK lookup, not here. - // - // Gate on `DiscFormat::Dvd`, NOT `content_format == MpegPs`: HD-DVD - // `.evo` images are ALSO MPEG-PS but are AACS, not CSS — they must not - // enter the CSS crack path. A CSS DVD's IFO (which defines the titles - // this branch reads) is unscrambled, so `detect_format` reliably sets - // `Dvd` from the SD-resolution titles even on a still-scrambled image. + // CSS for a raw (still-scrambled) DVD image: recover the title key via + // known-plaintext crack (no SCSI auth needed). Gated on `DiscFormat::Dvd`, + // not `MpegPs`, since HD-DVD `.evo` images are also MPEG-PS but AACS. if disc.css.is_none() && disc.format == DiscFormat::Dvd && !disc.titles.is_empty() { - // Copied out so the crack's `disc.css` / `disc.encrypted` writes - // below don't collide with a live borrow of `disc.titles`. The - // ORDER is whatever `image_crack_extents` returns — never re-sorted - // here (see that function's docs: the local re-sort was the defect). + // Copied out so the crack's `disc.css`/`disc.encrypted` writes below + // don't collide with a live borrow of `disc.titles`. Order is exactly + // what `image_crack_extents` returns — never re-sorted here. let main_extents = Self::image_crack_extents(&disc.titles).to_vec(); if !main_extents.is_empty() { // Image reads aren't drive-batch-limited; use a generous batch. @@ -2021,10 +1960,9 @@ impl Disc { |p| udf_fs.read_file_prefix(reader, p, want), )?; let n = crate::aacs::mkb::mkb_content_len(&buf); - // `n` strictly inside `buf` => the record walk reached the padding - // boundary (full content captured). `buf` shorter than `want` => - // the whole file is already read. Otherwise the records may run - // past the prefix — grow and retry, bounded by MAX_BYTES. + // `n` strictly inside `buf` or `buf` shorter than `want` means the + // whole content is captured; otherwise records may run past the + // prefix — grow and retry, bounded by MAX_BYTES. if (n > 0 && n < buf.len()) || buf.len() < want || want >= MAX_BYTES { return Ok(crate::aacs::mkb::trim_mkb(buf)); } @@ -2037,11 +1975,9 @@ impl Disc { /// Unit Key out-of-band: obtain the key however you like, then apply it via /// [`Disc::decrypt_with`]. libfreemkv never makes a network call. pub fn read_aacs_inputs(iso_path: &std::path::Path) -> Result<(Vec<u8>, Vec<u8>, u8)> { - // Preserve the underlying open error (`Error::IoError`, E5000, carrying - // the OS errno) instead of collapsing ENOENT/EPERM/etc. into - // `Error::AacsNoKeys` (E7000). A missing or unreadable ISO is an I/O - // fault, not a key-resolution failure; callers that dispatch on - // `.code()` must be able to tell the two apart. + // Preserve the underlying open error (E5000) instead of collapsing + // ENOENT/EPERM into `Error::AacsNoKeys` (E7000): a missing ISO is an + // I/O fault, not a key-resolution failure, and `.code()` callers must differ. let mut reader = crate::io::file_sector_source::FileSectorSource::open(iso_path)?; let udf_fs = udf::read_filesystem(&mut reader)?; Self::read_aacs_inputs_from_reader(&mut reader, &udf_fs) @@ -2095,10 +2031,9 @@ impl Disc { let (aacs, aacs_error) = if !encrypted { (None, None) } else { - // Lookup-free: capture the disc's AACS inputs (MKB, VID, - // Unit_Key_RO.inf) but resolve NO key. The caller resolves a Key - // from a key source and applies it via `Disc::decrypt_with`. The - // disc reports "encrypted, no keys" until then. + // Lookup-free: capture the disc's AACS inputs (MKB, VID, Unit_Key_RO.inf) + // but resolve no key — the caller resolves one and applies it via + // `Disc::decrypt_with`; until then the disc reports "encrypted, no keys". match Self::resolve_vid_only(&udf_fs, reader, handshake.as_ref()) { Ok(state) => (Some(state), None), // A handshake failure (no VID) is more actionable than the @@ -2107,14 +2042,9 @@ impl Disc { } }; - // 3. Titles + container — dispatched by on-disc tree. HD-DVD and DVD are - // tree-level peers, each with its own enumerator; FMTS shares the BD - // tree (a `.fmts` stream variant). Disc FORMAT is a separate axis - // derived below from the AACS MKB generation, not the tree. - // The DVD scanner resolves its own main feature by following the disc's - // First-Play navigation (issue #40); it hands that title's `playlist_id` - // back here to use as the `nav_feature` ranking signal, mirroring the BD - // path's `bdnav::resolve_feature`. `None` for every other tree. + // 3. Titles + container — dispatched by on-disc tree (HD-DVD/DVD peers, + // FMTS shares the BD tree; FORMAT is a separate axis derived below). DVD + // resolves its main feature via First-Play nav (issue #40) as `nav_feature`. let mut dvd_nav_feature: Option<u16> = None; let (mut titles, content_format) = if udf_fs.find_dir("/BDMV").is_some() { ( @@ -2133,13 +2063,9 @@ impl Disc { } else { (Vec::new(), ContentFormat::BdTs) }; - // Title ordering: titles[0] should be the canonical main feature. - // - // Naive "longest duration first" misranks branching UHDs (see - // `canonical_title_order` for the full rationale). Sort the - // titles so the consumer-side `-t 1` / autorip's main-feature - // picker / `disc.titles.first()` all converge on the actual - // movie instead of the virtual play-all composite. + // Title ordering: titles[0] should be the canonical main feature. Naive + // "longest duration first" misranks branching UHDs (see `canonical_title_order`); + // sort so `-t 1` / autorip / `.first()` converge on the real movie. let capacity_bytes = capacity as u64 * 2048; titles.sort_by(|a, b| Self::canonical_title_order(a, b, capacity_bytes)); @@ -2147,13 +2073,9 @@ impl Disc { let meta_title = Self::read_meta_title(reader, &udf_fs); let feature_hint = crate::labels::apply(reader, &udf_fs, &mut titles); - // The authoritative signal: run the disc's own HDMV navigation the way a - // real player would (First-Play in the `bdnav` VM) and see which playlist - // it plays as the feature. Only for a BDMV tree; the VM abstains on BD-J - // discs and any non-convergence, returning `None`. Restrict the accepted - // result to video-bearing, non-trivial titles (duration within half of - // the longest video title's) so a short logo/pre-roll `PlayPlayList` - // cannot be mistaken for the feature. + // Authoritative signal: run the disc's own HDMV First-Play nav (`bdnav` VM) + // to see which playlist it plays; BDMV only, `None` on BD-J/non-convergence, + // restricted to non-trivial video titles so a logo/pre-roll isn't picked. let nav_feature = if udf_fs.find_dir("/BDMV").is_some() { let longest_video_dur = titles .iter() @@ -2177,16 +2099,9 @@ impl Disc { .filter(|id| titles.iter().any(|t| t.playlist_id == *id && t.has_video())) }; - // Authoritative main-feature ordering. The physical `canonical_title_order` - // sort above is refined once labels have run: the disc's own navigation - // pick (`nav_feature`) wins outright when present; otherwise a play-all / - // wrapper COMPOSITE (e.g. a real UHD title's decoy playlist `00245` = - // `[bumper][00001 feature clip][outro]`) is demoted below the standalone - // title it wraps, a title with no plausible video is dropped, and a - // feature the disc's own menu authoring designates (`feature_hint`) is - // promoted. After this, `titles[0]` is the selected main feature. The - // `canonical_title_order` sort is kept first because label ANCHORING - // (`labels::apply`, above) reads titles in duration-descending order. + // Authoritative main-feature ordering, refined once labels have run: + // `nav_feature` wins outright, else a play-all composite is demoted and + // `feature_hint` promoted; the sort above runs first for label anchoring. Self::sort_titles_by_main_feature( &mut titles, capacity_bytes, @@ -2195,36 +2110,23 @@ impl Disc { ); // Optional content-based forced-subtitle detection. `info` opts in so its - // 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. + // forced flags match the muxer's rip-time result (shared PGS classifier); + // rip leaves it off since the muxer detects forced without a second read. if opts.probe_forced_subtitles { Self::probe_forced_subtitles_for_bdts_titles(reader, &mut titles, opts.halt.as_ref()); } crate::labels::fill_defaults(&mut titles); // 5. Format (AACS MKB generation → BD/UHD/FMTS; tree → HD-DVD/DVD) and - // layers. Region coding is not yet decoded from the disc — every disc - // reports Region-free for now (correct for all UHD; a stub for - // region-locked BD/DVD until region detection is implemented). + // layers. Region coding is not decoded yet — every disc reports + // Region-free (correct for UHD; a stub until BD/DVD region detection). let format = Self::detect_disc_format(reader, &udf_fs, &titles); let layers = if capacity > 24_000_000 { 2 } else { 1 }; let region = DiscRegion::Free; - // 6. CSS detection for DVDs. - // Detection from a single probe sector would miss - // DVDs whose first sector is unscrambled, so the crack path - // scans extents internally and bottoms out at None on - // unencrypted media. - // CSS for a live-drive DVD is resolved by the drive-authentication - // path in `Disc::scan` (which has `&mut Drive`), AFTER this function - // returns. We deliberately do NOT run the reader-based crack path here: - // it is non-functional against this crate's descrambler (always returns - // None — see `css::crack_key`), and on a CSS-protected disc it would scan up - // to 50,000 scrambled sectors one-by-one, each rejected by the drive - // with sense 05/6F/03 ("read of scrambled sector without - // authentication") — roughly an hour of failing reads before the real - // auth path ever runs. Leave `css` unresolved here. + // 6. CSS detection for DVDs is deferred to `Disc::scan`'s drive-auth path + // (has `&mut Drive`), run after this returns — the reader-based crack path + // is NOT run here: on a CSS disc it would fail ~50,000 sectors one-by-one. let css = None; let encrypted = encrypted || css.is_some(); @@ -2255,11 +2157,9 @@ impl Disc { content_format, }; - // Structured scan diagnostic block (--log-level 3). Emits the - // per-title / per-stream / decision / AACS rows under the - // `freemkv::diag` target; a no-op unless that target is enabled. - // (DVD per-cell category rows are emitted earlier from the IFO scan, - // before the per-cell detail is lowered away.) + // Structured scan diagnostic block (--log-level 3): emits per-title/stream/ + // decision/AACS rows under `freemkv::diag`, a no-op unless enabled. (DVD + // per-cell rows are emitted earlier, before per-cell detail is lowered away.) crate::diag::dump_disc(&disc); Ok(disc) @@ -2267,7 +2167,6 @@ impl Disc { // ── Internal helpers ──────────────────────────────────────────────────── - /// Detect disc format from the main title's video streams. /// Total ordering used to sort `Disc::titles` so `titles[0]` is the /// canonical main feature. /// @@ -2292,7 +2191,7 @@ impl Disc { /// main-feature picker all grab the 4-hour composite instead of /// the 2-hour movie that actually matches TMDB. /// - /// **Sort priority (titles[0] = most likely main feature):** + /// **Sort priority (`titles[0]` = most likely main feature):** /// 1. Real titles (`size_bytes ≤ capacity_bytes`) before virtual /// composites. The capacity check is a hard "physically /// possible data on this disc" gate. `capacity_bytes == 0` means @@ -2338,11 +2237,9 @@ impl Disc { titles: &mut [DiscTitle], halt: Option<&crate::halt::Halt>, ) { - // One cache across every title: a disc's playlists overwhelmingly - // reference the same handful of clips (main feature, play-all, - // seamless-branch variants), so without memoisation the same physical - // extents are re-read from the drive once per playlist — 30-150 times - // on a typical Blu-ray. + // One cache across every title: playlists overwhelmingly reference the + // same handful of clips, so without memoisation the same extents are + // re-read from the drive once per playlist — 30-150 times on a Blu-ray. let mut cache = pgs_forced_probe::ForcedProbeCache::new(); for title in titles.iter_mut() { if title.content_format == ContentFormat::BdTs { @@ -2356,36 +2253,20 @@ impl Disc { b: &DiscTitle, capacity_bytes: u64, ) -> std::cmp::Ordering { - // A title bigger than the whole disc is a "play-all" composite artifact - // (its declared size double-counts clips shared with other playlists) — - // demote it below any real single title. - // - // `capacity_bytes == 0` means the capacity is UNKNOWN, not that the - // disc holds nothing: `read_udf` substitutes 0 when READ CAPACITY - // fails and scans on regardless. Applied literally the gate would - // INVERT there — every real title (`size_bytes > 0`) would be - // "oversize" and demoted, while a CLPI-less `size_bytes == 0` title - // would not, landing at `titles[0]` ahead of the feature. With no - // capacity to compare against, the gate is inert and the size / - // duration / audio keys decide the order on their own. + // A title bigger than the whole disc is a "play-all" composite (size + // double-counts shared clips) — demote it below real titles. `capacity_bytes + // == 0` means UNKNOWN (READ CAPACITY failed), so the gate stays inert then. let capacity_known = capacity_bytes > 0; let a_oversize = capacity_known && a.size_bytes > capacity_bytes; let b_oversize = capacity_known && b.size_bytes > capacity_bytes; a_oversize .cmp(&b_oversize) - // PRIMARY: largest physical size = the main feature. Robust where - // duration and clip-count are not — a decoy "play-all" playlist runs - // long (e.g. 1h31m) but is tiny (0.4 GB of reused/junk clips), and the - // real feature is often chaptered into MANY clips (one per chapter), - // which the old clip-count-ascending key wrongly demoted below 1-clip - // bonus reels. Validated across 23 UHD/BD discs — fixes several - // heavily-chaptered features (each ranked ~#13–36 before the fix), - // no regressions on the 19 already correct. + // PRIMARY: largest physical size = the main feature — robust where + // duration/clip-count are not, since a decoy play-all runs long but + // is tiny. Validated on 23 discs against the old clip-count key. .then_with(|| b.size_bytes.cmp(&a.size_bytes)) - // Tiebreak for equal-size twins: longer duration, then richer audio — - // the same feature authored as sibling playlists (a full-audio main - // vs an audio-reduced twin: 00800 [DTS-HD MA + 13 tracks] vs - // 00004 [stereo AC-3 only]). Prefer lossless-multichannel. + // Tiebreak for equal-size twins: longer duration, then richer audio + // (a full-audio main vs a stereo-only twin) — prefer lossless-multichannel. .then_with(|| b.duration_secs.total_cmp(&a.duration_secs)) .then_with(|| Self::audio_richness(b).cmp(&Self::audio_richness(a))) } @@ -2453,14 +2334,9 @@ impl Disc { .filter(|t| t.has_probable_video()) .map(|t| t.duration_secs) .fold(0.0_f64, f64::max); - // Largest video-bearing title's byte size, used as a plausibility floor - // below. A nav/authoring signal can legitimately resolve a seamless-branch - // or Dolby-Vision presentation playlist that a player navigates to but - // that carries almost no unique payload (e.g. a 0.4 GB / 102-clip branch - // sitting beside the 91.5 GB single-clip feature). Those are correct for - // playback but wrong as a RIP target, so a promoted title must also hold a - // meaningful fraction of this size; otherwise it falls through to the - // composite/size ranking, which recovers the substantial feature. + // Largest video-bearing title's byte size, a plausibility floor: a promoted + // title must hold a meaningful fraction of it, else a tiny but playback-valid + // seamless-branch playlist could win nav/authoring and get ripped instead. let max_video_size = titles .iter() .filter(|t| t.has_probable_video()) @@ -2470,15 +2346,9 @@ impl Disc { let carries_feature_payload = |t: &DiscTitle| { max_video_size == 0 || t.size_bytes as f64 >= 0.10 * max_video_size as f64 }; - // The composite scan below is O(n^2 · clip-set-size). BOTH the title count - // and the per-title clip count are untrusted disc metadata (playlist count - // and PlayItem count — the latter a u16 the mpls parser does not otherwise - // cap), so a crafted image can inflate either dimension into a multi-second - // uncancellable stall. Bound the total pair work to a sub-second budget; - // above it, skip composite classification (every real disc is orders of - // magnitude under it) and let the has-video + canonical tiers order the - // titles. Bounding the PRODUCT covers both the many-tiny-titles and the - // few-clip-heavy-titles attack shapes. + // The composite scan below is O(n^2 * clip-set-size); title/clip counts are + // untrusted, so a crafted image could stall it. Bound the total pair work; + // above budget, skip composite classification and use has-video/canonical order. let max_clips = clip_sets.iter().map(|s| s.len()).max().unwrap_or(0); let composite_scan_ok = titles .len() @@ -2506,23 +2376,17 @@ impl Disc { && qj.is_subset(pi) } }); - // The authoring hint is honoured only when the hinted title has - // REAL video streams (strict `has_video`, not the permissive - // `has_probable_video` floor — so a hint at a large streamless - // entry cannot resurrect the decoy), is NOT itself a composite (a - // mis-authored id pointing at a wrapper can't win), and runs at - // least half as long as the longest title (audit R4 — a hint at a - // short trailer/branch must not override the real 2h feature). + // Authoring hint honoured only when the title has REAL video + // (strict `has_video`, not `has_probable_video`), is NOT itself a + // composite, and runs at least half the longest title (audit R4). let authoring = !composite && hint.is_some_and(|h| h.matches(p.playlist_id, &p.playlist)) && p.has_video() && (longest_video_dur <= 0.0 || p.duration_secs >= 0.5 * longest_video_dur) && carries_feature_payload(p); - // The nav result is video-gated exactly like the authoring hint, - // so a mis-resolved playlist that happens to be streamless can - // never win (and thus cannot reopen the decoy hole). It is also - // payload-gated: a player may navigate to a low-byte branch/DV - // presentation, but that is never the rip target. + // Nav result is video- and payload-gated like the authoring hint, + // so a streamless or low-byte branch/DV presentation cannot win + // even if the disc's own navigation resolves to it. let nav = nav_feature == Some(p.playlist_id) && p.has_video() && carries_feature_payload(p); @@ -2636,11 +2500,9 @@ impl Disc { None => {} } } - // Unencrypted / unreadable MKB: refine by resolution, but a BD-tree - // disc is never below Blu-ray — only UHD can promote it. detect_format - // is a general resolution classifier that can return Dvd for an SD - // bonus/menu title, which must NOT tag a BDMV disc as DVD (that - // mis-sizes the ECC-block sweep). Clamp anything but UHD up to BluRay. + // Unencrypted/unreadable MKB: refine by resolution, but a BD-tree disc + // is never below Blu-ray — `detect_format` can return Dvd for an SD + // bonus title, which must not mis-tag a BDMV disc (mis-sizes the ECC sweep). return match Self::detect_format(titles) { DiscFormat::Uhd => DiscFormat::Uhd, _ => DiscFormat::BluRay, @@ -2694,10 +2556,9 @@ impl Disc { &mut buf, 5_000, )?; - // Share the decoder with `Drive::capacity` rather than re-deriving it. - // A hand-rolled copy here previously dropped the short-transfer check: - // a drive answering GOOD with an empty data phase leaves `buf` zeroed, - // which decodes to a 1-sector disc instead of an error. + // Share the decoder with `Drive::capacity` rather than re-deriving it — a + // hand-rolled copy previously dropped the short-transfer check, so a drive + // answering GOOD with an empty data phase decoded to a 1-sector disc. crate::drive::decode_read_capacity(&buf, result.bytes_transferred) } } @@ -2744,10 +2605,9 @@ pub enum Key { Unit(Vec<(u32, [u8; 16])>), } -// Redacting `Debug`: `Key` is crate-root re-exported and is the key-transport -// type crossing `Disc::decrypt_with`; every variant carries raw key material. -// Print only the variant name and count — never bytes. Guarded by -// `aacs_state_and_key_debug_are_redacted`. +// Redacting `Debug`: `Key` is the key-transport type crossing `Disc::decrypt_with`; +// every variant carries raw key material. Print only variant name and count — +// never bytes. Guarded by `aacs_state_and_key_debug_are_redacted`. impl std::fmt::Debug for Key { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -2837,10 +2697,9 @@ impl Disc { /// Used by disc-to-ISO and other full-disc operations. pub fn decrypt_keys(&self) -> crate::decrypt::DecryptKeys { if let Some(ref aacs) = self.aacs { - // An AACS state with NO unit keys is "encrypted, no keys" — e.g. - // the VID-only state from out-of-band resolution before a Unit Key - // is supplied. Report None so callers treat it as missing keys - // (not a usable, empty key set). + // An AACS state with NO unit keys is "encrypted, no keys" (e.g. a + // VID-only state before a Unit Key is supplied); report None so callers + // treat it as missing keys, not a usable empty key set. if aacs.unit_keys.is_empty() { return crate::decrypt::DecryptKeys::None; } @@ -2873,26 +2732,9 @@ impl Disc { halt: Option<&crate::halt::Halt>, ) -> Result<crate::decrypt::AacsKeyMap> { let mut ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)> = Vec::new(); - // ONE per-disc memo across every title. Without it every playlist re-derives - // the same disc-wide facts off the drive: - // - // * the multi-CPS "which held key opens this extent" decision — 8 random - // 6144-byte reads per extent, ~200 ms of seek apiece on a stock BD drive. - // A disc's playlists overwhelmingly reference the same handful of clips - // (main feature, play-all, per-chapter and seamless-branch variants), so - // this recomputed the same index from byte-identical input; - // * the UDF walk + `/AACS/IndividualSegment.tbl` read that decides whether - // the disc is FMTS at all — ~35 single-sector reads at low LBAs, reached - // from a head the previous title's content sampling left deep in the - // content area, so a full-stroke seek out and back per playlist. This - // runs on EVERY disc, FMTS or not; - // * on an FMTS (AACS 2.1) disc, the forensic anchor probe, the per-index - // phase probe AND the key-service round trip that returns the disc's - // index-key set. That last one is the key-server storm: one round trip - // per playlist for one disc-wide answer. - // - // The FMTS memos are what makes the multi-CPS memo reachable at all on an - // FMTS disc — that path returns its finished map before the extent loop. + // ONE per-disc memo across every title, else every playlist re-derives the + // same facts: multi-CPS key-per-extent seeks, FMTS-detection seeks, and on + // FMTS a key-server round-trip storm. The FMTS memo makes the CPS memo reachable. let mut cache = crate::mux::resolve::DiscKeyCache::new(); for title in &self.titles { let map = crate::mux::resolve::resolve_mux_key_map_cached( @@ -2974,18 +2816,9 @@ impl Disc { // firmware didn't (stock or Renesas drive) AND AACS state was // actually obtained. "AACS" => self.aacs.is_some() && !ld_removed_bus, - // The DVD read-unlock (CSS bus-auth) is issued during the scan - // for EVERY DVD to clear the drive's scrambled-read barrier; - // unlike the firmware/AACS arms above (whose success is already - // captured in disc state), the bus-auth outcome is not tracked - // separately, so this arm reports the MEDIUM — "the DVD - // read-unlock path engaged" — rather than a per-rip success - // bit. That is intentional: the CSS descramble is keyless and - // handled at mux time, and a genuine bus-auth failure surfaces - // downstream as a read/crack error, not here. (Formerly this - // reported `self.css.is_some()` — "a crack recovered a key" — - // which under-reported: an encrypted DVD that reads and muxes - // fine showed "no".) + // DVD read-unlock (CSS bus-auth) runs for EVERY DVD during scan + // and isn't tracked separately, so this reports the MEDIUM + // engaged, not a per-rip success bit — failures surface downstream. "DVD" => self.format == DiscFormat::Dvd, // A newly-registered unlocker with no runtime signal wired // here yet: report `no` rather than guess. @@ -3048,14 +2881,9 @@ impl Disc { if raw { return Ok(()); } - // Scrambled-but-uncracked CSS: the disc is encrypted but `css` is None, - // so the key check below can't see it. `css_error` records the MAIN - // feature's crack, so this is a WHOLE-DISC verdict — every title would - // fail identically — and it is raised as `CssNoDiscKey` (disc-level, - // `error::is_disc_level_no_key`), never as the per-title - // `CssKeyMissing` (`error::is_skippable_title_stub`). Raised as the - // latter, an undecryptable disc made the rip loop iterate all N titles - // logging "title skipped, it was an empty stub" and exit 0. + // Scrambled-but-uncracked CSS: `css` is None so the key check can't see + // it. `css_error` is a WHOLE-DISC verdict raised as `CssNoDiscKey`, never + // per-title `CssKeyMissing` (which would log "empty stub" per title and exit 0). if self.css_error.is_some() { return Err(Error::CssNoDiscKey); } @@ -3065,20 +2893,9 @@ impl Disc { let needs_key = matches!(keys, crate::decrypt::DecryptKeys::None); if needs_key { if self.aacs.is_some() { - // E7017 vs E7022 split: when key resolution had derivation - // material (device / processing keys) but no Volume ID to derive - // the unit key, the captured `aacs_error` is `AacsVidUnavailable` - // — report THAT (the fix is recovering the VID, not adding keys), - // not the generic `NoDiscKey`. Any reason NOT listed below (or an - // absent one) → `NoDiscKey` naming the disc by hash, unchanged. - // - // Same split, second axis: when a key SOURCE could not answer - // (unreachable / 5xx / bad token / rate-limited) resolution - // stamps that reason here, and it must surface as ITSELF. - // `NoDiscKey` asserts every source answered and none holds a key - // — the opposite of what happened — and reporting a seven-hour - // 502 outage that way sent operators looking for a VUK when the - // right action was to wait. + // E7017 vs E7022 split: with derivation material but no VID, report + // `AacsVidUnavailable`, not generic `NoDiscKey`. Likewise a key SOURCE + // failure surfaces as ITSELF, else operators hunt a VUK during an outage. match self.aacs_error { Some(Error::AacsVidUnavailable) => return Err(Error::AacsVidUnavailable), Some(Error::KeyServiceUnavailable) => { @@ -3148,10 +2965,9 @@ impl Disc { } let title = match self.titles.get(idx) { Some(t) if !t.extents.is_empty() => t, - // No extents to crack from: nothing scrambled to worry about, so mark - // it clear (`true`). Returning `false` here would let the gate's DVD - // "None keys + not clear = scrambled-uncracked" rule wrongly hard-fail - // a genuinely-unencrypted DVD title that has no extents. + // No extents to crack from: mark it clear (`true`). Returning `false` + // would let the gate's "None keys + not clear = scrambled-uncracked" + // rule wrongly hard-fail a genuinely-unencrypted extentless title. _ => return (self.decrypt_keys(), true), }; // Fast path: reuse the scan's cracked key if its span covers this title's @@ -3173,16 +2989,9 @@ impl Disc { ); } } - // Detection miss or a different VTS: crack this title's key from its OWN - // extents in a SINGLE scan, in natural PLAYBACK ORDER (never largest-cell- - // first — that was the 1.5.1 garbage bug, where a big cell's clear prefix - // starved the crack). Playback order reaches the scrambled feature body - // after only the (small) clear front matter that precedes it on a real - // disc. This is ONE crack_key_outcome call, exactly like the disc-wide - // scan: its single CSS-locked early-bail runs at most once, so a locked/ - // uncrackable title is NOT re-hammered per cell against the live drive - // (hard rule #2). The crack's 50k-sector budget bounds a fully-clear title, - // the same accepted bound the disc-wide scan uses. + // Detection miss or different VTS: crack this title's own extents in a + // SINGLE scan, in playback order (never largest-cell-first — the 1.5.1 + // garbage bug). One call, so a locked title isn't re-hammered (rule #2). match crate::css::crack_key_outcome(reader, &title.extents, batch_sectors, None) { crate::css::CrackOutcome::Cracked(state) => ( crate::decrypt::DecryptKeys::Css { @@ -3218,37 +3027,21 @@ impl Disc { if raw { return Ok(()); } - // A title proven clear by its own re-crack (no scrambled sector in its - // extents) needs no key even though the disc is CSS — pass it. The - // disc-wide `css_error` is deliberately NOT consulted here: it reflects - // the MAIN feature's crack, not this clear extra title. + // A title proven clear by its own re-crack needs no key even though the + // disc is CSS — pass it. Disc-wide `css_error` is deliberately NOT + // consulted here: it reflects the MAIN feature's crack, not this title. if title_is_clear && !keys.is_encrypted() { return Ok(()); } - // A DVD title that cracked to no key and is NOT clear is scrambled-but- - // uncrackable (`decrypt_keys_for_title` → `ScrambledUncracked`). Hard-fail - // here directly: `ensure_decryptable_keys` gates CSS on `self.css.is_some()` - // (the scan's disc-wide detection), which can be `None` when that up-front - // detection missed — exactly the case the per-title crack exists to catch. - // Without this, an uncrackable DVD title would fall through to `Ok` and mux - // scrambled sectors as corrupt PES at exit 0. - // - // `CssKeyMissing` (per-title, `error::is_skippable_title_stub`) is the - // RIGHT code here and must stay: this is one title of a multi-VTS disc, - // and a sibling title in another VTS may still crack its own key, so an - // all-titles rip skips this one and finishes the rest. The whole-disc - // failure — nothing on the disc cracked — is the gate above's - // `CssNoDiscKey`. + // A DVD title cracked to no key and NOT clear is scrambled-but-uncrackable; + // hard-fail directly (the disc-wide gate can miss this). `CssKeyMissing` + // is right here: a sibling VTS may still crack, so the rip skips only this one. if self.format == DiscFormat::Dvd && !title_is_clear && !keys.is_encrypted() { return Err(Error::CssKeyMissing); } - // A usable per-title key was resolved (a freshly-cracked CSS key, or AACS - // unit keys) — the title IS decryptable, so pass it WITHOUT consulting the - // disc-wide gate. `ensure_decryptable_keys` hard-fails (disc-level, - // `CssNoDiscKey`) on `self.css_error` - // unconditionally, which reflects the MAIN feature's crack: a bonus title - // in a different VTS that just cracked its own key must not be blocked by - // the main title having failed. + // A usable per-title key was resolved — pass WITHOUT the disc-wide gate: + // it hard-fails on `self.css_error` (the MAIN feature's crack), and a + // bonus title that cracked its own key must not be blocked by that. if keys.is_encrypted() { return Ok(()); } @@ -3259,7 +3052,7 @@ impl Disc { /// / resume path. The keys come from the mapfile's `# freemkv-uk:` header /// (persisted at sweep time when the disc was keyed), so the mux decrypts /// directly with NO key-service round-trip. Populates `self.aacs.unit_keys` - /// so [`decrypt_keys`] returns them and marks the source `ExternalUk`. + /// so [`Self::decrypt_keys`] returns them and marks the source `ExternalUk`. /// /// If the scan built no AACS state (`self.aacs == None`) — which happens /// when the keydb was absent at scan time (`scan_aacs_no_keydb` → @@ -3358,9 +3151,8 @@ impl Disc { // Terminal — the source / mapfile already holds the final UKs. (keys, None) } else { - // Every higher level derives DOWN to the unit keys, reusing the - // version-dispatched resolver — the single home for all AACS - // derivation (1.0 / 2.0 / 2.1 / 2.x). It needs the AACS inputs + // Every higher level derives DOWN via the version-dispatched resolver + // (single home for all AACS derivation), needing the AACS inputs // (Unit_Key_RO.inf, MKB, VID) stashed on the disc at scan time. let aacs = self.aacs.as_ref().ok_or(crate::error::Error::AacsNoKeys)?; if aacs.uk_ro.is_empty() { @@ -3408,12 +3200,9 @@ impl Disc { mkb: if mkb.is_empty() { None } else { Some(&mkb) }, }; - // Version dispatch — V10 uses the classical resolver at 48-byte - // stride; V20/V21 share the 64-byte stride, so try the classical V20 - // paths first and fall back to the 2.1 variant chain. The - // reason-preserving wrapper threads the no-key cause out so the - // decrypt gate can report E7017 (had derivation material but no VID) - // vs E7022 (no usable material) instead of a flat AacsKeyRejected. + // Version dispatch — V10 uses the classical 48-byte-stride resolver, + // V20/V21 the 64-byte stride. The reason-preserving wrapper lets the + // gate report E7017 vs E7022 instead of a flat AacsKeyRejected. let resolved = crate::aacs::resolve::resolve_keys_with_reason(&ctx, version_u8) .map_err(|_reason| crate::error::Error::AacsKeyRejected)?; @@ -3424,10 +3213,8 @@ impl Disc { }; // VALIDATE against real ciphertext. Conservative: reject only when a - // supplied sample is AACS-scrambled and NO candidate unit key can - // de-scramble it. With no samples (or only clear ones) there is nothing - // to disprove against, so the key is accepted as-is — keeping the - // sample-less paths (resume / mapfile cache) byte-for-byte unchanged. + // supplied sample is AACS-scrambled and NO candidate key can de-scramble + // it; with no samples the key is accepted as-is (sample-less paths unchanged). if !aligned_unit_keys_validate( &candidate_unit_keys, read_data_key.as_ref(), @@ -3512,13 +3299,9 @@ fn sysfs_batch_probe_supported(device_path: &str) -> bool { /// Detect the maximum transfer size in sectors for a device. pub fn detect_max_batch_sectors(device_path: &str) -> u16 { - // The sysfs probe below is Linux-only. Non-sysfs platforms (Windows in - // particular) use `\\.\`-form device paths (e.g. `\\.\CdRom0`, `\\.\D:`) - // that have no forward slash, so the Linux name-parsing below would treat - // the whole path as the device name, find no `/sys` node, and fall through - // to the block default (8192 sectors = 16 MiB) — far over the optical cap. - // Every device we open on a non-sysfs platform here is an optical drive, - // so return the optical default directly. + // The sysfs probe below is Linux-only; Windows-style `\\.\CdRom0` paths + // have no forward slash, so the parsing below would find no `/sys` node + // and fall through to the block default (16 MiB), far over the optical cap. if !sysfs_batch_probe_supported(device_path) { return DEFAULT_BATCH_SECTORS_OPTICAL; } @@ -3528,12 +3311,9 @@ pub fn detect_max_batch_sectors(device_path: &str) -> u16 { return DEFAULT_BATCH_SECTORS_OPTICAL; } - // Check whether THIS device (not any device on the host) is an - // optical drive: read the SCSI peripheral type of the target node - // only. Type 0x05 (decimal 5) = CD/DVD. A previous version scanned - // every /sys/class/scsi_device entry and returned true if any was - // optical, misclassifying a block device as optical on a host that - // also has an optical drive. + // Check whether THIS device (not any device on the host) is optical: read + // the SCSI type of the target node only (0x05 = CD/DVD). A previous version + // scanned every scsi_device entry, misclassifying a block device as optical. let is_optical = { // For an sg node the type lives at scsi_generic/<sg>/device/type; // for a block node (sr0/sdX) at /sys/block/<name>/device/type. @@ -4528,10 +4308,8 @@ mod tests { #[test] fn locate_ranges_at_risk_in_vs_out_of_feature() { - // The honest-"Maybe" behaviour the rendered drilldown must preserve: - // in-feature damage counts as movie time at risk; out-of-feature damage - // is still located but reads 0:00. (Ported from autorip's old - // RipProgress::from_map tests when that logic moved into the library.) + // Honest-"Maybe" behaviour: in-feature damage counts as movie time at risk; + // out-of-feature damage is still located but reads 0:00. // bps = size_bytes / duration_secs = 4096 B/s → 4096 B == 1000 ms. let mut title = title_with_video(Codec::Hevc, Resolution::R2160p); title.duration_secs = 100.0; @@ -5487,12 +5265,9 @@ mod tests { #[test] fn inject_unit_keys_synthesizes_aacs_state_when_scan_built_none() { - // Regression (E8005 deferred-mux loop): a keyed AACS disc swept WITHOUT a - // keydb scans to aacs=None + aacs_error=KeydbLoad, but its UK is persisted - // in the mapfile. At remux the UK is recovered and injected — that MUST - // yield usable decrypt keys. Before the fix, inject_unit_keys no-op'd - // (no aacs to mutate), decrypt_keys stayed None, and the mux deferred - // forever with "No keys available (E8005)" despite holding the UK. + // Regression (E8005 deferred-mux loop): a disc swept without a keydb scans to + // aacs=None + KeydbLoad error, but its persisted UK is recovered at remux, so + // injecting it must yield usable keys, not leave inject_unit_keys a no-op. let mut disc = make_test_disc(1000, "UHD"); disc.encrypted = true; disc.aacs_error = Some(crate::error::Error::KeydbLoad { @@ -5529,10 +5304,9 @@ mod tests { #[test] fn inject_unit_keys_labels_fmts_as_uhd_family() { - // FMTS is AACS 2.1 — a UHD-family, bus-encrypted format. Injecting a UK - // on an FMTS disc must synthesize the UHD version + bus encryption, not - // mislabel it AACS 1.0 / bus-off (which would break FMTS decryption on - // the mapfile-recovered-UK path). + // FMTS is AACS 2.1 — a UHD-family, bus-encrypted format. Injecting a UK on an + // FMTS disc must synthesize the UHD version + bus encryption, not mislabel it + // AACS 1.0 / bus-off, which would break FMTS decryption on the recovered-UK path. let mut disc = make_test_disc(1000, "FMTS"); disc.format = DiscFormat::Fmts; disc.encrypted = true; @@ -5564,12 +5338,9 @@ mod tests { } } - // ── ensure_decryptable: the system-wide decrypt verdict matrix ────────── - // - // This is the single gate every copy/mux entry point calls. The cases below - // are the full truth table: only "decryption needed AND unavailable AND not - // --raw" may error; every legit non-error case (raw / unencrypted / a - // resolved key) must proceed. + // ensure_decryptable: the single gate every copy/mux entry point calls. Full truth + // table below — only "decryption needed AND unavailable AND not --raw" may error; + // every legit non-error case (raw / unencrypted / a resolved key) must proceed. fn css_state() -> crate::css::CssState { crate::css::CssState { @@ -6128,10 +5899,9 @@ mod tests { #[test] fn decrypt_with_volume_derives_per_cps_unit_keys() { - // A Volume key (VUK) is NOT terminal — the lib must decrypt - // Unit_Key_RO.inf into ONE unit key per CPS unit. Oracle = the lib's - // own decrypt_unit_key, so this pins the derive-down WIRING (Volume → - // per-CPS Unit), not the cipher. + // A Volume key (VUK) is NOT terminal — the lib must decrypt Unit_Key_RO.inf + // into ONE unit key per CPS unit. Oracle is the lib's own decrypt_unit_key, + // so this pins the derive-down wiring (Volume -> per-CPS Unit), not the cipher. let vuk = [0x5au8; 16]; let enc0 = [0x12u8; 16]; let enc1 = [0x34u8; 16]; @@ -6284,13 +6054,9 @@ mod tests { #[test] fn unit_key_validation_rejects_partial_cps_unit_coverage() { - // Regression: a multi-CPS-unit disc. CPS unit 0's body is scrambled - // under uk0; CPS unit 1's body under uk1. A resolved key set that - // covers only CPS unit 0 used to pass validation (the old gate accepted - // on the FIRST sample any key decrypted), committing an incomplete set — - // CPS-unit-1 sectors then passed through as raw encrypted bytes into the - // ISO/MKV with no error surfaced. The gate must now reject a key set - // that leaves any scrambled sample uncovered. + // Regression: on a 2-CPS-unit disc, a key set covering only CPS unit 0 used to + // pass validation (old gate accepted on the first sample any key decrypted), + // silently letting CPS-unit-1 sectors through raw. Must reject any coverage gap. use crate::aacs::content::ALIGNED_UNIT_LEN; let mut clear = vec![0u8; ALIGNED_UNIT_LEN]; @@ -6818,10 +6584,9 @@ mod tests { assert_eq!(bytes_bad_in_title(&title, &spanning), 20 * 2048); } - // (The former `coding_type_a2_is_dts_hd_ma` asserted the DEFECT — that - // BD-ROM Part 3 code 0xA2 is lossless Master Audio. It is the lossy - // secondary stream; see `secondary_dts_hd_0xa2_is_lossy_not_master_audio`, - // which now covers both 0xA2 and the 0x86 primary.) + // (Former `coding_type_a2_is_dts_hd_ma` wrongly asserted 0xA2 is lossless Master + // Audio; it's the lossy secondary stream — see + // `secondary_dts_hd_0xa2_is_lossy_not_master_audio`, covering 0xA2 and 0x86.) /// HDMV coding_type 0x90 = Presentation Graphics (PG / subtitles) → Pgs, /// but 0x91 = Interactive Graphics (IG / menus) is NOT a subtitle stream. @@ -7149,12 +6914,9 @@ mod tests { // ── bytes_bad_in_title: empty-input guard ──────────────────────────── - // NOTE: `bad_ranges.is_empty() || title.extents.is_empty()` (mod.rs:628) — - // the `||`-to-`&&` mutant is EQUIVALENT here, not tested: the guard is a - // pure short-circuit. Whichever operand is empty, the corresponding loop - // (the outer `for ext in &title.extents` or the inner `for (pos, size) in - // bad_ranges`) simply iterates zero times and `total` stays its initial - // 0 — the early return changes nothing observable. See report. + // NOTE: `bad_ranges.is_empty() || title.extents.is_empty()` (mod.rs:628) — the + // `||`-to-`&&` mutant is EQUIVALENT here, not tested: it's a pure short-circuit, + // and whichever operand is empty the corresponding loop iterates zero times anyway. // ── byte_offset_in_title ────────────────────────────────────────────── @@ -7394,14 +7156,9 @@ mod tests { assert_eq!(result.main_at_risk_ms, 0.0); } - // NOTE: mod.rs:732 (`title.duration_secs > 0.0` seeding `bps`) — the - // `>`-to-`>=` mutant is EQUIVALENT. `bps` is only ever consumed behind - // its own `bps > 0.0` re-check at both use sites (mod.rs:743, 768); at - // the exact boundary (`duration_secs == 0.0`) the mutant instead computes - // `bps = size_bytes / 0.0` (`inf` or `NaN` since `size_bytes >= 0`), and - // `inf > 0.0` / `NaN > 0.0` are both `false` at the re-check — so the - // final output (`0.0` via the `else` branch) is identical either way. No - // reachable input makes this observable. + // NOTE: mod.rs:732 (`duration_secs > 0.0` seeding `bps`) — the `>`-to-`>=` mutant + // is EQUIVALENT: `bps` is only used behind its own `bps > 0.0` re-check (mod.rs:743, + // 768), and at the boundary `size_bytes / 0.0` is inf/NaN, still `false` there. // ── Codec::name / Display ──────────────────────────────────────────── diff --git a/src/disc/pgs_forced_probe.rs b/src/disc/pgs_forced_probe.rs index 9a0f6ccb..cbd604d7 100644 --- a/src/disc/pgs_forced_probe.rs +++ b/src/disc/pgs_forced_probe.rs @@ -192,11 +192,9 @@ fn plan_windows(sector_count: u32, share: u32) -> Vec<SampleWindow> { (0..windows) .map(|i| SampleWindow { offset: if windows == 1 { - // A title cut into many clips gives each extent a share worth one - // window. Putting that window at the extent's head samples every - // clip at the same relative position — and for the FIRST clip - // that position is the start of the feature, the one stretch a - // film reliably has no subtitles in. Take the middle instead. + // A single window per extent placed at the head would sample the + // first clip's start — the one span a film reliably lacks subtitles. + // Use the middle instead. align_down(span / 2) } else { // u64: `span * i` overflows u32 for a large extent. @@ -267,8 +265,8 @@ impl TrackEvidence { /// The coverage is the whole point. Evidence from a sampled (or budget-cut) read /// describes the sectors that were actually fed to the demuxer, not the extent — /// memoising it under the extent's full key and replaying it to another playlist -/// asserts a completeness the read never had. Recording `covered` and `complete` -/// lets a later run decide whether the entry answers ITS question or whether the +/// asserts a completeness the read never had. Recording `covered` alongside the +/// evidence's own `sampled` flag lets a later run decide whether the entry answers ITS question or whether the /// extent has to be read again. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) struct CachedEvidence { @@ -296,7 +294,7 @@ impl CachedEvidence { /// /// Many playlists on one disc reference the same clips (main feature, play-all, /// seamless-branch variants) but rarely with byte-identical extent LISTS: 00800 -/// = [A, B], 00801 = [A], 00802 = [B] are three different lists over two clips. +/// = `[A, B]`, 00801 = `[A]`, 00802 = `[B]` are three different lists over two clips. /// Keying on the whole list de-duplicated only exactly-identical playlists and /// re-read every shared clip once per list — up to `PROBE_BUDGET_SECTORS` /// (256 MiB) of optical-drive time each. Per-extent keying reads each physical @@ -398,11 +396,9 @@ pub(crate) fn probe_and_set_forced<S: SectorSource + ?Sized>( }) .collect(); - // Budget allocation: each extent gets a share of the sector budget in - // proportion to its size, computed over the title's WHOLE extent list rather - // than over the extents that happen to be uncached. That keeps an extent's - // sampling plan — and therefore the coverage its cache entry claims — a - // function of the title alone, not of cache state or probe order. + // Each extent's share of the sector budget is proportional to its size, + // computed over the title's whole extent list (not just uncached ones), so the + // sampling plan is a function of the title alone, not cache state or order. let total_sectors: u64 = title .extents .iter() @@ -439,10 +435,9 @@ pub(crate) fn probe_and_set_forced<S: SectorSource + ?Sized>( slot.merge(hit.evidence); } } - // No entry, or one whose coverage does not support the claim this - // run needs from it. (A playlist that declares a PGS PID a - // previous playlist did not also lands here, so the extra track is - // genuinely probed.) + // No entry, or one whose coverage doesn't support this run's claim — + // includes a PGS PID a previous playlist didn't declare, so it's + // genuinely probed. _ => fresh.push(pid), } } @@ -471,10 +466,8 @@ pub(crate) fn probe_and_set_forced<S: SectorSource + ?Sized>( .collect() } - // Whether ANY declared track could still have its outcome changed by reading - // more. This is the per-track early exit: the moment a track is disproven - // (and there is nothing left for its evidence to correct) it stops asking for - // budget, and when no track is asking, the run stops. + // Per-track early exit: once a track is disproven with nothing left to + // correct, it stops asking for budget; when no track is asking, the run stops. let anything_left_to_learn = |live: &[(u16, TrackEvidence)]| -> bool { let disc_uses_forced_flag = live.iter().any(|(_, e)| e.forced_seen); let busiest = live.iter().map(|(_, e)| e.displays).max().unwrap_or(0); @@ -488,19 +481,15 @@ pub(crate) fn probe_and_set_forced<S: SectorSource + ?Sized>( if !vendor_forced.get(pid).copied().unwrap_or(false) { return false; } - // Disproven but labelled forced: keep reading only while the evidence - // a demotion needs is still incomplete (see `demotable`) — otherwise - // the run would stop one window short of being ALLOWED to act on what - // it has already seen. + // Disproven but labelled forced: keep reading only while the evidence a + // demotion needs (see `demotable`) is still incomplete. !crate::mux::codec::pgs::demotable(e.facts(), disc_uses_forced_flag, busiest) }) }; if todo.is_empty() { - // Every extent's evidence came from a run that reached a designed stop and - // covered at least as much as this run planned to, so an absence claim - // over the composed evidence is as sound as the run that produced each - // part. + // Every extent's evidence reached a designed stop and covered at least as + // much as planned, so an absence claim over it is as sound as its source. apply_verdicts(title, &verdicts(&evidence, true)); return; } @@ -512,10 +501,9 @@ pub(crate) fn probe_and_set_forced<S: SectorSource + ?Sized>( // asserted from what was observed. let mut stop = StopReason::Exhausted; 'outer: for (ext, fresh_pids) in &todo { - // Trackers are PER EXTENT and only for the tracks this extent still owes - // evidence for — a track the cache already answered is not demuxed again, - // so no observation is ever counted twice, and budget is not spent on - // tracks that have nothing left to say. + // Trackers are per-extent, for tracks this extent still owes evidence for; + // an already-answered track isn't demuxed again, so no observation is + // double-counted and no budget is spent on tracks with nothing left to say. let mut trackers: HashMap<u16, ForcedTracker> = fresh_pids .iter() .map(|&p| (p, ForcedTracker::new())) @@ -531,21 +519,16 @@ pub(crate) fn probe_and_set_forced<S: SectorSource + ?Sized>( // Sectors of this extent actually fed to the demuxer — the coverage the // memo will claim, never more. let mut covered: u32 = 0; - // AACS aligned units are anchored at THIS extent's start LBA, so tell a - // decrypt-on-read source to gate relative to it rather than absolute disc - // LBA 0 — without this the very first read of a clip whose start_lba is - // not itself 3-aligned is rejected. Mirrors the mux read paths. Every - // window offset is a whole number of units from that base, so sampling - // does not disturb the gate. + // AACS units are anchored at this extent's start LBA, so gate a decrypt- + // on-read source relative to it (not disc LBA 0), or the first read of a + // non-3-aligned clip is rejected. Mirrors the mux read paths. reader.set_unit_base(ext.start_lba); // `None` = the extent's whole plan ran. `Some(reason)` = it stopped early. let mut cut_short: Option<StopReason> = None; for window in &plan { - // Demux/parse state is PER WINDOW, because a window is a - // DISCONTIGUOUS run of the clip: carrying a demuxer across the gap - // would splice two unrelated byte runs into one PES. The cost is at - // most a display set straddling a window edge — the same trade the - // per-extent reset already made at extent edges. + // Demux/parse state is per-window: a window is a discontiguous run of + // the clip, so carrying a demuxer across the gap would splice unrelated + // byte runs into one PES (same trade as the per-extent reset). let mut demux = TsDemuxer::new(fresh_pids); let mut parsers: HashMap<u16, PgsParser> = fresh_pids.iter().map(|&p| (p, PgsParser::new())).collect(); @@ -585,58 +568,22 @@ pub(crate) fn probe_and_set_forced<S: SectorSource + ?Sized>( break; } }; - // Advance by what was actually READ, not by what was requested. A - // short-but-nonzero read (a source whose batch is smaller than the - // request — `PrefetchedSectorSource` returns its producer's batch) - // used to advance `lba`/`remaining`/`sectors_read` by the full - // `count`, silently SKIPPING the unread tail of the chunk while - // `stop` stayed `Exhausted` — so an absence-based forced verdict was - // asserted (and memoised) over data that was never seen. + // Advance by what was actually READ, not requested: a short-but- + // nonzero read (e.g. `PrefetchedSectorSource`) used to advance by + // the full `count`, silently skipping the unread tail as `Exhausted`. let served = (n.min(want) / SECTOR_BYTES) as u32; - // ...but advancing by the raw sector count breaks the OTHER invariant - // this loop rests on: with a decrypting source every read must BEGIN a - // whole number of AACS aligned units past `ext.start_lba`, which is why - // `CHUNK_SECTORS` is a multiple of `ALIGNED_UNIT_SECTORS` (see the - // const-assert above). A short read of, say, 64 sectors is not a whole - // number of units, so the next `lba` would be off the unit grid, - // `DecryptingSectorSource` would reject it with `DecryptFailed` before - // reading, and the run would end `ReadFailed` — inconclusive, nothing - // memoised, forced detection silently back to the vendor label on - // exactly the encrypted discs the aligned chunk size was chosen for. - // - // So a read that did NOT satisfy the whole request advances only by - // whole aligned units, and the residue sectors are simply RE-READ from - // the next unit boundary on the following pass: at most two sectors of - // duplicated drive work, no gap, no double-feed of any byte to the - // demuxer (only the aligned prefix is fed), and no trailing partial - // unit — whose plaintext a unit-anchored decrypt cannot produce anyway - // — is ever handed to the parsers. - // - // A read that satisfied the whole request advances by all of it even - // when `count` itself was not unit-aligned: `count` is only ever below - // `CHUNK_SECTORS` on the extent's final chunk or at the sector budget, - // and both end the loop before another read of this extent. + // Advancing by raw sector count would break unit-alignment (every + // read must begin on an AACS aligned unit), so short reads advance only + // by whole units; the residue sectors are simply re-read next pass. let got = if served >= u32::from(count) { u32::from(count) } else { served - served % crate::aacs::content::ALIGNED_UNIT_SECTORS }; if got == 0 { - // Less than one whole aligned unit came back, so the read position - // cannot move: the next aligned boundary IS the one just read. The - // bytes are real, so feed them (never lose an observation), then - // RETRY the same aligned lba — a source that short-changed one call - // commonly satisfies the next, and only when it repeatedly cannot - // yield a whole unit is the run the truncated prefix an error is. - // Bounded retries are what keep a source that never yields a unit - // (including one that claims sectors and returns none) from - // spinning here for ever. A retry that re-serves the same bytes - // feeds them twice: the BOOLEAN evidence is monotone (observed / - // saw-a-non-forced-set / saw-a-forced-set), so a repeat cannot - // change it, and the display COUNT can over-count by at most - // [`STALL_RETRY_LIMIT`] repeats of a sub-unit (< 6 KB) read — - // too little to move the shape comparison the demotion gate - // makes, which is against the busiest track on the disc. + // Less than one aligned unit came back: feed the real bytes (never + // lose an observation), then retry the same lba, bounded by + // [`STALL_RETRY_LIMIT`] since boolean evidence is monotone to repeats. for pes in demux.feed(&buf[..n.min(want)]) { if let (Some(parser), Some(tracker)) = (parsers.get_mut(&pes.pid), trackers.get_mut(&pes.pid)) @@ -670,10 +617,9 @@ pub(crate) fn probe_and_set_forced<S: SectorSource + ?Sized>( } } } - // Saturating: a malformed extent can put the window's last chunk at - // the top of the 32-bit LBA space, and `remaining` has already - // reached 0 by then, so pinning the position is harmless — wrapping - // (or panicking in a debug build) is not. + // Saturating: a malformed extent can put the last chunk at the top of + // the 32-bit LBA space; `remaining` is already 0 by then, so pinning is + // harmless — wrapping (or a debug-build panic) is not. lba = lba.saturating_add(got); remaining -= got; sectors_read += got; @@ -687,11 +633,9 @@ pub(crate) fn probe_and_set_forced<S: SectorSource + ?Sized>( } } - // Drain the window's tail: the demuxer holds the last PES open until - // the next PUSI arrives, and with a sampled read that PUSI is in a - // different window (or nowhere), so without this the last display set - // of EVERY window is thrown away — 16 lost observations per extent, - // exactly where the sample is thinnest. + // Drain the window's tail: the demuxer holds the last PES open until the + // next PUSI, which a sampled read may put in another window (or nowhere), + // so without this every window loses its last display set. for pes in demux.flush() { if let (Some(parser), Some(tracker)) = (parsers.get_mut(&pes.pid), trackers.get_mut(&pes.pid)) @@ -714,22 +658,9 @@ pub(crate) fn probe_and_set_forced<S: SectorSource + ?Sized>( } } - // Fold this extent's evidence in, and memoise it if the read reached a - // DESIGNED stop — the whole plan ran, the sector budget was hit, or every - // track had already settled. The budget is a designed stop for exactly the - // reason [`StopReason`] documents, and it is the stop that fires on every - // disc that HAS a forced track — excluding it from the cache would mean - // nothing is ever memoised on precisely those discs. - // - // What makes THAT sound is the coverage stored alongside: the entry says - // how much of the extent was actually read, so a later playlist that - // intended to read more of it will re-read instead of inheriting an - // absence claim from a prefix (see [`CachedEvidence::answers`]). - // - // A halt or a read fault is different: the cut-off point is arbitrary, so - // its evidence is real for THIS title (nothing observed is retracted) but - // must not be frozen in as the extent's answer, or one transient fault - // would be replayed onto every other playlist sharing the clip. + // Fold in this extent's evidence; memoise only on a DESIGNED stop (plan + // done, budget hit, or all tracks settled), with coverage stored so a later + // playlist re-reads rather than inherits a halt/fault's arbitrary cutoff. let cacheable = cut_short.is_none_or(StopReason::absence_is_conclusive); let sampled = !(complete_plan && cut_short.is_none()); for (&pid, t) in trackers.iter() { @@ -742,11 +673,9 @@ pub(crate) fn probe_and_set_forced<S: SectorSource + ?Sized>( evidence: ev, covered, }; - // Never replace a richer memo with a thinner one: an extent - // re-read under a smaller share would otherwise DOWNGRADE what is - // known about it. Facts merge (they are monotone), and the - // coverage claimed is the larger of the two — conservative, since - // the two samples together cover at least that much. + // Never replace a richer memo with a thinner one (a re-read under a + // smaller share would downgrade it): facts merge monotonically, and + // the coverage claimed is the larger of the two, which is conservative. cache .entry((ext.start_lba, ext.sector_count, pid)) .and_modify(|prev| { @@ -800,7 +729,7 @@ fn tracker_evidence(t: &ForcedTracker, sampled: bool) -> TrackEvidence { /// Compose the per-track verdicts a run is ENTITLED to assert from the evidence /// it gathered. A track absent from the result keeps its vendor-derived flag. /// -/// Three gates, all PER TRACK, because the evidence is per track: +/// Four gates, all PER TRACK, because the evidence is per track: /// * `observed` — saw no display set at all, so nothing is known. (Never assert /// "not forced" from having seen nothing.) /// * on a truncated run, `non_forced` — the track saw an actual non-forced @@ -814,6 +743,9 @@ fn tracker_evidence(t: &ForcedTracker, sampled: bool) -> TrackEvidence { /// that flag. Discs like that exist (measured: not one track on the disc /// carries it), and without this gate the probe would strip the correct /// forced label off every track on every one of them. +/// * for the FORCED verdict, a minimum display-set count (`PROMOTE_MIN_DISPLAY_SETS`) +/// unless the read was complete — over a SAMPLE of a mixed track, one flagged +/// set alone is not enough to promote the whole track to forced. fn verdicts(evidence: &HashMap<u16, TrackEvidence>, conclusive: bool) -> HashMap<u16, bool> { // Disc-level facts the demotion gate rests on, over the tracks judged // together: does the authoring house set the flag at all, and how busy is the @@ -832,13 +764,9 @@ fn verdicts(evidence: &HashMap<u16, TrackEvidence>, conclusive: bool) -> HashMap busiest, ); } - // Calling a track FORCED is also an absence claim — "no display set - // here was un-flagged" — and over a SAMPLE of a track that mixes - // flagged and un-flagged sets (measured: such tracks exist, flagging - // a quarter of their sets), catching one flagged set and nothing else - // is exactly what a wrong promotion looks like. A read that covered - // every extent end to end has no such gap and may promote off a - // single set (measured: single-sign forced tracks exist too). + // Calling a track FORCED is also an absence claim ("no set here was + // un-flagged"); over a SAMPLE of a mixed track, one flagged set alone + // is a wrong promotion, so require a minimum unless the read was complete. !e.sampled || e.displays >= PROMOTE_MIN_DISPLAY_SETS }) .map(|(&pid, e)| (pid, !e.non_forced)) @@ -854,18 +782,9 @@ fn apply_verdicts(title: &mut DiscTitle, verdicts: &HashMap<u16, bool>) { && let Some(&forced) = verdicts.get(&sub.pid) { sub.forced = forced; - // A demoted track must not go on describing itself as forced. The - // flag and the qualifier are two renderings of one fact for - // different consumers — the muxer writes `FlagForced` from - // `forced`, the JSON sidecar writes its qualifier string from - // `qualifier` — so leaving `Forced` behind here published a track - // whose sidecar said "forced" and whose Matroska header said it was - // not. The probe read the content; it outranks the vendor's claim. - // - // Only this direction is a contradiction. `qualifier == None` on a - // track the probe promoted is not one: `None` is the absence of an - // editorial qualifier, not an assertion that the track is ordinary, - // and the vendor never claimed otherwise. + // A demoted track must not go on describing itself as forced: flag and + // qualifier render one fact for different consumers, so leaving `Forced` + // behind contradicts the header. The probe outranks the vendor's claim. if !forced && sub.qualifier == crate::disc::LabelQualifier::Forced { sub.qualifier = crate::disc::LabelQualifier::None; } @@ -969,11 +888,9 @@ mod tests { "the probe must never read past the budget, got {}", reader.served ); - // ...and must actually SPEND it: a plan that quietly reads a fraction of - // the budget would look "safe" while observing even less than the - // head-first read it replaced. One window's slack is allowed (windows are - // whole numbers of aligned units, so 16 of them need not divide the - // budget exactly). + // ...and must actually SPEND it: quietly reading a fraction would look + // "safe" while observing less than the head-first read it replaced. + // One window's slack is allowed (windows need not divide the budget exactly). assert!( reader.served + WINDOW_SECTORS > PROBE_BUDGET_SECTORS, "the probe must spend the budget it is given, got {}", @@ -1096,10 +1013,9 @@ mod tests { } } - // PGS PCS layout (matches the private constants in mux::codec::pgs): a - // display-set frame begins with a PCS (segment type 0x16); byte 13 is - // number_of_composition_objects; byte 17 is the first object's flags, whose - // 0x40 bit is forced_on_flag. + // PGS PCS layout (matches mux::codec::pgs): a display-set frame begins with a + // PCS (segment type 0x16); byte 13 is number_of_composition_objects; byte 17 + // is the first object's flags, whose 0x40 bit is forced_on_flag. const PCS_SEG: u8 = 0x16; const PCS_NUM_OBJECTS_OFF: usize = 13; const PCS_FLAGS_OFF: usize = 17; @@ -1172,10 +1088,8 @@ mod tests { pos: 0, }; let mut title = pgs_title(pid, false); // vendor label says NOT forced - // One sector, which is exactly what the reader serves: an extent that - // claims more sectors than the source yields is a SHORT read, and a short - // read is (correctly) inconclusive — see - // `read_error_after_partial_content_preserves_vendor_forced`. + // One sector, exactly what the reader serves: an extent claiming more + // sectors than the source yields is a SHORT read, correctly inconclusive. title.extents = vec![Extent { start_lba: 0, sector_count: 1, @@ -1193,9 +1107,8 @@ mod tests { #[test] fn nonforced_display_sets_clear_a_not_yet_forced_track() { // A non-forced display set observed on the wire settles the track as - // not-forced. The vendor label here already agrees, so this pins the - // verdict itself (the track must not come back FORCED off one non-forced - // set) without engaging the demotion guard — that gets its own tests. + // not-forced; this pins the verdict itself (must not come back FORCED off + // one non-forced set) without engaging the demotion guard. let pid = 0x1200u16; let mut reader = TsReader { data: ts_stream(pid, &pcs_display(false)), @@ -1288,10 +1201,9 @@ mod tests { #[test] fn read_error_after_partial_content_preserves_vendor_forced() { - // The defect: a forced verdict rests on the ABSENCE of a non-forced - // display set. When the read dies mid-title that absence means nothing — - // the rest of the track was never seen — yet the probe used to apply it as - // authoritative, overwriting the vendor flag from a fraction of the data. + // The defect: a forced verdict rests on the ABSENCE of a non-forced set. + // When the read dies mid-title that absence means nothing, yet the probe + // used to apply it as authoritative, overwriting the vendor flag. let pid = 0x1200u16; let mut reader = PartialTsReader::new(ts_stream(pid, &pcs_display(true)), ThenWhat::Error); let mut title = multi_read_pgs_title(pid, false); // vendor label: NOT forced @@ -1304,9 +1216,8 @@ mod tests { !s.forced, "a forced verdict from a read-truncated prefix must not overwrite the vendor flag" ); - // And it must not be memoised: the cache is keyed on the extent list, so a - // cached inconclusive result would spread this one read fault across every - // playlist sharing these clips and block any later re-read. + // And it must not be memoised: caching an inconclusive result would spread + // this one read fault across every playlist sharing these clips. assert!( cache.is_empty(), "an inconclusive probe must not be cached against these extents" @@ -1315,12 +1226,9 @@ mod tests { #[test] fn read_error_keeps_a_track_that_already_settled_not_forced() { - // Per-track, not per-title: "not forced" is POSITIVE evidence — a - // non-forced display set was actually seen — and no amount of unread data - // could retract it. That verdict survives the truncation even though a - // forced verdict would not. The vendor label already says not-forced, so - // what is pinned here is that the truncation does not let the track come - // back FORCED. + // Per-track, not per-title: "not forced" is POSITIVE evidence (a non-forced + // set was actually seen), so no amount of unread data retracts it — that + // verdict survives truncation even though a forced verdict would not. let pid = 0x1200u16; let mut reader = PartialTsReader::new(ts_stream(pid, &pcs_display(false)), ThenWhat::Error); let mut title = multi_read_pgs_title(pid, false); @@ -1339,9 +1247,8 @@ mod tests { #[test] fn budget_stop_still_applies_the_forced_verdict() { // The budget is a DESIGNED stop, not a failure: the probe's natural exit - // never fires for a genuinely forced track, so the budget is exactly the - // mechanism by which a forced verdict gets accepted from a bounded prefix. - // Classifying it as inconclusive would disable forced detection. + // never fires for a genuinely forced track, so the budget is how a forced + // verdict gets accepted from a bounded prefix; inconclusive would disable it. let pid = 0x1200u16; let mut reader = PartialTsReader::new(ts_stream(pid, &pcs_display(true)), ThenWhat::Zeros); let mut title = multi_read_pgs_title(pid, false); // vendor label: NOT forced @@ -1355,9 +1262,8 @@ mod tests { "a forced verdict from a budget-bounded prefix must still be applied" ); // One entry per (extent, PGS track): both extents reached a designed stop - // (the first was read to its end, the second stopped at the budget), so - // both are memoised — the budget is the stop that fires on every disc that - // HAS a forced track, so excluding it would memoise nothing there. + // (first read to end, second stopped at budget), so both are memoised — + // excluding the budget stop would memoise nothing on forced-track discs. assert_eq!(cache.len(), 2, "a conclusive probe is memoised per extent"); assert!(cache.contains_key(&(0, 4, pid))); assert!(cache.contains_key(&(100, u32::MAX, pid))); @@ -1367,9 +1273,7 @@ mod tests { fn cancelled_probe_is_neither_asserted_nor_cached() { // A halt lands at an arbitrary chunk boundary, so an absence claim from it // is worth no more than one from a read fault. Here the cancel arrives - // AFTER a forced display set has been observed — the same setup that, - // uncancelled, reaches the budget and legitimately asserts forced (see - // budget_stop_still_applies_the_forced_verdict). + // AFTER a forced set was observed, unlike the uncancelled budget case. let pid = 0x1200u16; let halt = crate::halt::Halt::new(); let mut reader = PartialTsReader { @@ -1386,11 +1290,9 @@ mod tests { !s.forced, "a verdict from a cancelled probe must not overwrite the vendor flag" ); - // The cancel landed inside the SECOND extent, whose read is therefore an - // arbitrary prefix: that extent must not be memoised, or the one - // cancellation would be replayed onto every other playlist sharing the - // clip. (The first extent WAS read to its end before the cancel, so its - // own evidence is sound and keeping it is the point of per-extent keying.) + // The cancel landed inside the SECOND extent, an arbitrary prefix that must + // not be memoised, or the cancellation replays onto every playlist sharing + // the clip. (The first extent read to its end, so it stays cached.) assert!( !cache.contains_key(&(100, u32::MAX, pid)), "a cancelled probe must not poison the cancelled extent's cache entry" @@ -1898,23 +1800,9 @@ mod tests { /// anything — and therefore whether the probe reports the run as truncated. #[test] fn a_stop_reason_decides_whether_absence_is_conclusive() { - // The debug line at the end of `probe_and_set_forced` is gated on - // `!stop.absence_is_conclusive()`. Assert that PREDICATE rather than - // counting emitted events. - // - // Reading a log line back needs a capturing subscriber, which is - // thread-local, while tracing's callsite-interest cache is global. - // Those race: the sibling test in disc/encrypt.rs that did this failed - // roughly one full-suite run in ten while passing every time in - // isolation, and serialising the captures crate-wide was not enough — - // the cache can still be re-evaluated against the process default - // dispatch. A boolean does not need a subscriber to check. - // - // ECMA of the decision: a stop that saw everything it was ever going to - // see (Exhausted) or stopped by DESIGN at the budget is conclusive, so - // the absence of a display set means the track is not forced and there - // is nothing to report. A stop that was cut short (Halted, ReadFailed) - // is not, and that is exactly what the operator needs told. + // Assert the PREDICATE directly rather than counting log events (capturing + // a tracing log line is racy — see the sibling test in disc/encrypt.rs). + // Exhausted/Budget stops are conclusive; Halted/ReadFailed are not. assert!( StopReason::Exhausted.absence_is_conclusive(), "reading every extent to its end is a complete observation" @@ -1936,15 +1824,9 @@ mod tests { ); } - // ── sampling: reading where the subtitles actually are ────────────────── - // - // SYNTHETIC FIXTURES. The clips below are generated, not captured: they - // reproduce the measured SHAPE of real discs — a feature's subtitles begin - // minutes into the title, a full dialogue track carries one to two thousand - // display sets spread over the whole runtime, a forced-narrative track - // carries tens — and nothing else. They prove the probe's LOGIC over that - // shape. They do not prove the shape itself; that comes from measurement on - // real discs, and if the shape is wrong these fixtures are wrong with it. + // ── sampling: reading where the subtitles actually are ────────── + // SYNTHETIC FIXTURES reproducing the measured SHAPE of real discs; they + // prove the probe's LOGIC over that shape, not the shape itself. /// One synthetic PGS track: `count` display sets, the first at /// `first_sector`, then every `period_sectors`. diff --git a/src/drive/capture.rs b/src/drive/capture.rs index a111a0f8..de36a04a 100644 --- a/src/drive/capture.rs +++ b/src/drive/capture.rs @@ -150,10 +150,9 @@ mod tests { #[test] fn mask_string_preserves_non_ascii_chars() { - // is_ascii_alphabetic/is_ascii_digit are false for non-ASCII, so - // multibyte chars pass through unchanged (no mojibake, no panic). - // 'c','a','f' are ASCII letters → 'A'; 'é' is non-ASCII → - // preserved; '9' → '0'. + // is_ascii_alphabetic/is_ascii_digit are false for non-ASCII, so multibyte + // chars pass through unchanged (no mojibake, no panic): ASCII letters → 'A', + // digits → '0', 'é' preserved. assert_eq!(mask_string("café9"), "AAAé0"); } diff --git a/src/drive/linux.rs b/src/drive/linux.rs index bfdda33a..d1f0cd29 100644 --- a/src/drive/linux.rs +++ b/src/drive/linux.rs @@ -84,14 +84,9 @@ pub fn resolve_device(path: &str) -> Result<(String, DeviceResolution)> { let sr_id = DriveId::from_drive(sr_transport.as_mut())?; drop(sr_transport); for (sg_path, sg_id) in find_drives() { - // Require a non-empty serial before treating vendor/product/ - // serial as a unique match. serial_number falls back to an - // empty string when GET CONFIGURATION 0108h is unavailable - // (common on OEM drives); two same-model drives would then - // both compare equal and the first in enumeration order would - // win silently, resolving sr1 to sr0's sg node. An empty - // serial can't disambiguate, so fall through to the no-match - // path instead. + // Require a non-empty serial before treating vendor/product/serial as a unique + // match: serial_number falls back to "" when GET CONFIGURATION 0108h is + // unavailable (OEM drives), which would let same-model drives collide silently. if !sr_id.serial_number.is_empty() && sg_id.vendor_id == sr_id.vendor_id && sg_id.product_id == sr_id.product_id diff --git a/src/drive/mod.rs b/src/drive/mod.rs index 50bfa25f..d21ba2c5 100644 --- a/src/drive/mod.rs +++ b/src/drive/mod.rs @@ -8,14 +8,9 @@ pub fn extract_scsi_context(e: &Error) -> (u8, Option<crate::scsi::ScsiSense>) { match e { Error::ScsiError { status, sense, .. } => (*status, *sense), Error::DiscRead { status, sense, .. } => (status.unwrap_or(0), *sense), - // A failed `ioctl(SG_IO)` and a vanished device never produced a SCSI - // reply at all — they are dead-bus faults, not recoverable bad sectors. - // `Error::is_scsi_transport_failure` (error.rs) already declares both - // variants transport failures so sweep / patch / fill_extents abort the - // pass instead of zero-filling against a wedged device; callers that - // flatten an error through here (Drive::read_one, DiscStream::fill_extents, - // freemkv-engine's recovery sweep/patch) would otherwise collapse them to - // status 0 and silently destroy that classification. + // A failed `ioctl(SG_IO)` or vanished device is a dead-bus fault, not a + // recoverable bad sector — map to SCSI_STATUS_TRANSPORT_FAILURE so sweep / + // patch / fill_extents abort the pass instead of zero-filling a wedged device. Error::IoError { .. } | Error::DeviceNotFound { .. } => { (crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE, None) } @@ -299,16 +294,9 @@ impl Drive { for attempt in 0..60u64 { hb.tick(attempt, 60); let mut buf = [0u8; 0]; - // `checked_exec`, not a bare `execute`. This poll was the ONE - // drive path that talked to the transport directly, and it is the - // longest-running one: 60 x 500 ms = ~30 s. It never read - // `self.halt`, so an operator Stop pressed while a cold drive spun - // up was ignored for up to half a minute while every other path in - // this file returned `Halted` at its next command boundary. A Stop - // that does nothing for 30 s is indistinguishable from a hung app. - // - // A TUR that FAILS is the ordinary not-ready-yet answer and must - // keep the loop going — only `Halted` aborts it. + // `checked_exec`, not bare `execute`: this poll (60 x 500 ms = ~30 s) must + // see `self.halt`, or a Stop during cold spin-up is ignored for half a + // minute. A TUR that FAILS is just not-ready-yet and keeps the loop going. match self.checked_exec(&tur, crate::scsi::DataDirection::None, &mut buf, 5_000) { Ok(_) => { tracing::info!( @@ -362,21 +350,9 @@ impl Drive { 5_000, ); - // MMC-6 §6.7: byte 5 is a Media Status only when the Event Header - // actually announces a Media Event Descriptor behind it. The header is - // Event Descriptor Length (big-endian, bytes 0-1), then byte 2 = NEA - // (bit 7) + Notification Class (bits 2-0), then byte 3 = Supported - // Event Classes. With NEA set the drive is telling us there is NO event - // to report and returns the header alone; with a different Notification - // Class the descriptor that follows is not a media one and its second - // byte means something else entirely. - // - // Decoding byte 5 unconditionally turns both of those into Media Status - // 0 == "tray closed, no disc" — a drive with a disc loaded reported as - // empty, from a reply that said nothing about the media at all. The - // drive is untrusted input here, so an event-less or foreign-class reply - // yields no media state and the TEST UNIT READY fallback answers - // instead. + // MMC-6 §6.7: byte 5 is Media Status only when the Event Header (NEA flag + + // Notification Class) announces a Media Event Descriptor. Otherwise decoding + // it reads as "no disc" for untrusted input, so fall back to TUR instead. const NEA: u8 = 0x80; const NOTIFICATION_CLASS_MASK: u8 = 0x07; const NOTIFICATION_CLASS_MEDIA: u8 = 0x04; @@ -416,10 +392,9 @@ impl Drive { 0x00 => DriveStatus::NoDisc, // tray closed, no disc 0x01 => DriveStatus::TrayOpen, // tray open, no media 0x02 => DriveStatus::DiscPresent, // tray closed, disc present - // 0x03 = tray-open bit AND media-present bit both set: - // a contradictory/transient state. Don't report it as - // ready — autorip must not start a rip on a drive that - // is still settling. Treat as tray-open. + // 0x03 = tray-open AND media-present both set: a contradictory, + // transient state. Don't report ready — autorip must not start on + // a drive that's still settling — so treat as tray-open. 0x03 => DriveStatus::TrayOpen, _ => DriveStatus::Unknown, } @@ -518,27 +493,17 @@ impl Drive { pub fn init(&mut self) -> Result<()> { let t0 = std::time::Instant::now(); tracing::info!(target: "freemkv::drive", phase = "init", "begin"); - // Drive-prep runs for EVERY disc, DVD INCLUDED. The drive-level firmware - // unlock lifts riplock and readies max read speed regardless of disc type - // — drive features are disc-independent. At init the disc kind is not yet - // probed (Unknown), so only the identity-keyed DRIVE unlocker matches - // here; the AACS host-cert handshake and the CSS bus-auth handshake run - // LATER, each gated on the actual disc kind, on TOP of the already- - // unlocked drive. A genuine transport fault means the bus is dead — abort - // init (the v1.1.0 invariant; `if let Ok` was silently swallowing it). - // Every other error (NotApplicable / no match) is "nothing applied" — - // fall through to stock mode. NOTE: the `if disc_is_dvd() { return }` skip - // that used to sit here was the v1.0.0-rc.1 regression — it skipped the - // drive-prep for DVD, leaving DVDs riplocked at stock speed. + // Drive-prep runs for EVERY disc, DVD INCLUDED — drive features are + // disc-independent; AACS/CSS handshakes run LATER, gated on disc kind. A + // transport fault aborts init (v1.1.0 invariant); other errors fall through. self.init_ran = true; let (matched, unlock_res) = crate::unlock_bridge::run_features(self.scsi.as_mut(), &self.drive_id); let r: Result<()> = match unlock_res { Ok(unlocked) => { - // Record WHICH drive-prep unlocker actually ran — "MT1959" (the - // drive-firmware bus-unlock route) or "Renesas" (the cert route) — - // not the ld-only identity lookup, so a Renesas drive reports - // itself honestly rather than as nothing. + // Record WHICH drive-prep unlocker ran ("MT1959" bus-unlock or + // "Renesas" cert route), not the id-only lookup, so a Renesas + // drive reports itself honestly rather than as nothing. self.unlocker_name = Some(matched.to_string()); // Stash the OEM Volume ID the unlocker returned for the AACS // handshake phase (do_handshake reads it via `oem_vid()`). A @@ -555,19 +520,14 @@ impl Drive { }), Err(_) => Ok(()), }; - // Raise the drive to its maximum read speed — UNCONDITIONALLY whenever - // the bus is alive (init didn't transport-fault), whether or not an - // unlocker matched, and for ANY disc type (DVD now flows through here too, - // so it gets max speed on the freshly firmware-unlocked drive instead of - // the stock riplock). A stock-mode drive with no firmware unlocker still - // wants max speed. Best-effort: a failure here must NOT fail the rip. + // Raise to max read speed UNCONDITIONALLY whenever the bus is alive, for + // ANY disc type — DVD included, and even a stock-mode drive with no + // firmware unlocker still wants it. Best-effort: failure must NOT fail the rip. if r.is_ok() { self.set_speed(Self::SPEED_MAX_KBPS); - // Ask the drive to REPORT recovered/marginal reads rather than - // silently commit best-effort data as GOOD (the dirty-disc - // "passed-clean-but-decodes-with-errors" trap). Best-effort: a drive - // that doesn't honor it just keeps its defaults — no regression, and - // on a clean disc it changes nothing. + // Ask the drive to REPORT recovered/marginal reads instead of silently + // committing best-effort data as GOOD (the dirty-disc "passed-clean-but- + // decodes-with-errors" trap). Best-effort: unsupported drives keep defaults. self.enable_recovered_error_reporting(); } tracing::info!( @@ -844,29 +804,20 @@ impl Drive { "Drive::read enter" ); - // Cap each CDB to the transport's max data-in transfer. A single - // READ larger than the adapter limit fails outright on some - // backends (notably Windows SPTI, where a 16 MiB read exceeds the - // adapter MaximumTransferLength → DeviceIoControl fails → we'd - // mis-read it as a transport failure and spam tiny-read fallbacks). - // For the common small read (count <= max_sectors) this is a single - // read_one call with no behavior change. + // Cap each CDB to the transport's max data-in transfer: a single READ past the + // adapter limit fails outright on some backends (e.g. Windows SPTI, where a + // 16 MiB read exceeds MaximumTransferLength and we'd mis-read it as transport failure). let max_sectors = (self.scsi.max_transfer_bytes() / 2048).max(1) as u32; if count as u32 <= max_sectors { return self.read_one(lba, count, buf, timeout_ms, recovery, fua); } - // Large read: split into chunks of at most `max_sectors` sectors, - // each a self-contained READ(10) with the same validation. Any - // chunk error reports that chunk's LBA (more precise than the whole - // request's base LBA). + // Large read: split into `max_sectors`-sized chunks, each a self-contained + // READ(10). Any chunk error reports that chunk's LBA, not the base LBA. let count = count as u32; - // Check the caller's buffer ONCE, up front. The chunk loop slices `buf` - // by `count * 2048`; without this an undersized buffer PANICKED ('range - // end index out of range') out of the public `read`/`read_fua`, while the - // single-chunk path above tolerates the same undersized buffer and returns - // `Err(DiscRead)` from `checked_exec`. Behaviour on an undersized buffer - // must not depend on the transport's transfer limit. + // Check the caller's buffer ONCE, up front: the chunk loop slices `buf` by + // `count * 2048`, and without this an undersized buffer PANICKED out of the + // public `read`/`read_fua` instead of returning `Err(DiscRead)` like the single-chunk path. if buf.len() < count as usize * 2048 { return Err(Error::DiscRead { sector: lba as u64, @@ -874,11 +825,9 @@ impl Drive { sense: None, }); } - // The whole range must be addressable: SBC-3 READ(10) carries a 32-bit - // LOGICAL BLOCK ADDRESS, so a request whose last chunk crosses `u32::MAX` - // has no valid CDB. `lba + done` below was unchecked — a debug panic out - // of the public API, and in release a wrap to a low LBA that was read and - // returned as if it were the requested one. + // The whole range must be addressable: READ(10) carries a 32-bit LBA, so a + // request crossing `u32::MAX` has no valid CDB. `lba + done` was unchecked — + // a debug panic, or in release a wrap to a low LBA read and returned as requested. if lba.checked_add(count.saturating_sub(1)).is_none() { return Err(Error::DiscRead { sector: lba as u64, @@ -921,13 +870,9 @@ impl Drive { // re-fetch the medium past its cache. fua: bool, ) -> Result<usize> { - // FUA is OFF on the bulk path — unconditionally forcing every READ(10) - // past the cache disabled the drive's readahead/streaming cache on the - // sequential sweep and collapsed throughput ~10x (UHD 15-25 → ~2 MB/s, - // DVD → ~0.5 MB/s), disc-type-agnostic — the cache IS the streaming - // throughput. It is set ONLY when a Pass-N recovery handler (FuaRetry) - // asks for it per marginal-sector re-read, where cache-masking of a - // stochastic sector actually matters (#55). + // FUA is OFF on the bulk path: forcing every READ(10) past the cache disabled + // readahead on the sequential sweep and collapsed throughput ~10x (UHD → ~2 + // MB/s, DVD → ~0.5 MB/s). Set ONLY per marginal-sector re-read by FuaRetry (#55). let cdb = [ crate::scsi::SCSI_READ_10, if fua { 0x08 } else { 0x00 }, @@ -950,26 +895,9 @@ impl Drive { Ok(result) if result.bytes_transferred == count as usize * 2048 => { Ok(result.bytes_transferred) } - // A READ(10) that completes with GOOD status but a residual - // underrun (bytes_transferred < requested) is a SHORT transfer: - // the tail of `buf` still holds stale bytes from a prior read. - // Committing those as recovered/Good is silent data corruption, so - // treat a short transfer as a failed read — the caller marks the - // range NonTrimmed and retries (a loud miss, never a silent commit). - // The sector/file path enforces the same invariant in - // sector/prefetched.rs; this is the live-drive counterpart. - // - // SAY SO. The refusal was correct and completely silent: the - // sibling `Err(e)` arm below warns with lba/count/status, this one - // logged nothing at all, so a drive that residual-underruns on - // GOOD status was indistinguishable in the logs from a scratched - // disc — two different populations (replace the drive vs. clean - // the disc) collapsed into one. Absence of a log is itself the - // defect here; the read still fails either way. - // - // `scsi_status`/`sense` are deliberately absent from the line - // rather than faked: the command SUCCEEDED, so there is no sense - // data to report. `transferred` vs `expected` is the whole signal. + // GOOD status with a residual underrun is a SHORT transfer: `buf`'s tail + // holds stale bytes, so fail (NonTrimmed, retried) rather than commit stale + // data — and log it, so it isn't indistinguishable from a scratched disc. Ok(result) => { tracing::warn!( target: "freemkv::drive", @@ -998,11 +926,9 @@ impl Drive { "Drive::read checked_exec failed" ); - // /dev/sr0 pread fallback (Linux only). The kernel - // sr_mod driver auto-retries failed reads (~5× per - // command). Empirically (BU40N + a UHD disc, - // 2026-05-08) dd via /dev/sr0 recovers ~50% of bad - // sectors that a single-shot SG_IO READ misses. + // /dev/sr0 pread fallback (Linux only): sr_mod auto-retries reads + // (~5x). Empirically (BU40N + UHD disc, 2026-05-08) this recovers + // ~50% of bad sectors that a single-shot SG_IO READ misses. #[cfg(target_os = "linux")] if recovery && let Some(fd) = self.block_dev_fd @@ -1141,15 +1067,9 @@ impl Drive { let stop = [SCSI_START_STOP_UNIT, 0, 0, 0, 0x00, 0]; // START=0, LOEJ=0 → spin down let start = [SCSI_START_STOP_UNIT, 0, 0, 0, 0x01, 0]; // START=1, LOEJ=0 → spin up let mut buf = [0u8; 0]; - // `checked_exec` + `sleep_until_halted`, not `execute` + a blind - // `thread::sleep`. This routine is ~15 s of deliberate waiting - // (`SPIN_DOWN_IDLE_SECS` + `SPIN_UP_SETTLE_SECS`) issued from the - // recovery path, i.e. exactly when a run is going badly and the - // operator is most likely to press Stop. Both commands bypassed the - // halt flag and both sleeps were unconditional, so the whole cycle was - // deaf: Stop appeared to hang. Nothing here is un-interruptible — a - // half-finished spin cycle leaves the drive spun down, which the next - // command spins back up. + // `checked_exec` + `sleep_until_halted`, not blind `thread::sleep`: this ~15 s + // wait runs from the recovery path, exactly when Stop is likely pressed. A + // half-finished spin cycle is fine — the next command spins the drive back up. self.checked_exec(&stop, crate::scsi::DataDirection::None, &mut buf, 30_000)?; sleep_until_halted( &self.halt, @@ -1462,17 +1382,9 @@ mod halt_tests { let r = sleep_until_halted(&flag, Duration::from_secs(10)); assert!(matches!(r, Err(Error::Halted))); let waited = t0.elapsed(); - // What this test is for: the sleep must end because the flag flipped, - // NOT because the 10 s timeout expired. Anything comfortably under - // that proves it, and the lower bound proves it did not return early - // for some other reason. - // - // The upper bound used to be 350 ms — flag at ~150 ms plus one 100 ms - // poll slice, with a little slack. That measures the SCHEDULER, not - // this function: a loaded CI runner took 377 ms and failed, which says - // nothing about whether the wake worked. Bound it well below the - // timeout instead, so the assertion still distinguishes the two - // outcomes it exists to distinguish. + // The sleep must end because the flag flipped, not the 10 s timeout; the lower + // bound rules out returning early otherwise. Upper bound was 350 ms but that + // measures the scheduler: a loaded CI runner hit 377 ms and failed for no reason. assert!(waited < Duration::from_secs(2), "waited {waited:?}"); assert!(waited >= Duration::from_millis(140), "waited {waited:?}"); } @@ -1848,11 +1760,9 @@ mod command_tests { #[test] fn read_builds_read10_cdb_with_be_lba_and_count() { - // Drive::read issues READ(10) (0x28). LBA bytes 2..5 big-endian, - // transfer length bytes 7..8 big-endian (MMC-6). FUA is DISABLED (byte 1 - // == 0x00) so the drive cache/readahead is allowed — forcing FUA on the - // bulk sweep collapsed throughput ~10x. Distinct nibbles catch a swapped - // shift. + // READ(10) (0x28): LBA bytes 2..5 BE, length bytes 7..8 BE (MMC-6). FUA is + // DISABLED (byte 1 == 0x00) — forcing FUA on the bulk sweep collapsed + // throughput ~10x. Distinct nibbles catch a swapped shift. let RecordingHarness { drive: mut d, cdb, @@ -2436,10 +2346,9 @@ mod command_tests { #[test] fn select_drive_falls_back_to_first_when_none_have_media() { - // No drive reports a disc → fall back to the FIRST opened drive so - // single-drive / quirky setups still get a drive (historical - // behavior preserved). Tag drive #1 distinctly (TrayOpen 0x01) and - // confirm it, not #2 (NoDisc 0x00), is returned. + // No drive reports a disc → fall back to the FIRST opened drive so single-drive + // / quirky setups still get one (historical behavior). Tag drive #1 distinctly + // (TrayOpen 0x01) and confirm it, not #2 (NoDisc 0x00), is returned. let drives = vec![drive_with_media_byte(0x01), drive_with_media_byte(0x00)]; let mut picked = select_drive_with_media(drives.into_iter()).expect("a fallback drive"); assert_eq!( @@ -2507,10 +2416,9 @@ mod command_tests { #[test] fn drive_status_short_transfer_falls_back_to_tur() { - // bytes_transferred < 6 means the GET EVENT reply is unusable; - // the code falls back to a TUR. FixedTransport always returns - // Ok, so the TUR "succeeds" → DiscPresent. (Buffer length 8 but - // payload only 4 bytes → bytes_transferred = 4.) + // bytes_transferred < 6 (buffer len 8, payload 4) means the GET EVENT reply + // is unusable, so code falls back to a TUR; FixedTransport always returns + // Ok, so the TUR "succeeds" → DiscPresent. let mut d = drive_with(vec![0u8; 4]); assert_eq!(d.drive_status(), DriveStatus::DiscPresent); } @@ -2610,11 +2518,9 @@ mod command_tests { #[test] fn get_config_feature_encodes_feature_code_be_in_cdb() { - // GET CONFIGURATION (0x46), RT field byte 1 = 0x02 (report the - // named feature), then the 16-bit feature code big-endian in - // bytes 2..4. 0x010D has a nonzero high byte, so a swapped shift - // (>> vs <<) or byte order bug asks the drive for the wrong - // feature entirely. + // GET CONFIGURATION (0x46), RT byte 1 = 0x02, feature code big-endian in + // bytes 2..4. 0x010D has a nonzero high byte, so a swapped shift (>> vs <<) + // or byte order bug would ask the drive for the wrong feature. let RecordingHarness { drive: mut d, cdb, @@ -3004,10 +2910,9 @@ mod command_tests { #[test] fn init_without_unlocker_is_ok_oem_fallback() { - // The test transport's identity matches no registered unlocker, so - // route_unlock returns None. init() must succeed (leaving the drive - // in stock mode for the host-cert handshake), not error — the OEM - // route is the no-match fallback, not a failure. + // No registered unlocker matches, so route_unlock returns None. init() must + // still succeed (stock mode for the host-cert handshake) — no-match is the + // OEM fallback, not a failure. let mut d = drive_with(vec![]); assert!( d.init().is_ok(), diff --git a/src/dvdnav/nav.rs b/src/dvdnav/nav.rs index a0af0313..fc4f138d 100644 --- a/src/dvdnav/nav.rs +++ b/src/dvdnav/nav.rs @@ -24,9 +24,8 @@ use crate::sector::SectorSource; use crate::udf::UdfFs; // ── VMGI management-table byte offsets into VIDEO_TS.IFO (DVD-Video spec) ───── -// -// The VMGI_MAT header carries these fixed offsets. The values they hold are -// per-disc; the offsets are constant. +// The VMGI_MAT header carries these fixed offsets: per-disc values live at +// disc-constant offsets. const VMGI_MAGIC: &[u8; 12] = b"DVDVIDEO-VMG"; /// u32 **byte** offset (from the start of VIDEO_TS.IFO) of the First-Play PGC. const VMGI_FP_PGC_PTR: usize = 0x84; @@ -192,10 +191,9 @@ impl Vm { let src_tainted = !immediate && self.reg_tainted(src); let cur = self.gprm[idx]; let cur_tainted = self.gprm_tainted[idx]; - // Each arm sets (new value, new taint). `mov` overwrites, so its taint is - // the source's alone — this is what CLEARS a prior taint on a concrete - // store. The accumulating ops fold the source into the current value, so - // their taint is the union. Unmodelled ops leave value and taint intact. + // Each arm sets (new value, new taint). `mov` overwrites with the source's + // taint alone (clearing prior taint); accumulating ops union current and + // source taint. Unmodelled ops leave value and taint intact. let (nv, nt) = match op { 1 => (v, src_tainted), // mov 3 => (cur.wrapping_add(v), cur_tainted || src_tainted), // add @@ -290,10 +288,9 @@ fn run_first_play(cmds: &[[u8; 8]]) -> Option<u8> { let cmd = vmcmd::decode(&cmds[pc]); - // A guarded command's predicate decides whether its instruction runs. - // `taken` is the predicate result; `tainted` marks it undecidable at scan - // time (it read a system parameter, directly or laundered through a - // GPRM). A command with no predicate always runs and is never tainted. + // A guarded command's predicate decides whether its instruction runs. `taken` + // is the predicate result; `tainted` marks it undecidable (read a system + // parameter, directly or via GPRM). No predicate = always runs, never tainted. let (taken, tainted) = match cmd.compare { Some(cmp) => vm.eval(&cmp), None => (true, false), @@ -332,11 +329,9 @@ fn run_first_play(cmds: &[[u8; 8]]) -> Option<u8> { continue; } - // Register preparation before a dispatch — a store, not a control - // transfer, so a tainted guard does NOT abstain. If the guard is - // undecidable we do not know whether the store ran, so the - // destination's post-state is unknown → taint it. Otherwise apply the - // store (which itself propagates or clears the destination's taint). + // Register prep before dispatch is a store, not a control transfer, so a + // tainted guard does NOT abstain. Undecidable guard -> unknown post-state + // -> taint the destination; otherwise apply the store normally. Instr::SetGprm { reg, op, @@ -356,11 +351,9 @@ fn run_first_play(cmds: &[[u8; 8]]) -> Option<u8> { // caught at read time, so no GPRM taint is needed here. Instr::Nop | Instr::SetSystem => {} - // Everything else leaves the deterministically-followable path: - // Break/Exit end the pre list with no title selected; the JumpSS - // sub-domain jumps and the intra-domain Link ops land in a menu or - // depend on an interactive button selection that cannot be resolved - // statically. Abstain → caller falls back. + // Everything else leaves the deterministically-followable path: Break/Exit + // end the pre list with no title; JumpSS/Link land in a menu or depend on + // an interactive button selection that can't be resolved statically. _ => return None, } diff --git a/src/dvdnav/vmcmd.rs b/src/dvdnav/vmcmd.rs index 24c41264..e7aa4b0b 100644 --- a/src/dvdnav/vmcmd.rs +++ b/src/dvdnav/vmcmd.rs @@ -159,15 +159,9 @@ fn be16(b: &[u8; 8], o: usize) -> u16 { ((b[o] as u16) << 8) | b[o + 1] as u16 } -// Compare-operand layouts ("if_version"s) per the DVD-Video VM. The op -// nibble is always `byte1` bits 6-4; the immediate flag is `byte1` bit 7. The -// operand *offsets* differ by command family. -// -// v1 (special + link): lhs reg = b[3]; rhs imm = bytes4-5 / rhs reg = b[5] -// (the register operand is the low byte of the reg-or-immediate field). -// v2 (jump + system-set): lhs reg = b[6]; rhs reg = b[7] (registers only). -// v3 (set-GPRM): lhs reg = b[2]; rhs imm = bytes6-7 / rhs reg = b[7] -// (the register operand is the low byte of the reg-or-immediate field). +// Compare-operand layouts ("if_version"s): op nibble = byte1 bits 6-4, imm +// flag = byte1 bit 7; offsets differ by family: v1(special/link) lhs=b[3], +// rhs=b[4:6]/b[5]; v2(jump/sys-set) lhs=b[6],rhs=b[7]; v3(set-GPRM) lhs=b[2],rhs=b[6:8]/b[7]. fn if_v1(b: &[u8; 8]) -> Option<Compare> { let op = (b[1] >> 4) & 7; (op != 0).then(|| Compare { diff --git a/src/error.rs b/src/error.rs index 14ca4aa8..ab12fc86 100644 --- a/src/error.rs +++ b/src/error.rs @@ -379,7 +379,7 @@ pub enum Error { /// fields): /// - [`Error::is_scsi_transport_failure`] — bail; bridge/transport wedge /// - [`Error::is_marginal_read`] — drive said this read was marginal; smaller block may recover - /// - [`Error::scsi_sense`] — borrow the sense triple for finer routing ([`ScsiSense::is_medium_error`] etc.) + /// - [`Error::scsi_sense`] — borrow the sense triple for finer routing ([`ScsiSense::is_medium_error`](crate::scsi::ScsiSense::is_medium_error) etc.) ScsiError { opcode: u8, status: u8, @@ -1201,16 +1201,9 @@ impl std::error::Error for Error { impl From<std::io::Error> for Error { fn from(e: std::io::Error) -> Self { - // If this `io::Error` is one WE produced (`From<Error> for io::Error` - // carries the typed value in its boxed payload), give the original - // back instead of burying it in `Error::IoError`. That wrapper is not - // neutral: `is_scsi_transport_failure` treats `IoError` as a - // transport-layer fault (dead bus / wedged bridge), so re-wrapping a - // round-tripped `DiscRead` MEDIUM ERROR turned a skippable bad sector - // into a pass-aborting bridge wedge — the exact inverse of what that - // arm exists for. Any error that crosses a thread boundary as an - // `io::Error` (the prefetch channel's `Batch`) keeps its - // classification, its SCSI status, and its sense data. + // If this `io::Error` is ours, unwrap to the original typed `Error` rather + // than re-wrapping as `IoError` (which `is_scsi_transport_failure` reads as + // a bridge wedge) — preserves classification, SCSI status, and sense data. match e.downcast::<Error>() { Ok(typed) => typed, // A genuine OS/`std` error — the `IoError` wrapper is correct here. @@ -1221,11 +1214,9 @@ impl From<std::io::Error> for Error { impl From<Error> for std::io::Error { fn from(e: Error) -> Self { - // An `Error::IoError` is just a wrapper around an underlying - // `io::Error` that entered via `From<io::Error> for Error`. - // Round-trip it back unchanged so the original `ErrorKind` and - // raw OS error code survive instead of being flattened to - // `Other` with a stringified message. + // `Error::IoError` just wraps an `io::Error` that entered via + // `From<io::Error> for Error` — round-trip it back unchanged so the + // original `ErrorKind`/OS code survive instead of flattening to `Other`. if let Error::IoError { source } = e { return source; } @@ -1312,12 +1303,9 @@ impl From<Error> for std::io::Error { // 9027 insufficient space / 9029 write failed: a filesystem-level // failure, not bad input. E_DIR_INSUFFICIENT_SPACE | E_DIR_WRITE_FAILED => std::io::ErrorKind::Other, - // dir:// SOURCE gates (9061–9064, 9066–9068): the folder handed in - // cannot be turned into a disc image — a 3D SSIF tree, an - // unsatisfiable VIDEO_TS placement, still-encrypted content, an - // unrecognized tree, one too large to address, a name too long to - // record, or a directory whose fan-out overflows its link count. - // All are properties of the input, decided before a byte is read. + // dir:// SOURCE gates (9061-9064, 9066-9068): the folder can't become a + // disc image (3D SSIF, bad VIDEO_TS placement, encrypted, unrecognized + // tree, too large, name too long, fan-out overflow) — input properties. E_DIR_IMAGE_SSIF_UNSUPPORTED | E_DIR_IMAGE_PLACEMENT | E_DIR_IMAGE_ENCRYPTED @@ -1330,12 +1318,9 @@ impl From<Error> for std::io::Error { E_DIR_IMAGE_FILE_CHANGED => std::io::ErrorKind::InvalidData, _ => std::io::ErrorKind::Other, }; - // Carry the typed value itself as the payload rather than its - // stringification. `Display` is unchanged (`io::Error` delegates to the - // boxed error, whose `Display` is the same `E<code>[: …]` string), so - // `error_code` and every consumer built on it are unaffected — but the - // typed error now SURVIVES the conversion and `From<io::Error> for - // Error` can hand it back intact. + // Carry the typed value itself as the payload, not its stringification. + // `Display` is unchanged (same `E<code>[: …]` string), so `error_code` is + // unaffected — but the typed error now SURVIVES and can round-trip back. std::io::Error::new(kind, e) } } @@ -1480,12 +1465,9 @@ impl Error { .. } ) || matches!( - // A failed `ioctl(SG_IO)` (Error::IoError, e.g. ENODEV/EIO on an - // unplugged USB bridge) and a vanished device (Error::DeviceNotFound, - // fd gone) are dead-bus / transport-layer faults too — NOT recoverable - // bad sectors. Treat them as transport failures so sweep / patch / - // fill_extents abort the pass and re-enumerate the bridge instead of - // zero-filling every read against a wedged device. + // A failed ioctl (IoError, e.g. ENODEV/EIO) or vanished device + // (DeviceNotFound) is a dead-bus fault too, not a bad sector — treat + // as transport failure so the pass aborts instead of zero-filling. self, Error::IoError { .. } | Error::DeviceNotFound { .. } ) @@ -1559,24 +1541,17 @@ mod tests { #[test] fn is_skippable_title_stub_excludes_malformed_mkv_input() { - // The two skippable per-title codes, round-tripped through io::Error - // exactly as `mux_stream` returns them. `MkvInvalid` now means ONLY the - // no-muxable-frames stub (the driver's headers-never-resolved gate and the - // MKV muxer's zero-frame `finish()` guard) — the meaning the doc on this - // predicate has always described. + // The two skippable per-title codes, round-tripped through io::Error as + // `mux_stream` returns them. `MkvInvalid` now means ONLY the no-muxable- + // frames stub, matching this predicate's documented meaning. let mkv: std::io::Error = Error::MkvInvalid.into(); let css: std::io::Error = Error::CssKeyMissing.into(); assert!(is_skippable_title_stub(&mkv)); assert!(is_skippable_title_stub(&css)); - // A malformed / truncated `mkv://` SOURCE is a FAILURE, not a stub. Every - // read-path rejection in `mux::mkvstream` and `mux::ebml`'s read - // primitives used to be raised as `MkvInvalid`, so a bad VINT, a cluster - // timestamp past i64::MAX or a BlockGroup child overrunning its group all - // landed in the skippable set above: an all-titles rip would pass silently - // over a corrupt input and exit reporting success. Reverting - // `Error::MkvSourceInvalid` back to `Error::MkvInvalid` at those raise - // sites turns this assertion red. + // A malformed/truncated mkv:// SOURCE is a FAILURE, not a stub. It used to + // be raised as `MkvInvalid`, so a corrupt source landed in the skippable + // set and an all-titles rip silently passed over it, exiting successfully. let corrupt: std::io::Error = Error::MkvSourceInvalid.into(); assert!( !is_skippable_title_stub(&corrupt), @@ -1599,11 +1574,9 @@ mod tests { let plain = std::io::Error::from(std::io::ErrorKind::BrokenPipe); assert!(!is_skippable_title_stub(&plain)); - // A header-buffer cap overflow is a REAL title whose codec init data - // never resolved — hundreds of MiB of frames, not an empty nav/menu - // stub. It used to be reported as `MkvInvalid`, which lands in the - // skippable set above, so an all-titles rip dropped a main feature and - // still exited successfully. It must have its own, non-skippable code. + // A header-buffer cap overflow is a REAL title whose codec init data never + // resolved, not an empty stub. Reported as `MkvInvalid` it would land in + // the skippable set, so an all-titles rip would drop a main feature silently. let cap: std::io::Error = Error::MuxHeaderBufferExceeded { bytes: 512 * 1024 * 1024 + 1, } @@ -1780,10 +1753,9 @@ mod tests { e, s ); - // Crude English filter — `Display` should never emit ASCII words - // longer than 4 chars (codes/paths/identifiers like `/dev/sg4`, - // `renesas`, `freebsd` all pass; "exclusive access denied" would - // not). + // Crude English filter — `Display` should never emit ASCII words longer + // than 4 chars (identifiers like `/dev/sg4`, `renesas` pass; + // "exclusive access denied" would not). for word in s.split(|c: char| !c.is_ascii_alphabetic()) { assert!( word.len() <= 8, diff --git a/src/harness.rs b/src/harness.rs index c3adf4a7..6cf20983 100644 --- a/src/harness.rs +++ b/src/harness.rs @@ -136,11 +136,9 @@ fn sweep_n<F: FnMut(&[u8])>(target: &str, magic: &[u8], n: usize, mut f: F) { run(target, "magic+noise", s, i, &buf, &mut f); } - // 3. Structured mutation of a plausible record: a valid magic, then mostly - // zeroes, with a handful of bytes corrupted and a truncation. Length and - // offset fields live in those early bytes, so this is what reaches the - // arithmetic — the offsets, counts and sizes a hostile image would lie - // about. + // 3. Structured mutation: valid magic, mostly zeroes, a handful of bytes + // corrupted plus truncation. Length/offset fields live in those early + // bytes, so this reaches the arithmetic a hostile image would lie about. let mut rng = Rng::new(s ^ 0x1234_5678_9ABC_DEF0); for i in 0..n { let mut buf = vec![0u8; 512]; @@ -196,10 +194,9 @@ fn bdnav_mobj_parse_never_panics() { #[test] fn dvd_nav_resolve_never_panics() { - // The DVD First-Play navigation resolver parses VMGI (`VIDEO_TS.IFO`) — - // FP_PGC command table + TT_SRPT, all attacker-controlled offsets/counts — - // and executes an untrusted command list on a bounded VM. Magic is the VMGI - // signature so the sweep reaches past the header guard into the executor. + // The DVD First-Play resolver parses VMGI (FP_PGC + TT_SRPT, all + // attacker-controlled) and executes an untrusted command list on a bounded + // VM. Magic is the VMGI signature, so the sweep reaches the executor. sweep("dvd_nav", b"DVDVIDEO-VMG", |b| { let _ = crate::dvdnav::nav::resolve_from_vmg(b); }); @@ -216,11 +213,9 @@ fn udf_name_parse_never_panics() { #[test] fn class_reader_parse_never_panics() { - // Java `.class` files are a documented format we READ (never execute) to - // recover BD-J stream labels — a peer of mpls/clpi here. Every count, - // length and constant-pool index in the file is attacker-controlled disc - // metadata, so the parser must reject malformed input with `Err`, never - // panic. Magic is the class-file magic `0xCAFEBABE`. + // Java `.class` files are READ (never executed) to recover BD-J stream + // labels. Every count/length/constant-pool index is attacker-controlled + // disc metadata, so the parser must reject malformed input, never panic. sweep("class_reader", &[0xCA, 0xFE, 0xBA, 0xBE], |b| { if let Ok(class) = crate::labels::class_reader::ClassFile::parse(b) { // Exercise the accessors a label parser drives, so their indexing @@ -246,11 +241,9 @@ fn class_reader_parse_never_panics() { #[test] fn jar_class_walk_never_panics() { - // The jar layer opens a `/BDMV/JAR/<x>.jar` (a zip) and walks every - // `.class` inside it, handing each to the class reader. Feed the sweep's - // bytes as a zip archive; when they parse as one, walk the classes exactly - // as `deluxe`/`dbp` do. Neither the zip open nor the class walk may panic. - // Magic is the zip local-file-header signature `PK\x03\x04`. + // The jar layer opens a `/BDMV/JAR/<x>.jar` zip and walks every `.class` + // inside, handing each to the class reader (as `deluxe`/`dbp` do). Neither + // the zip open nor the class walk may panic. Magic: `PK\x03\x04`. sweep("jar", &[0x50, 0x4B, 0x03, 0x04], |b| { if let Ok(mut archive) = zip::ZipArchive::new(std::io::Cursor::new(b.to_vec())) { crate::labels::jar::for_each_class(&mut archive, |_name, _class| {}); @@ -260,14 +253,9 @@ fn jar_class_walk_never_panics() { #[test] fn deluxe_bytecode_decode_never_panics() { - // The Deluxe BD-J label parser runs a symbolic stack machine over `<clinit>` - // bytecode (`decode_binding_class`). Fuzzing it THROUGH a zip is vacuous — - // `zip::ZipArchive::new` validates the end-of-central-directory record at the - // buffer's end, which the generators (front magic + noise) cannot synthesize, - // so the archive open always fails and the decoder is never reached. Drive - // the decoder DIRECTLY instead: parse the sweep bytes as a `.class` (they also - // exercise the class parser) and run the bytecode decoder over it with an - // empty master-enum table. None may panic on crafted/corrupt bytecode. + // Deluxe's BD-J parser runs a symbolic stack machine over `<clinit>` + // bytecode. Fuzzing THROUGH a zip is vacuous (generators can't fake a valid + // end-of-central-directory), so drive the decoder DIRECTLY on a `.class`. let master = crate::labels::deluxe::MasterEnumTable::from(&[] as &[( &'static str, crate::labels::deluxe::MasterEnum, @@ -292,10 +280,9 @@ fn fox_dcx_labels_never_panics() { #[test] fn bdnav_vm_resolve_never_panics() { - // The HDMV navigation VM executes an untrusted `MovieObject.bdmv` command - // list on a bounded VM. Parse the sweep bytes as a MovieObject set and run - // the VM from a First-Play entry — the combined parse+execute path a real - // disc drives, which the per-parser index/mobj sweeps never reach. + // The HDMV nav VM executes an untrusted MovieObject.bdmv command list. + // Parse sweep bytes as a MovieObject set and run the VM from First-Play — + // the combined parse+execute path the per-parser index/mobj sweeps miss. use crate::bdnav::index::{Index, PlaybackObj}; sweep("bdnav_vm", b"MOBJ", |b| { if let Some(mobjs) = crate::bdnav::mobj::parse(b) { @@ -311,10 +298,9 @@ fn bdnav_vm_resolve_never_panics() { #[test] fn ps_demuxer_feed_never_panics() { - // Stateful, unlike the others: the demuxer carries a buffer across feeds, so - // each case is fed to a FRESH demuxer and then a shared one. The shared pass - // is what exercises cross-feed state — a start code split over a boundary, - // a held PES completed by later bytes, the carry-over cap. + // Stateful, unlike the others: the demuxer carries a buffer across feeds. + // Each case is fed to a FRESH demuxer, then a shared one — the shared pass + // exercises cross-feed state: split start codes, held PES, the carry cap. let mut shared = crate::mux::ps::PsDemuxer::new(); sweep("ps_demux", &[0x00, 0x00, 0x01, 0xBA], |b| { let mut fresh = crate::mux::ps::PsDemuxer::new(); diff --git a/src/identity.rs b/src/identity.rs index 54b245cc..4a499618 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -17,19 +17,19 @@ use crate::scsi::{DataDirection, ScsiTransport}; /// - MMC-6 §5.3.10 for Firmware Information #[derive(Debug, Clone)] pub struct DriveId { - /// T10 VENDOR IDENTIFICATION — INQUIRY bytes [8:16] + /// T10 VENDOR IDENTIFICATION — INQUIRY bytes `[8:16]` /// SPC-4 §6.4.2 pub vendor_id: String, - /// PRODUCT IDENTIFICATION — INQUIRY bytes [16:32] + /// PRODUCT IDENTIFICATION — INQUIRY bytes `[16:32]` /// SPC-4 §6.4.2 pub product_id: String, - /// PRODUCT REVISION LEVEL — INQUIRY bytes [32:36] + /// PRODUCT REVISION LEVEL — INQUIRY bytes `[32:36]` /// SPC-4 §6.4.2 pub product_revision: String, - /// VENDOR SPECIFIC — INQUIRY bytes [36:43] + /// VENDOR SPECIFIC — INQUIRY bytes `[36:43]` /// SPC-4 §6.4.2 /// Content varies by vendor: firmware type code (MTK), date (Pioneer), etc. pub vendor_specific: String, @@ -60,26 +60,18 @@ impl DriveId { let mut inquiry = vec![0u8; 96]; let cdb_inq = [0x12, 0x00, 0x00, 0x00, 0x60, 0x00]; let inq = transport.execute(&cdb_inq, DataDirection::FromDevice, &mut inquiry, 5000)?; - // `bytes_transferred` is device-reported and untrusted — the same rule - // the two GET CONFIGURATION calls below already apply. It was ignored - // here, and the buffer is pre-zeroed, so a drive answering GOOD with a - // short or empty data phase (a USB-SATA bridge mid-wedge does exactly - // this) decoded to blank identity strings and a byte 0 of 0x00. Every - // platform enumerator gates on `raw_inquiry[0] & 0x1F == OPTICAL`, so - // 0x00 reads as DIRECT ACCESS and the drive silently disappears from - // the device list instead of reporting a failed probe. + // `bytes_transferred` is device-reported and untrusted. Unchecked, a + // GOOD status with a short/empty data phase decoded to blank identity + + // byte0 0x00 — which reads as DIRECT ACCESS, so the drive vanishes silently. if inq.bytes_transferred < INQUIRY_STANDARD_LEN { return Err(crate::error::Error::DriveInquiryShort); } // Never decode past what the drive actually sent. inquiry.truncate(inq.bytes_transferred.min(inquiry.len())); - // GET CONFIGURATION Feature 010Ch — MMC-6 §6.6. - // Best-effort: 010Ch (Firmware Information) is an optional feature. - // A drive that lacks it may CHECK CONDITION rather than return an - // empty descriptor, so a failure here is treated as feature-absent - // (empty firmware date + empty raw bytes) instead of aborting the - // whole identity probe. + // GET CONFIGURATION Feature 010Ch — MMC-6 §6.6. Best-effort: an optional + // feature a drive may lack (CHECK CONDITION), so failure here is + // feature-absent (empty firmware date + raw bytes), not a probe abort. let mut gc = vec![0u8; 256]; let cdb_gc = [0x46, 0x02, 0x01, 0x0C, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00]; // `bytes_transferred` is device-reported and untrusted; clamp every @@ -100,11 +92,9 @@ impl DriveId { Err(_) => (String::new(), Vec::new()), }; - // GET CONFIGURATION Feature 0108h — Serial Number. - // Best-effort, like 010Ch above: the serial-number feature is - // optional, so a drive that lacks it (CHECK CONDITION) or reports - // too few bytes deliberately yields an empty serial rather than - // failing the identity probe. + // GET CONFIGURATION Feature 0108h — Serial Number. Best-effort like + // 010Ch: optional feature, so lacking it (CHECK CONDITION) or too few + // bytes deliberately yields an empty serial rather than failing. let mut gc_serial = vec![0u8; 256]; let cdb_serial = [0x46, 0x02, 0x01, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00]; let serial_number = if let Ok(r) = diff --git a/src/ifo.rs b/src/ifo.rs index 29af87b6..8ce13d91 100644 --- a/src/ifo.rs +++ b/src/ifo.rs @@ -173,7 +173,7 @@ impl DvdTitle { if idx >= n { 0 } else { idx } } - /// The feature cells after the leading-cell filter ([`feature_start_cell`]). + /// The feature cells after the leading-cell filter ([`Self::feature_start_cell`]). pub fn feature_cells(&self) -> &[DvdCell] { &self.cells[self.feature_start_cell()..] } @@ -425,21 +425,14 @@ pub fn parse_vmg(reader: &mut dyn SectorSource, udf: &UdfFs) -> Result<DvdInfo> for (&vts_number, titles_info) in &title_set_map { match parse_vts(reader, udf, vts_number, titles_info) { Ok(ts) => title_sets.push(ts), - // The operator's Stop is not a placeholder title set. Once the - // drive's halt flag is set EVERY command fails with `Halted`, so - // swallowing it here would skip every REMAINING title set in turn - // and return `Ok` with a truncated list — a cancelled scan that - // is indistinguishable from a disc genuinely holding fewer - // titles. `scan_dvd_titles` propagates it to the caller. + // Halted means the drive itself stopped, not that this title set is a + // placeholder — once set, every later parse_vts call also fails, so + // swallowing it here would silently truncate the scan. Propagate it. Err(Error::Halted) => return Err(Error::Halted), Err(e) => { - // Some discs carry placeholder TT_SRPT entries for title sets - // that are not really there, so one failure is not fatal. But - // the failure must not be INVISIBLE: every skipped set is a - // title the user will never see, and swallowing the reason made - // a disc that enumerated 38 titles from one image and 10 from - // another look like a scan difference rather than 28 dropped - // reads. + // Some discs carry placeholder TT_SRPT entries for title sets that + // aren't really there, so one failure isn't fatal — but it must not + // be silent, since swallowing it can hide many dropped titles. skipped += 1; tracing::warn!( target: "freemkv::scan", @@ -563,25 +556,9 @@ fn parse_vts( // VTS_PGCIT sector pointer let pgcit_sector = be_u32(&vts_data, VTS_PGCIT_OFFSET)?; - // First sector of the VTS **Title** VOBS (`vtstt_vobs`, VTSTT_VOBS_OFFSET). - // The cell `first_sector` / `last_sector` values in the title PGCs are - // relative to this. Offset 0xC0 is `vtsm_vobs` — the VTS *menu* VOBS - // (VTS_xx_0.VOB), which on discs with a per-title menu (e.g. a Universal - // "the parental level has been set, press yes" first-play still) holds that - // interactive prompt. Reading the menu base instead prepended the menu VOB - // to the feature and shifted every cell extent back by - // `vtstt_vobs - vtsm_vobs` sectors, so the rip opened on the parental - // prompt instead of the movie. The title content lives at `vtstt_vobs`. - // - // `vtstt_vobs` is a sector address **relative to the start of this VTS_xx_0.IFO - // file**, not an absolute disc LBA. The cell `first_sector`/`last_sector` - // values are in turn relative to `vtstt_vobs`. To turn them into the absolute - // disc LBAs the reader needs, add the IFO file's own on-disc location (from - // the UDF FS). Without this rebase every extent started `ifo_lba` sectors too - // early — on one real disc the feature began at LBA 126 (the VMGI / - // VIDEO_TS.VOB main-menu region) instead of 132886 (VTS_03_1.VOB), so the - // first ~4.5 min of muxed video was the disc's main menu before the stream - // drifted into the movie. + // First sector of the VTS **Title** VOBS (`vtstt_vobs`), which PGC cell extents + // are relative to (offset 0xC0/`vtsm_vobs` is the *menu* VOBS, not the movie). + // It's relative to this IFO file, so rebase by the IFO's on-disc LBA (UDF FS). let vtstt_vobs = be_u32(&vts_data, VTSTT_VOBS_OFFSET)?; let ifo_lba = udf.file_start_lba(reader, &path)?; let vob_start_sector = ifo_lba.saturating_add(vtstt_vobs); @@ -600,11 +577,9 @@ fn parse_vts( } audio_streams.push(parse_audio_attr(&vts_data, aoff)?); } - // Assign each audio stream its on-wire private_stream_1 sub-stream id - // by per-codec ordinal — the same convention DVD authoring uses (AC-3 - // 0x80+, DTS 0x88+, LPCM 0xA0+). This is the routing key shared with - // the muxer; per-codec ordinals (not the positional index) are what - // keep mixed-codec titles from colliding. + // Assign each stream its on-wire private_stream_1 id by per-codec ordinal + // (AC-3 0x80+, DTS 0x88+, LPCM 0xA0+), the DVD authoring convention the + // muxer routes on — the positional index would collide in mixed-codec titles. assign_audio_sub_stream_ids(&mut audio_streams); // Subtitle streams: count at 0x254 (u16 BE), then 6 bytes each starting at 0x256 @@ -641,12 +616,9 @@ fn parse_vts( // ── Attribute parsers ─────────────────────────────────────────────────────── -// ── VTS_V_ATR byte 0 bitfield layout (DVD-Video spec, MSB first) ────────── -// bits 7-6 mpeg_version | bits 5-4 video_format | bits 3-2 display_aspect -// | bits 1-0 permitted_df -// Naming the positions is the guard against the original bug: video_format is -// bits 5-4, NOT bits 1-0 (those are the pan&scan/letterbox permission). Reading -// the low two bits mis-detected every PAL disc as NTSC → 720x480 not 720x576. +// VTS_V_ATR byte 0 (MSB first): bits 7-6 mpeg_version | bits 5-4 video_format | +// bits 3-2 display_aspect | bits 1-0 permitted_df. video_format is bits 5-4, NOT +// bits 1-0 (pan&scan/letterbox permission) — the earlier bug misread PAL as NTSC. const V_ATR_VIDEO_FORMAT_SHIFT: u8 = 4; const V_ATR_ASPECT_SHIFT: u8 = 2; const V_ATR_FIELD_MASK: u8 = 0x03; @@ -712,11 +684,9 @@ pub(crate) fn parse_audio_attr(data: &[u8], offset: usize) -> Result<DvdAudioAtt let b1 = byte_at(data, offset + 1)?; let coding_mode = (b0 >> 5) & 0x07; - // DVD-Video audio_coding_mode. Modes 2 and 3 are both MPEG audio Layer II - // (3 adds the MPEG-2 multichannel extension), so both are Codec::Mp2. - // Mode 2 previously mapped to Codec::Mpeg1 — a VIDEO variant, so - // Codec::kind() reported Video and the audio stream was classified and - // handled as video everywhere downstream. + // Modes 2 and 3 are both MPEG audio Layer II (3 adds the multichannel + // extension), so both map to Codec::Mp2. Mode 2 previously mapped to the + // video codec Codec::Mpeg1, so the audio stream was classified as video. let codec = match coding_mode { 0 => Codec::Ac3, 2 | 3 => Codec::Mp2, @@ -868,10 +838,9 @@ fn parse_pgcit( let entries_start = pgcit_offset + 8; let mut titles = Vec::new(); - // Every `continue` below drops a title the user will never see. None of - // them may be silent: `parse_vmg` already counts and warns per skipped - // title SET, and this function was the remaining place where a disc could - // quietly report fewer titles than it has. + // Every `continue` below drops a title the user will never see and must not + // be silent: `parse_vmg` warns per skipped title SET, but this was the + // remaining place a disc could quietly report fewer titles than it has. let mut skipped = 0usize; for &(chapter_count, vts_title_num) in titles_info { @@ -910,14 +879,9 @@ fn parse_pgcit( match parse_pgc(data, pgc_abs, chapter_count) { Ok(title) => titles.push(title), Err(e) => { - // By design a single unparseable PGC (truncated/corrupt entry, - // authoring-tool quirk) must not lose the whole title list. But - // "not fatal" is not the same as "not worth saying": every PGC - // skipped here is a title the user will never see, and this was - // the only remaining silent one — `parse_vmg` above already - // counts and warns per skipped title SET for exactly this - // reason. A disc quietly reporting fewer titles than it has is - // the failure this release exists to stop. + // A single unparseable PGC (truncated/corrupt entry, authoring quirk) + // must not lose the whole title list, but must not be silent either: + // a disc quietly reporting fewer titles than it has is what this warns. skipped += 1; tracing::warn!( target: "freemkv::scan", @@ -951,11 +915,8 @@ fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result<DvdTitle> return Err(Error::IfoParse); } - // PGC layout: - // 0x00-0x01: misc flags - // 0x02: nr_of_programs - // 0x03: nr_of_cells - // 0x04-0x07: playback_time (4 BCD bytes) + // PGC layout: 0x00-0x01 misc flags, 0x02 nr_of_programs, + // 0x03 nr_of_cells, 0x04-0x07 playback_time (4 BCD bytes). let num_cells = byte_at(data, pgc_offset + 0x03)? as usize; let time_bytes = sub_slice(data, pgc_offset + 0x04, 4)?; let duration_secs = bcd_to_secs(time_bytes); @@ -1011,14 +972,9 @@ fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result<DvdTitle> let mut times = Vec::new(); if pgm_map_offset > 0 && nr_of_programs > 0 && cell_playback_offset > 0 { let pgm_base = pgc_offset + pgm_map_offset; - // Collect per-cell durations as exact timecode FRAME counts. Summing - // frames (integers) and converting once per chapter keeps every mark - // on an exact frame boundary — summing f64 seconds would let rounding - // drift accumulate across a two-hour title. - // - // A cell whose rate flag is unspecified has no frame count, so it - // contributes plain seconds instead; that is tracked separately and - // added on top. In practice a PGC never mixes rates. + // Collect durations as exact FRAME counts, converting once per chapter so + // marks stay on frame boundaries (summing f64 seconds would drift). Cells + // with no rate flag fall back to plain seconds, tracked separately. let mut cell_frames: Vec<(u64, f64)> = Vec::with_capacity(num_cells); let mut rate: Option<DvdRate> = None; let cell_base = pgc_offset + cell_playback_offset; @@ -1108,29 +1064,18 @@ mod tests { #[test] fn tt_srpt_title_count_is_capped_and_deduplicated() { - // The TT_SRPT count is an untrusted u16. A ~800 KB crafted IFO declares - // 65535 entries, and each one re-parses a PGC into another full DvdTitle. - // - // The entries are DISTINCT, which matters: with 65535 identical entries - // the de-duplication collapses them to one title on its own and the count - // cap is never what bounds the result — the test would then pass with the - // cap removed entirely, proving only that dedup works. Distinct entries - // defeat dedup, so the cap is the only thing left holding the line. - // De-duplication gets its own assertion below, on its own fixture. - // Both title fields are u8, so vary each within its own range to get - // ~65025 distinct pairs out of the 65535 entries. + // The TT_SRPT count is an untrusted u16. Entries must be DISTINCT: identical + // entries would let dedup alone collapse them to one title, so the cap would + // never be exercised — vary both u8 fields for ~65025 distinct pairs. let entries: Vec<(u16, u8, u8)> = (0..u16::MAX) .map(|i| (5u16, (i % 255) as u8 + 1, ((i / 255) % 255) as u8 + 1)) .collect(); let data = tt_srpt_bytes(u16::MAX, &entries); let map = parse_tt_srpt(&data, 0).expect("well-formed TT_SRPT"); let total: usize = map.values().map(|v| v.len()).sum(); - // Asserted against the DVD-Video format maximum as a LITERAL, not against - // MAX_TT_SRPT_TITLES. Comparing a result to the very constant under test - // passes vacuously the moment someone raises that constant, which is the - // most likely regression here — and is the tautology class this audit has - // now found six times. 99 is the format's own ceiling, so this pins the - // cap to the spec rather than to whatever the code currently says. + // Asserted against the DVD-Video max as a LITERAL, not MAX_TT_SRPT_TITLES: + // comparing to the constant under test would pass vacuously if that constant + // were raised. 99 is the format's own ceiling, pinning the cap to the spec. assert!( total <= 99, "TT_SRPT produced {total} titles from a crafted 65535-entry table; \ @@ -1178,10 +1123,8 @@ mod tests { // 0 hours, 1 minute, 30 seconds, 15 frames of NTSC timecode. let bcd = [0x00, 0x01, 0x30, 0b11_010101]; let secs = bcd_to_secs(&bcd); - // 0b010101 = 0x15, BCD = 15 frames. - // Timecode 00:01:30:15 is 90*30 + 15 = 2715 frames, and each frame - // lasts 1001/30000 s, so real time is 2715*1001/30000 = 90.5905 s - // (NOT 90.5 s — NTSC timecode runs 0.1% slow against the clock). + // 0b010101 = 0x15 BCD = 15 frames. Timecode 00:01:30:15 = 2715 frames at + // 1001/30000 s each = 90.5905 s (NOT 90.5 s — NTSC runs 0.1% slow). let expected = 2715.0 * 1001.0 / 30000.0; assert!((secs - expected).abs() < 1e-9, "got {}", secs); assert!((secs - 90.5905).abs() < 1e-9, "got {}", secs); @@ -1438,15 +1381,9 @@ mod tests { /// bit positions drift, these fail. #[test] fn video_attr_absolute_bytes_pin_real_layout() { - // (byte @0x200, expected standard, expected aspect, expected resolution). - // PAL 16:9 anamorphic = mpeg(00) format(01=PAL) aspect(11=16:9) df(00) - // = 0b0001_1100 = 0x1C (e.g. a PAL 16:9 R2 feature disc). - // PAL 4:3 = 0b0001_0000 = 0x10. - // NTSC 16:9 = 0b0000_1100 = 0x0C. - // NTSC 4:3 = 0b0000_0000 = 0x00. - // A real disc also sets mpeg_version=01 (MPEG-2) in bits 7-6, which the - // parser must IGNORE; OR it in (|0x40) to prove it doesn't leak into the - // video_format read. + // (byte @0x200, expected standard, expected aspect, expected resolution): + // PAL 16:9=0x1C, PAL 4:3=0x10, NTSC 16:9=0x0C, NTSC 4:3=0x00. A real disc + // also sets mpeg_version=01 in bits 7-6 (|0x40), which the parser must ignore. let cases: &[(u8, TvSystem, DvdAspect, Resolution)] = &[ (0x1C, TvSystem::Pal, DvdAspect::R16x9, Resolution::R576i), (0x10, TvSystem::Pal, DvdAspect::R4x3, Resolution::R576i), @@ -1463,10 +1400,9 @@ mod tests { assert_eq!(attr.aspect, aspect, "byte {b0:#04x} → aspect"); assert_eq!(attr.resolution, res, "byte {b0:#04x} → resolution"); } - // Anti-bug anchor: the original bug read the TV system from bits 1-0 - // (permitted_df). A PAL byte whose low 2 bits are 0 (0x1C) must NOT be - // misread as NTSC — and a byte with low bits set but format=NTSC - // (0x03 = NTSC, df=11) must stay NTSC, proving the low bits are ignored. + // Anti-bug anchor: the original bug read TV system from bits 1-0 + // (permitted_df). A byte with low bits set but format=NTSC (0x03, df=11) + // must stay NTSC here, proving the low bits are ignored. let mut df = vec![0u8; 0x204]; df[0x200] = 0x03; // format=NTSC(00), df=11 assert_eq!( @@ -1497,10 +1433,9 @@ mod tests { #[test] fn mixed_codec_sub_stream_ids_are_distinct() { - // A title mixing AC-3, DTS and LPCM: the sub-id low nibble is the - // POSITIONAL audio-stream number (shared across codecs), OR'd with the - // codec base. So idx 1 (DTS) → 0x89, idx 3 (AC-3) → 0x83 — the real - // wire ids the demux routes on. All distinct (positions are unique). + // A title mixing AC-3, DTS and LPCM: sub-id low nibble is the positional + // audio-stream number OR'd with the codec base (idx 1 DTS → 0x89, idx 3 + // AC-3 → 0x83) — the real wire ids the demux routes on, all distinct. let mut streams = vec![ DvdAudioAttr { codec: Codec::Ac3, @@ -1625,10 +1560,8 @@ mod tests { assert_eq!(attr.language, "fra"); } - // ───────────────────────────────────────────────────────────────────── - // Added hardening tests. Grounded in the DVD-Video IFO spec - // (DVD-Video IFO format; http://dvd.sourceforge.net). - // ───────────────────────────────────────────────────────────────────── + // Added hardening tests, grounded in the DVD-Video IFO spec + // (http://dvd.sourceforge.net). /// BCD frame-rate flag: bits 7-6 of byte[3]. 0b01 = 25fps (PAL), /// 0b11 = 29.97fps (NTSC). 0b00/0b10 are "unknown" → frames ignored. @@ -2259,10 +2192,8 @@ mod tests { ); } - // ───────────────────────────────────────────────────────────────────── // Added: cell-category bit isolation, palette/program-map arithmetic, // and the malformed-language salvage paths. - // ───────────────────────────────────────────────────────────────────── /// Each low flag of the cell-category byte comes from its OWN bit and no /// other: bit3 seamless_play, bit2 interleaved, bit1 stc_discontinuity, @@ -2572,10 +2503,9 @@ mod tests { /// `[padding, Y, Cr, Cb]`. Every byte of every entry is distinct here, so /// a wrong stride, a wrong base or a shifted component shows up. #[test] - // The loop variable is the DOMAIN VALUE being checked (a palette entry number), not a - // cursor into a collection: it is what the assertion message names and - // what the table is keyed by. `.iter().enumerate()` would rename the - // thing under test to `i` and read worse. + // The loop variable is the DOMAIN VALUE under test (a palette entry number), + // not a collection cursor — it's what the assertion message names, so + // `.iter().enumerate()` would rename the thing under test and read worse. #[allow(clippy::needless_range_loop)] fn pgc_palette_entries_read_at_correct_stride() { let mut pal = [[0u8; 4]; 16]; @@ -2714,16 +2644,8 @@ mod tests { } // ── TT_SRPT: the title table (`parse_tt_srpt` / `parse_vmg`) ──────────── - // - // This is the table that says which titles a DVD has, and it had no tests - // at all until 1.6.2 — while being at the centre of both DVD stories in - // 1.6.1. The CSS descramble bug destroyed the sector carrying it, so a disc - // enumerating 38 titles produced an image enumerating 10, silently, at exit - // 0. And `dir://` was pulled from the release on 2026-08-05 because a - // folder produced the wrong title list — the same table, reached through a - // synthesized image. - // - // Both were found on real discs. Nothing hermetic covered the table itself. + // This table says which titles a DVD has and had no tests until 1.6.2, despite + // two real-disc 1.6.1 bugs that silently mis-enumerated titles through it. /// Build a VMG with a TT_SRPT at `tt_srpt_sector` declaring `entries` /// of `(num_chapters, vts_number, vts_title_num)`. @@ -2767,11 +2689,9 @@ mod tests { let at_sector = parse_tt_srpt(&vmg, sector as usize * crate::consts::SECTOR_BYTES) .expect("reading at the sector offset works"); assert_eq!(at_sector[&1], vec![(3, 1)], "sector {sector}"); - // Reading the same pointer as a BYTE offset lands 1/2048th of the - // way in — inside the header here — and yields a different table. - // It does not error: it silently reports the wrong titles, which is - // how 28 dropped titles looked like a scan difference rather than a - // failure. + // Reading the same pointer as a BYTE offset lands 1/2048th of the way + // in and yields a different table without erroring — silently wrong + // titles, which is how dropped titles looked like a scan difference. let as_bytes = parse_tt_srpt(&vmg, sector as usize).expect("does not error"); assert_ne!( as_bytes, at_sector, @@ -2819,11 +2739,9 @@ mod tests { /// unclamped 65535 is a ~540 MB allocation from a ~800 KB crafted file. #[test] fn tt_srpt_clamps_an_absurd_declared_title_count() { - // The fixture must actually CONTAIN more entries than the cap, or the - // walk stops when the buffer runs out and the clamp is never what - // bounded it. An earlier version of this test declared u16::MAX over a - // two-kilobyte fixture: the loop broke on `base + 12 > len` after three - // iterations and the assertion held with the clamp deleted entirely. + // The fixture must actually CONTAIN more entries than the cap, or the walk + // stops when the buffer runs out and the clamp is never exercised — an + // earlier version declared u16::MAX over a 2KB fixture and passed vacuously. const PRESENT: usize = MAX_TT_SRPT_TITLES + 40; let entries: Vec<(u16, u8, u8)> = (0..PRESENT) .map(|i| (1u16, 1u8, (i % 250 + 1) as u8)) diff --git a/src/io/bounded.rs b/src/io/bounded.rs index e6a804ab..1ed52c3b 100644 --- a/src/io/bounded.rs +++ b/src/io/bounded.rs @@ -109,19 +109,14 @@ where return Err(BoundedError::Halted); } - // Rendezvous channel: the worker sends exactly one value (the - // op's return) and then exits. Capacity-0 means the send blocks - // until we receive — fine on the happy path; on the timeout / - // halt path the receiver is dropped and the worker's send - // returns Err, which the worker ignores. + // Rendezvous channel: worker sends one value then exits. On timeout/halt + // the receiver is dropped, so the worker's send returns Err (ignored). let (tx, rx) = sync_channel::<R>(0); let _ = thread::Builder::new() .name("freemkv-bounded-syscall".into()) .spawn(move || { - // Ignore the send error: if we time out (or get halted) - // before the worker finishes, the receiver is dropped - // and `tx.send` returns Err. Either way, the worker has - // nothing more to do. + // Ignore send error: if we time out/halt before the worker finishes, + // the receiver is dropped and `tx.send` returns Err — nothing more to do. let _ = tx.send(op()); }); @@ -179,10 +174,8 @@ mod tests { fn halt_fires_during_wait() { let halt = Halt::new(); let halt2 = halt.clone(); - // Flip the halt from a side thread after ~300 ms — long - // enough that the receive loop has rolled at least one - // 250 ms slice and is sitting in `recv_timeout` again when - // the bit flips. + // Flip halt after ~300ms, long enough that the receive loop has rolled + // at least one 250ms slice and is back in `recv_timeout`. thread::spawn(move || { thread::sleep(Duration::from_millis(300)); halt2.cancel(); @@ -196,11 +189,9 @@ mod tests { #[test] fn worker_panics() { - // Worker panics → sender drops without sending → recv sees - // Disconnected → WorkerLost. We use an explicit panic in the - // op closure rather than `panic!()` from inside the channel - // machinery; the spawned thread's panic is contained (no - // process abort) because we don't `.join()` it. + // Worker panics → sender drops without sending → recv sees Disconnected → + // WorkerLost. Panic is in the op closure, not the channel machinery; it's + // contained (no process abort) because we don't `.join()` the thread. let r = bounded_syscall(None, Duration::from_secs(2), || -> u32 { panic!("intentional test panic"); }); diff --git a/src/io/byte_prefetcher.rs b/src/io/byte_prefetcher.rs index af0847ca..e16026f5 100644 --- a/src/io/byte_prefetcher.rs +++ b/src/io/byte_prefetcher.rs @@ -75,18 +75,15 @@ impl BytePrefetcher { chunk_bytes: usize, halt: Option<Halt>, ) -> std::io::Result<Self> { - // A zero-length chunk makes every recycled buffer an empty - // slice; `reader.read(&mut [])` returns Ok(0), which the loop - // below treats as EOF — the consumer would see a clean, - // silent zero-byte stream. Callers pass the downstream - // demuxer's batch size, which is always > 0. + // A zero-length chunk makes every recycled buffer empty; `reader.read(&mut [])` + // returns Ok(0), which the loop below treats as EOF, producing a silent + // zero-byte stream. Callers must pass the downstream demuxer's batch size (> 0). debug_assert!(chunk_bytes > 0, "BytePrefetcher chunk_bytes must be > 0"); let (tx, rx) = bounded::<Batch>(FORWARD_DEPTH); let (recycle_tx, recycle_rx) = bounded::<Vec<u8>>(RECYCLE_DEPTH); - // Seed the recycle pool. Without these the first - // `recycle_rx.recv()` would block forever (no consumer has - // returned a buffer yet). + // Seed the recycle pool; otherwise the first `recycle_rx.recv()` blocks + // forever since no consumer has returned a buffer yet. for _ in 0..RECYCLE_DEPTH { let _ = recycle_tx.send(vec![0u8; chunk_bytes]); } @@ -94,21 +91,13 @@ impl BytePrefetcher { let producer = std::thread::Builder::new() .name("freemkv-byte-prefetch".into()) .spawn(move || { - // Wrap the feed loop in catch_unwind so a panic in the inner - // `reader.read` (e.g. a decrypt-on-read slice/arith bug) is NOT - // indistinguishable from a clean finish at the demux boundary. A - // clean exit (EOF, halt, consumer disconnect) returns and drops - // `tx` → the demux loop reads RecvError as EOF (correct). A PANIC - // sends an explicit error sentinel first so the demux loop's - // `Ok(Err(_))` arm fires and propagates a typed error instead of - // converting the dropped channel into a clean `DemuxBatch::Eof` - // that would finalize a TRUNCATED mux while reporting success. + // catch_unwind: a clean exit drops `tx` (demux reads RecvError as EOF), + // but a panic sends an error sentinel first so demux gets a typed error + // instead of finalizing a truncated mux as success. let body = std::panic::AssertUnwindSafe(|| { let cancelled = || halt.as_ref().map(|h| h.is_cancelled()).unwrap_or(false); - // Liveness heartbeat: the producer blocks on the recycle and - // forward channels; a stalled consumer or a wedged reader shows - // up as the beat going silent. Total is unknown, so `pos` is - // cumulative bytes read. + // Liveness heartbeat: a stalled consumer or wedged reader shows up + // as the beat going silent. Total is unknown, so `pos` is cumulative. let mut hb = crate::progress::Heartbeat::new("byte_prefetch"); let mut produced_bytes: u64 = 0; loop { @@ -116,10 +105,9 @@ impl BytePrefetcher { if cancelled() { return; } - // Park on the recycle channel, but re-poll halt - // every POLL_INTERVAL: a pure-AtomicBool Halt does - // not disconnect the channel, so a blocking recv() - // would never re-reach the cancel check. + // Re-poll halt every POLL_INTERVAL: a pure-AtomicBool Halt does + // not disconnect the channel, so a blocking recv() would never + // re-reach the cancel check. let mut buf = loop { match recycle_rx.recv_timeout(POLL_INTERVAL) { Ok(b) => break b, @@ -132,11 +120,9 @@ impl BytePrefetcher { Err(RecvTimeoutError::Disconnected) => return, } }; - // Re-expose the full extent. After a short read the - // prior iteration truncated to n < chunk_bytes, so - // this regrows the length back to chunk_bytes - // without reallocating (capacity was fixed at - // construction and never shrinks). + // Regrow to chunk_bytes: a prior short read truncated len to + // n < chunk_bytes. No realloc — capacity was fixed at + // construction and never shrinks. if buf.len() < chunk_bytes { buf.resize(chunk_bytes, 0); } else { @@ -196,27 +182,12 @@ impl BytePrefetcher { /// drains `rx`, runs the demuxer in place on each filled buffer, /// and recycles back through `recycle_tx`. pub fn into_channels(self) -> (Receiver<Batch>, Sender<Vec<u8>>, PrefetchShell) { - // MOVE the three fields out cleanly — never clone. Each of - // `rx` and `recycle_tx` ends up with exactly ONE live copy: - // the one in the returned tuple. The pre-1.0.0 implementation - // cloned both and then `mem::forget`-ed `self`, leaking the - // originals so an extra live receiver + sender survived - // forever. That defeated the channel-disconnection shutdown: - // when the demux consumer exited early (halt, or a `tx.send` - // error in `demux_thread`), the producer's `recycle_rx.recv()` - // and `tx.send()` never saw all-peers-dropped, so the producer - // never returned and `PrefetchShell::drop`'s `join()` hung. - // - // `ManuallyDrop` + `ptr::read` reads each field out by value - // and suppresses `self`'s own `Drop` (which would otherwise - // double-`join`), leaving NO extra live endpoint behind. This - // is the panic-free equivalent of the `Option::take` approach - // and mirrors `sector::prefetched::into_channels`. + // MOVE the fields out, never clone: the pre-1.0.0 impl cloned + `mem::forget`-ed + // `self`, leaking endpoints that defeated disconnection shutdown (producer join + // hung forever). `ManuallyDrop` + `ptr::read` moves fields out cleanly instead. let me = std::mem::ManuallyDrop::new(self); - // SAFETY: `me` is `ManuallyDrop`, so none of these fields will - // be dropped by `me`. Each `ptr::read` performs exactly one - // bitwise move out; every field is read exactly once and never - // touched again, so there are no double-frees and no aliasing. + // SAFETY: `me` is `ManuallyDrop`, so none of these fields are dropped by `me`. + // Each `ptr::read` is one bitwise move, read exactly once — no double-frees. let producer = unsafe { std::ptr::read(&me.producer) }; // SAFETY: `rx` and `recycle_tx` are always `Some` here — // `into_channels` is the only way to consume a live @@ -230,13 +201,9 @@ impl BytePrefetcher { impl Drop for BytePrefetcher { fn drop(&mut self) { - // Drop channel endpoints BEFORE joining the producer so the - // producer observes SendTimeoutError::Disconnected (forward tx) - // or RecvTimeoutError::Disconnected (recycle rx) and exits - // promptly. Without this, a non-EOF source fills the depth-2 - // forward channel and then spins in send_timeout(POLL_INTERVAL) - // forever because rx is never drained, causing join() to - // deadlock. + // Drop channel endpoints BEFORE joining so the producer observes Disconnected + // (send/recv) and exits promptly. Otherwise a non-EOF source fills the forward + // channel and spins in send_timeout forever since rx is never drained. drop(self.rx.take()); drop(self.recycle_tx.take()); if let Some(h) = self.producer.take() { diff --git a/src/io/file_sector_source/mod.rs b/src/io/file_sector_source/mod.rs index b1aaba3a..f72608d3 100644 --- a/src/io/file_sector_source/mod.rs +++ b/src/io/file_sector_source/mod.rs @@ -33,7 +33,7 @@ //! //! On `open()` each platform issues its "sequential access expected" //! hint so OS-level readahead widens. The hint and the DONTNEED call -//! live in per-OS sibling modules ([`linux::hint_sequential`] et al.) +//! live in per-OS sibling modules (`linux::hint_sequential` et al.) //! — no inline `#[cfg]` in this file. //! //! ## Read-ahead prefetch @@ -190,13 +190,9 @@ impl SectorSource for FileSectorSource { ) -> Result<usize> { let count = count as u32; let bytes = count as usize * SECTOR_BYTES; - // A real check, not a debug_assert: this is a public `SectorSource` impl, - // so an undersized `out` is caller input, and `out[..bytes]` below would - // panic with 'range end index out of range' in release where the assert is - // compiled away. `Drive::read_fua` already carries exactly this guard, with - // a comment recording the same panic being fixed there — this impl was - // simply never given it, and `PrefetchedSectorSource` has a regression test - // for the case that this one lacked. + // A real check, not debug_assert: `out` is caller input to a public + // `SectorSource` impl, and `out[..bytes]` below would panic in release + // where the assert is compiled away. `Drive::read_fua` carries this guard. if out.len() < bytes { return Err(Error::DiscRead { sector: lba as u64, @@ -215,18 +211,14 @@ impl SectorSource for FileSectorSource { .read_exact(&mut out[..bytes]) .map_err(|e| Error::IoError { source: e })?; - // Queue the next batch's read with the kernel before the - // caller starts processing what we just returned. readahead() - // is non-blocking — it queues I/O and returns, so the kernel - // pulls those pages into cache while the consumer (decrypt + - // demux + mux) runs. Next read_sectors call hits a warm cache. + // Queue the next batch's read before the caller processes what we returned. + // readahead() is non-blocking; the kernel pulls pages into cache while the + // consumer runs, so the next read_sectors call hits a warm cache. platform::prefetch(&self.file, offset + bytes as u64, bytes as u64); - // Periodic page-cache eviction on the read side. Without - // this, an 85 GB streaming ISO read pins the entire file in - // the kernel page cache, which starves concurrent writes and - // collapses mux throughput. Mirrors the write-side - // WritebackPipeline's DONTNEED policy. + // Periodic page-cache eviction on the read side: an 85 GB streaming ISO + // read would otherwise pin the whole file in cache, starving concurrent + // writes. Mirrors WritebackPipeline's DONTNEED policy on the write side. self.bytes_read_since_drop += bytes as u64; if self.bytes_read_since_drop >= self.drop_chunk_bytes { let drop_start = self.drop_window_start; @@ -407,12 +399,9 @@ mod tests { #[test] fn drop_chunk_size_env_override() { - // Explicit 8 MiB via env var. - // SAFETY: tests in this crate are single-threaded per the - // default cargo test harness, but std::env::set_var is - // declared `unsafe` since Rust 2024 (it can race with other - // threads / TLS). For a test that runs in-process before any - // FileSectorSource construction this is safe in practice. + // Explicit 8 MiB via env var. SAFETY: set_var is `unsafe` since Rust 2024 + // (can race with other threads/TLS), but this test runs single-threaded + // and in-process before any FileSectorSource construction. unsafe { std::env::set_var("FREEMKV_READ_DROP_CHUNK_MIB", "8"); } diff --git a/src/io/image_writer.rs b/src/io/image_writer.rs index 7892978e..eb1163ec 100644 --- a/src/io/image_writer.rs +++ b/src/io/image_writer.rs @@ -101,17 +101,9 @@ pub fn write_image( on_progress(written); } - // flush() only pushes the BufWriter's bytes into the kernel via write(2). - // It makes no durability promise at all, so returning Ok here would report - // a finished image while up to several gigabytes of it still sit in the - // page cache. A crash, a power loss, or yanking the removable/network - // volume the image was written to then leaves a truncated or empty file - // that the caller was told was complete. - // - // For a 6-90 GB image that is exactly the failure this crate treats as - // worst: success reported over wrong output. `into_inner` is used rather - // than `flush` so a buffered-write error is surfaced instead of being - // dropped on the floor by BufWriter's Drop. + // flush() only pushes bytes into the kernel via write(2), no durability promise — + // a crash or yanked volume could leave a truncated file reported as complete. + // `into_inner` (not `flush`) also surfaces buffered-write errors, not silently drop them. let file = out.into_inner().map_err(|e| Error::IoError { source: e.into_error(), })?; diff --git a/src/io/pipeline.rs b/src/io/pipeline.rs index 95400b68..afa530f5 100644 --- a/src/io/pipeline.rs +++ b/src/io/pipeline.rs @@ -1,13 +1,12 @@ //! Generic bounded producer/consumer pipeline. //! //! `Pipeline<I, R>` spawns a single consumer thread, hands it items -//! through a bounded `mpsc::sync_channel`, and joins it on `finish()`. +//! through a bounded `crossbeam_channel`, and joins it on `finish()`. //! The consumer's behaviour is supplied by a [`Sink`] implementation: //! `apply` is called once per item, `close` is called once at the end. //! -//! Call sites in libfreemkv that want a producer/consumer split — -//! sweep (`disc/sweep.rs::SweepSink`) and the file-backed mux highway -//! — are built on this primitive. +//! The file-backed mux highway is built on this primitive. (An earlier +//! sweep sink also used it; sweep moved to freemkv-engine in 1.6.0.) //! //! ## Cancellation and error semantics //! @@ -170,19 +169,9 @@ fn finish_with_grace<R: Send + 'static>( } thread::sleep(POLL_INTERVAL); } - // Grace expired. CLAIM abandonment, then log and leak. Claiming BEFORE - // dropping the handle guarantees the leaked consumer observes it the moment - // its wedged syscall returns: it then skips any further `apply` and skips - // `close()`, rather than running on to finalise the abandoned output file. - // - // A compare-exchange, not a store, because the consumer may have committed to - // `close()` in the instant between our last `is_finished()` poll and now. It - // then cannot be stopped — the finalise IS happening — so abandoning it would - // report the rip as interrupted while a valid, fully finalised container - // lands on disk. Losing the race means waiting for the result the consumer is - // already producing instead. `AcqRel` pairs with the consumer's own - // compare-exchange and with the `Acquire` loads in its drain loop, so the flag - // is reliably observed even on weak memory models (ARM64/POWER). + // Grace expired. CLAIM abandonment before dropping the handle so the leaked consumer + // skips further `apply`/`close()` once its wedged syscall returns. Compare-exchange, + // not a store: if it already committed to `close()`, wait for its result instead. if state .compare_exchange( state::RUNNING, @@ -221,11 +210,9 @@ fn finish_with_grace<R: Send + 'static>( (output will not be finalised)", FINISH_GRACE_SECS ); - // Dropping `handle` without joining detaches from the thread — the - // consumer keeps running until its kernel call returns or the process - // exits. This is the intentional "leak" documented in - // `finish_with_halt`'s contract; the `abandoned` flag bounds what the - // leaked thread is allowed to do to the output before it exits. + // Dropping `handle` without joining detaches the thread; the consumer keeps + // running until its kernel call returns or the process exits. This is the + // intentional "leak" from `finish_with_halt`'s contract, bounded by `abandoned`. drop(handle); Err(leak_err) } @@ -365,11 +352,9 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> { let mut first_err: Option<Error> = None; let mut stopped = false; - // Rolling apply-throughput summary. The per-item "apply: OK" - // line was 99% of the mux log; collapse it into a periodic - // summary (count, avg ms, items/s) emitted ~every 5 s while - // debug tracing is on. The individual slow-apply ("took … s") - // STALL events below stay visible — those are signal, not noise. + // Rolling apply-throughput summary: the per-item "apply: OK" line was + // 99% of the mux log, so collapse it into a periodic summary (count, + // avg ms, items/s) every ~5s. Slow-apply STALL events stay visible below. let mut summary_count: u64 = 0; let mut summary_nanos: u128 = 0; let mut summary_since = Instant::now(); @@ -381,13 +366,9 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> { tracing::debug!("Pipeline receive: item={}", std::any::type_name::<I>()); } - // If the caller has abandoned us (grace period expired - // after a halt/timeout and the JoinHandle was dropped), - // stop applying items. We keep draining so the producer - // — if it is somehow still alive — never blocks on a - // dead receiver, but we touch the output no further. The - // final post-loop abandonment check returns the error - // and skips `close()`. + // Abandoned (grace expired, JoinHandle dropped): keep draining so a + // still-alive producer never blocks on a dead receiver, but touch + // the output no further. Post-loop check returns error, skips close(). if state_consumer.load(Ordering::Acquire) == state::ABANDONED { continue; } @@ -417,11 +398,9 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> { tracing::debug!("Pipeline: apply error, stopping, err={:?}", e); } first_err = Some(e); - // Publish the failure so the producer can stop - // FEEDING a dead write side instead of only learning - // about it at `finish()` — by which time it has read - // the rest of the disc. `Release` pairs with the - // `Acquire` load in `send_with_halt`. + // Publish the failure so the producer stops feeding a dead + // write side instead of learning at `finish()`, after reading + // the rest of the disc. `Release` pairs with `send_with_halt`. failed_consumer.store(true, Ordering::Release); } } @@ -473,16 +452,9 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> { ); } - // Final abandonment check: the common leak case is a - // consumer wedged inside `apply` (a blocking write - // syscall). When that syscall finally returns, the - // producer has long since dropped `tx`, so `recv` - // yields `Err` and we fall through to here. If the - // caller abandoned us in the meantime, skip `close()` - // entirely — finalising the output (e.g. writing the - // MKV Cues + patching the segment header) on a file the - // caller already reported as failed is exactly the - // write race we must not run. + // Final abandonment check: a consumer wedged in a blocking `apply` write + // can outlive the producer dropping `tx`, landing here via `recv -> Err`. + // Skip `close()` if abandoned meanwhile — it would race the write. match first_err { // No `close()` on this path, so there is nothing to claim — // just report, unless the caller has already given up on us. @@ -493,14 +465,9 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> { Err(e) } } - // CLAIM the finalise. A plain load here left a window in which - // the caller stored `abandoned` AFTER we read it as clear, so - // `close()` ran anyway and finalised (Cues + Segment-size - // patch) an output the caller had already reported as - // interrupted — a truncated rip indistinguishable from a - // complete one. The compare-exchange closes that window: if the - // caller got there first we skip `close()`, and if we get there - // first the caller waits for us instead of abandoning. + // CLAIM the finalise: a plain load could race the caller marking + // `abandoned`, letting `close()` finalise output already reported + // interrupted. Compare-exchange: loser skips `close()`, waits for winner. None => { if state_consumer .compare_exchange( @@ -645,12 +612,9 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> { let end = Instant::now() + deadline; let mut pending = item; loop { - // The consumer's `apply` has failed fatally: everything sent from here - // is drained and discarded, so hand the item back at once. Without this - // the producer saw every send succeed (the channel is always being - // drained) and went on reading the whole remaining title — an hour of - // drive time on a UHD — for a write that died on the first frame, only - // learning about it at `finish()`. + // The consumer's `apply` failed fatally: hand the item back now, since + // otherwise sends keep succeeding and the producer reads a whole UHD + // title before learning at `finish()` the write died on frame one. if self.consumer_failed() { if debug_enabled() { tracing::debug!( @@ -680,9 +644,8 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> { } return Err(pending); } - // Wait for space-available or halt-check tick, whichever - // is sooner. Crossbeam's send_timeout is kernel-wakeup - // based: the consumer's recv on a saturated channel + // Wait for space-available or halt-check tick, whichever is sooner. + // send_timeout is kernel-wakeup based: recv on a saturated channel // signals this thread the moment a slot opens up. let slice = SEND_HALT_CHECK_INTERVAL.min(end.saturating_duration_since(now)); match self.tx.send_timeout(pending, slice) { @@ -857,14 +820,9 @@ mod tests { #[test] fn back_pressure_blocks_sender() { - // depth=2 + 5 sends + 50ms/apply: with the consumer pinned at - // 50 ms per item, the producer can buffer 2 (channel cap) + - // 1 (consumer in flight) = 3 items before sends 4 and 5 must - // block on consumer progress. Wall-clock floor across all 5 - // sends is therefore ~2 * 50ms = 100ms (sends 4 and 5 each - // wait roughly one apply-cycle). Use 80 ms as the assertion - // floor to stay above the 50ms-per-item progress floor while - // tolerating CI jitter — it still proves blocking is real. + // depth=2 + 5 sends + 50ms/apply: producer buffers 3 items (2 channel cap + + // 1 in flight) before sends 4 and 5 must block, giving a ~100ms wall-clock + // floor. Assert 80ms to tolerate CI jitter while still proving blocking. let count = Arc::new(AtomicUsize::new(0)); let sink = SlowSink { delay: Duration::from_millis(50), @@ -985,10 +943,9 @@ mod tests { ) .expect("spawn should succeed"); - // Send 10 items. After Stop, subsequent sends may either - // succeed (already buffered) or fail with Err(I) (channel - // closed). Both are valid — we don't assert on the send - // results. + // Send 10 items. After Stop, subsequent sends may either succeed (already + // buffered) or fail with Err(I) (channel closed); both are valid, so we + // don't assert on the send results. for i in 0..10u64 { let _ = pipe.send(i); } @@ -1087,14 +1044,9 @@ mod tests { #[test] fn send_with_halt_returns_item_on_deadline() { - // depth=1 + consumer wedged in apply on the first item, AND - // the channel buffer already loaded with a second item, means - // any further `try_send` sees Full; with a 200 ms deadline and - // no halt fired, send_with_halt must return `Err(item)` within - // roughly the deadline. Synchronising on `started` ensures the - // consumer has actually started its wedged apply BEFORE we - // load the channel-buffer slot — without that, the consumer - // could still drain in a race window. + // depth=1 + wedged consumer + a loaded buffer slot means further `try_send` + // sees Full, so send_with_halt must return `Err(item)` around the 200ms + // deadline. Sync on `started` first so the consumer is wedged before we load the slot. let cancel = Arc::new(std::sync::atomic::AtomicBool::new(false)); let started = Arc::new(std::sync::atomic::AtomicBool::new(false)); let pipe = Pipeline::spawn( @@ -1208,13 +1160,9 @@ mod tests { matches!(res, Err(Error::Halted)), "expected Err(Halted), got {res:?}" ); - // Bailed out within the grace period plus a healthy margin. - // The grace spin-poll adds up to FINISH_GRACE_SECS (5s) of - // extra wait for a truly wedged consumer; the test's consumer - // is deliberately never released before this assert so we - // exercise the "grace expires → leak" path. 15s is well under - // the 10-minute JOIN_TIMEOUT backstop and proves the new code - // doesn't block forever. + // Bailed within the grace period plus margin: grace spin-poll adds up to + // FINISH_GRACE_SECS (5s) for the deliberately-unreleased, wedged consumer. + // 15s stays well under the 10-minute JOIN_TIMEOUT, proving it doesn't block forever. assert!( elapsed < Duration::from_secs(15), "halt observation took too long: {elapsed:?}" @@ -1571,10 +1519,9 @@ mod tests { halt.cancel(); let start = Instant::now(); - // finish_with_halt drops tx (signalling EOF), then observes the - // pre-cancelled halt and enters the grace spin. The consumer - // finishes close() within 500ms, so finish_with_halt must join - // cleanly and return Ok with the correct total. + // finish_with_halt drops tx (EOF), observes the pre-cancelled halt, and + // enters the grace spin; the consumer finishes close() within 500ms, so + // it must join cleanly and return Ok with the correct total. let res = pipe.finish_with_halt(Some(&halt)); let elapsed = start.elapsed(); @@ -1855,20 +1802,9 @@ mod tests { // caller genuinely reaches the abandon decision with a close in flight. let rel = release.clone(); thread::spawn(move || { - // Past the first grace window (and past the 250 ms poll cadence that - // bounds when the window is actually observed), inside the second. - // - // These intervals used to be 600 ms against a 300 ms grace, which - // left NO margin: two 300 ms windows end at 600 ms, and the 250 ms - // poll cadence can push the observation later still, so on a loaded - // runner the second window expired first and the caller abandoned — - // failing with Err(Halted) against a race, not a defect. - // - // Scaled up so the jitter is small relative to the intervals: the - // first window ends at ~1.0-1.25 s and the second at ~2.0-2.25 s, - // so releasing at 1.6 s sits well inside the second with roughly - // 350 ms of slack on either side. The ordering under test is - // unchanged; only the margin is. + // Release inside the second grace window. Old 600ms/300ms-grace intervals + // left no margin and flaked on loaded runners; scaled up so windows end + // at ~1.0-1.25s and ~2.0-2.25s, releasing at 1.6s for ~350ms slack. thread::sleep(Duration::from_millis(1600)); rel.store(true, Ordering::SeqCst); }); diff --git a/src/io/platform_macos.rs b/src/io/platform_macos.rs index 26b692fc..d05328a4 100644 --- a/src/io/platform_macos.rs +++ b/src/io/platform_macos.rs @@ -3,7 +3,7 @@ //! The `libc` crate doesn't expose these symbols across all macOS SDK //! versions, so we define them locally with values from //! `/usr/include/sys/fcntl.h`. Two call sites ( -//! [`crate::io::writeback_file`] and [`crate::io::sink::preallocate`]) +//! [`crate::io::writeback_file`] and `crate::io::sink::preallocate`) //! need the same constants and `fstore_t` layout — keeping a single //! source of truth here prevents the two copies from drifting. //! @@ -16,8 +16,9 @@ pub(crate) const F_PREALLOCATE: libc::c_int = 42; /// Anchor preallocation at the current physical EOF. pub(crate) const F_PEOFPOSMODE: libc::c_int = 3; -/// Prefer a contiguous allocation. Try this first; on `EINVAL` (no -/// contiguous run of that size), fall back to `F_ALLOCATEALL`. +/// Prefer a contiguous allocation. Call sites OR this together with +/// `F_ALLOCATEALL` on the first attempt; on failure they fall back to +/// `F_ALLOCATEALL` alone. pub(crate) const F_ALLOCATECONTIG: libc::c_uint = 0x0000_0002; /// Allow non-contiguous allocation. Stronger guarantee than just diff --git a/src/io/sink/mod.rs b/src/io/sink/mod.rs index 92d73028..6635f7f8 100644 --- a/src/io/sink/mod.rs +++ b/src/io/sink/mod.rs @@ -94,11 +94,9 @@ pub(crate) fn open_for_mkv( return Ok(Box::new(wf)); } } - // Only Linux differentiates the sink by filesystem type (NFS gets - // the WritebackFile machinery); every other OS always uses - // `LocalFileSink`. Reference `detect` as a value (no call, no - // `statfs` syscall) so it isn't flagged dead on non-Linux while - // still avoiding the wasted probe whose result we'd discard. + // Only Linux differentiates the sink by filesystem type; other OSes always use + // `LocalFileSink`. Reference `detect` as a value (no call/syscall) so it isn't + // flagged dead on non-Linux while avoiding a wasted probe. #[cfg(not(target_os = "linux"))] let _ = crate::platform::fs_type::detect; diff --git a/src/io/sink/preallocate/macos.rs b/src/io/sink/preallocate/macos.rs index ecfdd4cd..066ecc4f 100644 --- a/src/io/sink/preallocate/macos.rs +++ b/src/io/sink/preallocate/macos.rs @@ -18,11 +18,9 @@ pub(super) fn preallocate_impl(file: &File, size_bytes: u64) { // would wrap a >= 2^63 size to a negative length. let len = i64::try_from(size_bytes).unwrap_or(i64::MAX) as libc::off_t; let mut store = Fstore { - // Prefer a contiguous run but accept scattered extents to - // satisfy the full length. Without F_ALLOCATEALL the first - // attempt is best-effort and can return rc=0 with a partial - // allocation, so the fallback below would never fire. Matches - // writeback_file/macos.rs. + // Prefer contiguous but accept scattered extents. Without F_ALLOCATEALL + // the first attempt is best-effort and can return rc=0 with a partial + // allocation, so the fallback below would never fire. Matches writeback_file/macos.rs. fst_flags: F_ALLOCATECONTIG | F_ALLOCATEALL, fst_posmode: F_PEOFPOSMODE, fst_offset: 0, diff --git a/src/io/sink/socket.rs b/src/io/sink/socket.rs index 7f7aa69a..4756ff9c 100644 --- a/src/io/sink/socket.rs +++ b/src/io/sink/socket.rs @@ -56,12 +56,9 @@ impl SocketSink { /// `("host", 1234)`, a `SocketAddr`, etc. pub fn connect<A: ToSocketAddrs>(addr: A, sndbuf_bytes: Option<usize>) -> io::Result<Self> { let stream = TcpStream::connect(addr)?; - // `set_nodelay(true)` keeps small writes (TS packet trains, fMP4 - // moof headers) from sitting in Nagle's algorithm until the buffer - // fills. The BufWriter already absorbs syscall overhead; Nagle - // would just add latency without coalescing more. It is a latency - // hint, not a correctness requirement, so a platform that rejects - // TCP_NODELAY must not fail the connect — demote the error. + // `set_nodelay(true)` keeps small writes (TS packet trains, fMP4 moof headers) + // from sitting in Nagle's algorithm; BufWriter already absorbs syscall overhead. + // A latency hint, not a correctness requirement — a rejecting platform must not fail connect. let _ = stream.set_nodelay(true); if let Some(n) = sndbuf_bytes { set_send_buffer(&stream, n)?; @@ -119,10 +116,9 @@ impl UdpSocketSink { /// /// `sndbuf_bytes`, when present, is a hint to `SO_SNDBUF`. pub fn connect<A: ToSocketAddrs>(peer: A, sndbuf_bytes: Option<usize>) -> io::Result<Self> { - // Resolve the peer first so the local bind matches its address - // family. Binding `0.0.0.0:0` (IPv4) and then connecting to an - // IPv6 peer fails with EAFNOSUPPORT, so pick the wildcard that - // matches the resolved family. + // Resolve the peer first so the local bind matches its address family: + // binding `0.0.0.0:0` (IPv4) then connecting to an IPv6 peer fails + // with EAFNOSUPPORT. let peer_addr = peer .to_socket_addrs()? .next() @@ -164,12 +160,9 @@ impl SequentialSink for UdpSocketSink { } } -// ── Platform `SO_SNDBUF` tuning ──────────────────────────────────────────── -// -// std's `TcpStream` / `UdpSocket` don't expose `SO_SNDBUF`. We drop to -// libc on Linux + macOS (the libc-dep targets in Cargo.toml). On other -// targets the hint is silently ignored — the socket still works, the -// kernel just picks its own send-buffer size. +// ── Platform `SO_SNDBUF` tuning ───────────────────────────────────────── +// std doesn't expose `SO_SNDBUF`; drop to libc on Linux + macOS. Other +// targets silently ignore the hint — socket still works, kernel picks its own size. #[cfg(any(target_os = "linux", target_os = "macos"))] fn set_send_buffer(stream: &TcpStream, bytes: usize) -> io::Result<()> { @@ -185,10 +178,9 @@ fn set_udp_send_buffer(socket: &UdpSocket, bytes: usize) -> io::Result<()> { #[cfg(not(any(target_os = "linux", target_os = "macos")))] fn set_send_buffer(_stream: &TcpStream, _bytes: usize) -> io::Result<()> { - // Non-Linux-non-macOS targets aren't in Cargo.toml's libc dep list; - // silently ignore the hint rather than failing the connect. Callers - // can detect via the lack of an explicit "sndbuf applied" signal - // (not provided, intentionally — this is a hint, not a guarantee). + // Non-Linux-non-macOS targets aren't in Cargo.toml's libc dep list; silently + // ignore the hint rather than failing the connect (intentionally no + // "sndbuf applied" signal — this is a hint, not a guarantee). Ok(()) } @@ -265,11 +257,9 @@ mod tests { }); let mut sink = SocketSink::connect(addr, None).unwrap(); _assert_seq(&mut sink); - // The negative is harder to assert directly (no `is_not<T>`), - // but `SocketSink` does not impl `Seek`, so it can't unify with - // `RandomAccessSink`'s super-bound. The Phase 2 blanket impl - // `impl<T: SequentialSink + Seek> RandomAccessSink for T {}` thus - // excludes it by construction. + // No `is_not<T>` to assert the negative directly, but `SocketSink` doesn't + // impl `Seek`, so it can't unify with `RandomAccessSink`'s super-bound — + // the blanket `impl<T: SequentialSink + Seek> RandomAccessSink for T {}` excludes it. } #[test] diff --git a/src/io/writeback.rs b/src/io/writeback.rs index 28543ebd..8ecb3c39 100644 --- a/src/io/writeback.rs +++ b/src/io/writeback.rs @@ -3,7 +3,7 @@ //! queue bounded. On macOS and Windows, a no-op stub. //! //! The platform decision lives entirely in this file (the cfg-gated -//! `pub use` below). Callers — and `DiskWriter` itself — are +//! `pub use` below). Callers — and `WritebackFile` itself — are //! platform-independent. #[cfg(target_os = "linux")] diff --git a/src/io/writeback/linux.rs b/src/io/writeback/linux.rs index 3ed21956..64594907 100644 --- a/src/io/writeback/linux.rs +++ b/src/io/writeback/linux.rs @@ -179,18 +179,16 @@ impl WritebackPipeline { if pos < self.last_flush_pos.saturating_add(self.chunk_bytes) { return; } - // Byte offsets are unsigned throughout; the signed cast happens - // only at the libc call boundary where the kernel ABI requires - // `i64`. `saturating_sub` documents and hardens the line-above - // guard that `pos >= last_flush_pos`. + // Byte offsets are unsigned throughout; the signed cast happens only at the + // libc call boundary where the kernel ABI requires `i64`. `saturating_sub` + // hardens the line-above guard that `pos >= last_flush_pos`. let chunk_off: u64 = self.last_flush_pos; let chunk_len: u64 = pos.saturating_sub(self.last_flush_pos); let mut wait_ms: u64 = 0; let mut fadvise_ms: u64 = 0; - // Async kickoff for the just-completed chunk runs on every - // path (NFS, degraded, normal) — it's nominally non-blocking - // by spec and gives the kernel an early hint that this range - // is ready to flush. + // Async kickoff for the just-completed chunk runs on every path (NFS, + // degraded, normal) — non-blocking by spec, an early hint that this + // range is ready to flush. let kickoff_rc = unsafe { libc::sync_file_range( self.fd, @@ -211,16 +209,13 @@ impl WritebackPipeline { } if let Some((prev_off, prev_len)) = self.pending.take() { if self.skip_wait() { - // NFS branch (or degraded fallback after a prior - // timeout): the WAIT_AFTER + DONTNEED dance is what - // hangs on NFS — skip it. We still advance `pending` - // so the next call has a stable cycle. + // NFS branch (or degraded fallback after a prior timeout): the + // WAIT_AFTER + DONTNEED dance hangs on NFS — skip it, but still + // advance `pending` so the next call has a stable cycle. } else { - // Normal local-storage branch with belt-and-braces - // timeout. If WAIT_AFTER hangs > WAIT_AFTER_TIMEOUT - // we mark the pipeline degraded, log a loud error, - // and fall through to the skip path on subsequent - // calls. + // Normal local-storage branch with belt-and-braces timeout: if + // WAIT_AFTER hangs > WAIT_AFTER_TIMEOUT, mark degraded, log loudly, + // and fall through to the skip path on subsequent calls. match wait_after_with_timeout(self.clone_for_worker(), self.fd, prev_off, prev_len) { Some(ms) => { @@ -238,17 +233,13 @@ impl WritebackPipeline { self.record_wait(wait_ms); } None => { - // Timeout branch: switch to NFS-style skip - // for the rest of the pipeline's life. Do - // NOT call DONTNEED — if WAIT_AFTER hasn't + // Timeout branch: switch to NFS-style skip for the rest of the + // pipeline's life. Do NOT call DONTNEED — if WAIT_AFTER hasn't // returned, the pages aren't safely flushed. self.degraded.store(true, Ordering::Relaxed); - // Once degraded we skip DONTNEED, so every subsequent - // chunk's pages stay resident until close — the same - // page-cache exposure profile as NFS. Shrink to the - // floor so that exposure window is as small as the NFS - // path keeps it, instead of whatever the adaptive sizing - // had grown chunk_bytes to (up to 256 MiB). + // Skipping DONTNEED leaves pages resident until close (same + // exposure as NFS), so shrink chunk_bytes to the floor rather + // than whatever adaptive sizing had grown it to (up to 256 MiB). self.chunk_bytes = CHUNK_BYTES_MIN; tracing::error!( target: "mux", @@ -294,10 +285,9 @@ impl WritebackPipeline { if self.wait_after_window.len() < ADAPTIVE_WINDOW { return; } - // p95 index, derived from the window size so it stays valid if - // ADAPTIVE_WINDOW changes (a hard-coded `[14]` would panic OOB - // for a window <= 14). For the default 16 this is index 15 - // (ceil(16 * 95 / 100) - 1 = 15), i.e. the top sample. + // p95 index, derived from window size so it stays valid if ADAPTIVE_WINDOW + // changes (a hard-coded `[14]` would panic OOB for a window <= 14). For the + // default 16 this is index 15, i.e. the top sample. let mut sorted: Vec<u64> = self.wait_after_window.iter().copied().collect(); sorted.sort_unstable(); let p95_idx = (ADAPTIVE_WINDOW * 95).div_ceil(100).min(ADAPTIVE_WINDOW) - 1; @@ -442,10 +432,8 @@ fn wait_after_with_timeout( Err(crate::io::bounded::BoundedError::Timeout) | Err(crate::io::bounded::BoundedError::Halted) => None, Err(crate::io::bounded::BoundedError::WorkerLost) => { - // Worker thread spawn failed or panicked before sending. - // Treat as a benign success (no syscall ran) rather than - // a degrade trigger — falling through with elapsed_ms=0 - // matches the no-op behaviour. + // Worker thread spawn failed or panicked before sending. Treat as benign + // success (no syscall ran), not a degrade trigger — elapsed_ms=0 matches no-op. Some(0) } } diff --git a/src/io/writeback_file/linux.rs b/src/io/writeback_file/linux.rs index c88cde29..f04b3f5d 100644 --- a/src/io/writeback_file/linux.rs +++ b/src/io/writeback_file/linux.rs @@ -17,10 +17,9 @@ use std::time::Duration; /// Best-effort: a non-zero rc is logged but not propagated, since the /// caller would just continue with the unreserved file anyway. pub(super) fn preallocate(file: &File, size_bytes: u64) { - // FALLOC_FL_KEEP_SIZE = 0x01 — keep the reported file size at 0 - // (writes grow it normally) while still pre-reserving the extents. - // Clamp to the signed `off_t` range; an unchecked `as i64` cast - // would wrap a >= 2^63 size to a negative length (EINVAL no-op). + // FALLOC_FL_KEEP_SIZE keeps the reported file size at 0 (writes grow it normally) + // while pre-reserving extents. Clamp to `off_t` range; an unchecked `as i64` + // cast would wrap a >= 2^63 size to a negative length (EINVAL no-op). let len = i64::try_from(size_bytes).unwrap_or(i64::MAX); let rc = unsafe { libc::fallocate(file.as_raw_fd(), libc::FALLOC_FL_KEEP_SIZE, 0, len) }; tracing::debug!( diff --git a/src/io/writeback_file/macos.rs b/src/io/writeback_file/macos.rs index d5f0ae16..bb166d26 100644 --- a/src/io/writeback_file/macos.rs +++ b/src/io/writeback_file/macos.rs @@ -215,11 +215,9 @@ mod tests { "a halted F_FULLFSYNC must not be reported as a completed sync" ); - // The three arms must be DISTINGUISHABLE, not merely non-Ok. Each - // carries its own numeric code through the "E<code>" prefix that - // `From<Error> for io::Error` mints — the only shape `error_code` - // recognises. A bare `ErrorKind` cannot be classified, which is how a - // user cancel here used to read as a hard I/O failure. + // The three arms must be DISTINGUISHABLE, not merely non-Ok. Each carries its + // own numeric code via the "E<code>" prefix `From<Error> for io::Error` mints — + // a bare `ErrorKind` can't be classified, so a user cancel used to read as I/O failure. let lost = bounded_failure_to_result(BoundedError::WorkerLost) .expect_err("a lost F_FULLFSYNC worker must be an error"); assert!( diff --git a/src/io/writeback_file/mod.rs b/src/io/writeback_file/mod.rs index 02c7676d..3dec201c 100644 --- a/src/io/writeback_file/mod.rs +++ b/src/io/writeback_file/mod.rs @@ -143,8 +143,9 @@ impl WritebackFile { /// Like [`Self::create`] but pre-reserves `size_bytes` of disk /// space via the platform's extent-preallocation primitive (Linux - /// `fallocate(KEEP_SIZE)`, macOS `F_PREALLOCATE`, Windows - /// `SetFileValidData` stub). The reported file size is unchanged + /// `fallocate(KEEP_SIZE)`, macOS `F_PREALLOCATE`; Windows has no + /// equivalent, so it is a debug-logged no-op there). The reported + /// file size is unchanged /// (writes still grow the file naturally) — only the on-disk extent /// allocation is preallocated, which reduces extent fragmentation /// on large sequential writes (mux output, especially on slow @@ -228,18 +229,13 @@ impl Write for WritebackFile { impl Seek for WritebackFile { fn seek(&mut self, from: SeekFrom) -> io::Result<u64> { let p = self.file.seek(from)?; - // Only treat seeks that actually move the position as - // boundaries — sweep does a redundant `seek(Current(pos))` - // before every write, and we don't want that to drain the - // pipeline on every iteration. + // Only treat seeks that actually move the position as boundaries — sweep does + // a redundant `seek(Current(pos))` before every write, which shouldn't drain + // the pipeline on every iteration. if p != self.pos { - // Diagnostic for the NFS mux hang: the MKV format requires - // the muxer to seek back occasionally (cluster size - // patching, Cues index write, Segment header backpatch). - // Each such seek invalidates the writeback chunk tracking - // and forces a finalize → WAIT_AFTER on the in-flight - // chunk. Logging the seek delta lets us correlate hang - // offsets with specific muxer operations. + // Diagnostic for the NFS mux hang: MKV requires occasional backward seeks + // (cluster patching, Cues, Segment backpatch), each invalidating writeback + // chunk tracking. Logging the delta correlates hang offsets with muxer ops. let from_pos = self.pos; let to_pos = p; let delta: i64 = (to_pos as i64).wrapping_sub(from_pos as i64); @@ -274,17 +270,9 @@ impl super::sink::RandomAccessSink for WritebackFile {} impl Drop for WritebackFile { fn drop(&mut self) { - // Run the pipeline's tail finalize so the last in-flight chunk - // gets its `WAIT_AFTER` + `posix_fadvise(DONTNEED)`. Without - // this, callers that drop a `WritebackFile` without calling - // `sync_all` (panic, early-return, idiomatic `let _ = w;`) - // leave the trailing chunk in cache; the kernel still flushes - // on close, but the bounded-cache invariant fails at the tail. - // We deliberately do *not* call `self.file.sync_all()` here — - // close already triggers a flush, and an `fsync` from `Drop` - // would silently swallow its `io::Error` anyway. `finalize` is - // idempotent so an explicit `sync_all` followed by drop is - // still safe. + // Run the pipeline's tail finalize (WAIT_AFTER + DONTNEED); otherwise a drop + // without `sync_all` leaves the trailing chunk in cache. No `self.file.sync_all()` + // here — `Drop`-triggered fsync would swallow errors; `finalize` is idempotent. self.pipeline.finalize(); } } diff --git a/src/keysource.rs b/src/keysource.rs index 86047e98..acce56ad 100644 --- a/src/keysource.rs +++ b/src/keysource.rs @@ -381,12 +381,9 @@ pub fn resolve_and_apply_traced( let mut trace = crate::aacs::trace::ResolutionTrace::new(); - // The FIRST source failure seen, if any. A source that returns `Err` did not - // answer "no key for this disc" — it could not answer at all — and that - // reason is stamped onto `disc.aacs_error` below so the decrypt gate reports - // THAT instead of the generic `NoDiscKey`. First-wins (not last) so the - // ordered sources' most-preferred failure is the one the operator is told - // about, matching the first-valid-wins rule for successes. + // The FIRST source failure seen, if any. An `Err` means the source could not + // answer at all (not "no key"), so its reason is stamped onto `disc.aacs_error` + // below, reporting the ordered sources' most-preferred failure to the operator. let mut source_failure: Option<crate::error::Error> = None; // The ctx parses Unit_Key_RO.inf at the stride for `inputs.version` (the @@ -430,26 +427,9 @@ pub fn resolve_and_apply_traced( outcome: KeyOutcome::NoKey, }); } - // The source could NOT answer — it was unreachable, it errored, or it - // refused. Nothing is known about whether a key exists, so the path - // is EMPTY: recording `NoEntry` here is exactly the conflation that - // made a seven-hour run of HTTP 502s render as - // `key: online > no entry > NO KEY` + `E7022 No key source has a - // decryption key for this disc`, and sent operators hunting for a VUK - // that was never missing. - // - // The reason itself rides out on `disc.aacs_error` (below), the - // channel `Disc::ensure_decryptable_keys` already reads for the - // E7017-vs-E7022 split — so the decrypt gate raises the SOURCE's code - // (`KeyServiceUnavailable` / `KeyServiceUnauthorized` / - // `KeyServiceRateLimited`) instead of the generic `NoDiscKey`. - // - // `KeyOutcome` deliberately gains no variant: it is matched - // exhaustively by every front-end's trace renderer (freemkv's - // `pipe::render_resolution_trace`, autorip's - // `keysource::render_resolution_trace`), and this fix must not turn - // into a breaking change across four repos to say something the error - // code already says precisely. + // The source could NOT answer (unreachable/errored/refused), so path is + // EMPTY, not `NoEntry` — conflating the two once rendered 502s as "no + // key" and misled operators. The real reason rides on `disc.aacs_error`. Err(e) => { if source_failure.is_none() { source_failure = Some(e); @@ -462,10 +442,9 @@ pub fn resolve_and_apply_traced( } } } - // Nothing resolved. If a source FAILED rather than answered, stamp that - // reason onto the disc so the decrypt gate can report it — but never clobber - // a reason the scan already captured (e.g. `AacsVidUnavailable`), which is - // closer to the disc itself than a source outage is. + // Nothing resolved. If a source FAILED rather than answered, stamp that reason + // onto the disc for the decrypt gate — but never clobber a reason the scan + // already captured (e.g. `AacsVidUnavailable`), which is closer to the disc. if let Some(e) = source_failure && disc.aacs_error.is_none() { @@ -573,22 +552,9 @@ pub fn key_fetch( inputs: DiscInputs, make_sources: std::sync::Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync>, ) -> crate::sector::KeyFetch { - // One driver behind both operations: rebuild the sources, inject `samples` - // as the ctx's content samples, run `drive` (the per-kind fetch), map the - // resolved UnitKeys to raw keys. Memoized by the fingerprint of the sample - // batch: the resolved keys are disc-level (a clip's index / CPS keys are - // identical for every title that references it), so the first batch resolves - // over the network and every repeat is answered from the cache with no - // request. A GENUINELY-empty reply (every source ran and none held the key) - // is cached too — the key the service lacks for a batch won't appear on a - // re-ask, so re-hitting the network buys nothing. But an empty reply caused - // by a source FAILURE (network down, source unreachable) is NOT cached: that - // is a transient miss, and caching it would permanently drop a unit that - // could be recovered once the source recovers — the `errored` flag on - // `FetchOutcome` draws exactly that line. Each operation gets its OWN cache: - // a base batch and a forensic anchor never collide, and the same bytes could - // legitimately resolve differently per op. - // The per-kind driver: `drive_unit_keys` or `drive_fmts_indexes`. + // One driver behind both operations, memoized per sample-batch fingerprint + // (keys are disc-level, so repeats hit cache). A genuinely-empty reply is + // cached, but a source FAILURE is not — a transient miss (see `errored`). type FetchDriver = fn(&[Box<dyn KeySource>], &dyn ResolveCtx) -> FetchOutcome; fn make_op( inputs: DiscInputs, @@ -614,9 +580,8 @@ pub fn key_fetch( let mut di = inputs.clone(); di.samples = samples.to_vec(); // Parse Unit_Key_RO.inf at the disc's OWN stride (carried on `inputs`): - // an online /decode reply that returns a VUK (not a terminal UK) then - // derives unit keys from `enc_title_keys`, which a V10 disc parses at - // the 48-byte stride — hardcoding the V20 stride here corrupted them. + // a V10 disc parses `enc_title_keys` at the 48-byte stride, so + // hardcoding the V20 stride here would corrupt the derived unit keys. let ctx = DiscInputsCtx::new(&di); let outcome = drive(&sources, &ctx); let keys: Vec<[u8; 16]> = outcome.keys.into_iter().map(|u| u.key).collect(); @@ -666,11 +631,9 @@ pub fn read_encrypted_units( ) -> Vec<Vec<u8>> { use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted}; const CHUNK_UNITS: u32 = 15; // 45 sectors/read — under the drive transfer cap - // Probe several evenly-spaced points across EACH extent rather than only the - // midpoint-and-forward: a title whose encrypted feature starts late, or whose - // midpoint lands in a clear nav stretch, must STILL yield scrambled samples. - // Empty samples make `Disc::decrypt_with` skip wrong-key validation, so a - // real encrypted title returning nothing here is a silent wrong-key hazard. + // Probe several evenly-spaced points across EACH extent, not just midpoint + // forward: a title starting late or landing in a clear nav stretch must still + // yield samples — empty samples make `decrypt_with` skip wrong-key validation. const PROBES_PER_EXTENT: u32 = 8; let mut out: Vec<Vec<u8>> = Vec::new(); @@ -687,20 +650,17 @@ pub fn read_encrypted_units( continue; } let units_this = CHUNK_UNITS.min(total_units - unit); - // Saturate: start_lba comes from attacker-controlled UDF/MPLS - // extents; a malformed extent near u32::MAX would otherwise panic - // (debug) or wrap to a wrong LBA (release). An over-capacity LBA then - // fails cleanly via the read_sectors().is_err() skip below. + // Saturate: start_lba comes from attacker-controlled UDF/MPLS extents; + // near u32::MAX it would otherwise panic (debug) or wrap (release). + // An over-capacity LBA fails cleanly via the is_err() skip below. let lba = ext .start_lba .saturating_add(unit.saturating_mul(ALIGNED_UNIT_SECTORS)); let count = (units_this * ALIGNED_UNIT_SECTORS) as u16; let mut buf = vec![0u8; count as usize * 2048]; - // `false` = no recovery retries; the reader is the raw drive/file - // (no decrypt decorator), so these are the on-disc encrypted bytes. A - // read error at one probe skips THAT probe only — it must not abandon - // the rest of the extent (the old `break` blinded the sampler on a - // single transient miss). + // `false` = no recovery retries; reader is the raw drive/file (no + // decrypt decorator). A read error skips only THAT probe — it must not + // abandon the rest of the extent (the old `break` blinded the sampler). if reader.read_sectors(lba, count, &mut buf, false).is_err() { continue; } @@ -1298,10 +1258,9 @@ mod tests { use crate::error::Result; use crate::sector::SectorSource; - // Even units: CPI-clear (byte0 & 0xC0 == 0) AND sync-destroyed (no 0x47). - // Odd units: CPI-set (byte0 = 0xC0) with a scrambled body. - // Neither has clean TS syncs, so `is_clean` is FALSE for BOTH; - // `aacs_unit_encrypted` flags only the odd units. + // Even units: CPI-clear, sync-destroyed. Odd units: CPI-set, scrambled body. + // Neither has clean TS syncs (`is_clean` is FALSE for both), but + // `aacs_unit_encrypted` must flag only the odd (CPI-set) units. struct MixSource { ext_start: u32, total_units: u32, diff --git a/src/labels/bdmt.rs b/src/labels/bdmt.rs index d3052286..d9d50d8f 100644 --- a/src/labels/bdmt.rs +++ b/src/labels/bdmt.rs @@ -47,7 +47,9 @@ pub struct DiscMetadata { pub titles: BTreeMap<String, String>, /// First-line / short description, per lang pub descriptions: BTreeMap<String, String>, - /// Disc N of M for box sets (None if not a box set) + /// Disc N of M, returned whenever both `<di:discNumber>` and + /// `<di:numSets>` are present and sane (including `(1, 1)` for a + /// single-disc release); `None` if either is missing or invalid. pub disc_number: Option<(u32, u32)>, } @@ -76,10 +78,9 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<DiscMetadata> let Some(lang) = lang_code_from_filename(&entry.name) else { continue; }; - // entry.size is attacker-controlled UDF metadata and flows into - // a Vec::with_capacity in read_file. A real BDMV bdmt XML is a - // few KB; cap well above that so a crafted multi-GB size can't - // trigger a huge allocation before any parsing. + // entry.size is attacker-controlled and flows into a Vec::with_capacity + // in read_file. Cap well above a real few-KB bdmt XML so a crafted + // multi-GB size can't trigger a huge allocation before parsing. if !bdmt_size_acceptable(entry.size) { continue; } @@ -178,11 +179,9 @@ fn looks_like_xml(s: &str) -> bool { /// helpers are case- and namespace-insensitive, so callers pass the /// bare local name (no `di:` prefix). fn extract_title(xml_text: &str) -> Option<String> { - // Order matches the module-level convention: <di:name> first - // (Paramount-style), then <di:title>, then the nested - // tableOfContents/titleName form. - // xml::text already trims its result, so an empty string after - // extraction means a genuinely empty element. + // Priority: <di:name> (Paramount-style), then <di:title>, then nested + // tableOfContents/titleName. xml::text trims, so an empty string here + // means a genuinely empty element. for tag in ["name", "title"] { if let Some(s) = xml::text(xml_text, tag) && !s.is_empty() @@ -351,10 +350,9 @@ mod tests { #[test] fn multiple_languages_keyed_correctly() { - // Simulate driving parse_bdmt_xml from two synthetic XML - // blobs and aggregating into DiscMetadata the same way parse() - // would. This exercises the BTreeMap key handling without - // needing a UdfFs. + // Drive parse_bdmt_xml from two synthetic XML blobs and aggregate + // into DiscMetadata like parse() would, exercising BTreeMap key + // handling without needing a UdfFs. let eng_xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta"> <di:name>Aurora Drift</di:name> </discInfo>"#; @@ -395,12 +393,9 @@ mod tests { #[test] fn malformed_xml_returns_none() { - // Random gibberish has no recognizable title element. We - // document the contract: parse_bdmt_xml returns None, and - // parse() (the caller) skips the file. Aggregating across - // zero files leaves DiscMetadata::default() — which parse() - // surfaces as None to its caller. Either is documented as - // acceptable per the module spec. + // Gibberish has no recognizable title element: parse_bdmt_xml returns + // None and parse() skips the file. Zero files aggregated leaves + // DiscMetadata::default(), which parse() surfaces as None. let bad = "this is not xml &&& <<< nope"; assert!(parse_bdmt_xml(bad).is_none()); @@ -411,11 +406,9 @@ mod tests { #[test] fn description_with_only_child_xml_is_dropped() { - // Real-world bug: <di:description> contained only - // <di:thumbnail/> child elements with no actual prose. The - // previous parser surfaced the raw XML fragment as the - // description string. Now we reject candidates that begin - // with `<`. + // Real-world bug: <di:description> contained only <di:thumbnail/> + // children with no prose, and the old parser surfaced the raw XML + // fragment as the description. Now candidates starting with `<` are rejected. let xml = r#"<discInfo> <di:name>Skyline Run</di:name> <di:description> diff --git a/src/labels/class_reader.rs b/src/labels/class_reader.rs index a91218a0..3cfdeb21 100644 --- a/src/labels/class_reader.rs +++ b/src/labels/class_reader.rs @@ -10,10 +10,9 @@ //! for any label parser that needs structured access to .class files //! inside a `/BDMV/JAR/<x>.jar`. -// Foundation module — public API is staged for `labels::deluxe` (which -// will exercise the bytecode walker) and `labels::dbp`'s refactor onto -// the constant-pool iterator. The dead-code allow comes off as those -// callers land. Tests below cover the API in isolation. +// Foundation module — public API is staged for `labels::deluxe` (bytecode +// walker) and `labels::dbp`'s refactor onto the constant-pool iterator. +// The dead-code allow comes off once those callers land. #![allow(dead_code)] const CLASS_MAGIC: u32 = 0xCAFEBABE; @@ -32,10 +31,9 @@ pub enum Error { BadInstruction { pc: usize, opcode: u8 }, } -// No Display/std::error::Error impl: this is a crate-internal, typed error -// used only for `match`/`?` within the label parsers (callers discard it via -// `let Ok(_) = ... else continue`). Per the library's zero-English rule there -// is no user-facing text; the variant fields carry the structured detail. +// No Display/std::error::Error impl: crate-internal typed error for `match`/`?` +// within label parsers. Per the zero-English rule there is no user-facing +// text; variant fields carry the structured detail. pub type Result<T> = std::result::Result<T, Error>; @@ -155,7 +153,8 @@ impl ConstantPool { } /// For `ldc` / `ldc_w` operands: resolve a constant-pool index to a - /// best-effort string. Supports Utf8, String, Integer, Class. + /// best-effort string. Supports Utf8, String, Integer, Float, Long, + /// Double, and Class. pub fn load_constant_display(&self, index: u16) -> Option<String> { Some(match self.get(index)? { CpInfo::Utf8(s) => format!("utf8:{:?}", s), @@ -588,7 +587,7 @@ impl Instruction<'_> { /// ldc_w, ldc2_w, new, getstatic, putstatic, getfield, putfield, /// invokevirtual, invokespecial, invokestatic, invokeinterface, /// invokedynamic, checkcast, instanceof, anewarray, multianewarray, - /// ldc with cp index in operands[0] — return the index. Returns + /// ldc with cp index in `operands[0]` — return the index. Returns /// None for opcodes whose operand is not a CP index. pub fn cp_index(&self) -> Option<u16> { match self.opcode { @@ -657,10 +656,9 @@ fn instruction_size(code: &[u8], pc: usize) -> Option<usize> { if high < low { return None; } - // `high - low + 1` can overflow i32 for adversarial bytecode - // (e.g. low=i32::MIN/high=0, or low=0/high=i32::MAX), so widen - // to i64 before adding. The product and final sum are saturating - // so they cannot overflow usize on a 32-bit target either. + // `high - low + 1` can overflow i32 for adversarial bytecode, so widen + // to i64 before adding; the product/sum below saturate so they can't + // overflow usize on a 32-bit target either. let entries = (high as i64 - low as i64 + 1) as u64; let table_bytes = entries.saturating_mul(4); let base = (padded_start - pc + 12) as u64; @@ -989,14 +987,9 @@ impl<'a> Reader<'a> { } fn slice(&mut self, n: usize, needed: &'static str) -> Result<&'a [u8]> { - // `n` is attacker-supplied: it comes from a JVMS `u4` attribute_length - // / code_length (§4.7, §4.7.3) or a `u2` Utf8 length (§4.4.7). Unlike - // the fixed-width readers above, whose `self.pos + k` cannot leave the - // buffer's own address range, `self.pos + n` can wrap — on a 32-bit - // target a `u4` length near 0xFFFF_FFFF plus a non-zero `pos` panics - // in debug and in release wraps to a SMALL end offset that passes the - // bounds check, after which the slice index itself panics. Checked, so - // an out-of-range length is the EOF error it always should have been. + // `n` is attacker-supplied (a JVMS u4/u2 length), so unlike the fixed-width + // readers above, `self.pos + n` can wrap on a 32-bit target and pass a naive + // bounds check. checked_add turns an out-of-range length into an EOF error. let Some(end) = self.pos.checked_add(n) else { return Err(Error::UnexpectedEof { needed }); }; @@ -1130,10 +1123,9 @@ mod tests { #[test] fn instruction_size_tableswitch_overflow_does_not_panic() { - // Adversarial low/high spanning the full i32 range. `high - low + 1` - // overflows i32; the widened i64 count then saturates the byte - // products. Must return a value (possibly None on a 32-bit usize) - // without panicking. + // Adversarial low/high spanning the full i32 range overflows `high - low + 1` + // in i32; the widened i64 count then saturates the byte products. Must + // return a value (possibly None on a 32-bit usize) without panicking. for (low, high) in [ (i32::MIN, 0i32), (0i32, i32::MAX), @@ -1225,14 +1217,9 @@ mod tests { assert_eq!(i.cp_index(), None); } - // ── Robustness smoke tests ────────────────────────────────────────────── - // - // ClassFile::parse must NEVER panic on adversarial input, only - // return Err. These tests feed a battery of malformed byte - // sequences and assert Err results — they're the lightweight - // alternative to a full cargo-fuzz target (which would need - // nightly + separate crate). If we adopt cargo-fuzz later, these - // tests stay as deterministic regression cases. + // ── Robustness smoke tests ── ClassFile::parse must NEVER panic on + // adversarial input, only return Err. Lightweight alternative to a full + // cargo-fuzz target; stays useful as deterministic regression cases. /// Tiny pseudo-random byte generator — deterministic + reproducible /// without needing a `rand` dep. xorshift64*; good enough for @@ -1306,10 +1293,9 @@ mod tests { #[test] fn parse_does_not_panic_on_random_bytes() { - // 200 deterministic-pseudo-random byte buffers of varying - // lengths. The contract: never panic, only return Err (or in - // the vanishingly unlikely case of a coincidentally-valid - // buffer, Ok — we don't assert one or the other). + // 200 deterministic pseudo-random buffers of varying lengths. + // Contract: never panic, only return Err (or Ok in the vanishingly + // unlikely coincidentally-valid case — we don't assert which). let mut state: u64 = 0xDEADBEEF_DEADBEEF; for _ in 0..200 { let len = (xorshift(&mut state) % 256) as usize; @@ -1325,10 +1311,9 @@ mod tests { #[test] fn parse_does_not_panic_on_valid_magic_random_tail() { - // 100 buffers that start with the magic + plausible - // minor/major but have garbage afterwards. These are the - // most adversarial — they pass the magic check and then - // exercise every other parser path. + // 100 buffers with valid magic + plausible minor/major but garbage + // afterwards — the most adversarial, passing the magic check then + // exercising every other parser path. let mut state: u64 = 0xCAFEBABE_DEADBEEF; for _ in 0..100 { let mut buf = vec![0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x00, 0x00, 0x34]; @@ -1389,14 +1374,9 @@ mod tests { } } - // ----------------------------------------------------------------- - // ConstantPool / ClassFile accessor correctness - // - // These exercise plain data accessors on an already-parsed pool - // (built via the test-only `from_entries` constructor) — not the - // untrusted-bytes parsing path, just "does the right variant map to - // the right Option value." - // ----------------------------------------------------------------- + // ConstantPool / ClassFile accessor correctness: exercise data accessors + // on an already-parsed pool (test-only `from_entries` ctor), checking each + // variant maps to the right Option value. fn sample_pool() -> ConstantPool { // index: 0=Empty (reserved), 1=Utf8("Hello"), 2=Integer(42), @@ -1547,14 +1527,9 @@ mod tests { assert_ne!(cf.member_descriptor(&m), Some("doStuff")); } - // ----------------------------------------------------------------- - // Reader::u16/u32/u64 boundary + value correctness - // - // Mirrors `slice_boundary_is_inclusive_of_the_final_byte`: an - // exact-fit read must succeed, one byte short must fail. Plus - // positive-value tests so a scrambled byte assembly (not just an - // out-of-bounds read) would be caught. - // ----------------------------------------------------------------- + // Reader::u16/u32/u64 boundary + value correctness: an exact-fit read + // succeeds, one byte short fails, plus positive-value tests so a + // scrambled byte assembly (not just OOB) is caught. #[test] fn u16_boundary_is_inclusive_of_the_final_byte() { @@ -1656,10 +1631,9 @@ mod tests { #[test] fn constant_pool_long_entry_occupies_two_slots_via_real_parse() { - // Real class-file bytes (not the `from_entries` synthetic ctor): - // magic + minor/major + cp_count=4 + tag=5 (Long, 8-byte payload - // at index 1, reserved slot at index 2) + tag=1 (Utf8 at index 3) - // + empty access_flags/this/super/interfaces/fields/methods/attrs. + // Real class-file bytes (not the from_entries ctor): magic, minor/major, + // cp_count=4, tag=5 (Long, payload at index 1, reserved slot 2), + // tag=1 (Utf8 at index 3), then empty header tail sections. let mut buf = vec![ 0xCA, 0xFE, 0xBA, 0xBE, // magic 0x00, 0x00, // minor @@ -1691,12 +1665,9 @@ mod tests { } } - // ----------------------------------------------------------------- // instruction_size: tableswitch/lookupswitch with non-degenerate - // low/high/npairs (the existing tests only cover low==high==0 and - // npairs==0, which can't distinguish `-` from `+` in the entry-count - // arithmetic). - // ----------------------------------------------------------------- + // low/high/npairs — existing tests only cover the all-zero case, which + // can't distinguish `-` from `+` in the entry-count arithmetic. #[test] fn instruction_size_tableswitch_non_degenerate_range() { diff --git a/src/labels/clpi_audit.rs b/src/labels/clpi_audit.rs index e4fe3982..c54a0113 100644 --- a/src/labels/clpi_audit.rs +++ b/src/labels/clpi_audit.rs @@ -116,10 +116,9 @@ impl ClpiVsMplsAudit { /// merged view. Missing files (read errors, parse failures) are /// silently skipped — this is diagnostic, not correctness-critical. pub fn audit(reader: &mut dyn SectorSource, udf: &UdfFs) -> ClpiVsMplsAudit { - // Aggregate by PID across all CLPI files. If a PID appears in - // multiple clips (typical — main movie clip + trailers reference - // the same audio stream PIDs), first encountered wins (they should - // all agree per BD spec). + // Aggregate by PID across all CLPI files. A PID commonly repeats across + // clips (main movie + trailers sharing audio PIDs); first encountered + // wins, since they should all agree per BD spec. let mut clpi_by_pid: BTreeMap<u16, (u8, String)> = BTreeMap::new(); if let Some(dir) = udf.find_dir("/BDMV/CLIPINF") { let names: Vec<String> = dir diff --git a/src/labels/criterion.rs b/src/labels/criterion.rs index 93437e88..ad09fc2a 100644 --- a/src/labels/criterion.rs +++ b/src/labels/criterion.rs @@ -101,10 +101,8 @@ fn assign_stream_numbers( const NUMBER_SPACE_END: u32 = u16::MAX as u32 + 1; // Numbers already claimed by the map, per type. A map value of 0 is NOT a - // claim: apply_labels binds on 1-based stream numbers, so 0 is unmatchable. - // Treat 0 as "unmapped" here (defense in depth — parse_playback_config also - // filters it) so such a stream gets a real synthesized number instead of an - // orphan 0 that collides with / shadows a genuine stream 1. + // claim (apply_labels binds 1-based numbers, so 0 is unmatchable); treat it + // as unmapped so the stream gets a real number instead of colliding with 1. let mut taken_audio: Vec<u16> = Vec::new(); let mut taken_sub: Vec<u16> = Vec::new(); for info in infos { @@ -646,9 +644,8 @@ mod tests { assert_eq!(map.get("sub1").copied(), Some(3)); } - /// Spec: high confidence is returned when streamproperties.xml is fully - /// structured (no fallback). This is the Criterion parser's claim. - /// Mutation: change to ParseResult::medium → confidence assertion fails. + /// Spec: `LangInfoID` values are lowercased so they match + /// `apply_labels`' lookup (an uppercase "ENG" must parse as "eng"). #[test] fn parse_stream_infos_language_lowercased() { // LangInfoID values must be lowercased so they match apply_labels' lookup. diff --git a/src/labels/ctrm.rs b/src/labels/ctrm.rs index 328163bd..0c9ca2cb 100644 --- a/src/labels/ctrm.rs +++ b/src/labels/ctrm.rs @@ -56,11 +56,9 @@ fn merge(ls: Vec<StreamLabel>, mb: Vec<StreamLabel>) -> Vec<StreamLabel> { label.name = mb_match.name.clone(); } } - // Append any menu_base-only stream (present in mb but not in ls by - // (stream_type, stream_number)). Without this the both-files path - // silently drops streams the menu_base-only path would have emitted: - // language_streams is authoritative for type/purpose but is not - // necessarily a superset of menu_base. + // Append menu_base-only streams (present in mb but not in ls by + // (stream_type, stream_number)); language_streams is authoritative for + // type/purpose but not necessarily a superset of menu_base. for mb_label in mb { let already = result.iter().any(|l| { l.stream_type == mb_label.stream_type && l.stream_number == mb_label.stream_number @@ -197,11 +195,9 @@ fn parse_language_streams_text(text: &str) -> Vec<StreamLabel> { "csp" | "cs" | "lsp" | "ls" | "cf" | "pf" | "bp" | "pp" => { variant_code = variant.clone(); } - // Everything else: defer to vocab::codec as the single - // source of codec-name truth. If it recognizes the token - // (returns something other than the input) it's a known - // codec — store the canonical name. Otherwise it's an - // unknown token, stored as-is. + // Everything else: defer to vocab::codec as the single source of + // codec-name truth — a known token gets its canonical name, + // otherwise it's stored as-is. _ => codec_hint = vocab::codec(&variant).to_string(), } } @@ -222,10 +218,9 @@ fn parse_language_streams_text(text: &str) -> Vec<StreamLabel> { labels } -// NOTE: `parse_menu_base` / `parse_menu_base_text` are defined just below this -// module and structurally belong above it. They are left in place (with the -// lint allowed) rather than relocated here — a ~120-line block move that is -// safer to do as its own focused change than inline. +// NOTE: `parse_menu_base` / `parse_menu_base_text` structurally belong above this +// module but are left below (lint allowed) — a ~120-line move is safer as its +// own focused change. #[allow(clippy::items_after_test_module)] #[cfg(test)] mod tests { @@ -311,10 +306,9 @@ mod tests { #[test] fn dual_flag_entry_resolves_to_audio_with_no_subtitle_qualifier() { - // An entry tripping BOTH flags (audio_ prefix sets is_audio, - // class "SubtitleButton" sets is_subtitle). Audio wins the type, - // and the subtitle qualifier (SDH) must NOT be carried onto the - // resulting Audio label. Regression for the type/qualifier split. + // An entry tripping BOTH flags (audio_ prefix sets is_audio, class + // "SubtitleButton" sets is_subtitle): audio wins the type, and the + // subtitle qualifier (SDH) must NOT carry onto the Audio label. let labels = parse_props( "audio_1.class=SubtitleButton\n\ audio_1.streamNumber=6\n\ @@ -885,11 +879,9 @@ fn parse_menu_base_text(text: &str) -> Vec<StreamLabel> { let name = props.get("name").cloned().unwrap_or_default(); - // Purpose: ask vocab first (word-boundary matched — avoids the - // "Commenter" false positive the prior `name.contains("comment")` - // had). Then fall back to the structural prefix check - // (`audio_commentary.foo`-style keys group commentary streams - // regardless of display name). + // Ask vocab first (word-boundary matched, avoiding the "Commenter" + // false positive of the old `name.contains("comment")`), then fall + // back to the structural prefix check (`audio_commentary.foo`-style). let purpose = match vocab::purpose(&name) { LabelPurpose::Normal if prefix_is_commentary(prefix) => LabelPurpose::Commentary, p => p, diff --git a/src/labels/dbp.rs b/src/labels/dbp.rs index c6bdfe6f..907c3cb5 100644 --- a/src/labels/dbp.rs +++ b/src/labels/dbp.rs @@ -67,11 +67,9 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> } fn scan_jar(archive: &mut jar::Jar) -> Vec<StreamLabel> { - // BTreeMap so we keep the highest-numbered (last-written) label - // for each stream slot deterministic across runs. The same - // TextField,Audio1,... string can appear in multiple classes - // (button-state variants, localization fallbacks). Last write - // wins — they should all agree, but the structure is defensive. + // BTreeMap keeps the last-written label per stream slot deterministic. + // The same TextField,Audio1,... string can appear in multiple classes + // (button-state variants, fallbacks); last write should agree, but wins defensively. let mut audios: BTreeMap<u16, String> = BTreeMap::new(); let mut subs: BTreeMap<u16, String> = BTreeMap::new(); diff --git a/src/labels/deluxe.rs b/src/labels/deluxe.rs index c1b3050f..eeb3499d 100644 --- a/src/labels/deluxe.rs +++ b/src/labels/deluxe.rs @@ -83,10 +83,9 @@ use crate::udf::UdfFs; use std::collections::{BTreeMap, HashMap, HashSet}; pub fn detect(reader: &mut dyn SectorSource, udf: &UdfFs) -> bool { - // The real signal is `com/bydeluxe/` inside a top-level jar's central - // directory. With a reader in detect we check it directly (cheap - // central-directory scan, no bytecode walk) so this parser claims only - // Deluxe discs; `parse()` repeats the check. + // Real signal is `com/bydeluxe/` in a jar's central directory: a cheap + // scan, no bytecode walk, so this parser claims only Deluxe discs. + // `parse()` repeats the check. jar::for_each_jar(reader, udf, |_entry, archive| { jar::has_path_prefix(archive, "com/bydeluxe/").then_some(()) }) @@ -94,11 +93,9 @@ pub fn detect(reader: &mut dyn SectorSource, udf: &UdfFs) -> bool { } pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> { - // Per-studio dispatch signal. Deluxe authored the framework for several - // studios (Universal `studio="uni"`, Fox, WB, Disney/Pixar); each ships a - // `/BDMV/JAR/<n>/config.xml` naming the studio. The structural enum/binding - // matching below is studio-agnostic, so this is currently informational - // (logged, and available for future per-studio special-casing), not a gate. + // Per-studio dispatch signal: Deluxe authored the framework for several + // studios, each shipping `/BDMV/JAR/<n>/config.xml` naming the studio. + // Matching below is studio-agnostic, so this is informational only (logged). let studio = detect_studio(reader, udf); jar::for_each_jar(reader, udf, |entry_name, archive| { @@ -128,10 +125,9 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> // Build a fast-lookup table for Phase D's bytecode decoder. let master_table = MasterEnumTable::from(&enums); - // Phase C — find ALL binding-class candidates (audio + subtitle - // are often split across two classes on Deluxe). Each gets its - // own `<clinit>` walk; constructions union into a single - // stream list for interpret_streams. + // Phase C — find ALL binding-class candidates (audio + subtitle often + // split across two classes on Deluxe). Each gets its own `<clinit>` + // walk; constructions union into a single stream list. let binding_classes = find_binding_classes(archive, &master_table.class_name_set()); if binding_classes.is_empty() { tracing::info!( @@ -180,11 +176,9 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> subtitle = labels.iter().filter(|l| l.stream_type == StreamLabelType::Subtitle).count(), "deluxe emitted labels", ); - // High confidence: the master enums matched their framework-stable - // fingerprints, the binding class decoded its `<clinit>`, and at least - // one per-stream binding resolved to a real (language, purpose, codec) - // tuple. Un-named STN slots are still back-filled from the MPLS floor - // by the registry (`merge_mpls_floor`). + // High confidence: master enums matched, binding class decoded, and at + // least one binding resolved to a real (language, purpose, codec) tuple. + // Un-named STN slots are back-filled from the MPLS floor by the registry. Some(ParseResult::high(labels)) }) } @@ -399,13 +393,9 @@ impl CandidatePool { /// enums by `<clinit>` ldc-sequence fingerprint. Returns a vector of /// `(label, MasterEnum)` — at most one match per fingerprint label. fn identify_master_enums(archive: &mut jar::Jar) -> Vec<(&'static str, MasterEnum)> { - // First pass: collect every class's <clinit> ldc string sequence, keyed by - // the class's JVM INTERNAL name (`this_class`), NOT the zip entry name. - // The binding class references an enum constant as `getstatic <internal>.f` - // (e.g. `pd`, or `com/foo/pd`), so the master enum has to be identified by - // that same internal name or Phase C's getstatic count never matches it. - // The zip entry name (`pd.class`) is a different namespace, used only to - // locate a class for decoding. + // First pass: collect every class's <clinit> ldc strings, keyed by the JVM + // INTERNAL name (`this_class`), not the zip entry name — binding classes + // reference enum constants as `getstatic <internal>.f`. let mut pool = CandidatePool::default(); jar::for_each_class(archive, |zip_name, class| { let Some(ldcs) = clinit_ldc_strings(class) else { @@ -456,10 +446,9 @@ fn identify_master_enums(archive: &mut jar::Jar) -> Vec<(&'static str, MasterEnu return Vec::new(); } - // Second pass: capture the obfuscated `putstatic` field names for the - // classes we matched, so Phase D can resolve `getstatic <enum>.<field>` - // to an ordinal. This is a targeted re-walk of only the matched enum - // classes (at most one per fingerprint), not the whole jar again. + // Second pass: capture obfuscated `putstatic` field names for matched + // classes so Phase D can resolve `getstatic <enum>.<field>` to an ordinal. + // Targeted re-walk of only the matched classes, not the whole jar again. let want: HashSet<&str> = out.iter().map(|(_, name, _)| name.as_str()).collect(); let mut fields_by_class: HashMap<String, Vec<String>> = HashMap::new(); jar::for_each_class(archive, |zip_name, class| { @@ -607,12 +596,6 @@ fn ldcs_match_prefix(ldcs: &[String], prefix: &[&str]) -> bool { // ── Phase C: find the binding class ───────────────────────────────────────── -/// Phase C: identify the class that builds the per-stream label table. -/// That class has the highest count of `getstatic` operations whose -/// owning class is one of the master enum classes we identified in -/// Phase A. Returns the class name + the count (useful for the -/// analyzer / corpus regression). -/// /// Threshold: requires at least `MIN_GETSTATIC` matches to consider a /// class a binding candidate. Empirically the binding class on a /// typical disc has 50+ such getstatic references (one per slot × @@ -751,10 +734,9 @@ fn decode_binding( master: &MasterEnumTable, ) -> Vec<Construction> { let target_name = binding_class_name.to_string(); - // Short-circuit on the name match: try_each_class stops iterating - // (and stops decompressing/parsing remaining .class entries) as soon - // as the closure returns Some, instead of walking the whole jar past - // the target. + // Short-circuit on the name match: try_each_class stops iterating (and + // decompressing/parsing remaining .class entries) once the closure + // returns Some, instead of walking the whole jar past the target. jar::try_each_class(archive, |class_name, class| { if class_name != target_name { return None; @@ -910,10 +892,8 @@ impl<'a> BindingDecoder<'a> { .cp_index() .and_then(|i| self.pool.member_ref(i)) .map(|m| { - // Three-way resolution: - // 1. org.bluray.ti.CodingType.X → CodingType(X) - // 2. master-enum classname.X → EnumRef(kind, ord) - // 3. anything else → Unknown + // Resolution: CodingType.X → CodingType(X); master-enum + // classname.X → EnumRef(kind, ord); else Unknown. if m.class_name == BD_CODING_TYPE_CLASS { StackVal::CodingType(m.name.to_string()) } else if let Some((kind, ord)) = self.master.resolve(m.class_name, m.name) @@ -926,30 +906,20 @@ impl<'a> BindingDecoder<'a> { .unwrap_or(StackVal::Unknown); self.push(val); } - // invokespecial X.<init>(...) — pop args per descriptor. - // If the object on the stack underneath the args is a - // NewObj of class X (set by an earlier `new X / dup`), - // emit a Construction. + // invokespecial X.<init>(...) — pop args per descriptor. If the + // object underneath the args is a NewObj of class X (set by an + // earlier `new X / dup`), emit a Construction. INVOKESPECIAL => { let Some(idx) = insn.cp_index() else { return }; let Some(member) = self.pool.member_ref(idx) else { return }; let arg_count = parse_method_arg_count(member.descriptor); - // A per-stream binding constructor takes only scalars and enum - // references — never an array. A constructor with an array - // parameter is a container/title wrapper (e.g. Universal's - // title object `oq.<init>(…, [Lnp;, [Lwb;, [Loq;, [J)`, which - // holds the per-stream arrays) and must NOT be recorded as a - // stream binding: it carries a Language (the title's primary - // language) but is not itself a stream, and emitting it would - // land a spurious label in the subtitle list. The stack is - // still unwound below so the following real bindings stay in - // sync; only the `Construction` push is suppressed. + // A per-stream binding ctor takes only scalars/enum refs, never an + // array; a ctor with an array param is a container/title wrapper and + // must not be recorded as a stream binding (stack still unwinds below). let is_container = member.descriptor.contains('['); - // Pop args off the symbolic stack. if self.stack.len() < arg_count + 1 { - // Stack-machine drift — bail on this construction - // (but don't panic; the walker tolerates malformed - // input by best-effort). + // Stack-machine drift: bail on this construction without + // panicking; the walker tolerates malformed input best-effort. self.stack.clear(); return; } @@ -1000,11 +970,9 @@ impl<'a> BindingDecoder<'a> { self.stack.pop(); self.stack.pop(); } - // anewarray / newarray — pop the count, push the array reference. - // Modelled (rather than left to the `_` no-op) so the array - // constructions inside a container's argument list keep the - // symbolic stack aligned for the per-stream bindings built - // alongside them. + // anewarray / newarray — pop count, push array ref. Modelled (not + // left to `_`) so array constructions inside a container's arg list + // keep the symbolic stack aligned for per-stream bindings built alongside. 0xBD /* anewarray */ | 0xBC /* newarray */ => { self.stack.pop(); self.push(StackVal::Unknown); @@ -1023,10 +991,9 @@ impl<'a> BindingDecoder<'a> { self.stack.pop(); self.stack.pop(); } - // Branches / returns / unhandled — clear stack as a - // conservative resync. Binding `<clinit>` is straight- - // line code in practice, so we rarely hit these on the - // verified pattern. + // Branches/returns/unhandled — clear stack as a conservative resync. + // Binding `<clinit>` is straight-line code in practice, so these + // are rarely hit on the verified pattern. 0xA7 /* goto */ | 0xB1 /* return */ => { self.stack.clear(); } @@ -1093,12 +1060,9 @@ impl MasterEnumTable { let mut by_class = HashMap::new(); let mut by_kind = HashMap::new(); for (kind, m) in enums { - // The binding class references enum constants by their obfuscated - // static-field name (`getstatic <enum>.a`), so the resolver map is - // keyed on the `putstatic` field names captured alongside the - // values (`m.fields`), NOT on the value strings. A synthetic test - // enum with no captured field names falls back to keying on the - // values themselves, which is how those tests reference it. + // Binding classes reference constants by obfuscated field name + // (`getstatic <enum>.a`), so the resolver keys on captured `putstatic` + // field names (`m.fields`); a synthetic test enum falls back to values. let keys: &[String] = if m.fields.is_empty() { &m.values } else { @@ -1197,16 +1161,9 @@ fn interpret_streams(constructions: &[Construction], master: &MasterEnumTable) - } let Some(lang_ord) = lang_ord else { - // No language resolved. If the construction is still recognisably - // a stream binding it OCCUPIES its STN slot and must advance the - // counter — there is just nothing to label. Numbering only the - // slots that resolve renumbers the rest 1..N and lands every - // surviving label on the wrong stream. - // - // `saturating_add` is safe here where it would not be on the - // emitting path below: no label is produced, so parking the - // counter at `u16::MAX` binds nothing. The next slot that DOES - // resolve hits the `checked_add` guard and stops emission. + // A recognisable stream binding still occupies its STN slot and must + // advance the counter, or renumbering skews every surviving label. + // `saturating_add` is safe here since no label is produced. match slot_kind(c, coding_type.is_some(), &slot_kinds) { Some(StreamLabelType::Audio) => audio_idx = audio_idx.saturating_add(1), Some(StreamLabelType::Subtitle) => sub_idx = sub_idx.saturating_add(1), @@ -1226,13 +1183,9 @@ fn interpret_streams(constructions: &[Construction], master: &MasterEnumTable) - .map(str::to_string) .unwrap_or_default(); - // Neither `+= 1` (panics in debug, wraps in release) nor - // `saturating_add` is correct here. Saturation is what turned an - // overflow guard into a non-terminating loop in `criterion`, and here - // it would peg every stream past 65535 at the SAME number — silently - // mislabelling tracks, since `apply_labels` binds on - // `(type, stream_number)`. The 1-based u16 numbering space is a hard - // ceiling, so exhausting it stops label emission instead. + // Neither `+= 1` nor `saturating_add` is correct: saturation caused a + // non-terminating loop in `criterion` and would peg every overflowing + // stream at the SAME number. Exhausting the 1-based u16 space stops emission. let (stream_type, stream_number) = if coding_type.is_some() { let Some(n) = audio_idx.checked_add(1) else { tracing::warn!( @@ -1268,11 +1221,9 @@ fn interpret_streams(constructions: &[Construction], master: &MasterEnumTable) - Some(o) => deluxe_purpose_to_label(o), None => (LabelPurpose::Normal, LabelQualifier::None), }; - // Some frameworks (notably Universal) do not carry SDH/RNIB in the - // Purpose enum at all — they encode it as a distinct Language enum - // VALUE ("English SDH", "English RNIB"). When the purpose left the - // qualifier unset, recover it from the language display name so those - // tracks are still flagged. + // Some frameworks (Universal) encode SDH/RNIB as a distinct Language + // VALUE ("English SDH") rather than in the Purpose enum. When the + // purpose left the qualifier unset, recover it from the language name. if qualifier == LabelQualifier::None { qualifier = vocab::qualifier(&lang_value); } @@ -1421,14 +1372,9 @@ fn deluxe_purpose_to_label(ordinal: u16) -> (LabelPurpose, LabelQualifier) { mod tests { use super::*; - // ── Raw .class / .jar fixture builders ────────────────────────────────── - // - // `identify_master_enums`, `find_binding_classes` and `decode_binding` - // operate on `jar::Jar` (a real `ZipArchive`), not on the in-memory - // `ClassFile` struct the rest of this module's tests build directly (see - // `class_with_clinit`). To exercise them we need real serialized - // `.class` bytes inside a real (stored, uncompressed) zip — this is the - // inverse of `ClassFile::parse` / JVMS §4. + // Raw .class/.jar fixture builders: `identify_master_enums`, `find_binding_classes` + // and `decode_binding` operate on `jar::Jar` (a real `ZipArchive`), not the + // in-memory `ClassFile` struct other tests build, so these need real `.class` bytes. /// Serialize a constant pool (no Long/Double entries — those need the /// post-slot `Empty` padding this helper doesn't handle) to the on-disk @@ -1789,10 +1735,8 @@ mod tests { #[test] fn fingerprints_cover_documented_enums() { - // Lock the fingerprint roster — if someone adds/removes a - // fingerprint, this test forces them to think about it. The - // 5 documented enums (Language, Purpose, VideoFormat, Region, - // Studio) all need to be here. Codec is structural (separate + // Lock the fingerprint roster so adding/removing one is deliberate. + // All 5 documented enums must be here; Codec is structural (separate // path), not fingerprinted by ldc prefix. let labels: Vec<&str> = FINGERPRINTS.iter().map(|fp| fp.label).collect(); assert_eq!( @@ -1803,10 +1747,9 @@ mod tests { #[test] fn fingerprint_prefixes_nonempty_and_under_expected_count() { - // Each prefix must be non-empty and shorter than expected_count - // (so the count gives ADDITIONAL signal beyond the prefix - // match). If a prefix is as long as expected_count there's no - // counting benefit. + // Each prefix must be non-empty and shorter than expected_count so the + // count gives additional signal beyond the prefix match; a prefix as + // long as expected_count has no counting benefit. for fp in FINGERPRINTS { assert!(!fp.prefix.is_empty(), "{} has empty prefix", fp.label); assert!( @@ -1821,10 +1764,9 @@ mod tests { #[test] fn identify_master_enums_matches_purpose_fingerprint() { - // Exact match: 8 ldcs, first 4 = the Purpose prefix, count == - // expected_count exactly (abs_diff == 0). A decoy class with the - // same prefix but a wildly different count must be rejected and - // must NOT win over the exact match. + // Exact match: 8 ldcs, first 4 = Purpose prefix, count == expected_count + // exactly. A decoy class with the same prefix but a wildly different + // count must be rejected and must NOT win over the exact match. let good = class_with_ldc_strings( "GoodPurpose", &[ @@ -1923,10 +1865,9 @@ mod tests { #[test] fn identify_master_enums_accepts_count_at_the_tolerance_boundary() { - // abs_diff(expected_count, count) == LDC_COUNT_TOLERANCE (4) exactly - // must still be accepted (`> tolerance` rejects, so `== tolerance` - // is the last accepted value). This is the boundary `327:50` - // mutants (`>` -> `==`/`<`/`>=`) disagree on. + // abs_diff(expected_count, count) == LDC_COUNT_TOLERANCE (4) exactly must + // still be accepted (`> tolerance` rejects, so `== tolerance` is the last + // accepted value) — the boundary `>` vs `==`/`<`/`>=` mutants disagree on. let mut values: Vec<&str> = vec!["Normal", "Commentary", "PiP", "Trivia"]; let filler: Vec<String> = (0..8).map(|i| format!("Filler{i}")).collect(); // 4+8=12, diff=4 values.extend(filler.iter().map(String::as_str)); @@ -1943,9 +1884,8 @@ mod tests { #[test] fn identify_master_enums_finds_nothing_without_com_bydeluxe_signal() { - // No FINGERPRINTS-matching class in the jar at all -> empty result - // (kills the `vec![]` mutant only vacuously if paired with the - // positive tests above proving non-emptiness on a real match). + // No FINGERPRINTS-matching class in the jar -> empty result (kills the + // `vec![]` mutant only vacuously, paired with positive tests above). let unrelated = class_with_ldc_strings("Unrelated", &["Foo", "Bar"]); let zip = build_zip(&[("x/Unrelated.class", unrelated)]); let mut archive = open_jar(zip); @@ -1972,16 +1912,8 @@ mod tests { #[test] fn find_binding_classes_picks_top_candidates_above_threshold() { - // Class A: 100 getstatic refs (the top / binding class). B: 45 - // (>40% of top, kept). F: 40 (EXACTLY the 40% threshold — pins - // both the `(top_count * 2) / 5` arithmetic and the `>=` - // comparison: any of the `460`/`461` arithmetic mutants shift - // the threshold away from exactly 40, and a `>= -> <` mutant at - // 461 would drop this exact-boundary entry). E: 39 (just BELOW - // the true 40% threshold — a mutant that shrinks the threshold - // below 39 would wrongly keep this). C: 10 (well below, always - // dropped). D: 3 — below MIN_GETSTATIC(4), never even a raw - // candidate. + // A: 100 refs (top). B: 45 (>40% of top, kept). F: 40 (EXACTLY the 40% + // threshold). E: 39 (just below, dropped). D: 3 (below MIN_GETSTATIC(4)). let master_classes: HashSet<&str> = ["LanguageEnum"].into_iter().collect(); let a = class_with_getstatic_refs("A", "LanguageEnum", 100); let b = class_with_getstatic_refs("B", "LanguageEnum", 45); @@ -2027,15 +1959,9 @@ mod tests { #[test] fn decode_binding_finds_named_class_and_stops_at_first_match() { - // `decode_binding` matches by the Jar entry path (the same string - // `find_binding_classes` returns), not by the class's own - // `this_class` name. Two entries: the target path carries a real - // `new AudioSlot; dup; getstatic; invokespecial` construction; a - // decoy at a different path carries none (and, being pure - // getstatic/pop, would also match nothing if walked). If the name - // comparison is broken (`!=` mutated to `==`), decode_binding would - // either never match the real target (empty result) or would match - // and decode the WRONG entry. + // Matches by Jar entry path, not `this_class` name. Target path has a + // real construction; decoy has none. A broken path comparison would + // match nothing or decode the WRONG entry. let target = class_with_simple_construction("Ignored"); let decoy = class_with_getstatic_refs("Ignored2", "LanguageEnum", 2); let zip = build_zip(&[ @@ -2141,12 +2067,9 @@ mod tests { #[test] fn clinit_ldc_string_count_is_capped() { - // A `.class` gated only by a `com/bydeluxe/` path prefix deflates from - // ~100 KB up to the 64 MiB MAX_CLASS_BYTES ceiling, giving ~33M 2-byte - // `ldc` instructions. Every resolved operand is retained as an owned - // String, and identify_master_enums keeps the whole vector per class in - // a HashMap — so the allocation scales with the DECOMPRESSED size while - // the only byte cap is on the compressed disc file. + // A `.class` gated only by path prefix deflates up to the 64 MiB + // MAX_CLASS_BYTES ceiling (~33M 2-byte `ldc` instructions, each retained + // as an owned String), so allocation scales with decompressed size. const N: usize = 200_000; let mut code = Vec::with_capacity(N * 2); for _ in 0..N { @@ -2155,12 +2078,9 @@ mod tests { } let class = class_with_clinit(ldc_pool("English"), 2, &code); let ldcs = clinit_ldc_strings(&class).expect("<clinit> present"); - // Asserted against a LITERAL, not against MAX_CLINIT_LDC_STRINGS. A test - // that compares the result to the very constant under test passes - // vacuously the moment someone raises that constant — which is the most - // likely future regression here, and exactly the tautology class this - // audit has now found six times. 8192 is double the current cap, so this - // still allows the cap to be tuned, but not removed. + // Asserted against a LITERAL, not MAX_CLINIT_LDC_STRINGS: comparing to the + // constant under test passes vacuously once someone raises it. 8192 is + // double the current cap, allowing the cap to be tuned but not removed. assert!( ldcs.len() <= 8192, "retained {} ldc strings from {N} ldc instructions — the cap is not \ @@ -2197,12 +2117,9 @@ mod tests { #[test] fn clinit_ldc_string_bytes_boundary_matches_256kib_not_1280() { - // `MAX_CLINIT_LDC_BYTES = 256 * 1024` (262144). A `* -> +` mutant at - // that computation collapses the cap to `256 + 1024` (1280) — 205x - // smaller. 1000-byte strings make the two cap values discriminate - // sharply: correct code retains 262 of them (262000 bytes, the - // 263rd would push to 263000 > 262144); the mutant retains only 1 - // (the 2nd would push to 2000 > 1280). + // `MAX_CLINIT_LDC_BYTES = 256 * 1024`. A `* -> +` mutant collapses the + // cap to `256 + 1024` (1280), 205x smaller. 1000-byte strings discriminate + // sharply: correct code retains 262 (262000 bytes); mutant retains only 1. const N: usize = 400; let one = "x".repeat(1000); let mut code = Vec::with_capacity(N * 2); @@ -2442,10 +2359,9 @@ mod tests { #[test] fn binding_decoder_stack_is_bounded_by_max_stack() { - // ~67M single-byte `iconst_0` fit in a 64 MiB decompressed class, and - // each pushes a StackVal onto a Vec with no depth limit (~2 GiB). The - // Code attribute's own max_stack is parsed and must be honoured: JVMS - // 4.7.3 requires the operand stack never exceed it. + // ~67M single-byte `iconst_0` fit in a 64 MiB decompressed class, each + // pushing a StackVal with no depth limit (~2 GiB). Code's max_stack must + // be honoured: JVMS 4.7.3 requires the operand stack never exceed it. const MAX_STACK: u16 = 4; let code = vec![ICONST_0; 200_000]; let pool = build_simple_pool(); @@ -2524,24 +2440,9 @@ mod tests { #[test] fn decode_binding_class_finds_the_clinit_method_and_emits_its_construction() { - // decode_binding_class wraps BindingDecoder over every method literally - // named "<clinit>" on the class. Exercises the method-selection - // (`member_name(m) != Some("<clinit>")`) and per-method-union - // truncation (`room == 0`) logic that decode_binding_class adds on - // top of the already-tested BindingDecoder::step/run. - // - // Pool layout (must hold "<clinit>"/"()V"/"Code" at 1/2/3 per - // `class_with_clinit`'s contract, while ALSO matching the fixed cp - // indices — 6/8/12 — the reused `new AudioSlot; dup; getstatic; - // invokespecial` bytecode below references): - // 1 Utf8 "<clinit>" 2 Utf8 "()V" 3 Utf8 "Code" - // 4 Utf8 "LanguageEnum" 5 Class->4 - // 6 Fieldref{class:5,nat:9} 7 Utf8 "English" - // 8 Class->10 (AudioSlot) 9 NameAndType{name:7,desc:11} - // 10 Utf8 "AudioSlot" 11 Utf8 "LLanguageEnum;" - // 12 Methodref{class:8,nat:13} - // 13 NameAndType{name:14,desc:15} - // 14 Utf8 "<init>" 15 Utf8 "(LLanguageEnum;)V" + // Exercises method-selection and per-method-union truncation logic. + // Pool must hold "<clinit>"/"()V"/"Code" at 1/2/3 per `class_with_clinit`, + // and match cp indices 6/8/12 used by the reused construction bytecode. let pool = ConstantPool::from_entries(vec![ CpInfo::Empty, CpInfo::Utf8("<clinit>".into()), @@ -2598,11 +2499,8 @@ mod tests { #[test] fn binding_decoder_recognizes_simple_construction() { - // Synthetic <clinit>: - // new AudioSlot (cp idx 8 -> Class -> Utf8 "AudioSlot") - // dup - // getstatic Lang.Eng (cp idx 6 -> Fieldref) - // invokespecial AS.<init>(LLanguageEnum;)V (cp idx 12) + // Synthetic <clinit>: new AudioSlot (cp 8); dup; getstatic Lang.Eng (cp + // 6); invokespecial AS.<init>(LLanguageEnum;)V (cp 12). let code: Vec<u8> = vec![ NEW, 0, @@ -2640,12 +2538,8 @@ mod tests { #[test] fn binding_decoder_handles_iconst_and_bipush() { - // <clinit> with an int push before the construction: - // iconst_1 - // new AudioSlot; dup; getstatic Lang.Eng; invokespecial AS.<init>(LLanguageEnum;)V - // pop (drops the constructed object) - // bipush 42 - // pop + // <clinit>: iconst_1; new AudioSlot; dup; getstatic Lang.Eng; + // invokespecial AS.<init>(LLanguageEnum;)V; pop; bipush 42; pop. let code: Vec<u8> = vec![ ICONST_1, NEW, @@ -2679,13 +2573,9 @@ mod tests { #[test] fn binding_decoder_dup_duplicates_top_of_stack() { - // JVMS §3.11.7 `dup` (0x59): duplicate the top stack value. Checked - // directly on `decoder.stack` (not via emitted Constructions, which - // a single `new X; dup; invokespecial` sequence can satisfy either - // way — the leftover copy `dup` is responsible for only matters - // once something ELSE consumes it afterward). `new AudioSlot; dup` - // with no invokespecial must leave exactly two NewObj("AudioSlot") - // entries. + // JVMS §3.11.7 `dup` (0x59): duplicate top stack value, checked directly + // on `decoder.stack`. `new AudioSlot; dup` with no invokespecial must + // leave exactly two NewObj entries. let pool = build_simple_pool(); let master = lang_enum_master(); let code: Vec<u8> = vec![NEW, 0, 8, 0x59 /* dup */]; @@ -2732,12 +2622,9 @@ mod tests { #[test] fn binding_decoder_invokevirtual_pops_receiver_plus_args() { - // JVMS §6.5 `invokevirtual`/`invokeinterface` pop the receiver - // PLUS the descriptor's args (`extra = 1` for opcodes 0xB6/0xB9); - // `invokestatic` (0xB8) pops ONLY the args (no receiver). Each - // case below pushes exactly `to_pop` placeholder ints and checks - // the stack is fully drained — a wrong `extra`/`arg_count+extra` - // computation leaves a wrong number of leftovers. + // JVMS §6.5: invokevirtual/invokeinterface pop receiver PLUS args + // (`extra = 1` for 0xB6/0xB9); invokestatic (0xB8) pops ONLY args. Each + // case pushes exactly `to_pop` placeholders and checks full drain. let master = lang_enum_master(); let run_stack_len = |opcode: u8, descriptor: &str, n_pushes: usize| -> usize { let pool = call_ref_pool(descriptor); @@ -2783,10 +2670,9 @@ mod tests { 0, "invokestatic must pop exactly the arg count, no receiver" ); - // invokestatic with a leftover value UNDER the args: only the args - // are popped, the leftover survives. Distinguishes a `>` mutant at - // the `len < to_pop` guard (which would incorrectly `clear()` the - // whole stack here instead of leaving the leftover). + // invokestatic with a leftover value UNDER the args: only the args pop, + // the leftover survives. Distinguishes a `>` mutant at the `len < to_pop` + // guard (which would wrongly `clear()` the whole stack). assert_eq!( run_stack_len(0xB8, "(I)V", 2), // 1 leftover + 1 real arg pushed 1, @@ -2796,11 +2682,9 @@ mod tests { #[test] fn binding_decoder_invoke_family_defensively_clears_on_stack_underflow() { - // If the symbolic stack has FEWER entries than the call needs to - // pop (malformed/adversarial bytecode, or earlier drift), the - // decoder must defensively clear rather than underflow-subtract - // (`len - to_pop` with `len < to_pop` would panic on the `usize` - // subtraction). + // If the symbolic stack has FEWER entries than the call needs to pop + // (malformed bytecode or earlier drift), the decoder must defensively + // clear rather than underflow-subtract (`len - to_pop` would panic). let pool = call_ref_pool("(II)V"); // needs to_pop = 2 let code: Vec<u8> = vec![ICONST_0, 0xB8, 0, 6]; // only 1 value on stack let master = lang_enum_master(); @@ -2844,13 +2728,9 @@ mod tests { #[test] fn binding_decoder_int_push_opcodes_produce_the_right_value() { - // JVMS §3.11.3: iconst_<i> pushes exactly i (i in -1..=5); bipush - // sign-extends its i8 operand; sipush sign-extends its i16 operand; - // ldc of a CONSTANT_Integer pushes that constant. Each is checked - // as the sole arg of `new AudioSlot; dup; <push>; invokespecial - // AudioSlot.<init>(I)V` so a wrong (or absent, if the opcode's match - // arm were deleted) push shows up as a wrong (or missing/Unknown) - // arg value, not just "some construction happened". + // JVMS §3.11.3: iconst_<i> pushes exactly i; bipush/sipush sign-extend + // their operand; ldc of a CONSTANT_Integer pushes that constant. Each is + // checked as the sole ctor arg so a wrong/missing push arm is visible. let cases: Vec<(&str, Vec<u8>, i32)> = vec![ ("iconst_m1", vec![ICONST_M1], -1), ("iconst_0", vec![ICONST_0], 0), @@ -3211,19 +3091,9 @@ mod tests { assert_eq!(set.len(), 1); } - // ── Real-disc fixtures: Universal (studio="uni") ───────────────────────── - // - // Captured from a real Universal Blu-ray release's `/BDMV/JAR/00000.jar` - // (`com/bydeluxe/…`). - // These are verbatim, unmodified `.class` files — the format we READ, never - // execute — exercising the full Phase A→D pipeline against real obfuscated - // Deluxe bytecode rather than synthetic fixtures. - // - // pd.class = Language enum (65 values: English, French, Spanish, Dutch …) - // lp.class = Purpose enum (Normal, Commentary, PiP, Trivia, Descriptive, Score) - // tl.class = binding class: audio `np.<init>(I,Lpd;,Llp;,LCodingType;)`, - // subtitle `wb.<init>(I,Lpd;,Llp;,Lmi;)`, and a title-wrapper - // `oq.<init>(…,[Lnp;,[Lwb;,[Loq;,[J)` that must NOT leak a label. + // ── Real-disc fixtures: Universal (studio="uni") — verbatim `.class` files from + // a real `/BDMV/JAR/00000.jar`, exercising Phase A→D against real obfuscated + // bytecode: pd=Language enum, lp=Purpose enum, tl=binding class (np/wb/oq). const UNI_PD_CLASS: &[u8] = include_bytes!("testdata/deluxe_uni/pd.class"); const UNI_LP_CLASS: &[u8] = include_bytes!("testdata/deluxe_uni/lp.class"); const UNI_TL_CLASS: &[u8] = include_bytes!("testdata/deluxe_uni/tl.class"); diff --git a/src/labels/fox.rs b/src/labels/fox.rs index 8bec9bd7..f7a1ac21 100644 --- a/src/labels/fox.rs +++ b/src/labels/fox.rs @@ -277,24 +277,9 @@ fn normalize_language(raw: &str) -> String { raw.trim().to_ascii_lowercase() } -// ── Phase 2 (design only, not implemented here) ───────────────────────────── -// -// Newer Fox discs (later franchise sequels, surveyed as 11 discs currently mis-owned by -// the deluxe stub) ship NO loose `dcx.xml`. They wrap the same per-stream data -// inside `com/foxbd` BD-J `.class` bytecode. The follow-on parser would reuse -// `super::class_reader` the way `dbp`/`deluxe` already do: -// -// * `detect` already recognises the `com/foxbd/` central-directory prefix -// (above), so Phase 2 only adds a parse path. -// * Iterate the jar's classes via `super::jar::for_each_class` and read the -// constant pool (`ClassFile`), which holds the authored strings verbatim: -// - `FeatureIntroPlaylist.play: PLAYLIST ID:` → the feature playlist id -// (→ the same `FeaturePlaylistHint` the dcx path should wire up), -// - `RNIB` → a descriptive/narration audio track (→ Descriptive), -// - `primaryAudioId=` and the parallel subtitle-id constants → the STN -// slot numbering, mirroring the `id="NN"` mapping here. -// * Emit the identical `StreamLabel` shape at High confidence so the two Fox -// forms are indistinguishable downstream. +// Phase 2 (design only, not implemented): newer Fox discs ship no loose `dcx.xml`, +// wrapping the same per-stream data in `com/foxbd` BD-J `.class` bytecode instead. A +// follow-on parser would reuse `super::class_reader`/`jar` (as `dbp`/`deluxe` do). #[cfg(test)] mod tests { diff --git a/src/labels/jar.rs b/src/labels/jar.rs index 902dac65..10c37572 100644 --- a/src/labels/jar.rs +++ b/src/labels/jar.rs @@ -74,12 +74,12 @@ pub fn has_path_prefix(archive: &Jar, prefix: &str) -> bool { } /// Iterate every `.class` entry in the jar, parse it with -/// [`class_reader`], and call `f` with `(entry_name, &ClassFile)`. +/// [`class_reader`](super::class_reader), and call `f` with `(entry_name, &ClassFile)`. /// /// Entries that fail to read or parse are silently skipped — this is /// label-extraction code, robustness matters more than completeness. /// Callers that need to know which classes failed should use the -/// lower-level [`class_reader`] API directly. +/// lower-level [`class_reader`](super::class_reader) API directly. pub fn for_each_class<F>(archive: &mut Jar, mut f: F) where F: FnMut(&str, &ClassFile), diff --git a/src/labels/mod.rs b/src/labels/mod.rs index 14574911..1cc7ba15 100644 --- a/src/labels/mod.rs +++ b/src/labels/mod.rs @@ -119,15 +119,12 @@ pub enum LabelQualifier { } // ── Parser registry ──────────────────────────────────────────────────────── -// -// Each entry: (name, detect_fn, parse_fn). Order = tiebreaker only — -// the registry picks the highest-confidence parse result, falling back -// to array order on confidence ties. +// Each entry: (name, detect_fn, parse_fn). Order = tiebreaker only — the +// registry picks the highest-confidence parse result, falling back to order. // `detect` takes the reader too, so a parser can look INSIDE a jar's central // directory (real vendor-prefix / project-file check) rather than firing on -// "any jar present". Precise detection is what lets the registry scale to many -// parsers without cross-parser collisions. +// "any jar present", letting the registry scale without cross-parser collisions. type DetectFn = fn(&mut dyn SectorSource, &UdfFs) -> bool; type ParseFn = fn(&mut dyn SectorSource, &UdfFs) -> Option<ParseResult>; @@ -233,26 +230,18 @@ const PARSERS: &[(&str, DetectFn, ParseFn)] = &[ ("criterion", criterion::detect, criterion::parse), ("pixelogic", pixelogic::detect, pixelogic::parse), ("ctrm", ctrm::detect, ctrm::parse), - // dbp and deluxe now detect via the real `com/<vendor>/` central-directory - // prefix (reader-backed), so they claim only their own discs. Order between - // them is the tiebreaker on equal confidence; dbp goes first because its - // parse path is cheaper (constant-pool iteration vs. deluxe's bytecode - // walking). + // dbp and deluxe detect via the real `com/<vendor>/` central-directory + // prefix (reader-backed), so they claim only their own discs. dbp goes + // first on ties: its parse path is cheaper (constant-pool vs. bytecode). ("dbp", dbp::detect, dbp::parse), ("deluxe", deluxe::detect, deluxe::parse), - // Fox — older discs ship a loose plain-XML `/BDMV/JAR/<id>/dcx.xml` - // manifest (root `<dcx>`) with per-stream language/purpose/qualifier. - // High confidence: fully structured, no guessing. Registered ahead of - // the universal MPLS fallback so its editorial labels win. Newer Fox - // wraps the same data in `com/foxbd` bytecode (see fox.rs Phase 2 note), - // which this parser detects but does not yet decode. + // Fox — older discs ship a loose plain-XML `/BDMV/JAR/<id>/dcx.xml` manifest + // with per-stream language/purpose/qualifier (High confidence). Newer Fox + // wraps this in `com/foxbd` bytecode (fox.rs Phase 2); detected, not yet decoded. ("fox", fox::detect, fox::parse), - // Universal MPLS fallback. Returns Confidence::Low so framework - // parsers always win when they match. Closes the "no framework - // matched" gap (e.g. HDMV-only discs) with spec-derived language - // + base codec for every stream the playlist references. Runs - // last in registry order so it's only the chosen parser when - // nothing else fired. + // Universal MPLS fallback. Returns Confidence::Low so framework parsers + // always win when they match. Closes the "no framework matched" gap (e.g. + // HDMV-only discs) with spec-derived language + codec for every stream. ( "mpls_universal", mpls_universal::detect, @@ -260,8 +249,7 @@ const PARSERS: &[(&str, DetectFn, ParseFn)] = &[ ), // Menu-graphic filename language hints (Low). AFTER mpls_universal so the // richer spec-derived floor wins the Low tie whenever it produces anything; - // this only becomes the chosen parser when even MPLS yields nothing but the - // menu artwork still names its languages. A last-resort language source. + // only chosen when even MPLS yields nothing but the menu art still names languages. ("png_filenames", png_filenames::detect, png_filenames::parse), ]; @@ -484,10 +472,9 @@ fn anchor_score( pub(crate) fn apply_labels(labels: &[StreamLabel], titles: &mut [DiscTitle]) { use std::collections::HashMap; - // `(clip id, PID) -> index into `labels``, harvested from the anchor - // title of each stream type. Keyed by clip because a PID is only unique - // within one clip: two unrelated .m2ts files both open their audio at - // 0x1100. + // `(clip id, PID) -> index into `labels``, harvested from the anchor title + // of each stream type. Keyed by clip because a PID is only unique within + // one clip: two unrelated .m2ts files both open their audio at 0x1100. let mut pid_map: HashMap<(&str, u16), usize> = HashMap::new(); let mut anchors: [Option<usize>; 2] = [None; 2]; for stream_type in [StreamLabelType::Audio, StreamLabelType::Subtitle] { @@ -504,15 +491,9 @@ pub(crate) fn apply_labels(labels: &[StreamLabel], titles: &mut [DiscTitle]) { }) else { continue; }; - // ONLY the anchor's first clip. `disc::bluray` builds a title's - // stream list from `play_items[0]`'s STN table, so that is the only - // clip in which these PIDs were ever observed — the same clip tier 3 - // keys its derived ids on (`clip0`, below). Recording the fact - // against every clip the anchor plays claims knowledge of stream - // tables never read: a sibling playlist over a LATER clip then binds - // the anchor's editorial label onto whichever stream of that clip - // reuses the PID, which is a different physical stream (PIDs are - // unique only within a clip). + // ONLY the anchor's first clip: `disc::bluray` builds streams from + // `play_items[0]`'s STN table, the only clip these PIDs were seen in. + // A later clip could reuse the PID for a different stream otherwise. if let Some(clip) = title.clips.first() { pid_map.insert((clip.clip_id.as_str(), *pid), pos); } @@ -531,12 +512,9 @@ pub(crate) fn apply_labels(labels: &[StreamLabel], titles: &mut [DiscTitle]) { .map(|((c, p), v)| ((c.to_string(), p), v)) .collect(); - // Labels that name their own stream, indexed by that name. No anchor, no - // sequence and no ordinal is involved in reaching one of these: the id was - // read from the same STN / ProgramInfo table `disc::bluray` built the - // stream from, so equality here is the same elementary stream by - // construction. Later duplicates lose, matching the first-wins rule the - // rest of this module uses. + // Labels that name their own stream, indexed by that name. The id was read + // from the same STN / ProgramInfo table `disc::bluray` built the stream + // from, so equality here is the same elementary stream by construction. let by_id: HashMap<&StreamId, usize> = labels .iter() .enumerate() @@ -572,9 +550,8 @@ pub(crate) fn apply_labels(labels: &[StreamLabel], titles: &mut [DiscTitle]) { .unwrap_or_default(); // Resolve one stream to (label, authoritative). Authoritative means the - // label is known to belong to THIS stream rather than guessed onto it: - // tiers 1-3 of the doc comment above. Only tier 4, the bare ordinal, is - // not. + // label is known to belong to THIS stream rather than guessed onto it + // (tiers 1-3 of the doc comment above); only tier 4, the bare ordinal, isn't. let resolve = |stream_type: StreamLabelType, idx: u16, pid: u16, lang: &str| { if anchors[type_tag(stream_type) as usize] == Some(title_idx) && let Some(l) = label_at(labels, stream_type, idx) @@ -610,15 +587,9 @@ pub(crate) fn apply_labels(labels: &[StreamLabel], titles: &mut [DiscTitle]) { // Structured fields — callers translate purpose to UI text. a.purpose = label.purpose; - // Codec descriptor: trust the parser's `codec_hint` ONLY - // when it's consistent with the stream's actual codec — it - // may legitimately be richer (e.g. "Dolby Atmos" on a TrueHD - // stream, which the raw spec codec can't express). If the - // hint CONTRADICTS the stream (a mis-bound / shuffled label, - // e.g. "AC-3 2.0" on a TrueHD track, or "TrueHD" on a DD+ - // track), discard it and derive the descriptor from the - // stream itself — that's correct per-stream and can never be - // shuffled. An empty hint is left for `fill_defaults`. + // Trust the parser's `codec_hint` ONLY when consistent with the + // stream's actual codec (it may be richer, e.g. "Dolby Atmos" on + // TrueHD); if it CONTRADICTS, derive from the stream itself. let codec_desc = if label.codec_hint.is_empty() { // No codec hint — leave for fill_defaults. String::new() @@ -660,10 +631,9 @@ pub(crate) fn apply_labels(labels: &[StreamLabel], titles: &mut [DiscTitle]) { sub_idx += 1; if let Some((label, authoritative)) = resolve(StreamLabelType::Subtitle, sub_idx, s.pid, &s.language) - // A subtitle label carries nothing but the qualifier, - // so an unverifiable one is all risk and no gain: off - // the authoritative path, require the label and the - // stream to state the same language. + // A subtitle label carries nothing but the qualifier, so an + // unverifiable one is all risk, no gain: off the authoritative + // path, require the label and stream to state the same language. && (authoritative || languages_agree(&label.language, &s.language)) { @@ -798,19 +768,17 @@ fn codec_hint_consistent(hint: &str, codec: &crate::disc::Codec) -> bool { let says_dts = !says_dts_ma && !says_dts_hr && h.contains("dts"); let says_lpcm = h.contains("lpcm") || h.contains("pcm"); let says_atmos = h.contains("atmos"); - // DTS:X is an object-audio extension carried on a DTS-HD MA (or HR) - // core, exactly as Atmos rides TrueHD / DD+. The spec Codec enum has - // no DtsX variant, so a correctly-authored DTS:X hint must be judged - // consistent with its DtsHdMa/DtsHdHr carrier rather than discarded. + // DTS:X is an object-audio extension carried on a DTS-HD MA (or HR) core, + // as Atmos rides TrueHD/DD+. The spec Codec enum has no DtsX variant, so a + // correctly-authored hint must be judged consistent with its carrier. let says_dtsx = h.contains("dts:x") || h.contains("dts-x") || h.contains("dtsx"); let names_family = says_truehd || says_ddp || says_ac3 || says_dts_ma || says_dts_hr || says_dts || says_lpcm; - // Pure-editorial hint (no codec family named) isn't asserting a codec → - // consistent. "Atmos" alone implies a lossless carrier (TrueHD or DD+). - // ("DTS:X" always also matches the "dts" family above, so it never - // reaches this branch — it is handled in the DtsHdMa/DtsHdHr arms.) + // Pure-editorial hint (no codec family named) isn't asserting a codec, so + // it's consistent. "Atmos" alone implies a lossless carrier (TrueHD/DD+). + // ("DTS:X" always matches "dts" above, so it never reaches this branch.) if !names_family { return if says_atmos { matches!(codec, Codec::TrueHd | Codec::Ac3Plus) @@ -936,14 +904,9 @@ fn extract( } candidates.push((name, result)); } - // One tie-break rule, one implementation. `select_result` owns it and - // carries a regression test for a past bug where the LAST equal-confidence - // parser won instead of the first. `extract` — the path that actually - // ships — used to re-derive the same rule inline with a hand-rolled `>` - // scan and had no test of its own, so that fixed bug could have silently - // recurred here. Array order encodes a trust ordering (the hand-vetted - // parsers are registered ahead of the ones that detect on any BD-J disc), - // so "first wins on a tie" is load-bearing, not incidental. + // One tie-break rule, one implementation. `select_result` regression-tests a + // past bug where the LAST equal-confidence parser won instead of the first. + // Array order encodes trust (hand-vetted parsers before generic BD-J ones). let best = select_result(&candidates).map(|(n, r)| (*n, r.clone())); let (name, mut labels, feature_playlist) = match best { Some((n, r)) => { @@ -961,24 +924,18 @@ fn extract( } }; - // The MPLS floor: framework parsers under-yield on multi-track discs - // because their authoring layer only ships editorial labels for - // "interesting" streams (Director's Cut, Atmos, SDH) and leaves the rest as - // plain numbered slots. MPLS sees every stream a playlist references, and - // names each one, so merging it in gives the user every track even when - // only the "interesting" ones have editorial names. Skipped when - // mpls_universal was itself the chosen parser (its labels ARE the labels). + // The MPLS floor: framework parsers under-yield on multi-track discs, only + // shipping editorial labels for "interesting" streams (Atmos, SDH). MPLS + // names every stream a playlist references, filling the gaps. if name != "mpls_universal" && let Some(mpls_result) = mpls_universal::parse(reader, udf) { merge_mpls_floor(&mut labels, &mpls_result.labels); } - // CLPI orphan streams: PIDs in /BDMV/CLIPINF/*.clpi ProgramInfo that no - // MPLS playlist references. Empirically a small fraction of streams are - // CLPI-only — physically on disc, not menu-reachable. They too are - // appended under the id they name, so a title reaches one only if it - // actually carries that stream. + // CLPI orphan streams: PIDs in /BDMV/CLIPINF/*.clpi ProgramInfo that no MPLS + // playlist references (physically on disc, not menu-reachable). Appended + // under the id they name, so a title reaches one only if it carries it. let _orphans_added = append_clpi_orphans(&mut labels, reader, udf); (labels, feature_playlist) @@ -1075,12 +1032,9 @@ fn append_clpi_orphans( udf: &UdfFs, ) -> usize { use crate::consts::coding_type as c; - // Two exclusions, one exact and one fuzzy. Exact: a stream some label - // already NAMES is not an orphan, whatever it looks like. Fuzzy: the - // pre-existing (type, language, codec_hint) test, kept because it is what - // bounds this list to a handful of entries per disc rather than one per - // stream per clip; it can only ever drop a candidate, and an orphan that - // never binds costs nothing when it is dropped. + // Two exclusions: exact (a stream some label already NAMES is not an orphan) + // and fuzzy (the pre-existing (type, language, codec_hint) test, kept to + // bound this list to a handful of entries per disc, not one per stream). use std::collections::HashSet; let named: HashSet<&StreamId> = labels.iter().filter_map(|l| l.stream_id.as_ref()).collect(); let existing: HashSet<(StreamLabelType, String, String)> = labels @@ -1120,10 +1074,9 @@ fn append_clpi_orphans( if !seen_pids.insert(s.pid) { continue; } - // Translate CLPI coding_type → label stream_type. - // 0x90 = Presentation Graphics (PG subtitle). 0x91 = - // Interactive Graphics (BD-J menu overlay), NOT a user-facing - // subtitle — skip it, matching the MPLS path which drops IG. + // Translate CLPI coding_type → label stream_type. 0x90 = Presentation + // Graphics (PG subtitle); 0x91 = Interactive Graphics (BD-J menu + // overlay), NOT a user-facing subtitle — skip it, matching MPLS. let stype = match s.coding_type { c::LPCM..=c::DTS_HD_MA | c::AC3_PLUS_SECONDARY | c::DTS_HD_SECONDARY => { StreamLabelType::Audio @@ -1235,16 +1188,13 @@ pub fn analyze(reader: &mut dyn SectorSource, udf: &UdfFs) -> LabelAnalysis { }; // The MPLS floor: same merge as `extract()`. Skipped when MPLS was itself - // the chosen parser (its labels ARE the labels). - // - // Note this diagnostic path stops here — `extract()` also appends the CLPI - // orphans, which `analyze` has never reported. + // the chosen parser (its labels ARE the labels). This diagnostic path stops + // here — `extract()` also appends the CLPI orphans, which `analyze` never does. let gap_fill_added = if parser.is_some() && parser != Some("mpls_universal") { let before = labels.len(); - // Re-run MPLS unconditionally — we only ran framework parsers - // above (we want to know which one to pick), and in the - // common case where MPLS would have detected but wasn't - // chosen we still need its labels for the merge. + // Re-run MPLS unconditionally: we only ran framework parsers above (to + // know which to pick), and in the common case where MPLS would have + // detected but wasn't chosen we still need its labels for the merge. if let Some(mpls_result) = mpls_universal::parse(reader, udf) { merge_mpls_floor(&mut labels, &mpls_result.labels); } @@ -1262,10 +1212,9 @@ pub fn analyze(reader: &mut dyn SectorSource, udf: &UdfFs) -> LabelAnalysis { ); } - // bdmt runs independently of the parser registry: it's disc-level - // metadata (localized titles, box-set position), not per-stream - // labels, so the "highest confidence wins" logic doesn't apply. - // Always run if detected; surface result as a separate field. + // bdmt runs independently of the parser registry: it's disc-level metadata + // (localized titles, box-set position), not per-stream labels, so "highest + // confidence wins" doesn't apply. Always run if detected, as a separate field. let disc_metadata = if bdmt::detect(udf) { bdmt::parse(reader, udf) } else { @@ -1320,10 +1269,9 @@ fn collect_chapter_summary(reader: &mut dyn SectorSource, udf: &UdfFs) -> Vec<Ch if chapter_count == 0 { continue; } - // Duration: sum of (out_time - in_time) across play items, - // each in 45kHz PTS ticks → seconds. Approximates the disc - // module's per-title duration; we don't claim sample accuracy - // here, just enough to identify "the long one" (main movie). + // Duration: sum of (out_time - in_time) across play items, in 45kHz PTS + // ticks. Approximates the disc module's per-title duration; not + // sample-accurate, just enough to identify "the long one" (main movie). let duration_ticks: u64 = playlist .play_items .iter() @@ -1377,7 +1325,7 @@ pub struct LabelAnalysis { /// tool. pub gap_fill_added: usize, /// Per-playlist chapter summary: `(playlist_filename, chapter_count, duration_secs)`. - /// Sourced from MPLS PlaylistMark entries with `mark_type ≤ 1` + /// Sourced from MPLS PlaylistMark entries with `mark_type == 1` /// (chapter entries). Ordered by playlist filename. Empty if no /// MPLS files have parseable marks, or the disc isn't Blu-ray. pub chapter_summary: Vec<ChapterSummary>, @@ -1695,18 +1643,14 @@ mod registry_tests { #[test] fn parsers_registry_all_entries_populated() { for (name, detect, parse) in PARSERS { - // Function pointers can't be Null in safe Rust, so the - // assertion is just that the array entry was constructed - // — which the iter above already implies. The test - // exists to fail compile if someone changes the tuple - // shape (e.g. adds a 4th field) without updating callers, - // and as a marker for "these parsers exist." + // Function pointers can't be null in safe Rust, so this just fails to + // compile if the tuple shape changes (e.g. a 4th field is added) + // without updating callers; it also marks "these parsers exist." let _ = (name, detect, parse); } - // The loop above touches every registry entry. The non-empty - // invariant is covered separately by `parsers_registry_order_locked`, - // whose assert_eq! on the expected order fails if PARSERS is empty. - // This test fails to compile if the tuple shape changes. + // Non-empty invariant is covered separately by + // `parsers_registry_order_locked`, whose assert_eq! on the expected + // order fails if PARSERS is empty. } } @@ -1866,11 +1810,9 @@ mod gap_fill_tests { #[test] fn orphan_append_skips_matching_type_lang_codec_tuples() { - // If a "would-be orphan" actually shares (type, lang, codec) with a - // label we already have, drop it — the user-facing rendering would be a - // confusing duplicate. This fuzzy test is what bounds the orphan list - // to a handful of entries per disc; it is a population rule only, and - // no longer decides where anything binds. + // If a "would-be orphan" shares (type, lang, codec) with a label we + // already have, drop it to avoid a confusing duplicate. This fuzzy + // test bounds the orphan list; it's a population rule, not a binding rule. let labels = [ label(StreamLabelType::Audio, 1, "eng", "TrueHD"), label(StreamLabelType::Audio, 2, "fra", "AC-3"), @@ -1919,10 +1861,8 @@ mod gap_fill_tests { } // ── apply() integration tests ────────────────────────────────────────────── -// -// End-to-end coverage for the apply_labels + fill_defaults pipeline -// without needing a SectorSource / UdfFs. Synthetic DiscTitle + -// StreamLabel inputs, assert on the resulting Stream field values. +// End-to-end coverage for apply_labels + fill_defaults without needing a +// SectorSource / UdfFs. Synthetic DiscTitle + StreamLabel inputs. #[cfg(test)] mod apply_tests { @@ -2039,15 +1979,9 @@ mod apply_tests { subtitle(0x12A2, "fra"), ], )]; - // Capture through the crate's ONE global `tracing` subscriber: - // `testlog::capture` installs it exactly once and routes each event to a - // thread-local sink. A process-wide `set_global_default` HERE instead - // would poison every other test's callsite interest cache for the rest of - // the binary — `tracing` caches interest GLOBALLY — and only the first - // `set_global_default` in a process takes effect anyway. The shared - // subscriber answers interest for every callsite and isolates concurrent - // captures per thread, which is the invariant these log-accounting - // assertions rely on. + // `testlog::capture` installs the crate's ONE global `tracing` subscriber, + // routing events to a thread-local sink; `tracing` caches interest + // globally, so a per-test subscriber would poison other tests' callsites. let ((), events) = crate::testlog::capture(|| { apply_labels(&labels, &mut titles); }); @@ -2218,10 +2152,9 @@ mod apply_tests { #[test] fn apply_keeps_consistent_dtsx_hint_on_dts_hd_ma() { - // DTS:X rides a DTS-HD MA core just as Atmos rides TrueHD. A - // correctly-authored "DTS:X" hint on a DtsHdMa stream is richer - // than the spec codec yet consistent, so it's kept verbatim — - // not discarded and regenerated to "DTS-HD Master Audio". + // DTS:X rides a DTS-HD MA core just as Atmos rides TrueHD. A correctly + // authored "DTS:X" hint on a DtsHdMa stream is richer than the spec + // codec yet consistent, so it's kept verbatim, not regenerated. let mut titles = vec![title_with(vec![audio( 0x1100, Codec::DtsHdMa, @@ -2256,10 +2189,9 @@ mod apply_tests { #[test] fn apply_normalizes_plain_consistent_hint_to_marketing() { - // A French DD+ track: a DD+ stream whose hint "AC-3+ 5.1" is correct - // but short-form. A sibling DD+ track that fell back uses the marketing - // form — keeping the short form here would read inconsistently, so a - // plain (non-richer) consistent hint is normalized to the stream's own. + // A DD+ stream whose hint "AC-3+ 5.1" is correct but short-form; a sibling + // fallback track uses the marketing form, so a plain (non-richer) + // consistent hint is normalized to the stream's own to stay consistent. let mut titles = vec![title_with(vec![audio( 0x1100, Codec::Ac3Plus, @@ -3005,13 +2937,8 @@ mod apply_tests { } // ── codec_hint_consistent: chained-OR boundary hardening ──────────────── - // - // The family-detection booleans are built from chains of `h.contains(..) - // || h.contains(..) || ...` synonym checks. Each test below isolates ONE - // synonym clause (a hint string that matches that clause and NO other - // clause in the same chain) so a `||` -> `&&` flip at that specific - // position changes the family verdict — and, downstream, whether the - // codec match arm returns the spec-correct answer. + // The family-detection booleans chain `h.contains(..) || ...` synonym checks. + // Each test isolates ONE clause so a `||` -> `&&` flip there changes the verdict. /// Isolates the `"true hd"` (space form) synonym in `says_truehd`, /// which mutant testing hit at 396:44's `||`. If that `||` is @@ -3320,10 +3247,9 @@ mod apply_tests { &["00082", "00090"], vec![subtitle(0x1200, "eng"), subtitle(0x1201, "fra")], ), - // A sibling playlist over the anchor's SECOND clip. Its subtitle - // reuses PID 0x1200 — PIDs are only unique within a clip — and it - // is Spanish, so nothing about the anchor's English SDH slot - // describes it. + // Sibling playlist over the anchor's SECOND clip; reuses PID 0x1200 + // (PIDs are only unique within a clip) but is Spanish, so the + // anchor's English SDH slot doesn't describe it. title_on_clips("00451.mpls", &["00090"], vec![subtitle(0x1200, "spa")]), ]; apply_labels(&labels, &mut titles); @@ -3356,10 +3282,9 @@ mod apply_tests { /// Mutation: as above — move `by_id` after the ordinal fallback. #[test] fn a_vendor_codec_claim_does_not_follow_the_ordinal_onto_a_bonus_clip() { - // The measured shape: this framework states no codec_hint at all and - // puts its descriptor in `name`, which `apply_labels` falls back to - // verbatim. Nothing about the stream is consulted on that path, so the - // codec-consistency guard never runs and cannot catch the mis-binding. + // This framework states no codec_hint and puts its descriptor in `name`, + // which `apply_labels` falls back to verbatim, so the codec-consistency + // guard never runs and cannot catch the mis-binding. let feature_audio = StreamLabel { name: "English Dolby Atmos".into(), ..audio_label(1, "eng", "", "") diff --git a/src/labels/mpls_universal.rs b/src/labels/mpls_universal.rs index 20ac6171..d661df33 100644 --- a/src/labels/mpls_universal.rs +++ b/src/labels/mpls_universal.rs @@ -78,11 +78,9 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> return None; } - // MPLS gives language + codec but never editorial info (no - // commentary/SDH/director's cut). Low confidence means framework - // parsers (paramount, criterion, pixelogic, ctrm, dbp, deluxe) always - // win when they match. MPLS only gets chosen as the parser when - // nothing else fired — exactly the universal-fallback role we want. + // MPLS gives language + codec but never editorial info. Low confidence + // means framework parsers always win when they match; MPLS is only + // chosen when nothing else fired — the universal-fallback role we want. Some(ParseResult::low(labels)) } @@ -93,7 +91,7 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> /// needing a synthetic on-disc UDF image. /// /// Identity is `(clip, PID)` — what the STN entry states — and it is both the -/// dedup key and the label's [`StreamId`]. A stream twenty playlists list is +/// dedup key and the label's [`StreamId`](super::StreamId). A stream twenty playlists list is /// one label; two clips that both open their first audio at 0x1100 are two. /// This replaced a disc-global dense counter that numbered surviving entries /// 1, 2, 3, … in playlist-directory order: that number was not an STN slot in @@ -104,25 +102,16 @@ fn build_labels(playlists: &[crate::mpls::Playlist]) -> Vec<StreamLabel> { let mut seen: HashSet<super::StreamId> = HashSet::new(); for playlist in playlists { - // `Playlist::streams` is the FIRST play item's STN table, so every - // entry here is a stream of that play item's clip — the same clip - // `disc::bluray` records as the title's `clips[0]`. That pairing is - // what makes the PID an identity rather than a 16-bit number. - // - // Streams cannot be non-empty without a play item to have read them - // from, so the empty case is unreachable on a real disc; entries we - // cannot identify are skipped rather than emitted as unbindable - // labels. + // `Playlist::streams` is the FIRST play item's STN table, so every entry + // here is a stream of that play item's clip, matching `disc::bluray`'s + // `clips[0]` — that pairing is what makes the PID an identity. let Some(clip_id) = playlist.play_items.first().map(|pi| pi.clip_id.clone()) else { continue; }; - // 1-based STN slot within THIS playlist's table, per type — the - // `stream_number` field's documented meaning, counted the same way - // `disc::bluray` counts the stream list it builds from these entries. - // Nothing binds through it (these labels bind by id); it is stated - // truthfully rather than invented so that a reader of the label list - // sees where on its own playlist each stream sits. + // 1-based STN slot within THIS playlist's table, per type — matches how + // `disc::bluray` counts the same entries. Nothing binds through it (labels + // bind by id); it's stated truthfully so the label list shows real slots. let mut audio_idx: u16 = 0; let mut sub_idx: u16 = 0; @@ -201,13 +190,9 @@ fn label_type_for(entry: &crate::mpls::StreamEntry) -> Option<StreamLabelType> { } fn has_mpls_extension(name: &str) -> bool { - // Case-insensitive ".mpls" suffix. Some discs use uppercase, - // some lowercase; UDF filenames preserve case but we don't. - // - // UDF names are decoded via from_utf8_lossy, so a multi-byte - // replacement char (EF BF BD) can straddle byte index n-5; a raw - // byte slice there panics on a non-char-boundary. `ends_with` on a - // lowercased copy is char-boundary-safe and still case-insensitive. + // Case-insensitive ".mpls" suffix (discs use both cases). UDF names are + // decoded via from_utf8_lossy, so a multi-byte replacement char can straddle + // byte index n-5; `ends_with` on a lowercased copy avoids that boundary panic. name.len() >= 5 && name.to_ascii_lowercase().ends_with(".mpls") } @@ -573,11 +558,9 @@ mod tests { assert_eq!(id("fra"), Some(("00001".into(), 0x1101))); assert_eq!(id("deu"), Some(("00001".into(), 0x1102))); - // `stream_number` is the entry's slot in ITS OWN playlist's STN table - // — deu is pl2's second audio, so 2, not "the third distinct stream - // seen while scanning the disc". It used to be the latter: a dense - // disc-global counter that named no table anyone could count against, - // handed to a binder that reads the field as an STN slot. + // `stream_number` is the entry's slot in ITS OWN playlist's STN table — + // deu is pl2's second audio, so 2, not "third distinct stream on disc". + // (Used to be a dense disc-global counter fed to a binder expecting a slot.) let num = |lang: &str| { labels .iter() @@ -735,10 +718,8 @@ mod tests { #[test] fn secondary_audio_becomes_audio_label() { - // stream_type 5 = secondary audio. The conversion should - // still produce an Audio label (the registry's apply path - // can ignore secondary if it wants — this module just - // surfaces what's there). + // stream_type 5 = secondary audio; conversion still produces an Audio + // label (the registry's apply path can ignore secondary if it wants). let mut sec = audio_entry(0x1A00, 0x83, 3, 1, "eng"); sec.stream_type = 5; sec.secondary = true; diff --git a/src/labels/paramount.rs b/src/labels/paramount.rs index 0a47b679..a8851417 100644 --- a/src/labels/paramount.rs +++ b/src/labels/paramount.rs @@ -43,10 +43,9 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> return None; } - // Surface the feature playlist's identity (e.g. `id="00222"`) so title - // selection can prefer it over a size-inflated decoy. The id is 5-digit - // zero-padded on BD (matching the `NNNNN.mpls` filename); keep only the - // digits so a stray attribute quote/space can't poison the parse. + // Surface the feature playlist's identity so title selection can prefer + // it over a size-inflated decoy. The id is 5-digit zero-padded on BD + // (matching `NNNNN.mpls`); keep only digits to resist a stray quote/space. let feature_playlist = super::xml::attr(&feature, "id").and_then(|id| { let digits: String = id.chars().filter(|c| c.is_ascii_digit()).collect(); if digits.is_empty() { @@ -201,32 +200,14 @@ fn labels_from_feature(feature: &str) -> Vec<StreamLabel> { // Parse audio streams if let Some(aud) = xml::attr(feature, "aud") { - // aud_com1_idx is a trimmed, comma-separated list of CSV positions - // (some authoring tools emit whitespace, and multiple commentary - // tracks are possible) — symmetric with sub_com1_idx below. - // A HashSet, not a Vec: `com_indices` is parsed straight out of an - // attacker-controlled attribute with no length bound and was scanned - // linearly once per stream, so `aud="..."` and `aud_com1_idx="..."` - // both grown large make this quadratic in the size of one XML file. - // Membership is the only operation performed on it. + // aud_com1_idx: trimmed CSV positions, symmetric with sub_com1_idx. + // HashSet not Vec: parsed from an attacker-controlled attribute and + // only membership-tested; a Vec would make large inputs quadratic. let com_indices = com_indices(xml::attr(feature, "aud_com1_idx")); - // The CSV *is* the STN list: one cell per stream, in stream order, - // and `aud_com1_idx` is a 0-based index into those same cells. So - // `stream_number` is the cell's own 1-based position — NOT a counter - // that only advances on cells carrying a language. - // - // A cell with an empty language still occupies its STN slot; it just - // has nothing to label. Renumbering the surviving cells 1..N shifts - // every label behind an empty cell one slot forward, which is how a - // marker authored for one stream ends up written onto the stream in - // front of it (see the subtitle side, where the marker is `forced`). - // - // `u16::try_from` rather than `saturating_add`: past the 1-based u16 - // numbering space every cell would collapse onto `u16::MAX`, binding - // several streams to one label. Stop emitting instead. Unreachable on - // real media — the BD STN_table admits at most 32 primary audio - // streams per playlist. + // The CSV *is* the STN list, so `stream_number` is each cell's 1-based + // position, not a counter over labeled cells (empty cells still occupy + // their slot). `u16::try_from`: stop rather than wrap onto `u16::MAX`. for (i, lang) in aud.split(',').enumerate() { let Ok(stream_number) = u16::try_from(i + 1) else { break; @@ -262,11 +243,9 @@ fn labels_from_feature(feature: &str) -> Vec<StreamLabel> { // parsed input, membership-only use, linear scan once per stream. let com_indices = com_indices(xml::attr(feature, "sub_com1_idx")); - // As with audio: the cell position IS the STN slot. `forced_sub` and - // `sub_com1_idx` are indexed against those same cells, so an empty - // cell must not renumber the cells behind it — a forced marker - // authored for one PG slot would otherwise be written onto an - // earlier, full-dialogue subtitle track. + // As with audio: the cell position IS the STN slot, indexed by + // `forced_sub`/`sub_com1_idx`, so an empty cell must not renumber + // the rest — else a forced marker lands on the wrong subtitle track. for (i, lang) in sub.split(',').enumerate() { let Ok(stream_number) = u16::try_from(i + 1) else { break; @@ -620,10 +599,9 @@ mod tests { #[test] fn aud_com1_idx_trimmed_and_multivalue() { - // Whitespace around the index, and a multi-value list, must both - // resolve. com index is positional against the raw CSV, so with - // an empty slot at position 1, " 2 " marks the 'fra' track - // (CSV index 2, STN slot 3) as commentary. + // Whitespace and multi-value lists must both resolve; com index is + // positional against the raw CSV, so with an empty slot at position 1, + // " 2 " marks 'fra' (CSV index 2, STN slot 3) as commentary. let feature = r#"<playlist aud="eng,,fra" aud_com1_idx=" 2 " />"#; let labels = labels_from_feature(feature); let a = audio(&labels); @@ -782,10 +760,9 @@ mod tests { /// Mutation: cast `i + 1` to u16 → stream numbers wrap to 0, skipping apply. #[test] fn audio_stream_number_never_wraps() { - // 65535 audio tracks is impossible on a real disc but the parser must - // not panic or produce 0. Build a comma-separated list of 65535 "eng"s. - // We only run the number-assignment logic via labels_from_feature. - // Limit: CSV with 300 slots is sufficient to test the counter. + // 65535 tracks is impossible on a real disc but must not panic/produce 0. + // 300 slots is sufficient to exercise the number-assignment logic + // via labels_from_feature without building the full 65535-entry CSV. let aud: String = (0..300).map(|_| "eng").collect::<Vec<_>>().join(","); let feature = format!(r#"<playlist name="Feature" aud="{}" />"#, aud); let labels = labels_from_feature(&feature); diff --git a/src/labels/pixelogic.rs b/src/labels/pixelogic.rs index 0122ff3e..25942c4b 100644 --- a/src/labels/pixelogic.rs +++ b/src/labels/pixelogic.rs @@ -111,11 +111,9 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> // shortest meaningful run is 4 chars (lang + underscore). let strings = text::extract_ascii_strings(&data, 4); - // Tracked across all parse_token calls in this run: which uncatalogued - // token components did the blob contain? If any, we downgrade confidence - // to Medium — the labels are still valid but the disc surfaced something - // we don't catalogue — and report them once (see `UnknownParts`). Parsing - // is single-threaded and sequential, so a plain owned collector suffices. + // Collects uncatalogued token components; if any, confidence downgrades to Medium + // and they're reported once (see `UnknownParts`). Sequential parse, so a plain + // owned collector suffices. let mut unknown = UnknownParts::default(); let labels = assign_labels(&strings, &mut unknown); @@ -145,14 +143,9 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> /// from `parse` so the section/numbering logic is unit-testable without /// a `SectorSource`/`UdfFs`. fn assign_labels(strings: &[String], unknown: &mut UnknownParts) -> Vec<StreamLabel> { - // The authoritative per-feature stream list lives in the `FPL_` - // (FeaturePLaylist) section, in STN order. `SEG_*` entries are menu - // segments (intros, logos, disclaimers, previews) that can also carry - // stray stream tokens — e.g. a `SEG_MainFeature` preview segment that - // lists only a commentary track. Anchoring on such a segment grabs the - // wrong streams and misnumbers them. So when the project ships any - // `FPL_` playlist, anchor exclusively on it; only fall back to - // `SEG_MainFeature` on discs that have no `FPL_` section at all. + // The authoritative per-feature stream list lives in `FPL_` (FeaturePlaylist), in + // STN order. `SEG_*` menu segments can carry stray tokens, so anchor on `FPL_` when + // present; fall back to `SEG_MainFeature` only if no `FPL_` section exists. let has_fpl = strings.iter().any(|s| s.starts_with("FPL_")); let mut labels = Vec::new(); @@ -195,27 +188,9 @@ fn assign_labels(strings: &[String], unknown: &mut UnknownParts) -> Vec<StreamLa continue; } - // Second way a section ends, and the only one some discs give us. - // - // The `SEG_`/`SF_`/`FPL_` markers above are section NAMES, and not every - // section is named: a project's trailing sections (the per-language - // notice, disclaimer and dub-credit cards) are emitted with a plain - // clip name, or none at all. When the feature playlist is the last - // NAMED section in the blob — which it is on most of the corpus — the - // name-only rule never fires again and the walk swallows the whole - // tail of the file as if it were more of the feature's stream list. - // Those trailing clip names are per-language (`{lang3}_{card}`), so - // they pass `is_stream_token` and each one silently advances an STN - // counter; the ones whose card name collides with a catalogued - // component (an `AC` card reads as the AC-3 codec) even emit a label, - // for an STN slot the feature playlist does not have. - // - // What every section does have, named or not, is a stream list that - // OPENS with its video slots. So a `Video Stream N` entry that repeats - // one this section already listed cannot be another slot in this - // section — it is the first entry of the next one, and this section is - // over. Distinct video entries are kept (a section may legitimately - // list a secondary video stream alongside the primary). + // Second section-end signal (the only one some discs give): every section's stream + // list OPENS with its video slots, so a `Video Stream N` repeating one this section + // listed marks the next section's start. Guards against swallowing trailing cards. if s.starts_with("Video Stream") { if video_slots.contains(&s.as_str()) || video_slots.len() >= MAX_VIDEO_SLOTS { break; @@ -230,26 +205,9 @@ fn assign_labels(strings: &[String], unknown: &mut UnknownParts) -> Vec<StreamLa break; } - // Every entry in the section's stream list occupies one STN slot, - // whether or not it carries anything worth labelling. Pixelogic lists - // a slot as an editorial `{lang}_{codec|purpose|region}_…` token, or as - // a bare `Audio Stream N` / `PG Stream N` placeholder when no editorial - // label was authored. Both must advance the per-type counter, and so - // must an editorial token the grammar cannot classify — a - // region-only token (`fra_CF_`, `spa_LS_`: REGIONS sets `variant` but - // neither `is_audio` nor `is_subtitle`, so `parse_token_inner` returns - // `None`) or one whose only distinguishing component is uncatalogued - // (`jpn_DUB_`). Numbering only the slots that PARSE renumbers the rest - // 1..N and lands every surviving label on the wrong stream: on - // one observed UHD feature the seven `*_TXT_FOR_` forced tokens at PG - // STN 11-16/18 collapsed onto STN 2-8, flagging that disc's FULL - // subtitle tracks `forced` and leaving the real forced-narrative - // tracks unflagged. - // - // A slot the grammar cannot classify carries no stream type either, so - // it advances the list currently being enumerated: pixelogic sections - // run video → audio → PG, and `domain` follows the last slot whose type - // WAS known (a typed token or a typed placeholder), starting at Audio. + // Every stream-list entry occupies one STN slot (editorial token, bare placeholder, + // or unclassifiable token) and ALL must advance the per-type counter, else labels + // renumber onto wrong streams. Unclassifiable slots follow `domain` (video→audio→PG). if let Some(kind) = placeholder_kind(s) { domain = kind; match kind { @@ -369,12 +327,9 @@ fn parse_token_inner(s: &str, mut unknown: Option<&mut UnknownParts>) -> Option< if raw_part.is_empty() { continue; } - // Token components are spec-uppercase (codec IDs, ADES/ACOM/SDH, - // region codes). vocab elsewhere is deliberately case-insensitive, - // so normalize each component to uppercase before the gate to - // avoid silently dropping a lowercase-authored token (which would - // fall through to the unknown branch and, with no is_audio/ - // is_subtitle set, get the whole stream discarded below). + // Token components are spec-uppercase (codec IDs, ADES/ACOM/SDH, region codes). + // Normalize each to uppercase before the gate so a lowercase-authored token isn't + // silently dropped through the unknown branch (no is_audio/is_subtitle set). let part_up = raw_part.to_ascii_uppercase(); let part = part_up.as_str(); if AUDIO_CODECS.contains(&part) { @@ -399,37 +354,15 @@ fn parse_token_inner(s: &str, mut unknown: Option<&mut UnknownParts>) -> Option< } else if part == "STRI" || part == "TXT" { is_subtitle = true; } else if part == "FOR" { - // `FOR` (forced) is a subtitle-domain qualifier. A token whose - // only non-language component is FOR (e.g. `eng_FOR_`) would - // otherwise classify as neither audio nor subtitle and be - // dropped at the `!is_audio && !is_subtitle` guard below. Treat - // a forced marker as a subtitle signal so the stream survives. + // `FOR` (forced) is a subtitle-domain qualifier. Treat it as a subtitle signal + // so a token whose only non-language component is FOR (e.g. `eng_FOR_`) isn't + // dropped at the `!is_audio && !is_subtitle` guard below. qualifier = LabelQualifier::Forced; is_subtitle = true; } else if part == "DUB" { - // `DUB` names the forced-narrative subtitle track authored to - // accompany that language's DUBBED audio presentation — the - // signs/on-screen-text pass a viewer needs when the dialogue is - // already dubbed. Same editorial class as `*_TXT_FOR_`, spelled - // differently by some authoring runs. - // - // Evidence (two independent discs in the corpus): the token - // appears only inside the PG list, embedded in an otherwise - // contiguous run of `{lang}[_{region}]_TXT_FOR_` siblings — one - // forced-narrative slot per localized language — and takes the - // slot where that language's forced entry belongs. The stream it - // lands on is a sparse PG track (sub-megabyte, a few dozen - // display sets), the signature of a forced-narrative pass rather - // than a full subtitle track. - // - // Deliberately NOT added to `vocab::qualifier`: that maps - // free-form ENGLISH label text, where a bare word "dub" means - // dubbed AUDIO. The forced-subtitle reading is specific to this - // vendor's token grammar, so it stays token-local here. - // - // Like `FOR`, this is a subtitle-domain signal: a token whose - // only non-language component is `DUB` must still classify as a - // subtitle or the `!is_audio && !is_subtitle` guard drops it. + // `DUB` = forced-narrative subtitle for a language's dubbed audio (same class as + // `*_TXT_FOR_`). Token-local, NOT in `vocab::qualifier` (English "dub" = dubbed + // AUDIO). Like FOR, a subtitle-domain signal so the guard keeps the stream. qualifier = LabelQualifier::Forced; is_subtitle = true; } else if REGIONS.contains(&part) { @@ -437,14 +370,9 @@ fn parse_token_inner(s: &str, mut unknown: Option<&mut UnknownParts>) -> Option< } else if part.starts_with("PGSTREAM") { is_subtitle = true; } else { - // Unknown token component — skip this single part rather - // than discarding the entire stream record. Pre-refactor - // behavior was `return None` here, which silently dropped - // any stream containing a single uncatalogued token (e.g. - // a new codec ID or framework variant). Better to surface - // what we know than discard a whole stream over one part, - // but flag the parse as Medium-confidence so callers know - // some data was elided. + // Unknown token component — skip this single part rather than discarding the + // whole stream record (pre-refactor `return None` dropped streams over one + // uncatalogued token). Recorded so the parse downgrades to Medium confidence. tracing::debug!(part = ?part, "pixelogic: unrecognized token component, skipping"); if let Some(acc) = unknown.as_deref_mut() { acc.record(part); @@ -456,12 +384,9 @@ fn parse_token_inner(s: &str, mut unknown: Option<&mut UnknownParts>) -> Option< return None; } - // Tie-break for tokens that signal both domains (e.g. `eng_MLP_SDH_` - // sets is_audio via the codec and is_subtitle via SDH). An audio - // codec hint is the stronger, audio-domain signal, so prefer Audio - // when one is present (keeps the parsed codec_hint instead of - // discarding it); otherwise file as Subtitle. Pure-subtitle and - // pure-audio tokens are unaffected. + // Tie-break for tokens signalling both domains (e.g. `eng_MLP_SDH_`: is_audio via codec, + // is_subtitle via SDH). An audio codec is the stronger signal, so prefer Audio when + // present (keeps codec_hint); otherwise Subtitle. Pure tokens are unaffected. let has_audio_codec = is_audio && !codec.is_empty(); let stream_type = if is_subtitle && !has_audio_codec { StreamLabelType::Subtitle @@ -628,11 +553,9 @@ mod tests { #[test] fn parse_token_components_are_case_insensitive() { - // Regression for the case-sensitive gate: a lowercase codec/ - // qualifier component must classify identically to uppercase - // rather than falling through to the unknown branch and getting - // the whole stream dropped. The ISO 639-2 lang prefix is still - // required lowercase. + // Regression for the case-sensitive gate: a lowercase codec/qualifier component must + // classify identically to uppercase, not fall through to the unknown branch and drop + // the stream. The ISO 639-2 lang prefix is still required lowercase. let l = parse_token_inner("eng_mlp_", None).unwrap(); assert_eq!(l.stream_type, StreamLabelType::Audio); assert_eq!(l.codec_hint, "TrueHD"); @@ -657,11 +580,9 @@ mod tests { #[test] fn assign_labels_numbers_commentary_behind_placeholders() { - // Observed case: the FPL_MainFeature playlist lists three unlabelled main - // audio tracks as `Audio Stream N` placeholders, then a lone - // `eng_ACOM_` commentary at STN slot 4. The commentary must land on - // audio #4, not collapse onto #1 (which would tag the main feature - // track as commentary). + // Observed case: FPL_MainFeature lists three unlabelled `Audio Stream N` + // placeholders, then a lone `eng_ACOM_` commentary at STN slot 4. It must land on + // audio #4, not collapse onto #1 (which would tag the main track as commentary). let mut flag = UnknownParts::default(); let tokens = strs(&[ "FPL_MainFeature", @@ -763,10 +684,9 @@ mod tests { #[test] fn assign_labels_prefers_fpl_over_seg_mainfeature() { - // A `SEG_MainFeature` menu/preview segment carries a stray commentary - // token, but the real playlist is `FPL_MainFeature`. When an FPL_ - // section exists, the SEG_ one must be ignored as an anchor — so we - // number from the FPL playlist, putting the commentary at slot 2. + // A `SEG_MainFeature` segment carries a stray commentary token, but the real + // playlist is `FPL_MainFeature`. When an FPL_ section exists the SEG_ one is ignored + // as anchor, so numbering comes from the FPL playlist (commentary at slot 2). let mut flag = UnknownParts::default(); let tokens = strs(&[ "SEG_MainFeature", @@ -907,8 +827,8 @@ mod tests { } /// Spec: is_audio wins over is_subtitle when codec explicitly identified. - /// Tests the commentary audio case — `ACOM` sets both is_audio purpose and SDH-only-is-subtitle: - /// MLP codec wins → Audio. + /// `SDH` alone would suggest a subtitle, but an explicit `AC3` codec token + /// wins the tiebreak → Audio. /// Mutation: flip the tie-break → Subtitle returned when codec present. #[test] fn parse_token_codec_always_wins_type_tiebreak() { @@ -1074,12 +994,9 @@ mod tests { ], "only the feature playlist's own slots are labelled" ); - // A card's name is emitted BEFORE its own section's video slot, so a - // forward-only walk meets the first one while still nominally inside - // the feature section and counts it. That residue is one entry, at the - // tail of a list nothing follows in — it cannot renumber any label, - // only cost the parse its High confidence. Every card behind it is - // past the boundary and never seen. Pinned rather than papered over. + // A card's name is emitted BEFORE its section's video slot, so a forward-only walk + // counts the first one while still nominally inside the feature section. That lone + // tail entry can't renumber any label, only cost High confidence. Pinned, not hidden. assert_eq!( flag.seen.iter().map(String::as_str).collect::<Vec<_>>(), vec!["WARNING"], diff --git a/src/labels/png_filenames.rs b/src/labels/png_filenames.rs index eb991a51..2fe1a7a1 100644 --- a/src/labels/png_filenames.rs +++ b/src/labels/png_filenames.rs @@ -7,7 +7,7 @@ //! of menu languages the disc ships. //! //! This is a language-only hint (no per-stream purpose/codec), so it runs at -//! [`Confidence::Low`] — it never displaces a real framework parser, and it +//! [`Confidence::Low`](super::Confidence::Low) — it never displaces a real framework parser, and it //! sits at the same tier as the MPLS floor. It is here so the pattern is a //! first-class, testable parser that keeps picking up discs as the corpus //! grows, rather than lost logic. Detection is precise: it fires only on the diff --git a/src/labels/vocab.rs b/src/labels/vocab.rs index e64768e0..7916feb3 100644 --- a/src/labels/vocab.rs +++ b/src/labels/vocab.rs @@ -454,7 +454,8 @@ pub fn iso639_1_to_iso639_2(code: &str) -> Option<&'static str> { /// Classify a free-form English label string into a [`LabelPurpose`]. /// -/// Recognized keywords (case-insensitive, word-boundary matched): +/// Recognized keywords (case-insensitive; single-word keywords are +/// word-boundary matched, multi-word phrases are substring matched): /// - "commentary", "director's commentary" → `Commentary` /// - "descriptive", "description", "audio description", "described" → `Descriptive` /// - "score", "music only" → `Score` @@ -462,7 +463,8 @@ pub fn iso639_1_to_iso639_2(code: &str) -> Option<&'static str> { /// - anything else → `Normal` /// /// Word-boundary matching means "Commentary track" matches but -/// "Commenter Pro audio" does not. +/// "Commenter Pro audio" does not. Multi-word phrases like "audio +/// description" and "music only" are matched as plain substrings. pub fn purpose(text: &str) -> LabelPurpose { let lower = text.to_lowercase(); // Multi-word compounds first — they're more specific. @@ -531,12 +533,9 @@ fn has_word(haystack: &str, needle: &str) -> bool { if needle.is_empty() { return false; } - // Boundary check is char-aware (not byte-level): a non-ASCII letter - // adjacent to the match (e.g. an accented or CJK char, which is - // multiple UTF-8 bytes) is alphanumeric and so is NOT a boundary, - // preventing false positives like "sdh" inside "cafésch". Needles - // are ASCII tokens, so a byte-offset match aligns with char - // boundaries in `haystack`. + // Char-aware, not byte-level: an accented/CJK char adjacent to the match is + // alphanumeric and so NOT a boundary, preventing false positives like "sdh" + // inside "cafésch". Needles are ASCII, so byte offsets align with char bounds. for (idx, _) in haystack.match_indices(needle) { // Char immediately before the match. let before_is_alnum = haystack[..idx] diff --git a/src/labels/xml.rs b/src/labels/xml.rs index 52fe24bd..a4cbf685 100644 --- a/src/labels/xml.rs +++ b/src/labels/xml.rs @@ -110,10 +110,9 @@ pub fn attr(element: &str, name: &str) -> Option<String> { /// prior behavior in criterion.rs). pub fn text(xml: &str, tag: &str) -> Option<String> { let (_open_end, body_start) = find_open_tag(xml, tag, 0)?; - // For self-closing tags, body_start is past `/>` and there is no - // content. Detect with a *byte* comparison: slicing `&xml[..]` two - // bytes back can land inside a multi-byte UTF-8 char and panic - // (untrusted on-disc XML), but indexing the byte slice never does. + // For self-closing tags, body_start is past `/>` with no content. Detect via + // a *byte* comparison: slicing `&xml[..]` two bytes back can land inside a + // multi-byte UTF-8 char and panic on untrusted XML; byte indexing never does. let b = xml.as_bytes(); if body_start >= 2 && b[body_start - 2] == b'/' && b[body_start - 1] == b'>' { return Some(String::new()); @@ -462,10 +461,9 @@ mod tests { #[test] fn text_multibyte_before_self_close_does_not_panic() { - // A multi-byte UTF-8 char ending right before the `/>` used to - // panic on a non-char-boundary str slice in `text()`. The - // byte-level self-closing check must handle it cleanly. - // 'é' (0xC3 0xA9) directly precedes the `/>`. + // A multi-byte UTF-8 char ('é' = 0xC3 0xA9) right before `/>` used to + // panic on a non-char-boundary str slice in `text()`; the byte-level + // self-closing check must handle it cleanly. assert_eq!(text("<x>é</x>", "x"), Some("é".into())); // Self-closing form with a multi-byte char in an attr value. assert_eq!(text(r#"<x a="é"/>"#, "x"), Some("".into())); @@ -636,11 +634,8 @@ mod tests { } // ── Malformed / truncated input (untrusted on-disc XML) ──────────────── - // - // These scrapers run on XML lifted out of BD-J jar entries, which is - // attacker-controllable. Every scan in this module must terminate and - // stay in bounds on truncated or unbalanced input rather than panic. - // XML 1.0 §2.3 defines the Name production these boundary rules model. + // These scrapers run on attacker-controllable BD-J jar XML, so every scan + // must terminate and stay in bounds on truncated input (XML 1.0 §2.3). /// A quoted attribute value that is never closed must terminate the /// scan at EOF rather than reading past the end of the buffer. diff --git a/src/lib.rs b/src/lib.rs index 09ef483d..bc7b7caf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -136,65 +136,43 @@ pub(crate) mod udf; pub(crate) mod unlock_bridge; // ─── Drive lifecycle ──────────────────────────────────────────────────────── -// -// `Drive::open(path)` → `wait_ready()` → `init()` → `Disc::scan()`. `Drive` -// owns the SCSI session; `DriveCapture` etc. let advanced callers introspect -// drive identity / profile data for sharing. +// `Drive::open(path)` → `wait_ready()` → `init()` → `Disc::scan()`. `Drive` owns +// the SCSI session; `DriveCapture` etc. expose drive identity/profile data. pub use drive::capture::{ CapturedFeature, DriveCapture, capture_drive_data, mask_bytes, mask_string, }; pub use drive::{Drive, DriveStatus, extract_scsi_context, find_drive}; // ─── Disc session (drive open + SCSI bring-up hoist) ───────────────────────── -// -// One entry point that opens a drive and brings the transport up, so consumers -// stop hand-rolling `open → wait_ready → init → probe_disc → identify → scan`. -// Owns the `Drive` by value; forwards consumer-built key material into -// `ScanOptions` (the library derives no certs — see `KeySpec`). +// One entry point opens a drive, brings transport up, and forwards caller-built +// key material into `ScanOptions` (the library derives no certs; see `KeySpec`). pub use session::{ DeviceTarget, DiscSession, KeySourceFactory, KeySpec, ResolvedKeys, resolve_keys_for, scan_dir, scan_iso, }; // ─── Errors ───────────────────────────────────────────────────────────────── -// -// All fallible APIs return `Result<T, Error>`. `Error` is a typed enum with a -// numeric `code()`; **no English text in the library** — applications map -// codes to localized messages. See `error.rs` for the full taxonomy. +// All fallible APIs return `Result<T, Error>`; `Error` is a typed enum with a +// numeric `code()` — no English text in the library; see `error.rs` for taxonomy. pub use error::{ Error, Result, error_code, is_disc_level_no_key, is_halt, is_skippable_title_stub, }; // ─── Cooperative cancellation ─────────────────────────────────────────────── -// -// One-bit cooperative cancellation token, shared by every long-running loop — -// libfreemkv's mux, and the recovery passes (sweep/patch) that now live in the -// freemkv-engine crate. Clone it cheaply; pass it by value into each component; -// poll `is_cancelled()` inside the loop body. +// One-bit cancellation token shared by every long-running loop (mux, and the +// engine's sweep/patch passes); clone cheaply, poll `is_cancelled()` in loops. pub use halt::Halt; -// Generic bounded producer/consumer primitive used by the mux pipeline (and, -// via this re-export, by the engine's sweep/patch recovery passes) to overlap -// reads with writes via a dedicated consumer thread. -// `Pipeline::spawn(name, depth, sink)` spawns a named consumer; `pipe.send(item)` -// pushes one item with back-pressure; `pipe.finish()` joins the -// consumer and surfaces its `close()` output. Callers implement `Sink` -// to define per-item behaviour and end-of-stream finalisation. -// -// `DEFAULT_PIPELINE_DEPTH` (=4) is for callers without specific needs; -// most should use WRITE_PIPELINE_DEPTH instead. -// Patch uses `WRITE_THROUGH_DEPTH` (=1). Returning `Flow::Stop` from -// `apply` ends the consumer cleanly (still calls `close()`). +// Bounded producer/consumer primitive used by mux (and engine's sweep/patch) to +// overlap reads with writes via a consumer thread. `Sink::apply` defines +// per-item behavior; `Flow::Stop` ends the consumer cleanly, still calling `close()`. pub use io::pipeline::{ DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, Sink, WRITE_PIPELINE_DEPTH, WRITE_THROUGH_DEPTH, }; // ─── Bounded-cache buffered file writer ───────────────────────────────────── -// -// Drop-in `std::fs::File` replacement used everywhere the lib writes large -// sequential output (mux, extract, sweep, patch) — drains dirty pages -// continuously instead of bursting. General I/O infra, not recovery policy; -// promoted to `pub` so freemkv-engine's relocated sweep/patch can use it too. +// Drop-in `std::fs::File` replacement for large sequential output (mux, extract, +// sweep, patch); drains dirty pages continuously. `pub` for freemkv-engine reuse. pub use io::WritebackFile; /// Write an image-level source out as a sector image — what an `iso://` /// DESTINATION means for any source that is not a physical drive. Drive sources @@ -207,32 +185,17 @@ pub use event::{BatchSizeReason, Event, EventKind}; pub use identity::DriveId; // ─── Unlock seam ──────────────────────────────────────────────────────────── -// -// Drive/disc unlocking (removing bus encryption — firmware, AACS cert, CSS -// bus-auth) lives entirely in the `freemkv-unlock` crate. libfreemkv consumes -// it through the private `unlock_bridge` and exposes nothing of it: clients are -// oblivious to unlockers, exactly as they are to the SCSI layer. There is no -// public unlock surface to import. +// Drive/disc unlocking (firmware, AACS cert, CSS bus-auth) lives entirely in +// `freemkv-unlock`; libfreemkv consumes it via `unlock_bridge` and exposes nothing. // ─── Decryption (AACS / CSS) ──────────────────────────────────────────────── -// -// `Disc::scan()` resolves keys and stores them on `Disc`; in most flows you -// don't touch `DecryptKeys` directly — `DiscStream::new(reader, title, keys, …)` -// accepts whatever `Disc::decrypt_keys()` returned. `decrypt_sectors()` is -// for callers that operate on raw sector buffers (e.g. ISO patching). +// `Disc::scan()` resolves keys onto `Disc`; `DiscStream::new(...)` consumes +// them directly. `decrypt_sectors()` is for raw sector buffers (ISO patching). pub use decrypt::{AacsKeyMap, DecryptKeys, decrypt_sectors, decrypt_threads, set_decrypt_threads}; // ─── Disc structure ───────────────────────────────────────────────────────── -// -// `Disc::scan()` produces a fully-populated `Disc` (titles, streams, AACS -// state). `Disc::identify()` is the fast path — UDF only, no playlist parse, -// for displaying disc name + format quickly while a full scan runs in the -// background. The codec / channel / resolution enums are the canonical -// structured representation; never compare against display strings. -// Note: `disc::Stream` here is the codec enum (audio / video / sub kind) -// — not the `pes::Stream` trait re-exported below as `PesStream`. Two -// different concepts, the same short name; the trait gets the `Pes` -// prefix at the crate root to keep both addressable. +// `Disc::scan()` fully populates `Disc`; `Disc::identify()` is a UDF-only fast path +// for name/format display. Codec enums are canonical; never compare display strings. pub use dirimage::DirImage; pub use disc::{ AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, Disc, @@ -243,23 +206,8 @@ pub use disc::{ pub use keysource::{DiscInputs, KeySource, read_encrypted_units, resolve_and_apply}; // ─── Streams ──────────────────────────────────────────────────────────────── -// -// All stream types implement `pes::Stream` — read PES frames from a source, -// write PES frames to a sink. Pick the right type at construction: -// -// - `DiscStream` — physical drive or ISO (any `SectorSource`). Read-only. -// - `MkvStream` — Matroska container. Read on `open()`, write on `create()`. -// - `M2tsStream` — Blu-ray Transport Stream. Write-only sink (`create()`). -// - `NetworkStream` — TCP. Read on `listen()`, write on `connect()`. -// - `NullStream` — write-only black-hole sink. Useful for benchmarks. -// - `StdioStream` — pipe to/from stdin/stdout. Read or write. -// -// Most consumers use the URL resolvers (`input()` / `output()`) which pick -// the right type from a scheme:// URL. Direct construction is for callers -// that need to wire custom readers (e.g. autorip's drive-session reuse). -// The trait is re-exported as `PesStream` here to disambiguate from -// `disc::Stream` (the codec-kind enum re-exported above), which would -// otherwise collide at the crate root. +// All types implement `pes::Stream` (re-exported `PesStream` to avoid colliding +// with `disc::Stream`). Prefer `input()`/`output()` URL resolvers over direct construction. pub use pes::PesFrame; pub use pes::Stream as PesStream; @@ -275,13 +223,8 @@ pub use mux::{Medium, SourceInfo}; pub use mux::{Mp4FitReport, Mp4Sink, Mp4SkipReason, mp4_fit_report}; // ─── Lower-level surfaces ─────────────────────────────────────────────────── -// -// `ScsiTransport` is the platform-abstraction trait Drive uses; expose for -// out-of-tree platform backends. `SectorSource` / `SectorSink` are the -// direction-typed read/write traits; `FileSectorSource` and `FileSectorSink` -// are the ISO-on-disk implementations. [`DecryptingSectorSource`] is the -// single decrypt-on-read decorator (AACS / CSS / none) — wrap any -// `SectorSource` to get plaintext sectors out. +// `ScsiTransport` is the platform trait Drive uses, exposed for out-of-tree +// backends. `DecryptingSectorSource` wraps any `SectorSource` to decrypt (AACS/CSS). pub use mux::build_iso_pipeline; pub use mux::resolve_mux_key_map; pub use mux::select::{PidFilter, StreamSelection}; diff --git a/src/mpls.rs b/src/mpls.rs index b91649e6..31e7ab8c 100644 --- a/src/mpls.rs +++ b/src/mpls.rs @@ -153,13 +153,9 @@ pub fn parse(data: &[u8]) -> Result<Playlist> { let in_time = u32::from_be_bytes([item[12], item[13], item[14], item[15]]); let out_time = u32::from_be_bytes([item[16], item[17], item[18], item[19]]); - // Parse STN table from the first play item - // PlayItem layout after out_time: - // [20:28] UO_mask_table (8 bytes) - // [28] misc flags (1 byte) - // [29] still_mode (1 byte) - // [30:32] still_time (2 bytes) - // [32:] STN_table + // Parse STN table from the first play item. PlayItem layout after out_time: + // UO_mask_table(8) + misc flags(1) + still_mode(1) + still_time(2), then + // STN_table starting at STN_OFFSET. const STN_OFFSET: usize = 32; if item_idx == 0 && item.len() > STN_OFFSET + 16 { // STN header: length(2) + reserved(2) + counts(8) + reserved(4) = 16 bytes @@ -238,11 +234,9 @@ pub fn parse(data: &[u8]) -> Result<Playlist> { entry.stream_type = 6; entry.secondary = true; streams.push(entry); - // Skip extra ref bytes (audio refs + PG refs). - // Use `next < item.len()` to match the sibling secondary - // blocks; the inner `after_arefs < item.len()` re-guards - // the second read, so the stricter `+2` only mis-aligned - // spos when the aref count sits in the last 1-2 bytes. + // Skip extra ref bytes (audio refs + PG refs). `next < item.len()` + // matches the sibling secondary blocks; the inner + // `after_arefs < item.len()` re-guards the second read near the end. if next < item.len() { let n_arefs = item[next] as usize; let after_arefs = next + 2 + n_arefs + (n_arefs % 2); @@ -370,13 +364,9 @@ fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(Strea return None; } - // PID location depends on the stream-entry type (BD spec stream_entry()): - // type 1 (stream in the PlayItem's Clip): PID at +2 - // type 2 (stream in a SubPath SubClip): +subpath_id(1)+subclip_id(1) → PID at +4 - // type 3 / 4 (SubPath clip; type 4 = Dolby Vision +subpath_id(1) → PID at +3 - // enhancement layer, e.g. PID 0x1015): - // Previously only type 1 was handled, so the DV EL (type 4) and any - // sub-path stream fell through to PID 0 and were dropped by the mux. + // PID location depends on stream-entry type (BD spec stream_entry()): type 1 + // (PlayItem's Clip) → +2; type 2 (SubPath SubClip) → +4; type 3/4 (SubPath + // clip / DV enhancement layer) → +3. Previously only type 1 was handled. let pid_off = match item[pos + 1] { STREAM_ENTRY_PLAYITEM_CLIP => 2, STREAM_ENTRY_SUBPATH_SUBCLIP => 4, @@ -413,11 +403,9 @@ fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(Strea let mut color_space_val = 0u8; let mut language = String::new(); - // `stream_type` here is the STN category passed by the caller, which is - // only ever a primary category (VIDEO/AUDIO/PG_SUBTITLE/IG). Secondary - // audio/video and the DV enhancement layer are parsed through their - // matching primary category (identical attribute layout) and re-tagged by - // the caller after this returns, so there are no secondary arms here. + // `stream_type` is the STN category from the caller — always a primary category + // (VIDEO/AUDIO/PG_SUBTITLE/IG). Secondary audio/video and the DV enhancement layer + // share the primary's attribute layout and are re-tagged by the caller afterward. match stream_type { STREAM_CATEGORY_VIDEO => { // Video: coding_type(1) + format_rate(1) + [hdr_info(1) if HEVC] @@ -969,12 +957,9 @@ mod tests { #[test] fn mark_type_read_from_correct_offset() { - // Regression for the mark_type off-by-one: each PlayListMark entry is - // reserved(1) + mark_type(1) + .... The parser must read byte[1], not - // byte[0]. build_mpls_with_marks writes reserved=0 at byte[0] and the - // mark_type at byte[1], so a parser that read byte[0] would see 0 for - // every mark. Use distinct non-zero, non-1 types to make the offset - // error unmistakable. + // Regression for the mark_type off-by-one: PlayListMark is reserved(1)+mark_type(1)+..., + // so the parser must read byte[1], not byte[0]. build_mpls_with_marks sets reserved=0 + // and mark_type=byte[1]; distinct non-zero/non-1 types make an offset error unmistakable. let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None); let marks = vec![ TestMark { @@ -1027,10 +1012,7 @@ mod tests { assert_eq!(playlist.marks.len(), 0); } - // ───────────────────────────────────────────────────────────────────── - // Added hardening tests below, grounded in the BD-ROM MPLS spec byte - // layout. - // ───────────────────────────────────────────────────────────────────── + // ─── Added hardening tests below, grounded in the BD-ROM MPLS spec byte layout ─── /// Header guard: parse() requires `playlist_start + 10 <= data.len()` /// before reading the PlayList header (num_play_items at pl[6..8]). @@ -1210,11 +1192,9 @@ mod tests { /// PG, and the PG must keep its correct PID (proving IG advanced spos). #[test] fn ig_consumed_but_not_retained_and_dv_after_aligned() { - // STN parse order is video, audio, PG, IG, sec_audio, sec_video, - // pip_pg, DV. The IG entry must be consumed (advancing spos) but - // never retained. To PROVE IG advanced the cursor, place a Dolby - // Vision EL after the IG: if IG didn't advance spos, the DV parse - // would land on the IG bytes and read the wrong PID. + // STN parse order is video, audio, PG, IG, sec_audio, sec_video, pip_pg, DV. + // The IG entry must be consumed (advancing spos) but never retained; placing + // a DV EL right after IG proves this — a wrong spos would misread the DV PID. let video = build_stream_entry_video(0x1011, 0x24, 8, 1, Some(0x12)); let ig = build_stream_entry_pg(0x1400, 0x91, b"eng"); // IG entry bytes let dv = build_stream_entry_video(0x1015, 0x24, 8, 1, Some(0x12)); // DV EL diff --git a/src/mux/au_assembly.rs b/src/mux/au_assembly.rs index 31524511..9c1e8c76 100644 --- a/src/mux/au_assembly.rs +++ b/src/mux/au_assembly.rs @@ -245,11 +245,8 @@ impl AuAssembler { dts, source, }); - // Backstop: the `buf`-size cap prunes marks only when bytes accumulate. - // A run of zero-length (or start-code-free) timed fragments grows no - // bytes, so bound the deque directly — drop the oldest (stalest) mark, - // which belongs to an already-emitted or lost AU. A real AU spans far - // fewer fragments than this cap. + // Backstop: zero-length/start-code-free fragments grow no bytes, so the + // `buf`-size cap never prunes marks. Bound the deque directly instead. if self.marks.len() > MAX_MARKS { self.marks.pop_front(); } @@ -318,12 +315,9 @@ impl AuAssembler { } let end_abs = self.base + end as u64; - // The AU's own timing/source: take the FIRST Some of each field - // across every mark in this AU's range [base, end_abs), independently - // — one PES fragment may carry the source while a later fragment of - // the same AU carries the PTS (and vice versa), so reading only the - // front mark would drop the other field. This restores the semantics - // of the pre-consolidation separate pts/source mark deques. + // Take the first Some of each field independently across marks in + // [base, end_abs): one fragment may carry source while another carries + // PTS, so reading only the front mark would drop a field. let (mut pts, mut dts, mut source) = (None, None, None); while self.marks.front().is_some_and(|m| m.off < end_abs) { let m = self.marks.pop_front().unwrap(); @@ -399,10 +393,8 @@ impl AuAssembler { } let data = self.buf[..end].to_vec(); self.buf.drain(..end); - // Shrink toward what this AU actually needed (the tail plus room for - // another AU of about this size). Only the short tail is copied, and it - // brings `cap` back under the `2*end` threshold so the next AU of this - // size hands over instead of copying. + // Shrink toward this AU's actual need so `cap` drops under the `2*end` + // threshold and the next similarly-sized AU hands over instead of copying. self.buf.shrink_to(end.max(tail_len)); return data; } @@ -447,10 +439,9 @@ impl AuAssembler { fn au_boundary_resumable(&mut self) -> Option<usize> { match self.mode { Mode::StartCode(marker) => { - // Stateless: the AU ends at the next delimiter after the opener at - // buf[0]. Resume from the furthest searched offset (never before 4, - // to skip the opening delimiter). find_start_code needs 4 bytes, so - // back up 3 to catch a code straddling the previous buffer end. + // Resume from the furthest searched offset (never before 4, to skip the + // opening delimiter at buf[0]); find_start_code needs 4 bytes, so back + // up 3 to catch a code straddling the previous buffer end. let from = self.scan_pos.max(4); match find_start_code(&self.buf, from, marker) { Some(e) => Some(e), @@ -683,10 +674,8 @@ mod tests { #[test] fn au_merges_pts_and_source_from_different_fragments() { - // One fragment of an AU may carry the source stamp while a later fragment - // of the SAME AU carries the PTS (each PES gets a source; only the anchor - // gets a PTS). The AU must keep BOTH — reading only the front mark would - // drop whichever field the first fragment lacked. + // One fragment may carry the source stamp while a later fragment of the same + // AU carries the PTS; the AU must keep both, not just the front mark's field. let src = crate::pes::SourcePos::at_byte(4242); let mut a = AuAssembler::for_codec(Codec::H264); let full = au(0xAB, 80); @@ -745,10 +734,8 @@ mod tests { #[test] fn vc1_i_frame_keeps_its_preceding_seq_and_entry_headers() { - // An I-frame AU is [seq 0x0F][entry 0x0E][frame 0x0D][slices]; a following - // P-frame is just [frame 0x0D][slices]. A plain 0x0D split would strand the - // seq/entry headers on the P-frame's AU — the decode bug. The VC-1 mode must - // group them with the I-frame that follows them. + // A plain 0x0D split would strand the seq/entry headers on the following + // P-frame's AU (a decode bug); VC-1 mode must group them with the I-frame. let mut a = AuAssembler::for_codec(Codec::Vc1); let mut iframe = bdu(VC1_SEQ, 0xAA, 8); iframe.extend(bdu(VC1_ENTRY, 0xBB, 6)); @@ -875,11 +862,8 @@ mod tests { #[test] fn resumable_boundary_matches_from_scratch_across_all_fragmentations() { - // The incremental scan_pos cursor must produce byte-identical AUs to a - // whole-buffer rescan, at EVERY fragment granularity (this is what makes - // the O(n) resume equivalent to the old O(n^2) from-scratch scan). Build a - // multi-AU stream per codec, reassemble it fed 1 byte at a time up to - // whole, and require one canonical result. + // The incremental scan_pos cursor must match a whole-buffer rescan at every + // fragment granularity, from 1 byte at a time up to whole (O(n) vs O(n^2)). let h264 = { let mut s = au(0x11, 40); // AU1 (AUD + payload) s.extend(au(0x22, 70)); // AU2 @@ -971,11 +955,9 @@ mod tests { "an ordinary AU at the head of a clean run is continuous" ); - // Now start-code-free junk past the cap. The FIRST over-cap run still - // has the next AU's delimiter at buf[0], so it is force-flushed as an - // (over-long) access unit — nothing is discarded and nothing is lost. - // Only once the buffer holds no opener at all does the backstop throw - // bytes away, which is the case this test is about. + // The first over-cap run still has a delimiter at buf[0], so it force-flushes + // as an over-long AU with nothing lost. Only once no opener remains does the + // backstop discard bytes, which is what this test covers. let junk = vec![0xAB; MAX_AU_BUFFER + 4096]; a.push(&junk, Some(2000), None, None, false); a.push(&junk, Some(2100), None, None, false); @@ -1118,10 +1100,8 @@ mod tests { before, "the emitted AU must own the buffer's allocation (no whole-frame copy)" ); - // The replacement buffer keeps room for another AU of about this size, so - // the next AU does not re-grow — but it is NOT pinned to the OLD capacity, - // which would make `buf` a permanent high-water mark and send every later - // smaller AU down the copy path (see + // Must not pin to the old capacity, or `buf` becomes a permanent high-water + // mark sending every later smaller AU down the copy path (see // `handover_survives_a_large_au_instead_of_copying_every_later_one`). assert!( a.buf.capacity() >= au1.len(), @@ -1149,10 +1129,9 @@ mod tests { #[test] fn handover_survives_a_large_au_instead_of_copying_every_later_one() { let mut a = AuAssembler::for_codec(Codec::H264); - // Production shape: BD-TS aligns one access unit per PES, so each `push` - // carries about one AU and the buffer holds ~one AU at a time. One large AU - // (the IDR) followed by a run of much smaller ones (P/B frames). Each AU is - // pushed with the NEXT AU's opener so the previous one closes. + // BD-TS aligns one AU per PES, so each `push` carries about one AU. One large + // AU (IDR) is followed by smaller ones (P/B); each push includes the next + // AU's opener so the previous one closes. const SMALL: usize = 64 * 1024; let mut pending = au(0x11, 2 * 1024 * 1024); for i in 0..20u8 { @@ -1172,12 +1151,8 @@ mod tests { } // ── AU-opener detection: the per-mode start-code rule ───────────────── - // - // `au_opener_from` is the SECOND implementation of a rule each codec parser - // also encodes (h264 `NAL_AUD`, hevc `NAL_AUD`, vc1 `SC_*`, mpeg2 - // `PICTURE_CODE`/`SEQ_HEADER_CODE`/`GOP_CODE`). Two independent copies of one - // rule drift; these cases pin this copy to the normative byte values and to - // the codes that are explicitly NOT openers, so a drift shows up here. + // `au_opener_from` duplicates a rule each codec parser also encodes; these + // cases pin it to the normative byte values so a drift between copies shows up. /// The opener offset must be the position of the real start code, never a /// fixed 0. A constant `Some(0)` makes every pre-sync run of junk bytes look diff --git a/src/mux/codec/ac3.rs b/src/mux/codec/ac3.rs index 0837b5db..e15cbf91 100644 --- a/src/mux/codec/ac3.rs +++ b/src/mux/codec/ac3.rs @@ -33,12 +33,12 @@ use super::{CodecParser, Frame, PesPacket, pts_to_ns}; /// Sample rates indexed by fscod (0=48kHz, 1=44.1kHz, 2=32kHz). fscod=3 is /// reserved in AC-3; in E-AC-3 it signals "fscod2" (reduced rates: 24/22.05/16 -/// kHz, selected by byte-4 bits [5:4]). `frame_sample_rate` decodes fscod2 in +/// kHz, selected by byte-4 bits `[5:4]`). `frame_sample_rate` decodes fscod2 in /// the E-AC-3 case; this table's index-3 entry (48 kHz) is only the fallback /// when the header is too short to read fscod2. const SAMPLE_RATES: [u32; 4] = [48_000, 44_100, 32_000, 48_000]; -/// E-AC-3 reduced sample rates indexed by fscod2 (byte-4 bits [5:4]), used when +/// E-AC-3 reduced sample rates indexed by fscod2 (byte-4 bits `[5:4]`), used when /// fscod==3. Index 3 is reserved; we fall back to 48 kHz for it. const EAC3_REDUCED_RATES: [u32; 4] = [24_000, 22_050, 16_000, 48_000]; @@ -247,18 +247,16 @@ impl Ac3Parser { { self.frames_scanned += 1; } - // Decodability gate: a syncframe with an out-of-range bsid (> 16) or - // a failed native CRC (payload corruption) poisons the access unit it - // belongs to — a dependent substream is useless without its parent and - // vice versa, so the whole access unit is dropped as one silence gap. + // Decodability gate: an out-of-range bsid (> 16) or a failed native CRC + // poisons the whole access unit — a dependent substream is useless + // without its parent and vice versa — so it's dropped as one silence gap. let reason = ac3_drop_reason(&self.tally, frame, bsid); if substream_role(remaining, bsid) == SubstreamRole::Extends { match pending.as_mut() { - // A dependent substream — or an additional independent - // substream (`substreamid` 1..7) — extends the frame set it - // directly follows. Requiring byte contiguity keeps skipped - // junk out of the emitted access unit. + // A dependent (or additional independent, substreamid 1..7) + // substream extending the frame set it directly follows; byte + // contiguity is required to keep skipped junk out of the AU. Some(au) if au.end == start => { au.end = start + frame_size; if au.drop_reason.is_none() { @@ -266,18 +264,9 @@ impl Ac3Parser { } self.saw_extension = true; } - // A substream with no open access unit — a mid-frame-set - // resync, or a stream whose first syncframe is not - // `substreamid` 0 (Annex E orders a frame set independent - // substream 0 first, so joining mid-set is exactly the case - // here). It belongs to a frame set whose mandatory - // `substreamid`-0 substream was never seen, so it is neither - // decodable on its own (a dependent substream) nor timeable - // (an additional independent substream carries the frame set's - // time period, not its own PTS). Skip it rather than ship a - // fragment a decoder cannot use and re-time the whole - // timeline around; resync at the next `substreamid`-0 - // substream. No PTS advance: it carries no duration. + // No open access unit: mid-frame-set resync, or a stream whose + // mandatory substreamid-0 substream was never seen. Neither + // decodable alone nor timeable, so skip it with no PTS advance. _ => { tracing::debug!( target: "mux", @@ -289,9 +278,8 @@ impl Ac3Parser { if let Some(au) = pending.take() { close_access_unit(&mut self.tally, data, &au, marks, &mut frames); } - // First access unit that starts in this PES's own bytes: adopt - // this PES's timestamp so a genuine PTS jump is followed instead - // of the running cadence drifting past it. + // First AU starting in this PES's own bytes: adopt its timestamp so + // a genuine PTS jump is followed, not drifted past. if let Some(a) = &anchor && start >= a.at { @@ -317,26 +305,18 @@ impl Ac3Parser { scanned_to = pos; } - // Close or HOLD the trailing access unit. The rest of its frame set — its - // dependent substreams and any additional independent substreams — may - // still be in the next PES, so an access unit that can still grow is - // held: the carry-over rewinds to its first byte and the whole access - // unit is re-scanned (and only then counted/emitted) next call. An - // E-AC-3 `substreamid`-0 substream can always gain more of its frame set; - // a legacy AC-3 syncframe only in the AC-3-core + E-AC-3-dependent - // arrangement, so it is held only once this track has actually shown a - // substream that extends an access unit — a plain AC-3 track keeps - // emitting every frame in-call. + // Close or HOLD the trailing access unit: its frame set may still continue + // in the next PES, so a growable AU is held and re-scanned next call, but + // only once this track has shown a substream extending an AU (E-AC-3). let mut hold_from = None; let mut held_out = None; if let Some(au) = pending { if !at_eos && (au.bsid >= 11 || self.saw_extension) { frame_pts_ns = au.pts_ns; hold_from = Some(au.start); - // Everything below `scanned_to` has been searched already, and - // the access unit's own frames have been sized and CRC-gated; - // record both, rebased onto the carry-over (which starts at - // `au.start`), so the next call resumes instead of redoing it. + // Everything below `scanned_to` is already searched and CRC-gated; + // record it rebased onto the carry-over (starting at `au.start`) + // so the next call resumes instead of redoing the work. held_out = Some(HeldAu { end: au.end - au.start, scanned_to: scanned_to.max(au.end) - au.start, @@ -349,23 +329,15 @@ impl Ac3Parser { } } - // Keep unconsumed data for the next call. `pos` is the start of the - // last unprocessed search region. On the `start + frame_size > len` - // break it sits exactly at the straddling frame's syncword; on the - // `remaining.len() < 6` break it is the value from the top of that - // iteration, with the syncword possibly sitting after some pre-sync - // junk — so the re-scan below (from `pos`, NOT a recomputed sync) is - // required to locate the carry-over syncword. Carry from `pos`, NOT - // from the next syncword: discarding bytes between `pos` and the next - // sync would drop the partial frame we are deliberately keeping across - // the boundary. A held access unit wins: it starts before `pos`. + // Keep unconsumed data for the next call: re-scan from `pos`, not a + // recomputed sync, to avoid dropping the partial frame kept across the + // PES boundary. Held AU wins (its start is before `pos`). let keep_from = match hold_from { Some(h) => h, None if pos < data.len() => { - // A syncword at/after `pos` marks the carry-over start (anything - // before it is junk with no sync). With no full sync, retain the - // whole tail — including a lone trailing 0x0B that may be the first - // half of a syncword split across the PES boundary. + // A syncword at/after `pos` marks the carry-over start. With no full + // sync, retain the whole tail, including a lone trailing 0x0B that + // may be the first half of a syncword split across the boundary. match find_ac3_sync(&data[pos..]) { Some(o) => pos + o, None if data.last() == Some(&0x0B) => data.len() - 1, @@ -568,17 +540,9 @@ fn ac3_drop_reason( impl CodecParser for Ac3Parser { fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> { - // B1: a concealed/lost gap means the bytes held in `buf` are a TRUNCATED - // frame. Appending the post-gap bytes would splice them into one corrupt - // frame (wrong frame_size, bad CRC → "exponent out of range" / garbage). - // Drop the partial and resync on the next syncword — a clean single-frame - // gap instead of a frankenstein frame. (The video parsers carry this via - // the ResyncGate; audio has no inter-frame refs, so dropping the spliced - // partial is the whole fix.) - // - // Handle the discontinuity BEFORE the empty-data guard so the signal can - // never be stranded by an empty post-gap PES (the demuxer only emits - // non-empty PES today; this is defensive for any future caller). + // B1: a concealed/lost gap means `buf` holds a TRUNCATED frame; appending + // post-gap bytes would splice a corrupt frame, so drop the partial and + // resync on the next syncword. Handled before the empty-data guard. if pes.discontinuity { self.acc.clear(); // The held access unit's bytes went with it. @@ -588,35 +552,19 @@ impl CodecParser for Ac3Parser { return Vec::new(); } - // This PES's timestamp applies to the first access unit that STARTS in - // its own bytes; anything already buffered began in an earlier PES and - // keeps the running cadence (`flush_pts_ns`). Each subsequent access unit - // in the same call advances by the previous one's duration, so a PES that - // carries several access units stamps a monotonically increasing PTS - // instead of the same PES timestamp on all of them (which collapses their - // timecodes and drifts A/V). - // - // A PES with no PTS (rare for audio, but legal — and the case demuxers - // guard at a post-gap continuation) must NOT reset the timeline to 0: with - // no anchor the running cadence simply continues. The discontinuity- - // carrying PES is a PUSI with a PTS in practice, so this is - // defense-in-depth. + // This PES's timestamp applies to the first AU that STARTS in its own bytes; + // later AUs advance by the previous one's duration (avoiding A/V drift). A + // PES with no PTS must NOT reset the timeline: `flush_pts_ns` just continues. let carry_len = self.acc.len(); let anchor = pes.pts.map(|p| PtsAnchor { at: carry_len, pts_ns: pts_to_ns(p), }); - // Prepend leftover from previous PES, then take the whole buffer into a - // local so the scanner can call `self.tally` (the bytes are no longer - // borrowed from `self`). The unconsumed tail is written back at the end. + // Prepend leftover from the previous PES, then take the scratch buffer out + // of `self` so the scanner can borrow `self.tally`; it's put back at the + // end, keeping its capacity. There is no early return after this point. self.acc.push(pes); - // Copy the working bytes out so the scanner can borrow `self.tally`; - // the buffer keeps its marks, so the unconsumed tail stays attributed - // to the packet that carried it. - // Take the scratch OUT of `self` so the scanner can borrow `self.tally` - // while reading it; it is put back at the end of the call, keeping its - // capacity for the next PES. There is no early return after this point. let mut buf = std::mem::take(&mut self.scratch); buf.clear(); buf.extend_from_slice(self.acc.as_slice()); @@ -643,20 +591,16 @@ impl CodecParser for Ac3Parser { ); self.acc.clear(); self.held = None; - // Advance the cadence, as both sibling branches below do, so the - // three paths out of this block cannot disagree. Defensive: no - // input reaching this parser was found that both parses frames and - // leaves a residue this large, so the stale-cadence bug this - // prevents is not currently reachable and has no regression test. + // Advance the cadence, as the other two paths out of this block do, + // so all three cannot disagree. Defensive: no input parsing frames + // and leaving a residue this large is currently reachable. self.flush_pts_ns = frame_pts_ns; } else { self.acc.drain(keep_from); self.held = still_held; - // The carried bytes, when later completed and emitted (next call - // or by flush() at EOS), are timed at the PTS the scanner reached - // here: the PTS of the next access unit in presentation order, or - // — for a HELD access unit — that access unit's own PTS, so the - // hold never shifts it. + // Carried bytes, when later completed and emitted, are timed at the + // PTS the scanner reached here — the next AU's PTS, or for a HELD + // AU, its own PTS, so the hold never shifts it. self.flush_pts_ns = frame_pts_ns; } } else { @@ -675,11 +619,9 @@ impl CodecParser for Ac3Parser { } fn flush(&mut self) -> Vec<Frame> { - // Drain the carry-over buffer at EOS: a complete access unit (including - // one held back waiting for a possible dependent substream) may sit there - // with no following PES to close it, and without this drain the last - // ~32 ms of audio is lost. `at_eos` closes the trailing access unit - // instead of holding it; a partial/garbage tail yields nothing. + // Drain the carry-over at EOS: an AU (possibly held for a dependent + // substream) may sit there with no following PES to close it, else the + // last ~32ms of audio is lost. `at_eos` closes it instead of holding. let buf = self.acc.as_slice().to_vec(); let marks = self.acc.marks_snapshot(); self.acc.clear(); @@ -720,7 +662,7 @@ fn eac3_samples_per_frame(data: &[u8]) -> u32 { /// Sample rate (Hz) of an AC-3/E-AC-3 frame from its fscod field (byte 4 bits /// 7-6). For E-AC-3 (`bsid >= 11`) an fscod of 3 selects a reduced rate via -/// fscod2 (byte 4 bits [5:4]); decoding it keeps the frame duration correct +/// fscod2 (byte 4 bits `[5:4]`); decoding it keeps the frame duration correct /// instead of mistiming reduced-rate frames at 48 kHz (A/V drift). fn frame_sample_rate(data: &[u8], bsid: u8) -> u32 { if data.len() < 5 { @@ -867,7 +809,7 @@ pub(crate) fn ac3_frame_size(data: &[u8]) -> usize { } } -/// AC-3 frame size table: [frmsizecod] -> [48kHz words, 44.1kHz words, 32kHz words] +/// AC-3 frame size table: `[frmsizecod]` -> `[48kHz words, 44.1kHz words, 32kHz words]` const AC3_FRAME_SIZES: [[usize; 3]; 38] = [ [64, 69, 96], [64, 70, 96], @@ -968,11 +910,9 @@ mod tests { "the buffer must be handed back to the parser, not dropped" ); - // The discriminator: a LARGE packet, then a small one. A reused buffer - // keeps the large capacity; a fresh `Vec` sized to each packet drops - // back to the small one. Feeding equal-sized packets cannot tell those - // apart — the first version of this test did exactly that and a - // to_vec()-per-call implementation passed it. + // Discriminator: LARGE packet then small. A reused buffer keeps the large + // capacity; a fresh `Vec` drops to the small size. Equal-sized packets + // can't tell those apart — an earlier to_vec()-per-call bug passed that test. let big = [frame.clone(), frame.clone(), frame.clone(), frame.clone()].concat(); parser.parse(&PesPacket { source: None, @@ -1070,11 +1010,9 @@ mod tests { #[test] fn discontinuity_drops_truncated_partial() { - // B1: a partial AC-3 frame is buffered, then a concealed gap arrives - // (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 (decoders report "incomplete frame" / wrong sync). + // B1: a partial frame is buffered, then a discontinuity-marked PES brings a + // fresh complete frame. The partial must be DROPPED, not spliced, or the + // parser emits one corrupt frame from [stale partial | head of fresh]. let mut parser = Ac3Parser::new(); let frame_data = make_ac3_frame(0, 2); // 160 bytes, starts with 0x0B77 @@ -1218,11 +1156,9 @@ mod tests { #[test] fn buffer_stays_bounded_across_many_garbage_pes() { - // The carry-over buffer must never grow without bound. Feed - // many large PES packets that contain no usable frame and assert the - // retained buffer stays tiny — carry-from-`pos` drops all pre-sync junk, - // and a never-completing frame is bounded by the 8192-byte frame cap and - // the MAX_AC3_BUF resync guard. + // The carry-over buffer must never grow without bound: carry-from-`pos` + // drops pre-sync junk, and a never-completing frame is bounded by the + // 8192-byte frame cap and the MAX_AC3_BUF resync guard. let mut parser = Ac3Parser::new(); for i in 0..256 { // Vary the trailing byte so we also exercise the lone-0x0B retain. @@ -1393,10 +1329,9 @@ mod tests { #[test] fn eac3_fscod2_reduced_rate_duration() { - // E-AC-3 with fscod==3 (reduced rate) and fscod2==0 → 24 kHz, not 48. - // bsid>=11 selects the E-AC-3 path. When fscod==3 the block count is - // fixed at 6 → 1536 samples. Byte 4 layout: fscod(2)|fscod2(2)|... - // fscod=3 (0b11), fscod2=0 (0b00) → byte4 = 0b1100_0000 = 0xC0. + // E-AC-3 with fscod==3 (reduced rate) and fscod2==0 → 24 kHz, not 48; block + // count is then fixed at 6 → 1536 samples. Byte 4: fscod(2)|fscod2(2)|..., + // so fscod=3 (0b11), fscod2=0 (0b00) → byte4 = 0b1100_0000 = 0xC0. let data = [0x0B, 0x77, 0x00, 0x00, 0xC0, 16 << 3]; let bsid = get_bsid(&data); assert!(bsid >= 11, "test frame is E-AC-3"); @@ -1637,11 +1572,9 @@ mod tests { #[test] fn eac3_frame_at_min_frame_bytes_passes_sizing_then_crc_gate() { - // The smallest frame the SIZING layer accepts is MIN_FRAME_BYTES = 6 - // (frmsiz=2). A synthetic all-zero 6-byte frame passes sizing (so it - // reaches the decodability gate — proven by it being COUNTED as a drop, - // not silently size-skipped) but fails the CRC gate and is dropped; the - // following real AC-3 frame (valid CRC) is emitted. + // Smallest frame SIZING accepts is MIN_FRAME_BYTES = 6 (frmsiz=2). An + // all-zero 6-byte frame passes sizing (proven COUNTED as a drop, not + // size-skipped) but fails CRC; the following real AC-3 frame is emitted. let mut parser = Ac3Parser::new(); // 0x0B 0x77 | byte2=0 byte3=2 (frmsiz=2 → 6 bytes) | byte4=0 | byte5 bsid let mut data = vec![0x0B, 0x77, 0x00, 0x02, 0x00, 16 << 3]; @@ -1659,10 +1592,9 @@ mod tests { #[test] fn eac3_max_frmsiz_frame_within_window_accepted() { - // E-AC-3 frmsiz is an 11-bit field (3 bits of byte2 + 8 bits of byte3), - // so its maximum value is 0x7FF = 2047 → (2048)*2 = 4096 bytes, which is - // inside the MIN_FRAME_BYTES..=8192 accept window and, with a valid CRC, - // must be emitted. + // E-AC-3 frmsiz is an 11-bit field, so its max value 0x7FF = 2047 → + // (2048)*2 = 4096 bytes, inside the MIN_FRAME_BYTES..=8192 accept window + // and, with a valid CRC, must be emitted. let mut parser = Ac3Parser::new(); let mut frame = vec![0u8; 4096]; frame[0] = 0x0B; @@ -1671,9 +1603,8 @@ mod tests { frame[3] = 0xFF; // frmsiz low → 0x7FF = 2047 → 4096 bytes frame[5] = 16 << 3; // bsid 16 (E-AC-3) finalize_ac3_crc(&mut frame); // pass the decodability gate - // The trailing E-AC-3 access unit is HELD at the end of the call (a - // dependent substream may follow in the next PES), so it is closed by - // flush() at EOS rather than in-call. Content and size are unchanged. + // Trailing E-AC-3 AU is HELD at end of call (a dependent substream may + // follow), so it's closed by flush() at EOS, not in-call. let mut f = parser.parse(&make_eac3_pes(frame)); f.extend(parser.flush()); assert_eq!(f.len(), 1, "4096-byte E-AC-3 frame within window accepted"); @@ -1720,10 +1651,9 @@ mod tests { #[test] fn flush_rejects_frame_extending_past_buffer() { - // A buffered sync whose decoded frame size exceeds the buffered bytes - // must be dropped by flush (never emit fewer bytes than the size field - // declares). Build a real AC-3 header (160-byte frame) but only buffer - // 100 bytes. + // A buffered sync whose decoded frame size exceeds the buffered bytes must + // be dropped by flush (never emit fewer bytes than declared). Build a real + // 160-byte AC-3 frame but only buffer 100 bytes. let mut parser = Ac3Parser::new(); let frame = make_ac3_frame(0, 2); // sizes to 160 parser.acc.seed(&frame[..100]); @@ -1784,11 +1714,9 @@ mod tests { #[test] fn acmod_channels_stereo_2_0_no_lfe() { - // acmod=2 (2/0 L,R), no LFE → 2 channels. Verifies the channel count is - // read from the AC-3 bitstream's acmod, independent of any IFO claim. - // (A disc whose IFO lists 5.1 but where the wrong physical substream is - // selected is a separate stream-SELECTION bug, not this label path — - // tracked for rc.5.2.) + // acmod=2 (2/0 L,R), no LFE → 2 channels; verifies the channel count comes + // from the AC-3 bitstream's acmod, independent of any IFO claim (a disc + // IFO/substream mismatch is a separate SELECTION bug, tracked for rc.5.2). assert_eq!(acmod_channels(&make_bsi(2, false)), Some(2)); } @@ -1896,9 +1824,8 @@ mod tests { #[test] fn crc_drop_preserves_pts_sync_no_shift() { // THE INVARIANT: dropping a corrupt frame must not shift the audio after - // it. good / corrupt / good in one PES — the corrupt frame is dropped but - // the trailing clean frame keeps the EXACT PTS it would have had with no - // drop (base + 2 frame durations): a silence gap, not a shift. + // it. good/corrupt/good in one PES — the trailing clean frame keeps the + // EXACT PTS it would have had with no drop: a silence gap, not a shift. let mut parser = Ac3Parser::new(); let mut data = make_ac3_frame(0, 2); // f0 data.extend_from_slice(&make_corrupt_ac3_frame(0, 2)); // dropped @@ -1968,13 +1895,9 @@ mod tests { #[test] fn eac3_independent_plus_dependent_is_one_access_unit() { - // THE FIX: per ETSI TS 102 366 Annex E an access unit is the independent - // substream (strmtyp 0) plus every dependent substream (strmtyp 1) that - // follows it — the 7.1 Dolby Digital Plus arrangement. Both syncframes - // must emerge as ONE frame carrying the INDEPENDENT substream's PTS and - // exactly ONE frame duration (previously each syncframe was emitted as - // its own access unit and each advanced the clock, doubling the timeline - // and handing decoders a parentless dependent substream). + // THE FIX: per ETSI TS 102 366 Annex E an AU is the independent substream + // plus every dependent substream that follows it — both must emerge as ONE + // frame with the independent PTS and ONE duration (not double it). let mut parser = Ac3Parser::new(); let indep = make_eac3_frame(0, 0, 160); let dep = make_eac3_frame(1, 0, 96); @@ -2014,10 +1937,9 @@ mod tests { #[test] fn eac3_grouped_timeline_is_not_doubled() { - // Two complete access units (independent + dependent each) in one PES, - // followed by a third independent substream that closes the second. - // The emitted PTS cadence must be one frame duration per access unit — - // the 2x-runtime / A-V-drift symptom of ungrouped substreams. + // Two complete access units (independent + dependent each) in one PES, plus + // a third independent substream closing the second. PTS cadence must be + // one frame duration per AU — else the 2x-runtime / A-V-drift symptom. let mut parser = Ac3Parser::new(); let mut data = Vec::new(); for _ in 0..2 { @@ -2038,10 +1960,9 @@ mod tests { #[test] fn plain_ac3_frames_are_not_grouped_or_delayed() { - // NO REGRESSION for legacy AC-3 (bsid < 11): it has no substream - // structure (byte 2 is crc1, not strmtyp), so every syncframe is a - // complete access unit, emitted in the SAME call — never merged with its - // neighbour and never held back for a dependent that cannot exist. + // NO REGRESSION for legacy AC-3 (bsid < 11): it has no substream structure + // (byte 2 is crc1, not strmtyp), so every syncframe is a complete AU, + // emitted in the SAME call — never merged, never held for a dependent. let mut parser = Ac3Parser::new(); let mut data = Vec::new(); for _ in 0..3 { @@ -2060,10 +1981,9 @@ mod tests { #[test] fn eac3_access_unit_split_across_pes_is_grouped() { - // The access unit boundary is only known at the NEXT independent - // substream, so a trailing independent substream is held across the PES - // boundary: the dependent half arriving in the next PES still joins it, - // and the AU keeps the FIRST PES's PTS (its independent substream's). + // The AU boundary is only known at the NEXT independent substream, so a + // trailing one is held across the PES boundary: the dependent half in the + // next PES still joins it, keeping the FIRST PES's PTS. let mut parser = Ac3Parser::new(); let indep = make_eac3_frame(0, 0, 160); let dep = make_eac3_frame(1, 0, 96); @@ -2174,14 +2094,9 @@ mod tests { #[test] fn eac3_additional_independent_substream_stays_in_the_frame_set() { - // THE FIX: an Annex E frame set is independent substream 0 (mandatory, - // first) with its dependents, then the OPTIONAL additional independent - // substreams 1..7 with theirs — ALL covering the SAME time period. An - // additional independent substream (an associated / commentary service) - // therefore belongs to the frame set already open; keying the access-unit - // boundary on `strmtyp` alone made it close the AU and advance the running - // PTS a SECOND time over the same 32 ms, doubling the timeline (~1 s of - // A/V drift per second of audio). + // THE FIX: a frame set is substream 0 plus dependents, then OPTIONAL + // additional independent substreams 1..7 — ALL the SAME time period. + // Keying the AU boundary on `strmtyp` alone doubled the PTS advance. let mut parser = Ac3Parser::new(); let ind0 = make_eac3_frame(0, 0, 160); // main programme let dep0 = make_eac3_frame(1, 0, 96); // its dependent (7.1 extension) @@ -2228,13 +2143,9 @@ mod tests { #[test] fn eac3_stream_joined_mid_frame_set_resyncs_at_substreamid_0() { - // A stream whose first syncframe is an ADDITIONAL independent substream - // (here substreamid 3) joined a frame set whose mandatory substreamid-0 - // substream was never seen. Mirroring the orphan-dependent rule, it is - // skipped: it carries the frame set's time period rather than its own, so - // emitting it as an access unit would invent a period for audio whose - // main programme is missing. Grouping resyncs at the next substreamid-0 - // substream, and the orphan consumes none of the timeline. + // A stream whose first syncframe is ADDITIONAL substream (substreamid 3) + // joined a set whose mandatory substreamid-0 was never seen. It's skipped + // (carries the set's time period, not its own); resyncs at substreamid-0. let mut parser = Ac3Parser::new(); let orphan = make_eac3_frame(0, 3, 128); let orphan_dep = make_eac3_frame(1, 3, 96); @@ -2291,10 +2202,9 @@ mod tests { #[test] fn corrupt_dependent_substream_drops_the_whole_access_unit() { - // A dependent substream that fails its native CRC poisons the access unit - // it belongs to: emitting the independent half alone would ship an AU a - // decoder must reassemble from a corrupt pair. The drop accounts for one - // frame duration, so the following AU keeps its true PTS (gap, not shift). + // A dependent substream failing its native CRC poisons the whole AU: + // emitting the independent half alone ships a pair no decoder can + // reassemble. The drop still accounts for one duration (gap, not shift). let mut parser = Ac3Parser::new(); let mut dep = make_eac3_frame(1, 0, 96); dep[20] ^= 0xFF; // break the dependent substream's CRC @@ -2516,10 +2426,9 @@ mod tests { let mut data = Vec::new(); for _ in 0..CORRUPT_AUS { let mut f = eac3_substream_frame(0, 0); - // Corrupt a payload byte AFTER the CRC was finalized: the header - // (sizing, strmtyp/substreamid, bsid) stays intact so the frame is - // still parsed as a whole access unit and fails only the CRC — - // a VERIFIED drop, the only kind that feeds the poison verdict. + // Corrupt a payload byte AFTER CRC finalization: header stays intact so + // the frame is still parsed whole and fails only CRC — a VERIFIED + // drop, the only kind that feeds the poison verdict. f[100] ^= 0xFF; assert!(!frame_crc_ok(&f), "the fixture frame must fail its CRC"); data.extend_from_slice(&f); diff --git a/src/mux/codec/adts.rs b/src/mux/codec/adts.rs index d811399f..0b6afcd4 100644 --- a/src/mux/codec/adts.rs +++ b/src/mux/codec/adts.rs @@ -50,13 +50,9 @@ fn adts_verdict(data: &[u8]) -> AdtsVerdict { // aac_frame_length: 13 bits = byte3[1:0] | byte4 | byte5[7:5]. let frame_length = ((u32::from(data[3]) & 0x03) << 11) | (u32::from(data[4]) << 3) | (u32::from(data[5]) >> 5); - // The floor is the header the frame SAYS it carries, not a constant. - // protection_absent (byte1 bit0) clear means a 16-bit crc_check follows the - // 7-byte fixed+variable header, so the frame cannot be shorter than 9 — - // aac_frame_length counts the header and the CRC, not just the payload. - // Comparing against a flat 7 let a CRC-present frame declaring 7 or 8 - // through as structurally Valid, and the muxer then carried a frame whose - // own header says it is impossible. + // Floor depends on protection_absent: CRC-present (byte1 bit0 clear) adds a + // 16-bit crc_check after the 7-byte header, so the min is 9, not a flat 7 — + // else a CRC frame declaring length 7-8 wrongly passed as Valid. let header_bytes = if data[1] & 0x01 == 0 { 9 } else { 7 }; if frame_length < header_bytes { return AdtsVerdict::Invalid; diff --git a/src/mux/codec/coding.rs b/src/mux/codec/coding.rs index f8e7b5f4..f956fcf8 100644 --- a/src/mux/codec/coding.rs +++ b/src/mux/codec/coding.rs @@ -3,7 +3,7 @@ //! [`PictureInfo`] is the single per-frame carrier of coding signals that the //! muxer (and any downstream index/diagnostic) reads WITHOUT branching on the //! codec. Each codec's parser decodes its own bitstream once and folds the raw -//! signals into a [`CodingDetail`] variant; consumers then call ONLY the +//! signals into a [`CodingDetail`](crate::mux::codec::coding::CodingDetail) variant; consumers then call ONLY the //! codec-agnostic accessors ([`coding_type`](PictureInfo::coding_type), //! [`field_order`](PictureInfo::field_order), [`nb_fields`](PictureInfo::nb_fields), //! [`progressive`](PictureInfo::progressive)). The accessor surface is fixed: @@ -181,13 +181,9 @@ impl PictureInfo { match self.detail { CodingDetail::Mpeg2(m) => { if !m.frame_picture { - // A single field picture is inherently interlaced. Which - // field it actually codes is given by picture_structure - // (top/bottom), not by top_field_first — §6.3.10 constrains - // top_field_first to 0 for field pictures, so it is not the - // spec source here. picture_structure is not retained on - // this carrier, so top_field_first is used only as the lone - // field hint available (best-effort, not spec-derived). + // Field pictures are coded top/bottom by picture_structure, not + // top_field_first (§6.3.10 forces it to 0 here). picture_structure + // isn't retained on this carrier, so this is a best-effort hint. Some(if m.top_field_first { FieldOrder::Tff } else { diff --git a/src/mux/codec/crc.rs b/src/mux/codec/crc.rs index aab6db39..b5bb2209 100644 --- a/src/mux/codec/crc.rs +++ b/src/mux/codec/crc.rs @@ -99,24 +99,18 @@ mod tests { #[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. + // Independent known-answer (poly 0x002D, init 0, MSB-first) from a separate + // reference implementation, not crc16_mlp itself — catches a wrong + // polynomial/shift that truehd fixtures (self-derived) would miss. 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. - // This is a property of the CRC itself, pinned here so a change to the - // polynomial or the bit order is caught. It is NOT how the TrueHD - // caller validates a major sync: `truehd::mlp_major_sync_crc_ok` does a - // swap-and-XOR compare against the little-endian trailer word. (This - // comment used to claim the caller relied on the residue, and named a - // `truehd::mlp_major_sync_ok` that does not exist.) + // Appending the big-endian CRC zeroes the residue (pinned to catch poly/bit + // changes). NOT how TrueHD validates — mlp_major_sync_crc_ok instead does a + // swap-and-XOR compare against the little-endian trailer word. let msg = [0xF8u8, 0x72, 0x6F, 0xBA]; let c = crc16_mlp(&msg); let mut framed = msg.to_vec(); diff --git a/src/mux/codec/dropgate.rs b/src/mux/codec/dropgate.rs index 37cabf2b..524acbc6 100644 --- a/src/mux/codec/dropgate.rs +++ b/src/mux/codec/dropgate.rs @@ -59,7 +59,7 @@ impl DropTally { } /// Whether the track has been judged too damaged to mux. Once `true`, the - /// caller should drop every remaining AU (passing them to [`record_drop`] + /// caller should drop every remaining AU (passing them to [`Self::record_drop`] /// with a poison reason) rather than emit them. pub(crate) fn is_poisoned(&self) -> bool { self.poisoned diff --git a/src/mux/codec/dts.rs b/src/mux/codec/dts.rs index 799bf1b2..16957644 100644 --- a/src/mux/codec/dts.rs +++ b/src/mux/codec/dts.rs @@ -45,7 +45,7 @@ pub struct DtsParser { /// Keep/drop bookkeeping for the decodability gate: counts, per-drop and /// aggregate logging, and the whole-track poison fallback. A dropped AU is /// NEVER emitted, but the PTS clock is still advanced across it (see - /// [`stamp_pts`] usage) so every SURVIVING AU keeps the exact timestamp it + /// [`Self::stamp_pts`] usage) so every SURVIVING AU keeps the exact timestamp it /// would have had — a drop becomes a silence gap, never a shift. tally: super::dropgate::DropTally, } @@ -120,7 +120,7 @@ impl DtsParser { } /// Stamp an access unit's PTS. `front` is the AU's own core-PES PTS (from - /// [`front_pts`]); `dur_ns` its decoded duration. + /// [`Self::front_pts`]); `dur_ns` its decoded duration. /// /// The model matches the (correct) AC-3 path: **re-base to each PES's own /// container timestamp, and advance by one frame duration ONLY within a run @@ -201,20 +201,9 @@ const PTS_UNSET: i64 = -1; impl CodecParser for DtsParser { fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> { - // B1: a concealed/lost gap means the buffered DTS access unit is - // TRUNCATED. Splicing post-gap bytes onto it corrupts the core/extension - // framing (→ "Failed to decode block code(s)" / "Invalid data found"). - // Drop the partial AU and its PTS marks; the next PES re-bases a fresh - // unit. (DTS core/extension frames are independently decodable — every - // frame re-inits on its own syncword — so dropping the truncated partial - // and resuming on the next frame is sufficient here. This is NOT true of - // every audio codec: TrueHD/MLP carries predictor + restart state across - // access units and must additionally drop forward to its next major sync; - // see `codec::truehd`. The video ResyncGate handles inter-coded video.) - // - // Handle the discontinuity BEFORE the empty-data guard so the signal can - // never be stranded by an empty post-gap PES (defensive; the demuxer only - // emits non-empty PES today). + // B1: a concealed/lost gap means the buffered DTS AU is TRUNCATED; splicing + // post-gap bytes onto it corrupts the framing. Drop the partial AU and its + // PTS marks (DTS frames independently resync, unlike TrueHD/MLP). if pes.discontinuity { self.acc.clear(); self.pending_pts = PTS_UNSET; @@ -226,10 +215,6 @@ impl CodecParser for DtsParser { if pes.data.is_empty() { return Vec::new(); } - // 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. // A PES with no PTS (rare for audio, but legal) must NOT reset the // timeline to 0 — continue from the most recent known base. let pts_ns = super::pesbuf::PesFacts::of(pes) @@ -240,34 +225,15 @@ impl CodecParser for DtsParser { 0 }); - // On Blu-ray, a DTS-HD MA/HRA access unit is a DTS core frame - // (sync 0x7FFE8001) followed by one or more DTS extension substreams - // (sync 0x64582025). The m2ts demuxer hands those out as SEPARATE PES - // packets on the same PID — the core in one PES, then the extension - // substreams in following PES packets (with their own, later PTS). The - // lossless audio lives entirely in the extension substreams, so an - // access unit is only complete once all of its trailing extensions - // have been buffered. We assemble across PES boundaries here: an access - // unit runs from its core sync up to (but not including) the NEXT core - // sync. Emitting on the core boundary keeps the core + every following - // extension substream together (the lossless data), instead of the - // old per-PES emit that dropped the extension PES packets and - // downgraded the track to lossy DTS core (the lossy-core - // downgrade bug). The PTS is the core frame's PTS, captured when the unit began. - // Capture the access unit's PTS base on a fresh buffer, or whenever a - // prior forced (safety-valve) flush left it invalidated — in the - // forced case the bytes still in `buf` are not a real core frame, so - // the first PES to arrive after the flush carries the correct base. + // Blu-ray DTS-HD MA/HRA: core frame + extension substreams (lossless data) + // arrive in SEPARATE, later PES packets, so assemble core-to-next-core + // (else extensions get dropped = lossy core) and capture PTS base fresh. if self.acc.is_empty() || self.pending_pts == PTS_UNSET { self.pending_pts = pts_ns; } - // Mark where THIS PES's bytes begin in the buffer, with its PTS. The - // emitted access unit takes the PTS of the PES covering its first byte - // (see `front_pts`), so an AU whose core arrived in an earlier PES keeps - // that core's timestamp even when its extensions / the following core - // arrive (with a later PTS) in this same parse() call. - // `pts_ns` is this packet's own timestamp, or the carried-forward base - // when it had none; the source offset is always this packet's. + // Mark where THIS PES's bytes begin, with its PTS: the emitted AU takes + // the PTS of the PES covering its first byte (see `front_pts`), so an AU + // whose core arrived earlier keeps that timestamp over later extensions. self.acc.push_with( &pes.data, super::pesbuf::PesFacts::of(pes).with_pts_ns(pts_ns), @@ -303,15 +269,9 @@ impl CodecParser for DtsParser { break; } let core_size = dts_core_frame_size(self.acc.as_slice()); - // `dts_core_frame_size` returns a 14-bit `fsize + 1`, so it is - // always in [1, 16384]; the bare `== 0` / `> MAX_AU_BYTES` checks - // can never fire. A real DTS core frame is at least - // MIN_CORE_FRAME_BYTES (96, the ETSI spec floor), so any decoded - // size below that came from a false/corrupt sync. Reject it (drain - // the 4 syncword bytes and resync) instead of letting a tiny bogus - // size close the current access unit at a junk boundary and drop the - // trailing extension substreams. The `> MAX_AU_BYTES` upper bound is - // kept as a harmless guard. + // A real core frame is at least MIN_CORE_FRAME_BYTES (96, ETSI floor); + // a smaller size came from a false sync, so reject it (drain 4 bytes, + // resync) rather than close the AU at a junk boundary. if !(MIN_CORE_FRAME_BYTES..=MAX_AU_BYTES).contains(&core_size) { // Bogus core sync — skip past it and resync. self.drain_front(4); @@ -321,39 +281,24 @@ impl CodecParser for DtsParser { break; // core frame not fully buffered yet — wait } - // The access unit ends at the next *valid* core sync. The search - // begins after this core's syncword so we don't re-match it. - // Anything between the core and that next sync is this unit's - // extension substream(s) — which can themselves contain byte - // sequences matching the core syncword, so a raw `find_sync` match - // is not enough: a candidate is only a real boundary if its decoded - // core size is plausible. `next_core_boundary` skips bogus matches. - // - // `forced` distinguishes a real next-core boundary from a forced - // safety-valve flush. On a forced flush the access unit was NOT - // closed by a new core sync, so the bytes following it are not a - // fresh core frame and the current PES's PTS (which on a forced - // flush is an extension-substream PES, carrying its own later - // timestamp) must NOT become the next unit's PTS base. + // The AU ends at the next *valid* core sync; extension bytes can contain + // false syncword matches, so `next_core_boundary` validates decoded size. + // `forced` marks a safety-valve flush whose PTS must NOT become the base. let mut forced = false; let (au_end, ext_clean) = match next_core_boundary(self.acc.as_slice(), core_size) { NextCore::Found { end, ext_clean } => (end, ext_clean), NextCore::NeedMore if self.acc.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). + // A candidate boundary isn't fully buffered; normally wait, but + // past the AU cap apply the same force-flush as `None` so a + // crafted stream can't grow `buf` without bound. forced = true; (self.acc.len(), true) } NextCore::None => { - // No next core sync buffered yet. The trailing extension - // substream PES packets may still be arriving, so WAIT for - // them rather than emit a core-only (lossy) frame — unless - // the buffer has grown unreasonably large, in which case - // emit what we have to guarantee forward progress. + // No next core sync yet: trailing extension PES packets may + // still arrive, so WAIT rather than emit a lossy core-only + // frame, unless the buffer has grown unreasonably large. if self.acc.len() <= MAX_AU_BYTES { break; } @@ -362,26 +307,18 @@ impl CodecParser for DtsParser { } }; - // Damaged source encoding: when the extension boundary was GARBAGE - // (not any DTS sync — `ext_clean == false`), the extension bytes for - // this AU are corrupt and would make the decoder cascade "DSYNC check - // failed" / "Read past end of XLL band data". Emit the clean DTS core - // ALONE (a decodable, lossy frame) and still drain past the garbage to - // the next core — a perfect mux drops the bad frame's corrupt part - // rather than shipping it. A recognized-but-unsizeable extension - // (`ext_clean == true`) is preserved in full (lossless). + // When the extension boundary is GARBAGE (`ext_clean == false`), emit + // the clean core ALONE (lossy) and drain past it to the next core; an + // unsizeable-but-recognized extension is kept in full (lossless). let emit_end = if ext_clean { au_end } else { core_size }; let au: Vec<u8> = self.acc.as_slice()[..emit_end].to_vec(); - // The AU's own core PES PTS (the PES covering its first byte, even if - // that PES preceded the one(s) carrying its extensions or the next - // core), stamped monotonically: honored when it advances past the - // running clock (UHD one-AU-per-PES), but never allowed to collide - // with the previous AU when several cores share ONE PES (DVD). + // The AU's own core PES PTS, stamped monotonically: honored when it + // advances past the running clock (UHD, one AU per PES), but never + // allowed to collide with the previous AU (DVD, several cores per PES). let dur_ns = dts_core_duration_ns(&au) as i64; - // Advance the PTS clock for this AU BEFORE the decodability gate, so - // a dropped AU still advances the timeline exactly as an emitted one - // would: the following AU keeps its true PTS and the drop is a gap, - // never a shift. `emit_or_drop` decides whether to actually push it. + // Advance the PTS clock BEFORE the decodability gate, so a dropped AU + // still advances the timeline like an emitted one (gap, not shift). + // `emit_or_drop` decides whether to actually push it. let au_pts = self.stamp_pts(self.front_pts(), dur_ns); // Read BEFORE draining: after the drain the front is the NEXT // unit's packet, not this one's. @@ -393,10 +330,8 @@ impl CodecParser for DtsParser { // marker survives. Track it so the fallback stays sensible. self.pending_pts = self.front_pts(); if forced { - // Safety-valve flush: the next access unit's real core PES has - // not arrived. Invalidate the PTS so the next PES sets it - // regardless of buffer state, rather than inheriting this - // (non-core) PES's timestamp. + // Safety-valve flush: the next AU's real core PES hasn't arrived, + // so invalidate the PTS rather than inherit this non-core PES's. self.pending_pts = PTS_UNSET; } } @@ -557,10 +492,8 @@ fn next_core_boundary(buf: &[u8], core_size: usize) -> NextCore { } pos += sz; // skip the whole extension substream precisely } - // A real extension sync we couldn't size (truncated header / - // unsupported sub-form) — heuristic fallback, but the region IS a - // recognized extension, so keep it (ext_clean = true). This path - // is load-bearing for valid streams. + // A real extension sync we couldn't size (truncated/unsupported): + // heuristic fallback, but the region IS recognized, so keep it. _ => return scan_for_next_core(buf, pos, true), } } else if buf[pos..].starts_with(&DTS_CORE_SYNC) { @@ -578,10 +511,9 @@ fn next_core_boundary(buf: &[u8], core_size: usize) -> NextCore { } return scan_for_next_core(buf, pos, true); // implausible core — recognized sync, keep } else { - // GARBAGE at the extension boundary — neither a core sync nor a - // DTS-HD extension sync. This is damaged source encoding: the - // extension region is corrupt. Mark ext_clean = false so the caller - // emits the clean core alone and drops the garbage. + // GARBAGE at the extension boundary — neither a core nor extension + // sync, so the extension region is corrupt: mark ext_clean = false so + // the caller emits the clean core alone and drops the garbage. return scan_for_next_core(buf, pos, false); } } @@ -752,14 +684,9 @@ impl DropReason { fn core_header_drop_reason(au: &[u8]) -> Option<DropReason> { let mut r = BitReader::new(au.get(SYNCWORD_BYTES..)?); - // FTYPE: 1 = NORMAL frame, 0 = TERMINATION frame (the last frame of the - // stream). Per ETSI TS 102 114: a normal frame must carry a full 32-sample - // PCM block, so a DEFICIT_SAMPLE_COUNT other than the full block marks a - // terminating/short frame (the spec's deficit semantics, which - // on `normal_frame`) — the deficit-sample field must equal 32 ONLY for a - // normal frame. A termination frame legitimately carries fewer samples and - // is fully decodable; dropping it would silence the last frame of every - // stream that ends on one (a guaranteed per-track loss on real discs). + // FTYPE: 1 = NORMAL frame, 0 = TERMINATION frame. Per ETSI TS 102 114 the + // deficit-sample field must equal 32 ONLY for a normal frame; gating a + // termination frame on it would silence the last frame of every stream. let normal_frame = r.read_bit()? == 1; let deficit_samples = r.read_bits(5)? + 1; if normal_frame && deficit_samples != DTS_PCMBLOCK_SAMPLES { @@ -783,11 +710,9 @@ fn core_header_drop_reason(au: &[u8]) -> Option<DropReason> { return Some(DropReason::SampleRate); } let _br_code = r.read_bits(5)?; - // Reserved bit. Both reference decoders SKIP this field rather than reject - // on it — the field is reserved in ETSI TS 102 114 and skipped ("Reserved - // field"). A frame that sets it is still fully decodable, so rejecting it - // was a false-drop that silenced any real stream whose encoder set the bit. - // Read past it without gating (never reject a decodable frame). + // Reserved bit (ETSI TS 102 114): both reference decoders SKIP it rather + // than reject on it, so read past without gating — a frame setting it is + // still fully decodable. let _reserved = r.read_bit()?; // drc, ts, aux, hdcd (1 each) → ext_audio_type (3) → ext_present, aspf (1 each). r.skip_bits(4)?; @@ -833,11 +758,9 @@ mod tests { let fsize = size - 1; let mut data = vec![0u8; size]; data[0..4].copy_from_slice(&DTS_CORE_SYNC); - // byte4: FTYPE(1) SHORT(5) CPF(0) NBLKS-high(0). FTYPE = 1 = a NORMAL - // frame (the common real-stream case); SHORT = 31 makes deficit_samples - // = 32 = DTS_PCMBLOCK_SAMPLES, which the decodability gate (per ETSI TS - // 102 114) requires of a normal frame. NBLKS high bit (byte4 bit0) stays - // 0 for NBLKS = 15. (0x80 | (31 << 2) = 0xFC.) + // byte4: FTYPE(1) SHORT(5) CPF(0) NBLKS-high(0). FTYPE=1 (NORMAL frame); + // SHORT=31 makes deficit_samples=32=DTS_PCMBLOCK_SAMPLES, required by the + // decodability gate for a normal frame. (0x80 | (31 << 2) = 0xFC.) data[4] = 0x80 | (31u8 << 2); // NBLKS = 15 → (15+1)*32 = 512 samples/frame (the DVD/UHD DTS-core norm). // NBLKS is byte4 bit0 + byte5 bits7-2; here byte4 bit0 = 0, byte5 = 15<<2. @@ -935,11 +858,9 @@ mod tests { // EXSS size parse round-trips. assert_eq!(exss_frame_size(&make_exss(600, None)), Some(600)); - // 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 - // real-disc "Failed to decode block code(s)" decoder-failure class). - // Precise EXSS sizing spans the whole extension to the REAL next core. + // AU = core(512) + a REAL EXSS substream whose XLL payload embeds a false + // DTS core syncword (plausible size 512). Precise EXSS sizing must span + // the whole extension to the REAL next core, or the extension gets split. let core = make_dts_core(512); let exss = make_exss(600, Some(40)); let next = make_dts_core(512); @@ -958,10 +879,9 @@ mod tests { #[test] fn garbage_extension_emits_core_only_but_valid_ext_is_kept() { - // Damaged source: a valid core, then GARBAGE (no core sync, no extension - // sync) where the extension belongs, then the next core. The framer must - // mark this boundary ext_clean=false and the parser must emit the clean - // 512-byte CORE alone (dropping the garbage), draining to the next core. + // Damaged source: valid core, then GARBAGE (no core/extension sync) where + // the extension belongs, then the next core. Must mark ext_clean=false and + // emit the clean 512-byte CORE alone, draining past the garbage. let core = make_dts_core(512); let garbage = vec![0xE4, 0x3F, 0xE3, 0x90, 0xCC, 0x6C]; // real head bytes from a damaged stream let mut garbage = garbage; @@ -1043,12 +963,9 @@ mod tests { #[test] fn discontinuity_drops_truncated_partial() { - // 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 real-disc "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). + // B1: a partial core is buffered, then a discontinuity-marked PES carries + // a fresh one. The partial must be DROPPED, not spliced; the post-gap + // core is the only AU emitted, with the post-gap PTS. let mut parser = DtsParser::new(); // PES 1: first half of a 512-byte core (no boundary marker). @@ -1086,10 +1003,9 @@ mod tests { #[test] fn two_cores_back_to_back_advance_within_one_pes() { - // Both cores arrive in ONE PES — the DVD layout. AU1 keeps the PES PTS; - // AU2 must ADVANCE by one frame duration to stay monotonic. Reusing the - // single PES PTS for both was the "non monotonically increasing dts to - // muxer" bug (fixed for 1.2.1). + // Both cores arrive in ONE PES (DVD layout). AU1 keeps the PES PTS; AU2 + // must ADVANCE by one frame duration to stay monotonic — reusing the same + // PES PTS for both was the "non monotonically increasing dts" bug (1.2.1). let mut parser = DtsParser::new(); let mut stream = make_dts_core(512); stream.extend_from_slice(&make_dts_core(640)); @@ -1114,12 +1030,9 @@ mod tests { #[test] fn two_aus_flushed_in_one_call_keep_their_own_pts() { - // The real-stream trigger: core1 in PES A (pts 100), then core2 + core3 - // arrive in a LATER PES B (pts 200). Processing PES B closes both AU1 - // (core1) and AU2 (core2) in a single parse() call. AU2's core arrived - // in PES A region? No — here AU2 (core2) is in PES B, so it should be - // 200. The defect to guard is AU1 NOT being overwritten to 200, and AU2 - // not inheriting an unrelated timestamp. + // core1 in PES A (pts 100), then core2 + core3 in a LATER PES B (pts 200). + // Processing PES B closes both AU1 (core1) and AU2 (core2, in PES B, so + // 200) in one parse() call. Guard: AU1 must NOT be overwritten to 200. let mut parser = DtsParser::new(); // PES A: just core1 (held — no following core yet). @@ -1154,10 +1067,9 @@ mod tests { #[test] fn second_au_with_core_in_earlier_pes_keeps_that_pts() { - // core1 + core2 both arrive in PES A (pts 100); core3 arrives in PES B - // (pts 200). When PES B closes AU2 (core2, whose core was in PES A), AU2 - // must keep PES A's 100 — the bug was AU2 inheriting the closing PES's - // 200. + // core1+core2 arrive in PES A (pts 100); core3 in PES B (pts 200). When PES + // B closes AU2 (core2, whose core was in PES A), AU2 must keep PES A's + // 100 — the bug was AU2 inheriting the closing PES's 200. let mut parser = DtsParser::new(); // PES A: core1 + core2. AU1 (core1) emits immediately (core2 boundary); @@ -1168,10 +1080,9 @@ mod tests { assert_eq!(f.len(), 1); assert_eq!(f[0].pts_ns, pts_to_ns(90000), "AU1 PES A PTS"); - // PES B (realistically later): core3 — closes AU2 (core2). AU2's core - // was in PES A, so it is PES A's 2nd frame: it advances one frame - // duration from AU1 (still on PES A's timeline, and monotonic) — it does - // NOT inherit the closing PES B PTS. + // PES B: core3 closes AU2 (core2). AU2's core was in PES A, so it's PES + // A's 2nd frame — advances one duration from AU1, and does NOT inherit + // the closing PES B PTS. let f2 = parser.parse(&make_pes(make_dts_core(640), Some(190000))); assert_eq!(f2.len(), 1); assert_eq!(f2[0].data.len(), 600, "AU2 = core2"); @@ -1193,10 +1104,9 @@ mod tests { #[test] 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 a muxer rejects as - // "non monotonically increasing dts to muxer: X >= X". + // Punisher-DVD repro: a PES with SEVERAL DTS cores must emit STRICTLY + // increasing PTSs; the old code stamped every AU with the one PES PTS, + // rejected by muxers as "non monotonically increasing dts: X >= X". let mut parser = DtsParser::new(); let mut stream = Vec::new(); for _ in 0..6 { @@ -1242,11 +1152,9 @@ mod tests { #[test] fn dts_core_sfreq_table_matches_the_dca_spec() { - // 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. + // Lock the SFREQ → sample-rate table to ETSI TS 102 114 Table 6-4. The + // high-rate triad (48/96/192k at indices 13/14/15) must not shift; a wrong + // entry computes an N× frame duration, reintroducing PTS drift. let mut core = make_dts_core(512); let set_sfreq = |c: &mut [u8], idx: u8| c[8] = (c[8] & !0x3C) | ((idx & 0x0F) << 2); for (idx, want) in [ @@ -1273,19 +1181,9 @@ mod tests { #[test] fn new_pes_rebases_to_its_own_pts_no_drift() { - // Regression for the drift bug: a global running clock overshot a - // feature-long DTS track by minutes (2h44 for a 2h03 film). When a NEW - // PES arrives whose PTS is BEHIND where accumulated frame durations - // would put a running clock, the AU must re-base to that PES's OWN - // timestamp — tracking the container, not drifting ahead of it. - // - // The re-base can make one emitted PTS sit just below the previous AU's - // (a fresh PES whose PTS lands under the within-PES cursor). That is - // CORRECT here and is NOT a muxer defect: the parser reports the true - // container timestamps, and the mkv muxer applies the strictly-monotonic - // per-track nudge to AUDIO at emit time (`mkv::block_ts` / `monotonic_ts`, - // tested in `mkv.rs`), so the written block DTS is always monotonic. The - // alternative — clamping in the parser — is what reintroduced the drift. + // Drift-bug regression: a global running clock overshot a track by minutes. + // A NEW PES BEHIND the running clock must re-base to its OWN timestamp; + // the mkv muxer nudges AUDIO monotonic at emit — clamping here caused it. let mut parser = DtsParser::new(); // PES A: core1 + core2 (2 frames), pts 90000. let mut pes_a = make_dts_core(512); @@ -1329,12 +1227,9 @@ mod tests { #[test] fn keeps_dts_hd_extension_in_separate_pes_packets() { - // The real Blu-ray layout (ground-truthed on real UHD discs): the DTS core - // arrives in one PES, then its DTS-HD MA extension substreams arrive - // in SEPARATE following PES packets on the same PID. The parser must - // stitch core + all trailing extensions into one access unit — not - // emit a core-only (lossy) frame and drop the extension PES packets - // (the lossy-core downgrade bug). + // Real Blu-ray layout: the DTS core arrives in one PES, then its DTS-HD MA + // extensions arrive in SEPARATE following PES packets. Must stitch core + + // all trailing extensions into one AU, not emit lossy core-only frames. let mut parser = DtsParser::new(); // Frame 1: core (512) + two extension substreams (256 + 200). @@ -1415,11 +1310,9 @@ mod tests { #[test] fn bogus_tiny_core_sync_does_not_split_or_drop_real_au() { - // A real core frame followed by an extension substream that happens to - // contain a false core sync whose fsize decodes tiny. The bogus sync - // must NOT close the real access unit early (dropping the rest of the - // extension) nor emit a junk few-byte frame — it must be skipped, and - // the whole core + extension preserved as one access unit. + // A real core followed by an extension containing a false core sync whose + // fsize decodes tiny. The bogus sync must NOT close the AU early or emit + // a junk frame — skip it and preserve core + extension as one AU. let mut parser = DtsParser::new(); // Frame 1: core(512) + an extension whose body embeds a bogus tiny @@ -1456,10 +1349,8 @@ mod tests { #[test] fn sub_spec_core_size_is_rejected_as_false_sync() { // A core sync whose decoded fsize+1 lands in [CORE_HEADER_MIN_BYTES, - // MIN_CORE_FRAME_BYTES) — i.e. a "frame" smaller than the 96-byte ETSI - // spec minimum — is a false sync inside extension payload and must NOT - // close an access unit. Pick a decoded size of 64 (well inside the old - // 10..96 false-positive window the raised floor now rejects). + // MIN_CORE_FRAME_BYTES) — smaller than the 96-byte ETSI spec minimum — is + // a false sync and must NOT close an AU. 64 sits in the raised reject window. let false_size = 64usize; assert!( (CORE_HEADER_MIN_BYTES..MIN_CORE_FRAME_BYTES).contains(&false_size), @@ -1492,10 +1383,9 @@ mod tests { #[test] fn forced_emit_does_not_corrupt_next_au_pts() { - // When the buffer exceeds MAX_AU_BYTES with no next core sync, the - // parser force-emits for forward progress. The current PES at that - // point is an extension-substream PES (later PTS). The forced path must - // NOT make that extension PTS the base of the NEXT access unit. + // When the buffer exceeds MAX_AU_BYTES with no next core sync, the parser + // force-emits for forward progress; the current PES is an extension PES + // (later PTS). The forced path must NOT base the NEXT AU on that PTS. let mut parser = DtsParser::new(); // Core PES at the real PTS, then a giant extension (no next core) that @@ -1537,13 +1427,9 @@ mod tests { #[test] fn needmore_past_cap_force_flushes_to_bound_buffer() { - // A crafted DTS-HD stream whose extension substream declares a size - // larger than what is (ever) buffered keeps `next_core_boundary` in a - // sustained NeedMore state (a candidate boundary that is never fully - // buffered). Once `buf` exceeds MAX_AU_BYTES the NeedMore force-flush - // safety valve must fire — mirroring the None arm — so the buffer can't - // grow without bound. WITHOUT the guard the parser would `break` and - // retain everything, emitting nothing. + // A crafted stream whose extension declares a size larger than ever + // buffered keeps `next_core_boundary` in sustained NeedMore; past + // MAX_AU_BYTES the force-flush valve must fire, or `buf` grows forever. let mut parser = DtsParser::new(); let core = make_dts_core(512); @@ -1590,10 +1476,9 @@ mod tests { #[test] fn core_frame_size_bit_layout() { - // fsize is 14 bits at bits 46-59: byte5[1:0] (high 2), byte6 (mid 8), - // byte7[7:4] (low 4). Returned value is fsize + 1 (on-wire length-1). - // Set fsize = 0x1FFF (= 8191): byte5 low2 = 0b01, byte6 = 0xFF, - // byte7 high4 = 0xF (0xF0). (1<<12)|(0xFF<<4)|0xF = 0x1FFF → size 8192. + // fsize is 14 bits at bits 46-59: byte5[1:0] (high2), byte6 (mid8), + // byte7[7:4] (low4); returned value is fsize + 1 (on-wire length-1). + // (1<<12)|(0xFF<<4)|0xF = 0x1FFF → size 8192. let mut d = vec![0u8; CORE_HEADER_MIN_BYTES]; d[5] = 0x01; d[6] = 0xFF; @@ -1638,10 +1523,8 @@ mod tests { #[test] fn dts_core_samples_reads_nblks_high_bit_from_byte4_bit0() { // NBLKS is 7 bits: byte4 bit0 is the HIGH bit (<<6), byte5>>2 the low 6. - // With byte4 bit0 set and byte5 = 0, nblks = 64 (not 0), so a `<<` -> `>>` - // typo on the byte4 contribution (which always yields 0, since a 1-bit - // value has nothing to shift right into) collapses samples from 2080 - // down to 32. + // With byte4 bit0 set and byte5 = 0, nblks = 64 (not 0); a `<<` -> `>>` + // typo on byte4 (always 0) would collapse samples from 2080 to 32. let mut d = vec![0u8; CORE_HEADER_MIN_BYTES]; d[4] = 0x01; d[5] = 0x00; @@ -1650,13 +1533,9 @@ mod tests { #[test] fn dts_core_samples_and_sample_rate_decode_at_exact_header_min_length() { - // The length guard is `data.len() < CORE_HEADER_MIN_BYTES`; exactly - // CORE_HEADER_MIN_BYTES (10) bytes must still be read as a real header - // (index 8, SFREQ, is in bounds), not treated as truncated. Use NBLKS - // and SFREQ values that differ from the truncated-header fallbacks - // (512 samples, 48 kHz) so a `<` -> `<=` typo that takes the fallback - // path one byte early is visible in the output, not masked by a - // coincidental match. + // The guard is `data.len() < CORE_HEADER_MIN_BYTES`; exactly 10 bytes must + // still read as a real header (index 8, SFREQ, is in bounds). Use non- + // fallback NBLKS/SFREQ so a `<` -> `<=` typo is visible, not masked. let mut d = vec![0u8; CORE_HEADER_MIN_BYTES]; d[4] = 0x00; d[5] = 0x00; // nblks = 0 -> 32 samples, not the 512 fallback @@ -1668,11 +1547,9 @@ mod tests { #[test] fn dts_core_samples_and_sample_rate_decode_past_header_min_length_too() { - // A `<` -> `>` typo on the same guard takes the FALLBACK path once - // `data.len()` exceeds CORE_HEADER_MIN_BYTES instead of never — i.e. - // every real, normally-sized core frame (always well past 10 bytes) - // would silently report the 512-sample/48kHz fallback. Use a buffer - // twice the minimum with non-fallback NBLKS/SFREQ values. + // A `<` -> `>` typo on the same guard takes the FALLBACK path once len + // exceeds CORE_HEADER_MIN_BYTES — every real core frame would silently + // report the 512-sample/48kHz fallback. Use a buffer twice the minimum. let mut d = vec![0u8; CORE_HEADER_MIN_BYTES * 2]; d[4] = 0x00; d[5] = 0x00; // nblks = 0 -> 32 samples @@ -1732,10 +1609,8 @@ mod tests { #[test] fn next_core_needs_more_when_candidate_header_truncated() { // A second core sync appears but fewer than CORE_HEADER_MIN_BYTES follow - // it, so its size can't be judged → the access unit can't be closed yet - // (NeedMore → parse() breaks and waits). Build core(512) + a bare 2nd - // sync with only the 4 syncword bytes buffered (< CORE_HEADER_MIN_BYTES - // after it), so the candidate can't be validated. + // it, so its size can't be judged (NeedMore → parse() breaks and waits). + // Build core(512) + a bare 2nd sync with only the 4 syncword bytes. let mut parser = DtsParser::new(); let mut data = make_dts_core(512); data.extend_from_slice(&DTS_CORE_SYNC); // 2nd sync, header truncated @@ -1754,9 +1629,8 @@ mod tests { #[test] fn multiple_false_syncs_in_extension_all_skipped() { - // An extension body containing SEVERAL byte sequences that match the core - // syncword but decode to sub-spec sizes must ALL be skipped; the AU is - // closed only at the next real core. Guards the loop in + // An extension body with SEVERAL byte sequences matching the core syncword + // but decoding to sub-spec sizes must ALL be skipped; guards the loop in // next_core_boundary that advances `from = pos + 4` past each false sync. let mut parser = DtsParser::new(); let mut ext = make_dts_ext(400); @@ -1981,10 +1855,8 @@ mod tests { #[test] fn drop_preserves_av_sync_no_shift() { // THE INVARIANT: dropping an undecodable AU must never shift the audio - // that follows. A good/bad/good run in ONE PES — the bad middle core is - // dropped, but the trailing good core must keep the EXACT PTS it would - // have had with no drop (base + 2 frame durations), so the drop is a - // silence gap, not a shift. + // that follows. good/bad/good in ONE PES: the trailing good core keeps + // the EXACT PTS it would have had with no drop — a gap, not a shift. let mut parser = DtsParser::new(); let mut stream = make_dts_core(512); // c1: good stream.extend_from_slice(&make_bad_dts_core(512)); // c2: undecodable @@ -2047,10 +1919,8 @@ mod tests { } #[test] - // The loop variable is the DOMAIN VALUE being checked (a DTS SFREQ code), not a - // cursor into a collection: it is what the assertion message names and - // what the table is keyed by. `.iter().enumerate()` would rename the - // thing under test to `i` and read worse. + // The loop variable is the DOMAIN VALUE checked (a DTS SFREQ code), not a + // collection cursor: `.iter().enumerate()` would rename it to `i` and read worse. #[allow(clippy::needless_range_loop)] fn sr_validity_table_marks_reserved_codes() { // The core-header sample-rate validity table must have ZERO (reject) at @@ -2087,11 +1957,9 @@ mod tests { d[5] = (d[5] & 0x03) | (14u8 << 2); assert_eq!(core_header_drop_reason(&d), Some(DropReason::PcmBlocks)); - // audio_mode reserved (>= 16): AMODE = byte7 bits3-0 (high 4) + byte8 - // bits7-6. Set AMODE high nibble to 0xF → audio_mode = 60, a genuinely - // RESERVED code (16-63) a decoder rejects. (Codes 10-15 are LEGAL - // multichannel layouts and must NOT be dropped — see - // legal_multichannel_amode_is_not_dropped.) + // audio_mode reserved (>= 16): AMODE = byte7 bits3-0 + byte8 bits7-6. Set + // high nibble to 0xF → audio_mode = 60, a genuinely RESERVED code a + // decoder rejects (10-15 are LEGAL, see legal_multichannel_amode_is_not_dropped). let mut d = good.clone(); d[7] |= 0x0F; assert_eq!(core_header_drop_reason(&d), Some(DropReason::Amode)); @@ -2116,12 +1984,9 @@ mod tests { #[test] fn legal_multichannel_amode_is_not_dropped() { - // ETSI TS 102 114 §5.3.1: AMODE is a 6-bit field with 16 LEGAL - // channel-arrangement codes (0-15); only 16-63 are reserved. ETSI TS 102 - // 114 §5.3.1's per-AMODE channel counts cover all 16, confirming codes - // 10-15 are decodable 6/7/8-channel layouts. The decodability gate must - // KEEP them — dropping a spec-legal multichannel core silences audio the - // recover-100% goal must preserve. + // ETSI TS 102 114 §5.3.1: AMODE is 6 bits with 16 LEGAL codes (0-15), only + // 16-63 reserved; codes 10-15 are decodable 6/7/8-channel layouts. The + // gate must KEEP them — dropping a legal multichannel core silences audio. fn set_amode(core: &mut [u8], amode: u32) { // audio_mode = (byte7 & 0x0F) << 2 | (byte8 >> 6). core[7] = (core[7] & 0xF0) | ((amode >> 2) & 0x0F) as u8; @@ -2160,10 +2025,9 @@ mod tests { #[test] fn termination_frame_with_small_deficit_is_kept() { - // ETSI TS 102 114 (a normal frame carries a full 32-sample PCM block): - // a TERMINATION frame (FTYPE=0) may legally carry fewer than 32 deficit - // samples and is fully decodable. It must NOT be dropped — dropping the - // last frame of a stream silences real audio (recover-100% violation). + // ETSI TS 102 114: a normal frame carries a full 32-sample PCM block, but + // a TERMINATION frame (FTYPE=0) may legally carry fewer and is decodable. + // It must NOT be dropped — that would silence the last frame of a stream. let mut core = make_dts_core(512); set_ftype_short(&mut core, false, 10); // termination, deficit = 11 (< 32) assert_eq!( @@ -2200,11 +2064,9 @@ mod tests { #[test] fn reserved_bit_set_is_not_dropped() { - // The bit after RATE is a RESERVED field that both reference decoders - // SKIP (a reserved field per ETSI TS 102 114) — they - // never reject a frame that sets it. Rejecting was a false-drop that - // silenced any real stream whose encoder set the bit. Setting it (byte9 - // bit4) on an otherwise-valid core must leave it KEPT. + // The bit after RATE is RESERVED (ETSI TS 102 114); both reference + // decoders SKIP it and never reject on it. Setting it (byte9 bit4) on an + // otherwise-valid core must leave it KEPT. let mut core = make_dts_core(512); assert_eq!(core_header_drop_reason(&core), None, "baseline decodable"); core[9] |= 0x10; // set the reserved bit diff --git a/src/mux/codec/dvdsub.rs b/src/mux/codec/dvdsub.rs index ab73da29..1cea5c5a 100644 --- a/src/mux/codec/dvdsub.rs +++ b/src/mux/codec/dvdsub.rs @@ -68,17 +68,9 @@ impl CodecParser for DvdSubParser { let mut out = Vec::new(); - // A PES carrying a real PTS is the START of a new SPU; continuations of - // an in-progress SPU carry no PTS (the PS demuxer leaves `pts` None when - // the PES has no PTS field — see the module doc). PTS is therefore the - // authoritative SPU-boundary signal, NOT merely `pending.is_some()`. - // - // Append-as-continuation ONLY when this PES has no PTS. When it has a - // PTS but a stale `pending` is still open (a lost continuation, or a - // corrupt/oversized declared SPU_size that real data never reaches), - // force-emit the stuck unit truncated and fall through to start a fresh - // SPU from this PES. Without this, one bad SPU_size would swallow every - // later subtitle until EOF — exactly the damaged-disc case we target. + // PTS present == start of a new SPU (continuations carry none); a PTS + // while `pending` is still open (lost continuation/corrupt SPU_size) + // force-emits it truncated rather than swallowing all later SPUs. if pes.pts.is_none() { if self.pending.is_some() { // Continuation: append, bounded by MAX_SPU_BYTES. @@ -715,10 +707,9 @@ mod tests { #[test] fn no_pts_sized_segment_without_pending_starts_new_spu() { - // A no-PTS segment with no pending but a valid SPU_size (>= 2) and an - // incomplete length begins a fresh pending SPU (the demuxer may have - // dropped the PTS, but the size field is authoritative for boundary). - // declared = 16, only 3 bytes present → held pending, no emit. + // No-PTS segment, no pending, valid SPU_size (>= 2), incomplete length: + // starts a fresh pending SPU (size field is authoritative for boundary + // even if the demuxer dropped the PTS). declared=16, 3 bytes → held. let mut parser = DvdSubParser::new(None); let f = parser.parse(&make_pes(vec![0x00, 0x10, 0xAA], None)); assert!(f.is_empty(), "incomplete sized segment held, not emitted"); diff --git a/src/mux/codec/flac.rs b/src/mux/codec/flac.rs index b908af3c..8ffd2676 100644 --- a/src/mux/codec/flac.rs +++ b/src/mux/codec/flac.rs @@ -98,10 +98,9 @@ impl CodecParser for FlacParser { .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 - // through (a non-sync packet is not a frame we can validate; a poisoned - // track drops everything). + // Gate: a packet with a FLAC frame sync but nonzero whole-frame CRC-16 + // residue is corrupt → drop. Non-sync packets pass through unvalidated; + // a poisoned track drops everything. let corrupt = has_flac_sync(&pes.data) && crc16_ansi(&pes.data) != 0; if self.tally.is_poisoned() || corrupt { let reason = if self.tally.is_poisoned() { diff --git a/src/mux/codec/h264.rs b/src/mux/codec/h264.rs index 326c95c6..be2e6a84 100644 --- a/src/mux/codec/h264.rs +++ b/src/mux/codec/h264.rs @@ -35,22 +35,14 @@ fn h264_slice_coding_type(slice_type: u32) -> Option<CodingType> { /// codecPrivate, detects IDR keyframes, and converts each PES access unit into /// length-prefixed NAL units. Implements [`CodecParser`]. pub struct H264Parser { - // First-seen SPS/PPS seed the MKV codecPrivate (avcC) — the only out-of-band - // copy the player gets. BD H.264 repeats the parameter sets at every IDR; - // a player re-applies the avcC copy at each keyframe. A stream may redefine - // a parameter set mid-title under the SAME id with a different body. Any - // occurrence whose body DIFFERS from the codecPrivate copy must therefore be - // emitted IN-BAND at each point it appears so it overrides the re-applied - // avcC set; otherwise those frames decode against the wrong parameter set. - // (Same defect class as the HEVC PPS-redefinition bug — fixed identically.) + // First-seen SPS/PPS seed the MKV codecPrivate (avcC). A stream may redefine + // a set mid-title under the same id with a different body; that occurrence + // must be emitted in-band, or it decodes against the stale avcC copy. sps: Option<Vec<u8>>, pps: Option<Vec<u8>>, - // Currently-ACTIVE body of each type (most recent the bitstream defined), - // distinct from the fixed `sps`/`pps` codecPrivate copy. See the HEVC - // parser for the full rationale: the strip/emit decision must be made - // against the active set, and the active set must be re-asserted in-band at - // every keyframe that doesn't carry it, or a streaming decoder reverts to - // the stale avcC copy after a mid-title redefinition. + // Currently-active body of each type, distinct from the fixed `sps`/`pps` + // codecPrivate copy. Must be re-asserted in-band at every keyframe that + // doesn't carry it, or a decoder reverts to the stale avcC copy. cur_sps: Option<Vec<u8>>, cur_pps: Option<Vec<u8>>, /// Display-order PTS reconstruction, enabled only on the program-stream @@ -114,20 +106,13 @@ impl H264Parser { } // Bytes reserved at the front of every assembled access unit so the keyframe -// parameter-set re-assert can be spliced in without reallocating. An SPS + PPS -// re-assert is a couple hundred bytes (each a 4-byte length prefix plus a NAL -// that is tens to low hundreds of bytes on real BD/UHD streams); 1 KiB covers it -// with margin, and costs 1 KiB of slack per in-flight frame. If a stream's -// parameter sets ever exceed this the splice still produces correct output — it -// just reallocates once, exactly as it always did. Mirrors the HEVC parser. +// param-set re-assert (SPS+PPS) can splice in without reallocating. Oversized +// sets just reallocate once — correctness is unaffected. Mirrors HEVC parser. const PARAM_REASSERT_HEADROOM: usize = 1024; -// Per-thread count of keyframe re-asserts that had to reallocate the frame -// buffer. Test-only instrumentation: the whole point of -// `PARAM_REASSERT_HEADROOM` is that the splice is in-place, so that is MEASURED -// rather than reasoned about. See -// `keyframe_param_reassert_does_not_reallocate_the_frame`. Mirrors the HEVC -// parser. +// Per-thread count of keyframe re-asserts that had to reallocate. Test-only: +// proves the splice stays in-place rather than reasoning about it. See +// `keyframe_param_reassert_does_not_reallocate_the_frame`. Mirrors HEVC. #[cfg(test)] thread_local! { static PARAM_REASSERT_REALLOCS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) }; @@ -254,10 +239,9 @@ impl CodecParser for H264Parser { return Vec::new(); } - // MKV block timecodes are PRESENTATION timestamps; frames are stored in - // decode order and the player reorders by timecode. Use PTS, not DTS — - // DTS presents B-frames in decode order (visible judder) and breaks - // PTS-based seeking. Fall back to DTS only if PTS is absent. + // MKV block timecodes are presentation timestamps (decode order stored, + // player reorders). Use PTS not DTS: DTS presents B-frames in decode + // order (visible judder) and breaks PTS-based seeking; fall back only if absent. let explicit_pts = pes.pts.or(pes.dts).map(pts_to_ns); let pts_ns = explicit_pts.unwrap_or(0); @@ -270,13 +254,9 @@ impl CodecParser for H264Parser { // Did this access unit already carry each param-set type in-band? let mut emitted_sps = false; let mut emitted_pps = false; - // Pre-size: output is ~input bytes plus a few 4-byte NAL length prefixes. - // The unsized Vec growth chain otherwise reallocs several times per - // frame in the mux hot path (mirrors the HEVC parser). - // - // Plus `PARAM_REASSERT_HEADROOM` so the keyframe parameter-set re-assert - // below can be spliced in FRONT of the frame without reallocating. See - // that site. + // Pre-sized to input length plus PARAM_REASSERT_HEADROOM so the mux hot + // path avoids repeated reallocs and the keyframe param-set re-assert + // below can be spliced in front without reallocating (see that site). let mut frame_data = Vec::with_capacity(pes.data.len() + 64 + PARAM_REASSERT_HEADROOM); // MVC dependent-view passthrough: keep ALL param sets in-band (the frame @@ -287,10 +267,9 @@ impl CodecParser for H264Parser { let nal_type = nal[0] & 0x1F; match nal_type { - // Param sets: seed avcC, strip if unchanged vs the active set, - // emit in-band on any change (incl. reverting to the avcC copy). - // In MVC passthrough these fall through to the default arm so the - // subset SPS / PPS stay in-band (self-contained dependent AU). + // Param sets: seed avcC, strip if unchanged vs active set, emit + // in-band on any change. In MVC passthrough these fall through to + // the default arm so subset SPS/PPS stay in-band (self-contained AU). NAL_SPS if !mvc => { emitted_sps |= handle_param_set(&mut self.sps, &mut self.cur_sps, nal, &mut frame_data) @@ -299,34 +278,17 @@ impl CodecParser for H264Parser { emitted_pps |= handle_param_set(&mut self.pps, &mut self.cur_pps, nal, &mut frame_data) } - // Access unit delimiters: drop. Intentional and spec-correct — - // Matroska H.264 frame data omits AUDs (the container delimits - // access units), so keeping them in-band is redundant. Mirrors - // the HEVC parser. + // Access unit delimiters: drop. Matroska H.264 frame data omits + // AUDs (the container delimits access units), so keeping them + // in-band is redundant. Mirrors the HEVC parser. NAL_AUD => {} _ => { if nal_type == NAL_SLICE_IDR { keyframe = true; } - // Measure the coding type from the FIRST coded slice's header - // (ISO/IEC 14496-10 §7.3.3: first_mb_in_slice ue(v), then - // slice_type ue(v)). Populates PictureInfo so a consumer reads - // a MEASURED I/P/B, never a keyframe-only guess. Only the first - // slice of the access unit is read. - // - // The bytes after the 1-byte NAL header are EBSP, not RBSP, so - // emulation-prevention bytes must come out first (§7.4.1: the - // encoder inserts 0x03 after any 0x00 0x00, and §7.3.1 removes - // it before parsing). This used to read the raw NAL on the - // reasoning that slice_type is too early for one to intervene. - // That holds only up to a point: first_mb_in_slice is ue(v), so - // a value of 65535 or more needs sixteen leading zero bits and - // puts 0x00 0x00 at the very start of the payload, which an - // encoder must then escape. A UHD frame is ~32,400 macroblocks - // so a conforming Blu-ray never reaches it, but 8K does, and - // the disc is untrusted input — a stream declaring a large - // first_mb_in_slice made slice_type decode against a byte the - // encoder inserted, yielding a wrong picture type. + // Measure coding type from the first slice header (§7.3.3: + // first_mb_in_slice, slice_type, both ue(v)) after unescaping + // EBSP (§7.3.1) — large first_mb_in_slice needs escaped 0x00 0x00. if (nal_type == NAL_SLICE_NON_IDR || nal_type == NAL_SLICE_IDR) && coding_type.is_none() { @@ -352,54 +314,24 @@ impl CodecParser for H264Parser { return Vec::new(); } - // Open-GOP resync anchor: an intra-coded access unit is a keyframe even - // when it is NOT an IDR. BD H.264 titles use open GOPs whose only - // random-access point is a non-IDR I-frame (the recovery point, which an - // encoder MAY additionally tag with a recovery_point SEI). Keying off the - // already-measured picture coding type covers every signalling — a - // recovery_point SEI, or a disc that marks the recovery point only by GOP - // structure with no SEI at all — where SEI parsing alone would miss the - // latter. Without this, the B1 resync gate (`mux/resync.rs`), once armed - // by a concealed-gap / clip-seam discontinuity, never sees a keyframe on - // such a tail and drops every frame to EOF, truncating the video. HEVC - // does not hit this: its open-GOP IRAP (CRA) is a distinct NAL type - // already flagged a keyframe (`mux/codec/hevc.rs`); H.264 has no such NAL - // tag, so the intra coding type is the equivalent signal. Consistent with - // `videomap.rs`'s model that a keyframe IS the intra decode-restart point. - // IDR is still flagged by NAL type in the loop above, so a slice whose - // header failed to parse (`coding_type == None`) is unaffected here. + // Open-GOP resync anchor: BD titles use open GOPs whose random-access + // point is a non-IDR I-frame, not always SEI-tagged. Without treating it + // as a keyframe, the resync gate (`mux/resync.rs`) can miss it and drop frames to EOF. if coding_type == Some(CodingType::I) { keyframe = true; } - // Every keyframe is self-contained: re-assert the active SPS/PPS in-band - // ahead of the slices (even when unchanged vs codecPrivate) so a decoder - // that dropped the set at a reset recovers, and a stale avcC re-apply - // can't revert it. Skipped per-type only when this AU already carried it. + // Every keyframe is self-contained: re-assert active SPS/PPS in-band so a + // decoder that dropped the set recovers, and a stale avcC re-apply can't + // revert it. Skipped per-type only when this AU already carried it. if keyframe && !mvc { let mut prefix = Vec::with_capacity(PARAM_REASSERT_HEADROOM); reassert_active(&mut prefix, &self.cur_sps, emitted_sps); reassert_active(&mut prefix, &self.cur_pps, emitted_pps); if !prefix.is_empty() { - // SPLICE the couple hundred prefix bytes into the front of the - // already-assembled frame, in place. - // - // This used to be `prefix.extend_from_slice(&frame_data)` followed - // by `frame_data = prefix`: that grew `prefix` from a couple hundred - // bytes to the FULL access-unit size (a fresh multi-hundred-KB - // allocation), memcpy'd the whole frame into it, and dropped the - // presized `frame_data` buffer — one extra whole-frame allocation - // plus one extra whole-frame copy per keyframe. A UHD title is - // ~200,000 frames of 150-400 KB with a keyframe every 1-2 s, so - // that is thousands of avoidable multi-hundred-KB copies per title, - // each large enough to go through mmap. - // - // `frame_data` was reserved with `PARAM_REASSERT_HEADROOM` to spare - // precisely so this splice fits without reallocating; what remains - // is one in-place memmove inside the existing buffer. Byte-identical - // output either way — pinned by - // `keyframe_param_reassert_emits_exact_bytes`. Mirrors the HEVC - // parser's fix so the two stay consistent. + // Splice prefix into frame_data in place, sized via + // PARAM_REASSERT_HEADROOM to avoid the extra whole-frame alloc+copy + // per keyframe this used to cost. Mirrors the HEVC parser's fix. #[cfg(test)] let cap_before = frame_data.capacity(); frame_data.splice(0..0, prefix); @@ -442,27 +374,16 @@ impl CodecParser for H264Parser { return None; } - // avcC encodes each NAL's length in a 16-bit field. A param set larger - // than 65535 bytes would truncate the length while the full bytes are - // appended → mis-framed record. Refuse rather than emit a corrupt avcC - // (param sets this large are non-conforming anyway). + // avcC encodes each NAL's length in a 16-bit field; a param set over + // 65535 bytes would truncate the length while full bytes are appended, + // mis-framing the record. Refuse rather than emit a corrupt avcC. if sps.len() > 0xFFFF || pps.len() > 0xFFFF { return None; } - // AVCDecoderConfigurationRecord (ISO 14496-15): - // configurationVersion = 1 - // AVCProfileIndication = SPS[1] - // profile_compatibility = SPS[2] - // AVCLevelIndication = SPS[3] - // lengthSizeMinusOne = 3 (4-byte length prefix) - // numOfSequenceParameterSets = 1 - // sequenceParameterSetLength = sps.len() - // sequenceParameterSetNALUnit = sps - // numOfPictureParameterSets = 1 - // pictureParameterSetLength = pps.len() - // pictureParameterSetNALUnit = pps - // [High Profile extension per ISO 14496-15 §5.3.3.1.2, when applicable] + // AVCDecoderConfigurationRecord (ISO 14496-15): fields built below are + // configurationVersion, profile/compatibility/level from SPS[1..4], + // lengthSizeMinusOne=3, then one SPS and one PPS, length-prefixed. let mut record = vec![ 1, // configurationVersion @@ -480,14 +401,9 @@ impl CodecParser for H264Parser { record.push(pps.len() as u8); record.extend_from_slice(pps); - // ISO 14496-15 §5.3.3.1.2: for High-Profile and the related - // chroma/bit-depth-extended profiles the record has 4 trailing extension - // bytes carrying chroma_format_idc and the luma/chroma bit depths. The - // full set that mandates the extension is profile_idc ∈ {100, 110, 122, - // 144, 244 (High 4:4:4 Predictive), 44, 83, 86, 118, 128, 138, 139, 134, - // 135}. Older parsers expect the record to END after the PPS for - // Baseline/Main/Extended — do NOT append for those (strict parsers - // reject the extra bytes). + // ISO 14496-15 §5.3.3.1.2: High-Profile and related chroma/bit-depth + // profiles get 4 trailing extension bytes (chroma_format_idc, luma/chroma + // bit depths). Do NOT append for Baseline/Main/Extended: strict parsers reject them. let profile_idc = sps[1]; const HIGH_PROFILES: [u8; 14] = [ 100, 110, 122, 144, 244, 44, 83, 86, 118, 128, 138, 139, 134, 135, @@ -532,19 +448,15 @@ impl CodecParser for H264Parser { /// overflows 32 bits, leading-zero count > 31, etc.). The caller silently /// omits the extension in that case. fn parse_sps_high_profile_ext(sps: &[u8]) -> Option<(u8, u8, u8)> { - // Strip emulation-prevention bytes: 00 00 03 xx → 00 00 xx (drop the 03). - // We skip byte 0 (NAL header) and start the RBSP from byte 1. Shares - // `unescape_ebsp` with the slice-header prefix reader rather than - // re-deriving the same rule — see that function's doc comment for why a - // second, window-based copy of this rule used to disagree with it. + // Strip emulation-prevention bytes (00 00 03 xx -> 00 00 xx), skipping the + // NAL header. Shares `unescape_ebsp` with the slice-header prefix reader — + // see its doc comment for why a second, re-derived copy used to disagree. let raw = &sps[1..]; // skip NAL header byte let rbsp: Vec<u8> = unescape_ebsp(raw, raw.len()); - // RBSP layout after stripping the NAL header byte: - // [0] profile_idc (already checked by caller) - // [1] constraint flags - // [2] level_idc - // [3..] seq_parameter_set_id ue(v), then High-Profile fields + // RBSP layout after stripping the NAL header byte: [0] profile_idc (already + // checked by caller), [1] constraint flags, [2] level_idc, [3..] + // seq_parameter_set_id ue(v) then High-Profile fields. if rbsp.len() < 4 { return None; } @@ -568,11 +480,9 @@ fn parse_sps_high_profile_ext(sps: &[u8]) -> Option<(u8, u8, u8)> { // bit_depth_chroma_minus8 let bit_depth_chroma_minus8 = reader.read_ue()?; - // Clamp to the 2- and 3-bit fields in the avcC extension bytes. - // Valid H.264 values are 0..=6; the spec guarantees ≤ 6, so no real - // content should be truncated. Out-of-spec values are clamped rather - // than rejected so a corrupt-but-decodable SPS still produces a - // reasonable avcC. + // Clamp to the 2- and 3-bit avcC extension fields. Valid H.264 values are + // 0..=6 so no real content is truncated; out-of-spec values are clamped + // rather than rejected so a corrupt-but-decodable SPS still yields an avcC. Some(( (chroma_format_idc & 0x03) as u8, (bit_depth_luma_minus8 & 0x07) as u8, @@ -585,7 +495,7 @@ struct SpsReader<'a> { data: &'a [u8], /// Current byte index. byte: usize, - /// Number of bits remaining in data[byte] (0 means fully consumed, advance). + /// Number of bits remaining in `data[byte]` (0 means fully consumed, advance). bits_left: u8, } @@ -663,11 +573,9 @@ impl<'a> Iterator for NalIterator<'a> { type Item = &'a [u8]; fn next(&mut self) -> Option<&'a [u8]> { - // Loop (not tail-recursion) over empty NALs: a crafted/garbled Annex B - // stream with many adjacent start codes (e.g. 00 00 01 00 00 01 ...) - // yields empty NALs back-to-back; recursing once per empty NAL would - // overflow the stack. `self.pos` advances to `nal_end` each iteration, - // so the loop always terminates. Mirrors the HEVC parser's while-scan. + // Loop (not tail-recursion): a garbled Annex B stream with many adjacent + // start codes yields empty NALs back-to-back, and recursing once per one + // would overflow the stack. Mirrors the HEVC parser's while-scan. loop { if self.pos >= self.data.len() { return None; @@ -679,11 +587,9 @@ impl<'a> Iterator for NalIterator<'a> { // Find next start code (or end of data) let nal_end = find_start_code(self.data, nal_start).unwrap_or(self.data.len()); - // Strip the leading zeros of the following start code. For a - // conforming bitstream this is lossless: rbsp_trailing_bits() sets a - // stop-one bit, so the final byte of any RBSP is never 0x00 — the only - // trailing zeros here belong to the next 00 00 (00) 01 prefix, never to - // the NAL's RBSP payload. (Mirrors the HEVC parser.) + // Strip leading zeros of the following start code: lossless since + // rbsp_trailing_bits() sets a stop-one bit, so an RBSP's final byte + // is never 0x00 — these zeros belong to the next prefix. Mirrors HEVC. let mut end = nal_end; while end > nal_start && self.data[end - 1] == 0x00 { end -= 1; @@ -724,10 +630,9 @@ mod tests { /// in `parse` — a non-IDR I-slice used to yield `keyframe == false`. #[test] fn open_gop_intra_access_unit_is_a_keyframe() { - // Annex B AU: one non-IDR coded slice (NAL type 1, nal_ref_idc 3 → 0x61) - // whose header decodes first_mb_in_slice = 0 (ue = "1") and slice_type = 7 - // (ue = "0001000"), i.e. an all-I slice. The two ue(v) fields pack into - // one byte: "1" + "0001000" = 0b1000_1000 = 0x88. + // Annex B AU: one non-IDR coded slice (NAL type 1, nal_ref_idc 3 -> 0x61) + // whose header decodes first_mb_in_slice=0 (ue="1"), slice_type=7 (I, + // ue="0001000"); packed into one byte: "1"+"0001000" = 0x88. let au = vec![0x00, 0x00, 0x01, 0x61, 0x88, 0x00]; let mut parser = H264Parser::new(); let frames = parser.parse(&make_pes(au, Some(0))); @@ -778,12 +683,9 @@ mod tests { /// the wrong picture type. #[test] fn slice_header_parse_removes_emulation_prevention_bytes() { - // first_mb_in_slice = 65535: ue(v) is 16 zero bits, a 1, then 16 bits of - // (v+1) below the leading bit — 33 bits total. slice_type = 2 (I) is - // ue(v) = 011. Bit string, before escaping: - // 0000000000000000 1 0000000000000000 011 - // = 36 bits, padded to octets 00 00 80 00 30 — and the leading 00 00 - // obliges the encoder to insert 0x03, giving 00 00 03 80 00 30. + // first_mb_in_slice=65535 needs 16 zero bits (ue(v)); combined with + // slice_type=2 (I, ue="011") the bits pack to 00 00 80 00 30 RBSP, + // and the leading 00 00 forces the encoder to escape it: 00 00 03 80 00 30. let rbsp = [0x00u8, 0x00, 0x80, 0x00, 0x30]; let ebsp = [0x00u8, 0x00, 0x03, 0x80, 0x00, 0x30]; @@ -924,15 +826,9 @@ mod tests { v } let mut parser = H264Parser::new(); - // AU1 seeds the active SPS/PPS. The SPS is deliberately ~200 bytes rather - // than a handful: `frame_data` is presized to `pes.data.len() + 64 + - // PARAM_REASSERT_HEADROOM` and a bare keyframe uses slightly less than - // `pes.data.len()`, so a tiny parameter set fits in the incidental `+64` - // slack and the test would pass with `PARAM_REASSERT_HEADROOM` set to - // zero — proving only that this fixture does not realloc, not that the - // headroom is what prevents it. At ~200 bytes the prefix exceeds the - // incidental slack, so the constant is load-bearing and zeroing it makes - // this test fail. + // AU1 seeds the active SPS/PPS. The SPS is deliberately ~200 bytes so the + // re-assert prefix exceeds the incidental `+64` slack in `frame_data`'s + // capacity — a tiny SPS would pass even with PARAM_REASSERT_HEADROOM zeroed. let mut big_sps = vec![0x42, 0x00, 0x1E]; big_sps.extend(std::iter::repeat_n(0xAB, 200)); let au1 = [ @@ -1131,9 +1027,7 @@ mod tests { fn h264_populates_measured_coding_type_and_source() { use super::super::coding::CodingType; // Slice-header body = first_mb_in_slice=0 ('1') then slice_type ue(v): - // 0x88 = '1 0001000' → slice_type 7 (I) - // 0x98 = '1 00110..' → slice_type 5 (P) - // 0x9C = '1 00111..' → slice_type 6 (B) + // 0x88='1 0001000'->I (7); 0x98='1 00110..'->P (5); 0x9C='1 00111..'->B (6). let src = crate::pes::SourcePos::at_byte(8192); let parse = |nal_type: u8, body: u8| { let mut p = H264Parser::new(); @@ -1595,10 +1489,9 @@ mod tests { au.extend_from_slice(&[0x68, 0x11]); au.extend_from_slice(&[0x00, 0x00, 0x01]); au.extend_from_slice(&[0x65, 0x10]); - // Two identical AUs. Each is a keyframe, so each re-asserts the active - // SPS/PPS in-band (self-contained keyframe) even though the bodies are - // unchanged — a decoder that dropped them at a reset recovers at every - // IDR. Frame = SPS, PPS, IDR. + // Two identical AUs, each a keyframe: each re-asserts the active SPS/PPS + // in-band even though unchanged, so a decoder that dropped them at a + // reset recovers at every IDR. Frame = SPS, PPS, IDR. parser.parse(&make_pes(au.clone(), Some(0))); let f = parser.parse(&make_pes(au, Some(90000))); assert_eq!( @@ -1611,9 +1504,8 @@ mod tests { #[test] fn many_empty_nals_do_not_overflow_stack() { // Regression: NalIterator::next must iterate, not recurse, over empty - // NALs. A crafted Annex B stream of tens of thousands of adjacent start - // codes (each producing an empty NAL) would blow the stack under the old - // tail-recursive implementation. Iterating handles it in bounded stack. + // NALs, or tens of thousands of adjacent start codes (each an empty NAL) + // would blow the stack under the old tail-recursive implementation. let mut data = Vec::new(); // 50_000 back-to-back 3-byte start codes → 50_000 empty NALs. for _ in 0..50_000 { @@ -1637,14 +1529,9 @@ mod tests { #[test] fn avcc_exact_length_fields_and_payload() { - // The AVCDecoderConfigurationRecord must encode SPS length and PPS length - // as 16-bit big-endian fields, followed by the verbatim NAL bodies. - // Uses a Main-Profile SPS (profile_idc=0x4D=77) so no High-Profile - // extension bytes are appended — the test validates the fixed-header - // layout only. High-Profile extension is covered by - // avcc_high_profile_appends_extension_bytes. - // SPS = 0x67,profile,compat,level + 2 payload bytes (6 bytes total). - // PPS = 0x68 + 2 payload bytes (3 bytes total). + // Validates the fixed AVCDecoderConfigurationRecord header/length-field + // layout using a Main-Profile SPS (profile_idc=0x4D=77) so no High-Profile + // extension bytes are appended (see avcc_high_profile_appends_extension_bytes). let mut parser = H264Parser::new(); let mut data = Vec::new(); data.extend_from_slice(&[0x00, 0x00, 0x01]); @@ -1708,10 +1595,9 @@ mod tests { #[test] fn nal_type_masks_high_three_bits() { - // nal_type = byte0 & 0x1F. The forbidden_zero_bit (bit 7) and - // nal_ref_idc (bits 6-5) must not affect type detection. An IDR (type 5) - // header is 0x65 (nal_ref_idc=3) or 0x25 (nal_ref_idc=1) — both type 5, - // both keyframes. + // nal_type = byte0 & 0x1F. forbidden_zero_bit (bit 7) and nal_ref_idc + // (bits 6-5) must not affect type detection: 0x65 (ref_idc=3) and 0x25 + // (ref_idc=1) are both IDR (type 5), both keyframes. for idr_hdr in [0x65u8, 0x25, 0x05, 0x85] { let mut parser = H264Parser::new(); let data = vec![0x00, 0x00, 0x01, idr_hdr, 0x10, 0x20]; @@ -1758,8 +1644,7 @@ mod tests { fn trailing_zeros_of_next_start_code_stripped_from_nal() { // The byte(s) before a following 4-byte start code (00 00 00 01) are // leading zeros of that start code, not RBSP, and must be stripped from - // the current NAL. Two NALs separated by a 4-byte start code: NAL 1 must - // not absorb the extra 00. + // the current NAL — NAL 1 must not absorb the extra 00. let mut parser = H264Parser::new(); let mut data = vec![0x00, 0x00, 0x01, 0x41, 0xAA]; // NAL1 = 0x41 0xAA data.extend_from_slice(&[0x00, 0x00, 0x00, 0x01, 0x41, 0xBB]); // 4-byte SC @@ -1978,10 +1863,9 @@ mod tests { let cp = parser.codec_private().expect("avcC must be present"); - // Walk to the end of the fixed record to locate the extension bytes. - // Fixed header: 6 bytes. SPS length field: 2 bytes. SPS body. numPPS: 1. - // PPS length: 2. PPS body (0x68, 0xCE, 0x01 = 3 bytes). - // Fixed tail offset = 6 + 2 + sps.len() + 1 + 2 + 3 = sps.len() + 14. + // Locate extension bytes past the fixed record: 6-byte header + 2-byte + // SPS length + SPS body + 1-byte numPPS + 2-byte PPS length + 3-byte PPS + // body (0x68,0xCE,0x01) = sps.len() + 14. let ext_off = sps.len() + 14; assert!( cp.len() == ext_off + 4, diff --git a/src/mux/codec/hevc.rs b/src/mux/codec/hevc.rs index c38153d6..3cb62149 100644 --- a/src/mux/codec/hevc.rs +++ b/src/mux/codec/hevc.rs @@ -13,19 +13,14 @@ const NAL_VPS: u8 = 32; const NAL_SPS: u8 = 33; const NAL_PPS: u8 = 34; const NAL_AUD: u8 = 35; -// Supplemental Enhancement Information (Rec. ITU-T H.265 Table 7-1): a prefix -// SEI (type 39) precedes the coded picture it applies to, a suffix SEI (40) -// follows it. HDR10 static metadata (mastering display / content light level) -// is carried in PREFIX SEI on UHD streams; both are scanned for the two HDR10 -// payload types below. SEI NALs still pass through to the frame data unchanged -// (the `_ =>` arm); scanning them is observation-only. +// SEI (H.265 Table 7-1): prefix (39) precedes its picture, suffix (40) follows. +// HDR10 static metadata is carried in prefix SEI on UHD streams; both types are +// scanned for the HDR10 payloads below but pass through to frame data unchanged. const NAL_SEI_PREFIX: u8 = 39; const NAL_SEI_SUFFIX: u8 = 40; -// HEVC SEI payload types (Rec. ITU-T H.265 Annex D.2) carrying HDR10 static -// metadata. -// - Mastering Display Colour Volume (D.2.28): payloadType 137. -// - Content Light Level Information (D.2.35): payloadType 144. +// HEVC SEI payload types (H.265 Annex D.2) carrying HDR10 static metadata: +// Mastering Display Colour Volume (D.2.28) = 137, Content Light Level Info (D.2.35) = 144. const SEI_MASTERING_DISPLAY_COLOUR_VOLUME: u32 = 137; const SEI_CONTENT_LIGHT_LEVEL_INFO: u32 = 144; // Dolby Vision RPU (Reference Processing Unit) — NAL type 62 (UNSPEC62). @@ -35,12 +30,9 @@ const _NAL_UNSPEC62_DV_RPU: u8 = 62; // IRAP types (keyframes): BLA, IDR, CRA const NAL_BLA_W_LP: u8 = 16; const NAL_RSV_IRAP_VCL23: u8 = 23; -// CRA_NUT (Clean Random Access). A CRA at a splice carries RASL leading -// pictures that reference frames from BEFORE the splice; on linear decode of a -// concatenated title those references are gone ("Could not find ref with POC -// N"). The HEVC spec remedy is to rewrite the splice CRA as a BLA (Broken Link -// Access): a decoder then sets NoRaslOutput and discards the RASL cleanly with -// no error. See `mark_clip_boundary` / the IRAP arm in `parse`. +// CRA_NUT (Clean Random Access). A CRA at a splice carries RASL pictures +// referencing frames before the splice, gone after concatenation ("Could not +// find ref with POC N"). Remedy: rewrite to BLA so NoRaslOutput discards them cleanly. const NAL_CRA_NUT: u8 = 21; /// Highest VCL (coded-slice) NAL type. Rec. ITU-T H.265 Table 7-1: types 0..=31 /// are VCL, 32..=63 non-VCL. A coded slice carries a `slice_type`. @@ -97,90 +89,33 @@ fn hevc_first_slice_coding_type(nal: &[u8], nal_type: u8, num_extra: u32) -> Opt /// codecPrivate, detects IRAP keyframes, and converts each PES access unit into /// length-prefixed NAL units. Implements [`CodecParser`]. 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 (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 - // each point it appears (i.e. at every keyframe of the redefined segment) so - // it overrides the re-applied codecPrivate set; otherwise those frames decode - // against the wrong parameter set → CABAC/cu_qp_delta desync. + // First-seen param set of each type seeds the MKV codecPrivate (hvcC); a + // player re-applies it at every keyframe. A body that differs from this + // copy (mid-title redefinition) must be emitted in-band, or CABAC desyncs. vps: Option<Vec<u8>>, sps: Option<Vec<u8>>, pps: Option<Vec<u8>>, - // The currently-ACTIVE parameter-set body of each type — the most recent - // one the bitstream defined, which the decoder must use until the next - // redefinition. Distinct from the `vps/sps/pps` codecPrivate copy above - // (which is fixed to the FIRST one seen). When a stream redefines a param - // 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 (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`. + // Currently-active param-set body of each type, distinct from the fixed + // `vps/sps/pps` codecPrivate copy above. A player re-applies codecPrivate at + // every keyframe, so a mid-title redefinition must be re-emitted in-band. See `parse`. cur_vps: Option<Vec<u8>>, cur_sps: Option<Vec<u8>>, cur_pps: Option<Vec<u8>>, - // Splice-aware CRA→BLA rewrite (non-seamless BD clip boundaries). - // - // When a BD title concatenates clips at a NON-SEAMLESS join (MPLS - // connection_condition 0x05 or 0x06), the next clip opens with a CRA whose - // RASL leading pictures reference frames from before the splice — gone after - // concatenation. The caller (the code that crosses the join) sets this flag - // via `mark_clip_boundary`; the parser then rewrites the FIRST CRA it sees - // at/after that point from CRA_NUT (21) to BLA_W_LP (16) so a linear decoder - // sets NoRaslOutput and discards the dangling RASL with no error. The flag - // is consumed (cleared) by that first CRA so only ONE CRA per boundary is - // touched — never a mid-stream CRA, never an IDR, never a non-CRA NAL. - // - // ARMED BY TWO PATHS — do not read this flag as caller-driven only: - // - // 1. `mark_clip_boundary`, which a caller invokes only for a non-seamless - // (0x05/0x06) join. connection_condition 0x01 is the first-item/seamless - // case and must NOT trigger this flag. In practice NO caller wires this - // up: the mpls connection_condition is not plumbed through the threaded - // mux pipeline (see the note at the auto-detect site in `parse`). - // 2. The in-parser PTS-backstep AUTO-DETECTION in `parse` — a backward PES - // PTS step beyond `BACKSTEP_TICKS` sets it with no caller involvement. - // This is the path that actually fires in production, and it is the one - // the CRA→BLA rewrite exists for. - // - // So the rewrite branch is NOT dead, and output is NOT byte-identical to a - // parser without this field: any stream whose PES PTS steps backward by more - // than `BACKSTEP_TICKS` — including a damaged/rewritten PTS field on an - // untrusted disc — has its next CRA_NUT rewritten to BLA_W_LP, which makes a - // decoder discard that CRA's valid RASL leading pictures. That false-arming - // risk is held down by the `PTS_WRAP_PERIOD` unwrapping and the high-water - // watermark, not by the flag being unreachable: REMOVING either guard on the - // strength of "only `mark_clip_boundary` sets this" is a live corruption bug. - // Pinned by `cra_at_auto_detected_pts_backstep_rewritten_to_bla` and - // `cra_after_33bit_pts_wrap_not_rewritten`. + // Splice-aware CRA→BLA rewrite for a non-seamless BD clip boundary (first + // CRA_NUT -> BLA_W_LP so NoRaslOutput discards dangling RASL). Armed by + // `mark_clip_boundary` AND PTS-backstep auto-detect in `parse` — not dead code. pending_clip_boundary: bool, - // Highest PES PTS seen on this video stream so far, on a MONOTONIC 64-bit - // timeline (raw 33-bit PTS unwrapped across 2^33 wraparounds — see - // `pts_wrap_offset`). Used to AUTO-DETECT a non-seamless clip boundary from - // the bitstream when the caller never plumbs one in (the common case — see - // `BACKSTEP_TICKS`). `None` until the first AU with a PTS. + // Highest PES PTS seen, on a monotonic 64-bit timeline (33-bit PTS unwrapped + // across 2^33 wraps — see `pts_wrap_offset`). Auto-detects a non-seamless clip + // boundary when the caller never plumbs one in (see `BACKSTEP_TICKS`). high_pts: Option<i64>, - // Accumulated 2^33-tick offset applied to raw PES PTS values to unwrap them - // onto the monotonic timeline `high_pts` lives on. The 33-bit 90 kHz PTS - // wraps every ~26.5 h; a BD clip can start at a high base and cross the wrap - // mid-title. Without unwrapping, the 2^33→0 step looks like a backward clip - // reset and false-arms the CRA→BLA rewrite (corrupting a legitimate in-clip - // CRA and dropping valid RASL pictures). Each detected wrap adds 2^33 here. + // Accumulated 2^33-tick offset unwrapping raw PES PTS onto the monotonic + // `high_pts` timeline. Without it, a 33-bit PTS wrap (~26.5h) looks like a + // backward clip reset and false-arms the CRA→BLA rewrite, corrupting valid RASL. pts_wrap_offset: i64, - // HDR10 static metadata accumulated from prefix/suffix SEI. The Mastering - // Display Colour Volume (payloadType 137) and Content Light Level Info - // (payloadType 144) messages arrive in (possibly) separate SEI NALs; each is - // captured independently and STICKY (first seen wins — they are per-stream - // constants). `hdr10()` combines them into a complete `Hdr10Metadata` only - // when BOTH are present. An SDR / no-SEI stream leaves both `None` so no - // colour-volume metadata is ever fabricated. + // HDR10 static metadata from prefix/suffix SEI: mastering display (137) and + // content light level (144), captured independently and sticky (first wins). + // `hdr10()` combines both only when present; SDR streams stay `None`, never fabricated. sei_mastering: Option<MasteringDisplay>, sei_content_light: Option<ContentLightLevel>, /// Display-order PTS reconstruction, enabled only on the program-stream @@ -210,55 +145,34 @@ struct ContentLightLevel { max_pic_average_light_level: u16, } -// A backward PES-PTS step larger than this (90 kHz ticks) marks a non-seamless -// BD clip boundary: each .m2ts clip carries its own PTS base, so at a 0x05/0x06 -// join the next clip's PTS resets backward by far more than any B-frame reorder -// window (HEVC reorder depth tops out ~16 frames, <1 s at 24 fps). 3 s = 270000 -// ticks sits well above any legitimate reorder dip and far below any real clip's -// duration, so it never false-triggers within a clip. This MIRRORS the mux-side -// `DISCONTINUITY_BACKSTEP_NS` (3 s) in `mux/timeline.rs`, which independently -// rebases the timeline at the same boundaries; here it drives the CRA→BLA rewrite -// that kills the dangling-RASL "Could not find ref with POC N" decode errors a -// concatenated multi-clip title otherwise produces. +// A backward PES-PTS step over this (90kHz ticks) marks a non-seamless BD clip +// boundary: each .m2ts clip has its own PTS base, resetting far more than any +// B-frame reorder window. Mirrors `DISCONTINUITY_BACKSTEP_NS` in `mux/timeline.rs`. const BACKSTEP_TICKS: i64 = 270_000; -// The mirror above is enforced, not just described: 90 kHz ticks → ns is -// × (1_000_000_000 / 90_000) = × 100_000 / 9, so 270_000 ticks must be exactly -// `DISCONTINUITY_BACKSTEP_NS`. Changing either constant without the other fails -// the build here, which is the drift the comment exists to prevent. +// Enforced, not just described: 90kHz ticks -> ns is × 100_000 / 9, so this must +// equal `DISCONTINUITY_BACKSTEP_NS` exactly. Changing either constant alone fails the build. const _: () = assert!( BACKSTEP_TICKS * 100_000 / 9 == crate::mux::timeline::DISCONTINUITY_BACKSTEP_NS, "HEVC BACKSTEP_TICKS must mirror mux::timeline::DISCONTINUITY_BACKSTEP_NS" ); // Bytes reserved at the front of every assembled access unit so the keyframe -// parameter-set re-assert can be spliced in without reallocating. A VPS + SPS + -// PPS re-assert is a few hundred bytes (each a 4-byte length prefix plus a NAL -// that is tens to low hundreds of bytes on real BD/UHD streams); 1 KiB covers it -// with margin, and costs 1 KiB of slack per in-flight frame. If a stream's -// parameter sets ever exceed this the splice still produces correct output — it -// just reallocates once, exactly as it always did. +// param-set re-assert (VPS+SPS+PPS, typically well under 1 KiB on BD/UHD +// streams) can splice in without reallocating. Oversized sets just reallocate once. const PARAM_REASSERT_HEADROOM: usize = 1024; -// Per-thread count of keyframe re-asserts that had to reallocate the frame buffer. -// Test-only instrumentation: the whole point of `PARAM_REASSERT_HEADROOM` is that -// the splice is in-place, so that is MEASURED rather than reasoned about. See +// Per-thread count of keyframe re-asserts that had to reallocate. Test-only: +// proves the splice stays in-place rather than reasoning about it. See // `keyframe_param_reassert_does_not_reallocate_the_frame`. #[cfg(test)] thread_local! { static PARAM_REASSERT_REALLOCS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) }; } -// The 33-bit 90 kHz PES PTS counter wraps at 2^33 ticks (~26.5 h). When the raw -// PTS steps backward by approximately a full period — i.e. it landed just past -// the wrap — it is a counter wraparound, NOT a clip reset: unwrap it (add 2^33) -// instead of arming the CRA→BLA rewrite. A genuine non-seamless clip join resets -// the PTS to a fresh small base, a backward step of arbitrary (sub-2^33) size; a -// wrap is specifically a step of ~2^33. We accept any backward step within one -// `PTS_WRAP_PERIOD`/2 of a full period as a wrap (the new value is below the old -// high-water but within a reorder window of the wrap point), which cleanly -// separates the two cases since a clip reset to a small base is nowhere near 2^33 -// below the high-water unless the title is itself ~26 h long (impossible on BD). +// 33-bit 90kHz PTS wraps at 2^33 ticks (~26.5h). A backward step of ~2^33 is a +// wraparound (unwrap, add 2^33) not a clip reset (arbitrary sub-2^33 backward +// step); accepting steps within `PTS_WRAP_PERIOD`/2 of a full period separates the two cases. const PTS_WRAP_PERIOD: i64 = 1 << 33; impl Default for HevcParser { @@ -305,7 +219,7 @@ impl HevcParser { } /// Combine the accumulated mastering-display and content-light SEI into a - /// complete [`Hdr10Metadata`], or `None` until BOTH HDR10 SEI messages have + /// complete [`Hdr10Metadata`](crate::mux::codec::Hdr10Metadata), or `None` until BOTH HDR10 SEI messages have /// been seen. Requiring both means an SDR / partially-signalled stream never /// emits a half-populated (confidently-wrong) HDR10 record. fn hdr10(&self) -> Option<crate::mux::codec::Hdr10Metadata> { @@ -335,13 +249,9 @@ impl HevcParser { /// Unknown payload types are skipped by their size so a later HDR10 message /// in the same NAL is still reached. fn scan_sei(&mut self, nal: &[u8]) { - // Both HDR10 messages are per-stream constants and STICKY (first seen - // wins), so once both are captured every remaining match arm below - // declines and the whole scan is a guaranteed no-op. Return BEFORE - // `strip_emulation_prevention`, which allocates and byte-copies the entire - // SEI RBSP: an HDR10 UHD stream carries a prefix SEI per access unit, so - // without this the other ~200,000 access units of a title each paid one - // allocation and one copy for a result that is discarded. + // Both HDR10 messages are sticky (first wins); once both are captured, + // return before `strip_emulation_prevention` (alloc+copy) — an HDR10 UHD + // stream carries a prefix SEI per AU, saving ~200k discarded copies per title. if self.sei_mastering.is_some() && self.sei_content_light.is_some() { return; } @@ -377,10 +287,8 @@ impl HevcParser { } i += payload_size; // An RBSP trailing byte (0x80) or padding zeros after the last - // message is not another payloadType; stop when nothing meaningful - // remains. `read_sei_ff_value` returning None on the next pass - // handles end-of-buffer; a lone 0x80 trailing bits byte is consumed - // as a (bogus) payloadType of 128 then fails the size read → break. + // message aren't another payloadType. `read_sei_ff_value` returning + // None handles end-of-buffer; a lone 0x80 is a bogus type failing the size read. if i >= rbsp.len() { break; } @@ -514,37 +422,19 @@ impl CodecParser for HevcParser { return Vec::new(); } - // MKV block timecodes are PRESENTATION timestamps; frames are stored - // in decode order (the order they arrive here) and the player reorders - // for display by timecode. So use PTS, not DTS — using DTS makes the - // block timecode monotonic in storage order, which presents B-frames in - // decode order (visible judder / wrong frames) and breaks PTS-based - // seeking. Fall back to DTS only if PTS is somehow absent. + // MKV block timecodes are presentation timestamps (decode order stored, + // player reorders). Use PTS not DTS: DTS presents B-frames in decode + // order (visible judder) and breaks PTS-based seeking; fall back only if absent. let explicit_pts = pes.pts.or(pes.dts).map(pts_to_ns); let pts_ns = explicit_pts.unwrap_or(0); - // Auto-detect a non-seamless clip boundary from the bitstream. freemkv - // reads a BD title's clips as ONE concatenated sector stream and the - // mpls connection_condition is not plumbed through the (threaded) mux - // pipeline, so `mark_clip_boundary` is otherwise never invoked. Each - // .m2ts clip carries its own PTS base; at a 0x05/0x06 join the next - // clip's PTS resets backward by far more than any reorder window. A - // backward step beyond `BACKSTEP_TICKS` is that boundary: arm the same - // CRA→BLA rewrite (`pending_clip_boundary`) the first IRAP of the new - // clip then consumes. Without this, the splice CRA's RASL leading - // pictures reference pre-join frames gone after concatenation and a - // linear decoder floods "Could not find ref with POC N" (the UHD - // multi-clip defect). Uses the 90 kHz PES PTS (not the rebased mux timeline) - // UNWRAPPED onto a monotonic 64-bit timeline first — the raw 33-bit PTS - // wraps every ~26.5 h, and a single-clip title that crosses 2^33→0 would - // otherwise false-arm the rewrite (corrupting a legitimate in-clip CRA). - // Tracks the high-water mark so a single in-clip B-frame dip never arms - // it. DTS-only AUs (no PTS) leave the watermark untouched. + // Auto-detect a non-seamless clip boundary: mpls connection_condition + // isn't plumbed through the (threaded) mux pipeline, so a PTS backstep + // beyond `BACKSTEP_TICKS` on the unwrapped/high-water timeline arms the rewrite. if let Some(raw_pts) = pes.pts { - // Unwrap onto the monotonic timeline. If the offset-adjusted value - // dropped to roughly a full period (2^33) below the high-water, the - // 33-bit counter wrapped: add another period and re-check, rather - // than treat the wrap as a backward clip reset. + // Unwrap onto the monotonic timeline: if the offset-adjusted value + // dropped ~2^33 below the high-water, the counter wrapped — add + // another period and re-check, rather than treat it as a clip reset. let mut unwrapped = raw_pts + self.pts_wrap_offset; if let Some(high) = self.high_pts && high - unwrapped > PTS_WRAP_PERIOD / 2 @@ -572,13 +462,9 @@ impl CodecParser for HevcParser { let mut emitted_vps = false; let mut emitted_sps = false; let mut emitted_pps = false; - // Pre-size: output is ~input bytes with a few 4-byte length - // prefixes added. UHD frames are 150-300 KB; the unsized Vec - // growth chain otherwise reallocs 5-7× per frame. - // - // Plus `PARAM_REASSERT_HEADROOM` so the keyframe parameter-set re-assert - // below can be spliced in FRONT of the frame without reallocating. See - // that site. + // Pre-sized to input length plus PARAM_REASSERT_HEADROOM: UHD frames are + // 150-300 KB and an unsized Vec otherwise reallocs 5-7x per frame; the + // headroom also lets the keyframe param-set re-assert splice in without reallocating. let mut frame_data = Vec::with_capacity(data.len() + 64 + PARAM_REASSERT_HEADROOM); // Single-pass NAL scan: extract params, detect keyframes, build length-prefixed output @@ -586,30 +472,24 @@ impl CodecParser for HevcParser { while let Some(sc_pos) = find_start_code(data, pos) { if let Some(nal_start) = skip_start_code(data, sc_pos) { let next = find_start_code(data, nal_start).unwrap_or(data.len()); - // Strip the leading zeros of the following start code. For a - // conforming bitstream this is lossless: rbsp_trailing_bits() - // sets a stop-one bit, so the final byte of any RBSP is never - // 0x00 — the only trailing zeros here belong to the next - // 00 00 (00) 01 prefix. + // Strip leading zeros of the following start code: lossless + // since rbsp_trailing_bits() sets a stop-one bit, so an RBSP's + // final byte is never 0x00 — these zeros belong to the next prefix. let mut end = next; while end > nal_start && data[end - 1] == 0x00 { end -= 1; } - // Skip empty NALs entirely. When the trailing-zero strip reduces - // `end` back to `nal_start` (e.g. `00 00 01 00 00 01`, or a - // zero-filled bad sector between two start codes), the slice is - // empty; emitting a 4-byte 0x00000000 length prefix with no NAL - // body produces a structurally invalid NALU a decoder rejects. + // Skip empty NALs: when the trailing-zero strip reduces `end` + // back to `nal_start` (e.g. adjacent start codes, or a zero-filled + // bad sector), emitting a 0-length NALU would be structurally invalid. if nal_start < data.len() && end > nal_start { // HEVC NAL header: 2 bytes. Type is bits 1-6 of first byte. let nal_type = (data[nal_start] >> 1) & 0x3F; - // Measure the coding type from the FIRST coded slice (VCL NAL - // 0..=31). Only attempted once the active PPS is known, so - // `num_extra_slice_header_bits` — and thus the bit offset to - // `slice_type` — is EXACT. With no PPS we decline rather than - // guess, leaving coding `None` (honestly absent). + // Measure coding type from the first coded slice (VCL NAL + // 0..=31), only once the active PPS is known so the bit + // offset to `slice_type` is exact; else decline (`None`), never guess. if coding_type.is_none() && nal_type <= NAL_VCL_MAX && let Some(num_extra) = self @@ -649,24 +529,15 @@ impl CodecParser for HevcParser { &mut frame_data, ) } - // Drop Access Unit Delimiters. This is intentional and - // spec-correct: Matroska HEVC frame data omits AUDs - // (the container delimits access units), so carrying - // them in-band is redundant. H.264 does the same below. + // Drop Access Unit Delimiters: Matroska HEVC frame data + // omits AUDs (the container delimits access units), so + // carrying them in-band is redundant. H.264 does the same. NAL_AUD => {} t if (NAL_BLA_W_LP..=NAL_RSV_IRAP_VCL23).contains(&t) => { keyframe = true; - // Splice-aware CRA→BLA rewrite. At the FIRST CRA - // following a non-seamless clip boundary (flag set - // via `mark_clip_boundary`), rewrite CRA_NUT (21) → - // BLA_W_LP (16) so a linear decoder sets NoRaslOutput - // and drops the dangling RASL with no error. The flag - // is consumed here so exactly ONE CRA per boundary is - // touched. A non-CRA IRAP (IDR, BLA) clears the flag - // too (the boundary is handled — IDR carries no - // cross-splice refs) but is NOT modified. Default - // path (flag never set) is unreachable → byte- - // identical output. + // Splice-aware CRA→BLA: the first CRA_NUT after a + // non-seamless boundary becomes BLA_W_LP so NoRaslOutput + // drops RASL. Any IRAP clears the flag; only CRA is rewritten. if self.pending_clip_boundary && t == NAL_CRA_NUT { // First CRA after a non-seamless boundary: rewrite // its header type to BLA_W_LP. NAL type is bits 1-6 @@ -706,38 +577,18 @@ impl CodecParser for HevcParser { return Vec::new(); } - // A player re-applies the hvcC (codecPrivate) parameter sets at every - // keyframe. If the active set was redefined mid-title and the source - // stopped repeating that redefinition at later IRAPs (relying on the - // decoder to retain it — valid for a raw bitstream), the hvcC - // re-insertion would silently revert to the stale FIRST body and every - // frame in the segment decodes against the wrong parameter set - // (CABAC/cu_qp_delta desync). Re-assert the active set in-band, ahead - // of this AU's slices, so it wins. Re-asserted at EVERY keyframe (even - // when active == codecPrivate) so each keyframe is self-contained and a - // decoder that dropped the set (CRA reset / SPS event) self-heals. + // A player re-applies hvcC param sets at every keyframe; if the active + // set was redefined mid-title and the source stopped repeating it, the + // reversion desyncs CABAC. Re-assert in-band at every keyframe (self-heals). if keyframe { let mut prefix = Vec::with_capacity(PARAM_REASSERT_HEADROOM); reassert_active(&mut prefix, &self.cur_vps, emitted_vps); reassert_active(&mut prefix, &self.cur_sps, emitted_sps); reassert_active(&mut prefix, &self.cur_pps, emitted_pps); if !prefix.is_empty() { - // SPLICE the few hundred prefix bytes into the front of the - // already-assembled frame, in place. - // - // This used to be `prefix.extend_from_slice(&frame_data)` followed - // by `frame_data = prefix`: that grew `prefix` from a few hundred - // bytes to the FULL access-unit size (a fresh multi-MB allocation), - // memcpy'd the whole frame into it, and dropped the presized - // `frame_data` buffer — one extra whole-frame allocation plus one - // extra whole-frame copy per keyframe. A 2 h UHD title at 24 fps - // with a 1 s GOP is ~7,200 keyframes, i.e. ~7,200 multi-MB - // allocations and ~14-28 GB of avoidable memcpy per title. - // - // `frame_data` was reserved with `PARAM_REASSERT_HEADROOM` to spare - // precisely so this splice fits without reallocating; what remains - // is one in-place memmove inside the existing buffer. Byte-identical - // output either way. + // Splice prefix into frame_data in place via PARAM_REASSERT_HEADROOM, + // avoiding the extra whole-frame alloc+copy per keyframe the old + // extend+reassign cost — ~7,200 keyframes, 14-28 GB/title on a 2h UHD. #[cfg(test)] let cap_before = frame_data.capacity(); frame_data.splice(0..0, prefix); @@ -748,11 +599,9 @@ impl CodecParser for HevcParser { } } - // HDR10 static metadata is per-stream; once both SEI messages have been - // seen it is stamped onto every frame's PictureInfo so it rides the same - // deferred-muxer path the measured field order uses (the muxer reads it - // from the first coded picture before writing the track header). `None` - // until both SEI present → SDR / no-SEI tracks carry nothing. + // HDR10 static metadata is stamped onto every frame's PictureInfo once + // both SEI messages are seen, riding the deferred-muxer path (reads it + // from the first coded picture before the track header). `None` for SDR tracks. let hdr10 = self.hdr10(); let frame = Frame { // Coding-type only: HEVC field order (pic_struct, from a pic_timing @@ -786,10 +635,9 @@ impl CodecParser for HevcParser { let sps = self.sps.as_ref()?; let pps = self.pps.as_ref()?; - // hvcC encodes each NAL's length as a 16-bit field. A param set larger - // than 65535 bytes would silently truncate the length while the full - // bytes are appended → mis-framed record. Refuse rather than emit a - // corrupt hvcC (param sets this large are non-conforming anyway). + // hvcC encodes each NAL's length as a 16-bit field; a param set over + // 65535 bytes would truncate the length while full bytes are appended, + // mis-framing the record. Refuse rather than emit a corrupt hvcC. if vps.len() > 0xFFFF || sps.len() > 0xFFFF || pps.len() > 0xFFFF { return None; } @@ -799,19 +647,9 @@ impl CodecParser for HevcParser { // chroma/bit depths) followed by numOfArrays length-prefixed NAL arrays. let mut record = Vec::new(); - // Minimal HEVCDecoderConfigurationRecord header. - // - // The stored SPS NAL is [2-byte HEVC NAL header][SPS RBSP...]. - // profile_tier_level fields must be read off the - // emulation-prevention-STRIPPED RBSP — a `00 00 03` sequence in the - // first ~15 SPS bytes would otherwise shift every raw byte index and - // corrupt the PTL (profile/compat/constraint/level). We strip first - // (same as parse_sps_chroma) and index into the cleaned RBSP: - // rbsp[0] sps_vps_id u(4)+max_sub_layers u(3)+temporal_nesting u(1) - // rbsp[1] general_profile_space u(2)+tier u(1)+profile_idc u(5) - // rbsp[2..6] general_profile_compatibility_flags u(32) - // rbsp[6..12] general_constraint_indicator_flags 48 bits - // rbsp[12] general_level_idc u(8) + // The stored SPS NAL is [2-byte NAL header][SPS RBSP]. profile_tier_level + // fields must be read off the emulation-prevention-STRIPPED RBSP — a + // `00 00 03` in the first ~15 bytes would shift byte indices and corrupt the PTL. let ptl: Vec<u8> = if sps.len() > 2 { strip_emulation_prevention(&sps[2..]) } else { @@ -835,10 +673,9 @@ impl CodecParser for HevcParser { record.extend_from_slice(&[0xF0, 0x00]); // parallelismType (6 + 2 bits) record.push(0xFC); - // chromaFormat / bit depths — parse the real values from the SPS RBSP. - // A hardcoded 8-bit 4:2:0 is wrong for 10-bit Main 10 UHD (essentially - // all UHD content). Fall back to 8-bit 4:2:0 only if the SPS can't be - // parsed (emulation-prevention is handled; sub-layer PTL is skipped). + // chromaFormat / bit depths — parse real values from the SPS RBSP; a + // hardcoded 8-bit 4:2:0 is wrong for 10-bit Main 10 UHD (nearly all UHD + // content). Fall back to it only if the SPS can't be parsed. let chroma = parse_sps_chroma(sps).unwrap_or(SpsChroma { chroma_format_idc: 1, bit_depth_luma_minus8: 0, @@ -854,14 +691,9 @@ impl CodecParser for HevcParser { record.push(0xF8 | (chroma.bit_depth_chroma_minus8 & 0x07)); // avgFrameRate record.extend_from_slice(&[0, 0]); - // Byte 21 packs four fields (ISO/IEC 14496-15): - // constantFrameRate u(2) = 0 (unknown / not constant) - // numTemporalLayers u(3) = sps_max_sub_layers_minus1 + 1 - // temporalIdNested u(1) = sps_temporal_id_nesting_flag - // lengthSizeMinusOne u(2) = 3 (4-byte length prefix) - // sps_max_sub_layers_minus1 is u(3) (0..7), so +1 is 1..8. The hvcC - // numTemporalLayers field is u(3) (0..7); the max legal value (8) is - // saturated to 7 rather than wrapping to 0 via the & 0x07 mask. + // Byte 21 packs constantFrameRate=0, numTemporalLayers (u3), + // temporalIdNested, lengthSizeMinusOne=3 (ISO 14496-15). sub_layers+1 can + // be 8, which saturates to 7 (not wraps to 0 via &0x07) for the u(3) field. let num_temporal_layers = chroma.max_sub_layers_minus1.saturating_add(1).min(7) & 0x07; let temporal_id_nested = chroma.temporal_id_nesting_flag & 0x01; record.push((num_temporal_layers << 3) | (temporal_id_nested << 2) | 0x03); @@ -907,12 +739,9 @@ struct SpsChroma { temporal_id_nesting_flag: u8, } -// Per-thread count of `strip_emulation_prevention` calls. Test-only -// instrumentation: the function allocates and byte-copies a whole RBSP, and -// `scan_sei` used to run it for every SEI NAL of every access unit, so "how many -// copies did a stream actually cost" is worth MEASURING rather than reasoning -// about. Thread-local, not a global atomic, because `cargo test` runs tests -// concurrently. See `scan_sei_stops_copying_once_both_hdr10_messages_are_captured`. +// Per-thread count of `strip_emulation_prevention` calls: the function +// allocates+copies a whole RBSP, so this measures cost rather than reasoning +// about it. Thread-local (not atomic) since `cargo test` runs concurrently. #[cfg(test)] thread_local! { static RBSP_COPIES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) }; @@ -959,7 +788,7 @@ fn read_sei_ff_value(rbsp: &[u8], i: &mut usize) -> Option<u32> { /// Parse a Mastering Display Colour Volume SEI payload (Rec. ITU-T H.265 /// D.2.28 / semantics D.3.28). Layout — 24 bytes total, all big-endian: -/// display_primaries_x[c] u(16), display_primaries_y[c] u(16) for c=0,1,2 +/// `display_primaries_x[c]` u(16), `display_primaries_y[c]` u(16) for c=0,1,2 /// (SEI primary order is c=0 Green, c=1 Blue, c=2 Red) /// white_point_x u(16), white_point_y u(16) /// max_display_mastering_luminance u(32) @@ -1053,10 +882,9 @@ fn parse_sps_chroma(sps: &[u8]) -> Option<SpsChroma> { /// Consume a profile_tier_level(profilePresentFlag=1, maxNumSubLayersMinus1) /// structure from the bit reader (HEVC 7.3.3). fn parse_profile_tier_level(r: &mut BitReader, max_sub_layers_minus1: u32) -> Option<()> { - // general PTL fixed layout (HEVC 7.3.3): profile_space u(2) + tier u(1) + - // profile_idc u(5) = 8, general_profile_compatibility_flags u(32), - // constraint-flags/reserved area = 48, general_level_idc u(8). - // Total = 8 + 32 + 48 + 8 = 96 bits = 12 bytes. Skip 96 bits. + // general PTL fixed layout (HEVC 7.3.3): profile_space/tier/profile_idc (8) + + // compatibility_flags (32) + constraint-flags/reserved (48) + level_idc (8) + // = 96 bits = 12 bytes. Skip 96 bits. r.skip_bits(96)?; if max_sub_layers_minus1 > 0 { @@ -1445,10 +1273,9 @@ mod tests { /// decoded values still match the un-emulated payload. #[test] fn hevc_hdr10_sei_de_emulates() { - // prim_x[0]=0x0000, prim_y[0]=0x0002 → raw payload starts 00 00 00 02. - // A conforming HEVC encoder inserts an emulation-prevention 0x03 after the - // 00 00 (since the following byte is ≤ 0x03), giving 00 00 03 00 02. The - // parser MUST strip that 03 before reading, or every later field shifts. + // prim_x[0]=0x0000, prim_y[0]=0x0002: raw payload starts 00 00 00 02, so + // a conforming encoder inserts emulation-prevention 0x03 -> 00 00 03 00 02. + // The parser must strip that 03 before reading, or every later field shifts. let prim_x = [0u16, 6550, 35400]; let prim_y = [2u16, 2300, 14600]; let payload = mastering_payload(prim_x, prim_y, 15635, 16450, 10_000_000, 1); @@ -1526,9 +1353,8 @@ mod tests { /// offset on any real stream that sets it — mislabelling every picture's /// coding type. #[test] - // The underscores in these literals mark BITFIELD boundaries in the - // bitstream header being built (e.g. a 5-bit field then a 3-bit field), - // not thousands-style digit groups. Regrouping them uniformly would + // The underscores here mark BITFIELD boundaries (e.g. 5-bit then 3-bit), + // not thousands-style digit groups — regrouping them uniformly would // satisfy the lint by destroying the only thing they encode. #[allow(clippy::unusual_byte_groupings)] fn nonzero_num_extra_slice_header_bits_shifts_the_slice_type_offset() { @@ -1560,11 +1386,9 @@ mod tests { None ); - // Slice segment header for a non-IRAP VCL NAL (TRAIL_R, type 1): - // first_slice_segment_in_pic_flag 1, slice_pic_parameter_set_id ue=0 - // ('1'), then THREE reserved bits set to 101 (deliberately not zero, so a - // parser that reads them instead of skipping them cannot agree), then - // slice_type ue(v) = '011' → 2 → I. + // Slice header for TRAIL_R (type 1): first_slice=1, pps_id ue=0 ('1'), + // then THREE reserved bits = 101 (deliberately nonzero, so skipping vs + // reading them disagrees), then slice_type ue(v)='011' -> 2 (I). let slice_body = 0b1_1_101_011u8; assert_eq!(slice_body, 0xEB); @@ -1600,10 +1424,9 @@ mod tests { #[test] fn hevc_populates_measured_coding_type_and_source() { use super::super::coding::CodingType; - // PPS body 0xC0 = pps_id 0, sps_id 0, dependent_slice 0, output_flag 0, - // num_extra_slice_header_bits 0 → slice_type follows pps_id directly. - // Slice body (TRAIL_R, non-IRAP VCL type 1) = first_slice 1, pps_id 0, - // slice_type: 0xD8 → 2 (I); 0xD0 → 1 (P); 0xE0 → 0 (B). + // PPS 0xC0: all fields 0 so slice_type follows pps_id directly. Slice + // body (TRAIL_R type 1) = first_slice=1, pps_id=0, slice_type: + // 0xD8->I(2); 0xD0->P(1); 0xE0->B(0). let nal = |t: u8, body: u8| { let mut v = vec![0x00, 0x00, 0x01]; v.extend_from_slice(&hevc_nal_header(t)); @@ -1789,11 +1612,9 @@ mod tests { // redefinition (a real change from B → emitted). let au4 = [nal(34, &pps_a), nal(19, &[0x13])].concat(); parser.parse(&make_pes(au4, Some(10800))); - // AU5: BARE keyframe, source omits the PPS, and the active set now - // EQUALS codecPrivate. It must STILL be re-asserted in-band — every - // keyframe is self-contained: a decoder that dropped PPS id 0 at a CRA - // reset can only recover from an in-band copy, and there is no genuine - // change here to trigger the emit path. + // AU5: bare keyframe, active set now EQUALS codecPrivate. Must still be + // re-asserted in-band — a decoder that dropped PPS id 0 at a CRA reset + // can only recover from an in-band copy, though no genuine change occurred. let au5 = nal(19, &[0x14]); let f5 = parser.parse(&make_pes(au5, Some(14400))); assert!( @@ -1912,11 +1733,9 @@ mod tests { pps_body(&nals_in(&f3[0].data)).iter().any(|p| p == &a), "switch back to codecPrivate PPS-A must be emitted in-band" ); - // AU4 keyframe: A again, now == active AND == codecPrivate. Under the - // self-contained-keyframe rule it is STILL re-asserted in-band so a - // decoder that dropped PPS id 0 at this IRAP recovers. handle_param_set - // strips the source copy (== active), then reassert_active prepends the - // active set unconditionally. + // AU4 keyframe: A again, now == active AND == codecPrivate. Still + // re-asserted in-band (self-contained-keyframe rule) so a decoder that + // dropped PPS id 0 at this IRAP recovers. let f4 = parser.parse(&make_pes( [nal(34, &a), nal(19, &[4])].concat(), Some(10800), @@ -1929,14 +1748,9 @@ mod tests { #[test] fn hvcc_profile_tier_level_offsets() { - // The hvcC fixed header must read profile_tier_level from the SPS - // RBSP, not from the NAL header. Stored SPS = [2-byte NAL header][RBSP]. - // RBSP layout (byte-aligned): - // sps[2] sps_vps_id/max_sub_layers/temporal_nesting - // sps[3] general_profile_space+tier+profile_idc - // sps[4..8] general_profile_compatibility_flags - // sps[8..14] general_constraint_indicator_flags - // sps[14] general_level_idc + // The hvcC fixed header must read profile_tier_level from the SPS RBSP, + // not the NAL header. Stored SPS = [2-byte NAL header][RBSP]; layout is + // byte-aligned sps[2..15] per the labeled array below. let mut parser = HevcParser::new(); // Distinct, recognizable values for each field. @@ -2392,10 +2206,9 @@ mod tests { d.extend_from_slice(&cra_au(&[0x11, 0x22])); d }; - // Reference parser: the rewrite field exists but is NEVER marked, so its - // output is exactly the pre-feature behaviour. We compare a never-marked - // run against a second never-marked run AND against the documented - // invariant that the CRA is emitted as-is (type 21, payload intact). + // Reference parser: the rewrite field exists but is never marked, so its + // output is exactly pre-feature behaviour — compared against a second + // never-marked run and the invariant that CRA is emitted as-is (type 21). let mut a = HevcParser::new(); let mut b = HevcParser::new(); let fa = a.parse(&make_pes(build(), Some(0))); @@ -2587,11 +2400,9 @@ mod tests { let f = parser.parse(&make_pes(d, Some(1))); assert_eq!(count_pps(&f[0].data), 1, "redefined PPS must be inline"); - // PES3: PPS-B repeated on a NON-keyframe slice — B is already the active - // set, so this carries no change and is stripped. (Re-assertion for - // players that re-apply hvcC at keyframes is handled by - // `reassert_active` at KEYFRAMES, not on every trailing frame; these - // slices are TRAIL_R, not IRAP.) + // PES3: PPS-B repeated on a non-keyframe slice — B is already active, so + // this carries no change and is stripped. Re-assertion for hvcC-reapplying + // players is handled by `reassert_active` at keyframes, not TRAIL_R slices. let mut d = pps(0xBB); d.extend(slice()); let f = parser.parse(&make_pes(d, Some(2))); @@ -2601,11 +2412,9 @@ mod tests { "PPS equal to the active set carries no change → stripped" ); - // PES4: back to PPS-A. Even though A == codecPrivate, the ACTIVE set is - // B, so switching to A is a real change and MUST be emitted in-band — a - // streaming decoder (hvcC at init, in-band updates only) is sitting on B - // and would otherwise never revert. (This is the PPS revert bug: the old - // `== codecPrivate → strip` rule dropped exactly this revert.) + // PES4: back to PPS-A. Active set is B, so switching to A is a real + // change and must be emitted in-band — a streaming decoder sitting on B + // would never revert otherwise (the old `== codecPrivate -> strip` rule dropped this). let mut d = pps(0xAA); d.extend(slice()); let f = parser.parse(&make_pes(d, Some(3))); @@ -2620,10 +2429,9 @@ mod tests { #[test] fn empty_nal_between_start_codes_emits_no_bare_prefix() { - // `00 00 01 00 00 01 <real NAL>`: the first start code is immediately - // followed by another, so the in-between NAL is empty after the - // trailing-zero strip. It must be skipped, NOT written as a bare - // 0x00000000 length prefix (which a decoder treats as malformed). + // `00 00 01 00 00 01 <real NAL>`: two adjacent start codes leave the + // in-between NAL empty after the trailing-zero strip. It must be skipped, + // not written as a bare 0x00000000 length prefix (malformed to a decoder). let mut parser = HevcParser::new(); let mut data = Vec::new(); data.extend_from_slice(&[0x00, 0x00, 0x01]); // start code, empty NAL @@ -2789,10 +2597,9 @@ mod tests { "frame data must contain Dolby Vision RPU NAL (type 62), got: {:?}", nal_types ); - // Self-contained keyframe: the active VPS/SPS/PPS are re-asserted in-band - // ahead of the IDR, so the frame is VPS, SPS, PPS, IDR, RPU — in that - // order. The RPU (type 62) is preserved (never stripped); only the - // duplicate-suppression of unchanged param sets was lifted at keyframes. + // Self-contained keyframe: VPS/SPS/PPS re-assert ahead of the IDR, giving + // VPS,SPS,PPS,IDR,RPU. The RPU (type 62) is preserved (never stripped) — + // only duplicate-suppression of unchanged param sets is lifted at keyframes. assert_eq!( nal_types, vec![32, 33, 34, 19, 62], @@ -2938,10 +2745,9 @@ mod tests { #[test] fn hvcc_byte21_from_sps_temporal_layers() { - // make_sps_with_chroma sets sps_max_sub_layers_minus1 = 0 and - // sps_temporal_id_nesting_flag = 1, so byte 21 must encode - // numTemporalLayers = 1, temporalIdNested = 1, lengthSizeMinusOne = 3: - // (1 << 3) | (1 << 2) | 3 = 0x0F. + // make_sps_with_chroma sets sub_layers_minus1=0, nesting_flag=1, so byte + // 21 = numTemporalLayers=1, temporalIdNested=1, lengthSizeMinusOne=3: + // (1<<3)|(1<<2)|3 = 0x0F. let sps = make_sps_with_chroma(1, 2, 2); let cp = codec_private_from_sps(&sps); assert_eq!( @@ -2952,10 +2758,9 @@ mod tests { #[test] fn hvcc_handles_emulation_prevention_in_sps() { - // Insert an emulation-prevention byte (00 00 03) into the SPS RBSP and - // confirm the chroma/bit-depth parse still lands on the right values. - // Build a 10-bit 4:2:0 SPS, then splice 00 00 03 into the RBSP tail - // (after the fields we parse) — the strip must not corrupt earlier bits. + // Insert emulation-prevention bytes (00 00 03) after the parsed fields + // in a 10-bit 4:2:0 SPS RBSP tail and confirm the chroma/bit-depth + // parse still lands right — the strip must not corrupt earlier bits. let mut sps = make_sps_with_chroma(1, 2, 2); // Append a benign 00 00 03 sequence to the RBSP. sps.extend_from_slice(&[0x00, 0x00, 0x03, 0x00]); @@ -2979,10 +2784,8 @@ mod tests { #[test] fn bitreader_ue_golomb_values() { - // Exp-Golomb ue(v): codeNum 0 = "1", 1 = "010", 2 = "011", 3 = "00100", - // 4 = "00101". (H.264/HEVC §9.1.) Pack "1 010 011" = 1010011x. - // Byte 0b1010_0110: read ue → 0 (leading "1"), then "010" → 1, then - // "011" → 2. + // Exp-Golomb ue(v) (H.264/HEVC §9.1): codeNum 0="1", 1="010", 2="011". + // Packed "1 010 011" = byte 0b1010_0110: read ue -> 0, then 1, then 2. let mut r = BitReader::new(&[0b1010_0110]); assert_eq!(r.read_ue(), Some(0)); assert_eq!(r.read_ue(), Some(1)); @@ -3154,10 +2957,9 @@ mod tests { #[test] fn hvcc_parses_chroma_through_sublayer_ptl() { - // With max_sub_layers_minus1 = 2 the parser must consume the sub-layer + // With max_sub_layers_minus1=2 the parser must consume the sub-layer // present-flag bits, reserved bits, and two sub-layer PTL blocks before - // reaching chroma_format_idc / bit depths. A wrong sub-layer skip would - // mis-read the bit depths. + // reaching chroma/bit-depth fields, or a wrong skip mis-reads them. let sps = make_sps_full(1, 2, 2, 2, false); let cp = codec_private_from_sps(&sps); assert_eq!(cp[16], 0xFC | 1, "4:2:0 after sub-layer PTL skip"); @@ -3199,10 +3001,9 @@ mod tests { #[test] fn hvcc_array_headers_and_lengths() { - // After the 23-byte fixed header + numOfArrays the record holds three - // arrays. Each: (0x20 | nal_type), numNalus(=1, u16-BE), nalLength(u16), - // NAL bytes. Verify the SPS array's nal_type byte and length encode - // correctly. (ISO/IEC 14496-15 §8.3.3.1.) + // After the fixed header the record holds three arrays, each + // (0x20 | nal_type), numNalus(=1, u16-BE), nalLength(u16), NAL bytes + // (ISO/IEC 14496-15 §8.3.3.1). Verify the SPS array encodes correctly. let mut parser = HevcParser::new(); let mut data = Vec::new(); data.extend_from_slice(&[0x00, 0x00, 0x01]); @@ -3286,10 +3087,9 @@ mod tests { #[test] fn hevc_nal_type_extraction_masks_correctly() { - // HEVC NAL type = (byte0 >> 1) & 0x3F. The forbidden_zero_bit (bit 7) and - // the low layer-id bit (bit 0) must not affect type. hevc_nal_header(19) - // = [(19<<1), 0x01] = [0x26, 0x01]; with the forbidden bit set (0xA6) it - // is still type 19. + // HEVC NAL type = (byte0 >> 1) & 0x3F. forbidden_zero_bit (bit 7) and the + // low layer-id bit (bit 0) must not affect type: hevc_nal_header(19) = + // [0x26, 0x01]; with the forbidden bit set (0xA6) it's still type 19. let mut parser = HevcParser::new(); let data = vec![0x00, 0x00, 0x01, 0xA6, 0x01, 0x10, 0x20]; // 0xA6>>1&0x3F = 19 let f = parser.parse(&make_pes(data, Some(0))); @@ -3529,10 +3329,9 @@ mod tests { "reconstructed PTS are all distinct (no DTS collision)" ); - // With reorder OFF (transport-stream behaviour) the sparse-PTS frames - // collapse onto the anchor's timestamp and nothing is buffered, so flush - // is empty. This is the discriminator that proves `with_ps_reorder(true)` - // really changed the parser rather than the two paths being identical. + // With reorder OFF the sparse-PTS frames collapse onto the anchor's + // timestamp and nothing is buffered, so flush is empty — the discriminator + // proving `with_ps_reorder(true)` actually changed behaviour. let (raw_during, raw_tail) = feed(false); assert!( raw_tail.is_empty(), diff --git a/src/mux/codec/lpcm.rs b/src/mux/codec/lpcm.rs index 413cdd0c..e211cb49 100644 --- a/src/mux/codec/lpcm.rs +++ b/src/mux/codec/lpcm.rs @@ -137,11 +137,9 @@ mod tests { #[test] fn dvd_lpcm_preserves_all_pcm_bytes() { - // DVD-PS LPCM: PsDemuxer already removed the 7-byte private sub-header, - // so the payload handed to this parser is raw PCM. The DVD parser must - // NOT strip any further bytes — applying the BD 4-byte strip to a DVD - // payload would drop one sample pair per PES and progressively drift - // the audio. + // DVD-PS LPCM: PsDemuxer already removed the 7-byte private sub-header, so + // the payload here is raw PCM. Must NOT strip further — applying the BD + // 4-byte strip would drop one sample pair per PES and drift the audio. let mut parser = LpcmParser::new_dvd(); let pcm = vec![0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0x01, 0x02]; let frames = parser.parse(&make_pes(pcm.clone(), Some(90000))); diff --git a/src/mux/codec/mod.rs b/src/mux/codec/mod.rs index abde746b..e9aeee2d 100644 --- a/src/mux/codec/mod.rs +++ b/src/mux/codec/mod.rs @@ -202,9 +202,8 @@ pub fn parser_for_codec( ) -> Box<dyn CodecParser> { match codec { // `is_dvd_ps` marks a program-stream source (DVD VOB / HD-DVD EVO), whose - // video is timestamped only at GOP granularity. On that path the H.264 / - // HEVC / VC-1 parsers reconstruct a display-order PTS per frame; on the - // BD/UHD transport path (per-frame PTS) they leave timestamps untouched. + // video is timestamped only at GOP granularity: H.264/HEVC/VC-1 reconstruct + // a display-order PTS per frame there; on BD/UHD (per-frame PTS) they don't. Codec::H264 => Box::new(h264::H264Parser::new().with_ps_reorder(is_dvd_ps)), Codec::Hevc => Box::new(hevc::HevcParser::new().with_ps_reorder(is_dvd_ps)), Codec::Mpeg2 => Box::new(mpeg2::Mpeg2Parser::new()), @@ -219,12 +218,9 @@ pub fn parser_for_codec( Codec::Lpcm if is_dvd_ps => Box::new(lpcm::LpcmParser::new_dvd()), Codec::Lpcm => Box::new(lpcm::LpcmParser::new()), Codec::DvdSub => Box::new(dvdsub::DvdSubParser::new(codec_data)), - // Video codecs with no dedicated parser. There is no frame-boundary - // detection here, so a PES carrying multiple access units is emitted as - // one oversized block — but marking every frame a keyframe (as the - // audio passthrough does) would explode Cues density and mislead - // seeking. Use the non-keyframe passthrough and warn that framing is - // approximate. Mpeg1/Av1 are real Codec variants without a parser yet. + // Video codecs with no dedicated parser (Mpeg1/Av1 are real, just unparsed): + // multi-AU PES becomes one oversized block. Use non-keyframe passthrough, + // not all-keyframe (would explode Cues density); warn framing is approximate. Codec::Mpeg1 | Codec::Av1 => { tracing::warn!( target: "mux", @@ -462,16 +458,9 @@ mod provenance_guard { for (name, src) in PARSER_SOURCES { let src = &code_only(src); for blk in frame_literals(src) { - // `PesFrame` in the tests of other modules is not ours; only - // codec `Frame` literals are scanned, and a test fixture that - // builds a PesPacket with `source: None` is legitimate. - // Two spellings, not one. `source: None` is the obvious way to - // lose provenance; OMITTING the field entirely is the quiet - // one, because `Frame` derives Default, so - // `Frame { pts_ns, .. Default::default() }` compiles and - // yields `source: None` while containing no such text. A guard - // that only knew the first spelling would have watched a - // parser be rewritten into the second and stayed green. + // Only codec `Frame` literals are scanned (not `PesFrame`, whose + // `source: None` fixtures are fine). Two loss spellings: explicit + // `source: None`, or omitting it (Frame derives Default, so `..` yields None). let explicit_none = blk.contains("source: None"); let no_source_field = !blk.contains("source:"); if explicit_none || no_source_field { diff --git a/src/mux/codec/mpeg2.rs b/src/mux/codec/mpeg2.rs index 13fe684f..2ad4b38b 100644 --- a/src/mux/codec/mpeg2.rs +++ b/src/mux/codec/mpeg2.rs @@ -179,7 +179,7 @@ impl Mpeg2Parser { parse_aspect_ratio(hdr) } - /// Process one reassembled access unit (from [`AuAssembler`]): decode its + /// Process one reassembled access unit (from [`AuAssembler`](crate::mux::au_assembly::AuAssembler)): decode its /// per-picture coding info, capture a new sequence header, and buffer the /// picture into the current GOP for display-order timestamping. The AU's /// timing / source / discontinuity were already attributed by the assembler. @@ -220,12 +220,9 @@ impl Mpeg2Parser { } else { 0 }; - // Decode the picture coding extension ONCE here and fold every - // per-picture datum (coding type + tff/rff/progressive_frame/ - // frame_picture, plus the sequence's progressive flag) into one - // codec-agnostic `PictureInfo`. `nb_fields()`, `keyframe()`, and - // `field_order()` all derive from it; nothing downstream re-parses the - // elementary stream. + // Decode the picture coding extension ONCE here and fold every per-picture + // datum into one codec-agnostic `PictureInfo`; `nb_fields()`, `keyframe()`, + // and `field_order()` all derive from it, so nothing re-parses the stream. let (tff, rff, progressive_frame, frame_picture) = picture_coding_flags(&data); let info = PictureInfo::mpeg2( coding_type_from_raw(raw_coding_type), @@ -239,10 +236,9 @@ impl Mpeg2Parser { ); let keyframe = info.keyframe(); - // A GOP boundary means the buffered run is a COMPLETE GOP (all its - // pictures display before the next GOP's), so flush it before starting - // the new one. `temporal_reference` resets to 0 at the boundary, keeping - // each GOP's display order self-contained. + // A GOP boundary means the buffered run is a COMPLETE GOP, so flush it + // before starting the new one; `temporal_reference` resets to 0 there, + // keeping each GOP's display order self-contained. if gop_boundary && !self.gop_buf.is_empty() { self.flush_gop(out); } @@ -264,10 +260,9 @@ impl Mpeg2Parser { source: au.source, }, }); - // Safety cap: a stream with no GOP/sequence boundaries would buffer - // unbounded. Force-flush a pathologically long run as its own GOP — - // bounded by BOTH the frame count and the total buffered bytes, so a - // crafted stream of few-but-huge pictures cannot over-allocate either. + // Safety cap: force-flush a pathologically long run as its own GOP, + // bounded by BOTH frame count and total bytes, so a crafted stream of + // few-but-huge pictures cannot over-allocate either. if self.gop_buf.len() >= MAX_PENDING_FRAMES || self.gop_bytes >= MAX_PENDING_BYTES { self.flush_gop(out); } @@ -333,11 +328,9 @@ impl CodecParser for Mpeg2Parser { if pes.data.is_empty() { return Vec::new(); } - // Feed the fragment to the assembler, which reframes the elementary - // stream on picture boundaries and hands back each complete access unit - // with its start timing. MKV block timecodes are presentation timestamps; - // prefer PTS (DTS shows B-frames in decode order — judder and broken - // seeking), falling back to DTS only when PTS is absent. + // Feed the fragment to the assembler, which reframes on picture boundaries. + // MKV block timecodes are presentation timestamps; prefer PTS (DTS shows + // B-frames in decode order — judder/broken seeking), DTS only as fallback. let pts = pes.pts.or(pes.dts).map(pts_to_ns); let aus = self .au_asm @@ -594,16 +587,9 @@ mod tests { #[test] fn parser_populates_full_pictureinfo_and_source() { use crate::mux::codec::coding::FieldOrder; - // Drive the REAL parser over three pictures that exercise EVERY facet of - // PictureInfo the parser measures (not just field order): - // I: tff=1 rff=0 pf=0 → type I, TFF, 2 fields, !progressive, keyframe - // P: tff=0 rff=0 pf=0 → type P, BFF, 2 fields, !progressive, !keyframe - // B: tff=0 rff=1 pf=1 → type B, Progressive, 3 fields (2:3 pulldown), - // progressive, !keyframe - // ...and assert the byte-exact source provenance rides every frame. - // Each picture in its OWN PES with its OWN source stamp — the realistic - // shape (real DVD video is one picture across many PES, each stamped), so - // every picture's frame carries the provenance of its packet. + // Drive the REAL parser over I/P/B pictures exercising every PictureInfo + // facet, each in its OWN PES with its OWN source stamp (realistic DVD + // shape), asserting byte-exact provenance rides every frame. let mk_pes = |data: Vec<u8>, byte: u64| PesPacket { source: Some(crate::pes::SourcePos::at_byte(byte)), pid: 0x1011, @@ -875,10 +861,9 @@ mod tests { #[test] fn two_pictures_in_one_gop_emit_both_on_flush() { - // Two pictures with no GOP/sequence boundary between them are ONE GOP. - // The VFR timeline needs the whole GOP (a P-frame's PTS depends on its - // later B-frames), so they buffer until the GOP closes / EOF, then emit - // in DECODE order, each containing exactly its own picture. + // Two pictures with no GOP/sequence boundary between them are ONE GOP; the + // VFR timeline needs the whole GOP (a P-frame's PTS depends on later + // B-frames), so they buffer until close/EOF, then emit in DECODE order. let mut parser = Mpeg2Parser::new(); let mut pic1 = make_picture_header(PICTURE_TYPE_I); @@ -1047,10 +1032,9 @@ mod tests { #[test] fn telecine_pts_accumulates_by_field_durations_not_a_fixed_grid() { - // NTSC film, frame_rate_code 4 = 29.97 → field_period ≈ 16.683 ms. A 2:3 - // frame (rff=1) occupies 3 fields, a 2:2 frame 2 fields. PTS must - // accumulate by ACTUAL field durations so the next frame starts exactly - // when this one ends — closing the fixed-29.97-grid gap that judders. + // NTSC film 29.97: a 2:3 frame (rff=1) occupies 3 fields, 2:2 occupies 2. + // PTS must accumulate by ACTUAL field durations so the next frame starts + // exactly when this one ends — closing the fixed-grid gap that judders. let mut p = Mpeg2Parser::new(); let field = 1_000_000_000i64 * 1001 / 30000 / 2; @@ -1143,11 +1127,9 @@ mod tests { #[test] fn leading_frames_buffered_until_first_pts_anchor() { - // A DVD title can open with a still-frame/first-play sequence whose PTS - // lands a few frames in (the disc stamps the opening I-frames at one real - // PES PTS, not 0). Leading frames must be held and then anchored to that - // real timeline — never zero-stamped. 25 fps = 40 ms. PTS (2 s) arrives - // only on the THIRD picture. + // A DVD title can open with a still-frame sequence whose PTS lands a few + // frames in. Leading frames must be held and anchored to that real + // timeline, never zero-stamped. 25fps=40ms; PTS (2s) arrives on picture 3. let mut p = Mpeg2Parser::new(); let mut a = make_seq_header(720, 480, 3, 3); @@ -1183,14 +1165,9 @@ mod tests { #[test] fn opening_au_keeps_disc_pts_and_opening_seq_header_no_zero_floor() { - // Opening-GOP / still-frame open regression. A DVD - // title opens on a VOBU that begins with a sequence header + I-frame; the - // disc stamps that opening I-frame at its REAL (non-zero) timeline PTS, - // not 0. The parser must (a) emit the opening I-frame with that real PTS - // — never floored to 0 — and (b) capture THAT opening sequence header as - // codec_private (read at headers-ready, before any later AU). Proves the - // opening pictures are emitted with the correct seq header + PTS, ruling - // out the "wrong/last seq header" and "PTS floored to t=0" hypotheses. + // Opening-GOP regression: a DVD VOBU opens with a seq header + I-frame + // stamped at its REAL non-zero PTS. Emit that PTS (never floored to 0) + // and capture THAT opening seq header as codec_private. let mut p = Mpeg2Parser::new(); // Opening AU: seq header (the codecPrivate) + GOP + I-frame TR0 carrying diff --git a/src/mux/codec/mpegaudio.rs b/src/mux/codec/mpegaudio.rs index f1df8cf2..47ad9d17 100644 --- a/src/mux/codec/mpegaudio.rs +++ b/src/mux/codec/mpegaudio.rs @@ -51,10 +51,9 @@ 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 (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. + // bitrate_index == 0 (free format) is NOT rejected: it's a legal, decodable + // mode (decoder derives frame size from sync spacing); rejecting it would + // be a false positive on a clean stream. MpaVerdict::Valid } diff --git a/src/mux/codec/pgs.rs b/src/mux/codec/pgs.rs index 880e2de3..eda2aad1 100644 --- a/src/mux/codec/pgs.rs +++ b/src/mux/codec/pgs.rs @@ -18,17 +18,13 @@ use super::{CodecParser, Frame, PesPacket, pts_to_ns}; const SEGMENT_PCS: u8 = 0x16; -// Upper bound on a pending display set's accumulated bytes. Real PGS -// display sets are small (a 1080p RLE bitmap plus palette is well under -// 1 MB); a stream that keeps appending non-PCS segments without ever -// emitting a PCS is malformed. Cap accumulation to bound memory and -// drop further appends until the next PCS resyncs the parser. Mirrors -// the MAX_*_BYTES / MAX_*_BUF caps in the DTS and AC-3 parsers. +// Upper bound on a pending display set's bytes (real sets are well under 1 MB). +// Caps a malformed stream that appends non-PCS segments forever without a PCS, +// dropping further appends until the next PCS resyncs — mirrors DTS/AC-3 caps. const MAX_PGS_PENDING_BYTES: usize = 4 * 1024 * 1024; -// Offset within the PES payload at which number_of_composition_objects -// lives in a PCS: 3-byte segment header + 10 bytes of PCS fields -// (video_w/h, frame_rate, comp_num, comp_state, palette_update, -// palette_id_ref) = 13. +// Offset of number_of_composition_objects in a PCS: 3-byte segment header + +// 10 bytes of PCS fields (video_w/h, frame_rate, comp_num, comp_state, +// palette_update, palette_id_ref) = 13. const PCS_NUM_OBJECTS_OFFSET: usize = 13; // Offset of the first composition_object's flags byte within a PCS PES payload: // PCS header(13) + number_of_composition_objects(1) + object_id_ref(2) + @@ -259,20 +255,16 @@ impl CodecParser for PgsParser { if pes.data.is_empty() { return Vec::new(); } - // Keep PTS as Option: a PCS with no PTS has an UNKNOWN start/clear time. - // Collapsing it to a 0 sentinel produces a frame with a wrong start time - // and an absurd duration (the full elapsed time of the disc). PGS PCS - // packets carry a PTS on well-formed BD streams, so a missing PTS is a - // malformed-stream path that we skip cleanly rather than corrupt. + // Keep PTS as Option: collapsing a missing PTS to 0 gives a wrong start + // and an absurd duration (full disc runtime). Well-formed BD PCS packets + // always carry a PTS, so a missing one is a malformed-stream path we skip. let pts = pes.pts.map(pts_to_ns); let is_pcs = pes.data[0] == SEGMENT_PCS; - // A PCS too short to carry number_of_composition_objects is malformed. - // Don't let it fall through to the non-PCS arm (where it would pollute - // the pending display set or pass through as a lone frame): close any - // pending set undurated (mirroring the no-PTS display path) and drop - // the truncated header so the parser resyncs on the next PCS. + // A PCS too short for number_of_composition_objects is malformed. Don't + // let it fall to the non-PCS arm (would pollute the pending set): close + // any pending set undurated and drop the header to resync on next PCS. if is_pcs && pes.data.len() <= PCS_NUM_OBJECTS_OFFSET { return self .pending @@ -299,10 +291,9 @@ impl CodecParser for PgsParser { let mut out = Vec::new(); match pcs_num_objects { - // Clear/empty PCS — closes any pending display. Drop the - // clear segment itself; BlockDuration covers the screen - // wipe. A clear PCS with no PTS can't time the duration, so - // emit the pending set with no duration (it lingers to EOF). + // Clear/empty PCS closes any pending display; drop the segment + // itself (BlockDuration covers the wipe). No PTS means no + // duration, so the pending set is emitted lingering to EOF. Some(0) => { let frame = match pts { Some(end) => self.emit_pending(end), @@ -377,9 +368,8 @@ impl CodecParser for PgsParser { }); } // No pending set AND no PTS: drop it. Emitting at pts_ns=0 would - // place a stray bitmap at 00:00:00.000 with no timing reference; - // the no-PTS PCS arms above avoid the 0 sentinel for the same - // reason. + // place a stray bitmap at 00:00:00.000 with no timing reference + // — same reason the no-PTS PCS arms above avoid the 0 sentinel. } } @@ -387,13 +377,9 @@ impl CodecParser for PgsParser { } fn flush(&mut self) -> Vec<Frame> { - // A display set is only emitted when the *next* PCS arrives - // (either an empty clear PCS or a replacing display PCS). At - // end-of-stream there is no follower, so without this the last - // subtitle of every PGS track would be silently dropped. Emit - // the pending set with no duration — the trailing block lingers - // until end of file, which is exactly the desired behavior for - // the final on-screen subtitle (see the module doc). + // A display set is only emitted when the next PCS arrives; at EOS there + // is no follower, so without this the last subtitle would be silently + // dropped. Emit it with no duration — it lingers to EOF (see module doc). match self.pending.take() { Some((facts, data)) => vec![Frame { discontinuity: false, @@ -600,10 +586,9 @@ mod tests { #[test] fn display_pcs_without_pts_is_not_stored_with_zero_start() { - // A display PCS with no PTS has an unknown start time. It must NOT be - // stored with a 0 sentinel — otherwise a later clear PCS at real PTS T - // would emit a frame with pts_ns=0 and duration_ns=T (hours of ns for a - // mid-disc subtitle). The malformed display PCS is skipped instead. + // A display PCS with no PTS has an unknown start time; must NOT store it + // with a 0 sentinel, or a later clear PCS at real PTS T would emit + // pts_ns=0, duration_ns=T. The malformed display PCS is skipped instead. let mut parser = PgsParser::new(); let frames = parser.parse(&make_pes(pcs_bytes(1), None)); assert!(frames.is_empty(), "no-PTS display PCS emits nothing"); diff --git a/src/mux/codec/reorder.rs b/src/mux/codec/reorder.rs index 01fd532b..c24ccb49 100644 --- a/src/mux/codec/reorder.rs +++ b/src/mux/codec/reorder.rs @@ -9,7 +9,7 @@ //! this by reconstructing per-picture PTS from `temporal_reference`; these three //! codecs carry no such field.) //! -//! [`SparsePtsReorder`] reconstructs a display-order PTS for every frame from two +//! [`SparsePtsReorder`](crate::mux::codec::reorder::SparsePtsReorder) reconstructs a display-order PTS for every frame from two //! signals the parsers already provide — the coded picture type (I/P/B) and the //! sparse anchor PTS — plus a per-frame duration self-calibrated from the spacing //! between consecutive GOP anchors (no external frame-rate needed). It mirrors @@ -110,12 +110,9 @@ impl SparsePtsReorder { .coding .map(|c| c.coding_type()) .unwrap_or(CodingType::P); - // A keyframe opens a new GOP: the picture already accumulated in `cur` is - // a complete GOP. Complete it (this frame belongs to the NEW GOP). Also - // force-complete a pathologically long run that never signalled a - // keyframe — bounded by BOTH frame count and total buffered bytes, so a - // crafted/corrupt stream of few-but-huge access units cannot buffer - // without bound. + // A keyframe opens a new GOP: whatever is in `cur` is a complete GOP, so + // complete it. Also force-complete a run with no keyframe, bounded by + // frame count AND bytes, so few-but-huge access units can't buffer unbounded. let mut out = Vec::new(); let over_cap = self.cur.len() >= MAX_GOP_FRAMES || self.cur_bytes >= MAX_GOP_BYTES; if (frame.keyframe || over_cap) && !self.cur.is_empty() { @@ -163,11 +160,9 @@ impl SparsePtsReorder { let mut out = Vec::new(); match self.held.take() { Some(held) => { - // Calibrate a per-frame duration from the two anchors' spacing, - // spread across the held GOP's display-frame count. Approximate - // (assumes both anchors sit at a similar relative display slot), - // but each GOP re-locks its own origin, so the estimate only sets - // intra-GOP spacing. + // Calibrate a per-frame duration from the two anchors' spacing over + // the held GOP's frame count. Approximate, but each GOP re-locks + // its own origin, so this only sets intra-GOP spacing. if self.dur_ns == 0 && let (Some((p_held, _)), Some((p_next, _))) = (held.anchor, gop.anchor) { diff --git a/src/mux/codec/startcode.rs b/src/mux/codec/startcode.rs index e8d71eb2..b44cd63b 100644 --- a/src/mux/codec/startcode.rs +++ b/src/mux/codec/startcode.rs @@ -285,11 +285,9 @@ mod tests { #[test] fn skip_4byte_with_01_as_last_byte_returns_one_past_end() { - // `00 00 00 01` of length exactly 4: the 4-byte branch guard is - // `pos + 3 < data.len()` (3 < 4 = true) AND data[2]==0x00, data[3]==0x01 - // → 4-byte code recognised → returns pos+4 = 4 (one past the buffer, the - // position where the NAL body would begin). The caller treats len as the - // empty-NAL boundary, so this is in-bounds-safe. + // `00 00 00 01` len 4: guard `pos + 3 < data.len()` (3<4) with data[2]=0x00, + // data[3]=0x01 recognises the 4-byte code and returns pos+4=4, one past the + // buffer; the caller treats len as the empty-NAL boundary (in-bounds-safe). let data = [0x00, 0x00, 0x00, 0x01]; assert_eq!(skip_start_code(&data, 0), Some(4)); } @@ -318,25 +316,18 @@ mod tests { #[test] fn skip_start_code_three_zeros_no_terminator_is_not_a_code() { - // `00 00 00` with nothing after it is neither a 3-byte code (3rd byte - // isn't 0x01) nor a complete 4-byte code (there is no 4th byte at all). - // Pins the `pos + 3 < data.len()` guard: on a 3-byte all-zero buffer - // `pos + 3 == data.len()`, so the guard must be strict `<` and reject - // the 4-byte branch before it would index one past the end. A `<=` - // (or a `pos * 3` typo, which also evaluates to a value not exceeding - // `data.len()` here) lets the 4-byte branch run and read `data[pos+3]` - // out of bounds. + // `00 00 00` is neither a 3-byte code (3rd byte isn't 0x01) nor a complete + // 4-byte code (no 4th byte). Pins `pos + 3 < data.len()`: here it equals + // len, so the guard must be strict `<`, or the 4-byte branch reads OOB. let data = [0x00, 0x00, 0x00]; assert_eq!(skip_start_code(&data, 0), None); } #[test] fn read_ue_thirty_one_leading_zeros_is_still_a_valid_code() { - // Exactly 31 leading zero bits, a stop bit, then 31 zero info bits: - // a legal (if enormous) ue(v) code with code_num = 2^31 - 1. The - // truncation guard is `leading_zeros > 31`, so 31 must NOT trip it — - // only 32 does. A guard mutated to `>= 31` or `== 31` aborts one bit - // early and returns None instead of the real value. + // 31 leading zero bits, a stop bit, 31 zero info bits: a legal ue(v) code + // with code_num = 2^31 - 1. The truncation guard `leading_zeros > 31` must + // NOT trip on 31 — only 32; a `>= 31` mutant aborts one bit early. let data = [0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]; assert_eq!(BitReader::new(&data).read_ue(), Some(u32::MAX >> 1)); } diff --git a/src/mux/codec/truehd.rs b/src/mux/codec/truehd.rs index 848fb7b1..6b0a3b3a 100644 --- a/src/mux/codec/truehd.rs +++ b/src/mux/codec/truehd.rs @@ -134,21 +134,9 @@ impl TrueHdParser { return AuCheck::Unverifiable; }; if !mlp_major_sync_crc_ok(ms, mshdr) { - // A failing major-sync checksum is only a TRUSTWORTHY corruption - // signal once we already hold a validated baseline (num_substreams - // captured from a PRIOR checksum-clean major sync). Before that — at - // stream head, when no major sync has validated yet — a checksum - // mismatch is far more likely a limitation of our own major-sync - // header-size parse than real corruption. Arming the drop-forward - // there is catastrophic: every following AU is collateral-dropped - // waiting for a "validated" major sync that (if our parse is the - // problem) NEVER comes, so the WHOLE TrueHD track is silently dropped - // — the AUs are physically emitted late (empty until the mux end), - // de-interleaving the track and sending decoders into an unbounded - // memory spiral. So until we have a baseline to protect, treat a - // checksum-failed major sync as UNVERIFIABLE (keep it) rather than - // Corrupt. Once a clean major sync HAS established the baseline, a - // later failure is a real re-sync trigger and still drops forward. + // A failing checksum is only trustworthy once a validated baseline + // (num_substreams from a prior clean major sync) exists — before + // that, arming drop-forward risks silently dropping the whole track. if self.num_substreams.is_some() { return AuCheck::Corrupt; // real corruption vs a proven baseline } @@ -156,13 +144,9 @@ impl TrueHdParser { } self.num_substreams = mlp_num_substreams(ms); header_size += mshdr; - // The rate nibble is only trustworthy once the major sync's CRC has - // validated (above), so capture format_info here and refine the PTS - // cadence from it ONLY on this validated path. - // ONLY for stream type 0xBA. An MLP (0xBB) major sync's following word - // is not the TrueHD `format_info` layout, so decoding it as one gave a - // wrong rate and channel count; leaving it `None` keeps the - // container-derived rate, which is the honest answer. + // format_info is only trustworthy once the major sync's CRC has + // validated (above), and only for stream type 0xBA: an MLP (0xBB) + // major sync's next word isn't the TrueHD layout, so leave it `None` there. if au.len() >= 12 && is_truehd_major_sync(u32::from_be_bytes([au[4], au[5], au[6], au[7]])) { @@ -296,18 +280,9 @@ fn mlp_major_sync_crc_ok(ms: &[u8], mshdr: usize) -> bool { if mshdr < 4 || ms.len() < mshdr { return false; } - // 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.) + // checksum16(buf,n) = crc16_2D(buf,n-2) ^ read_le16(buf+n-2), evaluated with + // n=mshdr-2 against read_le16(buf+mshdr-2). `crc16_mlp` yields bytes in the + // opposite order to a standard LE CRC, so swap_bytes() before XOR/compare. let checksum = crc16_mlp(&ms[..mshdr - 4]).swap_bytes() ^ u16::from_le_bytes([ms[mshdr - 4], ms[mshdr - 3]]); checksum == u16::from_le_bytes([ms[mshdr - 2], ms[mshdr - 1]]) @@ -356,36 +331,13 @@ fn mlp_parity_ok(au: &[u8], header_size: usize, substr_header_size: usize) -> bo impl CodecParser for TrueHdParser { fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> { // B1: a concealed/lost gap means the buffered TrueHD AU is TRUNCATED. - // Splicing post-gap bytes onto it corrupts the AU framing (→ "Invalid - // data found") and strands the PTS cadence (the non-monotonic audio-DTS - // band at gaps). Drop the partial; with `buf` now empty the PTS-base block - // below re-seeds the cadence from the post-gap PES, monotonic across the - // gap. (Audio has no inter-frame refs — this is the whole audio fix.) - // - // Handle the discontinuity BEFORE the empty-data guard so the signal can - // never be stranded by an empty post-gap PES (defensive; the demuxer only - // emits non-empty PES today). + // Splicing post-gap bytes onto it corrupts framing; drop the partial so + // PTS re-seeds from the post-gap PES. Handled before the empty-data guard (defensive). if pes.discontinuity { self.acc.clear(); - // Unlike AC-3/DTS (independent frames), MLP/TrueHD carries filter + - // predictor + restart state ACROSS access units. A demux-signalled - // discontinuity (lost TS packets / a concealed source gap) leaves that - // state stale, so resuming mid-stream on the next parity-OK AU decodes - // against broken state and desyncs a conformant decoder ("restart header - // sync incorrect" / "Invalid blocksize"). Arm the same drop-forward the - // corruption path uses: keep dropping until the next CRC-valid major sync - // (a clean re-init point), then resume. This mirrors the video path's - // "resync on the next keyframe" after a gap — the gapped audio can't be - // recovered either way, but the output stays decoder-clean instead of - // shipping a decoder-choking seam. - // - // Only once a validated major sync has established a baseline: before - // that there is no CRC-validatable re-init point to resume on, so arming - // here would collateral-drop the WHOLE track waiting for a major sync we - // cannot yet validate — the exact stream-head catastrophe `au_check` - // guards (a checksum-failed head major sync is kept, not nuked). At - // stream head we therefore keep the current behaviour: drop the truncated - // partial (above) and let the fresh post-gap unit through. + // Unlike AC-3/DTS, MLP/TrueHD carries state across AUs, so resuming + // stale state after a gap desyncs a decoder. Arm drop-forward to the + // next CRC-valid major sync, only once a baseline exists (see au_check). if self.num_substreams.is_some() { self.resync_pending = true; } @@ -394,48 +346,15 @@ impl CodecParser for TrueHdParser { return Vec::new(); } - // Capture the PTS base ONLY at an access-unit boundary, i.e. when no AU - // is mid-assembly in `buf`. TrueHD access units span PES packets; a PES - // that merely continues an AU already in progress carries its own (later) - // PTS, which must NOT override the running per-AU timestamp. Adopting it - // mid-AU would snap that AU's PTS backward/forward and break the - // monotonic +AU_DURATION_NS cadence (A/V drift). Once the buffer is empty - // the next PES legitimately begins a new AU and seeds the base. + // Capture the PTS base only at an AU boundary (buf empty): a PES that + // merely continues an AU in progress carries a later PTS that must not + // override the running timestamp, or it breaks the monotonic +AU_DURATION_NS cadence. if self.acc.is_empty() && let Some(pts) = pes.pts { - // Resync to the authoritative PES PTS. TrueHD AUs are a fixed - // sample count (40 @ 48 kHz), so the per-AU `+AU_DURATION_NS` - // cadence is sample-accurate — more so than the disc's per-PES - // PTS, which carries the source muxer's own rounding jitter. - // - // Two distinct backward steps must be handled OPPOSITELY: - // - // 1. Small backward jitter (sub-second PES rounding): when the - // buffer empties exactly on a PES boundary and that PES's PTS - // lands a few ticks *below* the running cadence, an - // unconditional reset would set the next AU's timestamp below - // the AU just emitted, producing non-monotonic block - // timestamps a muxer rejects. CLAMP to the running position so - // output stays strictly monotonic. - // - // 2. Large backward step (> DISCONTINUITY_BACKSTEP_NS): this is a - // clip-boundary PTS reset — the title's clips are read as one - // concatenated stream and a non-seamless boundary resets the - // source PES PTS near zero. This is NOT jitter and must NOT be - // clamped: clamping strands the audio at the previous clip's - // tail cadence, so when `TimelineContinuity` later bumps the - // global offset for the new epoch (driven by the video - // back-jump) the stranded-high audio PTS is flung ~a whole - // clip past the frontier, producing the non-monotonic - // audio-DTS band on multi-clip titles. ADOPT the raw reset so the per-track raw PTS that - // reaches `TimelineContinuity` carries the true boundary, and - // the corrector rebases it exactly as it already does for the - // DTS / AC-3 parsers (which never clamp). Same threshold the - // timeline corrector uses to classify a discontinuity. - // - // A genuine forward gap/discontinuity is always adopted by the - // `.max()`. + // Resync to the authoritative PES PTS (sample-accurate vs the source + // muxer's rounding jitter). Small backward jitter clamps to stay + // monotonic; a large backward step (clip boundary) is adopted raw. let new = pts_to_ns(pts); if new < self.next_pts_ns - DISCONTINUITY_BACKSTEP_NS { // Clip-boundary reset: take the raw PTS, restart the cadence. @@ -455,14 +374,9 @@ impl CodecParser for TrueHdParser { break; } - // AC-3 frame (interleaved): starts with sync word 0x0B77. - // - // 0x0B 0x77 is also a legal TrueHD AU header (check-nibble 0, - // length-high-bits 0xB → length 0xB77 words). To avoid an AC-3 - // misread stealing a real TrueHD AU, an AC-3 frame is only accepted - // when its computed end is corroborated by what follows: end of - // buffer (frame fills the rest), another AC-3 sync, or a plausible - // TrueHD AU header. If none holds, this is treated as a TrueHD AU. + // AC-3 frame (interleaved): starts with sync 0x0B77, which is also a + // legal TrueHD AU header. To avoid stealing a real TrueHD AU, an AC-3 + // frame is accepted only when its end is corroborated by what follows. if self.acc.as_slice()[0] == 0x0B && self.acc.as_slice()[1] == 0x77 { match self.ac3_frame_at_head() { Ac3Size::Unmappable => { @@ -489,10 +403,9 @@ impl CodecParser for TrueHdParser { | self.acc.as_slice()[1] as usize) & 0xFFF; if unit_words == 0 { - // A zero-length AU is malformed/padding. The AU header is 4 bytes - // (length + timing); drain the whole header, not just the length - // word, otherwise the timing bytes get misread as the next - // length word and produce a spurious parse on the next iteration. + // A zero-length AU is malformed/padding. Drain the whole 4-byte + // header, not just the length word, or the timing bytes get + // misread as the next length word — a spurious parse next iteration. self.acc.drain(4); continue; } @@ -503,10 +416,9 @@ impl CodecParser for TrueHdParser { break; // incomplete access unit, wait for more data } - // Restart-point question — either stream type (0xBA TrueHD, 0xBB MLP) - // is a decoder re-init point, so both count as a major sync here. - // DECODING format_info with the TrueHD layout is a separate question, - // gated on 0xBA alone in `au_check`. + // Restart-point question: either stream type (0xBA TrueHD, 0xBB MLP) + // is a decoder re-init point, so both count as major sync here. + // Decoding format_info with the TrueHD layout is gated on 0xBA alone. let is_major_sync = unit_bytes >= 8 && is_mlp_major_sync(u32::from_be_bytes([ self.acc.as_slice()[4], @@ -515,11 +427,9 @@ impl CodecParser for TrueHdParser { self.acc.as_slice()[7], ])); - // Decodability gate. MLP/TrueHD decode state persists across access - // units, so a corrupt AU is dropped FORWARD to the next VALIDATED - // major sync (the clean re-init point) rather than excised in place. - // The PTS clock advances across every dropped AU so a drop is a - // silence gap, never a shift. + // Decodability gate: MLP/TrueHD decode state persists across AUs, so + // a corrupt AU is dropped FORWARD to the next validated major sync + // rather than excised in place: a drop is a silence gap, never a shift. let au = self.acc.as_slice()[..unit_bytes].to_vec(); let pts = self.next_pts_ns; // Read BEFORE the drain below: this unit's source is the packet @@ -908,10 +818,8 @@ mod tests { #[test] fn corrupt_major_sync_drops_forward_to_next_valid() { // MLP state carries across AUs, so a corrupt AU is dropped FORWARD to the - // next valid major sync (the clean re-init point). Sequence: valid MS, - // corrupt MS (bad CRC), a normal AU, then a valid MS. Only the two valid - // major syncs survive; the corrupt MS and the intervening normal AU are - // dropped (the latter because decode state is poisoned until re-init). + // next valid major sync. Sequence: valid MS, corrupt MS, normal AU, valid + // MS — only the two valid syncs survive (the normal AU is poisoned collateral). let mut parser = TrueHdParser::new(); let ms1 = valid_major_sync(); let mut ms_bad = valid_major_sync(); @@ -937,22 +845,9 @@ mod tests { #[test] fn discontinuity_resyncs_forward_to_next_major_sync() { - // A source-signalled discontinuity (lost TS packets / a concealed gap) - // leaves MLP's cross-AU predictor + restart state stale. Resuming - // mid-stream on the next parity-OK AU decodes against broken state and - // desyncs a conformant decoder ("restart header sync incorrect" / - // "Invalid blocksize"). So a discontinuity must arm the SAME drop-forward - // the corruption path uses: drop every post-gap AU until the next - // CRC-valid major sync (the clean re-init point), then resume there. - // - // Regression for a real disc whose transport - // stream has a multi-PID continuity-counter gap at ~6:49: before this - // fix the TrueHD path cleared its buffer and resumed on the next NON-major - // AU, shipping a decoder-choking seam (559 `restart header sync` errors), - // while AC-3/DTS on the same disc were fine because THEIR frames are - // independently decodable. Video already resyncs to a keyframe after a - // gap (see `mux::resync`); this gives TrueHD the equivalent, keyed on the - // codec's own re-init point (a major-sync AU). + // A discontinuity leaves MLP's cross-AU state stale, so it arms the same + // drop-forward the corruption path uses: drop post-gap AUs until the next + // CRC-valid major sync. Fixed 559 "restart header sync" errors on a real disc. let mut parser = TrueHdParser::new(); // Establish a baseline so num_substreams is known and the gate is live. @@ -1004,15 +899,9 @@ mod tests { #[test] fn crc_failed_head_major_sync_is_kept_not_track_killed() { - // REGRESSION (every TrueHD title muxed after the checksum gate landed): a - // real-world major sync whose checksum our own header-size parse can't - // validate must NOT, at stream head — before ANY major sync has validated — - // arm the drop-forward. Doing so latched `resync_pending` on the very first - // AU and collateral-dropped EVERY following AU forever (no "validated" major - // sync ever came), silently dropping the entire TrueHD track: its AUs were - // emitted only at mux end, de-interleaving the track and spiralling decoders - // into unbounded memory ("decoder ran out of memory"). With no baseline to - // protect, a checksum-failed major sync is KEPT and the audio flows. + // REGRESSION (every TrueHD title after the checksum gate landed): a + // checksum-failed major sync at stream head (no baseline yet) must NOT + // arm drop-forward, or it silently drops the whole track (OOM decoder). let mut parser = TrueHdParser::new(); let mut ms_bad = valid_major_sync(); ms_bad[10] ^= 0xFF; // break a checksum-covered header byte → checksum fails @@ -1061,14 +950,9 @@ mod tests { #[test] fn extended_major_sync_crc_validates_and_rejects() { - // COVERAGE GAP (the endianness bug that once "silently dropped the whole - // track" on real 7.1/Atmos): the EXTENDED major-sync header path - // (ms[25]&1 set, mshdr = 28 + 2 + 2*n) had ZERO test coverage — every - // other fixture builds only the basic 28-byte header. Build an extended - // header whose trailer is an INDEPENDENTLY-computed oracle (ref_crc16_2d, - // NOT crc16_mlp) stored LITTLE-ENDIAN, and assert the validator accepts - // it, rejects a body corruption, and rejects the same trailer stored - // big-endian (which is exactly the endianness-mix regression). + // COVERAGE GAP: the extended major-sync header path (ms[25]&1 set) had + // zero test coverage — every other fixture builds only the basic header. + // Build one with an independent oracle (ref_crc16_2d) to catch the regression. assert_eq!( ref_crc16_2d(b"123456789"), 0x4FF7, @@ -1154,10 +1038,9 @@ mod tests { frames.extend(parser.flush()); assert_eq!(frames.len(), 2, "the parity-broken AU is dropped"); assert_eq!(parser.dropped_frames(), 1); - // The drop is a SILENCE GAP, and its length is what the CLI reports as - // lost audio. One TrueHD access unit is 40 samples; at the 48 kHz base - // rate that is 40/48000 s = 833_333 ns. A count without a duration (or a - // duration that ignores the AU rate) understates or invents the loss. + // The drop is a SILENCE GAP whose length is what the CLI reports as lost + // audio: one AU = 40 samples at 48 kHz = 40/48000s = 833_333 ns. A count + // without a rate-aware duration understates or invents the loss. assert_eq!( parser.dropped_duration_ns(), 833_333, @@ -1192,10 +1075,9 @@ mod tests { #[test] fn transient_corruptions_do_not_poison_whole_track() { - // Regression (audit HIGH): TrueHD drop-forward must NOT amplify a couple - // of transient errors into a false whole-track poison. Two corruptions, - // each forcing a long collateral resync run past 200 total AUs, must - // leave the track un-poisoned and keep the good audio that follows. + // Regression (audit HIGH): drop-forward must not amplify a couple of + // transient errors into a false whole-track poison — even resync runs + // past 200 AUs must leave the track un-poisoned and keep good audio after. let mut parser = TrueHdParser::new(); let mut data = valid_major_sync(); // Corruption #1 then a long run of normal AUs (all collateral-dropped @@ -1230,10 +1112,9 @@ mod tests { #[test] fn corrupt_major_sync_rate_nibble_does_not_shift_pts() { - // Regression (audit MED): a corrupt major sync whose rate nibble decodes - // to the 44.1 kHz family must NOT refine au_duration_ns — the rate is only - // trustworthy after the CRC validates. Otherwise the resumed 48 kHz audio - // is shifted (not gapped). + // Regression (audit MED): a corrupt major sync's rate nibble must NOT + // refine au_duration_ns — the rate is only trustworthy after the CRC + // validates, else the resumed 48kHz audio is shifted (not gapped). let mut parser = TrueHdParser::new(); let ms1 = valid_major_sync(); // 48 kHz let mut ms_bad = valid_major_sync(); @@ -1340,10 +1221,7 @@ mod tests { fn discontinuity_drops_truncated_partial() { // B1: a partial TrueHD unit is buffered, then a concealed gap (PES marked // discontinuity) carries a fresh unit. The truncated partial must be - // DROPPED — splicing it makes the length-prefixed framer emit a - // wrong-size unit (corrupt AU framing) and re-seeds the PTS cadence from - // the post-gap PES rather than stranding it (the non-monotonic audio-DTS - // band at gaps). + // dropped — splicing it corrupts AU framing; PTS re-seeds from the post-gap PES. let mut parser = TrueHdParser::new(); // PES 1: first 150 bytes of a 300-byte unit (length prefix says 300, only @@ -1396,11 +1274,8 @@ mod tests { #[test] fn pes_pts_lagging_the_au_cadence_never_emits_backward() { // Regression: the per-AU cadence is sample-accurate, but a PES boundary - // can carry a PTS that lags it slightly (source-muxer rounding jitter). - // When the buffer empties exactly on that boundary, an unconditional - // reset to the PES PTS snapped the next AU's timestamp BELOW the AU just - // emitted — the non-monotonic block timestamps a muxer rejects (the - // multi-clip UHD case). The reset must clamp forward-only. + // can carry a PTS that lags it slightly (muxer rounding jitter). An + // unconditional reset snapped the next AU below the prior one — clamp forward-only. let mut parser = TrueHdParser::new(); let au = make_truehd_unit(100); // PES1: three complete AUs at pts 90000 — buffer empties, cadence runs @@ -1425,17 +1300,9 @@ mod tests { #[test] fn clip_boundary_pts_reset_is_adopted_not_clamped() { - // Regression (multi-clip non-monotonic audio-DTS band): - // a title's clips are read as one concatenated stream, so at a - // non-seamless boundary the source PES PTS resets near zero — a LARGE - // backward step (> DISCONTINUITY_BACKSTEP_NS), NOT muxer jitter. The - // parser must ADOPT that reset (restart the cadence at the raw PTS), the - // same way the DTS / AC-3 parsers pass raw PTS through, so the per-track - // raw PTS reaching TimelineContinuity carries the true boundary and the - // corrector can rebase it. Clamping it forward (the old `.max()`) stranded - // the audio at the previous clip's tail; when the global offset later - // bumped for the new epoch the stranded audio was flung ~a clip past the - // frontier — the non-monotonic band. + // Regression (multi-clip non-monotonic audio-DTS band): a non-seamless + // clip boundary resets PES PTS near zero (large backward step, not + // jitter) and must be ADOPTED raw, like DTS/AC-3, or audio strands at the prior tail. let mut parser = TrueHdParser::new(); let au = make_truehd_unit(100); // Clip 1: an AU at PES PTS = 10s (90000 ticks/s → 900_000 ticks). Buffer @@ -1476,10 +1343,9 @@ mod tests { #[test] fn continuation_pes_pts_does_not_override_au_in_progress() { - // An AU split across two PES packets: the FIRST PES (pts 90000) begins - // the AU; the SECOND PES (pts 99999, a later timestamp) merely continues - // it. The emitted AU must keep the first PES's PTS, not adopt the - // continuation PES's later timestamp. + // An AU split across two PES packets: the first PES (pts 90000) begins + // it, the second (pts 99999) merely continues it. The emitted AU must + // keep the first PES's PTS, not the continuation's. let mut parser = TrueHdParser::new(); let unit = make_truehd_unit(200); let mid = 100; @@ -1519,12 +1385,9 @@ mod tests { #[test] fn zero_length_au_drains_full_header() { - // A zero-length AU header (4 bytes: length=0 + timing) must be skipped - // whole. If only 2 bytes were drained the timing bytes would be misread - // as a bogus length word. Here the timing bytes are 0x01 0x90 (= 0x190 = - // 400 words = 800 bytes) which, if misread, would stall the parser - // waiting for 800 bytes that never come. Draining 4 lets the following - // real unit parse. + // A zero-length AU header (4 bytes) must be skipped whole. Draining only + // 2 would misread the timing bytes (0x01 0x90 = 400 words = 800 bytes) as + // a bogus length, stalling the parser waiting for bytes that never come. let mut parser = TrueHdParser::new(); let mut data = vec![0x00, 0x00, 0x01, 0x90]; // length=0, timing=0x0190 data.extend_from_slice(&make_truehd_unit(200)); @@ -1535,11 +1398,9 @@ mod tests { #[test] fn unmappable_ac3_header_resyncs_not_stalls() { - // A permanently unmappable 0x0B77 header at the buffer head (reserved - // fscod==3) must NOT stall the parser. It used to be treated as - // "incomplete, wait" and break forever, dropping every following AU. - // Now it resyncs (drains 2 bytes) so a clean TrueHD unit behind it is - // eventually emitted. + // A permanently unmappable 0x0B77 header (reserved fscod==3) must NOT + // stall the parser — it used to be treated as "incomplete, wait" and + // break forever. Now it resyncs (drains 2 bytes) so a clean unit behind it is emitted. let mut parser = TrueHdParser::new(); // Unmappable AC-3-looking head: 0x0B77, byte4 fscod=3 (0xC0). let mut data = vec![0x0B, 0x77, 0x00, 0x00, 0xC0, 0x00]; @@ -1557,11 +1418,9 @@ mod tests { #[test] fn truehd_au_with_0b77_head_not_stolen_by_ac3() { - // A TrueHD AU whose first two bytes are 0x0B 0x77 (length 0xB77 = 2935 - // words = 5870 bytes) must NOT be misrouted to the AC-3 path. The AC-3 - // size for this header (fscod from byte4) would close the boundary in - // the wrong place; the secondary corroboration rejects it because the - // computed AC-3 end is not followed by another AC-3 sync / TrueHD AU. + // A TrueHD AU whose first two bytes are 0x0B 0x77 must NOT be misrouted + // to the AC-3 path — the AC-3 size closes the boundary wrong; secondary + // corroboration rejects it since the computed end isn't followed by a real sync. let mut parser = TrueHdParser::new(); // 5870-byte AU starting with 0x0B 0x77. Byte 4 = 0x00 → AC-3 would // size it as fscod=0, frmsizecod=0 → 128 bytes. The bytes at offset 128 @@ -1660,14 +1519,9 @@ mod tests { #[test] fn channels_from_stream_rejects_mlp_sync_0xfb() { - // 0xF8726FBB is the MLP stream type, NOT TrueHD (0xF8726FBA). The word - // after an MLP major sync holds quantization word lengths and the MLP - // group sample-rate fields, not TrueHD's rate nibble plus the 6ch/8ch - // presentation channel-assignment masks. Decoding it with the TrueHD - // layout (the scan used to mask the low sync bit) reported channels out of - // unrelated bits; the honest answer is None so the caller keeps its - // container-derived count. Byte pattern below decodes as 8 channels ONLY - // under the TrueHD layout, so this test fails if the mask comes back. + // 0xF8726FBB is the MLP stream type, NOT TrueHD (0xF8726FBA). Its next + // word holds quantization/MLP rate fields, not TrueHD's rate nibble + + // channel masks; decoding it as TrueHD reads channels from unrelated bits. let mut data = vec![0x00]; data.extend_from_slice(&0xF872_6FBBu32.to_be_bytes()); data.extend_from_slice(&0x0000_001Fu32.to_be_bytes()); @@ -1776,10 +1630,9 @@ mod tests { #[test] fn major_sync_variant_bit_also_keyframe() { - // The RESTART-POINT check masks the low sync bit, so 0xF8726FBB (MLP) - // counts as a major sync too — both stream types re-init the decoder, so - // both are keyframes. (Only the format_info DECODE is 0xBA-only; see - // `channels_from_stream_rejects_mlp_sync_0xfb`.) + // The restart-point check masks the low sync bit, so 0xF8726FBB (MLP) + // counts as a major sync too — both types re-init the decoder, so both + // are keyframes (format_info decode alone is 0xBA-only; see the sibling test). let mut parser = TrueHdParser::new(); let mut unit = make_truehd_unit(200); unit[4..8].copy_from_slice(&0xF872_6FBBu32.to_be_bytes()); @@ -1812,24 +1665,9 @@ mod tests { #[test] fn buffer_stays_bounded_across_many_partial_pes() { - // Malformed/never-completing input must keep the reassembly buffer - // bounded across an unbounded number of PES packets. - // - // The bound that actually holds is MAX_AU_BYTES, not MAX_TRUEHD_BUF: - // `parse`'s loop only breaks with data retained when - // `self.acc.len() < unit_bytes`, and `unit_bytes` is - // `((buf[0] << 8 | buf[1]) & 0xFFF) * 2 <= 8190`. Every other exit - // drains. So the post-loop `buf.len() > MAX_TRUEHD_BUF` cap (256 KiB) is - // an unreachable backstop — an exhaustive sweep of all 65536 two-byte - // AU heads × fragment sizes {3, 5, 100, 4096, 8189, 65535} over 20 PES - // each peaks at 8189 bytes. Asserting only `<= MAX_TRUEHD_BUF` is - // therefore vacuous; assert the reachable ceiling instead. - // - // Fixture: heads of 0xFF 0xFF (masked to 0xFFF words = 8190 bytes - // declared — this also exercises the 12-bit mask) with 8189 bytes - // present, so each PES leaves the buffer one byte short of a complete - // AU. The previous fixture used 4096-byte fragments, which completed an - // AU every second call and never loaded the buffer past ~4 KiB. + // Malformed/never-completing input must keep the buffer bounded. The + // bound that actually holds is MAX_AU_BYTES (8190), not MAX_TRUEHD_BUF — + // asserting only `<= MAX_TRUEHD_BUF` is vacuous; assert the reachable ceiling instead. let mut parser = TrueHdParser::new(); let mut worst = 0usize; for _ in 0..200 { @@ -1938,10 +1776,9 @@ mod tests { #[test] fn sample_rate_unknown_rate_falls_back_to_none() { - // 0xF is the explicit invalid code; 0x3/0xB are formula-only and not - // whitelisted; 0x7/0xE are reserved. None of them may produce a rate — - // the host must fall back to its container value, never write a wrong - // SamplingFrequency. + // 0xF is the explicit invalid code; 0x3/0xB are formula-only, not + // whitelisted; 0x7/0xE are reserved. None may produce a rate — the host + // must fall back to its container value, never write a wrong SamplingFrequency. for bad in [0x3u32, 0x7, 0xB, 0xC, 0xD, 0xE, 0xF] { assert_eq!( truehd_sample_rate_hz(format_info_with(bad)), @@ -1953,10 +1790,9 @@ mod tests { #[test] fn sample_rate_nibble_does_not_disturb_channel_decode() { - // Internal-consistency guard: with the 96 kHz nibble AND a 7.1 mask in - // the same word, the rate reads 96000 and the channels still read 8 — - // proving the rate nibble (bits 31..28) and the channel masks - // (bits 19..0) do not collide. + // Internal-consistency guard: with the 96kHz nibble AND a 7.1 mask in + // the same word, rate reads 96000 and channels still read 8 — proving + // the rate nibble (bits 31..28) and channel masks (bits 19..0) don't collide. let fi = format_info_with(0x1); assert_eq!(truehd_sample_rate_hz(fi), Some(96000)); assert_eq!(truehd_channels(fi), Some(8)); diff --git a/src/mux/codec/vc1.rs b/src/mux/codec/vc1.rs index 8769dd4e..46bed9e4 100644 --- a/src/mux/codec/vc1.rs +++ b/src/mux/codec/vc1.rs @@ -85,20 +85,14 @@ fn vc1_frame_coding_type(frame_rbsp: &[u8], seq_header: Option<&[u8]>) -> Option } pub struct Vc1Parser { - // First-seen seq_header + entry_point seed the MKV codecPrivate - // (BITMAPINFOHEADER extra data). These are the only out-of-band copies - // the player gets. A stream may redefine either header mid-title; any - // occurrence whose body DIFFERS from the active value must be emitted - // IN-BAND at each point it appears, and at every keyframe (RAP) if the - // active value differs from the codecPrivate copy, so seek points carry - // valid decoder state (SMPTE 421M requires seq+entry before every RAP). + // First-seen seq_header + entry_point seed MKV codecPrivate. A redefined + // body must be emitted IN-BAND at each occurrence, and at every keyframe + // when it differs from codecPrivate (SMPTE 421M requirement). seq_header: Option<Vec<u8>>, entry_point: Option<Vec<u8>>, - // Currently-ACTIVE body of each type — the most recent the bitstream - // defined. Distinct from the fixed codecPrivate copies above. The - // strip/emit decision is made against `cur_*`, not the first-seen copy: - // a switch BACK to the first-seen body (== codecPrivate) is still a - // change a streaming decoder must be told about. + // Currently-ACTIVE body of each type, distinct from the fixed codecPrivate + // copies above. Strip/emit is decided against `cur_*`, not the first-seen + // copy: switching BACK to the first-seen body is still a change to signal. cur_seq_header: Option<Vec<u8>>, cur_entry_point: Option<Vec<u8>>, width: u32, @@ -188,20 +182,17 @@ impl CodecParser for Vc1Parser { return Vec::new(); } - // MKV block timecodes are PRESENTATION timestamps; frames are stored in - // decode order and the player reorders by timecode. Use PTS, not DTS — - // DTS presents B-frames in decode order (visible judder) and breaks - // PTS-based seeking. Fall back to DTS only if PTS is absent. + // MKV block timecodes are PRESENTATION timestamps; use PTS, not DTS — + // DTS presents B-frames in decode order (judder, broken seeking). + // Fall back to DTS only if PTS is absent. let explicit_pts = pes.pts.or(pes.dts).map(pts_to_ns); let ts_ns = explicit_pts.unwrap_or(0); let mut has_seq_header = false; let mut has_entry_point = false; let mut frame_start: Option<usize> = None; // Track whether this AU carried a redefined (in-band) copy of each - // header type. These are collected into separate temporaries so the - // final keyframe prefix can be assembled in the canonical SMPTE 421M - // order (seq_header then entry_point) regardless of bitstream scan - // order. + // header type, in separate temporaries so the keyframe prefix can be + // assembled in canonical SMPTE 421M order regardless of scan order. let mut redefined_seq: Option<Vec<u8>> = None; let mut redefined_ep: Option<Vec<u8>> = None; @@ -265,21 +256,9 @@ impl CodecParser for Vc1Parser { // Keyframe = this PES contains a sequence header (I-frame indicator in BD) let keyframe = has_seq_header; - // Build the in-band prefix in the canonical SMPTE 421M order: - // sequence_header (0x0F) THEN entry_point (0x0E). - // - // For each header type, use the in-band-redefined body when the AU - // carried a change; otherwise re-assert the active body (unchanged - // repeat) so every RAP is self-contained. At non-keyframes only - // genuine redefinitions are emitted. - // - // Assembling into separate seq/ep slots and concatenating in fixed - // order avoids the ordering hazard that arose when the scan loop - // appended headers in bitstream order and reassert() later appended - // to whatever was already there: if seq was unchanged (stripped) but - // entry_point was redefined (appended), the old code would produce - // [entry_point] then reassert seq AFTER it → [entry_point, - // seq_header], inverting the required order. + // Build the in-band prefix in canonical SMPTE 421M order — seq_header + // (0x0F) then entry_point (0x0E) — using each header's redefined body if + // changed else the active one, to avoid the scan-order-dependent [ep, seq] hazard. let mut prefix: Vec<u8> = Vec::new(); if keyframe { // seq_header slot: prefer the in-band-redefined body, else active. @@ -323,12 +302,9 @@ impl CodecParser for Vc1Parser { } } None => { - // No frame start code. If this PES carried only parameter sets - // (sequence header / entry point, captured above into - // codecPrivate), there is no coded picture to emit — drop it - // rather than passing parameter bytes through as a bogus - // keyframe. Mirrors how the H.264/HEVC parsers skip - // parameter-set-only access units. + // No frame start code: if this PES carried only parameter sets + // (already captured into codecPrivate above), drop it rather than + // pass through as a bogus keyframe (mirrors H.264/HEVC). if has_seq_header || has_entry_point { return Vec::new(); } @@ -413,20 +389,15 @@ fn parse_vc1_resolution(sh: &[u8]) -> Option<(u32, u32)> { // Simple/Main profile: resolution not in sequence header return None; } - // Advanced profile sequence-header layout (SMPTE 421M, bit-level from sh[4]): - // PROFILE(2) + LEVEL(3) + COLORDIFF_FORMAT(2) + FRMRTQ_POSTPROC(3) + - // BITRTQ_POSTPROC(5) + POSTPROCFLAG(1) + MAX_CODED_WIDTH(12) + - // MAX_CODED_HEIGHT(12) ... - // Total bits before MAX_CODED_WIDTH: 2+3+2+3+5+1 = 16 bits. - // We need 16+12+12 = 40 bits = 5 de-escaped bytes from sh[4..]. + // Advanced profile seq-header layout (SMPTE 421M, from sh[4]): PROFILE(2)+ + // LEVEL(3)+COLORDIFF_FORMAT(2)+FRMRTQ_POSTPROC(3)+BITRTQ_POSTPROC(5)+ + // POSTPROCFLAG(1)=16 bits, then MAX_CODED_WIDTH(12)+HEIGHT(12) = 40 bits total = 5 bytes. if sh.len() < 9 { return None; } - // VC-1 Annex-B EBDU payload may carry emulation-prevention bytes (an - // inserted 0x03 after a 00 00 run). De-escape the payload before bit - // extraction so an EP byte landing within the first few bytes can't shift - // every subsequent bit and corrupt MAX_CODED_WIDTH/HEIGHT. Collect just the - // 5 de-escaped bytes the bit fields need. + // VC-1 Annex-B EBDU may carry emulation-prevention bytes (0x03 after 00 00). + // De-escape before bit extraction so an EP byte doesn't shift subsequent + // bits and corrupt MAX_CODED_WIDTH/HEIGHT; collect just the 5 bytes needed. let payload = &sh[4..]; let mut deesc = Vec::with_capacity(5); let mut zeros = 0u8; @@ -449,10 +420,8 @@ fn parse_vc1_resolution(sh: &[u8]) -> Option<(u32, u32)> { for &b in &deesc { bits = (bits << 8) | b as u64; } - // bits holds 40 significant bits laid out as: - // [16 leading bits][MAX_CODED_WIDTH:12][MAX_CODED_HEIGHT:12] - // so MAX_CODED_WIDTH starts 12 bits from the LSB end and MAX_CODED_HEIGHT - // occupies the low 12 bits (shift 0). + // bits holds 40 significant bits: [16 leading][WIDTH:12][HEIGHT:12], so + // WIDTH starts 12 bits from the LSB end and HEIGHT occupies the low 12 (shift 0). const WIDTH_SHIFT: u64 = 12; // 40 - 16 - 12 let coded_width = ((bits >> WIDTH_SHIFT) & 0xFFF) as u32 + 1; let coded_height = (bits & 0xFFF) as u32 + 1; @@ -697,10 +666,9 @@ mod tests { let frames = parser.parse(&pes); assert_eq!(frames.len(), 1); - // Seq+entry seed codecPrivate on first occurrence, but because this is a - // keyframe (RAP) they are re-asserted in-band so the RAP is - // self-contained. Frame data therefore STARTS with the seq_header start - // code, and the SC_FRAME picture data follows. + // Seq+entry seed codecPrivate on first occurrence, but as a keyframe (RAP) + // they're re-asserted in-band, so frame data STARTS with the seq_header + // start code, then the SC_FRAME picture data. let fd = &frames[0].data; assert!(fd.len() >= 4); assert_eq!(&fd[0..4], &[0x00, 0x00, 0x01, SC_SEQUENCE_HEADER]); @@ -832,10 +800,9 @@ mod tests { #[test] fn advanced_profile_resolution_uses_16bit_offset() { - // Regression: the parser skipped 11 bits (omitting BITRTQ_POSTPROC's 5 - // bits) instead of 16, reading width/height 5 bits too early. Encode a - // non-default 1280x720 and confirm it round-trips, proving the 16-bit - // pre-width offset. + // Regression: parser skipped 11 bits (omitting BITRTQ_POSTPROC's 5) instead + // of 16, reading width/height 5 bits early. Round-trip a non-default + // 1280x720 to confirm the 16-bit pre-width offset. let mut parser = Vc1Parser::new(); let mut data = make_ap_seq_header(1280, 720); // A frame so the parser emits and stores the header. @@ -903,12 +870,9 @@ mod tests { #[test] fn resolution_max_encodable_is_8192_within_bound() { - // MAX_CODED_WIDTH/HEIGHT are 12-bit fields (max 4095). The decoded - // dimension is (coded + 1) * 2, so the largest representable value is - // (4095 + 1) * 2 = 8192 — exactly the `<= 8192` accept bound. A real - // header therefore always satisfies the bound; the guard exists for - // corrupt input but the field width makes 8192 the ceiling. Encoding - // 8192x8192 (coded = 4095) must round-trip. + // MAX_CODED_WIDTH/HEIGHT are 12-bit (max 4095); decoded dim = (coded+1)*2, + // so (4095+1)*2 = 8192 is the ceiling and exactly the `<= 8192` accept + // bound (guard exists for corrupt input). 8192x8192 (coded=4095) must round-trip. let sh = make_ap_seq_header(8192, 8192); assert_eq!(parse_vc1_resolution(&sh), Some((8192, 8192))); } @@ -1208,10 +1172,9 @@ mod tests { .collect(); parser.parse(&make_pes(au1, Some(0))); - // AU2: keyframe — seq_header UNCHANGED (same bytes as AU1), entry_point - // REDEFINED to B. This is the bug trigger: the scan emits ep_b into the - // accumulator but strips sh; the keyframe reassert must then prepend sh - // BEFORE ep_b, not after. + // AU2: keyframe, seq_header UNCHANGED, entry_point REDEFINED to B. Bug + // trigger: scan emits ep_b but strips sh, so the keyframe reassert must + // prepend sh BEFORE ep_b, not after. let au2: Vec<u8> = sh .iter() .chain(ep_b.iter()) diff --git a/src/mux/demux_sink.rs b/src/mux/demux_sink.rs index 2699a535..32a56e8a 100644 --- a/src/mux/demux_sink.rs +++ b/src/mux/demux_sink.rs @@ -261,13 +261,9 @@ impl EsWriter for AnnexBWriter { } self.wrote_params = true; } - // Reframe via the canonical length-prefixed→Annex-B converter (single - // source of truth across all muxers — see `crate::mux::hevc`). It skips - // zero-length NALs and drops a truncated trailing NAL without panicking, - // rather than `break`ing on the first zero-length NAL. - // Reuse the writer's buffer rather than allocating per frame; clear() - // keeps the capacity, so steady state costs no allocation at all. The - // prefix width still comes from the record, never a hardcoded 4. + // Reframe via the canonical length-prefixed->Annex-B converter (see + // `crate::mux::hevc`), which skips zero-length NALs and drops a truncated + // trailing NAL without panicking. Reuse `self.scratch` to avoid per-frame allocation. self.scratch.clear(); self.scratch.reserve(f.data.len() + (f.data.len() / 32) + 4); let mut scratch = std::mem::take(&mut self.scratch); @@ -291,10 +287,9 @@ fn annexb_param_sets(codec: Codec, record: &[u8]) -> Vec<u8> { _ => return Vec::new(), }; converted.unwrap_or_else(|| { - // A malformed hvcC/avcC record yields no parameter sets. Returning empty - // means keyframes ship WITHOUT in-band SPS/PPS — playable from the first - // keyframe but broken for seek-to-arbitrary-point and hardware decoders. - // Surface it rather than silently degrading the output. + // A malformed hvcC/avcC record yields no parameter sets, so keyframes ship + // without in-band SPS/PPS (breaks seek and hardware decoders); warn rather + // than silently degrading. tracing::warn!( target: "mux", ?codec, @@ -316,9 +311,8 @@ fn annexb_param_sets(codec: Codec, record: &[u8]) -> Vec<u8> { struct PgsSupWriter; // ── PGS / HDMV segment framing constants ───────────────────────────────────── -// HDMV Presentation Graphics Stream, as published in the Blu-ray Disc -// Read-Only Format (BD-ROM) Part 3 graphics-stream specification (and the -// public US 2009/0185789 A1 application that documents the segment layout). +// HDMV Presentation Graphics Stream, per BD-ROM Part 3 graphics-stream spec +// (and public US 2009/0185789 A1, which documents the segment layout). /// `.sup` per-segment magic: ASCII "PG" (0x50 0x47) starting each segment's /// 13-byte header (magic | PTS u32 BE | DTS u32 BE) in a PGStream `.sup` file. @@ -385,7 +379,7 @@ impl PgsSupWriter { /// the segment well-formed. /// /// Returned bytes are concatenated `type(1)+size(2 BE)+payload` segments, - /// the same shape [`emit_segments`] consumes. + /// the same shape [`Self::emit_segments`] consumes. fn synthetic_clear_display_set(width: u16, height: u16) -> Vec<u8> { // Empty PCS payload (HDMV PGS, BD-ROM Part 3): width(2) height(2) // frame_rate(1) composition_number(2) composition_state(1) @@ -435,10 +429,8 @@ impl EsWriter for PgsSupWriter { fn write_frame(&mut self, w: &mut dyn Write, f: &PesFrame, pts_ns: i64) -> io::Result<usize> { let pts90 = ns_to_90k(pts_ns); let mut written = Self::emit_segments(&f.data, pts90, pts90, w)?; - // The parser folds the display/clear PCS pair's wipe time into - // `duration_ns` and drops the clear bytes. Re-emit a synthetic clear - // display set at `pts + duration` so the subtitle is timed out instead - // of lingering to EOF. + // The parser folds the wipe time into `duration_ns` and drops the clear + // bytes; re-emit a synthetic clear at `pts + duration` so it times out. if let Some(dur) = f.duration_ns { let clear_pts = ns_to_90k(pts_ns.saturating_add(dur as i64)); let (w_px, h_px) = Self::pcs_dimensions(&f.data); @@ -728,19 +720,14 @@ 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. + // Before the kind filter: the video track drives multi-clip PTS-continuity + // rebasing and the audio DELAY tag even on `audio://`/`sub://` outputs. if kind == TrackKind::Video && ref_video_track.is_none() { ref_video_track = Some(idx); } - // Also BEFORE the kind filter, and for the same reason: the seam - // crossing rule differs for a track that carries B-frame reorder, - // and a video track still flows through write() on an `audio://` - // or `sub://` export even though nothing persists it. Reading the - // kind back out of the filtered slot would call it non-video and - // hand it the permissive rule. + // Also before the kind filter: the seam-crossing rule differs for a + // B-frame-reorder track; reading kind from the filtered slot would + // misclassify a video track on an `audio://`/`sub://` export. if kind == TrackKind::Video { video_tracks.insert(idx); } @@ -812,12 +799,9 @@ impl DemuxSink { if self.opts.delay_mode == DelayMode::None { return Ok(()); } - // No video reference (audio-only title, or the reference track never - // produced a frame) → there is nothing to measure a delay against. - // OMIT the delay entirely rather than fall back to a reference of zero: - // a filename claiming `DELAY 600ms` when the real offset is unknown is a - // silently wrong number that downstream muxers will act on, while a - // missing tag is simply "no delay information", which is the truth. + // No reference PTS: omit the delay rather than default to zero. A filename + // claiming `DELAY 600ms` when the offset is unknown is silently wrong; a + // missing tag correctly says "no delay information". let Some(ref_pts) = self.ref_first_pts_ns else { tracing::warn!( target: "mux", @@ -902,18 +886,13 @@ impl Stream for DemuxSink { } fn write(&mut self, frame: &PesFrame) -> io::Result<()> { - // The PRIMARY VIDEO track (`ref_video_track`, the first DiscStream::Video) - // drives epoch decisions; every other track is a passive rider on the same - // global offset — see `TimelineContinuity`. Drive epochs off the same - // dynamically-resolved video reference used for the audio-delay - // computation, NOT the literal stream index 0 — an M2TS/PMT title can list - // an audio ES before the video ES, in which case track 0 is audio and a - // non-video epoch driver would ratchet the frontier on sparse/lagging PTS. + // Use the dynamically-resolved video reference, not literal stream index 0: + // an M2TS/PMT title can list audio before video, so a non-video epoch + // driver would ratchet the frontier on sparse/lagging PTS. let drives = Some(frame.track) == self.ref_video_track; - // Keyed on the track KIND, not on driving epochs: a second video track - // (a Dolby Vision enhancement layer) does not drive epochs but does - // carry B-frame reorder, and the seam-crossing rule differs on exactly - // that property. + // Keyed on track kind, not on driving epochs: a Dolby Vision enhancement + // layer doesn't drive epochs but carries B-frame reorder, which is what + // the seam-crossing rule keys on. let is_video = self.video_tracks.contains(&frame.track); // See `MkvMuxer::write_frame`: `None` is material outside the // playlist's clip marks and is dropped rather than emitted. @@ -945,17 +924,12 @@ impl Stream for DemuxSink { return Ok(()); } self.finished = true; - // Same reporting as the MKV muxer's finish. Frames the playlist's clip - // marks exclude are dropped on purpose, but the count must not be - // write-only in one sink and reported in the other — an unexpected - // volume here is how a demux ends up quietly short. + // Same reporting as the MKV muxer's finish: an unexpected drop volume is + // how a demux quietly comes up short, so surface it here too. let seam_dropped = self.timeline.dropped_total(); - // Frames ARRIVED and every one was dropped. That is a fully-dropped - // title, which this sink used to finish cleanly: a directory of - // zero-byte track files beside a populated chapters document, at exit - // 0. Keyed on frames having been offered, because a sink that is never - // given any — a chapters-only export, or a track class the title does - // not carry — legitimately writes none. + // Frames arrived and all were dropped: previously finished cleanly as + // zero-byte track files. Keyed on frames-offered so a legitimately empty + // sink (chapters-only export, absent track class) still writes none. if self.frames_mapped == 0 && seam_dropped > 0 { return Err(crate::error::Error::SinkWroteNothing.into()); } @@ -999,12 +973,8 @@ mod tests { }; // ── The language component is disc bytes ────────────────────────────── - // - // `opts.base` was sanitised and the language beside it was not, though a - // language code is three raw STN bytes run through `from_utf8_lossy` with - // no validation. `00 00 00` is the ordinary "undefined" encoding on real - // Blu-rays, and a NUL in a path fails `File::create` with `InvalidInput` — - // which aborted the whole export before a single track file was opened. + // Language is three raw STN bytes via `from_utf8_lossy`, unsanitised. `00 00 00` + // (real Blu-ray "undefined") put a NUL in the path, failing `File::create`. /// The stem stays one usable filename component whatever the disc says. #[test] @@ -1214,13 +1184,9 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - // ── Annex-B reframing ──────────────────────────────────────────────────── - // - // The length-prefixed → Annex-B conversion and the hvcC/avcC param-set - // extraction are exercised canonically in `crate::mux::hevc`; the sink - // delegates to those helpers. Here we only assert the sink-level wiring: - // param-set prepend and (crucially) that a zero-length NAL mid-frame no - // longer truncates the rest of the access unit. + // ── Annex-B reframing: covered in `crate::mux::hevc`; here we only assert + // sink-level wiring — param-set prepend and that a zero-length NAL mid-frame + // no longer truncates the rest of the AU. #[test] fn zero_length_nal_midframe_does_not_truncate_access_unit() { @@ -1590,11 +1556,9 @@ mod tests { assert!(plain.contains("<ChapterString>Opening Credits</ChapterString>")); } - // ── Timeline continuity ────────────────────────────────────────────────── - // - // The corrector itself is tested verbatim in `crate::mux::timeline`. Here we - // only confirm the sink drives it with the right `drives_epoch`: track 0 is - // the epoch driver, every other track is a passive rider on the same offset. + // ── Timeline continuity: the corrector itself is tested in `crate::mux::timeline`. + // Here we only confirm the sink drives it with the right `drives_epoch`: track 0 + // is the epoch driver, every other track rides the same offset. #[test] fn timeline_track0_drives_epoch_others_ride() { diff --git a/src/mux/demux_thread.rs b/src/mux/demux_thread.rs index 0e998303..1df557d2 100644 --- a/src/mux/demux_thread.rs +++ b/src/mux/demux_thread.rs @@ -92,13 +92,9 @@ impl DemuxThread { let mut ts = ts; let mut ps = ps; - // SAFETY (no teardown deadlock on spawn failure): the worker closure is - // `move`, so it OWNS `prefetch_rx` and `recycle_tx`. If `spawn` fails it - // consumes and drops the closure, which drops those channel ends — so the - // upstream producer observes disconnection and exits on its own BEFORE we - // join it. `producer_shell` (whose Drop joins the producer) is NOT captured - // by the closure, so dropping it on the Err path below joins a producer that - // has already exited → non-blocking. + // SAFETY: on `spawn` failure the dropped `move` closure drops `prefetch_rx`/ + // `recycle_tx`, so the producer disconnects and exits before `producer_shell` + // (uncaptured, joins on Drop) is dropped on the Err path — non-blocking. let spawn_result = std::thread::Builder::new() .name("freemkv-demux".into()) .spawn(move || { @@ -108,10 +104,8 @@ impl DemuxThread { let mut prof_read_ns: u128 = 0; let mut prof_feed_ns: u128 = 0; let mut prof_bytes: u64 = 0; - // Liveness heartbeat: the feed loop blocks on prefetch_rx.recv() - // and on tx.send(); a stuck upstream/downstream shows up as the - // beat going silent. Total is unknown for a stream, so `pos` is - // cumulative bytes fed. + // Liveness heartbeat: a stuck upstream/downstream shows up as the beat + // going silent. Total is unknown, so `pos` is cumulative bytes fed. let mut hb = crate::progress::Heartbeat::new("demux_feed"); let mut fed_bytes: u64 = 0; loop { @@ -142,9 +136,8 @@ impl DemuxThread { None }; let n = buf.len(); - // Source byte offset of this buffer's first byte = bytes fed - // so far. Threaded into the demuxer so every PES it cuts is - // stamped with its SourcePos (carried, not reconstructed). + // Source byte offset of this buffer's first byte, threaded into the + // demuxer so every PES it cuts carries its SourcePos. let buf_base = fed_bytes; fed_bytes += n as u64; if let Some(ref mut d) = ts { @@ -154,18 +147,12 @@ impl DemuxThread { } else { None }; - // Recycle the buffer back to the producer - // before pushing the demuxed packets. If the - // recycle channel is closed the producer has - // exited; we drop the buffer and continue. + // Recycle before pushing the packets. A closed recycle channel + // means the producer exited; drop the buffer and continue. let _ = recycle_tx.send(buf); - // Always send the batch — even when empty (null / - // untracked PIDs only). send() is how we detect an early - // consumer disconnect; on mostly-null extents spanning - // gigabytes of disc the batch can stay empty for a long - // time, and skipping empty sends would hide the - // disconnect until a (possibly never-arriving) non-empty - // batch. An empty batch yields no frames downstream. + // Always send, even empty batches: send() is how an early + // consumer disconnect is detected, and skipping empty sends on + // mostly-null extents could hide it for a long time. if tx.send(DemuxBatch::Ts(pkts)).is_err() { return; } @@ -204,11 +191,9 @@ impl DemuxThread { } } else { let _ = recycle_tx.send(buf); - // No demuxer (a BdTs title with zero streams): still send - // an empty batch so an early consumer disconnect is - // detected here too, exactly like the ts/ps branches above. - // Without it this worker reads the whole disc even after - // the consumer has dropped. + // No demuxer (zero-stream title): still send an empty batch to + // detect an early consumer disconnect, or this worker reads + // the whole disc even after the consumer has dropped. if tx.send(DemuxBatch::Ts(Vec::new())).is_err() { return; } @@ -226,10 +211,8 @@ impl DemuxThread { let _ = tx.send(DemuxBatch::Ps(tail)); } } - // Clean end-of-stream sentinel. Reaching here means no - // panic occurred; a panic during `feed`/`flush` skips - // this and drops `tx`, which the consumer reads as an - // error rather than a clean EOF. + // Clean EOF sentinel. A panic during `feed`/`flush` skips this and + // drops `tx`, which the consumer reads as an error, not clean EOF. let _ = tx.send(DemuxBatch::Eof); }); @@ -512,13 +495,9 @@ mod tests { #[test] fn empty_batches_are_forwarded_for_disconnect_detection() { - // The worker forwards EVERY batch, including empty ones, so an early - // consumer disconnect is detected promptly via `send()` (crossbeam's - // Sender has no non-destructive disconnect check). An empty batch is - // harmless downstream: `pump_one_batch` consumes 0 packets and returns - // Ok(true) — only the explicit `Eof` sentinel ends the stream. A buffer - // that yields no complete PES therefore produces an empty Ts batch - // followed by Eof. + // The worker forwards every batch, including empty ones, so disconnect is + // detected promptly via `send()`. Only the explicit `Eof` sentinel ends the + // stream, so a no-PES buffer produces an empty Ts batch followed by Eof. let (pf_tx, pf_rx) = bounded::<std::io::Result<Vec<u8>>>(4); let (rc_tx, _rc_rx) = bounded::<Vec<u8>>(4); let pid = 0x1011; @@ -625,11 +604,9 @@ mod tests { /// sender is disconnected, which is the property the fix relies on. #[test] fn channels_disconnected_before_producer_join_on_spawn_failure() { - // Build a prefetch channel pair. The producer "thread" is simulated by - // holding prefetch_tx; we verify it observes disconnection after we - // drop prefetch_rx (and only after — not before). - // crossbeam channels expose disconnection only through send/recv - // results (there is no is_disconnected()), so we probe it that way. + // The producer "thread" is simulated by holding prefetch_tx; verify it + // observes disconnection only after dropping prefetch_rx, since crossbeam + // exposes disconnection only through send/recv results. let (pf_tx, pf_rx) = bounded::<std::io::Result<Vec<u8>>>(1); let (rc_tx, rc_rx) = bounded::<Vec<u8>>(1); diff --git a/src/mux/disc.rs b/src/mux/disc.rs index 168c18eb..4595c160 100644 --- a/src/mux/disc.rs +++ b/src/mux/disc.rs @@ -169,11 +169,9 @@ pub struct DiscStream { // a percent without a separate API call. bytes_total_extents: u64, - // PES output — single-threaded inline demux + codec parse. The - // pipeline-mode mux (3-stage threaded) lives in - // [`super::pipelined_stream::PipelinedPesStream`]; this type is - // the legacy in-thread path for live-disc reads where adaptive - // batch retry on bad sectors lives in `fill_extents`. + // PES output — single-threaded inline demux + codec parse. Pipeline-mode + // mux lives in [`super::pipelined_stream::PipelinedPesStream`]; this is + // the legacy in-thread path where bad-sector adaptive retry lives. ts_demuxer: Option<super::ts::TsDemuxer>, ps_demuxer: Option<super::ps::PsDemuxer>, parsers: Vec<(u16, Box<dyn super::codec::CodecParser>)>, @@ -223,14 +221,9 @@ impl DiscStream { let mut title = title; let extents = title.extents.clone(); - // Resolve this title's CSS key from the reader if the caller supplied - // none — the SAME shared step the file-backed mux highway - // (`build_iso_pipeline`) uses, so single-pass and multi-pass descramble a - // DVD identically. No-op for AACS / already-keyed / genuinely-clear input - // or `raw`; a scrambled-but-uncrackable DVD is a hard `CssKeyMissing`. - // `halt` is passed here (not deferred to `with_halt`) so a Stop during the - // crack scan is honored — the scan runs at construction, before the caller - // can attach a token. + // Resolve this title's CSS key here (same step build_iso_pipeline uses, + // so passes descramble identically); no-op for AACS/clear/raw. `halt` is + // passed now, not via `with_halt`, since the scan runs before attach. crate::css::resolve_dvd_title_key( &mut *reader, &extents, @@ -242,11 +235,9 @@ impl DiscStream { )?; let bytes_total_extents: u64 = extents.iter().map(|e| e.sector_count as u64 * 2048).sum(); - // Debug log reader type at construction — critical for diagnosing mux - // reading from drive instead of ISO. `type_name_of_val(&*reader)` - // resolves the CONCRETE type behind the box (Drive / FileSectorSource), - // unlike `type_name::<dyn SectorSource>()` which always prints the - // trait-object name regardless of the underlying source. + // Debug log reader type — helps diagnose mux reading from drive instead + // of ISO. `type_name_of_val` resolves the CONCRETE type behind the box, + // unlike `type_name::<dyn SectorSource>()` which prints the trait name. tracing::debug!( target: "mux", "DiscStream constructed with reader type: {}", @@ -254,45 +245,32 @@ impl DiscStream { ); // CSS/unencrypted content needs a decrypting wrapper to yield plaintext - // VOB bytes before the AC-3 sub-stream probe can read real `acmod`s. - // MUX path (read > decrypt > mux): decrypt every unit in place and pass the - // bytes to the muxer; a unit that decrypts to broken TS is the muxer's - // concern, never conceal / re-fetch / count as loss (fail loud only on a - // genuine can't-decrypt). DiscStream is a decode/mux stream (live-drive - // single-pass / direct), never the ciphertext-preserving sweep. + // VOB bytes before the AC-3 probe reads real `acmod`s. A unit that + // decrypts to broken TS is the muxer's concern, not loss to conceal. let mut reader = DecryptingSectorSource::new(reader, decrypt_keys.clone()); // Wrong-substream fix (Silence-of-the-Lambs): re-route the title's - // declared AC-3 audio onto the physically-correct `0x8x` sub-streams by - // probing their real channel counts off the head of the feature. No-op - // for non-DVD or when the probe yields nothing. + // declared AC-3 audio onto the physically-correct `0x8x` sub-streams + // by probing real channel counts off the feature's head. No-op otherwise. crate::disc::dvd_audio_probe::probe_and_remap(&mut reader, &mut title); - // Parser table + PID map + demuxer come from the CANONICAL builder shared - // with the file-backed highway (`resolve::build_demux_state`). This used - // to be an open-coded copy of the same loop, which drifted: it built every - // parser with plain `parser_for_codec` and so never routed a Blu-ray 3D - // MVC dependent (right-eye) view to the param-set-passthrough parser - // (ISO/IEC 14496-10 Annex H) — the same 3D disc muxed correctly from an - // ISO and incorrectly from a live drive. Call the canonical builder rather - // than repeating its dispatch, so a future rule added there cannot go - // missing on the `disc://` path again. + // Use the CANONICAL builder shared with the file-backed highway + // (`resolve::build_demux_state`): an open-coded copy of this once + // drifted and mis-routed Blu-ray 3D MVC dependent views on this path. let (parsers, pid_to_track, ts_demuxer, ps_demuxer) = super::resolve::build_demux_state(&title, content_format); // AACS decrypts whole 6144-byte (3-sector) units keyed off each read - // buffer's first 16 bytes, so reads/skips must stay 3-sector aligned. - // CSS and unencrypted content are per-2048-byte and self-synchronizing - // (align 1). Same rule the file-backed highway applies in resolve.rs. + // buffer's first bytes, so reads/skips must stay 3-sector aligned; CSS/ + // unencrypted is per-2048-byte, self-sync (align 1). Same rule as resolve.rs. let unit_align: u16 = match &decrypt_keys { crate::decrypt::DecryptKeys::Aacs { .. } => 3, _ => 1, }; - // `reader` is already wrapped in DecryptingSectorSource above (so the - // internal fill_extents path sees plaintext bytes; for DecryptKeys::None - // the decorator is a pass-through). Reset the unit base the probe read - // advanced so the first fill_extents read starts cleanly. + // `reader` is already wrapped above (DecryptKeys::None makes it a + // pass-through). Reset the unit base the probe read advanced so the + // first fill_extents read starts cleanly. reader.set_unit_base(0); // B1 resync gates: one per stream, video flagged so the gate only @@ -460,12 +438,9 @@ impl DiscStream { } fn fill_extents(&mut self) -> io::Result<bool> { - // Skip exhausted and zero-sector extents iteratively. This was a - // self-recursive call, which costs a stack frame per skipped extent: - // nothing filters `sector_count == 0` out of a UDF/MPLS extent list, so - // a malformed disc declaring thousands of empty extents in a row could - // overflow the stack. That aborts the process — uncatchable, and fatal - // to a long-running service — rather than surfacing as an io::Error. + // Skip exhausted/zero-sector extents iteratively, not recursively: a + // malformed disc can declare thousands of empty extents, and a + // self-recursive version overflowed the stack, aborting the process. let (ext_start, ext_sectors, remaining) = loop { if self.current_extent >= self.extents.len() { return Ok(false); @@ -489,14 +464,9 @@ impl DiscStream { // disc LBA 0. No-op for CSS / None. self.reader.set_unit_base(ext_start); - // Adaptive sizer: start at current (preferred until a failure), shrink - // on failure, advance on success. One 5s read attempt per try — no - // retry loops, no sleeps. On size-1 failure, skip or error. - // - // Halt is checked at the top of every iteration — in a dense bad zone - // this loop can spend minutes shrinking and skipping sectors; without - // the check, Stop wouldn't take effect until the outer PES read() loop - // finally emits a frame, which may never happen. + // Adaptive sizer shrinks on failure, grows on success; one 5s attempt + // per try, no retry loops. Halt is checked every iteration since a bad + // zone can spend minutes here, else Stop waits for the outer read() loop. let start_time = std::time::Instant::now(); @@ -510,13 +480,9 @@ impl DiscStream { tracing::debug!(target: "mux", "fill_extents waiting at LBA {} ({}s elapsed, sectors={})", lba, start_time.elapsed().as_secs(), remaining); } - // Keep every read buffer starting on a real on-disc unit boundary. - // AACS (unit_align=3) decrypts whole 6144-byte units keyed off the - // buffer's first bytes, so a sub-unit read mid-extent desyncs the - // rest of the title; always read at least one full unit. Only the - // final partial unit at the extent tail (remaining < align) is read - // short — nothing follows it to desync. CSS/raw (align=1) is - // per-sector and self-synchronizing, so this is a no-op there. + // Every read buffer must start on a real on-disc unit boundary: AACS + // (align=3) decrypts whole units keyed off the buffer's first bytes, + // so a sub-unit mid-extent read would desync the rest of the title. let align = self.unit_align.max(1) as u32; let want = remaining.min(self.adaptive.current() as u32); let sectors: u16 = if align <= 1 { @@ -536,10 +502,9 @@ impl DiscStream { .read_sectors(lba, sectors, &mut self.read_buf[..bytes], false); if let Ok(&got) = res.as_ref() { - // SectorSource::read_sectors returns the number of bytes - // written into buf. All in-tree sources return full-or-error, - // but a short count would leave the stale/zeroed tail of - // read_buf in place. + // read_sectors returns bytes written into buf. All in-tree + // sources are full-or-error, but a short count would leave + // stale/zeroed tail bytes in read_buf. debug_assert!(got <= bytes, "read_sectors over-reported byte count"); if let Some(ev) = self.adaptive.on_success(sectors) { self.emit(ev); @@ -549,15 +514,9 @@ impl DiscStream { break; } - // Transport failure (status=0xFF: USB-bridge crash / disconnect) is - // NOT a skippable bad sector. The bridge is wedged and every - // subsequent read fails identically, so shrinking + skipping past it - // — even under `skip_errors` — just marches the whole disc at one - // ~15s bridge-recovery per probe, producing no usable output (the - // "runs forever, no MKV" report). Abort immediately, highest - // priority, mirroring the multipass sweep's transport-failure rule - // in `read_error::handle_read_error`. The CLI/UX surfaces this so the - // user power-cycles the drive (or switches to multipass recovery). + // Transport failure (status=0xFF: bridge crash/disconnect) is NOT a + // skippable bad sector — the bridge is wedged and every subsequent + // read fails, so skipping just marches the disc producing no MKV. if let Some(e) = res.as_ref().err() && e.is_scsi_transport_failure() { @@ -570,14 +529,9 @@ impl DiscStream { .into()); } - // The read SOURCE is gone (a prefetch producer thread that - // terminated), not one range of media. Shrinking and retrying at - // the same LBA asks a dead source for data it can never produce, - // and the `skip_errors` branch below would then zero-fill and - // advance over every remaining sector of the title and still - // return success. Abort with the terminal error itself — a - // fabricated SCSI status would be a lie, so this is deliberately - // NOT folded into the transport-failure arm above. + // The read SOURCE is gone (e.g. a terminated prefetch thread), not + // bad media — skip_errors would zero-fill the rest of the title and + // report success. Abort with the terminal error, not a fake status. if let Some(e) = res.as_ref().err() && e.is_source_terminated() { @@ -585,18 +539,9 @@ impl DiscStream { } if (sectors as u32) <= align { - // Bottomed out at one unit (AACS) / one sector (CSS) / the - // extent tail. This is single-pass disc→MKV, which has NO Pass N - // to come back and recover later — so before we skip or bail, - // give the drive its full ECC recovery budget ONCE - // (`recovery=true` → READ_RECOVERY_TIMEOUT_MS, ~60s), exactly as - // the multipass patch does on its bad ranges. A single bounded - // read, never a loop (hard rule #2: tight retry loops on one LBA - // push the BU40N into fast-fail). On success we USE the recovered - // data, so the old "transient retry returns a bogus status for - // readable data" hole cannot reopen; the earlier 10s-timeout read - // gave the drive no chance to recover a marginal sector that a - // 60s ECC read can. + // Bottomed out with no later pass to recover — give the drive one + // full ECC recovery read (~60s) before skip/bail. A single bounded + // read, never a loop (hard rule #2: retry loops fast-fail the BU40N). tracing::debug!( target: "mux", "fill_extents last-chance recovery read at LBA {} ({} sectors, 60s ECC)", @@ -618,15 +563,9 @@ impl DiscStream { break; } - // Recovery read also failed. A transport failure here (status - // 0xFF: USB-bridge crash / disconnect) is NOT a skippable bad - // unit — same as the original 10s read above. The line-442 - // short-circuit only inspected `res`; the 60s recovery read - // (`rec`) can wedge the bridge on its own, and falling into the - // `skip_errors` branch below would zero-fill + advance, treating - // a dead bridge as a skippable unit and marching the whole disc - // at one bridge-recovery per probe (hard rule #2, "runs forever, - // no MKV"). Re-check `rec` and abort, mirroring line 442. + // Recovery read also failed: re-check `rec` for a transport + // failure (bridge crash) same as `res` above — the 60s recovery + // read can wedge the bridge on its own; abort rather than skip. if let Some(e) = rec.as_ref().err() && e.is_scsi_transport_failure() { @@ -639,31 +578,27 @@ impl DiscStream { .into()); } - // Same rule as after the first-attempt read: the 60s recovery - // read goes through the same source, so it can be the call - // that discovers the source is dead. Skipping the unit would - // zero-fill the rest of the title as fabricated content. + // Same rule as the first-attempt read: the recovery read can + // also be the call that discovers the source is dead. Skipping + // would zero-fill the rest of the title as fabricated content. if let Some(e) = rec.as_ref().err() && e.is_source_terminated() { return Err(crate::error::Error::SourceTerminated.into()); } - // Recovery read also failed. Skip the WHOLE failed unit or bail. - // Zero-filling and advancing by the full unit keeps - // current_offset unit-aligned, so the next read still begins on a - // real AACS unit boundary (a 1-sector skip here would desync the - // rest of the title — the bug this guards). + // Recovery read also failed. Skip the WHOLE failed unit, not one + // sector: advancing by the full unit keeps current_offset + // AACS-aligned (a partial skip here would desync the rest). if self.skip_errors { let zb = sectors as usize * 2048; self.read_buf.resize(zb, 0); self.read_buf[..zb].fill(0); self.buf_valid = zb; self.errors += 1; - // `errors` counts skip events; `lost_bytes` counts the - // bytes actually zero-filled. For AACS (unit_align=3) a - // single event skips a whole 6144-byte unit, so loss - // estimates must use this, not `errors * 2048`. + // `errors` counts skip events, `lost_bytes` the bytes + // zero-filled. One AACS event can skip a whole 6144-byte + // unit, so loss estimates must use lost_bytes, not errors*2048. self.lost_bytes = self.lost_bytes.saturating_add(zb as u64); self.emit(EventKind::SectorSkipped { sector: lba as u64 }); self.current_offset += sectors as u32; @@ -882,11 +817,9 @@ impl crate::pes::Stream for DiscStream { let Some(&(_, track)) = pid_to_track.iter().find(|(p, _)| p == pid) else { continue; }; - // Flush frames carry their own per-frame `discontinuity` (a - // post-gap picture buffered at EOF was stamped by the parser), - // so route them through the SAME B1 gate the in-stream path - // uses — otherwise a trailing dangling-reference frame would - // bypass the resync. + // Flush frames carry their own per-frame `discontinuity`, so + // route them through the SAME B1 gate as the in-stream path — + // otherwise a trailing dangling-reference frame bypasses resync. for frame in parser.flush() { let emit = match resync.get_mut(track) { Some(gate) => gate.admit( @@ -922,10 +855,9 @@ impl crate::pes::Stream for DiscStream { if let (Some(t0), Some(t1)) = (t0, t1) { prof_tick("fill", t1.duration_since(t0).as_nanos(), bytes as u64); } - // Plaintext: the wrapped reader (DecryptingSectorSource) - // applied AACS / CSS in-place during fill_extents' - // read_sectors call. The pre-0.18 inline decrypt step - // lived here. + // Plaintext already: DecryptingSectorSource applied AACS/CSS + // in-place during fill_extents' read_sectors call (the pre-0.18 + // inline decrypt step used to live here). let buf_base = self.fed_bytes; self.fed_bytes = self.fed_bytes.saturating_add(bytes as u64); @@ -945,11 +877,9 @@ impl crate::pes::Stream for DiscStream { .copied() { if skip_parse { - // Profiling escape hatch — bypass the codec - // parser and pass the raw PES bytes straight - // through as a single PesFrame. Lets us - // attribute consumer-thread time to - // "demux + framing" vs "codec parse". + // Profiling escape hatch — bypass the codec parser, + // pass raw PES bytes through as one PesFrame, so we + // can attribute time to demux+framing vs codec parse. self.pending_frames.push_back(crate::pes::PesFrame { coding: None, source: None, @@ -1080,10 +1010,9 @@ impl crate::pes::Stream for DiscStream { } fn headers_ready(&self) -> bool { - // FREEMKV_SKIP_PARSE bypasses codec parsers entirely for - // bottleneck profiling, so codec_private is never populated. - // Pretend headers are ready immediately in that mode so the - // CLI loop doesn't hang waiting for them. + // FREEMKV_SKIP_PARSE bypasses codec parsers entirely, so + // codec_private is never populated — pretend headers are ready + // immediately in that mode so the CLI loop doesn't hang. if self.skip_parse { return true; } @@ -1099,14 +1028,9 @@ impl crate::pes::Stream for DiscStream { } fn errors(&self) -> u64 { - // Read errors PLUS every frame the B1 resync gates discarded. - // - // Summed from the gates here rather than counted at each of the three - // `admit` call sites: one place cannot drift, three can, and a frame - // dropped by the demuxer flush is the same loss as one dropped on the - // main path. `ResyncGate::dropped` is zeroed at every resync, so only - // `dropped_total` sees a gap that RESOLVES — which is most of them, and - // was the whole reason concealed video loss reached no caller. + // Read errors PLUS every frame the B1 resync gates discarded. Summed + // here rather than at each of three `admit` call sites (one place can't + // drift); `dropped_total` catches gaps that RESOLVE, which most do. self.errors + self .resync @@ -1116,11 +1040,9 @@ impl crate::pes::Stream for DiscStream { } fn lost_bytes(&self) -> u64 { - // Read-error zero-fill loss (counted in fill_extents) — real missing - // content the abort gate must see. There is no decrypt-loss term: the - // decrypt path passes bad-encoded/undecryptable units through (a broken-TS - // unit is the muxer's concern, and a missing key is indistinguishable from - // bad authoring here), so only physical read loss is reported. + // Read-error zero-fill loss only — the abort gate needs real missing + // content. No decrypt-loss term: undecryptable units pass through as + // broken TS (the muxer's concern), indistinguishable from bad authoring. self.lost_bytes } } @@ -1809,10 +1731,9 @@ mod tests { .unwrap(); stream.skip_errors = true; - // Drive fill_extents across batches: the good leading sectors mux fine, - // and the batch covering the bad sector shrinks to size 1, fails the - // fast read (0x02), then the bottom-out recovery read returns the - // transport failure (0xFF) — which must abort. + // Drive fill_extents across batches: the batch covering the bad sector + // shrinks to size 1, fails the fast read (0x02), then the bottom-out + // recovery read returns the transport failure (0xFF), which must abort. let mut res = Ok(true); for _ in 0..1000 { res = stream.fill_extents(); @@ -2079,10 +2000,8 @@ mod tests { lba - ext_start ); // Non-tail reads must be a whole number of units; the only permitted - // short read is a final partial unit (below one unit). Assert it - // rather than documenting it — a mid-stream non-unit-multiple read - // would straddle AACS unit boundaries and decrypt under the wrong - // alignment. + // short read is a final partial unit. A mid-stream non-unit-multiple + // read would straddle AACS unit boundaries and decrypt wrong. assert!( (count as u32).is_multiple_of(ALIGN) || (count as u32) < ALIGN, "read count {count} is neither a whole number of units nor a sub-unit tail" @@ -2094,10 +2013,8 @@ mod tests { assert!(stream.errors >= 1, "expected the bad unit to be skipped"); // Regression: `lost_bytes` must account for the WHOLE skipped unit - // (3 sectors = 6144 bytes), not a single sector. A loss estimate - // built from `errors * 2048` would undercount AACS loss ~3x — the - // single-pass abort-gate bug this guards against. Exactly one unit - // is bad in this fixture, so lost_bytes == errors * ALIGN * 2048. + // (3 sectors = 6144 bytes), not one sector — `errors * 2048` would + // undercount AACS loss ~3x, the single-pass abort-gate bug this guards. assert_eq!( stream.lost_bytes, stream.errors * ALIGN as u64 * 2048, @@ -2110,8 +2027,7 @@ mod tests { // Crucial anti-desync assertion: the read that bottomed out and was // skipped must have been a single 3-sector unit starting at offset 12 - // (the unit boundary at or below the bad sector 13), NOT a 1-sector - // read at 13. Find a recorded read of (12, 3). + // (the unit boundary at/below bad sector 13), NOT a 1-sector read at 13. assert!( reads .iter() @@ -2347,9 +2263,8 @@ mod tests { assert_eq!(PesStream::errors(&s), 0, "clean before anything happens"); // Drive a gate directly: a discontinuity, two dropped inter-coded - // frames, then a keyframe that resyncs and zeroes the per-run count. - // The short-read fixture's title carries no streams, so give it a - // gate to drive. + // frames, then a keyframe that resyncs and zeroes the per-run count + // (the short-read fixture's title has no streams, so give it a gate). s.resync.push(crate::mux::resync::ResyncGate::new()); let gate = s.resync.last_mut().expect("just pushed"); assert!(!gate.admit(true, true, false)); @@ -2609,9 +2524,8 @@ mod tests { ); // Behavioural witness for the WRAPPED reader's keys: while AACS keys - // are installed the decorator refuses a read that does not begin on a - // 6144-byte aligned unit (it would mis-decrypt every following unit), - // so a mid-unit LBA hard-fails. + // are installed, a read not starting on a 6144-byte unit boundary + // hard-fails (it would mis-decrypt every following unit). let mut buf = vec![0u8; 2048]; let before = s.reader.read_sectors(1, 1, &mut buf, false); assert!( diff --git a/src/mux/driver.rs b/src/mux/driver.rs index e6d407d2..b0f7cb50 100644 --- a/src/mux/driver.rs +++ b/src/mux/driver.rs @@ -217,7 +217,7 @@ pub struct MuxOptions { /// /// `Send + Sync + 'static` because [`mux_stream`] takes the handle as an /// [`Arc<dyn MuxEvents>`] and clones it into the constructors' `'static` -/// [`EventFn`] (via [`reader_event_fn`]) so the reader-side events fire from the +/// [`EventFn`](crate::sector::prefetched::EventFn) (via [`reader_event_fn`]) so the reader-side events fire from the /// highway's producer thread / the live `DiscStream`'s read loop. The driver /// fires [`Self::on_output_opened`] and the write-side [`Self::on_write_progress`] /// from the driving thread; the reader-side events @@ -310,30 +310,18 @@ pub fn mux_stream( halt: &Halt, events: std::sync::Arc<dyn MuxEvents>, ) -> std::io::Result<MuxOutcome> { - // Construct the source stream. The file/ISO path calls the untouched - // `build_iso_pipeline` highway (zero added copies); the live path builds a - // `DiscStream`; a URL source goes through `input()`. The reader-side - // constructors take a `'static` `EventFn`; we clone the `Arc<dyn MuxEvents>` - // into one (`reader_event_fn`) so the highway's producer thread — and the - // live `DiscStream`'s read loop — forward `BytesRead`/`SectorSkipped`/ - // `BatchSizeChanged`/`ReadError` back to the consumer's handle. - // - // Each arm also derives the run's PROVENANCE (`SourceInfo`) — the facts a - // `fvi://` destination records in its header (`docs/FVI_FORMAT.md` §6.2). - // `output()` has no access to the source, so the driver is the only place - // that can supply it honestly; a member the arm genuinely cannot reach is - // left at its default rather than guessed (the sink omits the empty ones). + // Construct the source stream (ISO/live/URL) — reader constructors need a + // `'static` EventFn, so we clone `Arc<dyn MuxEvents>` into `reader_event_fn`. + // Each arm also derives SourceInfo since `output()` has no access to source. let (stream, playlist_name, mut source): (Box<dyn Stream>, Option<String>, SourceInfo) = match input_src { - // The Url path builds its demux INSIDE `input()`, which prunes to the - // selected streams via `InputOptions.selection` (which the caller sets). - // Note: `MuxOptions.selection` does NOT apply here — that's the File/ - // Session arms' field; a Url-source caller must set `InputOptions.selection`. + // The Url path builds its demux INSIDE `input()`, pruned via + // `InputOptions.selection`. `MuxOptions.selection` does NOT apply + // here — that's the File/Session arms' field. MuxInput::Url { url, opts: in_opts } => { - // Provenance: the source URL verbatim, its scheme's medium, and the - // title `input()` will open (`None` selects title 0, per its doc). - // `playlist` is filled in below from the opened stream's scanned - // title — it is not known until the scan runs. + // Provenance: source URL verbatim, scheme's medium, and the title + // `input()` will open. `playlist` fills in below from the opened + // stream's scanned title — not known until the scan runs. let source = SourceInfo { medium: url_medium(&parse_url(url)), path: url.to_string(), @@ -354,21 +342,16 @@ pub fn mux_stream( keys, key_fetch, } => { - // Prune to the selected audio/subtitle streams BEFORE the highway - // builds its demux state from `title.streams` (and before - // `build_iso_pipeline`'s `probe_and_remap` may rewrite DVD AC-3 - // PIDs). Video is always kept; a no-op for the default All/All. + // Prune to selected audio/subtitle streams BEFORE the highway + // builds demux state (and before probe_and_remap rewrites DVD + // AC-3 PIDs). Video is always kept; no-op for default All/All. let mut title = title; opts.selection .apply(&mut title) .map_err(std::io::Error::from)?; - // Provenance: the staged image, in the same `iso://` URL form the - // `Url` arm records, plus the title's playlist. The 0-based TITLE - // INDEX is genuinely NOT reachable here — `MuxInput::Iso` carries an - // already-scanned `DiscTitle`, which has no index, and the caller's - // position in `disc.titles` is not passed. It stays 0 rather than - // being guessed; adding it would be an additive field on the public - // `MuxInput::Iso` variant. + // Provenance: staged image as an `iso://` URL, plus playlist. Title + // INDEX is NOT reachable here — `MuxInput::Iso` carries an already- + // scanned `DiscTitle` with no index, so it stays 0 rather than guessed. let source = SourceInfo { medium: Medium::Iso, path: format!("iso://{}", path.display()), @@ -432,11 +415,9 @@ pub fn mux_stream( source, ) }; - // Prune to the selected streams before `DiscStream::new` builds its - // demux tables from `title.streams` (and before its inline - // `probe_and_remap`). Only touches the stream list, so the - // ciphertext sampling in `resolve_inline_base_map` (keyed on extents) - // is unaffected. No-op for the default All/All. + // Prune to selected streams before `DiscStream::new` builds demux + // tables (and before inline probe_and_remap). Only touches the + // stream list; ciphertext sampling in resolve_inline_base_map unaffected. opts.selection .apply(&mut title) .map_err(std::io::Error::from)?; @@ -445,14 +426,9 @@ pub fn mux_stream( let mut reader = session.take_reader().ok_or_else(|| Error::DeviceNotReady { path: session.device_path().to_string(), })?; - // Resolve the AACS key map off the STAGED reader BEFORE it is moved - // into `DiscStream::new` (borrow to sample, then move to construct). - // Without this the AACS `DecryptingSectorSource` inside the stream has - // no map and fails `DecryptFailed` on the first content unit — the - // single-pass live-mux decrypt bug. `session.key_fetch()` (retained by - // `resolve_keys`) recovers a multi-CPS/orphan/forensic unit the pool is - // missing. DVD/clear/`raw` resolve to `None` (CSS self-cracks in - // `DiscStream::new`; raw is ciphertext passthrough) — unchanged. + // Resolve the AACS key map off the STAGED reader BEFORE it moves into + // `DiscStream::new` (borrow to sample, then move). Without this the + // AACS reader has no map and fails `DecryptFailed` on the first unit. let base_map = resolve_inline_base_map( &mut *reader, &title, @@ -491,39 +467,24 @@ pub fn mux_stream( mut keys, key_map, } => { - // Prune to the selected streams, exactly as the Iso and Session arms - // do. Without this a caller's audio/subtitle selection was silently - // ignored on the live-drive path while the field's own doc said it was - // applied. Only touches the stream list, so the extent-keyed ciphertext - // sampling in `resolve_inline_base_map` below is unaffected. No-op for - // the default All/All. + // Prune to selected streams, exactly as the Iso/Session arms do — + // without this, selection was silently ignored on the live-drive + // path despite the field's doc saying it was applied. let mut title = title; opts.selection .apply(&mut title) .map_err(std::io::Error::from)?; - // Provenance: the medium is certain (a raw physical `SectorSource`), - // and the title's playlist is on hand. The device PATH and the title - // INDEX are genuinely NOT reachable: `MuxInput::Live` hands over an - // opaque `Box<dyn SectorSource>` with no path accessor and an - // already-scanned `DiscTitle` with no index. Both stay empty/0 — - // the sink omits an empty path rather than inventing one. + // Provenance: medium is certain, playlist is on hand. Device PATH + // and title INDEX are NOT reachable — `MuxInput::Live` hands an + // opaque reader/title with neither, so both stay empty/0. let source = SourceInfo { medium: Medium::Disc, playlist: title.playlist.clone(), ..SourceInfo::default() }; - // The map installed BEFORE reads begin. Two sources: - // - A caller-supplied `key_map` (autorip's FMTS gate resolved the - // forensic per-segment map and passes it here) is used VERBATIM — - // never re-resolved. - // - `None` on an AACS disc means a plain (non-FMTS) single/multi-CPS - // disc that the caller did NOT map. Resolve the base map here off the - // live reader, exactly as the `Session` arm and `build_iso_pipeline` - // do — otherwise the AACS `DecryptingSectorSource` has no map and - // fails `DecryptFailed` on the first content unit (the single-pass - // live-mux decrypt bug). Borrow to sample, then move into the stream. - // DVD/clear/`raw` → `None` (unchanged: CSS self-cracks in - // `DiscStream::new`; raw is ciphertext passthrough). + // Map installed BEFORE reads begin: a caller-supplied `key_map` + // (autorip's FMTS gate) is used VERBATIM; `None` on AACS resolves + // the base map here (else `DecryptFailed` on first unit); DVD/raw → None. let base_map = match key_map { Some(map) => Some(map), None => resolve_inline_base_map( @@ -536,10 +497,9 @@ pub fn mux_stream( Some(halt), )?, }; - // INLINE `DiscStream` — the same constructor the `Session` arm uses, - // NOT `build_iso_pipeline` (the prefetch highway). The consumer's - // adaptive batch-retry lives in `DiscStream::fill_extents`, which the - // highway would bypass; the live single-pass path must keep it. + // INLINE `DiscStream`, same constructor as the `Session` arm, NOT + // `build_iso_pipeline` — the highway would bypass the adaptive + // batch-retry that lives in `DiscStream::fill_extents`. let mut stream = crate::mux::DiscStream::new( reader, title, @@ -552,13 +512,9 @@ pub fn mux_stream( if opts.raw { stream.set_raw(); } - // Apply the key map BEFORE reads begin — for an FMTS forensic map this - // rewrites the extent walk to our-phase units only and installs the map - // so each unit decrypts with its mapped key; for a plain single/multi-CPS - // base map it installs the per-unit content key. `None` leaves the walk - // unchanged (CSS / clear / raw). Single-pass FMTS correctness depends on - // this: dropping the forensic map reads the alternate device-group units - // and mis-decrypts the forensic segment. + // Apply the key map BEFORE reads begin: for FMTS it rewrites the + // extent walk to our-phase units and installs the mapped key; `None` + // leaves it unchanged. Dropping the map mis-decrypts the segment. if let Some(map) = base_map { stream = stream.with_key_map(map); } @@ -608,11 +564,11 @@ fn session_mux_keys(disc: &crate::disc::Disc) -> DecryptKeys { } /// Resolve the base AACS key map for an INLINE live-drive mux (the `Session` / -/// `Live` arms) BEFORE the reader is moved into [`DiscStream::new`] — the +/// `Live` arms) BEFORE the reader is moved into [`DiscStream::new`](crate::mux::DiscStream::new) — the /// counterpart to the map resolution [`build_iso_pipeline`] performs internally /// for the file highway. /// -/// Under the map-only decrypt model an AACS [`DecryptingSectorSource`] decrypts +/// Under the map-only decrypt model an AACS [`DecryptingSectorSource`](crate::sector::decrypting::DecryptingSectorSource) decrypts /// NOTHING until a key map is installed: with no map the AACS arm of the decrypt /// path fails loud with [`Error::DecryptFailed`] on the first content unit (the /// deliberate "a reader built without its map is a bug" guard). Both inline arms @@ -620,21 +576,21 @@ fn session_mux_keys(disc: &crate::disc::Disc) -> DecryptKeys { /// only a caller-supplied forensic FMTS map (`None` for a plain AACS disc) — so /// EVERY plain AACS Blu-ray/UHD muxed via the live single-pass path failed /// `DecryptFailed` on the first content read. This resolves + returns the map so -/// the caller can install it via [`DiscStream::with_key_map`]. +/// the caller can install it via [`DiscStream::with_key_map`](crate::mux::DiscStream::with_key_map). /// /// - AACS keys → resolve (`resolve_mux_key_map`: single-CPS content map, /// multi-CPS per-extent key selection, or FMTS per-segment map) and return /// `Some(map)`. Resolution failure propagates (fail loud), matching the ISO /// path's decrypt gate. /// - CSS / clear / `None` → `Ok(None)`: CSS self-cracks per title inside -/// [`DiscStream::new`], and a genuinely-clear disc needs no map. +/// [`DiscStream::new`](crate::mux::DiscStream::new), and a genuinely-clear disc needs no map. /// - `raw` → `Ok(None)`: ciphertext passthrough, no decrypt step to key. /// /// The `reader` is borrowed only to SAMPLE ciphertext here (the UDF/FMTS probe /// and any multi-CPS unit samples); a single-CPS disc — the overwhelming /// majority, including every single-key UHD — resolves its map with NO content /// read beyond the one-time UDF filesystem probe. The caller then moves the same -/// reader into [`DiscStream::new`]; reads are by absolute LBA, so the sampling +/// reader into [`DiscStream::new`](crate::mux::DiscStream::new); reads are by absolute LBA, so the sampling /// leaves no read-position state behind. fn resolve_inline_base_map( reader: &mut dyn SectorSource, @@ -648,11 +604,9 @@ fn resolve_inline_base_map( if raw || !matches!(keys, DecryptKeys::Aacs { .. }) { return Ok(None); } - // Thread the driver's cancel token into the live-drive key resolution: the - // resolve chain samples ciphertext off the LIVE reader (the FMTS probe can do - // hundreds of reads, each able to stall to the SCSI recovery timeout), so an - // operator `/api/stop` mid-resolution must be honored here — not only once the - // read loop starts. + // Thread the driver's cancel token into key resolution: the resolve chain + // samples ciphertext off the LIVE reader (FMTS can do hundreds of reads, + // each able to stall), so `/api/stop` must be honored here too. let map = resolve_mux_key_map(reader, title, keys, fetch, format, halt)?; Ok(Some(Arc::new(map))) } @@ -721,12 +675,8 @@ fn drive_mux( } // ── chapters:// / json:// short-circuit — BEFORE the header pump/gate ── - // - // These sinks write their whole file from the scanned title at `output()` - // time and consume no PES frames, so they need no codec headers. Running - // the header pump/gate first (the CLI's ordering) would false-fail a - // metadata export on a title whose video headers never resolve. Do it here - // by construction instead. + // These sinks write their whole file at `output()` time and consume no PES + // frames — running the header gate first could false-fail a metadata export. if matches!( parse_url(dest_url), StreamUrl::Chapters { .. } | StreamUrl::Json { .. } @@ -745,11 +695,9 @@ fn drive_mux( }); } - // ── Header pump ── - // - // Buffer frames until every video track's codec_private has resolved; MKV - // can't write a track header without codec init data. The loop breaks on - // EOF/None too, so the gate below re-checks. + // ── Header pump ── Buffer frames until every video track's codec_private + // has resolved; MKV can't write a track header without codec init data. + // The loop breaks on EOF/None too, so the gate below re-checks. let mut buffered: Vec<PesFrame> = Vec::new(); let mut buffered_bytes: usize = 0; while !stream.headers_ready() { @@ -767,10 +715,8 @@ fn drive_mux( let read = match stream.read() { Ok(r) => r, // A halt landing DURING a blocking read surfaces as `Error::Halted` - // (reads dominate wall-clock, so an operator stop most often lands - // here, not at the between-reads check above). A clean stop is not a - // failure — yield `completed = false`, matching the finish stage and - // the highway path, so the consumer's stop-preserves-staging path runs. + // (reads dominate wall-clock, so a stop usually lands here). Not a + // failure — yield `completed = false` so stop-preserves-staging runs. Err(e) if crate::error::is_halt(&e) => { return Ok(MuxOutcome { completed: false, @@ -788,17 +734,9 @@ fn drive_mux( Some(frame) => { buffered_bytes = buffered_bytes.saturating_add(frame.data.len()); buffered.push(frame); - // Bounded header buffer: a title whose codec_private never - // resolves would otherwise buffer the entire (tens-of-GB) - // stream into RAM until OOM. Fail fast instead of swapping the - // box to death. - // - // NOT `Error::MkvInvalid` — that code is what an empty nav/menu - // PGC stub yields and `error::is_skippable_title_stub` - // classifies it as skippable, so an all-titles rip DROPPED a - // title that had produced half a gigabyte of real frames and - // then exited reporting success. A cap overflow gets its own - // non-skippable code so it can never be mistaken for a stub. + // Bounded header buffer: a title whose codec_private never resolves + // would otherwise buffer the whole stream into RAM until OOM. NOT + // `Error::MkvInvalid` — that's skippable-stub, and this must not be. if buffered_bytes > HEADER_BUFFER_CAP_BYTES { tracing::error!( target: "mux", @@ -818,18 +756,13 @@ fn drive_mux( } } - // ── Header gate ── - // - // The pump can break on EOF (or a read error re-surfaced as `?`) without - // headers ever resolving. Finalising then writes a track header with no - // CODEC_PRIVATE — a structurally-invalid MKV the zero-output guard does not - // catch. Refuse. + // ── Header gate ── The pump can break on EOF without headers resolving. + // Finalising then would write a track header with no CODEC_PRIVATE — a + // structurally-invalid MKV the zero-output guard does not catch. Refuse. if !stream.headers_ready() { - // Re-check halt FIRST. On the prefetch-highway path a halt landing while - // the pump is blocked in a read can end the stream as `Ok(None)` rather - // than `Err(Halted)`, so the loop breaks with headers unresolved through no - // fault of the data. Reporting that as MkvInvalid tells the consumer its - // disc is malformed and skips the stop-preserves-staging path. + // Re-check halt FIRST: on the highway path a halt can end the stream as + // `Ok(None)` rather than `Err(Halted)`, breaking the loop with headers + // unresolved through no fault of the data — not a malformed disc. if halt.is_cancelled() { return Ok(MuxOutcome { completed: false, @@ -869,12 +802,9 @@ fn drive_mux( let pipe = Pipeline::spawn_named("freemkv-mux-consumer", WRITE_PIPELINE_DEPTH, sink) .map_err(std::io::Error::from)?; - // ── Frame pump ── - // Per-frame send deadline resolved from `MuxOptions.send_deadline` at the - // driver entry: a hard bound (autorip) or the effectively-unbounded - // [`NO_SEND_DEADLINE`] (CLI interactive path). Either way `send_with_halt` - // re-checks the halt token every `POLL_INTERVAL`, so Ctrl-C / `/api/stop` - // stays responsive regardless of this value. + // ── Frame pump ── Per-frame send deadline is a hard bound (autorip) or + // effectively unbounded (CLI). Either way `send_with_halt` re-checks halt + // every `POLL_INTERVAL`, so Ctrl-C / `/api/stop` stays responsive. let deadline = send_deadline; let mut interrupted = false; @@ -885,10 +815,8 @@ fn drive_mux( break; } // Feed write-side progress during the drain exactly as the steady-state - // loop below does. The watchdog is fed only from `on_write_progress`; - // no `stream.read()` runs here (reads finished in the header pump), so - // omitting this leaves the watchdog unfed for the whole header-drain - // phase and it can false-escalate on a long/slow drain. + // loop below does — the watchdog is fed only from `on_write_progress`, + // and no `stream.read()` runs here to do it for us. events.on_write_progress(bytes.load(Ordering::Relaxed), total_bytes); } @@ -909,30 +837,16 @@ fn drive_mux( } Ok(None) => break, // A halt landing mid-read is a clean operator stop, not a read - // failure: drain the consumer and fall through to the - // `completed = false` interrupt path, matching the header pump, - // the finish stage, and the highway (which returns Ok(None) on halt). + // failure: fall through to the `completed = false` interrupt path, + // matching the header pump, finish stage, and highway. Err(e) if crate::error::is_halt(&e) => { interrupted = true; break; } Err(e) => { - // Drain + join the consumer so its output file handle is - // released, then report the ROOT cause. - // - // The consumer's result used to be discarded with `let _`. A - // write-side `WriteSink::apply` failure that had ALREADY killed - // the output — the destination volume filling, say — was thrown - // away, and only the read error surfaced: the caller diagnosed a - // damaged disc and retried the rip onto the same full volume - // instead of being told it was out of space. A hard write - // failure precedes and explains the read error here (the - // producer only reaches the next `read()` because its previous - // `send` did not block on a dead consumer), so prefer it. - // Halt/join-timeout are NOT root causes — those are the clean - // operator-stop and wedge paths the finish stage below - // translates to `completed = false` — so the read error still - // wins over them. + // Drain + join the consumer, then report the ROOT cause: a + // write failure (e.g. volume full) precedes and explains the + // read error, so prefer it — except Halt/join-timeout, not root causes. match pipe.finish_with_halt(Some(halt)) { Err(w @ (Error::Halted | Error::PipelineJoinTimeout)) => { tracing::debug!( @@ -959,12 +873,9 @@ fn drive_mux( } } - // ── Finish ── - // - // Drop the producer side and join the consumer; its `close()` finalises the - // container and returns the payload-byte count. On halt/wedge this returns - // an error variant; we translate that to `completed = false` rather than - // surfacing it as a hard failure (a clean operator stop is not an error). + // ── Finish ── Drop the producer, join the consumer; `close()` finalises + // the container. On halt/wedge this returns an error variant, translated + // to `completed = false` rather than a hard failure. let (bytes_written, undelivered_streams, finalize_failed) = match pipe.finish_with_halt(Some(halt)) { Ok((b, undelivered)) => (b, undelivered, false), @@ -993,10 +904,9 @@ fn drive_mux( }); } - // ── Zero-output / NoStreams gate ── - // - // A natural drain that wrote no streams or not a single payload byte is the - // empty/undecryptable-input silent failure — refuse to report it complete. + // ── Zero-output / NoStreams gate ── A natural drain that wrote no streams + // or not a single payload byte is the empty/undecryptable-input silent + // failure — refuse to report it complete. if num_streams == 0 || bytes_written == 0 { return Err(Error::NoStreams.into()); } @@ -1312,11 +1222,9 @@ mod tests { None, ) .expect_err("unresolved headers must be refused"); - // This gate is the GENUINE stub case — the pump ended without any video - // track's codec_private resolving, so the title produced no muxable - // frames. `MkvInvalid` now means only this (malformed `mkv://` input is - // `MkvSourceInvalid`, E9053), and it must stay skippable so an all-titles - // rip drops the empty nav/menu PGC and finishes the rest. + // This gate is the GENUINE stub case: no video track's codec_private + // resolved. `MkvInvalid` now means only this (bad `mkv://` input is + // `MkvSourceInvalid`), and must stay skippable for all-titles rips. assert!( crate::error::is_skippable_title_stub(&err), "MkvInvalid is a skippable stub, got {err}" @@ -1367,11 +1275,9 @@ mod tests { assert!(out.output_opened, "the sink was opened before the halt"); } - // ── A halt landing mid-read (Err(Halted) from the stream, the common - // operator-stop case since reads dominate wall-clock) is a clean stop, - // NOT a mux failure. Both the frame pump and the header pump must yield - // completed=false. Mutation: propagating the read-arm Err instead of - // mapping Halted → completed=false makes these `.expect` calls panic. ── + // ── A halt landing mid-read (Err(Halted), the common operator-stop case) + // is a clean stop, NOT a failure — both pumps must yield completed=false. + // Mutation: propagating the read-arm Err instead of mapping it panics. ── #[test] fn halt_err_during_frame_read_yields_completed_false() { let halt = Halt::new(); @@ -2238,18 +2144,8 @@ mod tests { } // ── Regression A: header-buffer cap fails fast instead of OOM ─────────── - // - // A stream whose headers never resolve but that keeps yielding frames must - // be refused once the pre-headers buffer passes `HEADER_BUFFER_CAP_BYTES`, - // BEFORE draining the whole (unbounded, OOM-inducing) stream. Frames are - // zero-filled `Vec`s (`alloc_zeroed`, lazily paged) so the nominal size is - // cheap in RSS. We hand the pump FAR more frames than the cap needs and - // assert both that it fails with the same `MkvInvalid` outcome AND that it - // stopped EARLY (bounded read count) — the latter is what distinguishes the - // capped path from the un-capped one. - // - // Mutation: removing the cap makes the pump drain all `MANY` frames (reads - // == MANY) before failing at the header gate → the `reads_seen` bound fails. + // Must refuse once the buffer passes `HEADER_BUFFER_CAP_BYTES`, BEFORE + // draining the whole stream — asserted via a bounded read count. #[test] fn header_buffer_cap_fails_fast_instead_of_oom() { use std::sync::atomic::AtomicUsize; @@ -2282,10 +2178,9 @@ mod tests { None, ) .expect_err("over-cap header buffer must fail fast, not OOM"); - // The cap overflow must carry its OWN code, not `MkvInvalid`: an - // all-titles rip asks `is_skippable_title_stub` whether to move on, and - // `MkvInvalid` answers "yes, an empty nav/menu stub" — which silently - // dropped a title that had just produced 512 MiB of real frames. + // The cap overflow must carry its OWN code, not `MkvInvalid`: that code + // means "skippable empty stub" and would silently drop a title that + // had just produced 512 MiB of real frames. assert_eq!( err.to_string(), format!("E{}: {}", crate::error::E_MUX_HEADER_BUFFER_EXCEEDED, { @@ -2306,14 +2201,8 @@ mod tests { } // ── Regression B: watchdog fed during the buffered header drain ───────── - // - // Headers resolve only after K frames are buffered; those K frames are then - // flushed to the sink in the drain loop. Every flushed frame must fire - // `on_write_progress` (the sole watchdog feed) — with exactly K frames and - // no remainder, ALL `on_write_progress` calls come from the drain. - // - // Mutation: dropping the `events.on_write_progress(...)` call in the drain - // loop leaves the count at 0 → this fails. + // Headers resolve only after K frames buffer, then flush to the sink. + // Every flushed frame must fire `on_write_progress`, the sole watchdog feed. #[test] fn write_progress_fed_during_header_drain() { const K: usize = 6; @@ -2352,15 +2241,8 @@ mod tests { } // ── Regression C: Session key selection special-cases DVD ─────────────── - // - // `session_mux_keys` is the seam the `MuxInput::Session` arm uses to pick - // decrypt keys. A DVD must get `None` (so DiscStream cracks the correct - // per-title/per-VTS CSS key); a non-DVD passes `decrypt_keys()` through. - // Building a full multi-VTS DiscSession fixture is infeasible in a unit - // test, so this asserts the is_dvd→None DECISION at the tightest seam. - // - // Mutation: reverting to unconditional `disc.decrypt_keys()` returns - // `Css{..}` for the DVD case → the `None` assertion fails. + // A DVD must get `None` (so DiscStream cracks the correct per-title/VTS + // CSS key); a non-DVD passes `decrypt_keys()` through unconditionally. fn disc_with_css(format: crate::disc::DiscFormat) -> crate::disc::Disc { crate::disc::Disc { volume_id: "TEST".into(), @@ -2399,17 +2281,8 @@ mod tests { } // ── Regression E: send_deadline routing (Some vs None) ────────────────── - // - // `effective_send_deadline` is the tightest seam of the write-pipeline send - // decision. autorip passes `Some(60s)` (bounded, watchdog-backed file mux); - // the CLI's interactive stdout/network path passes `None`, which must map to - // an effectively-unbounded deadline so a slow-but-alive downstream is never - // spuriously reported as an interrupted mux. Ctrl-C stays responsive under - // `None` because `send_with_halt` re-checks the halt token every - // `POLL_INTERVAL` regardless of the deadline (see `NO_SEND_DEADLINE`). - // - // Mutation: reverting `None` to a fixed `Duration::from_secs(60)` (the - // pre-fix behaviour) makes the None-case assertions fail. + // autorip passes `Some(60s)`; CLI passes `None`, which must map to an + // unbounded deadline so a slow-but-alive downstream isn't reported interrupted. #[test] fn effective_send_deadline_routes_some_and_none() { // Some(d) → exactly d. diff --git a/src/mux/ebml.rs b/src/mux/ebml.rs index c469fbb3..49223e20 100644 --- a/src/mux/ebml.rs +++ b/src/mux/ebml.rs @@ -41,11 +41,8 @@ pub fn write_size(w: &mut impl Write, size: u64) -> io::Result<()> { size as u8, ]) } else if size >= 0x00FF_FFFF_FFFF_FFFF { - // 0x00FF_FFFF_FFFF_FFFF (max 56-bit) encodes byte-for-byte - // identical to write_unknown_size (the EBML all-ones - // "unknown/open-ended" sentinel), and anything larger doesn't fit - // the 7-byte payload. Reject so a finite size can never be emitted - // as the unknown-size marker. + // Max 56-bit value encodes identical to write_unknown_size's all-ones + // sentinel; reject so a finite size can never be mistaken for it. Err(crate::error::Error::MkvUnencodable.into()) } else { // 8-byte size for large elements @@ -363,11 +360,8 @@ pub fn read_size(r: &mut impl Read) -> io::Result<(u64, usize)> { } Ok((val, 8)) } else { - // b0 == 0x00: no length marker in the first byte. A VINT wider than - // 8 bytes is not representable by Matroska's size encoding, so this - // is a malformed/over-long size field rather than a valid 8-byte - // length. Reject it instead of silently building a size from the - // following 7 bytes (which would desync the parse). + // b0 == 0x00: no length marker, meaning a VINT wider than 8 bytes, which + // Matroska can't represent. Reject rather than desync the parse. Err(crate::error::Error::MkvSourceInvalid.into()) } } @@ -381,10 +375,8 @@ pub fn read_element_header(r: &mut impl Read) -> io::Result<(u32, u64, usize)> { /// Read an unsigned integer value of `len` bytes. pub fn read_uint_val(r: &mut impl Read, len: usize) -> io::Result<u64> { - // An EBML unsigned integer is at most 8 bytes. A malformed element - // claiming `len > 8` would index past this stack buffer and panic - // (DoS on untrusted input) — reject it at the source so every caller - // is safe, not just the ones that pre-check. + // len > 8 would index past this stack buffer and panic (DoS on untrusted + // input); reject at the source so every caller is safe, not just pre-checked ones. if len > 8 { return Err(crate::error::Error::MkvSourceInvalid.into()); } @@ -398,7 +390,7 @@ pub fn read_uint_val(r: &mut impl Read, len: usize) -> io::Result<u64> { } /// Read a float value. EBML floats are exactly 0, 4, or 8 bytes; any other -/// length is rejected as [`Error::MkvSourceInvalid`] and exactly the float width is +/// length is rejected as [`Error::MkvSourceInvalid`](crate::Error::MkvSourceInvalid) and exactly the float width is /// consumed (so a malformed element never under- or over-reads and desyncs the /// rest of the parent element). pub fn read_float_val(r: &mut impl Read, len: usize) -> io::Result<f64> { @@ -519,10 +511,8 @@ pub const FIELD_ORDER: u32 = 0x9D; // FlagInterlaced values: 1 = interlaced, 2 = progressive (0 = undetermined). pub const INTERLACED_INTERLACED: u64 = 1; pub const INTERLACED_PROGRESSIVE: u64 = 2; -// FieldOrder values (Matroska / RFC 9559, element 0x9D): 1 = top-field-first, -// 6 = bottom-field-first, 0 = progressive. The muxer derives TFF vs BFF from the -// bitstream's measured top_field_first when available, falling back to TFF for -// interlaced content (NTSC 480i / PAL 576i / HD 1080i are overwhelmingly TFF). +// FieldOrder (RFC 9559 element 0x9D): 1 = TFF, 6 = BFF, 0 = progressive. Falls +// back to TFF when top_field_first isn't measured (most interlaced content is TFF). // 0xFF is our sentinel for "undetermined / omit the element". pub const FIELD_ORDER_TFF: u8 = 1; /// Bottom-field-first (RFC 9559 element 0x9D = 6). Emitted when the bitstream's @@ -566,9 +556,8 @@ pub const BLOCK_ADD_ID_EXTRA_DATA: u32 = 0x41ED; /// BlockAdditional). Used by the MVC (`mvcC`) mapping for Blu-ray 3D. pub const BLOCK_ADD_ID_VALUE: u32 = 0x41F0; -// Block additions carried inside a BlockGroup — per-frame side data. For -// Blu-ray 3D (MVC) the dependent (right-eye) view NAL units for an access unit -// ride here as a BlockAdditional under the track's `mvcC` mapping (RFC 9559 +// Block additions inside a BlockGroup — per-frame side data. For Blu-ray 3D (MVC), +// dependent (right-eye) NAL units ride as a BlockAdditional under `mvcC` (RFC 9559 // §5.1.4.1.4; Matroska Codec Specifications §4.1.5). pub const BLOCK_ADDITIONS: u32 = 0x75A1; pub const BLOCK_MORE: u32 = 0xA6; @@ -603,10 +592,9 @@ pub const CUE_TRACK_POSITIONS: u32 = 0xB7; pub const CUE_TRACK: u32 = 0xF7; pub const CUE_CLUSTER_POSITION: u32 = 0xF1; -// Tags — per-track statistics tags. mkvmerge convention: a `BPS` SimpleTag -// per track carries the bits-per-second so readers (Windows Explorer's MKV -// property handler) that read the container tag rather than computing from -// stream size show a bitrate for every track, not just CBR audio. +// Tags — mkvmerge convention: a `BPS` SimpleTag per track carries bits-per-second +// so readers relying on the container tag (e.g. Explorer's MKV handler) show a +// bitrate for every track, not just CBR audio. pub const TAGS: u32 = 0x1254_C367; pub const TAG: u32 = 0x7373; pub const TARGETS: u32 = 0x63C0; @@ -969,13 +957,8 @@ mod tests { assert_eq!(consumed, 8); } - // ============================================================ - // write_id — exact width selection per EBML element-ID ranges - // (Matroska/EBML spec: an element ID is written verbatim; its - // declared width is implied by the position of the leading 1 bit. - // write_id must pick the minimal whole-byte encoding so the ID - // round-trips and parsers see the same width.) - // ============================================================ + // write_id — exact width selection per EBML element-ID ranges. write_id must + // pick the minimal whole-byte encoding so the ID round-trips at the same width. #[test] fn write_id_exact_bytes_per_width() { @@ -1019,22 +1002,15 @@ mod tests { #[test] fn read_id_rejects_zero_first_byte() { - // A first byte of 0x00 has no length marker in any of bits 7..4, so - // read_id falls through to the else branch and must reject it (an - // EBML ID wider than 4 bytes is not representable here). Otherwise the - // parser would desync. + // 0x00 has no length marker, meaning an ID wider than 4 bytes, which isn't + // representable here; reject or the parser desyncs. let mut c = Cursor::new(&[0x00u8, 0x11, 0x22, 0x33]); let e = read_id(&mut c).unwrap_err(); assert_eq!(e.kind(), io::ErrorKind::InvalidData); } - // ============================================================ - // write_uint — the SIZE byte must reflect the minimal big-endian - // value width (1/2/3/4/8). The Matroska spec stores unsigned ints - // big-endian with no leading-zero bytes; the declared element size - // is exactly that width. A boundary bug would write the wrong size - // and desync every following element. - // ============================================================ + // write_uint — the SIZE byte must reflect the minimal big-endian value width + // (1/2/3/4/8, no leading-zero bytes); a boundary bug desyncs every following element. #[test] fn write_uint_size_byte_matches_value_width() { @@ -1101,10 +1077,8 @@ mod tests { assert_eq!(&mn[2..], &i64::MIN.to_be_bytes()); } - // ============================================================ // write_float — EBML floats here are always 8-byte IEEE-754 doubles, // big-endian (Matroska SamplingFrequency/Duration). size byte = 0x88. - // ============================================================ #[test] fn write_float_is_8_byte_big_endian_double() { @@ -1120,11 +1094,8 @@ mod tests { assert_eq!(got.to_bits(), 48000.0f64.to_bits()); } - // ============================================================ - // write_string / write_binary — declared size must equal the byte - // length (UTF-8 byte count, not char count) so the reader consumes - // exactly the payload and no more. - // ============================================================ + // write_string / write_binary — declared size must equal the byte length + // (UTF-8 byte count, not char count) so the reader consumes exactly the payload. #[test] fn write_string_size_is_utf8_byte_count_not_char_count() { @@ -1147,11 +1118,8 @@ mod tests { assert_eq!(&buf[3..], &data); } - // ============================================================ - // read_string_val — Matroska strings may be null-padded; the reader - // strips trailing NULs but must preserve interior content and the - // payload byte-count consumed. - // ============================================================ + // read_string_val — Matroska strings may be null-padded; the reader strips + // trailing NULs but must preserve interior content and bytes consumed. #[test] fn read_string_val_strips_only_trailing_nulls() { @@ -1170,10 +1138,8 @@ mod tests { assert_eq!(s.as_bytes(), b"a\0b"); } - // ============================================================ - // read_uint_val — big-endian assembly; an EBML uint never exceeds 8 - // bytes (the reader rejects len>8 to avoid a stack OOB). - // ============================================================ + // read_uint_val — big-endian assembly; an EBML uint never exceeds 8 bytes + // (the reader rejects len>8 to avoid a stack OOB). #[test] fn read_uint_val_big_endian_and_len_zero() { @@ -1197,10 +1163,8 @@ mod tests { assert_eq!(e.kind(), io::ErrorKind::InvalidData); } - // ============================================================ - // read_float_val — exactly 0/4/8 byte widths; 4-byte is an f32 - // promoted to f64, 8-byte is an exact f64. - // ============================================================ + // read_float_val — exactly 0/4/8 byte widths; 4-byte is an f32 promoted to + // f64, 8-byte is an exact f64. #[test] fn read_float_val_4_byte_is_f32_promoted() { @@ -1225,11 +1189,8 @@ mod tests { } } - // ============================================================ - // read_binary_val / read_exact_bounded — a declared length that - // exceeds the bytes actually present is a truncated (malformed) - // element and must error without allocating the full declared size. - // ============================================================ + // read_binary_val / read_exact_bounded — a declared length exceeding bytes + // present is a truncated element; must error without allocating the full size. #[test] fn read_binary_val_short_read_errors() { @@ -1241,10 +1202,8 @@ mod tests { assert_eq!(v, vec![1, 2, 3, 4]); } - // ============================================================ - // read_element_header — header_bytes is id_len + size_len, and a - // truncated header (EOF mid-size) surfaces as an error. - // ============================================================ + // read_element_header — header_bytes is id_len + size_len, and a truncated + // header (EOF mid-size) surfaces as an error. #[test] fn read_element_header_reports_total_header_len() { @@ -1275,12 +1234,8 @@ mod tests { assert_eq!(e.kind(), io::ErrorKind::UnexpectedEof); } - // ============================================================ - // write_size — every declared width-boundary, asserting the exact - // VINT bytes (length marker + payload). Grounded in the EBML VINT - // spec: width W encodes 7*W payload bits, the highest value of each - // width being reserved as the unknown-size sentinel. - // ============================================================ + // write_size — every declared width-boundary, asserting the exact VINT bytes. + // Width W encodes 7*W payload bits; the highest value of each width is reserved. #[test] fn write_size_exact_bytes_at_width_boundaries() { @@ -1310,13 +1265,9 @@ mod tests { assert_eq!(b, [0x01, 0, 0, 0, 0x0F, 0xFF, 0xFF, 0xFF]); } - // ============================================================ - // start_master / end_master — the size placeholder is an 8-byte VINT - // (0x01 + 7 payload bytes), and end_master must back-patch the exact - // body byte count (end - start - 8). This is the core of every nested - // Matroska master element; a wrong subtraction silently corrupts the - // declared size of EVERY master element in the file. - // ============================================================ + // start_master / end_master — the placeholder is an 8-byte VINT and end_master + // must back-patch the exact body size (end - start - 8); a wrong subtraction + // corrupts every nested master element's declared size. #[test] fn end_master_backpatches_exact_body_size() { @@ -1479,10 +1430,8 @@ mod tests { /// end_master without a multi-terabyte buffer, which is why this is /// tested at the encoder. #[test] - // The underscores in these literals mark BITFIELD boundaries in the - // bitstream header being built (e.g. a 5-bit field then a 3-bit field), - // not thousands-style digit groups. Regrouping them uniformly would - // satisfy the lint by destroying the only thing they encode. + // Underscores mark bitfield boundaries (e.g. 5-bit then 3-bit), not digit + // groups; regrouping uniformly would destroy the only thing they encode. #[allow(clippy::unusual_byte_groupings)] fn fixed_width_vint8_is_big_endian_over_the_full_payload() { assert_eq!( diff --git a/src/mux/fmp4/mod.rs b/src/mux/fmp4/mod.rs index fcc06007..aa988d55 100644 --- a/src/mux/fmp4/mod.rs +++ b/src/mux/fmp4/mod.rs @@ -276,11 +276,9 @@ fn build_dinf() -> Vec<u8> { } fn build_stbl() -> Vec<u8> { - // Stub stsd: empty sample description (zero entries). Replace with - // hvc1+hvcC once the fragmenting path lands so the init segment is - // actually decodable. Must be populated together with build_mvex: - // when the hvc1+hvcC sample entry lands and entry_count becomes 1, - // the trex default_sample_description_index=1 becomes valid. + // Stub stsd: zero entries. Must land together with build_mvex's trex — + // once hvc1+hvcC is added here (entry_count=1), trex's + // default_sample_description_index=1 becomes valid. let mut stsd_body = Vec::new(); stsd_body.extend_from_slice(&[0, 0, 0, 0]); stsd_body.extend_from_slice(&0u32.to_be_bytes()); // entry_count @@ -306,9 +304,8 @@ fn build_stbl() -> Vec<u8> { fn build_mvex() -> Vec<u8> { // trex: track_ID=1, default_sample_description_index=1, others=0. - // dsdi=1 only becomes valid once build_stbl's stsd carries the - // matching hvc1 sample entry (entry_count=1) — keep the two in sync - // when fragment emission lands. + // dsdi=1 is only valid once build_stbl's stsd has the matching hvc1 + // entry (entry_count=1) — keep the two in sync. let mut trex_body = Vec::new(); trex_body.extend_from_slice(&[0, 0, 0, 0]); // version + flags trex_body.extend_from_slice(&VIDEO_TRACK_ID.to_be_bytes()); @@ -411,15 +408,9 @@ mod tests { assert!(has_mvex, "moov missing mvex"); } - // ============================================================ - // ISO/IEC 14496-12 box-tree structural invariants - // - // Every box is [size:u32-BE][type:4][body]. `size` covers the full - // box including the 8-byte header. The init segment must be a clean - // sequence of well-sized boxes — a wrong size silently desyncs every - // ISO BMFF / DASH parser. These tests walk the tree byte-exactly - // rather than scanning for fourCCs. - // ============================================================ + // ISO/IEC 14496-12 box-tree invariants: every box is [size:u32-BE][type:4] + // [body], size covers the header too. A wrong size silently desyncs every + // ISO BMFF/DASH parser, so these tests walk the tree byte-exactly. /// Walk a flat sequence of top-level boxes, returning /// (type, box_start, box_total_size). Asserts each declared size lands @@ -482,10 +473,8 @@ mod tests { #[test] fn ftyp_major_brand_and_compatible_brands() { - // ISO/IEC 14496-12 §4.3: ftyp = major_brand(4) + minor_version(4) + - // compatible_brands[]. The stub declares iso6 / minor 1 / {iso6, dash, - // msdh, hvc1}. A regression that dropped a brand or mis-ordered the - // header would break DASH brand negotiation. + // ISO/IEC 14496-12 §4.3 ftyp layout: major_brand(4)+minor_version(4)+ + // compatible_brands[]. A dropped/reordered brand breaks DASH negotiation. let buf = init_segment(); let (ftyp_size, _) = read_box_header(&buf); let body = &buf[8..ftyp_size as usize]; @@ -521,10 +510,9 @@ mod tests { #[test] fn mvhd_timescale_and_next_track_id() { - // §8.2.2 mvhd (version 0): after 4-byte version+flags, the fields are - // creation(4) modification(4) timescale(4) duration(4) ... and the box - // ends with next_track_ID(4). The stub uses 90000 Hz timescale and - // next_track_ID = 2 (track 1 reserved for video). + // §8.2.2 mvhd v0: version+flags(4) creation(4) modification(4) + // timescale(4) duration(4) ... next_track_ID(4). Stub uses 90000 Hz + // and next_track_ID=2 (track 1 reserved for video). let buf = init_segment(); let boxes = walk_boxes(&buf); let (_, moov_start, moov_size) = boxes.iter().find(|(t, _, _)| t == b"moov").unwrap(); @@ -584,12 +572,9 @@ mod tests { #[test] fn stbl_present_with_empty_sample_tables() { - // The fragmented init segment carries no samples in moov, so stsd has - // entry_count 0 and stts/stsc/stsz/stco are all empty. Walk down - // moov.trak.mdia.minf.stbl and assert the stsd entry_count is 0 - // (current stub state). If stsd ever gains an hvc1 entry, trex's - // default_sample_description_index=1 becomes meaningful — this test - // documents the coupling the source comment calls out. + // Fragmented init segment carries no samples in moov: stsd entry_count + // is 0 and stts/stsc/stsz/stco are empty (current stub state). If stsd + // ever gains an hvc1 entry, trex's dsdi=1 becomes meaningful — see build_mvex. let buf = init_segment(); let boxes = walk_boxes(&buf); let (_, moov_start, moov_size) = boxes.iter().find(|(t, _, _)| t == b"moov").unwrap(); @@ -758,10 +743,9 @@ mod tests { #[test] fn write_video_after_init_still_unimplemented_and_no_media() { - // Even after the init segment is already emitted, write_video must keep - // returning Unimplemented and must not append any media bytes (no - // moof/mdat), so a caller can't be fooled into thinking the second call - // succeeded. + // Even after init segment emission, write_video must keep returning + // Unimplemented and append no media bytes (no moof/mdat), so a caller + // can't be fooled into thinking the call succeeded. let mut buf: Vec<u8> = Vec::new(); let mut mux = Fmp4Mux::new(&mut buf); mux.write_init_segment().unwrap(); diff --git a/src/mux/fvi_sink.rs b/src/mux/fvi_sink.rs index cef08761..90925a3b 100644 --- a/src/mux/fvi_sink.rs +++ b/src/mux/fvi_sink.rs @@ -4,8 +4,8 @@ //! into a container, emits one machine-readable *video-index* record per coded //! picture of the title's primary video track. It is a thin consumer of the //! reusable, pure-data [`VideoMap`](crate::mux::videomap) model -//! ([`MapHeader`]/[`PictureRecord`]): the sink builds the header from the title, -//! then writes one record per video [`PesFrame`] straight to disk — nothing here +//! ([`MapHeader`](crate::mux::videomap::MapHeader)/[`PictureRecord`](crate::mux::videomap::PictureRecord)): the sink builds the header from the title, +//! then writes one record per video [`PesFrame`](crate::pes::PesFrame) straight to disk — nothing here //! re-parses the elementary stream, and the whole index is never buffered. //! //! The on-disk shape is the freemkv FVI format (normative public spec @@ -79,14 +79,9 @@ fn write_fvi_header(w: &mut dyn Write, h: &MapHeader) -> io::Result<()> { /// members (`field_order`, `progressive`, `nb_fields`) are emitted ONLY when the /// codec actually measured them — an honest absence, never a guessed default. fn write_fvi_record(w: &mut dyn Write, r: &PictureRecord) -> io::Result<()> { - // `src` is REQUIRED by the record schema (Appendix A); when provenance is - // absent the member is still emitted as null — a reader treats null as - // "position unknown". - // - // Per `docs/FVI_FORMAT.md` §9, `src.byte` is the offset of the AU's first - // byte WITHIN its `sector` (not the absolute source offset). `SourcePos.byte` - // is the absolute offset, so reduce it modulo the sector size; `sector` - // already carries the whole-sector count. + // `src` is REQUIRED (Appendix A); null when provenance absent = "position + // unknown". Per §9, `src.byte` is the offset WITHIN the sector, so reduce the + // absolute `SourcePos.byte` modulo sector size; `sector` is the whole count. let src = match r.source { Some(s) => serde_json::json!({ "sector": s.sector, @@ -107,18 +102,12 @@ fn write_fvi_record(w: &mut dyn Write, r: &PictureRecord) -> io::Result<()> { obj["pts"] = serde_json::json!(pts); } // dts is MAY — the highway carries no DTS on a frame, so it is omitted. - // TODO(provenance→recovery join): a `recovered` MAY member belongs here, - // sourced from the sweep/patch mapfile's bad-range overlap with this AU's - // `src`. Not reachable at the PesFrame today; omitted (spec MAY). + // TODO(provenance→recovery join): a `recovered` MAY member (sweep/patch + // mapfile overlap with this AU's `src`) belongs here; unreachable at PesFrame. - // Coding-derived members (§7.1), emitted as top-level members for ANY frame - // whose parser decoded the signal — derived through the codec-agnostic - // `PictureInfo` accessors, never the raw bitstream: - // - `field_order` only when measured (TFF/BFF/Progressive); OMITTED on a - // codec-type-only codec (HEVC/H.264/VC-1) — honest absence. - // - `progressive` only when the codec signalled it (Option<bool>). - // - `nb_fields` (displayed field periods, soft-telecine basis) when coding - // is present. + // Coding-derived members (§7.1) via codec-agnostic `PictureInfo` accessors, + // never the raw bitstream: `field_order` only when measured (OMITTED on + // codec-type-only HEVC/H.264/VC-1), `progressive` only when signalled. if let Some(c) = r.coding { if let Some(fo) = field_order_label(r.coding) { obj["field_order"] = serde_json::json!(fo); diff --git a/src/mux/hevc/mod.rs b/src/mux/hevc/mod.rs index ff5f8143..4fa3b686 100644 --- a/src/mux/hevc/mod.rs +++ b/src/mux/hevc/mod.rs @@ -76,19 +76,15 @@ impl<W: Write> HevcMux<W> { /// — Annex B has no timing layer. pub fn write_frame(&mut self, _pts_ns: i64, data: &[u8]) -> io::Result<()> { if !self.params_written { - // Mark written *before* the write: a partial write that then - // errors must not cause a later re-entry to re-emit the full - // parameter set on top of the bytes the sink already - // received (duplicate/split VPS/SPS/PPS). Callers discard the - // mux on any write error. + // Mark written *before* the write: on a write error, a later + // re-entry must not re-emit VPS/SPS/PPS on top of bytes the sink + // already received. Callers discard the mux on any write error. self.params_written = true; if let Some(cp) = &self.codec_private { match hvcc_to_annex_b(cp) { Some(params) => self.writer.write_all(¶ms)?, - // A non-empty hvcC that yields no NAL is a caller - // contract violation: emitting the stream without - // VPS/SPS/PPS produces undecodable output. Surface it - // rather than dropping the parameter sets silently. + // A non-empty hvcC yielding no NAL is a caller contract + // violation: emitting without VPS/SPS/PPS is undecodable. None if !cp.is_empty() => { return Err(crate::error::Error::HevcParamParse.into()); } @@ -268,10 +264,9 @@ pub(crate) fn append_length_prefixed_as_annex_b_sized( DEFAULT_NAL_LENGTH_SIZE }; let mut offset = 0; - // True once we've consumed at least one well-formed length prefix - // (even a zero-length one). Distinguishes "parsed as length-prefixed, - // all NALs empty" (emit nothing) from "not length-prefixed at all" - // (pass through as already-Annex B). + // True once at least one well-formed length prefix is consumed (even a + // zero-length one): distinguishes "all NALs empty" (emit nothing) from + // "not length-prefixed at all" (pass through as already-Annex B). let mut parsed_any = false; while offset + length_size <= data.len() { // Big-endian over exactly `length_size` octets (ISO/IEC 14496-15: the @@ -281,19 +276,16 @@ pub(crate) fn append_length_prefixed_as_annex_b_sized( .fold(0usize, |acc, &b| (acc << 8) | b as usize); offset += length_size; if offset + len > data.len() { - // Mid-NAL truncation (e.g. a NAL cut by a bad disc sector) — - // drop the truncated trailing NAL and emit only the valid - // Annex-B prefix accumulated so far. We never emit a half-NAL - // nor leak raw length-prefixed bytes into the Annex-B stream. + // Mid-NAL truncation (e.g. bad disc sector): drop the trailing + // NAL, emit only the valid Annex-B prefix so far — never leak + // raw length-prefixed bytes into the Annex-B stream. break; } parsed_any = true; if len == 0 { - // A zero-length prefix (e.g. pad bytes read off a damaged - // sector) would otherwise emit a bare start code with no - // RBSP — an invalid empty Annex B NAL. Skip it, mirroring - // the `nal_len == 0` guard in `hvcc_to_annex_b` (ISO/IEC - // 14496-15). + // Zero-length prefix (e.g. damaged-sector padding) would emit an + // invalid empty NAL; skip it (mirrors `hvcc_to_annex_b`'s + // `nal_len == 0` guard, ISO/IEC 14496-15). continue; } out.extend_from_slice(&START_CODE); @@ -301,11 +293,9 @@ pub(crate) fn append_length_prefixed_as_annex_b_sized( offset += len; } if !parsed_any && !data.is_empty() { - // No length prefixes parsed at all and no leading start code: - // pass the bytes through rather than discard them (recover-100% - // goal — a decoder can attempt its own resync; dropping them - // guarantees loss). This is distinct from "parsed as length- - // prefixed but every NAL was zero-length", which emits nothing. + // Nothing parsed and no leading start code: pass bytes through + // rather than discard them (recover-100% goal; a decoder can + // resync). Distinct from "parsed but every NAL was zero-length". out.extend_from_slice(data); } } @@ -342,10 +332,9 @@ pub(crate) fn avcc_to_annex_b(avcc: &[u8]) -> Option<Vec<u8>> { let mut offset = AVCC_HEADER_LEN + 1; let mut out = Vec::new(); - // Extract `count` length-prefixed NALs starting at `*offset` into `out`. - // Returns `false` (truncated) if a length field or NAL body runs past the - // end — the caller then stops, so it never reads further length fields out - // of mid-NAL bytes (mirrors `hvcc_to_annex_b`). + // Extract `count` length-prefixed NALs into `out`. Returns `false` if a + // length field or NAL body runs past the end, so the caller stops rather + // than reading further length fields out of mid-NAL bytes. fn take(avcc: &[u8], count: usize, offset: &mut usize, out: &mut Vec<u8>) -> bool { for _ in 0..count { if *offset + 2 > avcc.len() { @@ -443,10 +432,9 @@ mod tests { #[test] fn mid_nal_truncation_drops_trailing_nal_keeps_prefix() { - // First NAL is valid (2-byte payload), second has a length prefix - // claiming 100 bytes with only 3 present. Policy: emit the valid - // first NAL as Annex B, drop the truncated trailing NAL — never - // leak raw length-prefixed bytes into the Annex B stream. + // Second NAL's length prefix claims 100 bytes with only 3 present. + // Policy: emit the valid first NAL, drop the truncated trailing one + // — never leak raw length-prefixed bytes into the Annex B stream. let mut raw = Vec::new(); raw.extend_from_slice(&2u32.to_be_bytes()); raw.extend_from_slice(&[0x11, 0x22]); @@ -682,10 +670,8 @@ mod tests { #[test] fn non_length_prefixed_three_plus_bytes_passes_through() { - // A 4+ byte buffer that does NOT parse as length-prefixed (the first - // u32 length exceeds the remaining bytes on the very first NAL, parsing - // nothing) is passed through unchanged (parsed_any == false branch). - // 0xFFFFFFFF length with no body → parsed_any stays false → pass-through. + // 0xFFFFFFFF length with no body: exceeds remaining bytes on the very + // first NAL, so parsed_any stays false and the buffer passes through. let raw = [0xFF, 0xFF, 0xFF, 0xFF, 0x11, 0x22]; let got = length_prefixed_to_annex_b(&raw); assert_eq!(&got[..], &raw[..], "unparseable → passed through verbatim"); diff --git a/src/mux/m2ts.rs b/src/mux/m2ts.rs index 0bf29a3f..db7a1c38 100644 --- a/src/mux/m2ts.rs +++ b/src/mux/m2ts.rs @@ -22,11 +22,9 @@ impl M2tsStream { /// Create for writing PES frames → BD-TS output. /// Writes FMKV metadata header, then muxes PES frames into BD transport stream. pub fn create(mut writer: impl Write + Send + 'static, title: &DiscTitle) -> io::Result<Self> { - // Write FMKV metadata header unconditionally. An empty streams - // array is valid JSON and round-trips fine; skipping the header - // for a zero-stream title would make the output indistinguishable - // from a non-FMKV file on read-back (read_header returns - // Ok(None) → PMT fallback) even though M2tsStream produced it. + // Write FMKV header unconditionally: skipping it for a zero-stream title + // would make the output indistinguishable from a non-FMKV file on + // read-back (read_header → Ok(None) → PMT fallback). Empty array is valid. let m = meta::M2tsMeta::from_title(title); meta::write_header(&mut writer, &m)?; let pids: Vec<u16> = title @@ -40,21 +38,17 @@ impl M2tsStream { .collect(); let boxed: Box<dyn Write + Send> = Box::new(writer); let mut muxer = super::tsmux::TsMuxer::new(boxed, &pids); - // Declare each video track's codec. This one call decides both the ES - // framing (HEVC/H.264 arrive length-prefixed and need Annex-B conversion; - // MPEG-2 and VC-1 are already start-code ES and would be mangled by it) - // and which parameter-set record parser applies (avcC vs hvcC). Passing - // the codec rather than a NAL-or-not flag is deliberate: the two facts - // must never be able to disagree. + // Declaring the codec decides both ES framing (HEVC/H.264 are length- + // prefixed → need Annex-B conversion; MPEG-2/VC-1 are already start-code + // ES) and which param-set parser applies (avcC vs hvcC) — kept as one fact. for (i, s) in title.streams.iter().enumerate() { if let DiscStream::Video(v) = s { muxer.set_video_codec(i, v.codec)?; } } for (i, cp) in title.codec_privates.iter().enumerate() { - // codec_privates is parallel to streams/pids; ignore any - // trailing entries that exceed the track count rather than - // surfacing a track-range error for a benign metadata overrun. + // codec_privates is parallel to streams/pids; ignore trailing entries + // beyond the track count rather than error on a benign metadata overrun. if i >= pids.len() { break; } @@ -72,9 +66,8 @@ impl M2tsStream { impl crate::pes::Stream for M2tsStream { fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> { // Write-only sink. The m2ts:// read direction is served by - // `super::resolve::build_m2ts_pipeline` → - // `PipelinedPesStream`; routing through this type for reads - // was removed when the highway became the only ingress. + // `super::resolve::build_m2ts_pipeline` → `PipelinedPesStream`; routing + // reads through this type was removed when the highway became sole ingress. Err(crate::error::Error::StreamWriteOnly.into()) } @@ -309,9 +302,8 @@ mod tests { let ts_bytes = &buf[header_end..]; // Find first PUSI packet on VIDEO_PID; verify RAI in AF flags. - // chunks_exact drops any partial trailing chunk — only whole - // 192-byte BD-TS packets are valid, and it avoids OOB indexing on a - // short final chunk. + // chunks_exact drops a partial trailing chunk — only whole 192-byte + // BD-TS packets are valid, and it avoids OOB indexing on a short chunk. let pkt = ts_bytes .chunks_exact(192) .find(|p| { diff --git a/src/mux/m2ts_mux/mod.rs b/src/mux/m2ts_mux/mod.rs index d58379ba..1cff079c 100644 --- a/src/mux/m2ts_mux/mod.rs +++ b/src/mux/m2ts_mux/mod.rs @@ -212,12 +212,9 @@ impl<W: Write> M2tsMux<W> { // first keyframe can't carry params usefully). let mut es = Vec::with_capacity(data.len() + 64); if keyframe && !self.params_attempted { - // Arming the latch below is right either way — the same codec_private - // bytes cannot parse better on a later keyframe — but it must not be - // SILENT, which is what it was: no parameter sets reached the stream, - // every later keyframe skipped the prepend, and `finish()` returned Ok - // on a TS whose video cannot be decoded. Mirrors the same fix in the - // BD-TS sibling (`tsmux.rs`). + // Must not fail SILENT: if params don't parse, warn loudly rather than + // letting `finish()` return Ok on an undecodable TS. Mirrors the same + // fix in the BD-TS sibling (`tsmux.rs`). match self.video_codec_private.as_deref() { Some(cp) => match super::hevc::hvcc_to_annex_b(cp) { Some(params) => { @@ -240,8 +237,6 @@ impl<W: Write> M2tsMux<W> { } self.params_attempted = true; } - // Append the Annex-B form directly into the pre-sized `es` - // buffer rather than materializing an intermediate Vec. // The source's hvcC declares the NAL length-prefix width (ISO/IEC // 14496-15 §8.3.3.1.2 `lengthSizeMinusOne + 1`); assuming 4 mangles a // legal 1- or 2-octet-prefixed source into a start-code-free stream. @@ -293,17 +288,9 @@ impl<W: Write> M2tsMux<W> { self.base_pts_90k.get_or_insert(raw_90k); } let base = self.base_pts_90k.unwrap_or(raw_90k); - // Modular 33-bit subtraction. The 90 kHz PTS clock is a 33-bit field - // that wraps every 2^33 ticks (~26.5 h). A plain `saturating_sub` would - // collapse ANY frame whose (33-bit-masked) tick lands below `base` to - // PTS 0 — including a frame across a legitimate clock wrap (raw wraps - // past 0 and lands far below base), flat-lining timing for that span. - // Wrap the difference into the 33-bit range, then interpret it as a - // signed 33-bit delta: a small magnitude in the LOWER half is genuine - // forward progression (incl. across a wrap) and is kept; a value in the - // UPPER half means the frame is truly BEFORE the base (a small backward - // step — e.g. a leading audio frame ahead of the first video keyframe), - // which still floors to 0 per the documented behavior. + // Modular 33-bit subtraction: the PTS clock wraps every 2^33 ticks + // (~26.5 h), so plain `saturating_sub` would flat-line timing across a + // wrap. Wrap the delta into 33 bits and treat it as signed instead. let delta = raw_90k.wrapping_sub(base) & 0x1_FFFF_FFFF; // Signed 33-bit: the sign bit is bit 32 (value 2^32), so the entire // upper half [2^32, 2^33) is negative (frame before base) and floors @@ -333,24 +320,17 @@ impl<W: Write> M2tsMux<W> { pcr: Option<u64>, is_keyframe_video: bool, ) -> io::Result<()> { - // PSI cadence is enforced per TS packet — interleave a fresh - // PAT+PMT into the packet stream every PSI_INTERVAL_PACKETS so - // long single-PES emissions (e.g. one 60 KB video frame) don't + // Interleave fresh PAT+PMT every PSI_INTERVAL_PACKETS so long + // single-PES emissions (e.g. one 60 KB video frame) don't // starve receivers tuning in mid-stream. let mut offset = 0; let mut first = true; while offset < pes.len() { self.maybe_emit_psi()?; - // PCR cadence is enforced per VIDEO TS packet, NOT per PES. A single - // UHD HEVC I-frame is one PES spanning thousands of TS packets; if - // PCR could only ride the PES's first packet, the clock would go - // un-restamped for the whole frame — a multi-second gap far beyond - // the 40-packet / ~100 ms bound, which strict T-STD validators treat - // as a clock discontinuity. So re-stamp whenever the per-packet - // counter reaches the interval (or on the very first video packet of - // the stream), regardless of whether this is the PES start. Re-using - // the PES's own `pcr` for a mid-PES packet keeps the gap bounded. + // PCR cadence is per VIDEO TS packet, NOT per PES: a single UHD HEVC + // I-frame spans thousands of packets, so re-stamping only at PES + // start risks a multi-second T-STD clock-discontinuity violation. let attach_pcr = (pid == PID_VIDEO) && (pcr.is_some()) && (!self.first_video_written @@ -373,17 +353,9 @@ impl<W: Write> M2tsMux<W> { } let remaining = pes.len() - offset; - // Capacity for payload given AF body and 1-byte AF length. - // When AF body is empty we can still skip the AF entirely - // and get the full 184 B; only invoke the AF when we'd - // otherwise need stuffing. - // - // Per ISO/IEC 13818-1 Table 2-6, when an adaptation field is - // present its first body byte is the mandatory 8-bit flags - // byte. A stuffing-only field still needs that flags byte - // (all flags 0) — omitting it would make a strict decoder - // read the first 0xFF stuffing byte as flags (PCR_flag=1, - // …) and parse a phantom PCR out of the stuffing/payload. + // Per ISO/IEC 13818-1 Table 2-6, an adaptation field's first body byte + // is the mandatory 8-bit flags byte, even when stuffing-only (all 0) — + // omitting it lets a decoder misread the 0xFF stuffing as flags. let (af_present, payload_len, stuffing): (bool, usize, usize) = if !af_body.is_empty() { // AF is mandatory (PCR, RAI, …). 1 byte length + body + // stuffing + payload = 184. @@ -487,11 +459,9 @@ fn build_video_pes(pts_90k: u64, es: &[u8]) -> Vec<u8> { /// Build a PES packet for an audio access unit. fn build_audio_pes(pts_90k: u64, es: &[u8]) -> Vec<u8> { - // Audio PES: a bounded length is written whenever the PES fits in a - // u16 (the common case), so receivers don't have to scan for the next - // start code. For an access unit larger than ~64 KiB (rare — e.g. a - // large TrueHD frame) the length field falls back to the unbounded - // (0x0000) form, which most demuxers tolerate for private_stream_1. + // A bounded length is written whenever the PES fits a u16; larger units + // (rare — e.g. a large TrueHD frame) fall back to the unbounded (0x0000) + // form, which most demuxers tolerate for private_stream_1. build_pes_packet( crate::consts::pes_stream_id::PRIVATE_STREAM_1, pts_90k, @@ -533,10 +503,9 @@ fn build_pes_packet(stream_id: u8, pts_90k: u64, es: &[u8], length_in_header: bo fn build_pat(pmt_pid: u16) -> Vec<u8> { let mut section = Vec::new(); section.push(0x00); // table_id = PAT - // section_syntax_indicator(1) | '0'(1) | reserved(2) | section_length(12) - // section_length covers from end of this field through CRC. - // Body: transport_stream_id(2) + version/cni(1) + section/last_section(2) + program(4) = 9 bytes, - // plus CRC(4) = 13. Encoded big-endian. + // section_length covers from end of this field through CRC: body + // (transport_stream_id 2 + version/cni 1 + section/last_section 2 + + // program 4 = 9 bytes) plus CRC(4) = 13. section.extend_from_slice(&[0xB0, 13]); section.extend_from_slice(&[0x00, 0x01]); // transport_stream_id = 1 section.push(0xC1); // reserved | version=0 | current_next=1 @@ -604,13 +573,9 @@ fn build_pmt(audio: Option<AudioCodec>) -> Vec<u8> { /// Build the adaptation field carrying a PCR (no other flags). fn build_pcr_adaptation(pcr_90k: u64) -> Vec<u8> { - // adaptation_field_length is set by `Packet::append_adaptation` - // — this function returns just the field body. - // - // Layout: discontinuity_indicator(1) | random_access(1) | - // elementary_stream_priority(1) | PCR_flag(1) | OPCR_flag(1) | - // splicing_point_flag(1) | transport_private_data_flag(1) | - // adaptation_field_extension_flag(1) | PCR(48b). + // adaptation_field_length is set by `Packet::append_adaptation` — + // this function returns just the field body: flags byte (PCR_flag + // etc.) followed by the 48-bit PCR (33b base | 6b reserved | 9b ext). let mut af = vec![0x10]; // PCR_flag=1; RAI is OR'd in by the caller when applicable let pcr_base = pcr_90k & 0x1_FFFF_FFFF; // 33-bit let pcr_ext: u16 = 0; // 9-bit, we keep it zero (no sub-tick precision) @@ -726,12 +691,9 @@ mod tests { #[test] fn crc32_is_self_validating() { - // The MPEG-TS CRC has the property that prepending a single - // bit-flip changes the output; running it over its own input + - // CRC yields a fixed magic constant (the CRC residue). Rather - // than hardcoding sample bytes, verify the underlying algorithm - // by checking that two distinct inputs produce distinct CRCs - // and that the same input is deterministic. + // Rather than hardcoding sample bytes and a magic CRC residue, + // verify the algorithm behaviorally: distinct inputs produce + // distinct CRCs and the same input is deterministic. let a = [ 0u8, 0xB0, 0x0D, 0x00, 0x01, 0xC1, 0x00, 0x00, 0x00, 0x01, 0xE1, 0x00, ]; @@ -741,10 +703,8 @@ mod tests { let crc_b = mpegts_crc32(&b); assert_ne!(crc_a, crc_b); assert_eq!(crc_a, mpegts_crc32(&a)); // deterministic - // Sanity: all-zero input ⇒ CRC = 0 (init XORs but the - // shift/feedback cancels for zero data after init drains). - // We don't assert exact value — that depends on poly choice — - // but check it's not the same as for non-zero data. + // Sanity check for all-zero input: don't assert an exact value + // (depends on poly choice), just that it differs from non-zero data. let crc_zero = mpegts_crc32(&[0u8; 12]); assert_ne!(crc_zero, crc_a); } @@ -878,14 +838,9 @@ mod tests { #[test] fn stuffing_only_tail_packet_is_spec_valid() { - // A short final PES packet must stuff via an adaptation field - // whose first body byte is the mandatory zero-flags byte (per - // ISO/IEC 13818-1 Table 2-6), never a bare 0xFF stuffing byte - // that a decoder would misread as PCR/OPCR/etc. flags. - // - // Use an AUDIO PES (no PCR, no RAI on its tail) so the only AF on - // the last packet is the stuffing-only field under test. The PES - // is sized so its final TS packet is short (< 184 payload bytes). + // A short final PES packet must stuff via an AF whose first body byte is + // the mandatory zero-flags byte (ISO/IEC 13818-1 Table 2-6), never a bare + // 0xFF a decoder could misread as flags. Uses an AUDIO PES (no PCR/RAI). let mut sink: Vec<u8> = Vec::new(); let mut mux = M2tsMux::new(&mut sink); mux.set_audio(AudioCodec::Ac3); @@ -1060,12 +1015,9 @@ mod tests { mux.finish().unwrap(); drop(mux); - // The first video packet carries PCR + RAI (keyframe PES start). RAI - // rides only the FIRST packet of a KEYFRAME PES; every OTHER PCR-bearing - // video packet — the mid-PES re-stamps and the non-keyframe PES starts — - // must have RAI clear. Skip the very first video packet (the keyframe - // RAI carrier) and assert the first remaining PCR-bearing packet is RAI - // clear. + // RAI rides only the FIRST packet of a KEYFRAME PES; every other + // PCR-bearing video packet must have RAI clear. Skip that first + // (keyframe RAI carrier) packet and check the rest. let video_pkts: Vec<&[u8]> = sink .chunks(188) .filter(|p| u16::from_be_bytes([p[1] & 0x1F, p[2]]) == PID_VIDEO) @@ -1116,10 +1068,9 @@ mod tests { assert_ts_well_formed(&sink); - // Walk every video TS packet in order; record which ones carry a PCR - // (AF present with PCR_flag 0x10). The packet INDEX (among video - // packets) of consecutive PCR carriers must never advance by more than - // PCR_INTERVAL_PACKETS. + // Walk every video TS packet; record which carry a PCR (AF present with + // PCR_flag 0x10). The video-packet index of consecutive PCR carriers must + // never advance by more than PCR_INTERVAL_PACKETS. let mut video_idx = 0usize; let mut pcr_indices: Vec<usize> = Vec::new(); let mut total_video = 0usize; @@ -1151,10 +1102,9 @@ mod tests { ); // First PCR is on the first video packet. assert_eq!(pcr_indices[0], 0, "first video packet must carry PCR"); - // No gap between consecutive PCRs exceeds the interval. The counter is - // post-incremented and PCR attaches on `>= PCR_INTERVAL_PACKETS`, so the - // packet index gap is `PCR_INTERVAL_PACKETS + 1` (40 packets carrying no - // PCR, then the re-stamp packet) — the spec "every 40 packets" bound. + // Counter is post-incremented and PCR attaches on `>= PCR_INTERVAL_PACKETS`, + // so the max index gap is `PCR_INTERVAL_PACKETS + 1` — the spec's + // "every 40 packets" bound. let max_gap = PCR_INTERVAL_PACKETS + 1; for w in pcr_indices.windows(2) { assert!( @@ -1173,13 +1123,9 @@ mod tests { #[test] fn keyframe_video_with_pcr_combines_flags() { - // The FIRST video PES is always a keyframe carrying BOTH a PCR (forced - // at stream start so the receiver has the clock the PMT promises) AND a - // RAI (keyframe) — exercising the flag-OR path that combines RAI into the - // PCR adaptation-field flags byte (0x10 | 0x40 = 0x50). (PCR cadence is - // now enforced per video packet, NOT per PES boundary, so a LATER - // keyframe PES start no longer deterministically lands on a PCR-due - // packet; the combine path is pinned here on the guaranteed first PES.) + // The FIRST video PES always carries both PCR (forced at stream start) + // and RAI, exercising the flag-OR path (0x10 | 0x40 = 0x50). Pinned on + // the first PES since PCR is now per-packet, not per-PES-boundary. let mut sink: Vec<u8> = Vec::new(); let mut mux = M2tsMux::new(&mut sink); let mut small = Vec::new(); @@ -1231,19 +1177,16 @@ mod tests { #[test] fn crc32_residue_over_section_plus_crc_is_zero() { - // Defining property of the MPEG-TS CRC (ISO 13818-1 Annex B): running - // the CRC over a message WITH its appended 4-byte CRC yields a fixed - // residue. For this poly/init (no final XOR) the residue over - // [data || crc(data)] is 0. This pins the algorithm independent of - // any sample vector. + // Defining property of the MPEG-TS CRC (ISO 13818-1 Annex B): for this + // poly/init (no final XOR), running it over [data || crc(data)] yields + // residue 0 — this pins the algorithm independent of any sample vector. let data = [ 0x00u8, 0xB0, 0x0D, 0x00, 0x01, 0xC1, 0x00, 0x00, 0x00, 0x01, 0xE1, 0x00, ]; let crc = mpegts_crc32(&data); - // Known-answer vector for CRC-32/MPEG-2 (poly 0x04C11DB7, init - // 0xFFFFFFFF, no reflection, no final XOR — ISO/IEC 13818-1 Annex B), - // independently computed. This pins the polynomial, not just internal - // consistency. + // Known-answer vector for CRC-32/MPEG-2 (poly 0x04C11DB7, init 0xFFFFFFFF, + // no reflection, no final XOR — ISO/IEC 13818-1 Annex B), independently + // computed. Pins the polynomial, not just internal consistency. assert_eq!(crc, 0xE8F9_5E7D, "CRC-32/MPEG-2 known-answer vector"); let mut with_crc = data.to_vec(); with_crc.extend_from_slice(&crc.to_be_bytes()); @@ -1423,26 +1366,9 @@ mod tests { #[test] fn extreme_pts_does_not_overflow_and_clamps_to_33bit() { - // `base_relative_pts` widens to u128 then wraps the delta into 33 bits. An - // adversarial i64::MAX ns must not overflow, and the encoded PES PTS must be - // the EXACT wrapped value. - // - // The old version of this test wrote a single video frame, which by its own - // admission rebases to relative PTS 0 — so its only assertion was - // `0 < 2^33`, which cannot fail: widening the 33-bit mask (or deleting it) - // left the one test named for 33-bit clamping green. Two frames are needed - // so the second has a non-zero delta to pin, and the expected value is - // computed here from the spec formula rather than read back from the code - // under test. - // - // NOTE on the test's name: bit 32 of the PES PTS field is UNREACHABLE from - // this path. `base_relative_pts` interprets the delta as a signed 33-bit - // value and floors the entire upper half [2^32, 2^33) to 0, so anything the - // encoder ever receives is < 2^32. "Clamping to 33 bits" is therefore a - // structural property of the delta rule, not something a test can exercise; - // what IS pinned below is the exact encoding of the largest reachable value - // and the documented i64::MAX outcome. - // Decode the 33-bit PTS out of a PUSI video packet's PES header. + // Two frames are required: a single frame always rebases to PTS 0, making + // the only assertion the vacuous `0 < 2^33`. Bit 32 is unreachable (the + // signed-33-bit delta rule floors the upper half to 0), so this pins encoding. let decode_pts = |pkt: &[u8]| -> u64 { // Payload after the AF: AF area = 1 (length byte) + af_len. let af_len = pkt[4] as usize; @@ -1458,13 +1384,9 @@ mod tests { frame.extend_from_slice(&4u32.to_be_bytes()); frame.extend_from_slice(&[0x40, 0x01, 0x0C, 0x01]); - // (a) A LARGE but forward delta: exact-value assertion across all 33 bits. - // - // Frame 1 at PTS 0 seeds the base to 0, so frame 2's relative PTS is its own - // tick value. 477_218_477 x 100_000 ns divides exactly by the 100_000/9 - // conversion, giving 4_294_966_293 ticks — just under 2^32, i.e. the largest - // magnitude the signed-33-bit delta rule treats as forward progression, and - // a value that needs the full 3+15+15-bit PES PTS field to survive. + // (a) A LARGE but forward delta, exact-value assertion. 477_218_477 x + // 100_000 ns divides exactly, giving 4_294_966_293 ticks — just under + // 2^32, the largest magnitude the signed-33-bit rule treats as forward. let mut sink: Vec<u8> = Vec::new(); { let mut mux = M2tsMux::new(&mut sink); @@ -1483,12 +1405,9 @@ mod tests { "the encoded PES PTS must be the exact tick value, not a truncated one" ); - // (b) The adversarial i64::MAX: no overflow, no panic, and the documented - // signed-33-bit outcome. Its tick count masks to 6_564_084_417, which is in - // the UPPER half of the 33-bit range, so the delta rule reads it as a frame - // BEFORE the base and floors it to 0 (the same rule that floors leading - // audio). Asserted explicitly so this is a pinned decision, not the vacuous - // `0 < 2^33` the old single-frame version checked. + // (b) The adversarial i64::MAX: no overflow/panic. Its tick count masks to + // 6_564_084_417, in the UPPER half of the 33-bit range, so the delta rule + // reads it as a frame BEFORE the base and floors it to 0 — pinned explicitly. let mut sink: Vec<u8> = Vec::new(); { let mut mux = M2tsMux::new(&mut sink); @@ -1513,11 +1432,9 @@ mod tests { #[test] fn base_relative_pts_wraps_across_33bit_clock_rollover() { - // Regression: a real 90 kHz clock wrap must NOT collapse to PTS 0. - // Seed base near the top of the 33-bit range; a later frame whose tick - // has wrapped past 0 lands far below base. The OLD `saturating_sub` - // returned 0 (flat-lining timing); modular subtraction must return the - // true small forward delta. + // Regression: a real 90 kHz clock wrap must NOT collapse to PTS 0. The OLD + // `saturating_sub` returned 0 here (flat-lining timing); modular subtraction + // must return the true small forward delta instead. let mut sink: Vec<u8> = Vec::new(); let mut mux = M2tsMux::new(&mut sink); // Force the base to 2^33 - 100 directly (a value reachable only after diff --git a/src/mux/meta.rs b/src/mux/meta.rs index 90807b91..0579912e 100644 --- a/src/mux/meta.rs +++ b/src/mux/meta.rs @@ -1,6 +1,6 @@ //! M2TS metadata header — embeds title/stream info in raw m2ts files. //! -//! Format: [8B magic] [4B json_len] [JSON] [padding to 192B boundary] [BD-TS data...] +//! Format: `[8B magic] [4B json_len] [JSON] [padding to 192B boundary] [BD-TS data...]` //! Other tools skip the header during TS sync recovery (scan for 0x47). use crate::disc::{ @@ -186,10 +186,9 @@ impl M2tsMeta { codec_private: _, } => { let hdr_fmt = hdr.parse().unwrap_or(crate::disc::HdrFormat::Sdr); - // Prefer the explicitly stored color space. Pre-0.30.7 - // metadata has none, so derive it from the HDR format: - // every HDR variant (HDR10/HDR10+/HLG/Dolby Vision) is - // BT.2020; SDR is BT.709. + // Prefer the stored color space; pre-0.30.7 metadata has none, + // so derive from HDR format: every HDR variant (HDR10/HDR10+/ + // HLG/Dolby Vision) is BT.2020, SDR is BT.709. let cs = if color_space.is_empty() { color_space_from_hdr(hdr_fmt) } else { @@ -303,10 +302,9 @@ pub fn write_header(w: &mut impl Write, meta: &M2tsMeta) -> io::Result<()> { // English string into an io::Error (no-English rule). let json = serde_json::to_vec(meta).map_err(|_| crate::error::Error::NoMetadata)?; - // Guard the length field against truncation: the read side rejects - // anything over MAX_JSON_SIZE, and `as u32` would silently wrap a - // >=4 GiB JSON into a wrong, smaller length. Near-impossible for - // real stream metadata, but a v1.0 primitive shouldn't truncate. + // Guard the length field against truncation: `as u32` would silently wrap a + // >=4 GiB JSON into a wrong, smaller length. Near-impossible for real + // metadata, but a v1.0 primitive shouldn't truncate. let json_len = u32::try_from(json.len()).map_err(|_| crate::error::Error::NoMetadata)?; let raw_len = 8 + 4 + json.len(); // magic + len + json let padded_len = raw_len.div_ceil(BD_SOURCE_PACKET_BYTES) * BD_SOURCE_PACKET_BYTES; @@ -329,16 +327,14 @@ pub fn write_header(w: &mut impl Write, meta: &M2tsMeta) -> io::Result<()> { pub fn read_header(r: &mut impl Read) -> io::Result<Option<M2tsMeta>> { const MAX_JSON_SIZE: usize = 10 * 1024 * 1024; // 10 MB - // Read the first byte alone so a zero-byte stream (a legitimate - // headerless file) stays Ok(None), while a stream that begins with some - // magic bytes then truncates mid-magic surfaces as an error rather than - // being masked as "no header". + // Read the first byte alone so a zero-byte stream (legitimate headerless + // file) stays Ok(None), while a stream that truncates mid-magic surfaces as + // an error rather than being masked as "no header". let mut first = [0u8; 1]; if let Err(e) = r.read_exact(&mut first) { - // A clean EOF (no header at all) means "no FMKV header" — the caller - // falls back to a PMT scan. Any OTHER I/O failure (broken pipe, - // permission denied, mid-read disc error) is a real error and must - // propagate, not masquerade as a headerless stream. + // A clean EOF means "no FMKV header" — caller falls back to a PMT scan. + // Any OTHER I/O failure (broken pipe, permission denied, disc error) is + // real and must propagate, not masquerade as a headerless stream. if e.kind() == io::ErrorKind::UnexpectedEof { return Ok(None); } @@ -619,15 +615,9 @@ mod tests { } } - // ============================================================ - // Header byte-layout invariants - // - // Format: [8B magic][4B json_len BE][JSON][padding to 192B]. - // The header MUST end on a 192-byte (BD-TS packet) boundary so the - // following TS data stays packet-aligned and other tools can resync - // by scanning for 0x47. A wrong padding calc silently misaligns the - // entire m2ts payload. - // ============================================================ + // Header byte-layout invariants. Format: [8B magic][4B json_len BE][JSON] + // [pad to 192B]. MUST end on a 192-byte (BD-TS packet) boundary so following + // TS stays aligned (tools resync on 0x47); wrong padding misaligns the payload. #[test] fn magic_bytes_exact_layout() { @@ -739,10 +729,9 @@ mod tests { #[test] fn second_magic_byte_mismatch_is_none_not_error() { - // First byte matches MAGIC[0] ('F') so we commit to reading 7 more, - // but the resulting 4-byte magic differs from "FMKV". Per the reader - // contract this is "not an FMKV stream" → Ok(None), letting the caller - // fall back to a PMT scan. (Only a truncated read after 'F' errors.) + // First byte matches MAGIC[0] ('F') so we read 7 more, but the 4-byte + // magic differs from "FMKV": per the reader contract this is "not FMKV" + // → Ok(None), not an error. (Only a truncated read after 'F' errors.) let mut buf = vec![b'F', b'X', b'X', b'X', 0, 0, 0, 0]; // pad so the 8-byte magic read succeeds. buf.extend_from_slice(&[0u8; 8]); @@ -753,10 +742,9 @@ mod tests { #[test] fn read_header_consumes_exactly_one_packet_boundary() { - // After a successful read_header, the reader must be positioned exactly - // at a 192-byte boundary AND nothing of the following data consumed. - // Append a sentinel TS sync byte (0x47) right after the header and - // confirm it is the very next byte available. + // After read_header the reader must sit exactly at a 192-byte boundary + // with none of the following data consumed. Append a sentinel TS sync + // byte (0x47) after the header and confirm it is the very next byte. let meta = M2tsMeta::from_title(&video_title(HdrFormat::Hdr10, ColorSpace::Bt2020)); let mut buf = Vec::new(); write_header(&mut buf, &meta).unwrap(); @@ -793,10 +781,9 @@ mod tests { #[test] fn video_codec_private_round_trips_through_header() { - // A video stream's HEVCDecoderConfigurationRecord must survive - // from_title → write_header → read_header → codec_privates(). Without - // this, an FMKV-driven remux loses the hvcC and the MKV video track is - // undecodable. + // A video stream's HEVCDecoderConfigurationRecord must survive from_title + // → write_header → read_header → codec_privates(); otherwise an FMKV-driven + // remux loses the hvcC and the MKV video track is undecodable. let mut t = video_title(HdrFormat::Hdr10, ColorSpace::Bt2020); t.codec_privates = vec![Some(vec![0x01, 0x02, 0x20, 0x00])]; // fake hvcC let meta = M2tsMeta::from_title(&t); diff --git a/src/mux/meta_sink.rs b/src/mux/meta_sink.rs index 2fbdb5cf..21397e92 100644 --- a/src/mux/meta_sink.rs +++ b/src/mux/meta_sink.rs @@ -168,16 +168,9 @@ pub struct JsonSink { impl JsonSink { pub fn create(path: &Path, title: &DiscTitle) -> io::Result<Self> { - // 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. - // - // `NoMetadata` (E9008), matching `mux::meta`'s serialize guard: this is a - // metadata-encoding failure with no MKV involved. It was `MkvInvalid`, - // which `error::is_skippable_title_stub` reports as a skippable empty - // nav/menu stub — a json:// export that failed to encode is not that. + // Infallible in practice, but propagate rather than silently write "{}". + // `NoMetadata` (E9008), not `MkvInvalid` — the latter's skippable-stub + // ruling would wrongly swallow a real encode failure as an empty nav stub. let doc = serde_json::to_string_pretty(&title_json(title)) .map_err(|_| crate::error::Error::NoMetadata)?; let mut f = File::create(path)?; diff --git a/src/mux/mkv.rs b/src/mux/mkv.rs index 11ce5695..a6d7125e 100644 --- a/src/mux/mkv.rs +++ b/src/mux/mkv.rs @@ -16,12 +16,8 @@ use crate::disc::Resolution; use std::io::{self, Seek, Write}; // ── CICP colour codes (ITU-T H.273) ────────────────────────────────────────── -// -// The Matroska Colour element (RFC 9559) carries MatrixCoefficients, -// TransferCharacteristics and Primaries verbatim as the integer code-points -// defined by ITU-T H.273 ("Coding-independent code points", CICP). Hoisting the -// codes to named constants keeps the colour match arms self-documenting and the -// values traceable to the public spec table that defines each. +// Matroska's Colour element (RFC 9559) carries Matrix/Transfer/Primaries verbatim as +// ITU-T H.273 CICP code-points; named constants keep each value traceable to the spec table. /// ColourPrimaries = 1 (BT.709 / sRGB) — ITU-T H.273 Table 2. const CICP_PRIMARIES_BT709: u8 = 1; @@ -87,7 +83,7 @@ const BLOCK_ADD_ID_VALUE_MVC: u64 = 2; /// `BlockAddIDExtraData` for the `mvcC` BlockAdditionMapping (and, in the ISO /// container, the `MVCConfigurationBox('mvcC')` payload). /// -/// Layout mirrors the AVCDecoderConfigurationRecord except byte[4] repurposes +/// Layout mirrors the AVCDecoderConfigurationRecord except `byte[4]` repurposes /// the AVC record's `bit(6) reserved` as /// `complete_representation(1) | explicit_au_track(1) | reserved '1111'(4)`. /// `profile`/`compat`/`level` describe the WHOLE MVC stream and come from the @@ -191,10 +187,9 @@ pub(crate) fn cicp_for_video(v: &VideoStream) -> (u8, u8, u8, u8) { CICP_PRIMARIES_BT601_525, COLOUR_RANGE_LIMITED, ), - // Unknown colorimetry → CICP "unspecified" (code point 2) for matrix, - // transfer, and primaries, with limited range (the disc norm). Both the - // MKV sink and the FVI sidecar emit 2 so the two sinks of one title - // agree (matches `Colour::from_color_space`'s Unknown mapping). + // Unknown colorimetry → CICP "unspecified" (2) for matrix/transfer/primaries, + // limited range (disc norm); MKV and the FVI sidecar both emit 2 so the two + // sinks of one title agree (matches `Colour::from_color_space`'s Unknown mapping). ColorSpace::Unknown => ( CICP_MATRIX_UNSPECIFIED, CICP_TRANSFER_UNSPECIFIED, @@ -389,20 +384,14 @@ impl MkvTrack { Codec::Mpeg2 => ebml::CODEC_MPEG2, Codec::Mpeg1 => ebml::CODEC_MPEG1, Codec::Av1 => ebml::CODEC_AV1, - // Every video codec this crate can produce is named above. The - // remaining arm is reached only by a non-video or Unknown codec - // routed here in error — see the audio counterpart for why this - // keeps a fallback rather than erroring. + // Every video codec this crate can produce is named above; the remaining + // arm is reached only by a non-video/Unknown codec routed here in error + // (see the audio counterpart — no error channel exists here either). _ => ebml::CODEC_MPEG2, }; - // An Unknown resolution has no real dimensions — `pixels()` reports - // (0, 0) for it, and the serializer then omits PixelWidth/PixelHeight - // (Matroska marks them optional). The local `matches!(Unknown)` guard - // that used to sit here existed only because `pixels()` fabricated a - // 1920x1080 default; it does not any more, so the check belongs in the - // one accessor rather than in each caller that remembered to write it. - // None -> 0, and the writer already omits the optional - // PixelWidth/PixelHeight elements on 0 (RFC 9559 5.1.4.1.28-29). + // Unknown resolution -> `pixels()` reports (0, 0) (it no longer fabricates a + // 1920x1080 default), and the writer omits the optional PixelWidth/PixelHeight + // on 0 per RFC 9559 5.1.4.1.28-29. let (w, h) = v.resolution.pixels().unwrap_or((0, 0)); let (num, den) = v.frame_rate.as_fraction(); let default_duration_ns = if num > 0 { @@ -414,13 +403,9 @@ impl MkvTrack { // Derived by the single shared resolver so every sink (this muxer, the // FVI sidecar in `videomap.rs`) reports identical code points. let (matrix, transfer, primaries, range) = cicp_for_video(v); - // Display dimensions. For square-pixel video (HD/UHD/BD) the display - // aspect equals the pixel grid, so display == pixel. For anamorphic - // content (DVD: 720x480/576 pixels shown as 16:9 or 4:3) the coded - // pixels are NOT square — keep the coded height and derive the width so - // DisplayWidth:DisplayHeight carries the intended DAR (e.g. 720x576 - // 16:9 → 1024x576). Without this, players use the square-pixel ratio - // and show the disc as 5:4 / 3:2 instead of 16:9. + // HD/UHD/BD is square-pixel so display == pixel. DVD (720x480/576) is + // anamorphic — coded pixels aren't square, so derive width from the DAR + // (e.g. 720x576 16:9 -> 1024x576) or players show 5:4/3:2 instead of 16:9. let (display_width, display_height) = match v.display_aspect { Some((an, ad)) if an > 0 && ad > 0 && h > 0 => ((h * an + ad / 2) / ad, h), _ => (w, h), @@ -443,40 +428,13 @@ impl MkvTrack { colour_primaries: primaries, colour_range: range, interlaced: v.resolution.is_interlaced(), - // FieldOrder (Matroska 0x9D) is a bitstream property - // (`top_field_first`) the IFO/MPLS scan cannot know, so it is NOT set - // here — it would only ever be a guess. Default to UNDETERMINED; the - // mux stream (`MkvStream`) sets the MEASURED value from the first - // coded picture's `PictureInfo` before the muxer writes the header. - // If an interlaced track ever reaches the muxer still UNDETERMINED, - // that is a parser/source gap and is logged loudly — never faked. + // FieldOrder is a bitstream property the IFO/MPLS scan can't know, so it + // defaults to UNDETERMINED here; `MkvStream` sets the MEASURED value from + // the first coded picture. Still UNDETERMINED at mux time = logged, never faked. field_order: ebml::FIELD_ORDER_UNDETERMINED, - // DefaultDecodedFieldDuration is DELIBERATELY NOT emitted (0 here - // suppresses the element; see the writer in `MkvMuxer::new`). - // - // rc.5.1 added it (= half the frame period, 20 ms for 576i25) to try - // to fix the Windows-fps report, on the theory that Windows derives - // fps from it. The captured real-media evidence proves the opposite: with - // FlagInterlaced=1 + DefaultDuration=40 ms + DefaultDecodedFieldDuration=20 ms, - // 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 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 media analyzers rely on lives in the - // MPEG-2 elementary stream's picture_coding_extension (picture_structure / - // 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. + // DefaultDecodedFieldDuration DELIBERATELY NOT emitted (0 suppresses it): + // setting it made Windows Explorer report half fps / "Variable" instead of + // "Constant" (a known-good rip omits it); the ES already signals interlace. field_duration_ns: 0, sample_rate: 0.0, channels: 0, @@ -488,10 +446,9 @@ impl MkvTrack { } else { None }, - // HDR10 static metadata is measured from the HEVC SEI at mux time, - // not known at construction. The mux stream sets it from the first - // coded picture's PictureInfo before the header is written (the same - // deferred path FieldOrder uses). `None` here → omitted unless seen. + // HDR10 static metadata is measured from the HEVC SEI at mux time, not known + // at construction; the mux stream sets it before the header is written + // (same deferred path FieldOrder uses). `None` here -> omitted unless seen. hdr10: None, mvc_params: None, } @@ -501,17 +458,9 @@ impl MkvTrack { /// Matroska registry; every DTS family member (core, DTS-HD HR, DTS-HD MA) /// maps to the single registered `A_DTS` ID (see the note below). pub fn audio(a: &AudioStream) -> Self { - // The Matroska codec-ID registry defines `A_DTS` for the entire - // DTS family — the spec text for `A_DTS` explicitly states it - // "Supports DTS, DTS-ES, DTS-96/26, DTS-HD High Resolution Audio - // 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 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. + // `A_DTS` is the sole registered codec ID for the whole DTS family; players + // distinguish core/HD-HRA/HD-MA from the bitstream itself. The old `A_DTS/MA` + // and `A_DTS/HR` suffixes aren't registered and some strict parsers reject them. let codec_id = match a.codec { Codec::Ac3 => ebml::CODEC_AC3, Codec::Ac3Plus => ebml::CODEC_EAC3, @@ -523,21 +472,14 @@ impl MkvTrack { Codec::Mp3 => ebml::CODEC_MP3, Codec::Flac => ebml::CODEC_FLAC, Codec::Opus => ebml::CODEC_OPUS, - // Every audio codec this crate can produce is named above. The - // remaining arm is reached only by a non-audio or Unknown codec - // routed here in error; A_AC3 is the historical fallback and is - // wrong for such a track, but MkvTrack::audio has no error channel — - // `every_audio_codec_gets_its_own_registered_codec_id` pins the real - // codecs so a newly-added one cannot silently land here instead. + // Every audio codec this crate can produce is named above; the remaining + // arm (A_AC3, wrong for such a track) is reached only in error, since + // `MkvTrack::audio` has no error channel — a test pins each real codec ID. _ => ebml::CODEC_AC3, }; - // Unknown sample rate / channel layout: emit 0 so the serializer omits - // the SamplingFrequency / Channels element (Matroska supplies its own - // spec default) rather than writing a fabricated 48000 Hz / 6-channel - // value into the file. - // No guard needed: the accessors return 0 for Unknown, which is exactly - // what this wants. They used to fabricate 6 channels at 48 kHz, so every - // caller had to remember to check the variant first. + // Unknown sample rate/channels -> accessors return 0, so the serializer omits + // SamplingFrequency/Channels rather than writing a fabricated 48kHz/6ch value + // (they used to fabricate that default, forcing every caller to guard it). let sr = a.sample_rate.hz(); let ch = a.channels.count(); @@ -565,25 +507,9 @@ impl MkvTrack { field_duration_ns: 0, sample_rate: sr, channels: ch, - // KNOWN GAP, not a deliberate omission. The Matroska Codec - // Specifications say of `A_PCM/INT/BIG` and `A_PCM/INT/LIT` that "the - // audio bit depth MUST be read and set from the `BitDepth` element", - // and raw PCM has no in-band header left to recover it from once - // `LpcmParser` has stripped the BD/DVD framing — so a `Codec::Lpcm` - // track written with 0 here (the serializer then omits BitDepth - // entirely) has no recoverable sample width. - // - // The value is NOT available at this call site: [`AudioStream`] has no - // bit-depth member, and neither of the two places that know it reaches - // here. BD LPCM signals it in the 4-byte ES header (byte 3 bits 7-6) - // that `LpcmParser` discards, and the Blu-ray clip info does not carry - // it at all; DVD LPCM signals it in the IFO audio-attribute block - // (byte 1 bits 7-6, quantization: 16/20/24-bit), which `parse_audio_attr` - // does not read. Closing it means carrying the depth on `AudioStream` - // for the DVD path and a deferred setter fed from the first PES for the - // BD path (the route `hdr10` and `field_order` already take). Guessing - // 16 here would be worse than omitting: it turns a track a player may - // probe or reject into one it confidently misdecodes. + // KNOWN GAP, not deliberate: Matroska requires BitDepth for `A_PCM/INT/*`, + // but `LpcmParser` strips the BD/DVD framing that carries it and + // `AudioStream` has no bit-depth field; guessing 16 would be worse than 0. bit_depth: 0, dv_config: None, hdr10: None, @@ -955,11 +881,9 @@ impl<W: Write + Seek> MkvMuxer<W> { let seek_id_be = (ebml::SEEK as u16).to_be_bytes(); let seek_inner_id_be = (ebml::SEEK_ID as u16).to_be_bytes(); let seek_pos_id_be = (ebml::SEEK_POSITION as u16).to_be_bytes(); - // Absolute file offset where the CUES Seek entry begins, so that — if no - // Cues element is ultimately written (zero cue points) — the entry can be - // overwritten with a Void at finish() instead of leaving a SeekHead - // pointer that resolves to whatever element (Tags / EOF) happens to land - // at the Cues offset. See `cues_seek_entry_pos` / `finish`. + // Offset of the CUES Seek entry, so that if no Cues element is written (zero + // cue points) it can be overwritten with a Void at finish() instead of a + // SeekHead pointer resolving to whatever element happens to land there. let mut cues_seek_entry_pos: Option<u64> = None; for target_id in &targets { let entry_pos = writer.stream_position()?; @@ -994,11 +918,9 @@ impl<W: Write + Seek> MkvMuxer<W> { ebml::write_float(&mut writer, ebml::DURATION, duration_ticks)?; None } else { - // The source declared no duration (e.g. HD-DVD — its `.MAP` timemaps - // are not parsed). Reserve a DURATION placeholder now and back-patch - // it at finish() from the muxed timeline, so the file still declares a - // runtime instead of showing "unknown". The 8-byte float payload sits - // 3 bytes in (2-byte ID `0x4489` + 1-byte size `0x88`). + // No declared duration (e.g. HD-DVD `.MAP` timemaps aren't parsed): reserve + // a DURATION placeholder and back-patch it at finish() from the muxed + // timeline. Payload sits 3 bytes in (2-byte ID `0x4489` + 1-byte size). let pos = writer.stream_position()?; ebml::write_float(&mut writer, ebml::DURATION, 0.0)?; Some(pos + 3) @@ -1029,11 +951,9 @@ impl<W: Write + Seek> MkvMuxer<W> { for (i, track) in tracks.iter().enumerate() { let track_uid = (i + 1) as u64 | 0x100_0000; track_uids.push(track_uid); - // Build the MVC (Blu-ray 3D) MVCDecoderConfigurationRecord ONCE per - // track from the dependent view's subset-SPS/PPS. `None` for every - // non-3D track (and if the params are malformed) — the single source - // of truth for the CodecPrivate mvcC extension, the - // BlockAdditionMapping, and whether BlockAdditionals are conforming. + // Build the MVC (Blu-ray 3D) MVCDecoderConfigurationRecord ONCE per track; + // `None` for non-3D or malformed params. Single source of truth for the + // CodecPrivate mvcC extension, BlockAdditionMapping, and conformance flag. let mvc_record = track .mvc_params .as_ref() @@ -1054,11 +974,9 @@ impl<W: Write + Seek> MkvMuxer<W> { ebml::write_uint(&mut writer, ebml::FLAG_DEFAULT, 0)?; } if track.track_type == ebml::TRACK_TYPE_SUBTITLE && track.codec_id == ebml::CODEC_PGS { - // Reserve a 1-byte FlagForced (initial value = the scan/vendor - // flag) and record its offset, so PGS content can promote it to 1 - // at finish() if the track proves to be forced narrative. Written - // explicitly (ID + size + value) so the value is a single, - // in-place-patchable byte — same idiom as the Channels fixup. + // Reserve a 1-byte FlagForced (initial = scan/vendor flag) and record + // its offset, so PGS content can promote it to 1 at finish() if the + // track proves forced narrative — same patchable-byte idiom as Channels. ebml::write_id(&mut writer, ebml::FLAG_FORCED)?; ebml::write_size(&mut writer, 1)?; let value_offset = writer.stream_position()?; @@ -1077,25 +995,20 @@ impl<W: Write + Seek> MkvMuxer<W> { if let Some(ref cp) = track.codec_private { 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/analyzers read to recognise the - // stereoscopic MVC track (the per-frame dependent view rides - // in BlockAdditional under the mapping below). + // MVC (Blu-ray 3D) base track: CodecPrivate = base-view avcC + `mvcC` + // extension, the track-level signal decoders read to recognise the + // stereoscopic track (dependent view rides in BlockAdditional below). Some(record) => { let cp_mvc = mvc_codec_private(cp, record); ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, &cp_mvc)?; } - // Non-MVC (2D/UHD/audio/…): write the codec_private verbatim — - // the unchanged path, byte-identical to a 2D mux. + // Non-MVC (2D/UHD/audio/…): write codec_private verbatim, unchanged. None => ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, cp)?, } } - // Pre-0.13 a deferred codecPrivate path existed for video tracks - // (placeholder reserve + later seek-back fill via - // `fill_codec_private`). The PES pipeline hands codec_private - // up-front via the DiscTitle, so the deferred path was never - // exercised — removed in the 0.13 dead-code sweep. + // Pre-0.13 had a deferred codecPrivate path (placeholder + seek-back fill) + // for video, but the PES pipeline hands it up-front via DiscTitle, so that + // path was never exercised — removed in the 0.13 dead-code sweep. // DefaultDuration — frame duration in nanoseconds if track.default_duration_ns > 0 { @@ -1106,16 +1019,9 @@ impl<W: Write + Seek> MkvMuxer<W> { )?; } - // DefaultDecodedFieldDuration (one FIELD = half a frame), a DIRECT - // 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 a media analyzer flip to VFR on - // the captured 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. + // DefaultDecodedFieldDuration: production video always passes 0 here + // (emitting it made Windows Explorer report half fps / VFR, see + // `MkvTrack::video`); the guard still emits a valid element for other callers. if track.track_type == ebml::TRACK_TYPE_VIDEO && track.interlaced && track.field_duration_ns > 0 @@ -1132,10 +1038,9 @@ impl<W: Write + Seek> MkvMuxer<W> { let vid_pos = ebml::start_master(&mut writer, ebml::VIDEO)?; ebml::write_uint(&mut writer, ebml::PIXEL_WIDTH, track.pixel_width as u64)?; ebml::write_uint(&mut writer, ebml::PIXEL_HEIGHT, track.pixel_height as u64)?; - // Scan type. FlagInterlaced: 1 = interlaced, 2 = progressive. - // FieldOrder (0x9D) is only written for interlaced content with a - // determined order: TFF = 1, BFF = 6, 0 = progressive, and the - // element is omitted entirely when undetermined (RFC 9559). + // FlagInterlaced: 1 = interlaced, 2 = progressive. FieldOrder (0x9D) is + // written only for interlaced content with a determined order (TFF=1, + // BFF=6); omitted entirely when undetermined (RFC 9559). ebml::write_uint( &mut writer, ebml::FLAG_INTERLACED, @@ -1146,10 +1051,9 @@ impl<W: Write + Seek> MkvMuxer<W> { }, )?; if track.interlaced && track.field_order != ebml::FIELD_ORDER_UNDETERMINED { - // `track.field_order` was set CORRECTLY before construction - // (the mux stream reads the first coded picture's measured - // field order and sets it on the track), so this writes the - // right value the first time — no later rewrite. + // `track.field_order` was set correctly before construction (the mux + // stream reads the first coded picture's measured field order), so + // this writes the right value the first time — no later rewrite. ebml::write_uint(&mut writer, ebml::FIELD_ORDER, track.field_order as u64)?; } if track.display_width > 0 && track.display_height > 0 { @@ -1186,9 +1090,8 @@ impl<W: Write + Seek> MkvMuxer<W> { ebml::end_master(&mut writer, vid_pos)?; } - // Blu-ray 3D (MVC) signaling — BlockAdditionMapping (sibling of - // Video) carries the mvcC MVCDecoderConfigurationRecord so players / - // analyzers recognise the dependent (right-eye) view that rides as a + // Blu-ray 3D (MVC) signaling: BlockAdditionMapping carries the mvcC record + // so players recognise the dependent (right-eye) view riding as a // per-frame BlockAdditional under this mapping (BlockAddIDValue = 2). match mvc_record.as_ref() { Some(record) => { @@ -1202,10 +1105,9 @@ impl<W: Write + Seek> MkvMuxer<W> { ebml::write_binary(&mut writer, ebml::BLOCK_ADD_ID_EXTRA_DATA, record)?; ebml::end_master(&mut writer, map_pos)?; } - // `mvc_params` present but the record failed to build (malformed - // parameter sets): no mapping, and `track_has_mvc_mapping` above - // is already `false`, so BlockAdditionals are dropped — the file - // stays conforming rather than carrying an orphaned BlockAddID. + // `mvc_params` present but the record failed to build (malformed params): + // no mapping, `track_has_mvc_mapping` is already `false`, so + // BlockAdditionals are dropped — file stays conforming, no orphan BlockAddID. None if track.mvc_params.is_some() => { let (s, p) = track.mvc_params.as_ref().unwrap(); tracing::warn!( @@ -1238,15 +1140,9 @@ impl<W: Write + Seek> MkvMuxer<W> { // Omit Channels when unknown (0) — Matroska defaults it to 1 // rather than us fabricating a 6-channel count. if track.channels > 0 { - // Capture the ACTUAL file offset of the 1-byte Channels value - // so an AC-3 track can correct it from the bitstream acmod on - // its first frame (the IFO nibble is unreliable). Rather than - // assume write_uint's encoding (ID + size widths), write the - // element's ID and size explicitly, then record the position - // immediately before the value byte. Channels is 1..=255 so - // the value is exactly one byte (Size = 1), and the acmod - // correction is likewise 1..=255 — the width never changes, so - // the in-place single-byte rewrite stays valid. + // Capture the offset of the 1-byte Channels value so an AC-3 track can + // correct it from the bitstream acmod (IFO nibble is unreliable); + // written explicitly so the in-place single-byte rewrite stays valid. ebml::write_id(&mut writer, ebml::CHANNELS)?; ebml::write_size(&mut writer, 1)?; let value_offset = writer.stream_position()?; @@ -1367,32 +1263,11 @@ impl<W: Write + Seek> MkvMuxer<W> { /// when to remove the on-screen artifact (the practical case is /// PGS subtitles — without it, the last bitmap lingers until the /// next display set replaces it). Otherwise a plain `SimpleBlock`. - /// Rewrite a video track's `FieldOrder` value in place from the MEASURED - /// field order carried on the first coded picture, replacing the scan-time - /// guess written at construction. This is the fix for the "we parsed - /// `top_field_first` then ignored it" red flag: the muxer now stamps the - /// field order the bitstream actually states, not an assumption. /// - /// Idempotent — only the first call per track patches (later calls and - /// non-interlaced / non-video tracks are no-ops). `Progressive` / unknown - /// (`None`) leaves the written value untouched: an interlaced track keeps - /// its guess rather than being cleared via a multi-element change. The byte - /// width is fixed (FieldOrder is 0..=14), so the in-place rewrite is valid. - /// - /// `block_additional`, when `Some`, is attached to the frame as a Matroska - /// `BlockAdditional` (BlockAddID=2) — Blu-ray 3D (MVC): the base view is the - /// Block and the dependent (right-eye) access unit rides as the - /// BlockAdditional under the track's `mvcC` mapping. Such a frame is always a - /// `BlockGroup` (never a SimpleBlock), with a `ReferenceBlock` when it is not - /// TEST-ONLY. Every production path writes through [`MkvMuxer::write_frame_at`] - /// and passes the byte offset the frame was read from; the deferred-mux - /// replay of buffered frames was the last one that did not, and it now - /// does. Keeping this compiled out of the library means there is no - /// production API through which a frame can reach the file with its - /// provenance discarded — the placement logic stopped relying on - /// timestamps precisely so that could not happen silently. + /// TEST-ONLY convenience wrapper over [`MkvMuxer::write_frame_at`] (no byte + /// offset, no source provenance). Every production path writes through + /// `write_frame_at` directly. #[cfg(test)] - /// a keyframe. `None` for every non-3D frame. pub fn write_frame( &mut self, track_idx: usize, @@ -1413,7 +1288,7 @@ impl<W: Write + Seek> MkvMuxer<W> { ) } - /// [`Self::write_frame`] with the frame's SOURCE BYTE OFFSET. + /// `write_frame` (test-only convenience wrapper) with the frame's SOURCE BYTE OFFSET. /// /// Under a seam plan that offset identifies which clip the frame came from /// by lookup rather than by inferring it from the timestamp — which is @@ -1421,6 +1296,13 @@ impl<W: Write + Seek> MkvMuxer<W> { /// the same instant. Callers that have a `PesFrame` should pass /// `frame.source.map(|s| s.byte)`; `write_frame` is the same call with no /// provenance, which falls back to the mark heuristics. + /// + /// `block_additional`, when `Some`, is attached to the frame as a Matroska + /// `BlockAdditional` (BlockAddID=2) — Blu-ray 3D (MVC): the base view is the + /// Block and the dependent (right-eye) access unit rides as the + /// BlockAdditional under the track's `mvcC` mapping. Such a frame is always a + /// `BlockGroup` (never a SimpleBlock), with a `ReferenceBlock` when it is not + /// a keyframe. `None` for every non-3D frame. #[allow(clippy::too_many_arguments)] pub fn write_frame_at( &mut self, @@ -1432,11 +1314,9 @@ impl<W: Write + Seek> MkvMuxer<W> { block_additional: Option<&[u8]>, src_byte: Option<u64>, ) -> io::Result<()> { - // --log-level 3: capture the first ~100 coded frames per track to the - // side file BEFORE any timeline mangling, with the codec parser's own - // frame PTS — so an opening-GOP / mid-GOP-open / menu issue is - // reconstructable from the log + side file alone (no disc). No-op (and - // no allocation) on normal runs; the capture is `None`. + // --log-level 3: capture the first ~100 coded frames per track to the side file + // BEFORE any timeline mangling, with the parser's own PTS, so an opening-GOP + // issue is reconstructable offline. No-op/no alloc normally (capture = `None`). if let Some(cap) = self.opening_capture.as_mut() { cap.record(track_idx, pts_ns, keyframe, data); } @@ -1445,32 +1325,14 @@ impl<W: Write + Seek> MkvMuxer<W> { // below, which must exempt EVERY video track (incl. a Dolby Vision EL). let is_video = self.track_is_video.get(track_idx).copied().unwrap_or(false); - // The clip-boundary epoch decision is driven by the PRIMARY video track - // ONLY (the first video track, NOT the literal index 0 — the M2TS/PMT - // path can list an audio ES before the video ES, so streams[0] may be - // audio). A title can carry a SECOND video track — a Dolby Vision - // enhancement layer — whose PTS runs on its OWN timeline, interleaved - // with the base layer's. The two video PTS sequences overlap, so the EL's - // frames look like multi-second backward jumps against the base layer's - // frontier and would false-trigger an epoch reset on every GOP (the exact - // ratchet that inflated a 1-clip timeline to ~7 h). Only the base - // video layer establishes/advances the frontier and opens epochs; the EL - // — like audio and subtitles — rides the current offset. + // Clip-boundary epochs are driven by the PRIMARY video track only (not literal + // index 0 — M2TS/PMT can list audio first): a Dolby Vision EL's overlapping PTS + // would false-trigger a reset every GOP otherwise (once inflated a 1-clip title to ~7h). let drives_epoch = Some(track_idx) == self.primary_video_track; - // Map the raw PES PTS onto the continuous output timeline FIRST, before - // any base/cluster math: at a non-seamless clip / layer-break boundary - // the source PES PTS jumps backward. Rebasing it (a global offset across - // all tracks, A/V-sync-preserving) keeps the boundary from becoming a - // band of non-monotonic block timestamps. Only the PRIMARY video track - // drives the boundary decision; every other track (audio, subtitle, DV - // EL) rides the current offset. A single-clip BD title still trims to - // its one clip's [in, out] marks (dropping trailing audio the m2ts - // carries past OUT); only a source with no marks at all is a true no-op. - // `None` means the playlist does not include this frame — material - // outside every clip's IN/OUT marks, which only a seam-plan-driven - // title can report. Dropping it is the point: emitting it is what put - // duplicate content on the timeline at a join. + // Map the raw PES PTS onto the continuous timeline FIRST: source PTS jumps + // backward at a clip boundary, so rebasing avoids non-monotonic timestamps. + // `None` = outside clip IN/OUT marks; dropping avoids duplicate content at a join. let Some(pts_ns) = self.continuity.map( pts_ns, drives_epoch, @@ -1482,23 +1344,15 @@ impl<W: Write + Seek> MkvMuxer<W> { }; let raw_ticks = pts_ns / TIMESTAMP_SCALE_NS; - // Cluster boundaries normally coincide with a video keyframe so every - // Cues entry resolves to a seekable IDR at the cluster start. Keyed on - // the primary video track (first video index) when the title HAS video; - // for an audio-only / subtitle-only title (no video track) fall back to - // the first track (index 0) so its keyframes still open clusters — - // otherwise no cluster would ever open and every frame would be dropped. + // Clusters normally open on a video keyframe so Cues resolve to a seekable + // IDR. For an audio/subtitle-only title (no video track) fall back to track + // 0's keyframes, or no cluster would ever open and every frame would drop. let cluster_driver = self.primary_video_track.unwrap_or(0); let is_video_key = keyframe && track_idx == cluster_driver; - // Derive the timestamp base from the first *kept* keyframe (the frame - // that opens the first cluster), NOT the first frame merely seen. The - // first frame seen can have a higher display PTS than the subsequent - // I-frame (B-frame reordering / a PTS discontinuity), which would make - // later cluster/cue timestamps negative and wrap to ~u64::MAX on the - // `as u64` cast in `start_cluster`/`finish`. Anchoring on the first kept - // keyframe guarantees the open cluster's timestamp is 0 and all later - // relative offsets are computed from a frame we actually wrote. + // Base is the first *kept* keyframe, NOT the first frame seen: with B-frame + // reordering the first frame seen can have a higher PTS, which would make + // later timestamps negative and wrap to ~u64::MAX on the `as u64` cast. let base = match self.base_pts_ticks { Some(b) => b, None => { @@ -1513,26 +1367,14 @@ impl<W: Write + Seek> MkvMuxer<W> { raw_ticks } }; - // Floor at 0: base is the first kept keyframe, so any frame with an - // earlier PTS (audio/subtitle arriving with a pre-keyframe timestamp, or - // a back-jump on a stream discontinuity) would compute negative here, - // which would wrap to ~u64::MAX on the `as u64` cluster/cue write and - // could overflow the i16 block-relative cast. Frames before the first - // kept keyframe are clamped to t=0 rather than corrupting the timeline. + // Floor at 0: a frame earlier than base (pre-keyframe audio, or a stream + // discontinuity back-jump) would compute negative and wrap to ~u64::MAX on + // the `as u64` write; clamp to t=0 instead of corrupting the timeline. let pts_ticks = (raw_ticks - base).max(0); - // Strictly-monotonic block timestamps — AUDIO/SUBTITLE ONLY. Some audio - // PES PTS truncate to the same tick as the previous frame (or tick back - // one); nudge those to prev+1 tick (sub-frame, inaudible). - // - // VIDEO is EXEMPT: with B-frames, presentation PTS is legitimately - // non-monotonic in decode/storage order (a B-frame's PTS sits between its - // anchors, below the frame stored before it). Forcing it - // strictly-increasing clobbers those PTS, which a `copy` remux preserves - // but a decoder rejects — it derives DTS from the HEVC POC and finds them - // colliding ("non monotonically increasing dts"). Matroska SimpleBlock - // permits non-monotonic block timestamps (negative block-relative - // offsets), so leave the true PES PTS intact for video. + // Strictly-monotonic timestamps — AUDIO/SUBTITLE ONLY: nudge a truncated/ + // backward PES tick to prev+1 (inaudible). VIDEO is EXEMPT: B-frame PTS is + // legitimately non-monotonic and forcing it makes decoders reject the DTS. let pts_ticks = block_ts( is_video, self.last_pts_ticks.get(&track_idx).copied(), @@ -1561,29 +1403,9 @@ impl<W: Write + Seek> MkvMuxer<W> { } else { let rel = pts_ticks - self.cluster_ts_ticks; if !(MIN_BLOCK_REL..=MAX_BLOCK_REL).contains(&rel) { - // The block-relative timestamp is a signed 16-bit value, so a - // frame whose offset from the current cluster's timestamp falls - // outside i16::MIN..=i16::MAX ticks (~±3.27 s at the 0.1 ms - // scale) would silently wrap on the `as i16` cast, corrupting A/V - // sync. The keyframe-driven boundary above only fires on a video - // keyframe — a long audio-only stretch, a very long GOP with no - // intervening keyframe (positive direction), or an - // audio/subtitle PES whose PTS back-jumps below the open cluster - // (negative direction) can drift past the i16 range. Force a - // fresh cluster here even without a keyframe to keep the cast in - // range. pts_ticks is already floored at 0 above, so the new - // cluster timestamp never wraps on the `as u64` write in - // start_cluster. - // - // This split cluster is NOT keyframe-aligned, but it MUST still - // carry a Cue entry: at the finer 0.1 ms scale these forced - // splits are routine (any GOP/audio run over ~3.27 s triggers - // one), so omitting them would leave multi-second gaps in the - // seek index where a player's `-ss` lands at the wrong cluster. - // The Cue points at this cluster's start; a player seeking here - // resumes decode from the first block (it back-references the - // prior keyframe via the codec, as players already do for - // non-IDR cue targets). Cue track is the current frame's track. + // Block-relative timestamp is signed 16-bit (~±3.27s at 0.1ms scale); a + // long GOP/audio stretch or PTS back-jump can wrap it, so force a fresh + // cluster with its own Cue entry, or seeking lands in multi-second gaps. self.start_cluster(pts_ticks)?; self.cues.push(CuePoint { timestamp_ticks: pts_ticks, @@ -1596,11 +1418,9 @@ impl<W: Write + Seek> MkvMuxer<W> { // Committed to writing this frame — record its (monotonic) timestamp so // the next block on this track is forced strictly later. self.last_pts_ticks.insert(track_idx, pts_ticks); - // Track the highest block timestamp so a missing source duration can be - // back-patched from the real muxed runtime at finish(). - // Track the block END (start + its own duration when known), not just - // the start, so a back-patched Segment Duration covers the final frame's - // full presentation instead of understating the runtime by one frame. + // Track the highest block END (start + duration when known), not just start, + // so a back-patched Segment Duration (for a missing source duration) covers + // the final frame's full presentation instead of understating the runtime. let block_end_ticks = pts_ticks + duration_ns.map_or(0, |d| (d as i64 / TIMESTAMP_SCALE_NS).max(1)); self.max_block_ticks = self.max_block_ticks.max(block_end_ticks); @@ -1608,11 +1428,9 @@ impl<W: Write + Seek> MkvMuxer<W> { let relative_ts = (pts_ticks - self.cluster_ts_ticks) as i16; let duration_ticks = duration_ns.map(|dur_ns| (dur_ns as i64 / TIMESTAMP_SCALE_NS).max(1) as u64); - // A BlockAdditional with BlockAddID=2 is only conforming when the track - // declared the matching mvcC BlockAdditionMapping. If it did not (the - // dependent view's parameter sets were never captured before the header - // was written), drop the additional and emit a plain block rather than a - // non-conforming file with an orphaned BlockAddID. + // A BlockAdditional with BlockAddID=2 conforms only when the track declared + // the matching mvcC mapping; if not (dependent-view params never captured + // before the header was written), drop it and emit a plain block instead. let block_additional = match block_additional { Some(a) if self @@ -1626,21 +1444,9 @@ impl<W: Write + Seek> MkvMuxer<W> { Some(_) => None, None => None, }; - // Offset (ticks) of the referenced keyframe relative to this block, for - // any BlockGroup Block that is NOT a keyframe. Inside a BlockGroup the - // SimpleBlock 0x80 keyframe bit is reserved and MUST be 0, so - // keyframe-ness is carried ONLY by the presence/absence of a - // ReferenceBlock. A non-keyframe that omits it is indistinguishable from - // an intra frame — which is how every MPEG-2 P/B frame used to be written - // (the MPEG-2 parser stamps a per-frame duration, so ALL its frames take - // the BlockGroup path, not just the intra ones this path was written for). - // Gated to video: audio/subtitle frames on this path are self-contained - // (keyframe==true), and referencing a video keyframe from a non-video - // track would be a bogus cross-track reference. - // - // Fall back to 0 (self-relative) in the pre-first-keyframe corner - // (unreachable in practice — such frames are dropped before a cluster - // opens) so the marker is never absent. + // Offset of the referenced keyframe for a non-keyframe BlockGroup: inside a + // BlockGroup the SimpleBlock keyframe bit is reserved/0, so keyframe-ness is + // carried only by presence of a ReferenceBlock (gated to video; falls back to 0). let reference = if keyframe || !is_video { None } else { @@ -1654,10 +1460,9 @@ impl<W: Write + Seek> MkvMuxer<W> { ) }; match block_additional { - // MVC: base view Block + dependent-view BlockAdditional, always a - // BlockGroup. Non-keyframe base frames get a ReferenceBlock to the - // last keyframe (a keyframe carries none), so a player never treats a - // P/B frame as a seek point. + // MVC: base view Block + dependent-view BlockAdditional, always a BlockGroup; + // non-keyframe base frames get a ReferenceBlock so a player never treats + // a P/B frame as a seek point. Some(additional) => { self.write_block_group_mvc( track_idx + 1, @@ -1679,11 +1484,9 @@ impl<W: Write + Seek> MkvMuxer<W> { } }, } - // Remember this VIDEO track's last keyframe tick, so a later non-keyframe - // on the same track references a keyframe on its own track. Recorded per - // track: the ReferenceBlock above is emitted for any video track, so a - // single global slot produced cross-track references on a multi-video-track - // title (MVC base + secondary view, or a disc with two angles). + // Recorded per track (not a single global slot) so a later non-keyframe + // references a keyframe on its OWN track — a shared slot produced cross-track + // references on a multi-video-track title (MVC base+EL, or a two-angle disc). if keyframe && is_video && let Some(slot) = self.last_video_keyframe_ticks.get_mut(track_idx) @@ -1697,11 +1500,9 @@ impl<W: Write + Seek> MkvMuxer<W> { *b += data.len() as u64; } - // Correct the AC-3 track's Channels element from the bitstream acmod on - // the FIRST frame of the track. The DVD IFO audio nibble is unreliable - // (it claims 5.1 on a 2.0 stream); the bitstream acmod is authoritative. - // Only the first frame triggers it; the byte width is unchanged so the - // patch is a single-byte in-place rewrite (then restore position). + // Correct AC-3 Channels from the bitstream acmod on the first frame: the DVD + // IFO nibble is unreliable (claims 5.1 on a 2.0 stream); acmod is authoritative. + // Byte width is unchanged, so the patch is a single-byte in-place rewrite. if let Some(fixup) = self.ac3_channel_fixups.get_mut(&track_idx) && !fixup.corrected { @@ -1728,10 +1529,8 @@ impl<W: Write + Seek> MkvMuxer<W> { } } - // Accumulate PGS forced-subtitle state: a display set marks the track as - // having shown a subtitle, and clears `all_forced` the moment a - // non-forced set appears. `finish()` promotes FlagForced only for a track - // that displayed subtitles and had every one forced. + // Accumulate PGS forced-subtitle state; `finish()` promotes FlagForced only + // for a track that displayed subtitles and had every one forced. if let Some(fixup) = self.pgs_forced_fixups.get_mut(&track_idx) { fixup.tracker.observe(data); } @@ -1756,27 +1555,18 @@ impl<W: Write + Seek> MkvMuxer<W> { /// valid but empty MKV (zero clusters, zero frames), `finish` returns /// `Error::MkvInvalid` when frames were submitted but none were written. pub fn finish(mut self) -> io::Result<()> { - // A title that produced no frames (e.g. fully unreadable, or every - // frame dropped before the first track-0 keyframe opened a cluster) - // would otherwise yield a structurally-empty MKV with no clusters or - // cues. Surface that as an error rather than writing valid-but-empty - // output. - // Order matters. A title the seam plan dropped ENTIRELY also has a zero - // frame count, and `MkvInvalid` is classified by - // `is_skippable_title_stub` as an empty nav/menu stub — so reporting it - // that way would make an all-titles rip drop a real feature and finish - // the rest at exit 0. Decide the seam case FIRST, with a code that is - // not skippable. + // Order matters: a title the seam plan dropped ENTIRELY also has zero frames, + // and `MkvInvalid` is classified as a skippable empty nav/menu stub — so that + // case must be decided FIRST, or a real feature silently drops at exit 0. if self.frame_count == 0 && self.continuity.dropped_total() > 0 { return Err(crate::error::Error::SinkWroteNothing.into()); } if self.frame_count == 0 { return Err(crate::error::Error::MkvInvalid.into()); } - // Partial pre-cluster drops do not fail the mux (leading audio ahead of the - // first video IDR is normal), but they MUST leave a record: this counter - // was write-only, so frames silently vanished from the output while the run - // reported success. See the field's doc. + // Partial pre-cluster drops don't fail the mux (leading audio ahead of the + // first video IDR is normal), but must leave a record — this counter was + // write-only, so frames silently vanished while the run reported success. if self.dropped_pre_cluster > 0 { tracing::warn!( target: "mux", @@ -1785,21 +1575,13 @@ impl<W: Write + Seek> MkvMuxer<W> { "frames were discarded before the first cluster opened (no track-0 video keyframe had arrived yet); they are absent from the output" ); } - // Frames the playlist's clip marks excluded are dropped on purpose — a - // join legitimately discards the material a disc stores twice — but the - // count must not be write-only. Same reasoning as the pre-cluster - // counter above: an unexpected VOLUME here is how a title ends up - // quietly short while the run reports success. + // Frames excluded by the playlist's clip marks are dropped on purpose (a join + // legitimately discards material a disc stores twice), but the count must not + // be write-only — same reasoning as the pre-cluster counter above. let seam_dropped = self.continuity.dropped_total(); - // Counting a drop is not the same as bounding it. A join legitimately - // discards the material a disc stores twice — tens of frames — but if a - // title's marks do not line up with its PES clock the plan can discard - // most of it, and the only other gate is a GLOBAL zero-frame check - // whose error is additionally classified as a skippable stub. Between - // them, a title emitting seconds of a two-hour feature exits 0. That is - // the defect this change set already shipped once. - // - // More dropped than kept is never a real join, so it fails. + // Counting isn't bounding: if marks don't line up with the PES clock, the plan + // can discard most of a title, and the only other gate (zero-frame check) is a + // skippable stub — a title emitting seconds of a feature exiting 0 shipped once. if seam_dropped > self.frame_count { return Err(crate::error::Error::SeamPlanDroppedMost { dropped: seam_dropped, @@ -1825,22 +1607,9 @@ impl<W: Write + Seek> MkvMuxer<W> { // Close final cluster self.end_cluster()?; - // Correct FlagForced for PGS subtitle tracks from what the mux actually - // saw. In-place single-byte rewrite of the reserved value, then restore - // the append position for the Cues that follow. - // - // PROMOTE (0→1) a track that proved to be forced narrative: it displayed - // subtitles and every one carried `forced_on_flag`. Positive evidence, - // no further justification needed. - // - // DEMOTE (1→0) a track whose vendor label claims forced but whose content - // contradicts it — the case a whole-file mux is uniquely entitled to - // judge, because unlike the scan-time probe it has seen EVERY display set - // on the track. Gated by - // [`super::codec::pgs::demotable`]: an absence of `forced_on_flag` proves - // nothing on a disc whose authoring never sets it, so the gate demands - // that some track here demonstrably does, and that this track have the - // shape of a full dialogue track rather than of a forced-narrative one. + // Correct FlagForced for PGS tracks from what the mux saw (in-place rewrite). + // PROMOTE (0->1) a track whose displays all carry `forced_on_flag`. DEMOTE + // (1->0) a vendor-labelled track that contradicts it, per [`super::codec::pgs::demotable`]. let disc_uses_forced_flag = self .pgs_forced_fixups .values() @@ -1909,18 +1678,14 @@ impl<W: Write + Seek> MkvMuxer<W> { ebml::end_master(&mut self.writer, cues_pos)?; } - // Per-track BPS statistics tags (mkvmerge convention). A reader that - // reads the container `BPS` tag (Windows Explorer's MKV property - // handler) rather than computing bitrate from stream size shows a - // bitrate for EVERY track this way, not just CBR audio. + // Per-track BPS statistics tags (mkvmerge convention): a reader that shows + // container `BPS` (Windows Explorer) rather than computing it from stream + // size gets a bitrate for EVERY track this way, not just CBR audio. self.write_bps_tags()?; - // Back-patch SeekHead SeekPosition values now that all element offsets - // are known. When no Cues element was written (zero cue points), the - // CUES entry's SeekPosition would otherwise be back-patched to - // `cues_offset`, which now holds Tags / EOF — a dangling pointer to a - // non-Cues element. Skip that fixup and instead Void the whole CUES Seek - // entry (below) so the SeekHead carries no false pointer. + // Back-patch SeekHead SeekPosition values now offsets are known. If no Cues + // were written, skip the CUES fixup (else it'd point at whatever now sits at + // `cues_offset` — Tags/EOF) and Void the whole entry below instead. for fixup in &self.seek_fixups { if fixup.target_id == ebml::CUES && !have_cues { continue; @@ -1947,14 +1712,9 @@ impl<W: Write + Seek> MkvMuxer<W> { self.writer .write_all(&(self.max_block_ticks as f64).to_be_bytes())?; } else { - // The timeline never advanced past tick 0 (a degenerate - // single-frame recovery at t=0 with no per-frame duration): we - // can't derive a runtime, so DON'T leave a literal DURATION=0.0 - // (players read that as a zero-length/corrupt file). Void the - // whole 11-byte DURATION element (ID 2 + size 1 + 8-byte payload) - // so the Segment simply omits it, as an unknown-duration source - // did before the back-patch. `pos` is the payload start (+3 from - // the element start), so back up 3. + // Timeline never advanced past tick 0 (degenerate single-frame recovery): + // can't derive a runtime, so DON'T leave DURATION=0.0 (reads as corrupt). + // Void the whole 11-byte element instead; `pos` is payload start (+3), back up 3. self.writer.seek(std::io::SeekFrom::Start(pos - 3))?; ebml::write_id(&mut self.writer, ebml::VOID)?; ebml::write_size(&mut self.writer, 9)?; // 11 - 1 (Void id) - 1 (size) @@ -1962,12 +1722,9 @@ impl<W: Write + Seek> MkvMuxer<W> { } } - // Neutralise the unused CUES Seek entry. The entry is a fixed 21-byte - // Seek master: SEEK(2 ID + 1 size) + SEEK_ID(2+1) + 4-byte target id + - // SEEK_POSITION(2+1) + 8-byte value = 21 bytes. A Void (0xEC, 1-byte ID) - // with a 1-byte size VINT covering the remaining 19 bytes occupies - // exactly 1 + 1 + 19 = 21 bytes, overwriting the entry in place without - // shifting any following element. + // Neutralise the unused CUES Seek entry: it's a fixed 21-byte Seek master, and + // a 1-byte Void ID + 1-byte size VINT covering the remaining 19 bytes occupies + // exactly 21 bytes too, overwriting it in place without shifting later elements. if !have_cues && let Some(entry_pos) = self.cues_seek_entry_pos { self.writer.seek(std::io::SeekFrom::Start(entry_pos))?; ebml::write_id(&mut self.writer, ebml::VOID)?; @@ -2229,11 +1986,9 @@ mod tests { }; // ── BlockGroup path: every frame carries a duration (the MPEG-2 shape). let t = MkvTrack::video(&v); - // Write through SharedWriter and call finish(), so the buffer is a WELL-FORMED - // Matroska file whose cluster sizes are back-patched. The previous form took - // `muxer.writer.into_inner()` without finishing, leaving cluster sizes unwritten - // — which is why this test could only byte-scan for a ReferenceBlock instead of - // parsing the structure it actually cares about. + // Write through SharedWriter and call finish() so cluster sizes are back-patched + // (the previous form skipped finish(), leaving sizes unwritten, so this test could + // only byte-scan for a ReferenceBlock instead of parsing the real structure). use std::sync::{Arc, Mutex}; let shared = Arc::new(Mutex::new(Cursor::new(Vec::new()))); let mut muxer = MkvMuxer::new(SharedWriter(shared.clone()), &[t], None, 0.0, &[]).unwrap(); @@ -2251,11 +2006,9 @@ mod tests { muxer.finish().unwrap(); let data = shared.lock().unwrap().clone().into_inner(); - // Assert PER BLOCK, not "a ReferenceBlock exists somewhere". Keyframe-ness - // inside a BlockGroup is signalled by the ABSENCE of ReferenceBlock, so the - // I-frame must carry none and each non-keyframe must carry one pointing back - // at the keyframe. A mutant that emits one on every block satisfies a - // presence check while making the whole file read as non-keyframe. + // Assert PER BLOCK: keyframe-ness in a BlockGroup is signalled by ABSENCE of + // ReferenceBlock, so the I-frame must carry none and each non-keyframe exactly + // one. A blanket presence check would pass a mutant that emits one on every block. let groups = all_block_groups(&data); assert_eq!( groups.len(), @@ -2651,10 +2404,9 @@ mod tests { #[test] fn dts_variants_map_to_registered_a_dts_codec_id() { - // The Matroska codec-ID registry defines `A_DTS` for the whole DTS - // family (core, DTS-HD HRA, DTS-HD MA). The `/MA` and `/HR` suffixes - // are not registered and break strict parsers, so every DTS variant - // must emit plain `A_DTS`. + // `A_DTS` is the sole registered ID for the whole DTS family; the `/MA` and + // `/HR` suffixes aren't registered and break strict parsers, so every DTS + // variant must emit plain `A_DTS`. for codec in [Codec::Dts, Codec::DtsHdMa, Codec::DtsHdHr] { let track = MkvTrack::audio(&audio_stream(codec)); assert_eq!( @@ -2673,15 +2425,9 @@ mod tests { #[test] fn every_audio_codec_gets_its_own_registered_codec_id() { - // A CodecID names the payload. Falling through to A_AC3 for a codec the - // match had no arm for writes a track declaring AC-3 while carrying - // something else entirely — a player either refuses the track or decodes - // noise. Every audio codec this crate can produce must map to its own - // registered Matroska ID. - // - // Reachable: ifo.rs maps DVD audio_coding_mode 3 to Codec::Mp2, and - // mp4/read.rs maps the `mp4a` sample entry to Codec::Aac, so a DVD with - // MPEG audio and an mp4:// AAC source both hit this path. + // A CodecID names the payload: falling through to A_AC3 for an unmatched codec + // writes a track declaring AC-3 but carrying something else, which players refuse + // or decode as noise. Every producible codec must map to its own registered ID. for (codec, want) in [ (Codec::Ac3, "A_AC3"), (Codec::Ac3Plus, "A_EAC3"), @@ -2918,19 +2664,15 @@ mod tests { ); // Defensively confirm no Seek entry's SeekPosition resolves to the (now - // Tags/EOF) cues offset: scan all 8-byte SeekPosition values in the - // SeekHead and ensure none equals the offset where Cues would have been. - // (Sanity: the file must still parse its real elements.) + // Tags/EOF) cues offset, and that the file still parses its real elements. assert!( find_id(&data, ebml::INFO).is_some() && find_id(&data, ebml::TRACKS).is_some(), "Info and Tracks must still be present and seekable" ); - // Suppressing the CUES fixup must suppress ONLY that one. The remaining - // Seek entries still have to be back-patched to their real offsets, or - // the SeekHead ships with SeekPosition=0 for Info and Tracks — a - // player-visible index pointing at the Segment header instead of the - // elements it names. + // Suppressing the CUES fixup must suppress ONLY that one: the other Seek entries + // still need back-patching to real offsets, or the SeekHead ships SeekPosition=0 + // for Info/Tracks — an index pointing at the Segment header, not the elements. let (_, seg_start) = locate_segment(&data); let entries = parse_seekhead(&data); for target in [ebml::INFO, ebml::TRACKS] { @@ -2978,10 +2720,9 @@ mod tests { #[test] fn block_ts_exempts_video_from_monotonic_nudge() { - // VIDEO keeps its true PTS even when non-monotonic in storage order — a - // B-frame whose presentation PTS sits below the frame stored before it - // must NOT be nudged to prev+1ms (that clobbering is what produced the - // "non monotonically increasing dts" flood on decode). + // VIDEO keeps its true PTS even when non-monotonic in storage order: a B-frame + // whose PTS sits below the prior stored frame must NOT be nudged to prev+1ms + // (that clobbering produced the "non monotonically increasing dts" decode flood). assert_eq!( block_ts(true, Some(1040), 1000), 1000, @@ -3205,10 +2946,8 @@ mod tests { let kf_region = &data[pos_before_kf as usize..pos_after_kf as usize]; let nkf_region = &data[pos_after_kf as usize..pos_after_nkf as usize]; - // In a SimpleBlock, after ID + size + track_vint + 2-byte timestamp, - // the next byte is flags. Keyframe flag = 0x80, non-keyframe = 0x00. - // Find the flags byte in each region: it's the byte after the 2-byte timestamp. - // SimpleBlock ID is 0xA3. Find it and walk past ID + size + vint + ts. + // In a SimpleBlock (ID 0xA3), the flags byte follows ID + size + track_vint + + // 2-byte timestamp. Keyframe flag = 0x80, non-keyframe = 0x00. fn extract_flags(region: &[u8]) -> u8 { // Find 0xA3 (SimpleBlock ID) let sb_pos = region.iter().position(|&b| b == 0xA3).unwrap(); @@ -4174,12 +3913,9 @@ mod tests { #[test] fn keyframe_driven_clusters_start_on_video_keyframe() { - // The COMMON case: a keyframe-driven cluster (opened because a video - // keyframe crossed the cluster-duration boundary) must begin with the - // video keyframe. With a 1 s GOP and 3 s clusters, keyframe spacing keeps - // every keyframe-driven cluster well within the i16 block span, so no - // forced i16-split clusters appear here and EVERY cluster is - // keyframe-aligned. + // COMMON case: a keyframe-driven cluster (opened when a video keyframe crosses + // the cluster-duration boundary) must begin with that keyframe. With a 1s GOP and + // 3s clusters, no forced i16-split clusters appear, so EVERY cluster is aligned. let tracks = [make_video_track(), make_audio_track()]; let frames = frames_for(30.0, 1.0); let (data, _) = mux_to_bytes(&tracks, &[], &frames); @@ -4209,10 +3945,9 @@ mod tests { #[test] fn cue_count_equals_cluster_count() { - // Regression for the cluster-split Cue gap: EVERY cluster — whether - // opened by a video keyframe OR forced by the i16 block-relative split — - // must carry a Cue, so cluster count always equals cue count and the - // seek index has no multi-second holes. + // Regression for the cluster-split Cue gap: EVERY cluster (keyframe-opened OR + // forced by the i16 block-relative split) must carry a Cue, so cluster count + // equals cue count and the seek index has no multi-second holes. let tracks = [make_video_track(), make_audio_track()]; let frames = frames_for(30.0, 1.0); let (data, _) = mux_to_bytes(&tracks, &[], &frames); @@ -4235,18 +3970,9 @@ mod tests { #[test] fn cue_count_equals_cluster_count_blockgroup_vfr() { - // DVD (MPEG-2) seek-index regression. Unlike UHD/HEVC — which emits - // SimpleBlocks (`duration_ns = None`) — DVD video is VFR: every coded - // picture carries a per-frame `duration_ns = Some(..)`, so it is written - // as a BlockGroup, NOT a SimpleBlock. The Cues index must still get one - // cue per cluster on this path exactly like the SimpleBlock path; a DVD - // MKV with thousands of clusters and ZERO cues lets players chapter-seek - // but never scrub. The pre-existing cue tests all feed `None` (SimpleBlock - // only), leaving this BlockGroup path unguarded — this test covers it. - // - // Drives the REAL `Mpeg2Parser` end-to-end (decode-order frames, - // non-monotonic B-frame display PTS, telecine field durations) into the - // muxer, so it exercises the genuine DVD frame shape, not a hand-built one. + // DVD (MPEG-2) seek-index regression: VFR frames are BlockGroups (not the + // SimpleBlocks prior cue tests cover), which must still emit one cue per cluster + // or a DVD MKV scrubs nowhere. Driven by the REAL `Mpeg2Parser` end-to-end. use crate::mux::codec::CodecParser; use crate::mux::codec::mpeg2::Mpeg2Parser; use crate::mux::ts::PesPacket; @@ -4330,10 +4056,9 @@ mod tests { muxer.finish().unwrap(); let data = shared.lock().unwrap().clone().into_inner(); - // The muxer branches on `duration_ns`: Some → BlockGroup, None → - // SimpleBlock. Every frame above carries Some, so this output is wholly - // the BlockGroup path — confirmed structurally by walking the first - // cluster's children (a BlockGroup present, no SimpleBlock). + // The muxer branches on `duration_ns` (Some -> BlockGroup, None -> SimpleBlock). + // Every frame above carries Some, so confirm the BlockGroup path structurally by + // walking the first cluster's children (a BlockGroup present, no SimpleBlock). let clusters = find_clusters(&data); { let (c0_start, c0_size, _) = clusters[0]; @@ -4401,11 +4126,9 @@ mod tests { "cue position 0x{:X} did not resolve to a cluster", pos ); - // CueTrack is a Matroska TrackNumber (1-based), not the muxer's - // 0-based track index. Every cluster here is opened by the primary - // video track (index 0), so its cues must name track 1. A cue whose - // CueTrack is 0 names no track at all and players ignore the whole - // seek index. + // CueTrack is a 1-based Matroska TrackNumber, not the muxer's 0-based index. + // Clusters here are opened by video (index 0), so cues must name track 1; + // CueTrack=0 names no track and players ignore the whole seek index. assert_eq!( track, 1, "CueTrack must be the 1-based TrackNumber of the cluster-opening \ @@ -4442,14 +4165,9 @@ mod tests { #[test] fn opening_keyframe_with_nonzero_disc_pts_anchors_base_not_corrupted() { - // Opening-GOP PTS handling regression. A DVD title - // opens on an I-frame the disc stamps at its REAL timeline PTS (here - // ~10 s, a large non-zero value — NOT 0). The muxer must anchor `base` on - // that first kept keyframe so the first cluster's timestamp is 0 (the - // `.max(0)` floor must NOT corrupt it into a huge value or wrap), and the - // following frame must land exactly one frame interval (40 ms = 400 ticks - // at the 0.1 ms scale) later — proving the opening pictures keep their - // relative timeline and aren't garbled. + // Opening-GOP PTS regression: a DVD title opens on an I-frame at its REAL PTS + // (~10s, non-zero). The muxer must anchor `base` there so cluster 0 is timestamp 0 + // (the `.max(0)` floor must not wrap it) and the next frame lands one interval later. let tracks = [make_video_track()]; const OPEN_PTS: i64 = 10_000_000_000; // 10 s opening anchor let frames = vec![ @@ -4509,11 +4227,9 @@ mod tests { #[test] fn duration_placeholder_voided_when_timeline_never_advances() { - // Degenerate recovery: a source with no declared duration muxes exactly - // one keyframe at tick 0 with no per-frame duration, so max_block_ticks - // stays 0. The reserved DURATION placeholder must be VOIDED (element - // omitted) rather than left as a bogus 0.0 that players read as a - // zero-length file. + // Degenerate recovery: one keyframe at tick 0 with no duration leaves + // max_block_ticks at 0, so the reserved DURATION placeholder must be VOIDED + // (omitted) rather than left as a bogus 0.0 that players read as zero-length. let tracks = [make_video_track()]; let one_frame = vec![(0usize, 0i64, true, vec![0xAAu8; 16])]; let (data, _) = mux_to_bytes(&tracks, &[], &one_frame); @@ -4612,14 +4328,9 @@ mod tests { #[test] fn long_audio_gap_forces_cluster_no_i16_overflow() { - // Regression for the `(pts_ms - cluster_ts_ms) as i16` truncation: - // a single video keyframe at t=0 opens one cluster, then a long - // audio-only stretch (no further video keyframe) drifts well past - // i16::MAX ms (~32.767 s). Without the overflow guard the audio - // blocks past 32.767 s would write a wrapped (negative) relative - // timestamp into the SimpleBlock. With the guard a fresh cluster is - // forced so every relative_ts stays in range and reconstructs to the - // true absolute timestamp. + // Regression for `(pts_ms - cluster_ts_ms) as i16` truncation: one keyframe at + // t=0 opens a cluster, then an audio-only stretch drifts past i16::MAX ms + // (~32.767s); without the overflow guard blocks would write a wrapped relative ts. let tracks = [make_video_track(), make_audio_track()]; let mut frames: Vec<(usize, i64, bool, Vec<u8>)> = Vec::new(); // One video keyframe at t=0 (opens the first cluster). @@ -4664,10 +4375,9 @@ mod tests { "expected the i16 guard to force extra clusters, got {}", clusters.len() ); - // CLUSTER-SPLIT CUE REGRESSION: each i16-forced split cluster is NOT - // keyframe-aligned (the only video keyframe is at t=0), yet it MUST carry - // a Cue — otherwise a player seeking into the long audio stretch lands in - // a multi-second seek-index hole. Every cluster must have a matching cue. + // CLUSTER-SPLIT CUE REGRESSION: an i16-forced split cluster is NOT + // keyframe-aligned, yet MUST still carry a Cue or a player seeking into + // the long audio stretch lands in a multi-second seek-index hole. let cues = parse_cues(&data); assert_eq!( cues.len(), @@ -4683,12 +4393,9 @@ mod tests { max_cue > MAX_BLOCK_REL, "cue coverage must extend past the first i16 boundary, max cue {max_cue}" ); - // The cue a forced split emits must be as usable as a keyframe cue: its - // CueClusterPosition is a SEGMENT-RELATIVE offset that has to land on the - // cluster it describes, and its CueTrack has to be the 1-based - // TrackNumber of the frame that forced the split (audio = track 2 here). - // Neither was asserted, so a wrong sign on the offset or a 0 track number - // would have shipped as a seek index pointing at nothing. + // A forced-split cue must be as usable as a keyframe cue: CueClusterPosition is + // SEGMENT-RELATIVE and must land on its cluster, and CueTrack must be the + // 1-based TrackNumber that forced the split — else the seek index points at nothing. let (_, seg_start) = locate_segment(&data); let cluster_starts: Vec<usize> = clusters .iter() @@ -4716,10 +4423,9 @@ mod tests { "CueTrack must be a 1-based TrackNumber (1 = video, 2 = audio), got {track}" ); } - // At least one cue must belong to the AUDIO track: every cluster after the - // first is opened by an audio frame, and the cue records that frame's - // track. A `track_idx * 1` slip would report 0 (no such track) and a - // `track_idx - 1` slip would underflow or report the video track. + // At least one cue must belong to the AUDIO track: every cluster after the first + // is opened by an audio frame. A `track_idx * 1` slip would report 0 (no track) + // and a `track_idx - 1` slip would underflow or report the video track. assert!( cues.iter().any(|(_, track, _)| *track == 2), "the audio-forced splits must emit cues naming the audio TrackNumber (2)" @@ -4801,10 +4507,8 @@ mod tests { #[test] fn a_title_the_seam_plan_emptied_is_not_reported_as_a_skippable_stub() { let tracks = [make_video_track()]; - // TWO clips (SeamPlan::from_clips requires at least two, with strictly - // increasing IN marks), covering 100s..200s and 200s..300s in 45 kHz - // ticks. Every frame below sits far before the first IN, so the plan - // places none of them and drops them all. + // TWO clips (SeamPlan::from_clips requires >=2, strictly increasing IN marks); + // every frame below sits far before the first IN, so the plan drops them all. let clips = [ crate::disc::Clip { feed_span: None, @@ -4844,13 +4548,9 @@ mod tests { #[test] fn backjumped_audio_handled_by_i16_split_no_wrap() { - // A NON-VIDEO (audio) frame whose PTS back-jumps far below the open - // cluster does NOT drive an epoch (only video does — that is the rc3 - // fix), so it is not rebased forward. Instead the i16 block-relative - // guard catches the out-of-range negative offset and forces a fresh, - // Cue-carrying cluster floored at t=0 — no negative i16 relative, no - // wrapped `as u64` cluster timestamp. Build: video kf at 0, video kf at - // 40s, then audio at raw t=0 (a 40s back-jump). + // A NON-VIDEO frame back-jumping below the open cluster does NOT drive an + // epoch (only video does, per the rc3 fix); instead the i16 block-relative + // guard forces a fresh Cue-carrying cluster floored at t=0, avoiding wrap. let tracks = [make_video_track(), make_audio_track()]; let frames = vec![ (0usize, 0i64, true, vec![0x01; 16]), @@ -4885,11 +4585,9 @@ mod tests { #[test] fn negative_pts_audio_after_keyframe_does_not_wrap() { - // Stream order: video keyframe at 5s (anchors base=5000ms, opens cluster - // at ts 0), then an audio frame with raw PTS 4s — earlier than base. - // raw_ms - base = -1000ms (negative). It must be floored to 0 rather - // than wrapping the `as u64` cluster/cue write or overflowing the i16 - // relative cast. + // Video keyframe at 5s anchors base=5000ms; audio at raw 4s gives + // raw_ms - base = -1000ms, which must floor to 0 rather than wrapping the + // `as u64` cluster/cue write or overflowing the i16 relative cast. let tracks = [make_video_track(), make_audio_track()]; let frames_in_order = [ (0usize, 5_000_000_000i64, true, vec![0xBB; 16]), // video kf at 5s @@ -4932,12 +4630,9 @@ mod tests { assert_eq!(&b[..n], &[0x3F, 0xFF, 0xFF]); } - // ============================================================ - // SimpleBlock byte layout (Matroska §6.2.3): the element's declared - // size must equal track_vint_len + 2 (rel ts) + 1 (flags) + data, and - // the rel-ts is a signed 16-bit big-endian field. A wrong size desyncs - // every following element; a wrong ts byte order corrupts A/V sync. - // ============================================================ + // SimpleBlock byte layout (Matroska §6.2.3): declared size must equal + // track_vint_len + 2 (rel ts) + 1 (flags) + data, with rel-ts a signed 16-bit + // big-endian field. A wrong size desyncs elements; wrong byte order corrupts A/V sync. /// Locate the first SimpleBlock and return (declared_size, track_vint_len, /// rel_ts, flags, data_slice) by decoding its header inline. @@ -5020,12 +4715,9 @@ mod tests { ); } - // ============================================================ - // BlockGroup (Matroska §6.2.4): a Block inside a BlockGroup carries - // BlockDuration, and the Block's keyframe flag bit (0x80) MUST be 0 - // (keyframe-ness is signalled by absence of ReferenceBlock). PGS - // subtitle frames take this path. - // ============================================================ + // BlockGroup (Matroska §6.2.4): a Block inside a BlockGroup carries BlockDuration, + // and its keyframe flag bit (0x80) MUST be 0 (keyframe-ness is signalled by + // absence of ReferenceBlock). PGS subtitle frames take this path. /// One parsed BlockGroup. struct BlockGroupInfo { @@ -5064,10 +4756,9 @@ mod tests { assert_eq!(tid, ebml::CLUSTER_TIMESTAMP); cursor.seek(io::SeekFrom::Current(tsize as i64)).unwrap(); while (cursor.position() as usize) < body.len() { - // Stop at a tail that is not a whole element (padding, or a cluster - // body that runs to the end of the buffer) rather than unwrapping. - // Callers assert the expected BlockGroup COUNT, so a desync that - // truncated this walk would fail there rather than pass quietly. + // Stop at a non-whole-element tail (padding/buffer end) rather than + // unwrapping; callers assert the BlockGroup COUNT, so a desync here + // fails there instead of passing quietly. let Ok((id, size, _)) = ebml::read_element_header(&mut cursor) else { break; }; @@ -5176,12 +4867,9 @@ mod tests { ); } - // ============================================================ - // Cluster boundary (CLUSTER_DURATION_TICKS): a new cluster opens on a - // video keyframe once >= the cluster duration has elapsed since the open - // cluster's timestamp. A keyframe exactly at the boundary opens a new - // cluster; one just under stays in the current cluster. - // ============================================================ + // Cluster boundary (CLUSTER_DURATION_TICKS): a new cluster opens on a video + // keyframe once >= the cluster duration has elapsed since the open cluster's + // timestamp; one just under the boundary stays in the current cluster. #[test] fn keyframe_at_cluster_boundary_opens_new_cluster() { @@ -5220,11 +4908,8 @@ mod tests { ); } - // ============================================================ // monotonic_ts saturating add — at i64::MAX the +1 must saturate, not - // overflow-panic. (The strictly-monotonic invariant relies on - // saturating_add.) - // ============================================================ + // overflow-panic; the strictly-monotonic invariant relies on saturating_add. #[test] fn monotonic_ts_saturates_at_i64_max() { @@ -5235,19 +4920,13 @@ mod tests { assert_eq!(monotonic_ts(Some(10), 100), 100); } - // ============================================================ - // SeekHead encoding (Matroska §7.1): the muxer writes fixed-width - // entries — SeekID as a 4-byte binary element (size 0x84) and - // SeekPosition as an 8-byte uint (size 0x88) so they can be - // back-patched in place. Verify the declared SeekID matches the target - // element ID bytes. - // ============================================================ + // SeekHead encoding (Matroska §7.1): the muxer writes fixed-width entries — + // SeekID as a 4-byte binary element (0x84) and SeekPosition as an 8-byte + // uint (0x88) — so they can be back-patched in place. - // ============================================================ - // dolby_vision_config (dvcC / DOVIDecoderConfigurationRecord) bit - // packing. Byte 2: profile(7 bits) << 1 | level high bit. Byte 3: - // level low 5 bits << 3 | rpu | el | bl. Byte 4: bl_compat_id << 4. - // ============================================================ + // dolby_vision_config (dvcC) bit packing: byte 2 = profile(7 bits) << 1 | + // level high bit; byte 3 = level low 5 bits << 3 | rpu | el | bl; byte 4 = + // bl_compat_id << 4. #[test] fn dolby_vision_config_packs_level_and_compat_id() { @@ -5276,12 +4955,9 @@ mod tests { assert_eq!(c[3] >> 3, 0); } - // ============================================================ - // Full round-trip: mux frames → MKV bytes → MkvStream reader → frames. - // This is the strongest "never silently truncate" property: every - // written frame must be readable back with the same track, keyframe - // flag and data. - // ============================================================ + // Full round-trip: mux frames -> MKV bytes -> MkvStream reader -> frames. The + // strongest "never silently truncate" property: every written frame must be + // readable back with the same track, keyframe flag and data. #[test] fn muxed_frames_round_trip_through_reader() { @@ -5529,10 +5205,9 @@ mod tests { #[test] fn video_576i_field_order_undetermined_at_track_build() { - // Field order is a bitstream property the IFO/MPLS scan cannot know, so - // the track is built with FieldOrder=UNDETERMINED — never a scan-time - // guess. The mux stream sets the MEASURED value from the first coded - // picture before the header is written (mkvstream::apply_coding_to_track). + // Field order is a bitstream property the IFO/MPLS scan cannot know, so the + // track is built UNDETERMINED — never a scan-time guess; the mux stream sets + // the MEASURED value from the first coded picture (apply_coding_to_track). let v = VideoStream { pid: 0xE0, codec: Codec::Mpeg2, @@ -5556,15 +5231,9 @@ mod tests { #[test] fn interlaced_576i_omits_default_decoded_field_duration_keeps_full_frame_duration() { - // Interlaced-fps signalling regression. The Windows-fps fix: a 576i25 track must - // carry the FULL-FRAME DefaultDuration (40 ms → `1/DefaultDuration` = 25 - // 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 real-media evidence proved it did the - // 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. + // Windows-fps regression: a 576i25 track must carry FULL-FRAME DefaultDuration + // (40ms -> 25fps) and NOT emit DefaultDecodedFieldDuration. rc.5.1's 20ms field + // duration made Explorer report 12.5fps/VFR; a known-good rip omits it (25fps/CFR). let v = VideoStream { pid: 0xE0, codec: Codec::Mpeg2, @@ -5625,15 +5294,9 @@ mod tests { #[test] fn field_duration_when_set_is_direct_trackentry_child_not_in_video() { - // GUARDS the element nesting for the retained (now non-default) writer - // path: if a caller DOES set field_duration_ns > 0, - // DefaultDecodedFieldDuration (0x234E7A) must be emitted as a DIRECT child - // of TrackEntry — NOT nested inside the Video master (which only holds - // FlagInterlaced / FieldOrder), per the Matroska schema. The production - // video path passes 0 (so the element is suppressed — see - // interlaced_576i_omits_default_decoded_field_duration_keeps_full_frame_duration); - // this test builds a track with a non-zero field duration to exercise the - // writer guard and pin its (correct) nesting depth. + // GUARDS nesting for the retained (now non-default) writer path: if a caller + // sets field_duration_ns > 0, DefaultDecodedFieldDuration must be a DIRECT + // child of TrackEntry, NOT nested in Video, per the Matroska schema. let mut t = make_video_track(); t.interlaced = true; t.field_order = ebml::FIELD_ORDER_TFF; @@ -5705,11 +5368,9 @@ mod tests { #[test] fn pal_576i_emits_bt470bg_colour_codes() { - // GUARDS audit §2 colour-code gap: the dvd.rs tests assert at the stream - // layer (ColorSpace::Bt470bg); nothing asserted the actual CICP tuple - // emitted in the MKV. PAL SD must emit matrix/transfer/primaries = - // (5,5,5) with range=1 (BT.470BG). A swap with NTSC's (6,6,6) goes - // uncaught by the stream-layer tests alone. + // GUARDS audit §2 colour-code gap: dvd.rs only asserts at the stream layer + // (ColorSpace::Bt470bg), not the actual CICP tuple emitted in the MKV, so + // a swap with NTSC's (6,6,6) would go uncaught without this test. let v = VideoStream { pid: 0xE0, codec: Codec::Mpeg2, @@ -5826,12 +5487,9 @@ mod tests { #[test] fn ntsc_480i_duration_metadata_and_field_order_undetermined_at_build() { - // NTSC 480i duration metadata (Windows-fps fix) PLUS field-order honesty. - // Field order is a bitstream property the IFO/MPLS scan cannot know, so a + // NTSC 480i duration metadata (Windows-fps fix) PLUS field-order honesty: a // track built without a measured picture carries FieldOrder=UNDETERMINED - // and the element is OMITTED — never a hardcoded TFF guess. The MEASURED - // value is set by the mux stream from the first coded picture (see - // mkvstream::apply_coding_to_track and its dedicated test). + // (omitted), never a hardcoded TFF guess — see apply_coding_to_track. let v = VideoStream { pid: 0xE0, codec: Codec::Mpeg2, @@ -5936,11 +5594,9 @@ mod tests { #[test] fn finalize_emits_per_track_bps_tags() { - // At finalize a Tags master with a per-track BPS SimpleTag is written. - // BPS = bytes*8/duration_secs, using the duration the SOURCE declared — - // the muxed timeline is only consulted when the source gave none. Here - // the source declares 10 s while the frames span 2 s, so the two answers - // differ and the tag must carry the 10 s one. + // At finalize a Tags master carries per-track BPS = bytes*8/duration_secs, + // using the SOURCE-declared duration (muxed timeline only used if none given). + // Here the source declares 10s while frames span 2s, so the tag must use 10s. let tracks = [make_video_track(), make_audio_track()]; let shared = Arc::new(Mutex::new(Cursor::new(Vec::new()))); let writer = SharedWriter(shared.clone()); @@ -5979,18 +5635,9 @@ mod tests { #[test] fn bps_tags_use_the_backpatched_duration_when_the_source_declares_none() { - // An HD-DVD title declares no duration, so DURATION is reserved and - // back-patched at finish() from the muxed timeline — and the BPS tags are - // computed from that derived runtime. Every operator in the chain - // (max_block_ticks → seconds → bytes*8/seconds) was unasserted, so a - // wrong scale, a wrong sign on the final block's own duration, or a - // multiply where a divide belongs all shipped a plausible-looking file - // with a bitrate and runtime that are simply wrong. - // - // Ten 40 ms frames at 0..360 ms, each declaring a 40 ms duration: - // last block start = 360 ms = 3600 ticks (0.1 ms scale) - // its own duration = 40 ms = 400 ticks - // max_block_ticks = 4000 ticks = 0.4 s of muxed runtime + // No declared duration -> DURATION is back-patched from the muxed timeline at + // finish(), and BPS derives from that runtime (the max_block_ticks -> seconds -> + // bytes*8/seconds chain was previously unasserted). Ten 40ms frames -> 0.4s runtime. let tracks = [make_video_track()]; let shared = Arc::new(Mutex::new(Cursor::new(Vec::new()))); let writer = SharedWriter(shared.clone()); @@ -6144,10 +5791,9 @@ mod tests { #[test] fn mvc_frame_emits_blockgroup_additional_and_reference() { - // A track declaring an mvcC mapping: a keyframe base frame carrying a - // dependent AU emits BlockGroup > BlockAdditions > BlockMore - // {BlockAddID=2, BlockAdditional=dep}; a following non-keyframe adds a - // ReferenceBlock so it is not mistaken for a seek point. + // A track declaring an mvcC mapping: a keyframe with a dependent AU emits + // BlockGroup > BlockAdditions > BlockMore {BlockAddID=2, BlockAdditional=dep}; + // a following non-keyframe adds a ReferenceBlock so it's not mistaken for a seek point. let mut v = make_video_track(); v.mvc_params = Some(( vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22], @@ -6186,10 +5832,9 @@ mod tests { data.windows(2).any(|w| w == dep_p), "non-keyframe dependent AU present" ); - // Per-block, not a byte scan. ReferenceBlock's ID is the single byte 0xFB, so - // "one exists somewhere in the file" says nothing about WHICH block carries it: - // a mutant emitting one on every BlockGroup — the keyframe included — passed - // the old presence check while making every frame read back as a non-keyframe. + // Per-block, not a byte scan: "one exists somewhere in the file" says nothing + // about WHICH block carries ReferenceBlock — a mutant emitting it on every + // BlockGroup (keyframe included) passed the old presence-only check. let groups = all_block_groups(&data); assert_eq!( groups.len(), @@ -6200,20 +5845,17 @@ mod tests { groups[0].reference, None, "the MVC keyframe must carry NO ReferenceBlock — that absence IS the keyframe signal" ); - // The MVC BlockGroup writer takes the track number on its own call path, - // separate from every other block writer. It has to be the same 1-based - // TrackNumber the TrackEntry declared: a base-view block filed under - // track 0 (or 2, on a single-track file) is a 3D title whose left eye - // silently vanishes. + // The MVC BlockGroup writer takes the track number on its own call path; it + // must be the same 1-based TrackNumber the TrackEntry declared, or a base-view + // block filed under the wrong track is a 3D title whose left eye silently vanishes. assert!( groups.iter().all(|g| g.track == 1), "every MVC base-view Block belongs to TrackNumber 1, got {:?}", groups.iter().map(|g| g.track).collect::<Vec<_>>() ); - // Assert the OFFSET, not just presence — the sibling non-MVC BlockGroup - // test pins the exact value, and a mutant emitting a constant or a - // wrong-signed offset would pass a presence-only check. The keyframe sits - // at tick 0, so the offset is the negated block-relative timestamp. + // Assert the OFFSET, not just presence — a mutant emitting a constant or + // wrong-signed offset would pass a presence-only check; the keyframe sits at + // tick 0, so the offset is the negated block-relative timestamp. let off = groups[1] .reference .expect("the non-keyframe MVC base frame must carry a ReferenceBlock"); @@ -6338,17 +5980,9 @@ mod tests { ); } - // ---- 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. + // CodecPrivate emission (avcC/hvcC/VC-1/MPEG-2): `MkvTrack::video` builds with + // `codec_private: None`, filled in later by the PES pipeline. Verify the muxer + // emits it as a DIRECT TrackEntry child (never nested in Video), byte-for-byte. /// Build a representative video track for `codec` whose codecPrivate is set /// to `cp` (mirroring what the mux pipeline supplies up-front). @@ -6381,10 +6015,9 @@ mod tests { #[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." + // A distinctive payload per codec so a mix-up (wrong track/truncation) is + // caught. 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 @@ -6481,13 +6114,9 @@ mod tests { ); } - // ---- 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. + // DefaultDuration ns by frame rate (emitted bytes): existing tests only pin + // 25/29.97 fps. This fills the remaining rates, asserting the value read back + // from the muxer (not just the track field) to catch serializer regressions too. /// Read the DefaultDuration value (ns) emitted for a single-video-track mux. fn emitted_default_duration_ns(frame_rate: crate::disc::FrameRate) -> u64 { @@ -6797,10 +6426,8 @@ mod tests { #[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. + // Expected ns/frame = 1e9 * den / num for each (num, den) fraction, e.g. + // 23.976 = 24000/1001 -> 41_708_333, 29.97 = 30000/1001 -> 33_366_666. let cases = [ (FrameRate::F23_976, 41_708_333u64), (FrameRate::F24, 41_666_666), diff --git a/src/mux/mkvstream.rs b/src/mux/mkvstream.rs index 1534673f..1bc47463 100644 --- a/src/mux/mkvstream.rs +++ b/src/mux/mkvstream.rs @@ -29,13 +29,8 @@ fn skip_bytes(r: &mut impl Read, n: u64) -> io::Result<()> { } // ── Sanity caps for untrusted EBML element sizes ────────────── -// -// Sizes come straight from the EBML stream (file or network) and are -// otherwise cast to `usize` and used to allocate/read. An adversarial -// or corrupt container can claim a multi-GB element and trigger an OOM -// allocation, or claim an integer element wider than 8 bytes and panic -// the fixed 8-byte reader. Every untrusted size is validated against -// one of these caps before allocation. +// Sizes are cast to `usize` for alloc/read; a corrupt container can claim a multi-GB +// element (OOM) or one wider than 8 bytes (panic) — every size is checked against a cap. /// Largest accepted SIMPLE_BLOCK payload. A block is a small vint track /// header + 2-byte rel-ts + 1-byte flags + one frame of elementary data. @@ -234,11 +229,9 @@ impl MvcMerge { { pb.additional = Some(frame.data.clone()); } else { - // Bound the orphan map BEFORE inserting: if dependents pile up - // unpaired (pairing badly drifted), drop the drifted buffer so it - // stays bounded — but keep THIS just-arrived dependent, whose base - // frame commonly arrives next. Clearing after the insert would - // discard it and overcount orphans by one. + // Bound the orphan map BEFORE inserting: drop a badly-drifted buffer + // but keep THIS just-arrived dependent, whose base frame commonly + // arrives next — clearing after insert would discard it too. if self.dep_by_pts.len() >= MVC_PAIR_WINDOW * 4 { self.orphan_deps += self.dep_by_pts.len() as u64; self.dep_by_pts.clear(); @@ -373,18 +366,16 @@ impl MkvStream { title: &DiscTitle, output_path: Option<&std::path::Path>, ) -> io::Result<Self> { - // Blu-ray 3D (MVC): a dependent (right-eye) view stream is NOT emitted as - // its own track — it is folded into the base track as per-frame - // BlockAdditional. Detect it so we skip building a track for it and set up - // the merge. `base_stream_idx` is the first video stream. + // Blu-ray 3D (MVC): a dependent (right-eye) view is NOT its own track — it's + // folded into the base track as per-frame BlockAdditional. Detect it here so + // we skip building a track for it and set up the merge. let dep_stream_idx = title .streams .iter() .position(|s| matches!(s, crate::disc::Stream::Video(v) if v.is_mvc_dependent())); - // The base is the first NON-dependent video. Excluding the dependent here - // means a (malformed / hand-built) title whose only video IS the dependent - // yields `base_stream_idx == None` → no merge (the dependent is muxed as an - // ordinary track) instead of `base == dep` and a panic on the skipped slot. + // Base is the first NON-dependent video; excluding the dependent means a + // malformed title whose only video IS the dependent yields `None` (muxed + // as an ordinary track) instead of `base == dep` and a panic on the skipped slot. let base_stream_idx = title .streams .iter() @@ -446,10 +437,9 @@ impl MkvStream { _ => None, }; - // Defer muxer construction (and the TrackEntry dump) until the first - // coded picture arrives, so the primary video track's FieldOrder is set - // from the parser's MEASURED value before the header is written — never - // a guess. The dump moves to activation so it reflects the final track. + // Defer muxer construction until the first coded picture arrives, so the + // primary video track's FieldOrder is set from the parser's MEASURED value + // before the header is written — never a guess. let video_track = tracks.iter().position(|t| t.track_type == 1); Ok(Self { @@ -488,11 +478,9 @@ impl MkvStream { if let Some(vt) = pending.video_track { apply_coding_to_track(&mut pending.tracks[vt], coding, video_picture_seen); } - // Blu-ray 3D: set the base video track's `mvc_params` from the dependent - // view's captured subset-SPS/PPS BEFORE the header is written, so the - // TrackEntry carries the `mvcC` BlockAdditionMapping. Captured from the - // first dependent AU (which arrives right after the first base AU in the - // SSIF), so it is available by the time the first base frame activates. + // Blu-ray 3D: set base track's `mvc_params` from the dependent view's captured + // subset-SPS/PPS BEFORE the header is written (so TrackEntry carries `mvcC`); + // captured from the first dependent AU, which arrives right after the base AU. if let Some(mvc) = &self.mvc { if let Some(params) = &mvc.captured_params { if let Some(t) = pending.tracks.get_mut(mvc.base_track_idx) { @@ -523,13 +511,9 @@ impl MkvStream { muxer.set_opening_capture(crate::diag::OpeningCapture::new(path, pending.tracks.len())); } for (f, additional) in pending.buffered.drain(..) { - // Provenance must survive the replay. These frames were buffered - // before the muxer existed, but each still carries the byte offset - // it was read from — dropping it here sent them down the timestamp - // heuristic instead, the one path the rest of this change set - // exists to stop relying on. They sit at the head of the title, so - // clip 0 is usually the right guess and the damage was bounded; - // "usually right" is exactly what provenance replaced. + // Provenance must survive the replay: these pre-muxer frames still carry + // the byte offset they were read from — dropping it here would fall back + // to the timestamp heuristic this change set exists to stop relying on. muxer.write_frame_at( f.track, f.pts, @@ -632,10 +616,9 @@ fn apply_coding_to_track( coding: Option<crate::mux::codec::PictureInfo>, video_picture_seen: bool, ) { - // HDR10 static metadata measured from the bitstream (HEVC SEI). Applied for - // ANY track type that carries it (independent of interlace): the first coded - // picture's PictureInfo holds it once both HDR10 SEI messages were seen. - // `None` (SDR / no-SEI) leaves the track's `hdr10` untouched → omitted. + // HDR10 static metadata measured from the bitstream (HEVC SEI), applied for any + // track type once both HDR10 SEI messages were seen. `None` (SDR/no-SEI) leaves + // the track's `hdr10` untouched -> omitted. if let Some(h) = coding.and_then(|c| c.hdr10()) { track.hdr10 = Some(h); } @@ -691,10 +674,9 @@ impl crate::pes::Stream for MkvStream { loop { let (id, size, _) = match ebml::read_element_header(&mut rs.reader) { Ok(h) => h, - // Only a genuine premature/clean EOF ends the stream. Any other - // error (disc read failure, corrupt sector, network drop) must - // propagate, or a mid-mux I/O failure would silently truncate - // the output with no error signal. + // Only a genuine premature/clean EOF ends the stream; any other error + // must propagate, or a mid-mux I/O failure (disc/sector/network) + // would silently truncate the output with no error signal. Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None), Err(e) => return Err(e), }; @@ -703,10 +685,9 @@ impl crate::pes::Stream for MkvStream { ebml::CLUSTER => continue, ebml::CLUSTER_TIMESTAMP => { let raw = read_uint_bounded(&mut rs.reader, size)?; - // The cluster timestamp is an untrusted u64; a value above - // i64::MAX would cast to a large negative i64 and poison - // every block PTS in the cluster. Reject it, mirroring the - // EBML-size guard in parse_mkv_header. + // Untrusted u64: a value above i64::MAX would cast to a large + // negative i64 and poison every block PTS in the cluster; reject + // it, mirroring the EBML-size guard in parse_mkv_header. if raw > i64::MAX as u64 { return Err(crate::error::Error::MkvSourceInvalid.into()); } @@ -730,35 +711,26 @@ impl crate::pes::Stream for MkvStream { continue; } ebml::BLOCK_GROUP => { - // MkvMuxer emits a BlockGroup (BLOCK + BLOCK_DURATION) for - // every frame carrying a duration — i.e. all AC3 audio and - // PGS subtitle frames. Descend into the group, read the - // inner BLOCK (0xA1) and BLOCK_DURATION (0x9B), and yield a - // frame so a round-trip through this muxer does not silently - // drop those tracks. A non-u64::MAX size bounds the children. + // MkvMuxer emits a BlockGroup (BLOCK + BLOCK_DURATION) for every + // frame with a duration (AC3 audio, PGS subtitles); descend and + // read both children so a round-trip doesn't silently drop them. if size == u64::MAX { return Err(crate::error::Error::MkvSourceInvalid.into()); } let mut remaining = size; let mut block: Option<Vec<u8>> = None; let mut duration_ms: Option<u64> = None; - // Keyframe-ness of a BlockGroup is carried ONLY by the - // presence/absence of a ReferenceBlock child: inside a - // BlockGroup the SimpleBlock 0x80 keyframe bit is reserved - // and the writer always emits it as 0. Reading that bit - // (as this arm used to) makes EVERY BlockGroup frame look - // like a non-keyframe — which silently broke every re-mux - // of MPEG-2 video, whose parser stamps a per-frame duration - // so all of its frames take the BlockGroup path. + // Keyframe-ness of a BlockGroup is carried ONLY by ReferenceBlock's + // presence — SimpleBlock's 0x80 bit is reserved (always 0) here, + // so reading it broke every MPEG-2 frame (always this path). let mut has_reference = false; while remaining > 0 { let (cid, cs, hlen) = ebml::read_element_header(&mut rs.reader)?; if cs == u64::MAX { return Err(crate::error::Error::MkvSourceInvalid.into()); } - // A child whose header + body exceeds the bytes left in - // the BlockGroup is malformed — reject it rather than - // saturating `remaining` to 0 and reading past the group. + // A child whose header+body exceeds bytes left in the group + // is malformed — reject rather than saturating `remaining` to 0. let consumed = (hlen as u64).saturating_add(cs); if consumed > remaining { return Err(crate::error::Error::MkvSourceInvalid.into()); @@ -782,28 +754,9 @@ impl crate::pes::Stream for MkvStream { skip_bytes(&mut rs.reader, cs)?; } ebml::BLOCK_ADDITIONS => { - // A `BlockAdditions > BlockMore > BlockAdditional` - // subtree is a per-frame SIDE payload. For a - // Blu-ray 3D title written by this crate it is the - // MVC dependent-view (right-eye) access unit, - // BlockAddID=2, described by the track's `mvcC` - // BlockAdditionMapping. - // - // `PesFrame` has no side-payload field and the - // read-side stream table (built by - // `parse_mkv_header`, which does not parse - // BlockAdditionMapping) has no dependent-view - // track to hand it to, so this reader CANNOT - // reconstruct it — re-muxing a 3D MKV yields a - // base-view-only (2D) file. Reconstruction needs - // header-parse plumbing well beyond this arm. - // - // What it must NOT be is silent: this used to fall - // into the `_` skip arm below, so an - // `mkv://` → `mkv://` re-mux of a 3D rip dropped - // one whole eye with no error, no warning and - // `lost_bytes == 0`. Account for it so the loss - // reaches `MuxOutcome.lost_bytes` / `errors`. + // Carries the MVC dependent-view AU for 3D titles; `PesFrame` + // has no side-payload field so it can't be reconstructed, and a + // 3D re-mux silently became 2D. Must NOT be silent: account it. if rs.additions_dropped == 0 { tracing::warn!( target: "mux", @@ -823,10 +776,9 @@ impl crate::pes::Stream for MkvStream { } } if let Some(block) = block { - // BLOCK_DURATION is expressed in TimestampScale ticks, - // not milliseconds. Scale by the segment's ts_scale_ns - // (1_000_000 for freemkv's own 1 ms scale; non-default - // in foreign MKVs) — same scaling PTS uses. + // BLOCK_DURATION is TimestampScale ticks, not ms — scale by + // ts_scale_ns (1_000_000 for our own 1ms scale, non-default + // in foreign MKVs), same scaling PTS uses. let dur_ns = duration_ms.map(|ticks| ticks.saturating_mul(rs.ts_scale_ns as u64)); let frames = parse_block( @@ -871,10 +823,9 @@ impl crate::pes::Stream for MkvStream { if self.mvc.is_none() { return self.emit(frame, None); } - // Blu-ray 3D: run the frame through the MVC merge, which remaps track - // indices, folds the dependent view into the base as BlockAdditional - // (paired by PTS), and yields 0+ frames ready to emit. `ingest` returns - // owned pairs so the `self.mvc` borrow is released before `emit`. + // Blu-ray 3D: fold the dependent view into the base as BlockAdditional + // (paired by PTS) and yield 0+ frames; `ingest` returns owned pairs so the + // `self.mvc` borrow is released before `emit`. let emits = self.mvc.as_mut().unwrap().ingest(frame); for (f, additional) in emits { self.emit(&f, additional.as_deref())?; @@ -920,10 +871,9 @@ impl crate::pes::Stream for MkvStream { fn codec_private(&self, track: usize) -> Option<Vec<u8>> { if let Mode::Read(ref rs) = self.mode { - // `track` is a stream index; `codec_privates` is keyed by Matroska - // TrackNumber. Those are NOT the same space (RFC 9559 §5.1.4.1.1 - // requires only a non-zero TrackNumber), so translate through the - // real map instead of assuming `track + 1`. + // `track` is a stream index but `codec_privates` is keyed by Matroska + // TrackNumber (RFC 9559 §5.1.4.1.1 requires only non-zero) — translate + // through the real map instead of assuming `track + 1`. let track_num = rs.tracks.num_of(track)?; rs.codec_privates .iter() @@ -1192,9 +1142,8 @@ fn parse_track(r: &mut impl Read, size: u64) -> io::Result<ParsedTrack> { arem = arem.saturating_sub(ahlen as u64 + as_); match aid { ebml::SAMPLING_FREQUENCY => sr = ebml::read_float_val(r, as_ as usize)?, - // Clamp instead of `as u8`: a foreign/corrupt MKV with a - // CHANNELS value that is a multiple of 256 would truncate to - // 0 (an invalid channel count) on a bare cast. Saturate to + // Clamp instead of `as u8`: a CHANNELS value that's a multiple of + // 256 would truncate to 0 (invalid) on a bare cast; saturate to // u8::MAX so an absurd count degrades to "many", never to 0. ebml::CHANNELS => ch = read_uint_bounded(r, as_)?.min(u8::MAX as u64) as u8, _ => { @@ -1252,21 +1201,15 @@ fn parse_track(r: &mut impl Read, size: u64) -> io::Result<ParsedTrack> { } else if sr >= 48000.0 { SampleRate::S48 } else { - // Anything below the lowest rate this enum maps is UNKNOWN, not 48 kHz. - // The ladder's final `else` used to be S48, so a legal 32000 Hz AC-3 or - // DTS track — common in broadcast-sourced content — was recorded as - // 48 kHz, and the wrong rate then propagated into the reconstructed - // AudioStream. `SampleRate::from_hz` in disc/mod.rs is the crate's - // canonical mapping and returns Unknown here; this ladder exists only - // because the MKV element is a float and needs tolerance rather than - // exact equality. + // Below the lowest mapped rate is UNKNOWN, not 48kHz: the ladder's final + // `else` used to be S48, so a legal 32kHz AC-3/DTS track was misrecorded as + // 48kHz. This float ladder exists only for tolerance vs. `SampleRate::from_hz`. SampleRate::Unknown }; - // Map MKV track numbers to BD-TS PIDs. A 13-bit TS PID tops out at - // 0x1FFF; compute in u32 so the `0x1100 + (tnum - 2)` arithmetic can't - // wrap u16 for large track numbers, and reject anything that would land - // outside the valid PID space. + // Map MKV track numbers to BD-TS PIDs, computed in u32 so `0x1100 + (tnum - 2)` + // can't wrap u16 for large track numbers (a 13-bit PID tops out at 0x1FFF); + // reject anything landing outside the valid PID space. let ts_pid = ts_pid_for_track(tnum)?; let stream = match ttype { @@ -1441,13 +1384,9 @@ pub(crate) fn split_lacing(lacing: u8, body: &[u8]) -> Option<Vec<&[u8]>> { return None; } let each = rest.len() / n; - // A zero-size frame carries no data and cannot be a valid frame, so - // `each == 0` is malformed rather than "n empty frames". It has to be - // rejected explicitly: 0 % n == 0 passes the divisibility check above, - // and `chunks` on an empty slice yields nothing whatever its argument, - // so clamping the chunk width instead returned Some(vec![]) — zero - // frames where the Lacing Head declared n, dropping the whole lace - // silently with no error raised. + // `each == 0` is malformed, not "n empty frames" — 0 % n == 0 passes the + // divisibility check above, and `chunks` on an empty slice yields nothing, + // so unrejected this silently dropped the whole lace with no error. if each == 0 { return None; } @@ -1547,9 +1486,8 @@ fn parse_block( let keyframe = flags & 0x80 != 0; let body = &block[vl + 3..]; // saturating_add: a hostile CLUSTER_TIMESTAMP near i64::MAX plus a positive - // rel_ts would overflow this add (panic in debug/test, wrap to a large - // negative PTS in release) — one operation BEFORE the saturating_mul below. - // rel_ts as i64 is exact, so this fully bounds the sum on adversarial input. + // rel_ts would overflow (panic in debug, wrap in release) — done before the + // saturating_mul below so the sum is fully bounded on adversarial input. let pts_ticks = cluster_ts_ticks.saturating_add(rel_ts as i64); // saturating_mul: a hostile CLUSTER_TIMESTAMP could push pts_ticks near // i64::MAX, where ticks→ns would overflow and panic in debug builds. @@ -1585,20 +1523,15 @@ fn parse_block( boundaries are unknowable, so the block is rejected rather than passed \ downstream as one mangled frame" ); - // Its own code, not the generic `MkvSourceInvalid`: a laced Block names a - // specific RFC 9559 §10.3 feature whose header is self-inconsistent, which - // is a distinct diagnosis from "the container is corrupt somewhere". - // Neither is `MkvInvalid` — `error::is_skippable_title_stub` classifies - // that code as an empty nav/menu stub, so a real track with unseparable - // frames would be dropped by the caller while the run reported success. + // Its own code, not `MkvSourceInvalid` (generic corruption) or `MkvInvalid` + // (`error::is_skippable_title_stub` treats that as an empty nav/menu stub, + // which would drop a real unseparable track while reporting success). return Err(crate::error::Error::MkvLacingInvalid.into()); }; - // RFC 9559 §10.3.5: a Block carries a single timestamp, which applies to the - // FIRST frame of the lace; every later frame has an "underdetermined" - // timestamp but MUST be contiguous with its predecessor. Recover the spacing - // from the track's DefaultDuration (§5.1.4.1.13, already nanoseconds) when - // declared, else by dividing this Block's BlockDuration across the lace. + // RFC 9559 §10.3.5: a Block's timestamp applies to the FIRST laced frame only; + // later frames are "underdetermined" but contiguous. Recover spacing from the + // track's DefaultDuration when declared, else BlockDuration divided across the lace. let count = laced.len().max(1) as u64; let per_frame_ns = tracks .default_durations @@ -2233,10 +2166,8 @@ mod tests { #[test] fn simple_block_oversized_size_is_rejected() { - // Cluster containing a SIMPLE_BLOCK that claims a 2 GiB payload. - // The reader must reject it (MkvSourceInvalid) rather than attempt a - // multi-GB allocation. Header parse stops at CLUSTER, so the - // SIMPLE_BLOCK is hit on the first read(). + // Cluster with a SIMPLE_BLOCK claiming a 2 GiB payload: must be rejected + // (MkvSourceInvalid), not trigger a multi-GB allocation. let mut cluster = Vec::new(); ebml::write_id(&mut cluster, ebml::CLUSTER).unwrap(); ebml::write_unknown_size(&mut cluster).unwrap(); @@ -2293,10 +2224,8 @@ mod tests { } #[test] fn truncated_simple_block_body_errors_not_panics() { - // A SIMPLE_BLOCK that declares a 64-byte payload but supplies none. - // read_exact_bounded must surface a clean typed MkvSourceInvalid error - // (a truncated declared element is malformed input), never panic, - // and never allocate the full declared size up front. + // A SIMPLE_BLOCK declaring a 64-byte payload but supplying none must surface + // a clean typed MkvSourceInvalid, never panic, never allocate the full size. let mut cluster = Vec::new(); ebml::write_id(&mut cluster, ebml::CLUSTER).unwrap(); ebml::write_unknown_size(&mut cluster).unwrap(); @@ -2373,19 +2302,9 @@ mod tests { #[test] fn block_group_frame_round_trips_with_duration() { - // MkvMuxer emits AC3/PGS frames — and every MPEG-2 video frame — as a - // BlockGroup (BLOCK + BLOCK_DURATION [+ REFERENCE_BLOCK]). The reader - // must descend into the group and yield the frame (with its duration) - // rather than skipping it — otherwise every such frame this muxer writes - // is lost on read-back. - // - // Keyframe-ness: inside a BlockGroup the SimpleBlock 0x80 bit is - // RESERVED (always 0 here); a Block with NO ReferenceBlock child is a - // keyframe. This group has none, so the frame is a keyframe — which is - // also the truth for the AC-3/PGS frames this path was written for - // (they are self-contained). The `!keyframe` this test used to assert - // came from reading the reserved bit; see - // `reference_block_marks_block_group_frame_as_non_keyframe`. + // MkvMuxer emits AC3/PGS and every MPEG-2 frame as a BlockGroup; the reader + // must descend and yield it, not skip it. Inside a BlockGroup the SimpleBlock + // 0x80 bit is RESERVED (always 0), so keyframe-ness is ReferenceBlock's absence. let block = [0x82u8, 0x00, 0x05, 0x00, 0x11, 0x22, 0x33]; // track 2, rel 5, reserved bit 0, 3 data let mut bg_body = Vec::new(); ebml::write_id(&mut bg_body, ebml::BLOCK).unwrap(); @@ -2622,16 +2541,14 @@ mod tests { let e = open_err(MkvStream::open(Cursor::new(bytes))); assert!(is_mkv_source_invalid(&e)); // 65537 is the case the guard really exists for: it truncates onto the - // PERFECTLY VALID TrackNumber 1, so an over-width number that is merely - // "not 65536" would be accepted and its blocks would be routed to another - // track's stream. Reject the whole class, not one value. + // PERFECTLY VALID TrackNumber 1, so a check for merely "not 65536" would + // route its blocks to another track's stream. Reject the whole class. let bytes = mkv_with_track_and_cluster(65537, 1, &[]); let e = open_err(MkvStream::open(Cursor::new(bytes))); assert!(is_mkv_source_invalid(&e)); - // 65535 fits u16 exactly. It is still rejected — but by the PID guard, - // not the width guard, because 0x1100 + 65533 is far outside the 13-bit - // TS PID space. Pinning WHICH boundary each guard owns keeps a - // widened-by-one width check from silently becoming the load-bearing one. + // 65535 fits u16 exactly; it's rejected by the PID guard, not the width + // guard (0x1100 + 65533 is outside the 13-bit TS PID space). Pinning WHICH + // boundary each guard owns keeps a widened width check from becoming load-bearing. assert!(ts_pid_for_track(65535).is_err()); assert_eq!( TrackTable { @@ -2658,10 +2575,8 @@ mod tests { #[test] fn unknown_size_inner_child_in_tracks_is_rejected() { - // A TRACK_ENTRY child declaring EBML unknown size (cs == u64::MAX) must - // be rejected, not used in `hlen + cs` (which would overflow → debug - // panic). Hand-build a TRACK_ENTRY whose first child carries the - // unknown-size marker. + // A TRACK_ENTRY child declaring EBML unknown size (cs == u64::MAX) must be + // rejected, not used in `hlen + cs` (overflow -> debug panic). let mut entry = Vec::new(); ebml::write_id(&mut entry, ebml::TRACK_NUMBER).unwrap(); ebml::write_unknown_size(&mut entry).unwrap(); // child size = unknown @@ -2773,10 +2688,7 @@ mod tests { } // ============================================================ - // block_vint — the (Simple)Block track-number VINT. Matroska §6.2: - // the leading-1 bit position selects the width (1-4 bytes here), and - // the value occupies the remaining bits. A width-selection bug would - // mis-attribute every block to the wrong track. + // block_vint — Block track-number VINT (§6.2); a width bug mis-attributes blocks. // ============================================================ #[test] @@ -2810,10 +2722,7 @@ mod tests { } // ============================================================ - // parse_block — turns a (Simple)Block payload into PesFrames. - // Layout: [track VINT][rel_ts i16 BE][flags u8][data...]. - // Guards: len<4 → none; vl+3 > len → none; track 0 → none; - // unknown TrackNumber → none. + // parse_block — Block payload into PesFrames; guards len<4, track 0, unknown track. // ============================================================ /// `parse_block` for an UNLACED block on a file whose TrackNumbers are @@ -2922,11 +2831,9 @@ mod tests { #[test] fn parse_block_cluster_ts_plus_rel_ts_saturates_no_overflow() { - // Regression: a hostile CLUSTER_TIMESTAMP near i64::MAX plus a POSITIVE - // rel_ts overflows the `cluster_ts + rel_ts` ADD — one step before the - // saturating_mul. With a plain `+` this panics in debug/test (overflow - // checks on) and silently wraps to a large negative PTS in release. - // rel_ts = +0x7FFF = 32767 (max positive signed 16-bit). + // Regression: CLUSTER_TIMESTAMP near i64::MAX plus a POSITIVE rel_ts overflows + // `cluster_ts + rel_ts`; a plain `+` panics in debug and wraps negative in + // release. rel_ts = +0x7FFF = 32767 (max positive signed 16-bit). let block = [0x81u8, 0x7F, 0xFF, 0x80, 0xAA]; let f = parse_block_one(&block, i64::MAX, 1_000_000, 1, None).unwrap(); // The add saturates at i64::MAX, then the mul saturates too. @@ -2937,17 +2844,10 @@ mod tests { ); } - // ============================================================ - // ts_pid_for_track — mid-range mapping (the existing test covers the - // edges; this fills in a representative middle value to lock the - // 0x1100 + (tnum-2) formula). - // ============================================================ + // ts_pid_for_track — mid-range mapping locking the 0x1100 + (tnum-2) formula. - // ============================================================ - // CLUSTER_TIMESTAMP overflow guard — a value above i64::MAX would cast - // to a large negative i64 and poison every block PTS in the cluster. - // The reader must reject it. - // ============================================================ + // CLUSTER_TIMESTAMP overflow guard: a value above i64::MAX would cast to a large + // negative i64 and poison every block PTS in the cluster; the reader must reject it. #[test] fn cluster_timestamp_above_i64_max_is_rejected() { @@ -2964,10 +2864,9 @@ mod tests { let e = stream.read().unwrap_err(); assert!(is_mkv_source_invalid(&e)); - // `i64::MAX` itself is the last representable value and must be ACCEPTED - // — the guard is against a u64 that would go NEGATIVE on the cast, not - // against a large timestamp. Rejecting it too turns the boundary into an - // off-by-one that drops a legal cluster. + // `i64::MAX` itself is the last representable value and must be ACCEPTED — + // the guard is against a u64 that goes NEGATIVE on the cast, not against a + // large timestamp; rejecting it too would drop a legal cluster. let mut cluster = Vec::new(); ebml::write_id(&mut cluster, ebml::CLUSTER).unwrap(); ebml::write_unknown_size(&mut cluster).unwrap(); @@ -2991,20 +2890,15 @@ mod tests { ); } - // ============================================================ - // A malformed mkv:// SOURCE must never be classified as a skippable - // title stub. The read path used to raise `Error::MkvInvalid` for every - // malformed-input rejection, and `error::is_skippable_title_stub` reports - // that code as an empty nav/menu stub — so an all-titles rip silently - // passed over a corrupt input and exited reporting success. - // ============================================================ + // A malformed mkv:// SOURCE must never be classified as a skippable title stub: + // raising `Error::MkvInvalid` here made `is_skippable_title_stub` treat it as an + // empty nav/menu stub, so an all-titles rip silently passed over corrupt input. #[test] fn corrupt_source_is_not_classified_as_a_skippable_title_stub() { - // Same corrupt fixture as above (CLUSTER_TIMESTAMP > i64::MAX) driven - // through the real reader, asserted against the public classifier. - // Mutation: raising `Error::MkvInvalid` instead of - // `Error::MkvSourceInvalid` at that guard turns this red. + // Same corrupt fixture as above, driven through the real reader and asserted + // against the public classifier. Mutation: raising `MkvInvalid` instead of + // `MkvSourceInvalid` at that guard turns this red. let mut cluster = Vec::new(); ebml::write_id(&mut cluster, ebml::CLUSTER).unwrap(); ebml::write_unknown_size(&mut cluster).unwrap(); @@ -3032,11 +2926,8 @@ mod tests { ); } - // ============================================================ - // parse_mkv_header — TimestampScale threading and clamping. The frame - // PTS path multiplies by ts_scale_ns; a zero or absurd scale must - // clamp to the 1ms default rather than zero out / overflow PTS. - // ============================================================ + // parse_mkv_header — TimestampScale threading/clamping: PTS multiplies by + // ts_scale_ns, so a zero or absurd scale must clamp to the 1ms default. #[test] fn zero_timestamp_scale_clamps_to_default() { @@ -3276,10 +3167,7 @@ mod tests { ); } - // ============================================================ - // Block LACING (RFC 9559 §10.3) and TrackNumber→stream routing - // (RFC 9559 §5.1.4.1.1). - // ============================================================ + // Block LACING (RFC 9559 §10.3) and TrackNumber->stream routing (§5.1.4.1.1). /// One TrackEntry description for `mkv_with_tracks_and_cluster`: /// (TrackNumber, TrackType, DefaultDuration ns, CodecPrivate). @@ -4406,10 +4294,9 @@ mod tests { let mut s = MkvStream::open(Cursor::new(build(u64::MAX))).unwrap(); let f = s.read().unwrap().expect("one frame"); assert_eq!(f.pts, 5 * 1_000_000, "clamped to the 1 ms default scale"); - // `i64::MAX` is the last value that still fits a positive i64, so it is - // taken as the scale rather than clamped, and the tick→ns multiply - // saturates instead of overflowing. Absurd, but bounded and defined — the - // clamp is against values that would go NEGATIVE, not against large ones. + // `i64::MAX` still fits a positive i64, so it's taken as the scale (the + // multiply saturates rather than overflows) — the clamp is against values + // that go NEGATIVE, not against large ones. let mut s = MkvStream::open(Cursor::new(build(i64::MAX as u64))).unwrap(); let f = s.read().unwrap().expect("one frame"); assert_eq!( @@ -4481,10 +4368,8 @@ mod tests { .unwrap() .is_empty() ); - // The same boundary with a WIDER track VINT: a 2-octet track number - // makes the shortest legal block five bytes. Every other block test uses - // the 1-octet form, where the header-length check happens to agree with - // itself however it is computed. + // Same boundary with a WIDER track VINT: a 2-octet track number makes the + // shortest legal block five bytes, unlike every other test's 1-octet form. let five = [0x40u8, 0x01, 0x00, 0x00, 0x80]; let frames = parse_block(&five, 0, 1_000_000, &tracks, None).unwrap(); assert_eq!( diff --git a/src/mux/mod.rs b/src/mux/mod.rs index 630f9a14..be70e1ad 100644 --- a/src/mux/mod.rs +++ b/src/mux/mod.rs @@ -30,17 +30,9 @@ pub mod pipelined_stream; pub mod resolve; pub mod select; -// Internal-only modules. Every reference is via `crate::mux::…` / -// `super::…` from inside the crate; nothing in the downstream crates or -// integration tests imports them and lib.rs re-exports nothing from -// them, so they are not part of the stable public API. -// -// `#[allow(dead_code)]`: narrowing these from `pub` to `pub(crate)` -// surfaces a handful of helpers/accessors that were only ever reachable -// as (unused) public API — e.g. the MPEG-2 resolution/frame-rate -// accessors and an alternate `DemuxThread` spawn path. They are kept as -// part of the parser/demux surface and covered by unit tests; allow the -// dead-code lint rather than delete still-relevant scaffolding. +// Internal-only modules (referenced only via `crate::mux::…`; not public API). +// `#[allow(dead_code)]`: narrowing `pub`→`pub(crate)` surfaces helpers only +// reachable as unused public API — kept as tested scaffolding, not deleted. pub(crate) mod au_assembly; #[allow(dead_code)] pub(crate) mod codec; @@ -48,12 +40,9 @@ pub(crate) mod demux_sink; #[allow(dead_code)] pub(crate) mod demux_thread; -// Internal modules — implementation details. Their *types* are re-exported -// where appropriate (`MkvStream`, `M2tsStream`, etc. surface from `lib.rs`), -// but the module paths themselves are not part of the API. Pre-0.13 these -// were `pub`, leaking low-level EBML primitives, TS muxer internals, and -// network/stdio implementations that no external caller had business -// reaching for. +// Internal modules — implementation details. Their *types* are re-exported where +// appropriate (`MkvStream`/`M2tsStream` from `lib.rs`), but the paths are not API. +// Pre-0.13 these were `pub`, leaking EBML/TS/network/stdio internals. pub(crate) mod ebml; /// `fvi://` sink — freemkv's native per-picture video index (see /// `docs/FVI_FORMAT.md`). A write-only PES sink that emits one JSON-Lines record @@ -66,24 +55,9 @@ pub(crate) mod m2ts; pub mod meta; pub(crate) mod meta_sink; -// ── Sequential-sink muxers ────────────────────────────────────────────────── -// -// Container muxers that consume PES frames and write to a -// `SequentialSink`. They are NOT the bidirectional `MkvStream` / -// `M2tsStream` (which round-trip via the `Stream` trait + BD-TS -// framing); these are write-only and sequential. -// -// `pub(crate)`: these have no external callers and are not re-exported -// from lib.rs. `fmp4` is an explicit STUB (`Fmp4Mux::write_video` -// accumulates and discards) — shipping it as `pub` would lock a -// half-built type into the v1.0 stability contract via the -// `libfreemkv::mux::fmp4::Fmp4Mux` path. `m2ts_mux` is the plain -// MPEG-TS sequential muxer and `hevc` is its Annex-B helper; both are -// staged scaffolding for the sink split and are not yet wired into a -// live pipeline (the production paths use `tsmux` / `mkv`). -// `#[allow(dead_code)]`: retained intentionally until the sink split -// lands; they are exercised by their own unit tests. If any becomes a -// public muxer, re-export its concrete type from lib.rs instead. +// ── Sequential-sink muxers ── write-only PES → `SequentialSink`. `pub(crate)`+ +// `allow(dead_code)`: `fmp4` is a STUB (pub would lock a half-built type into +// v1.0); `m2ts_mux`/`hevc` are sink-split scaffolding (production: `tsmux`/`mkv`). #[allow(dead_code)] pub(crate) mod fmp4; #[allow(dead_code)] @@ -112,22 +86,17 @@ pub(crate) mod tsmux; #[allow(dead_code)] pub(crate) mod videomap; -// `demux://` and `fvi://` sinks are constructed internally by `output()` via the -// direct `super::demux_sink::` / `super::fvi_sink::` paths — no re-export needed, -// and no consumer names these types, so they are not public API. -// -// The provenance types ARE public: `output()` takes a `SourceInfo` so a `fvi://` -// destination can record the INPUT it was built from (`docs/FVI_FORMAT.md` §6.2) -// rather than the file it is writing. +// `demux://`/`fvi://` sinks are built internally by `output()`; not public API. +// The provenance types ARE public: `output()` takes a `SourceInfo` so an `fvi://` +// destination records the INPUT it was built from (§6.2), not the file written. pub use disc::DiscStream; pub use driver::{MuxEvents, MuxInput, MuxOptions, MuxOutcome, mux_stream}; pub use m2ts::M2tsStream; pub use mkvstream::MkvStream; pub use videomap::{Medium, SourceInfo}; -// `Mp4Sink` is public (like `MkvStream` / `M2tsStream`) so a caller that drives -// the sink directly can ask `final_report()` what the finished file actually -// contains — the pre-mux `mp4_fit_report` plan is a prediction, and two of its -// inclusions can still be dropped at `finish()`. +// `Mp4Sink` is public so a caller driving the sink can ask `final_report()` what +// the finished file contains — the pre-mux `mp4_fit_report` is only a prediction, +// and two of its inclusions can still be dropped at `finish()`. pub use mp4::{Mp4FitReport, Mp4Sink, Mp4SkipReason, fit_report as mp4_fit_report}; pub use network::NetworkStream; pub use null::NullStream; @@ -155,10 +124,9 @@ mod tests { use super::resolve::{StreamUrl, parse_url}; use std::path::PathBuf; - // The scheme table is the public contract documented at the top of - // resolve.rs: `scheme://path`. These tests pin the round-trip - // (parse_url → scheme()/path_str()) against that table, not against - // whatever the parser happens to emit. + // The scheme table is the public contract documented at the top of resolve.rs + // (`scheme://path`). These tests pin the round-trip (parse_url → + // scheme()/path_str()) against that table, not whatever the parser emits. #[test] fn scheme_names_match_the_documented_table() { diff --git a/src/mux/mp4/audio.rs b/src/mux/mp4/audio.rs index 63909d91..c93c9cfe 100644 --- a/src/mux/mp4/audio.rs +++ b/src/mux/mp4/audio.rs @@ -241,8 +241,8 @@ pub(super) fn dec3_box(c: &DolbyConfig) -> Vec<u8> { bx(b"dec3", &[b[3], b[4], b[5], b[6], b[7]]) } -/// Build an audio sample entry (`ac-3` / `ec-3`) with the given config box. -/// `AudioSampleEntry` per ISO/IEC 14496-12 §12.2.3. +/// Build an audio sample entry (`ac-3` / `ec-3` / `dtsc` / `dtsh`) with the +/// given config box. `AudioSampleEntry` per ISO/IEC 14496-12 §12.2.3. pub(super) fn audio_sample_entry( fourcc: &[u8; 4], channels: u16, @@ -314,17 +314,15 @@ fn parse_dts(frame: &[u8]) -> Option<DtsConfig> { let lfe = lff == 1 || lff == 2; let sample_rate = DTS_SFREQ[sfreq]; - // AMODE is a 6-bit field, so 16..=63 are reachable — they are RESERVED in - // ETSI TS 102 114 and describe a layout this code cannot name. Refuse rather - // than guess: the old `unwrap_or(6)` invented a channel count that the speaker - // mask could not match, which is the self-contradiction this box must not have. + // AMODE is a 6-bit field, so 16..=63 are reachable but RESERVED (ETSI TS 102 114) — + // no channel count or speaker mask is known for them. Refuse rather than guess: the + // old `unwrap_or(6)` invented a count the speaker mask could not match. let base_ch = *DTS_AMODE_CH.get(amode)?; 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. 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). + // DTS-HD extension substream sync (0x64582025) after the core frame. Search only + // at/after the core end (core_size = fsize+1): scanning the whole frame would + // false-positive inside the compressed core payload, mislabeling dtsc as dtsh. let ext_sync = [0x64, 0x58, 0x20, 0x25]; // 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. @@ -399,12 +397,9 @@ fn dts_channel_layout(amode: usize, lfe: bool) -> u16 { /// Describes the DTS core; whole access units (core + any extension) are passed /// through as samples, so a DTS-HD-aware decoder still finds the extension. fn ddts_box(c: &DtsConfig) -> Vec<u8> { - // avg/max bitrate: computed from the core frame size × frame rate (the core - // RATE field reads "open/variable" for lossless, so it's not usable directly). - // Rate is NOT integral in general (e.g. 48000 / 512 = 93.75 frames/s for a - // 512-sample core), so dividing first truncates and under-declares the - // bitrate. Multiply before dividing to keep the full precision, rounding to - // nearest so the declared value is not systematically low. + // avg/max bitrate: derived from core frame size × frame rate (core RATE reads + // "open/variable" for lossless, so unusable directly). Rate isn't integral + // (e.g. 93.75 frames/s), so multiply before dividing, rounding to nearest. let bitrate = if c.frame_samples > 0 { let num = c.core_size as u64 * 8 * c.sample_rate as u64; let den = c.frame_samples as u64; @@ -418,10 +413,9 @@ fn ddts_box(c: &DtsConfig) -> Vec<u8> { out.extend_from_slice(&bitrate.to_be_bytes()); // maxBitrate out.extend_from_slice(&bitrate.to_be_bytes()); // avgBitrate out.push(if c.has_extension { 24 } else { 16 }); // pcmSampleDepth - // Bit-packed tail (56 bits): - // FrameDuration2 StreamConstruction5 CoreLFEPresent1 CoreLayout6 CoreSize14 - // StereoDownmix1 RepresentationType3 ChannelLayout16 MultiAssetFlag1 - // LBRDurationMod1 ReservedBoxPresent1 Reserved5 + // Bit-packed tail (56 bits): FrameDuration2 StreamConstruction5 CoreLFEPresent1 + // CoreLayout6 CoreSize14 StereoDownmix1 RepresentationType3 ChannelLayout16 + // MultiAssetFlag1 LBRDurationMod1 ReservedBoxPresent1 Reserved5 let frame_duration = match c.frame_samples { 0..=512 => 0, 513..=1024 => 1, @@ -437,23 +431,16 @@ fn ddts_box(c: &DtsConfig) -> Vec<u8> { push(stream_construction, 5); push(c.lfe as u128, 1); push(c.amode as u128, 6); - // CoreSize is 14 bits, but core_size = FSIZE + 1 and FSIZE is itself 14 bits, - // so a maximum-size core is 16384 — one past what the field can hold, and the - // `& ((1<<14)-1)` mask in `push` would wrap it to 0. Clamp: declaring 16383 is - // one byte short, declaring 0 tells a decoder the core is empty. + // CoreSize is 14 bits; core_size = FSIZE+1 (FSIZE itself 14 bits) maxes at + // 16384 — one past the field, which `push`'s mask would wrap to 0. Clamp to + // 16383 (one byte short) rather than 0 (decoder thinks the core is empty). push((c.core_size as u128).min((1u128 << 14) - 1), 14); push(0, 1); // StereoDownmix push(0, 3); // RepresentationType push(c.channel_layout as u128, 16); - // MultiAssetFlag signals more than one audio ASSET in the substream — a parser - // that reads it goes on to select between asset descriptors. `has_extension` - // is a different thing entirely: a DTS-HD MA / HRA track is ONE asset whose - // extension substream carries the XLL/XBR component, so deriving the flag from - // it sent a parser looking for a second asset that does not exist (while - // StreamConstruction, pinned to the core-only value below, simultaneously said - // there was no extension at all — the box contradicting itself). This module - // parses the core header only and never reads the EXSS asset table, so the - // single-asset declaration is the only one it can honestly make. + // MultiAssetFlag signals a second audio ASSET — distinct from `has_extension` + // (a DTS-HD MA/HRA extension substream is still ONE asset). Deriving the flag + // from has_extension sent parsers hunting a nonexistent asset; 0 is the only honest value. push(0, 1); // MultiAssetFlag push(0, 1); // LBRDurationMod push(0, 1); // ReservedBoxPresent @@ -480,18 +467,9 @@ pub(super) fn dolby_sample_entry(codec: Codec, first_frame: &[u8]) -> Option<Vec } Codec::Ac3Plus => { let c = parse_dolby(first_frame)?; - // The config box must describe the syncframe that was actually found, - // not the codec the playlist claimed. When the first syncframe carries - // a legacy AC-3 bsid (≤ 10 — a Dolby Digital compatibility substream - // ahead of the Annex-E substreams, or a misdetected stream), - // `parse_dolby` took the AC-3 path: it has no nominal data rate, so an - // EC3SpecificBox built from it declares data_rate = 0 with an AC-3 - // bit_stream_identification — and ETSI TS 102 366 Annex F.6.1 defines - // data_rate as the bitstream's rate in kbit/s, which 0 is not. Annex F.4 - // assigns the AC3SampleEntry (`ac-3`) + AC3SpecificBox to an AC-3 - // bitstream, so emit that: it describes the parsed frame exactly, and - // whole access units still pass through, so a DD+ decoder finds any - // Annex-E substreams in the samples themselves. + // Describe the syncframe actually found, not the codec the playlist claimed: + // a legacy AC-3 bsid (≤10) has no data rate, so an EC3SpecificBox from it + // would wrongly declare data_rate=0 (ETSI TS 102 366 F.6.1) — emit ac-3/dac3 instead. let (fourcc, config): (&[u8; 4], Vec<u8>) = if c.bsid >= EAC3_MIN_BSID { (b"ec-3", dec3_box(&c)) } else { @@ -536,10 +514,9 @@ mod tests { /// A synthetic legacy AC-3 header: syncword, crc, fscod=0 (48k), /// frmsizecod, bsid=8, bsmod=0, acmod=7 (3/2), lfeon=1 → 5.1. - // The underscores in these literals mark BITFIELD boundaries in the - // bitstream header being built (e.g. a 5-bit field then a 3-bit field), - // not thousands-style digit groups. Regrouping them uniformly would - // satisfy the lint by destroying the only thing they encode. + // Underscores here mark BITFIELD boundaries in the header being built (e.g. a + // 5-bit field then a 3-bit field), not thousands-style digit groups — regrouping + // them uniformly would satisfy the lint by destroying the only thing they encode. #[allow(clippy::unusual_byte_groupings)] fn ac3_frame_5_1() -> Vec<u8> { let mut f = vec![0x0B, 0x77, 0x00, 0x00]; @@ -575,10 +552,9 @@ mod tests { /// A synthetic Annex-E (E-AC-3) syncframe: bsid=16, fscod=0 (48 kHz), /// numblkscod=3 (6 blocks), acmod=7 (3/2), lfeon=1 → 5.1, frmsiz=63 (128 B). - // The underscores in these literals mark BITFIELD boundaries in the - // bitstream header being built (e.g. a 5-bit field then a 3-bit field), - // not thousands-style digit groups. Regrouping them uniformly would - // satisfy the lint by destroying the only thing they encode. + // Underscores here mark BITFIELD boundaries in the header being built (e.g. a + // 5-bit field then a 3-bit field), not thousands-style digit groups — regrouping + // them uniformly would satisfy the lint by destroying the only thing they encode. #[allow(clippy::unusual_byte_groupings)] fn eac3_frame_5_1() -> Vec<u8> { // E-AC-3: syncword | strmtyp/substreamid/frmsiz | fscod/numblks/acmod/lfeon | bsid @@ -619,15 +595,9 @@ mod tests { #[test] fn ac3plus_with_a_legacy_syncframe_is_declared_ac3_not_a_zero_rate_ec3() { - // A Codec::Ac3Plus track whose first syncframe is a legacy AC-3 frame - // (bsid 8 < 11) is parsed by `parse_ac3`, which has no nominal data rate. - // ETSI TS 102 366 Annex F.6.1 requires the EC3SpecificBox `data_rate` to - // state the bitstream's rate in kbit/s and `bit_stream_identification` to - // be the substream's bsid, so an `ec-3` entry built from that config - // declared 0 kbps for an AC-3 bsid — a decoder configured from it sees a - // zero-bitrate stream. Annex F.4 assigns the AC3SampleEntry ('ac-3') + - // AC3SpecificBox to an AC-3 bitstream: emit the box that matches the - // syncframe that was actually parsed. + // A Codec::Ac3Plus track whose first syncframe is legacy AC-3 (bsid 8 < 11) + // has no nominal data rate; an `ec-3` entry built from it would declare + // 0 kbps, violating ETSI TS 102 366 F.6.1 — emit ac-3/dac3 (Annex F.4) instead. let e = dolby_sample_entry(Codec::Ac3Plus, &ac3_frame_5_1()).expect("entry built"); assert_eq!( &e[4..8], @@ -735,15 +705,9 @@ mod tests { #[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. - // fsize=0x3FFF makes core_size far larger than the frame, so the search - // region starts past the end and the embedded pattern is never tested. - // - // The frame is otherwise spec-legal: f[7]=0xF2/f[8]=0x74 give AMODE 9 (5.1) - // and SFREQ 13 (48 kHz). An earlier fixture put the ext-sync at f[4..8], - // which made f[7]=0x25 → AMODE 20, a RESERVED value `parse_dts` now - // refuses; the pattern's position is what this test is about, not AMODE. + // A 4-byte ext-sync pattern INSIDE the compressed core payload must NOT be + // read as a DTS-HD extension → stays dtsc. fsize=0x3FFF makes core_size far + // larger than the frame, so the search starts past the end and never tests it. let f = vec![ 0x7F, 0xFE, 0x80, 0x01, 0x00, 0x07, 0xFF, 0xF2, 0x74, 0x00, 0x00, 0x00, 0x64, 0x58, 0x20, 0x25, @@ -770,15 +734,9 @@ mod tests { #[test] fn dts_high_amode_channel_counts_are_declared() { - // The 16-entry DTS_AMODE_CH must declare the true core channel count for the - // spec-legal high AMODEs that now pass the decodability gate: AMODE 13→7, - // 14→8, 15→8 (ETSI TS 102 114 §5.3.1). The old 10-entry table - // fell through `unwrap_or(6)` → every one of these was declared as 6. - // - // Frame layout (mirrors dts_core_5_1_and_ddts): SFREQ=13 (48k), LFF=0 (no - // LFE) so `channels` is the bare base count. AMODE is split across - // f[7] low nibble (amode>>2) and f[8] top 2 bits (amode&3). - // f[8] = (amode&3)<<6 | 13<<2 = ... (keeps SFREQ=13) + // DTS_AMODE_CH must declare the true count for AMODE 13→7, 14→8, 15→8 (ETSI + // TS 102 114 §5.3.1) — the old 10-entry table fell through `unwrap_or(6)`. + // Frame layout mirrors dts_core_5_1_and_ddts; AMODE splits f[7]<<2 | f[8]>>6. let frame = |f7: u8, f8: u8| { vec![ 0x7F, 0xFE, 0x80, 0x01, 0x00, 0x05, 0xF2, f7, f8, 0x00, 0x00, 0x00, @@ -815,12 +773,9 @@ mod tests { channel_layout: dts_channel_layout(9, true), }; let b = ddts_box(&c); - // Decode CoreSize back out of the emitted box rather than restating the - // clamp. Layout: 8-byte box header, then DTSSamplingFrequency(4) + - // maxBitrate(4) + avgBitrate(4) + pcmSampleDepth(1) = 13 bytes, then the - // 56-bit packed tail. Within that tail CoreSize sits after - // FrameDuration(2) + StreamConstruction(5) + CoreLFEPresent(1) + - // CoreLayout(6) = 14 bits, so it occupies bits 14..28. + // Decode CoreSize back out of the emitted box: 8-byte header + 13 bytes + // (freq/bitrates/depth) precede the 56-bit tail, where CoreSize sits after + // FrameDuration(2)+StreamConstruction(5)+CoreLFEPresent(1)+CoreLayout(6) = bits 14..28. let tail = &b[8 + 13..]; assert!(tail.len() >= 4, "packed tail present"); let word = u32::from_be_bytes([tail[0], tail[1], tail[2], tail[3]]); @@ -834,25 +789,18 @@ mod tests { #[test] fn ddts_multi_asset_flag_is_clear_for_a_single_asset_with_an_extension() { - // MultiAssetFlag in the ETSI TS 102 114 DTSSpecificBox signals more than - // one audio ASSET in the substream. A DTS-HD MA track is ONE asset whose - // extension substream carries the XLL component, so deriving the flag - // from "an EXSS sync follows the core" told a parser to go select a - // second asset descriptor that does not exist. This module never parses - // the EXSS asset table, so the only declaration it can make is 0. + // MultiAssetFlag signals more than one audio ASSET; a DTS-HD MA track's XLL + // extension substream is still ONE asset, so deriving the flag from "EXSS + // follows the core" told a parser to select a nonexistent second asset — 0 is honest. let f = vec![ 0x7F, 0xFE, 0x80, 0x01, 0x00, 0x00, 0x00, 0x80, 0x00, 0x64, 0x58, 0x20, 0x25, ]; let c = parse_dts(&f).expect("parses"); assert!(c.has_extension, "fixture must exercise the extension path"); - // Decode the flag back out of the emitted box: 8-byte box header, then - // DTSSamplingFrequency(4) + maxBitrate(4) + avgBitrate(4) + - // pcmSampleDepth(1) = 13 bytes, then the 56-bit packed tail. Within the - // tail MultiAssetFlag is bit 48 — after FrameDuration(2) + - // StreamConstruction(5) + CoreLFEPresent(1) + CoreLayout(6) + - // CoreSize(14) + StereoDownmix(1) + RepresentationType(3) + - // ChannelLayout(16) — i.e. the MSB of the tail's 7th byte. + // Decode the flag from the emitted box: 8-byte header + 13 bytes precede the + // 56-bit tail, where MultiAssetFlag is bit 48 — after FrameDuration+ + // StreamConstruction+CoreLFEPresent+CoreLayout+CoreSize+StereoDownmix+RepType+ChannelLayout — MSB of byte 7. let b = ddts_box(&c); let tail = &b[8 + 13..]; assert_eq!(tail.len(), 7, "56-bit packed tail"); @@ -864,18 +812,14 @@ mod tests { } #[test] - // The loop variable is the DOMAIN VALUE being checked (a DTS AMODE value), not a - // cursor into a collection: it is what the assertion message names and - // what the table is keyed by. `.iter().enumerate()` would rename the - // thing under test to `i` and read worse. + // The loop variable is the DOMAIN VALUE being checked (a DTS AMODE), not a cursor + // into a collection — it's what the table is keyed by and the assertion names. + // `.iter().enumerate()` would rename the thing under test to `i` and read worse. #[allow(clippy::needless_range_loop)] fn ddts_channel_layout_speaker_count_matches_declared_channels() { - // The `ddts` box carries BOTH a channel count and a 16-bit speaker mask, - // and a decoder may trust either. They must agree for all 16 AMODEs. - // - // Speakers per ChannelLayout bit (ETSI TS 102 114 / DTS-in-ISOBMFF): the - // paired bits contribute 2, the single bits 1. LFE (bit3) is excluded here - // because DTS_AMODE_CH is the base count and `parse_dts` adds LFE on top. + // The `ddts` box carries BOTH a channel count and a 16-bit speaker mask; a + // decoder may trust either, so they must agree for all 16 AMODEs. Paired + // mask bits (ETSI TS 102 114) contribute 2 speakers, single bits 1; LFE (bit3) is added separately. const SPEAKERS_PER_BIT: [u8; 16] = [ 1, // bit0 C 2, // bit1 L/R @@ -923,13 +867,9 @@ mod tests { #[test] fn dts_reserved_amode_is_rejected_not_guessed() { - // AMODE is a 6-bit field, so a malformed or future stream can carry 16..=63. - // Those are RESERVED: neither the channel count nor the speaker mask is - // known, and the old code guessed 6 channels while the mask described far - // fewer. Refusing to parse is the only answer that cannot contradict itself. - // - // amode = (f[7] & 0x0F) << 2 | (f[8] >> 6), so f[7] low nibble 0b0100 (=4) - // gives amode 16..19 depending on f[8]'s top bits. SFREQ stays 13 (48 kHz). + // AMODE (6 bits) 16..=63 are RESERVED — no channel count/mask is known; the + // old code guessed 6 channels. amode = (f[7]&0x0F)<<2 | (f[8]>>6); f[7] low + // nibble 0b0100 gives amode 16..19 depending on f[8]'s top bits (SFREQ stays 13). let frame = |f7: u8, f8: u8| { vec![ 0x7F, 0xFE, 0x80, 0x01, 0x00, 0x05, 0xF2, f7, f8, 0x00, 0x00, 0x00, @@ -946,16 +886,9 @@ mod tests { assert_eq!(c.amode, 15); } - // ── (E-)AC-3 bit-field extraction ──────────────────────────────────────── - // - // The fixtures below give every BSI field a DIFFERENT value, which the - // channel-count fixtures above deliberately do not: `ac3_frame_5_1` has - // fscod=0, bsmod=0 and lfeon=1, so a mask that drops bits, a shift in the - // wrong direction, or a field read from its neighbour's lane all still - // produce 48 kHz / 6 channels. Field values here are pairwise distinct and - // are read back out of the EMITTED dac3/dec3 payload, so the assertions - // pin the arithmetic in ETSI TS 102 366 §5.3 / Annex E, not just a derived - // channel count. + // ── (E-)AC-3 bit-field extraction ────────────────────────────────────── + // Fixtures give every BSI field a DISTINCT value (unlike `ac3_frame_5_1`, which + // hides a dropped bit/wrong-lane read), read back from the EMITTED payload. /// dac3 payload (3 bytes) out of an emitted AC3SpecificBox. fn dac3_payload(b: &[u8]) -> [u8; 3] { @@ -1000,22 +933,18 @@ mod tests { #[test] fn dac3_payload_packs_each_field_at_its_spec_offset() { - // ETSI TS 102 366 Annex F.4, AC3SpecificBox = 24 bits: - // fscod(2) bsid(5) bsmod(3) acmod(3) lfeon(1) bit_rate_code(5) rsvd(5) - // 01 01000 101 110 1 10010 00000 - // = 01010001 01110110 01000000 = 51 76 40 - // Nothing else in this module asserts the dac3 BODY, so the packing - // shift/mask was free to change while every channel count still held. + // ETSI TS 102 366 Annex F.4, AC3SpecificBox = 24 bits: fscod(2) bsid(5) bsmod(3) + // acmod(3) lfeon(1) bit_rate_code(5) rsvd(5) = 01 01000 101 110 1 10010 00000 + // = 51 76 40. Nothing else in this module asserts the dac3 BODY, so this pins it. let c = parse_dolby(&ac3_frame_distinct()).expect("parsed"); assert_eq!(dac3_payload(&dac3_box(&c)), [0x51, 0x76, 0x40]); } #[test] fn dac3_truncates_an_oversized_field_instead_of_bleeding_into_its_neighbour() { - // Every field `parse_ac3` produces is already masked to its own width, - // so this states the packer's own contract: `push` writes `bits` bits - // and no more. A mask one bit too wide lets bsmod=8 set the bit that - // belongs to bsid, silently renumbering the bit stream identification. + // Every field from `parse_ac3` is already masked to its width; this pins + // `push`'s own contract of writing exactly `bits` bits — a mask one bit too + // wide lets bsmod=8 set bsid's bit, silently renumbering the bsid. let cfg = |bsmod, bit_rate_code| DolbyConfig { fscod: 1, bsid: 8, @@ -1038,15 +967,9 @@ mod tests { #[test] fn ac3_optional_mix_level_fields_are_skipped_exactly_when_present() { - // A/52 §5.4.2: cmixlev is present iff (acmod & 0x1) && acmod != 0x1 - // (a centre channel, but not centre-only); surmixlev iff acmod & 0x4 - // (surround present). Each wrongly skipped or wrongly kept 2-bit field - // moves `lfeon` two bits, so the LFE — and the channel count — is read - // out of the wrong lane. Both fixtures place lfeon=1 at its correct - // offset with 0 bits on either side of it. - // - // acmod=3 (3/0): centre, no surround → cmixlev present, surmixlev not. - // byte6 = 011 | cmixlev 01 | lfeon 1 | pad 00 = 0x6C + // A/52 §5.4.2: cmixlev present iff (acmod&0x1) && acmod!=0x1; surmixlev iff + // acmod&0x4. A wrongly skipped/kept 2-bit field shifts `lfeon`, reading LFE + // and channel count from the wrong lane. acmod=3: byte6=011|cmixlev01|lfeon1|00=0x6C. let c = parse_dolby(&[0x0B, 0x77, 0x00, 0x00, 0x00, 0x40, 0x6C, 0x00]).expect("parsed"); assert_eq!(c.acmod, 3); assert!(c.lfeon, "acmod 3 has no surmixlev to skip"); @@ -1064,10 +987,9 @@ mod tests { #[test] fn dolby_syncword_needs_both_bytes() { - // 0x0B alone is not a syncframe. Matching on either byte anchors the - // parse two bytes early, and every BSI field is then read from the - // wrong offset — bsid 0 instead of 8, i.e. an entirely different - // stream description built from the same buffer. + // 0x0B alone is not a syncframe. Matching on either byte anchors the parse + // two bytes early, so every BSI field reads from the wrong offset — bsid 0 + // instead of 8, an entirely different stream description from the same buffer. let mut f = vec![0x0B, 0x00]; f.extend_from_slice(&ac3_frame_5_1()); let c = parse_dolby(&f).expect("parsed at the real syncword"); @@ -1128,33 +1050,26 @@ mod tests { assert_eq!(c.acmod, 5, "3/1: byte4 bits 3-1"); assert!(c.lfeon, "byte4 bit 0"); assert_eq!(c.channels, 5, "3/1 + LFE"); - // data_rate exercises frmsiz (11 bits split across bytes 2-3) and - // numblkscod (byte4 bits 5-4) together: 584 B over 256 samples at - // 44.1 kHz = 584·8·44100/256/1000 = 804 kbit/s. Dropping frmsiz's high - // 3 bits, or reading numblkscod as 3 blocks instead of 1, moves it. + // data_rate exercises frmsiz (11 bits across bytes 2-3) and numblkscod (byte4 + // bits 5-4) together: 584B/256 samples @44.1kHz = 584·8·44100/256/1000 = 804 kbit/s. + // Dropping frmsiz's high 3 bits, or reading numblkscod as 3 blocks not 1, moves it. assert_eq!(c.data_rate_kbps, 804); } #[test] fn dec3_payload_packs_each_field_at_its_spec_offset() { - // ETSI TS 102 366 Annex F.6.1, EC3SpecificBox, one independent - // substream = 40 bits: - // data_rate(13)=804 num_ind_sub(3)=0 | fscod(2)=1 bsid(5)=16 - // reserved(1) asvc(1) bsmod(3)=0 acmod(3)=5 lfeon(1)=1 - // reserved(3) num_dep_sub(4)=0 reserved(1) - // 0001100100100 000 01 10000 0 0 000 101 1 000 0000 0 - // = 00011001 00100000 01100000 00001011 00000000 + // ETSI TS 102 366 Annex F.6.1, EC3SpecificBox (40 bits, one independent + // substream): data_rate(13)=804 num_ind_sub(3)=0 fscod(2)=1 bsid(5)=16 + // reserved/asvc/bsmod(3)=0 acmod(3)=5 lfeon(1)=1 num_dep_sub(4)=0 → 19 20 60 0B 00. let c = parse_dolby(&eac3_frame_distinct()).expect("parsed"); assert_eq!(dec3_payload(&dec3_box(&c)), [0x19, 0x20, 0x60, 0x0B, 0x00]); } #[test] fn eac3_fscod3_selects_the_reduced_rate_table_by_fscod2() { - // ETSI TS 102 366 Annex E.1.3.4: when fscod == 3 the two bits that are - // numblkscod otherwise become fscod2, indexing a DIFFERENT rate table - // (and the frame is always 6 blocks). fscod2=2 → 16 kHz. Read as - // numblkscod, or with the wrong mask, this indexes out of the 4-entry - // reduced table or lands on 24 kHz. + // ETSI TS 102 366 Annex E.1.3.4: when fscod==3, the numblkscod bits become + // fscod2 instead, indexing a DIFFERENT rate table (frame always 6 blocks). + // fscod2=2 → 16 kHz; read as numblkscod or wrong-masked, this lands on 24 kHz. let c = parse_dolby(&eac3_frame_fscod3()).expect("parsed"); assert_eq!(c.fscod, 3); assert_eq!(c.sample_rate, 16_000, "EAC3_REDUCED_RATES[fscod2=2]"); @@ -1187,10 +1102,9 @@ mod tests { #[test] fn dts_core_sync_needs_all_four_bytes() { - // 0x7FFE8001 is a 32-bit sync. Any partial match — the leading 0x7F, or - // the trailing 0x8001 — anchoring the parse means every field after it - // is read from the wrong offset: AMODE 4 (2 channels) instead of 9 - // (5.1) out of the very same buffer. + // 0x7FFE8001 is a 32-bit sync; a partial match (leading 0x7F or trailing + // 0x8001) anchors the parse wrong, so every field after it is read from the + // wrong offset: AMODE 4 (2 ch) instead of 9 (5.1) out of the same buffer. let mut f = vec![0x7F, 0x00, 0x80, 0x01]; f.extend_from_slice(&[ 0x7F, 0xFE, 0x80, 0x01, 0x00, 0x3C, 0x05, 0xF2, 0x77, 0x00, 0x02, 0x00, @@ -1229,10 +1143,9 @@ mod tests { #[test] fn dts_split_fields_keep_their_high_bits() { - // NBLKS is 7 bits straddling bytes 4-5 and FSIZE is 14 bits straddling - // bytes 5-7 (ETSI TS 102 114 §5.3.1). Shifting the high part the wrong - // way drops it silently: 79 blocks becomes 15, and a 12289-byte core - // becomes 1 — both of which then propagate into the ddts bitrate. + // NBLKS (7 bits, bytes 4-5) and FSIZE (14 bits, bytes 5-7; ETSI TS 102 114 + // §5.3.1) straddle byte boundaries. Shifting the high part wrong drops it + // silently: 79 blocks becomes 15, a 12289-byte core becomes 1 — into the bitrate. let c = parse_dts(&dts_frame_high_bits_set()).expect("parsed"); assert_eq!(c.frame_samples, 2560, "NBLKS 79 → (79+1)·32"); assert_eq!(c.core_size, 12_289, "FSIZE 0x3000 + 1"); @@ -1242,12 +1155,9 @@ mod tests { #[test] fn dts_lff_is_masked_out_of_its_neighbours_not_xored() { - // LFF is 2 bits at byte 10 bits 2-1; byte 10 = 0x0A puts a 1 in bit 3 - // as well, so `>> 1` yields 0b101 and only a MASK isolates LFF = 1 - // (LFE present). XOR-ing instead yields 0b110 = 2 — also a legal - // "LFE present" code, which is why the 5.1 fixtures could not see it — - // and here flips it to 0b110 & no-mask = 6, i.e. LFE absent, dropping - // a channel from both the count and the ddts speaker mask. + // LFF is 2 bits at byte10 bits 2-1; byte10=0x0A also sets bit3, so `>>1` + // gives 0b101 and only a MASK isolates LFF=1. The 5.1 fixtures can't catch + // an XOR bug here (0b110=2 is also "LFE present"); this drops a channel instead. let c = parse_dts(&dts_frame_high_bits_set()).expect("parsed"); assert!(c.lfe, "LFF = 1"); assert_eq!(c.channels, 6, "AMODE 9 (5 ch) + LFE"); @@ -1256,12 +1166,9 @@ mod tests { #[test] fn dts_ext_sync_in_the_last_core_byte_is_not_an_extension() { - // The EXSS search starts at exactly core_size = FSIZE + 1. Here FSIZE=9 - // (core_size 10) and the ext-sync pattern begins at byte 9 — the LAST - // byte of the core, one before the search window. Off-by-one either - // way (start at FSIZE, or at FSIZE-1) tests that byte and mislabels a - // plain DTS core as DTS-HD, emitting `dtsh` for a stream with no - // extension substream at all. + // EXSS search starts exactly at core_size=FSIZE+1. Here FSIZE=9 (core_size + // 10) and the ext-sync begins at byte 9 — the LAST core byte, just before + // the window. An off-by-one either way tests that byte and wrongly emits `dtsh`. let f = vec![ 0x7F, 0xFE, 0x80, 0x01, 0x00, 0x00, 0x00, 0x92, 0x74, 0x64, 0x58, 0x20, 0x25, ]; @@ -1277,12 +1184,9 @@ mod tests { #[test] fn ddts_bitrate_is_the_rounded_core_rate() { - // core_size · 8 · sample_rate / frame_samples, rounded to nearest. - // 8 · 8 · 44100 = 2 822 400 bits per 512 samples → 5512.5 bit/s, which - // must round UP to 5513: the +den/2 is what keeps the declared rate - // from being systematically low. Every operator in that expression - // moves the answer, and nothing else in this module reads maxBitrate - // or avgBitrate out of the emitted box. + // core_size·8·sample_rate/frame_samples, rounded to nearest. 8·8·44100 = + // 2822400 bits per 512 samples → 5512.5 bit/s, must round UP to 5513 via + // +den/2 — otherwise the declared rate is systematically low. let c = DtsConfig { sample_rate: 44_100, channels: 6, @@ -1317,10 +1221,9 @@ mod tests { #[test] fn ddts_frame_duration_code_covers_every_band() { - // FrameDuration (the tail's top 2 bits) codes the core frame length: - // 0 = 512 samples, 1 = 1024, 2 = 2048, 3 = 4096. NBLKS is 7 bits, so - // (NBLKS+1)·32 reaches 4096 and every band is live. Declaring the wrong - // band tells a decoder the frame is a different duration than it is. + // FrameDuration (tail's top 2 bits) codes core length: 0=512, 1=1024, + // 2=2048, 3=4096 samples. NBLKS is 7 bits, so (NBLKS+1)·32 reaches 4096 + // and every band is live — the wrong band declares the wrong duration. let dur = |frame_samples| { let c = DtsConfig { sample_rate: 48_000, diff --git a/src/mux/mp4/mod.rs b/src/mux/mp4/mod.rs index 16aac376..4744c3bd 100644 --- a/src/mux/mp4/mod.rs +++ b/src/mux/mp4/mod.rs @@ -42,14 +42,9 @@ const NS: i64 = 1_000_000_000; /// (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 -// `mdat`, then write `moov` into it at finish and pad the slack with a `free` -// box. Because the hole precedes `mdat`, sample offsets are fixed from the start -// — no rewrite, no offset patch. `moov` is dominated by the per-sample tables -// (`stsz` 4 B + `co64` 8 B + `ctts`/`stts`/`stss` ~4 B, one-sample-per-chunk), so -// its size scales with the total sample count. +// Faststart reserve sizing: reserve a `moov`-sized hole between `ftyp` and `mdat` +// so sample offsets are fixed up front (no rewrite/offset patch at finish). +// `moov` size scales with sample count (stsz/co64/ctts/stts/stss per sample). /// Estimated `moov` bytes per sample (calibrated against real discs; bias high). const BYTES_PER_SAMPLE: u64 = 16; @@ -98,12 +93,8 @@ fn estimate_reserve(title: &DiscTitle, included: &[usize]) -> u64 { est_samples += dur * fps; } DiscStream::Audio(a) => { - // Samples per frame differs sharply by codec, and 1.6.0 added DTS - // to the carried set (audio_fits now admits Dts | DtsHdMa | - // DtsHdHr). A DTS core AU is (nblks+1)*32 — commonly 512 samples, - // a third of an (E-)AC-3 frame's 1536 — so modelling every audio - // track as AC-3 under-reserved a DTS track's sample table 3x and - // pushed the mux onto the moov-at-end fallback. + // A DTS core AU is ~512 samples vs 1536 for (E-)AC-3; modelling every + // audio track as AC-3 under-reserved DTS tracks 3x and forced fallback. let samples_per_frame = match a.codec { Codec::Dts | Codec::DtsHdMa | Codec::DtsHdHr => 512.0, _ => 1536.0, @@ -117,12 +108,9 @@ fn estimate_reserve(title: &DiscTitle, included: &[usize]) -> u64 { let reserve = round_up_grain(est) .max(RESERVE_FLOOR) .saturating_add(RESERVE_BUFFER); - // The hole is a `free` box with a 32-bit size field, so a reserve at or above - // u32::MAX cannot be expressed: writing it truncated the size and left mdat - // beyond a box that claimed to be far shorter. Clamp to the largest - // grain-aligned value the field can hold. No real title comes near this — - // a 90 GB UHD title estimates a few MiB — but truncating silently produces an - // unreadable file, so it is bounded rather than trusted. + // The hole is a `free` box with a 32-bit size field; a reserve >= u32::MAX + // would truncate and leave mdat beyond a box claiming to be far shorter. + // Clamp to the largest grain-aligned value the field can hold. reserve.min(RESERVE_CAP) } @@ -311,10 +299,8 @@ impl<W: Write + Seek> Mp4Sink<W> { let mut tracks = Vec::new(); let mut route = vec![None; title.streams.len()]; let mut video_codec = Codec::Hevc; - // Track ids are 1-based and assigned in inclusion order. `moov`'s - // next_track_id is NOT derived from this counter — it is max(track_id) + 1 - // computed after the sample-less retain, since ids are handed out here - // before any track is dropped. + // Track ids are 1-based, assigned in inclusion order. `moov`'s next_track_id + // is max(track_id) + 1 computed after the sample-less retain, not this counter. for (n, &i) in report.included.iter().enumerate() { let track_id = n as u32 + 1; route[i] = Some(tracks.len()); @@ -326,11 +312,9 @@ impl<W: Write + Seek> Mp4Sink<W> { .get(i) .and_then(|c| c.clone()) .ok_or(crate::error::Error::Mp4MissingCodecPrivate)?; - // ISO/IEC 14496-12 makes width/height mandatory in tkhd - // (8.3.2) and VisualSampleEntry (12.1.3) — unlike Matroska - // there is no element to omit. Writing 0x0 yields a - // structurally complete file no player can render, with no - // error anywhere; refuse instead. + // ISO/IEC 14496-12 §8.3.2/§12.1.3 make width/height mandatory; + // writing 0x0 yields a structurally valid file no player can + // render, with no error anywhere, so refuse instead. let (w, h) = v .resolution .pixels() @@ -374,11 +358,9 @@ impl<W: Write + Seek> Mp4Sink<W> { writer.write_all(&ftyp)?; let hole_start = ftyp.len() as u64; - // Faststart: reserve a `moov`-sized hole (a `free` box) between `ftyp` - // and `mdat`. Only the 8-byte `free` header is written now; the body is - // left as a hole (sparse) and overwritten at finish() by moov + a smaller - // `free`. `mdat` therefore starts at a fixed offset, so co64 offsets are - // correct as written — no rewrite, no patch. + // Faststart: write only the 8-byte `free` header now; the body is left as a + // hole, overwritten at finish() by moov + a smaller `free`. mdat therefore + // starts at a fixed offset, so co64 offsets are correct as written. let reserve = estimate_reserve(title, &report.included); writer.write_all(&(reserve as u32).to_be_bytes())?; writer.write_all(b"free")?; @@ -439,11 +421,9 @@ impl<W: Write + Seek> Mp4Sink<W> { traks.push(trak); movie_dur = movie_dur.max((secs * movie_ts as f64) as u64); } - // `next_track_id` must EXCEED every track_ID in use (ISO/IEC 14496-12 - // §8.2.2). Deriving it from the retained COUNT broke that whenever - // `finish()` dropped a track: ids [1, 3] retained → count 2 → 3, which - // names a live track, so a tool appending a track with it creates a - // duplicate id. Take the real maximum. + // `next_track_id` must EXCEED every track_ID in use (ISO/IEC 14496-12 §8.2.2). + // Deriving it from the retained count breaks if finish() drops a track (e.g. + // ids [1, 3] retained → count 2 → 3 collides), so take the real maximum. let next_id = self .tracks .iter() @@ -469,12 +449,9 @@ impl<W: Write + Seek + Send> Stream for Mp4Sink<W> { let Some(slot) = self.route.get(frame.track).copied().flatten() else { return Ok(()); // excluded track (or out of range) }; - // Derive the audio sample entry opportunistically from whichever frame - // parses first. The entry is only needed at finish(), when build_moov runs - // — nothing on this path consumes it — so an unparseable frame must NOT - // cost us the frame. Dropping leading frames here lost audio silently, and - // a track whose frames never parsed vanished from the output entirely with - // no report; finish() now decides that case loudly instead. + // Derive the audio sample entry opportunistically from whichever frame parses + // first; it's only needed at finish(). Dropping unparseable leading frames + // here used to lose audio silently — finish() now reports that case loudly. if self.tracks[slot].media == Media::Audio && self.tracks[slot].audio_entry.is_none() && let Some(entry) = audio::dolby_sample_entry(self.tracks[slot].codec, &frame.data) @@ -499,10 +476,9 @@ impl<W: Write + Seek + Send> Stream for Mp4Sink<W> { return Ok(()); } self.finished = true; - // Every drop below is recorded in `self.dropped` so `final_report()` — the - // structured answer to "what is in this file" — cannot keep claiming a - // track the file does not have. A `tracing::warn` alone left the crate's - // own public report lying about the output. + // Every drop below is recorded in `self.dropped` so `final_report()` cannot + // keep claiming a track the file doesn't have; a `tracing::warn` alone left + // the crate's own public report lying about the output. let mut dropped = Vec::new(); // Drop tracks that never received a sample so moov carries no empty trak. self.tracks.retain(|t| { @@ -517,12 +493,9 @@ impl<W: Write + Seek + Send> Stream for Mp4Sink<W> { } kept }); - // An audio track with samples but no sample entry cannot be DESCRIBED: the - // moov would carry an stsd declaring entry_count=1 around an empty entry, - // i.e. a structurally invalid mp4 returned as success. Drop it instead, and - // say so — this crate's policy is that a skipped track is never silent. - // Its bytes stay in mdat unreferenced, which is harmless (wasted space in a - // valid file) and preferable to failing an export whose video is fine. + // An audio track with samples but no sample entry can't be described: stsd + // would declare entry_count=1 around an empty entry, a structurally invalid + // mp4. Drop and report it; unreferenced mdat bytes are harmless waste. self.tracks.retain(|t| { let describable = t.media != Media::Audio || t.audio_entry.is_some(); if !describable { @@ -749,14 +722,9 @@ fn detect_rate(samples: &[Sample]) -> (u32, u32) { deltas.sort_unstable(); let median = deltas[deltas.len() / 2]; let fps = NS as f64 / median as f64; - // Snap to the NEAREST standard rate inside the tolerance window, not the - // first one inside it. First-match made the answer depend on table order: - // every 1000/1001 rate sits within 0.5 fps of its integer twin and precedes - // it, so an exact 24.000 / 30.000 / 60.000 fps source was always declared - // 24000/1001, 30000/1001, 60000/1001 — a 0.1% timing error over the whole - // track. Nearest-match is order-independent, so the fix cannot be undone by - // someone appending a rate to STD_RATES (which is why it is preferred over - // simply reordering the table). + // Snap to the NEAREST standard rate inside the tolerance window, not the first + // one: first-match depended on table order, so exact 24/30/60 fps sources were + // always declared as their 1000/1001 twin (a 0.1% timing error track-wide). let mut best: Option<(u32, u32, f64)> = None; for &(ts, dur, rate) in STD_RATES { let d = (fps - rate).abs(); @@ -910,10 +878,8 @@ fn video_colr(stream: &DiscStream) -> Option<(u16, u16, u16, bool)> { let DiscStream::Video(v) = stream else { return None; }; - // No measured CICP and no colorimetry from the playlist → nothing usable to - // signal. The shared resolver returns the CICP "unspecified" triple (2/2/2) - // for that case; an ABSENT `colr` box already means exactly that, so omit the - // box rather than write it (unchanged behaviour for this sink). + // Nothing usable to signal: the resolver returns CICP "unspecified" (2/2/2) + // here, but an absent `colr` box already means that, so omit it instead. if v.measured_cicp.is_none() && v.color_space == crate::disc::ColorSpace::Unknown { return None; } @@ -1249,11 +1215,9 @@ mod tests { } } - // A minimal AC-3 5.1 frame the audio parser accepts. - // The underscores in these literals mark BITFIELD boundaries in the - // bitstream header being built (e.g. a 5-bit field then a 3-bit field), - // not thousands-style digit groups. Regrouping them uniformly would - // satisfy the lint by destroying the only thing they encode. + // A minimal AC-3 5.1 frame the audio parser accepts. Underscores mark + // BITFIELD boundaries in the header (e.g. 5-bit then 3-bit fields), not + // thousands-grouping; regrouping them uniformly would destroy that meaning. #[allow(clippy::unusual_byte_groupings)] fn ac3_frame() -> Vec<u8> { vec![ @@ -1531,11 +1495,9 @@ mod tests { #[test] fn reserve_rounds_to_4mb_plus_buffer() { - // round_up_4MB(x) + 4 MiB, floored at 8 MiB. - // Saturates rather than wrapping. div_ceil(GRAIN) * GRAIN overflows within - // one grain of u64::MAX, and the wrapped product is SMALL — which would - // turn the largest possible estimate into a negligible reserve, the exact - // opposite of the intent. Only the no-wrap property matters here. + // round_up_4MB(x) + 4 MiB, floored at 8 MiB. Must saturate rather than wrap: + // div_ceil(GRAIN) * GRAIN overflows within one grain of u64::MAX, and a + // wrapped product is SMALL, turning the largest estimate into a tiny reserve. assert!( round_up_grain(u64::MAX) >= u64::MAX - (4 << 20), "round_up_grain must saturate near u64::MAX, not wrap to a small value" @@ -1560,17 +1522,9 @@ mod tests { "≈12-16 MB for a 2h feature, got {r}" ); - // The case above is dominated by RESERVE_FLOOR + RESERVE_BUFFER: its - // per-sample term is ~6.4 MB, under the 8 MiB floor, so setting - // BYTES_PER_SAMPLE to 0 would leave it green. Pin a case where the - // per-sample estimate is what the result is MADE of. - // - // 2 hr, 23.976 fps HEVC + EIGHT AC-3 tracks (a commentary-heavy disc): - // video 7200 × 24000/1001 = 172_627 samples - // audio 8 × 7200 × (48000 / 1536) = 1_800_000 samples - // total 1_972_627 × 16 B = 31_562_032 B (30.1 MiB) - // → round up to 8 grains = 32 MiB, + 4 MiB buffer = 36 MiB exactly. - // With BYTES_PER_SAMPLE = 0 this collapses to the 12 MiB floor+buffer. + // The case above is dominated by the floor+buffer (per-sample term ~6.4 MB + // is under the floor), so BYTES_PER_SAMPLE=0 stays green there. Pin a case + // (2h HEVC + 8 AC-3 tracks) where per-sample dominates: ~30.1 MiB → 36 MiB. let mut streams = vec![hevc_video()]; streams.extend((0..8).map(|_| audio(Codec::Ac3, "eng"))); let mut t = title(streams, vec![]); @@ -1636,10 +1590,8 @@ mod tests { #[test] fn colr_transfer_is_hlg_for_an_hlg_title_not_pq() { - // ITU-T H.273 Table 3: transfer 18 = ARIB STD-B67 (HLG), 16 = SMPTE - // ST 2084 (PQ). `video_colr` hardcoded 16 for every BT.2020 stream, so an - // HLG title got the PQ EOTF applied to it — while the MKV sink of the same - // rip correctly wrote 18. + // ITU-T H.273 Table 3: transfer 18 = HLG, 16 = PQ. `video_colr` used to + // hardcode 16 for every BT.2020 stream, wrongly applying PQ EOTF to HLG. let mut v = video_stream(); v.hdr = HdrFormat::Hlg; v.color_space = ColorSpace::Bt2020; @@ -1723,10 +1675,9 @@ mod tests { #[test] fn exact_integer_frame_rates_are_not_declared_as_their_fractional_twins() { - // `detect_rate` returned the FIRST STD_RATES entry within 0.5 fps, and each - // 1000/1001 rate precedes its integer twin, so 24.000 / 30.000 / 60.000 - // were always written as 24000/1001, 30000/1001 and 60000/1001. The - // declared timescale/sample_delta is read back out of the muxed file. + // `detect_rate` used to return the FIRST STD_RATES entry within 0.5 fps, so + // 24/30/60 fps were always written as their 1000/1001 twins. Read the + // declared timescale/sample_delta back out of the muxed file to check. for (delta_ns, want) in [ (41_666_667i64, (24u32, 1u32)), // 24.000 (33_333_333, (30, 1)), // 30.000 @@ -1887,18 +1838,9 @@ mod tests { ); } - // ── track timing arithmetic — every operator with a concrete number ─────── - // - // Nothing above this point pins a NUMBER out of the PTS→ticks / duration / - // tkhd_dur / ctts chain: every existing test asserts a box exists or that - // gross shape (trak count, mdat size) is right, never that `stts`, `ctts` or - // `tkhd.duration` hold a specific value. Every arithmetic operator in that - // chain (`*`↔`/`↔`+`↔`-`, even `/`↔`%`) could be flipped with the whole - // suite green — a title would mux "successfully" with silently corrupted - // A/V sync or a wrong total duration. These pin exact values, chosen so a - // flipped operator cannot coincidentally reproduce the right answer (e.g. a - // non-zero `min_pts` so `pts - min_pts` and a mutated `pts + min_pts` differ, - // a `sample_dur` other than 1 so `*` and `/` by it differ). + // Track timing arithmetic: tests above never pin a NUMBER out of the + // PTS→ticks/duration/tkhd_dur/ctts chain, so any operator there (`+`↔`-`, + // `*`↔`/`) could flip with the suite green. These pin exact chosen values. /// `VideoTiming::derive`'s composition ticks are `(pts - min_pts) * ts / NS`. /// `min_pts` is deliberately non-zero: with min_pts == 0 a mutated `+` gives @@ -2117,10 +2059,8 @@ mod tests { #[test] fn detect_rate_picks_the_nearest_std_rate_regardless_of_table_order() { - // Order-independence is the property that keeps this fixed: every entry - // must resolve to itself when its own exact rate is measured, no matter - // where it sits in STD_RATES. A first-match rule can only satisfy this if - // the table happens to be ordered, which is what broke. + // Order-independence: every entry must resolve to itself when its own exact + // rate is measured, regardless of position in STD_RATES (unlike first-match). for &(ts, dur, rate) in STD_RATES { let d = (NS as f64 / rate).round() as i64; let samples: Vec<Sample> = (0..10) @@ -2259,10 +2199,8 @@ mod tests { keyframe: true, }) .collect(); - // Two distinct adjacent runs: [5,5] then [7,7,7]. If the coalescing - // guard always matches (or always mismatches), or `==`/`!=` is - // inverted, the run count and/or the runs' (count, value) pairs come - // out wrong. + // Two distinct adjacent runs: [5,5] then [7,7,7]. Catches a coalescing + // guard that always (mis)matches or an inverted `==`/`!=`. let durs = vec![5u32, 5, 7, 7, 7]; let stbl = build_audio_stbl(vec![0u8; 4], &samples, &durs); let stts = find_child(&stbl[8..], b"stts").expect("stts"); diff --git a/src/mux/mp4/read.rs b/src/mux/mp4/read.rs index 4c1ec8b4..4026524c 100644 --- a/src/mux/mp4/read.rs +++ b/src/mux/mp4/read.rs @@ -109,21 +109,9 @@ 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. Also bound by - // `file_len`: a sample occupies at least one byte of the file, so a tiny - // crafted file with a fixed-size `stsz` claiming count=0xFFFFFFFF can't - // inflate the `sizes`/`Vec<SampleRef>` allocations past the file's own size - // (a genuine large title has file_len ≫ sample count, so it is unaffected). - // The bound is in file BYTES PER SAMPLE, not in samples: each indexed - // sample costs ~60 bytes of RAM (SampleRef 40 + u32 size 4 + u64 offset 8, - // plus 4 each for the expanded stts/ctts), so a budget of one sample per - // file byte still let a 16 MiB crafted file (a fixed-size `stsz` declaring - // 16M one-byte samples, a one-entry stsc/stco/stts) force a ~1 GiB eager - // allocation and a 16M-element sort before a single frame was read — a 64x - // amplification. Dividing by MIN_FILE_BYTES_PER_SAMPLE caps the - // amplification at ~4x instead. + // Global cap on decoded samples across ALL tracks, since many small `trak` boxes + // could each stay under the per-track cap yet sum past it. Divided by + // MIN_FILE_BYTES_PER_SAMPLE so a crafted `stsz` can't force a ~1 GiB eager alloc. let mut sample_budget = MAX_SAMPLE_COUNT .min((file_len / MIN_FILE_BYTES_PER_SAMPLE).min(usize::MAX as u64) as usize); @@ -230,10 +218,8 @@ impl<R: Read + Seek> Mp4Reader<R> { samples = n, "mp4: stbl has samples but no stco/co64 chunk-offset table, dropping track" ); - // 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. + // No chunk-offset table means every sample offset would resolve to file + // byte 0 (muxing header bytes as frame data); drop the track instead. continue; } let stsc = find_box(stbl, b"stsc").map(parse_stsc).unwrap_or_default(); @@ -265,22 +251,17 @@ impl<R: Read + Seek> Mp4Reader<R> { .map(|b| parse_stts(b, n)) .unwrap_or_default(); if durations.len() < n { - // Absent OR short: `stts` is mandatory and must cover every sample - // (ISO/IEC 14496-12 §8.6.1). A short table gave every unmapped tail - // sample dur=0, collapsing the whole tail onto one instant — the - // same degenerate timing the absent case refuses, so refuse both. + // `stts` is mandatory and must cover every sample (ISO/IEC 14496-12 + // §8.6.1); absent or short gives unmapped tail samples dur=0, + // collapsing them onto one instant, so refuse both cases. tracing::warn!( track = track_idx, durations = durations.len(), samples = n, "mp4: stts does not cover every sample, dropping track" ); - // Samples exist but there is no decoding-time table: `stts` is - // mandatory in a valid stbl (ISO/IEC 14496-12 §8.6.1). Without it - // every sample would take dur=0 → all-zero, identical timestamps, - // collapsing the whole track onto one instant. Drop the track - // rather than emit degenerate timing (mirrors the stco/stsc - // guards above); an all-tracks-dropped file fails Mp4Invalid below. + // Mirrors the stco/stsc guards above: drop rather than emit + // degenerate all-zero timing for the unmapped tail. continue; } let ctts = find_box(stbl, b"ctts") @@ -295,11 +276,9 @@ impl<R: Read + Seek> Mp4Reader<R> { (ticks as i128 * NS / timescale as i128).clamp(i64::MIN as i128, i64::MAX as i128) as i64 }; - // Edit list (ISO/IEC 14496-12 §8.6.5 `edts` / §8.6.6 `elst`): the - // presentation timeline is NOT the media timeline. Ignoring it — which - // this reader did — starts every track at media time 0, so a track - // carrying the standard encoder-delay/A-V-offset edit ends up shifted - // against its siblings for the whole title, silently. + // Edit list (ISO/IEC 14496-12 §8.6.5 `edts` / §8.6.6 `elst`): presentation + // timeline != media timeline. Ignoring it starts every track at media time + // 0, silently shifting tracks with an encoder-delay/A-V-offset edit. let edit_offset_ticks = find_box(trak, b"edts") .and_then(|edts| find_box(edts, b"elst")) .map(|elst| { @@ -427,11 +406,9 @@ fn read_moov<R: Read + Seek>(file: &mut R) -> io::Result<Vec<u8>> { 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. + // A box must contain at least its header and not run past EOF; this also + // guarantees forward progress so a crafted size < 8 can't spin the loop. + // checked_add stops a 64-bit largesize near u64::MAX from wrapping 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()); } @@ -524,13 +501,9 @@ type EditListEntry = (u64, i64, i16); /// media_rate_integer:i16, media_rate_fraction:i16` (20 bytes); version 0 uses /// 32-bit duration/time (12 bytes). fn parse_elst(b: &[u8]) -> Vec<EditListEntry> { - // `<`↔`<=` here is equivalent, not a coverage gap: at the boundary - // `b.len() == 8` this early return and falling through produce the SAME - // result. Falling through computes `available = (b.len() - 8) / - // entry_size = 0`, which floors `n` to 0 regardless of the declared count, - // so the loop never runs and `Vec::new()` comes back either way. Confirmed - // by re-running the `<=` mutation against the full test suite, which still - // passes. + // `<`↔`<=` here is equivalent, not a coverage gap: at `b.len() == 8` falling + // through computes `available = (b.len() - 8) / entry_size = 0`, floors `n` + // to 0, and returns `Vec::new()` either way (confirmed via mutation testing). if b.len() < 8 { return Vec::new(); } @@ -614,17 +587,9 @@ fn elst_offset_ticks( } } - // `media_edits` and `odd_rate` are read ONLY by this `if` (to decide - // whether to log) — neither feeds `delay_media_ticks` or the `saturating_sub` - // below, so no mutation of this condition (`>`↔`==`/`<`/`>=`, `||`↔`&&`, or - // the `rate != 1` comparison that sets `odd_rate`) can change what this - // function returns; it can only change whether this particular warning - // fires. Mutation testing flags all five as surviving; that is expected, - // not a coverage gap — confirmed by re-running each mutation against the - // full test suite (`parse_elst_v1_entry_bytes_come_from_their_own_offsets` - // and `parse_elst_and_offset_arithmetic` among them), which still passes - // under every one. The same shape recurs below for the `None` arm's - // `empty_movie_ticks > 0` log guard. + // `media_edits`/`odd_rate` are read ONLY by this `if` to decide whether to log; + // they don't feed the return value, so mutating this condition (expected to + // survive mutation testing) only changes whether the warning fires — same shape below. if media_edits > 1 || odd_rate { tracing::warn!( track = track_idx, @@ -645,11 +610,9 @@ fn elst_offset_ticks( } Some(_) => 0, None => { - // Same shape as the `media_edits > 1 || odd_rate` guard above: this - // arm returns 0 no matter what, so a `>`↔`==`/`<`/`>=` mutant of - // this comparison only changes whether the warning below fires, - // never the return value. Confirmed equivalent empirically the - // same way. + // Same shape as the `media_edits > 1 || odd_rate` guard above: this arm + // returns 0 no matter what, so mutating this comparison only changes + // whether the warning below fires, never the return value. if empty_movie_ticks > 0 { tracing::warn!( track = track_idx, @@ -715,13 +678,8 @@ struct StsdInfo { /// stsd → codec + dimensions + codec_private + channel count (first entry). fn parse_stsd(b: &[u8]) -> Option<StsdInfo> { // version+flags(4) entry_count(4) then the first sample entry box. - // - // `< 8` vs `<= 8` is equivalent here, not a coverage gap: at `b.len() == - // 8` this early return and falling through agree. Falling through makes - // `entry = &b[8..]` an EMPTY slice, and the very next guard - // (`entry.len() < 8`) catches that unconditionally and returns `None` - // too. Confirmed by re-running the `<=` mutation against the full test - // suite, which still passes. + // `< 8` vs `<= 8` is equivalent here: at `b.len() == 8` falling through makes + // `entry` empty, and the next `entry.len() < 8` guard catches that too. if b.len() < 8 { return None; } @@ -764,10 +722,9 @@ fn parse_stsd(b: &[u8]) -> Option<StsdInfo> { channels: 0, }) } else { - // AudioSampleEntry: 6 reserved + 2 dri + 8 reserved + channelcount(2) - // 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). + // AudioSampleEntry header is 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) @@ -790,12 +747,9 @@ fn read_descriptor_len(b: &[u8], pos: &mut usize) -> usize { for _ in 0..4 { let Some(&byte) = b.get(*pos) else { break }; *pos += 1; - // `|`↔`^` is equivalent here, the same shape documented at - // `audio.rs`'s `BitReader::read`: `len << 7` always has zeros in its - // low 7 bits (a left shift shifts zeros in), and `byte & 0x7F` is - // masked to exactly those 7 bits, so the two operands never share a - // set bit — `|` and `^` agree on every input. Confirmed by re-running - // the `^` mutation against the full test suite, which still passes. + // `|`↔`^` is equivalent here (same shape as `audio.rs`'s `BitReader::read`): + // `len << 7` has zeros in its low 7 bits and `byte & 0x7F` is masked to + // exactly those bits, so the operands never share a set bit. len = (len << 7) | (byte & 0x7F) as usize; if byte & 0x80 == 0 { break; @@ -873,23 +827,9 @@ fn parse_stsz(b: &[u8], max: usize) -> Vec<u32> { /// stco (32-bit) / co64 (64-bit) → chunk offsets. fn parse_stco(b: &[u8], is64: bool) -> Vec<u64> { - // `< 8` vs `<= 8` is equivalent, the same shape as `parse_stsd`'s header - // guard: at `b.len() == 8` this early return and falling through agree. - // Falling through computes `count = be32(b, 4)` (valid — indices 4..8 - // exist) but then the per-entry guard `o + stride > b.len()` fails on the - // very first entry (`o == 8`, and `stride` is 4 or 8), so the loop body - // never runs either way and both paths return an empty Vec. The SAME - // pattern recurs in `parse_stsc`, `parse_stts`, `parse_ctts` and - // `parse_stss` below — each one's per-entry guard reads the identical - // `o` computed from the identical `b.len() == 8`, so all five converge on - // "no entries" at this boundary regardless of which side of it the check - // falls on. Confirmed by re-running the `<=` mutation against the full - // test suite (including `table_parsers_reject_every_length_up_to_the_header_size`, - // which exercises exactly this boundary for all five), which still - // passes. `< 8` vs `== 8` is NOT equivalent, though: it drops the - // rejection for every length strictly below 8, which then falls through - // to `be32(b, 4)` and panics out of bounds — that direction has its own - // test. + // `< 8` vs `<= 8` is equivalent (same shape as `parse_stsd`, and stsc/stts/ctts/stss + // below): at `b.len() == 8` falling through still fails the per-entry guard on the + // first entry. `< 8` vs `== 8` is NOT equivalent: it panics on `be32(b, 4)`. if b.len() < 8 { return Vec::new(); } @@ -951,11 +891,8 @@ fn sample_offsets(sizes: &[u32], chunk_offsets: &[u64], stsc: &[(u32, u32)]) -> .map(|&(nf, _)| (nf.saturating_sub(1)) as usize) .unwrap_or(n_chunks); let end = end.min(n_chunks); - // `<` vs `<=` is equivalent: at `start == end`, `spc[start..end]` is a - // valid EMPTY slice (Rust allows a range with equal bounds), and - // `.fill()` on an empty slice does nothing — identical to skipping the - // call entirely. Confirmed by re-running the `<=` mutation against - // the full test suite, which still passes. + // `<` vs `<=` is equivalent: at `start == end`, `spc[start..end]` is a valid + // empty slice and `.fill()` on it does nothing, same as skipping the call. if start < end { spc[start..end].fill(per); } @@ -975,11 +912,9 @@ fn sample_offsets(sizes: &[u32], chunk_offsets: &[u64], stsc: &[(u32, u32)]) -> sidx += 1; } } - // Samples the stsc did not place have NO known location. Fabricating one by - // packing after the last offset invents a position, and the frame is then read - // from arbitrary file bytes — exactly the "emit garbage" outcome the stco/stsc - // guards above refuse. Report the shortfall instead and let the caller drop - // the track. + // Samples the stsc did not place have NO known location; fabricating one by + // packing after the last offset would read a frame from arbitrary file bytes. + // Report the shortfall instead and let the caller drop the track. offsets } @@ -1238,10 +1173,9 @@ mod tests { .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. + // Faststart puts `moov` near the front, so a 64 KiB truncation keeps the + // parseable moov but drops mdat; read() must reject the resulting + // 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(); @@ -1359,10 +1293,8 @@ mod tests { } #[test] - // The underscores in these literals mark BITFIELD boundaries in the - // bitstream header being built (e.g. a 5-bit field then a 3-bit field), - // not thousands-style digit groups. Regrouping them uniformly would - // satisfy the lint by destroying the only thing they encode. + // Underscores mark BITFIELD boundaries in the bitstream header being built + // (e.g. 5-bit then 3-bit field), not thousands-style digit groups. #[allow(clippy::unusual_byte_groupings)] fn write_then_read_round_trip() { // Mux a small A/V title to an in-memory MP4, then demux it back and @@ -1474,10 +1406,9 @@ mod tests { /// `b_frame_presentation_order_survives_the_mp4_round_trip`.) #[test] fn stts_expands_runs_to_per_sample_deltas_in_order() { - // Three runs with DISTINCT deltas and distinct lengths, so a parser that - // dropped a run, reused the first delta, or emitted the runs in the - // wrong order cannot agree. A trailing 0-length run must contribute - // nothing (legal: §8.6.1.2 places no lower bound on sample_count). + // Three runs with DISTINCT deltas/lengths, so a parser that drops a run, + // reuses the first delta, or misorders runs cannot agree. A trailing + // 0-length run must contribute nothing (legal per §8.6.1.2). let mut stts = Vec::new(); stts.extend_from_slice(&[0, 0, 0, 0]); // version + flags stts.extend_from_slice(&4u32.to_be_bytes()); // entry_count @@ -1502,14 +1433,9 @@ mod tests { assert!(parse_stts(&stts[..7], MAX_SAMPLE_COUNT).is_empty()); } - // ── Composition offsets (`ctts`) — ISO/IEC 14496-12 §8.6.1.3. - // - // `ctts` is the ONLY place a reordered (B-frame) track's presentation time - // survives the write→read cycle: `stts` carries decode duration and `stss` - // carries sync points, so with the composition table wrong or absent every - // frame's PTS collapses onto its DTS and the picture order is destroyed. - // The writer's builder and this reader's parser must therefore be exact - // inverses, over the SIGNED offsets that make it a version-1 box. + // ── Composition offsets (`ctts`) — ISO/IEC 14496-12 §8.6.1.3. `ctts` is the + // ONLY place a reordered (B-frame) track's presentation time survives the + // write→read cycle; wrong/absent, every frame's PTS collapses onto its DTS. #[test] fn ctts_build_and_parse_are_exact_inverses_over_signed_offsets() { @@ -1544,15 +1470,9 @@ mod tests { #[test] fn b_frame_presentation_order_survives_the_mp4_round_trip() { - // Four samples in DECODE order carrying a classic I-P-B-B reorder: the - // second sample presents last. At 25 fps (timescale 25, one tick per - // frame) the composition offsets are [0, +2, -1, -1] — negative, so this - // exercises the signed version-1 path end to end. - // - // Before this test the round trip asserted sample sizes and keyframe - // flags only, so the entire composition-time chain (VideoTiming::ctts, - // build_ctts, parse_ctts) was unconstrained and a demuxed B-frame title - // could have presented in decode order with nothing noticing. + // Four samples in DECODE order, classic I-P-B-B reorder: at 25 fps the + // composition offsets are [0, +2, -1, -1] — negative, exercising the signed + // version-1 path (prior tests left composition time unconstrained). use crate::disc::{ Codec, DiscTitle, FrameRate, HdrFormat, Resolution, Stream as DiscStreamE, VideoStream, }; @@ -1730,10 +1650,8 @@ mod tests { /// one of any of them round-trips through this crate unnoticed while making /// the file unplayable elsewhere. #[test] - // The underscores in these literals mark BITFIELD boundaries in the - // bitstream header being built (e.g. a 5-bit field then a 3-bit field), - // not thousands-style digit groups. Regrouping them uniformly would - // satisfy the lint by destroying the only thing they encode. + // Underscores mark BITFIELD boundaries in the bitstream header being built + // (e.g. 5-bit then 3-bit field), not thousands-style digit groups. #[allow(clippy::unusual_byte_groupings)] fn moov_tree_carries_the_mandatory_track_header_and_media_boxes() { use crate::disc::{ @@ -2112,14 +2030,9 @@ mod tests { #[test] fn read_moov_over_cap_rejected_despite_inflated_file_len() { use std::io::{Read, Seek, SeekFrom}; - // A reader that reports an 8 GiB length on `seek(End)` (trivially forged by - // a sparse file, e.g. `truncate -s 8G`) but is backed by a tiny crafted - // header followed by an endless run of zeros. A `moov` whose declared size - // (512 MiB) is UNDER that inflated length passes the plain EOF check AND - // (crucially) the payload `read_exact` would SUCCEED against the zero - // stream — so only the absolute MAX_ALLOC_BYTES (256 MiB) cap can reject - // it. This makes the test flip to Ok (allocation attempted) if a regression - // drops the cap and keeps only the (sparse-file-defeatable) EOF check. + // Reader forges an 8 GiB `seek(End)` length (like a sparse file) backed by a + // tiny header + endless zeros, so a 512 MiB `moov` passes the plain EOF + // check; only the absolute MAX_ALLOC_BYTES cap can reject it. struct InflatedReader { data: Vec<u8>, pos: u64, @@ -2554,9 +2467,8 @@ mod tests { the timescale is ever used as a divisor" ); // Sanity: a REAL empty edit with a zero movie timescale is a separate, - // already-latent situation this test does not claim to fix — it is - // unreachable from from_reader (see doc comment above) and is outside - // the empty_movie_ticks == 0 boundary this test pins. + // already-latent, unreachable-from-from_reader situation this test does + // not claim to fix — it is outside the empty_movie_ticks == 0 boundary pinned here. } #[test] @@ -2626,10 +2538,9 @@ mod tests { p.extend_from_slice(&[0, 0, 0, 0]); p.extend_from_slice(&1u32.to_be_bytes()); // count p.extend_from_slice(&1u32.to_be_bytes()); // first_chunk - // Deliberately large: the stsz COUNT is the lie under test, so the - // stsc and stts must still cover whatever count survives the file_len - // bound. Both are clamped to the real sample count when expanded, so - // this places every bounded sample without itself being degenerate. + // Deliberately large: stsz COUNT is the lie under test, so stsc/stts must + // still cover whatever count survives the file_len bound (both clamp + // to the real sample count when expanded). p.extend_from_slice(&0xFFFFu32.to_be_bytes()); // samples_per_chunk p.extend_from_slice(&0u32.to_be_bytes()); // sample_desc_idx mp4_box(b"stsc", &p) @@ -2669,10 +2580,9 @@ mod tests { "fixture stays a few hundred bytes ({file_len})" ); let rd = Mp4Reader::from_reader(Cursor::new(moov), "hostile".into()).unwrap(); - // The index must be bounded by the file's byte length in FILE BYTES PER - // SAMPLE, not one sample per byte: each indexed sample costs ~60 bytes of - // RAM, so a one-sample-per-byte budget still let a 16 MiB crafted file - // force a ~1 GiB allocation (64x amplification) before a frame was read. + // Bounded by FILE BYTES PER SAMPLE, not one sample per byte: each indexed + // sample costs ~60 bytes of RAM, so one-sample-per-byte still let a 16 MiB + // crafted file force a ~1 GiB allocation (64x amplification). assert!( (rd.samples.len() as u64) <= file_len / MIN_FILE_BYTES_PER_SAMPLE, "sample count {} must be bounded by file_len/{MIN_FILE_BYTES_PER_SAMPLE} \ @@ -2758,10 +2668,8 @@ mod tests { p.extend_from_slice(&[0, 0, 0, 0]); p.extend_from_slice(&1u32.to_be_bytes()); // count p.extend_from_slice(&1u32.to_be_bytes()); // first_chunk - // All 3 samples live in the single chunk. This MUST match the stsz - // count: an stsc placing fewer samples than stsz declares is a - // malformed stbl, and the reader now drops such a track rather than - // fabricating offsets for the unplaced tail. + // All 3 samples live in the single chunk; MUST match stsz count, since + // an stsc placing fewer samples than stsz declares gets its track dropped. p.extend_from_slice(&3u32.to_be_bytes()); // samples_per_chunk p.extend_from_slice(&0u32.to_be_bytes()); // sample_desc_idx mp4_box(b"stsc", &p) diff --git a/src/mux/network.rs b/src/mux/network.rs index af4a8f37..b3dc62bc 100644 --- a/src/mux/network.rs +++ b/src/mux/network.rs @@ -106,20 +106,18 @@ impl NetworkStream { /// for in-crate tests, which must connect to `127.0.0.1` ephemeral /// listeners that the production vet would (correctly) reject. fn connect_vetted(addr: &str, vet: bool) -> io::Result<Self> { - // Resolve + vet the target before connecting. Connect to the - // vetted IP literal (not the raw name) so a DNS rebind between - // settings-save validation and now can't redirect us to a - // loopback/private/link-local host (SSRF). + // Connect to the vetted IP literal (not the raw name) so a DNS rebind between + // settings-save validation and now can't redirect us to a loopback/private/ + // link-local host (SSRF). let stream = if vet { let vetted = resolve_allowed_addr(addr)?; TcpStream::connect(vetted)? } else { TcpStream::connect(addr)? }; - // The sender is the latency-sensitive side; set nodelay here too - // (the listen side already does) so the final sub-MSS flush after - // finish() isn't held by Nagle. The 256 KB BufWriter coalesces - // bulk writes, so this only affects the tail. + // Sender is latency-sensitive; set nodelay here too (listen side already does) + // so the final sub-MSS flush after finish() isn't held by Nagle — the 256 KB + // BufWriter coalesces bulk writes, so this only affects the tail. stream.set_nodelay(true)?; Ok(Self { disc_title: DiscTitle::empty(), @@ -215,10 +213,9 @@ impl crate::pes::Stream for NetworkStream { header_written, } = &mut self.mode { - // Always emit the FMKV header before shutdown, even for a - // zero-frame stream (e.g. a title that produced no PES frames). - // Without it the receiver's read_header() sees a clean EOF and - // rejects the stream with NoMetadata. + // Always emit the FMKV header before shutdown, even for a zero-frame stream, + // or the receiver's read_header() sees a clean EOF and rejects the stream + // with NoMetadata. ensure_header_written(writer, header_written, &self.disc_title)?; writer.flush()?; writer.get_ref().shutdown(std::net::Shutdown::Write)?; @@ -380,10 +377,9 @@ mod tests { use crate::pes; use std::sync::mpsc; - // The listener thread owns the bound socket and reports its actual - // local address back over a channel before accept(). The main thread - // connects only after receiving the address — no bind/drop/re-bind - // window, no sleep-as-synchronisation. + // Listener thread reports its actual bound address over a channel before + // accept(); main thread connects only after receiving it — no bind/drop/ + // re-bind window, no sleep-as-synchronisation. let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); let (addr_tx, addr_rx) = mpsc::channel(); diff --git a/src/mux/pipelined_stream.rs b/src/mux/pipelined_stream.rs index 90cd8d1c..d062dc99 100644 --- a/src/mux/pipelined_stream.rs +++ b/src/mux/pipelined_stream.rs @@ -77,7 +77,7 @@ pub struct PipelinedPesStream { au_asm: Vec<super::au_assembly::AuAssembler>, } -/// The `Codec` of a stream, for configuring its [`AuAssembler`]. +/// The `Codec` of a stream, for configuring its [`AuAssembler`](crate::mux::au_assembly::AuAssembler). fn stream_codec(s: &crate::disc::Stream) -> crate::disc::Codec { use crate::disc::Stream; match s { @@ -150,11 +150,9 @@ impl PipelinedPesStream { Ok(DemuxBatch::Err(e)) => Err(e), // Explicit clean-completion sentinel from the demux worker. Ok(DemuxBatch::Eof) => Ok(false), - // The channel disconnected WITHOUT the worker first sending - // an `Eof` (or `Err`) sentinel — the worker panicked or was - // dropped mid-stream. Surface this as an error so a parser / - // demux panic is never reported to the caller as a clean - // end-of-stream (which would silently truncate output). + // Channel disconnected WITHOUT an `Eof`/`Err` sentinel — the worker + // panicked or was dropped mid-stream. Surface as an error so a parser/demux + // panic is never reported as a clean end-of-stream (silent truncation). Err(_) => Err(crate::error::Error::DemuxThreadPanicked.into()), } } @@ -184,14 +182,9 @@ impl PipelinedPesStream { { let is_video = self.is_video.get(track).copied().unwrap_or(false); for frame in parser.parse(&pes) { - // B1: after a concealed/lost gap, drop forward to the next - // keyframe on a video track so no frame with a dangling - // reference is emitted. The signal is read PER-FRAME - // (`frame.discontinuity`), not per-PES: buffering parsers - // (MPEG-2 GOPs, H.264/HEVC AU lag) stamp the exact post-gap - // picture, so only it arms the gate — not a whole PES of - // frames. Audio/subtitle always admit (independent frames); - // a track with no gate (out-of-range index) emits as-is. + // B1: after a concealed/lost gap, drop forward to the next video + // keyframe so no dangling-reference frame is emitted. Read PER-FRAME + // (post-gap picture); audio/subtitle admit, no-gate track emits as-is. let emit = match self.resync.get_mut(track) { Some(gate) => { let was_armed = gate.is_armed(); @@ -223,11 +216,9 @@ impl PipelinedPesStream { fn consume_ps(&mut self, packets: Vec<super::ps::PsPacket>) { for ps in packets { - // Route by the REAL DVD PID (matching the PIDs that - // `scan_dvd_titles` assigns) rather than a synthetic track - // index. The old `(sub_id & 0x1F) + 1` heuristic collided - // subtitle sub-id 0x20+j with audio track j+1, feeding - // VobSub PES into the AC-3 parser. + // Route by the REAL DVD PID (matching `scan_dvd_titles`) not a synthetic + // track index. The old `(sub_id & 0x1F) + 1` heuristic collided subtitle + // sub-id 0x20+j with audio track j+1, feeding VobSub PES into the AC-3 parser. let Some(pid) = ps.dvd_pid() else { if ps.is_nav() { // Expected DVD navigation packet (PCI/DSI) — tally, no WARN. @@ -255,22 +246,17 @@ impl PipelinedPesStream { ); continue; }; - // Carry the PS demuxer's byte-exact source stamp through to the codec - // parser, exactly as the TS path does — provenance must survive the - // PsPacket → PesPacket seam so the frame's `source` reaches the - // mux/index (FVI `src`), never reconstructed. + // Carry the PS demuxer's byte-exact source stamp through to the codec parser + // as the TS path does — provenance must survive the PsPacket → PesPacket seam + // so the frame's `source` reaches the mux/index (FVI `src`), never rebuilt. let (pts_i64, dts_i64, src) = ( ps.pts.map(|p| p as i64), ps.dts.map(|d| d as i64), ps.source, ); - // Reassemble the PS fragments into AU-complete PES for this track - // (passthrough for self-framing codecs — MPEG-2/audio), so the parser - // sees exactly the AU-complete shape a transport stream delivers. The - // AU-start PTS/source survive the reassembly. A track with no assembler - // (only reachable via a hand-built `pid_to_track` outrunning the stream - // list) passes the fragment straight through. (PS path: no AACS conceal - // → no continuity-gap flag.) + // Reassemble PS fragments into AU-complete PES (passthrough for self-framing + // MPEG-2/audio) so the parser sees the same shape a TS delivers; AU-start PTS/ + // source survive, no assembler → passthrough. (PS: no AACS conceal, no gap.) let pkts: Vec<PesPacket> = match self.au_asm.get_mut(track) { Some(asm) => asm .push_owned(ps.data, pts_i64, dts_i64, src, false) @@ -330,15 +316,9 @@ impl Stream for PipelinedPesStream { self.dropped_nav_packets ); } - // Drain any access unit a parser buffered past the last - // PES (e.g. DTS-HD's final core+extension unit, or MPEG-2's - // final GOP). These flush frames carry their own per-frame - // `discontinuity` (a post-gap picture buffered at EOF was - // stamped by the parser), so route them through the SAME B1 - // gate the in-stream path uses — otherwise a trailing - // dangling-reference frame (MPEG-2 final-GOP corner) would - // bypass the resync. Disjoint field borrows so the gate + - // is_video reads coexist with the mutable parser drain. + // Drain any AU a parser buffered past the last PES (DTS-HD tail, MPEG-2 + // final GOP), routing through the SAME B1 gate — flush frames carry their + // own `discontinuity`, so a trailing dangling-ref frame must not bypass it. let pid_to_track = &self.pid_to_track; let pending = &mut self.pending_frames; let resync = &mut self.resync; @@ -348,10 +328,9 @@ impl Stream for PipelinedPesStream { let Some(&(_, track)) = pid_to_track.iter().find(|(p, _)| p == pid) else { continue; }; - // First: the trailing access unit(s) the PS assembler - // buffered past the final fragment (the last AU has no - // following boundary). Parse them, THEN drain the parser's - // own internal buffer (MPEG-2 final GOP, DTS-HD tail). + // First the trailing AU(s) the PS assembler buffered past the + // final fragment (last AU has no following boundary); THEN drain + // the parser's own buffer (MPEG-2 final GOP, DTS-HD tail). let mut frames = Vec::new(); let tail = au_asm.get_mut(track).map(|a| a.flush()).unwrap_or_default(); for au in tail { @@ -412,10 +391,9 @@ impl Stream for PipelinedPesStream { } fn headers_ready(&self) -> bool { - // Match the previous DiscStream semantics: video tracks need - // codec_private before the consumer can write the container - // header. FREEMKV_SKIP_PARSE forces ready (no parser ever - // populates codec_private in that mode). + // Match DiscStream semantics: video tracks need codec_private before the + // consumer can write the container header. FREEMKV_SKIP_PARSE forces ready + // (no parser populates codec_private in that mode). if self.skip_parse { return true; } diff --git a/src/mux/ps.rs b/src/mux/ps.rs index f64abcd5..3bd96eba 100644 --- a/src/mux/ps.rs +++ b/src/mux/ps.rs @@ -2,7 +2,7 @@ //! //! DVDs use MPEG-2 Program Stream, which has: //! - Pack headers (00 00 01 BA) with SCR timestamps -//! - PES packets (00 00 01 [stream_id]) with variable length +//! - PES packets (00 00 01 `[stream_id]`) with variable length //! - System headers (00 00 01 BB) //! - Program end code (00 00 01 B9) //! @@ -136,11 +136,9 @@ impl PsPacket { let sub = self.sub_stream_id?; dvd_audio_pid(sub).or_else(|| dvd_subtitle_pid(sub)) } - // HD-DVD extended-stream-id (0xFD): route by the stream_id_extension - // (carried in `sub_stream_id`) to a distinct `0xFD00 | ext` PID, so a - // disc that puts several elementary streams on 0xFD keeps them apart. - // The codec (VC-1 etc.) is decided by the scanner's head probe, not - // here — this only assigns a stable routing key. + // HD-DVD extended-stream-id (0xFD): route by stream_id_extension (in + // `sub_stream_id`) to a distinct `0xFD00 | ext` PID so several elementary + // streams on 0xFD stay apart. Routing key only — scanner's probe picks codec. EXTENDED_STREAM_ID => self.sub_stream_id.map(hddvd_extended_pid), _ => None, } @@ -232,11 +230,9 @@ impl PsDemuxer { /// Flush remaining buffered data, returning any final PES packets. pub fn flush(&mut self) -> Vec<PsPacket> { - // At EOF, an unbounded (length 0) PES with no trailing start code is - // a complete-but-unterminated final packet — emit it rather than - // dropping the tail of the last frame. Genuinely incomplete packets - // (a length-bounded PES short of its declared size) are still - // discarded. + // At EOF, an unbounded (length 0) PES with no trailing start code is a + // complete-but-unterminated final packet — emit it rather than dropping the last + // frame's tail. Length-bounded PES short of its declared size is still discarded. let packets = self.extract_packets(true); self.buffer.clear(); // The buffer the cursor indexes into is gone. @@ -270,10 +266,9 @@ impl PsDemuxer { if sc + 14 > self.buffer.len() { break; // wait for more data } - // DVD-Video is always MPEG-2 PS, so every 0xBA is treated - // as a 14-byte MPEG-2 pack: the low 3 bits of byte 13 are - // pack_stuffing_length. (An MPEG-1 pack would be 12 bytes - // with no stuffing field, but DVD never emits one.) + // DVD-Video is always MPEG-2 PS, so every 0xBA is a 14-byte MPEG-2 + // pack: low 3 bits of byte 13 are pack_stuffing_length. (An MPEG-1 + // pack is 12 bytes with no stuffing field, but DVD never emits one.) let stuffing = (self.buffer[sc + 13] & 0x07) as usize; let pack_len = 14 + stuffing; if sc + pack_len > self.buffer.len() { @@ -302,12 +297,9 @@ impl PsDemuxer { let pes_packet_len = ((self.buffer[sc + 4] as usize) << 8) | self.buffer[sc + 5] as usize; - // Total bytes = 6 (start code + stream_id + length) + pes_packet_len. - // A length of 0 means unbounded (video streams); in that - // case the packet runs to the next PS-LAYER boundary (pack / - // system header / program end / next PES), NOT the next raw - // start code — the video ES payload is itself full of - // 00 00 01 xx codes that would otherwise cut the PES short. + // Length 0 means unbounded (video): the packet runs to the next PS-LAYER + // boundary (pack / system header / program end / next PES), NOT the next + // raw start code — the video ES payload is full of 00 00 01 xx codes. let end = if pes_packet_len == 0 { // Resume where the last call stopped searching for // THIS PES's terminating unit; anything before that is @@ -333,12 +325,9 @@ impl PsDemuxer { self.buffer.len() } None => { - // No boundary buffered yet. Normally wait for - // more data, but a corrupt stream could declare - // an unbounded PES followed by endless non- - // boundary bytes — bounding the buffer here - // stops untrusted input forcing unbounded - // allocation. Past the cap, flush what we have. + // No boundary buffered yet — normally wait for more data, but + // a corrupt unbounded PES could stream endless non-boundary + // bytes; cap the buffer to stop unbounded alloc, then flush. if self.buffer.len() - sc > MAX_PS_BUFFER { self.pending_scan = None; self.buffer.len() @@ -379,27 +368,17 @@ impl PsDemuxer { if self.has_base { self.buffer_base += pos as u64; } - // The cursor is a BUFFER offset, so it moves with the drain. A - // pending PES always starts at or after `pos` (the loop broke on - // it, having already consumed everything before it), so neither - // component can underflow. + // The cursor is a BUFFER offset, so it moves with the drain. A pending PES + // always starts at or after `pos` (the loop broke on it, having consumed + // everything before it), so neither component can underflow. self.pending_scan = self .pending_scan .map(|(pes_at, searched_to)| (pes_at - pos, searched_to - pos)); } - // Trim a start-code-free tail. Every other exit from the loop above - // leaves the buffer bounded (a pack, system header or length-bounded - // PES is at most ~64 KiB; a length-0 PES is force-flushed at - // MAX_PS_BUFFER), but a buffer that holds no `00 00 01` at all never - // reaches any of those branches: `find_start_code` returns None, `pos` - // stays 0 and nothing drains. Input that never contains a start code — - // a zero-filled VOB extent, or an AACS-encrypted clip probed as - // ciphertext — would then grow the buffer to the size of the whole - // title. Nothing in such a buffer can begin a PS unit except a 2-byte - // `00 00` prefix of a start code straddling the feed boundary, so keep - // exactly that and drop the rest. Lossless: the retained bytes are the - // only ones a later feed could complete into a start code. + // Trim a start-code-free tail: a buffer holding no `00 00 01` never drains, so + // zero-filled VOB or AACS ciphertext would grow it to whole-title size. Only a + // 2-byte `00 00` prefix straddling the feed boundary can start a unit — keep it. if self.buffer.len() > START_CODE_PREFIX_KEEP && find_start_code(&self.buffer, 0).is_none() { let drop = self.buffer.len() - START_CODE_PREFIX_KEEP; @@ -580,11 +559,9 @@ fn parse_pes_packet(data: &[u8]) -> Option<PsPacket> { let mut pts = None; let mut dts = None; - // The PTS (5 bytes at data[9..14]) and DTS (5 bytes at data[14..19]) - // live INSIDE the PES header, so gate on header_data_len covering them - // (>=5 for PTS, >=10 for PTS+DTS), not merely on total length. A - // non-conformant packet that sets the flags but declares a too-short - // header would otherwise read payload bytes as a bogus timestamp. + // PTS (data[9..14]) and DTS (data[14..19]) live INSIDE the PES header, so gate on + // header_data_len covering them (>=5 PTS, >=10 PTS+DTS), not total length: a packet + // setting the flags with a too-short header would read payload as a bogus timestamp. if pts_dts_flags >= 2 && header_data_len >= 5 && data.len() >= 14 { pts = parse_pts(&data[9..14]); } @@ -597,10 +574,9 @@ fn parse_pes_packet(data: &[u8]) -> Option<PsPacket> { // For private stream 1, the first payload byte is the sub-stream ID, // followed by a sub-header whose length depends on the sub-stream type. let (sub_stream_id, es_data) = if stream_id == EXTENDED_STREAM_ID { - // HD-DVD extended-stream-id: the real stream id lives in the - // stream_id_extension inside the PES extension. There is no leading - // sub-header byte on the payload (unlike private_stream_1), so the ES - // is the payload verbatim. + // HD-DVD extended-stream-id: real stream id is the stream_id_extension inside + // the PES extension. No leading sub-header byte on the payload (unlike + // private_stream_1), so the ES is the payload verbatim. ( parse_stream_id_extension(data, data[7], header_end), payload.to_vec(), @@ -609,16 +585,9 @@ fn parse_pes_packet(data: &[u8]) -> Option<PsPacket> { let sub_id = payload[0]; let skip = match sub_id { 0x80..=0x8F => 4, // AC3/DTS: sub_id + frame_count + access_unit_ptr(2) - // HD-DVD Dolby Digital Plus (E-AC-3): the sub-header is the same - // 4-byte shape as DVD AC-3 — sub_id + number_of_frames(1) + - // first_access_unit_pointer(2). Verified empirically on a real - // HD-DVD EVO: across every 0xC0..=0xC7 packet the 0x0B77 E-AC-3 syncword - // sits `first_access_unit_pointer` bytes past this 4-byte header - // (the leading bytes are the tail of the previous frame). Stripping - // exactly these 4 bytes on EVERY packet yields a clean, continuous - // E-AC-3 elementary stream that the ac3 parser reassembles across - // PES boundaries; a shorter skip would splice the sub-header bytes - // into a straddling frame and corrupt it. + // HD-DVD E-AC-3: 4-byte sub-header like DVD AC-3 (sub_id + num_frames(1) + + // first_access_unit_pointer(2)), verified on a real HD-DVD EVO. Strip exactly 4 + // bytes/packet for a clean ES; a shorter skip splices sub-header into a frame. 0xC0..=0xC7 => 4, 0xA0..=0xA7 => 7, // LPCM: sub_id + frames + ptr(2) + emphasis + quant_freq + channels _ => 1, @@ -730,18 +699,8 @@ mod tests { fn pes_header_with_pts() { let mut demuxer = PsDemuxer::new(); - // PTS = 90000 (1 second at 90kHz) - // 90000 = 0x15F90 - // bit32=0, bits 29-15 = 0x0002BF, bits 14-0 = 0x1F90 - // byte0: 0010_0_1 = 0x21 ... actually let's encode properly: - // - // pts = 90000 - // byte0: (0010 << 4) | ((pts >> 29) & 0x0E) | 1 - // = 0x20 | ((90000 >> 29) & 0x0E) | 1 = 0x20 | 0 | 1 = 0x21 - // byte1: (pts >> 22) & 0xFF = (90000 >> 22) & 0xFF = 0 - // byte2: ((pts >> 14) & 0xFE) | 1 = ((90000 >> 14) & 0xFE) | 1 = (0x0A & 0xFE) | 1 = 0x0B - // byte3: (pts >> 7) & 0xFF = (90000 >> 7) & 0xFF = (703) & 0xFF = 0xBF - // byte4: ((pts & 0x7F) << 1) | 1 = ((90000 & 0x7F) << 1) | 1 = (0x10 << 1) | 1 = 0x21 + // PTS = 90000 (1 second at 90kHz), encoded via encode_pts with prefix 0x20; + // expected 5 bytes are 0x21,0x00,0x0B,0xBF,0x21 (marker bits set in bytes 0/2/4). let pts_bytes = encode_pts(90000, 0x20); @@ -941,10 +900,9 @@ mod tests { #[test] fn unbounded_video_pes_not_cut_by_embedded_start_codes() { - // A length-0 video PES whose ES payload contains embedded MPEG start - // codes (picture 0x00, slice 0x01, GOP 0xB8, sequence 0xB3) must be - // delimited by the NEXT PS-layer boundary (here a program-end 0xB9), - // not by the first embedded 00 00 01 inside the payload. + // A length-0 video PES whose ES payload has embedded MPEG start codes (picture, + // slice, GOP, sequence) must be delimited by the NEXT PS-layer boundary (here a + // program-end 0xB9), not by the first embedded 00 00 01 inside the payload. let mut demuxer = PsDemuxer::new(); let mut data = vec![ @@ -983,11 +941,9 @@ mod tests { #[test] fn input_with_no_start_code_at_all_is_bounded() { - // An extent that never contains a 00 00 01 start code — a zero-filled - // VOB extent, or an AACS-encrypted clip probed as ciphertext (see - // src/disc/hddvd.rs) — must not accumulate. The whole-title feed in - // src/mux/disc.rs would otherwise grow the buffer to the size of the - // title (up to ~90 GB for UHD). + // An extent with no 00 00 01 start code — zero-filled VOB, or AACS ciphertext + // probed raw (see src/disc/hddvd.rs) — must not accumulate. The whole-title feed + // in src/mux/disc.rs would otherwise grow the buffer to title size (~90 GB UHD). let mut demuxer = PsDemuxer::new(); let chunk = vec![0u8; 1024 * 1024]; for _ in 0..(MAX_PS_BUFFER / chunk.len() + 8) { @@ -1024,11 +980,9 @@ mod tests { #[test] fn unbounded_video_pes_over_cap_is_force_flushed() { - // A corrupt stream declaring an unbounded PES followed by endless - // non-boundary bytes must not grow the buffer without limit. - // NOTE: this case feeds a real start code first, so it exercises only - // the in-PES cap; the no-start-code path is covered by - // `input_with_no_start_code_at_all_is_bounded`. + // A corrupt unbounded PES followed by endless non-boundary bytes must not grow + // the buffer without limit. This feeds a real start code first, exercising only + // the in-PES cap; no-start-code path → `input_with_no_start_code_at_all_is_bounded`. let mut demuxer = PsDemuxer::new(); let header = vec![0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x00, 0x00]; let packets = demuxer.feed(&header); @@ -1082,10 +1036,9 @@ mod tests { #[test] fn parse_extended_stream_id_extracts_stream_id_extension() { - // SHAUN's VC-1 video PES: stream_id 0xFD, flags2=0x01 (PES_extension - // only), header_data_length=3, optional bytes 0x0F/... — build the - // minimal well-formed variant: ext_flags=0x01 (PES_extension_flag_2), - // field_len=0x81, stream_id_extension=0x55. Payload is the ES. + // SHAUN's VC-1 video PES: stream_id 0xFD, flags2=0x01 (PES_extension only), + // header_data_length=3. Minimal well-formed variant: ext_flags=0x01 + // (PES_extension_flag_2), field_len=0x81, stream_id_extension=0x55. Payload is ES. let mut pkt = vec![0x00, 0x00, 0x01, EXTENDED_STREAM_ID]; let opt = [0x01u8, 0x81, 0x55]; let es = [0xDEu8, 0xAD, 0xBE, 0xEF]; @@ -1111,11 +1064,9 @@ mod tests { #[test] fn parse_extended_stream_id_skips_pts_and_dts_before_the_extension() { - // The common real case: an AU-opening 0xFD VC-1 video PES carries a PTS - // (and often DTS) in the optional-header region, which the parser must - // SKIP (PTS +5, DTS +5) to reach the PES_extension → stream_id_extension. - // Both branches were previously untested (flags2 there was 0x01, skipping - // everything), so an off-by-one in the skip would silently misroute video. + // Common real case: an AU-opening 0xFD VC-1 PES carries PTS (often DTS) in the + // optional-header region, which the parser must SKIP (PTS +5, DTS +5) to reach + // PES_extension → stream_id_extension. An off-by-one here silently misroutes video. let build = |flags2: u8, skip: usize| { let mut pkt = vec![0x00, 0x00, 0x01, EXTENDED_STREAM_ID]; // optional region: `skip` bytes (PTS/DTS placeholders) then @@ -1184,10 +1135,9 @@ mod tests { #[test] fn mixed_codec_audio_does_not_collide() { - // The core regression: a title mixing AC-3 (0x80), DTS (0x88) and - // LPCM (0xA0) audio. The old per-codec relative arithmetic mapped - // all three to 0xBD00. They must now get distinct PIDs that match - // what dvd.rs assigns from the same dvd_audio_pid() table. + // Core regression: a title mixing AC-3 (0x80), DTS (0x88) and LPCM (0xA0) audio. + // The old per-codec relative arithmetic mapped all three to 0xBD00; they must now + // get distinct PIDs matching what dvd.rs assigns from the same dvd_audio_pid(). let ac3 = mk(0xBD, Some(0x80)).dvd_pid().unwrap(); let dts = mk(0xBD, Some(0x88)).dvd_pid().unwrap(); let lpcm = mk(0xBD, Some(0xA0)).dvd_pid().unwrap(); @@ -1322,12 +1272,9 @@ mod tests { #[test] fn pack_header_stuffing_length_consumed() { - // pack_stuffing_length = low 3 bits of byte 13 (ISO 13818-1 - // §2.5.3.4). The demuxer must skip exactly 14 + stuffing bytes. The - // stuffing region here holds a DECOY PES start code (00 00 01 E0…); - // if the stuffing count is under-consumed the scanner would re-sync - // onto that decoy and emit a bogus PES. Correct skip lands directly - // on the REAL PES. + // pack_stuffing_length = low 3 bits of byte 13 (ISO 13818-1 §2.5.3.4); skip + // exactly 14 + stuffing bytes. The stuffing holds a DECOY PES start code + // (00 00 01 E0…): under-consuming it re-syncs onto the decoy and emits a bogus PES. let mut demuxer = PsDemuxer::new(); let mut data = vec![ 0x00, 0x00, 0x01, 0xBA, 0x44, 0x00, 0x04, 0x00, 0x04, 0x01, 0x01, 0x89, 0xC3, @@ -1591,10 +1538,9 @@ mod tests { #[test] fn hddvd_ddplus_pes_strips_4byte_subheader_to_syncword() { - // A private_stream_1 PES carrying DD+ (sub-id 0xC0) has a 4-byte - // sub-header (sub_id + num_frames(1) + access_unit_ptr(2)); the demuxer - // must strip exactly those 4 bytes so es_data begins at the E-AC-3 - // payload — here the 0x0B77 syncword sits right after the sub-header. + // A private_stream_1 PES carrying DD+ (sub-id 0xC0) has a 4-byte sub-header + // (sub_id + num_frames(1) + access_unit_ptr(2)); the demuxer must strip exactly + // those 4 so es_data begins at the E-AC-3 payload (0x0B77 syncword right after). let mut demuxer = PsDemuxer::new(); let mut data = vec![ 0x00, 0x00, 0x01, 0xBD, // private stream 1 @@ -1676,10 +1622,9 @@ mod tests { #[test] fn unbounded_video_pes_framed_by_next_pes_not_embedded_audio_code() { - // An unbounded (length 0) video PES must be delimited by the next - // PS-layer unit. A following AUDIO PES (0xC0) is a valid boundary, - // so the video ES must include its embedded 00 00 01 00 picture - // code but stop at the audio PES start. + // An unbounded (length 0) video PES must be delimited by the next PS-layer unit. + // A following AUDIO PES (0xC0) is a valid boundary, so the video ES keeps its + // embedded 00 00 01 00 picture code but stops at the audio PES start. let mut demuxer = PsDemuxer::new(); let mut data = vec![0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x00, 0x00]; let video_payload = [0x11, 0x00, 0x00, 0x01, 0x00, 0x22]; // embedded picture SC diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 71c76798..fa090d97 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -292,10 +292,9 @@ fn validate_network_addr(addr: &str) -> io::Result<()> { } .into()); } - // A bare IPv6 literal ("::1", "2001:db8::1") contains ':' yet has no port, - // so the simple `contains(':')` check would wrongly pass it and TcpListener - // would later return an untyped io::Error. Treat anything that parses as a - // bare IpAddr (v4 or v6) as port-less. + // A bare IPv6 literal ("::1") contains ':' but has no port, so the plain + // `contains(':')` check would wrongly pass it and yield an untyped + // io::Error later; treat anything parsing as IpAddr as port-less. if addr.parse::<std::net::IpAddr>().is_ok() { return Err(crate::error::Error::StreamUrlMissingPort { addr: addr.to_string(), @@ -308,10 +307,9 @@ fn validate_network_addr(addr: &str) -> io::Result<()> { } .into()); } - // Split host:port on the LAST ':' so a bracketed IPv6 literal - // (`[2001:db8::1]:9000`) splits at the port colon, not an address colon. - // Require the port substring to be a non-empty u16 — `host:` (empty) and - // `host:abc` (non-numeric) are invalid, despite containing ':'. + // Split on the LAST ':' so a bracketed IPv6 literal (`[::1]:9000`) splits + // at the port colon, not an address colon. Port must be a non-empty u16 — + // `host:` and `host:abc` are invalid despite containing ':'. let port = match addr.rsplit_once(':') { Some((_host, port)) => port, None => { @@ -379,17 +377,14 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S StreamUrl::Disc { .. } => { // Disc sources require live SCSI state — caller must use // `Drive::open() + Disc::scan() + DiscStream::new()` directly. - // Surfaced as a typed error (no English commentary in the - // library; the CLI/UI explains the right entry point). + // Typed error only; the CLI/UI explains the entry point. Err(crate::error::Error::DiscUrlNotDirect.into()) } StreamUrl::Iso { ref path } => { validate_file_path(path, "iso")?; - // FileSectorSource is the sole file-backed sector source. - // It carries the platform-tuned SEQUENTIAL fadvise hint - // (so the kernel readahead window widens) and the periodic - // DONTNEED page-cache eviction that bounds memory pressure - // when the mux output is being written to the same disk. + // Sole file-backed sector source: carries the platform SEQUENTIAL + // fadvise hint (widens kernel readahead) plus periodic DONTNEED + // eviction bounding memory pressure during same-disk mux output. let reader = crate::io::file_sector_source::FileSectorSource::open(path)?; let probe_path = path.clone(); let stream = image_input( @@ -400,13 +395,9 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S )?; Ok(Box::new(stream)) } - // `dir://` as a SOURCE: an extracted disc folder, presented as a - // synthetic UDF image (`crate::dirimage`). It reaches EXACTLY the same - // body as `iso://` above — scan, title select, key resolution, mux — - // because by the time `DirImage` exists it is just another - // `SectorSource`. That is the point of synthesizing a real filesystem - // rather than faking a tree: every destination follows for free, with - // no per-scheme mux path to keep in step. + // `dir://` as a SOURCE: an extracted disc folder presented as a + // synthetic UDF image (`crate::dirimage`), reaching the exact same + // body as `iso://` since `DirImage` is just another `SectorSource`. StreamUrl::Dir { ref path } => { validate_file_path(path, "dir")?; let reader = crate::dirimage::DirImage::open(path)?; @@ -501,15 +492,9 @@ where disc.decrypt_with(crate::disc::Key::Unit(opts.unit_keys.clone()), &[]) .map_err(|e| -> io::Error { e.into() })?; } - // Pre-flight decrypt gate (the single, system-wide verdict — see - // `Disc::ensure_decryptable`). Fails fast BEFORE any mux work when - // decryption is needed and unavailable: a scrambled-but-uncracked - // CSS disc (`css_error` set), or an AACS-encrypted disc with no - // usable key (would mux ~100 MB of garbage — encrypted m2ts → no TS - // syncs → demuxer emits nothing → empty/garbage output at exit 0). - // `--raw` and unencrypted/CSS-keyless-success discs pass. This is the - // disc-wide check; the per-title (multi-VTS CSS) check is below, once - // the chosen title's key is resolved. + // Pre-flight decrypt gate (see `Disc::ensure_decryptable`): fails fast + // on a scrambled-but-uncracked CSS disc or keyless AACS disc (else + // muxes garbage). Disc-wide only; per-title CSS check is below. disc.ensure_decryptable(opts.raw) .map_err(|e| -> io::Error { e.into() })?; if disc.titles.is_empty() { @@ -523,60 +508,41 @@ where } .into()); } - // Prune to the selected audio/subtitle streams now, on the scanned - // (pre-`probe_and_remap`) title, so everything downstream — the - // TrueHD channel-correction probe, the final title clone, and - // `build_iso_pipeline`'s demux/track construction — sees the pruned - // list. Video is always kept; a no-op for the default All/All. + // Prune to selected audio/subtitle streams now, pre-`probe_and_remap`, + // so all downstream consumers (TrueHD probe, title clone, + // `build_iso_pipeline`) see the pruned list. Video always kept. opts.selection .apply(&mut disc.titles[idx]) .map_err(|e| -> io::Error { e.into() })?; - // Per-title key resolution. DVD CSS is resolved at exactly ONE site — - // `build_iso_pipeline`'s per-title crack (below), which decrypts a - // crackable title, passes a genuinely-clear one through, and - // hard-fails an uncrackable one with CssKeyMissing. So for a DVD we do - // NOT pre-crack here: pass `None` and let the pipeline own it. - // Pre-cracking would re-open the ISO and re-scan every clear title - // (`decrypt_keys_for_title` → None → the pipeline re-cracks anyway). - // AACS / unencrypted resolve from `decrypt_keys()` with NO read; `--raw` - // (any format) is deliberate ciphertext passthrough — also `None`. + // DVD CSS resolves at exactly one site, `build_iso_pipeline`'s per-title + // crack below, so a DVD passes `None` here. AACS/unencrypted resolve + // via `decrypt_keys()`; `--raw` is deliberate passthrough — also `None`. let is_dvd = disc.format == crate::disc::DiscFormat::Dvd; let (keys, title_is_clear) = if opts.raw || is_dvd { (crate::decrypt::DecryptKeys::None, false) } else { (disc.decrypt_keys(), false) }; - // Decrypt gate for the AACS / non-DVD path: a None key means no usable - // disc key, which would mux scrambled ciphertext verbatim — fail loudly - // (NoDiscKey). The DVD path is gated inside `build_iso_pipeline` (its - // CSS hard-fail), and `--raw` passes. + // Decrypt gate for AACS/non-DVD: a None key means no usable disc key, + // which would mux ciphertext verbatim — fail loudly (NoDiscKey). DVD + // is gated inside `build_iso_pipeline`'s CSS hard-fail; `--raw` passes. if !is_dvd { disc.ensure_title_decryptable(opts.raw, &keys, title_is_clear) .map_err(|e| -> io::Error { e.into() })?; } - // FMTS (AACS 2.1) forensic segments are sourced + fail-loud-checked - // downstream by `resolve_mux_key_map`/`resolve_fmts_key_map`, which hold - // the key-fetch closure and can actually attempt resolution. (An older - // upfront blanket-reject gate lived here; it predated the resolver and - // rejected every 2.1 disc before a source could be tried.) - // Correct TrueHD channel counts (MPLS understates 7.1/Atmos as 5.1) - // by probing the first DECRYPTED access units of the chosen title. - // A fresh reader avoids disturbing the mux reader below. Skipped in - // --raw mode: the probe would re-open + decrypt for nothing (on an - // AACS disc with no key the correction is a no-op on ciphertext, and - // raw output isn't decoded anyway). + // FMTS (AACS 2.1) segments are sourced + checked downstream by + // `resolve_mux_key_map`/`resolve_fmts_key_map`, which hold the + // key-fetch closure and can actually attempt resolution. + + // Correct TrueHD channel counts (MPLS understates 7.1/Atmos as 5.1) by + // probing decrypted access units via a fresh reader; skipped in --raw + // since the probe would re-open + decrypt for nothing. if !opts.raw { match reopen() { Some(mut probe) => { - // The probe DECRYPTS the title head, so it needs the SAME - // up-front key map the mux read installs below. An AACS - // `DecryptingSectorSource` with no map fails loud on the - // first unit (`decrypt_sectors_mapped` is the only AACS - // decrypt path) — without resolving one here the correction - // is silently skipped on every AACS disc and 7.1/Atmos stays - // understated as the MPLS-declared 5.1. Resolution failure is - // non-fatal (`.ok()`): leave channels uncorrected, never - // fail the mux. + // The probe decrypts the title head, so it needs the same + // key map the mux read installs below, or an AACS source + // fails loud on the first unit. `.ok()`: non-fatal. let mut probe_keys = keys.clone(); let probe_title = disc.titles[idx].clone(); let probe_map = match &probe_keys { @@ -586,8 +552,7 @@ where &mut probe_keys, opts.key_fetch.as_ref(), disc.content_format, - // File-backed, bounded probe (best-effort `.ok()`); - // no live drive to protect from a stuck stop here. + // Bounded, file-backed probe; no live drive here. None, ) .ok() @@ -601,9 +566,8 @@ where crate::disc::correct_truehd_channels(&mut dec, &mut disc.titles[idx]); } None => { - // Non-fatal: a failed re-open just leaves MPLS 7.1/Atmos - // channel counts uncorrected (understated as 5.1). Log so - // the uncorrected path is diagnosable rather than silent. + // Non-fatal: a failed re-open leaves MPLS 7.1/Atmos counts + // uncorrected (as 5.1). Log so it's diagnosable, not silent. tracing::debug!( target: "mux", "TrueHD channel-correction probe re-open failed" @@ -613,26 +577,21 @@ where } let title = disc.titles[idx].clone(); let format = disc.content_format; - // ISO file: 8192-sector batch (16 MiB at 2048 B/sector) — - // sequential read from fast storage, no bad sectors. Empirically - // optimal; bumping to 16384 sectors (32 MiB) regressed (more cache - // pressure, longer per-batch latency starves the consumer between - // iterations). Physical drives keep smaller batches for adaptive - // error handling. + // ISO file: 8192-sector batch (16 MiB) — sequential read from fast + // storage, no bad sectors. Empirically optimal; 16384 sectors regressed + // (more cache pressure, longer per-batch latency starves the consumer). const ISO_MUX_BATCH_SECTORS: u16 = 8192; - // Pass `DecryptKeys::None` to the decrypt decorator when - // --raw is set — the read stack still flows through the - // same producer+demux+parse pipeline, just without the - // AACS / CSS step. Single highway for all ISO reads. + // Pass `DecryptKeys::None` to the decrypt decorator when --raw is set — + // the read stack still flows through the same producer+demux+parse + // pipeline, just without the AACS/CSS step. One highway for ISO reads. let effective_keys = if opts.raw { crate::decrypt::DecryptKeys::None } else { keys }; - // Install the shared fetch closure (if the app supplied one) so a - // unit no held key decrypts is re-tried via the app's key source. - // Suppressed in --raw (no decrypt step to recover). + // Install the shared fetch closure (if supplied) so a unit no held key + // decrypts is re-tried via the app's key source. Suppressed in --raw. let fetch = if opts.raw { None } else { @@ -670,13 +629,9 @@ pub fn output( match parsed { StreamUrl::Mkv { ref path } => { validate_file_path(path, "mkv")?; - // Wrap the output in `crate::io::WritebackFile` (bounded-cache - // writeback) so a UHD-scale MKV mux to slow / network-attached - // staging doesn't hit the dirty-page burst pathology that - // sweep already side-steps. BufWriter sits on top to coalesce - // mux's many small EBML element writes. Pre-reserve the - // target's worth of extents on Linux via fallocate(KEEP_SIZE) - // to reduce extent fragmentation during the mux. + // Wrap in `WritebackFile` (bounded-cache) so a UHD mux to slow/ + // network staging avoids dirty-page bursts. BufWriter coalesces + // small EBML writes; Linux fallocate(KEEP_SIZE) cuts fragmentation. let writer: Box<dyn super::WriteSeek + Send> = Box::new(std::io::BufWriter::with_capacity( IO_BUF_SIZE, @@ -686,10 +641,9 @@ pub fn output( } StreamUrl::Mp4 { ref path } => { validate_file_path(path, "mp4")?; - // Bounded-cache writeback (like mkv://) so a UHD-scale mux to slow / - // network-attached staging doesn't hit the dirty-page burst - // pathology; the mdat backpatch is an ordinary seek WritebackFile - // handles. BufWriter coalesces the many small moov box-header writes. + // Bounded-cache writeback (like mkv://) avoids dirty-page bursts + // on slow/network staging; the mdat backpatch is an ordinary + // seek WritebackFile handles. BufWriter coalesces moov writes. let writer = std::io::BufWriter::with_capacity( IO_BUF_SIZE, crate::io::WritebackFile::create_with_size_hint(path, title.size_bytes)?, @@ -705,13 +659,9 @@ pub fn output( Ok(Box::new(M2tsStream::create(writer, title)?)) } StreamUrl::Network { ref addr } => { - // Format-validate, then connect. `NetworkStream::connect` - // re-resolves the host and refuses any address that is - // loopback / private / link-local / multicast — this is the - // SSRF / DNS-rebinding guard, applied at the actual connect - // (not just at settings-save time). It is deliberately NOT in - // `validate_network_addr`, which is shared with the listen - // (receiver) path where binding loopback is legitimate. + // `NetworkStream::connect` re-resolves the host and refuses + // loopback/private/link-local/multicast — the SSRF/rebinding + // guard, not in `validate_network_addr` (shared with listen). validate_network_addr(addr)?; Ok(Box::new(NetworkStream::connect(addr)?.meta(title))) } @@ -719,22 +669,18 @@ pub fn output( StreamUrl::Null => Ok(Box::new(NullStream::new(title))), StreamUrl::Disc { .. } => Err(crate::error::Error::StreamReadOnly.into()), StreamUrl::Iso { .. } => Err(crate::error::Error::StreamReadOnly.into()), - // `dir://` is NOT a PES sink — it writes raw decrypted files, not muxed - // frames. A stray `dir://` routed into the mux/PES path fails loudly, - // exactly the category the crate already rejects for `iso://`. The CLI - // routes a `dir://` dest to `Disc::extract_tree` before reaching here. + // `dir://` is NOT a PES sink — it writes raw decrypted files, not + // muxed frames. A stray `dir://` here fails loudly, like `iso://`. + // The CLI routes a `dir://` dest to `Disc::extract_tree` first. StreamUrl::Dir { .. } => Err(crate::error::Error::StreamReadOnly.into()), // `demux://` with default options. The CLI constructs `DemuxSink` - // directly (with parsed flags) before reaching here, mirroring how a - // `dir://` dest is special-cased; this arm covers the bare - // `output()` call with the default option set. + // directly (with parsed flags) before reaching here; this arm + // covers the bare `output()` call with the default option set. StreamUrl::Demux { ref dir } => { validate_file_path(dir, "demux")?; - // The full `--demux/--naming/--delay/--container/--chapters` flag - // surface is parsed in the CLI, which constructs `DemuxSink` directly. - // This bare `output()` arm uses defaults but still seeds the filename - // `base` from the title's playlist name when present (the default - // "title" stem is only a last resort for an unnamed title). + // Full `--demux/--naming/--delay/--container/--chapters` flags + // are parsed in the CLI, which builds `DemuxSink` directly. This + // bare arm uses defaults but seeds `base` from the playlist name. let mut opts = super::demux_sink::DemuxOptions::default(); if !title.playlist.is_empty() { opts.base = title.playlist.clone(); @@ -743,10 +689,9 @@ pub fn output( dir, title, &opts, )?)) } - // `video://`, `audio://`, and `sub://` are `demux://` restricted to one - // track class — video as native elementary streams, audio in native - // containers, or subtitles as `.sup`/`.idx+.sub`/`.srt`. No chapters - // sidecar (that's a `demux://` / `chapters://` concern). + // `video://`, `audio://`, `sub://` are `demux://` restricted to one + // track class. No chapters sidecar (that's a `demux://`/`chapters://` + // concern). StreamUrl::Video { ref dir } | StreamUrl::Audio { ref dir } | StreamUrl::Sub { ref dir } => { @@ -769,13 +714,8 @@ pub fn output( )?)) } // `fvi://` writes the per-picture video index (`docs/FVI_FORMAT.md`). - // The header's `source` object describes the INPUT, so it comes from the - // caller-supplied `source` — never from `path`, which is the destination - // this sink writes. Passing the destination here made every index name - // itself as its own source AND made the output non-reproducible (two - // machines indexing identical bytes emitted different files purely from - // where they wrote). `None` → the neutral defaults; the optional members - // are omitted rather than guessed. + // The header's `source` describes the INPUT, from caller-supplied + // `source` — never `path`, which previously broke reproducibility. StreamUrl::Fvi { ref path } => { validate_file_path(path, "fvi")?; Ok(Box::new(super::fvi_sink::FviSink::create( @@ -784,7 +724,7 @@ pub fn output( source.cloned().unwrap_or_default(), )?)) } - // `chapters://` and `json://` write the title metadata at construction and + // `chapters://`/`json://` write title metadata at construction and // ignore the PES stream (see `meta_sink`). StreamUrl::Chapters { ref path } => { validate_file_path(path, "chapters")?; @@ -829,9 +769,8 @@ pub(crate) fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> Dem pid_to_track.push((pid, idx)); let is_dvd_ps = matches!(format, ContentFormat::MpegPs); // The Blu-ray 3D MVC dependent (right-eye) view uses a param-set- - // passthrough H.264 parser so each frame is a self-contained dependent - // access unit for a BlockAdditional; every other stream uses the - // ordinary parser for its codec. + // passthrough H.264 parser so each frame is a self-contained + // dependent access unit for a BlockAdditional. let parser = match s { crate::disc::Stream::Video(v) if v.is_mvc_dependent() => { super::codec::parser_for_mvc_dependent(codec, is_dvd_ps) @@ -1057,37 +996,23 @@ fn resolve_fmts_key_map( } = cache; use crate::aacs::segment::{clip_byte_to_lba, parse_individual_segments}; - // Cooperative cancel: this probes the LIVE drive across up to a few hundred - // `read_sectors` (the anchor + per-index probe loops), each able to stall to - // the SCSI recovery timeout. An operator `/api/stop` during forensic key - // resolution must be honored at each loop boundary rather than blocking until - // the whole probe completes (hard rule: don't hammer a struggling live drive). + // Cooperative cancel: probes the LIVE drive across up to a few hundred + // `read_sectors`, each able to stall to the SCSI timeout. `/api/stop` + // must be honored at each loop boundary, not just after completion. let check_halt = || -> io::Result<()> { if halt.is_some_and(|h| h.is_cancelled()) { return Err(crate::error::Error::Halted.into()); } Ok(()) }; - // Poll once on ENTRY as well as inside the probe loops: with both memos warm a - // title can reach the finished map without touching a single loop, so a - // 60-playlist sweep would otherwise run to completion after an operator Stop. + // Poll on ENTRY too: with both memos warm a title can reach the finished + // map without touching a loop, so a 60-playlist sweep would otherwise + // run to completion after an operator Stop. check_halt()?; - // Load the segment map. Distinguish a genuine "not an FMTS disc" negative - // from a transient live-drive I/O fault: swallowing the latter into Ok(None) - // would fall through to a base-Unit-Key-only map, garble the forensic units, - // let the demux drop them, and complete the mux with NO error — silently - // losing forensic content, contradicting this function's fail-loud contract. - // - `UdfNotFilesystem`: bytes read fine but are not a UDF disc (deterministic - // tag/format mismatch) → genuinely not FMTS → Ok(None). - // - `UdfNotFound`: the disc is UDF but has no `IndividualSegment.tbl` - // → genuinely not FMTS → Ok(None). - // - any other error (notably `DiscRead`): a read fault → propagate so the - // rip fails loud / can be retried rather than dropping forensic content. - // …and memoise the outcome for the whole disc: the walk reads the same fixed low - // LBAs and the same file for every title (see `FmtsTableCache`). Only the two - // DETERMINISTIC negatives are cached; a read fault propagates uncached so a - // later title still retries. + // Load the segment map. Distinguish "not FMTS" (`UdfNotFilesystem`/ + // `UdfNotFound` → Ok(None)) from a transient I/O fault (propagates, else + // silently loses forensic content). Only the two negatives are memoised. if table.is_none() { let udf = match crate::udf::read_filesystem(reader) { Ok(u) => Some(u), @@ -1106,10 +1031,9 @@ fn resolve_fmts_key_map( .as_deref() .and_then(parse_individual_segments) .filter(|s| !s.is_empty()); - // Locate the forensic feature clip in the SAME walk — the byte space the - // segment SPNs are relative to (see `FmtsClipCache`). Only an FMTS disc pays - // for the lookup. Assigned before `*table` so a read fault on the clip's ICB - // leaves NEITHER memo filled and a later title retries the whole thing. + // Locate the forensic feature clip in the SAME walk — the byte space + // segment SPNs are relative to (`FmtsClipCache`). Assigned before + // `*table` so a read fault leaves NEITHER memo filled and retries. *clip = Some(match (&parsed, udf.as_ref()) { (Some(_), Some(u)) => forensic_clip_extents(u, reader)?, _ => None, @@ -1120,69 +1044,48 @@ fn resolve_fmts_key_map( return Ok(None); // not an FMTS disc }; let segments = segments.clone(); - // ── ANCHOR the segment byte space. The SPNs are offsets into the FORENSIC - // FEATURE clip, NOT into this title's extent concatenation: a playlist that - // plays a trailer before the feature has the trailer's sectors FIRST in - // `title.extents`, so mapping clip byte 0 to `title.extents[0]` puts every - // segment in the wrong clip — the anchor probe then samples the trailer (no - // index key anchors ⇒ `FmtsKeyMissing` aborts the whole disc) or, worse, index - // keys get applied to non-forensic sectors while the real forensic units keep - // the base key: silent garble, no error. So the arithmetic is anchored on the - // forensic clip's OWN extents, which are a disc fact read from UDF. ───────── + // ANCHOR the segment byte space: SPNs are offsets into the FORENSIC clip, + // not this title's extents (a trailer-first playlist would else map + // segments to the wrong clip). Anchored on the clip's own UDF extents. let Some(Some(clip_extents)) = clip.as_ref() else { - // The disc carries a non-empty `IndividualSegment.tbl` — it HAS forensic - // content — but the clip that content lives in could not be identified, so - // there is no defensible anchor for the SPNs. Fail loud: a hard, retryable - // error beats silently mapping index keys onto the wrong clip's sectors. + // Disc has a non-empty `IndividualSegment.tbl` but its clip could not + // be identified, so there's no defensible SPN anchor. Fail loud: a + // retryable error beats silently mapping keys onto wrong sectors. tracing::warn!(target: "freemkv::keysource", segments = segments.len(), "fmts: forensic feature clip not identifiable — refusing an unanchored segment map"); return Err(crate::error::Error::FmtsKeyMissing.into()); }; let clip_extents = clip_extents.clone(); - // A title that does not read the forensic clip's sectors carries no forensic - // content (a menu/extras playlist, or simply a different clip): its base Unit - // Key/CPS map applies and there is nothing forensic to resolve — fall back - // (`Ok(None)`) rather than hard-failing. Without this, `resolve_content_key_map` - // — which resolves EVERY title for the whole-disc sweep — aborts the entire - // decrypt on the first non-forensic title (a menu playlist), and - // `build_iso_pipeline` aborts muxing any non-main title. + // A title not overlapping the forensic clip (menu/extras, or a + // different clip) has nothing forensic to resolve — fall back to + // `Ok(None)` rather than hard-failing every non-main title's mux. if !extents_overlap(&title.extents, &clip_extents) { return Ok(None); } - // Keep only the segments the forensic clip actually contains: a record whose - // clip bytes run past the clip's end belongs to no readable sector (a stale or - // foreign table entry) and must not be mapped. + // Keep only segments the clip actually contains: bytes running past the + // clip's end belong to a stale/foreign table entry, must not be mapped. let segments = filter_addressable_segments(segments, &clip_extents); if segments.is_empty() { return Ok(None); } - // This title HAS forensic content, so the forensic index keys are REQUIRED — - // exactly like a Unit Key. Without a configured key source we cannot obtain - // them, so we cannot produce a complete rip: fail loud rather than silently - // drop the forensic segments. (The caller may still choose `--raw`, which never - // reaches this path.) + // This title HAS forensic content, so the index keys are REQUIRED, like + // a Unit Key. No key source means no complete rip: fail loud rather than + // silently drop segments (`--raw` never reaches this path). let Some(fetch) = fetch else { return Err(crate::error::Error::FmtsKeyMissing.into()); }; - // ── The expensive half — the anchor probe, the per-index phase probe and the - // ONE key-service round trip — reads only the FORENSIC CLIP's sectors, so it is - // resolved at most once per disc; it stays keyed on the title's extent list - // (`FmtsKeyCache`), a key FINER than the answer needs, so each distinct extent - // list keeps its own verdict rather than inheriting another's. Without this, - // `resolve_content_key_map` re-ran the whole probe AND re-asked the key service - // once per playlist: on a 60-playlist disc that is 60 identical key-service - // round trips (the storm) and tens of thousands of random 6144-byte reads for - // one disc-wide answer. ─────────────────────────────────────────────────────── + // The expensive half (anchor + phase probes, ONE key-service round trip) + // reads only the forensic clip's sectors, resolved at most once per disc + // via `FmtsKeyCache` — else a 60-playlist disc repeats it 60x. let ek = extent_key(format, title); let (index_keys, phase_of_index) = match memo.get(&ek) { Some((k, p)) => (k.clone(), p.clone()), None => { let probed = probe_fmts_index_keys(reader, &clip_extents, &segments, fetch, format, halt)?; - // Only memoise a run whose every index reached a DEFINITE phase. A - // read-faulted index defaulted to `Phase::All` is a property of a - // transient live-drive fault, not of these extents — caching it would - // spread one bad read across every remaining title. + // Only memoise a run whose every index reached a DEFINITE phase — + // a read-faulted index defaulting to `Phase::All` is a transient + // fault, not a property of these extents; caching it would spread. if probed.all_phases_definite { memo.insert(ek, (probed.keys.clone(), probed.phases.clone())); } @@ -1190,11 +1093,9 @@ fn resolve_fmts_key_map( } }; - // Map array position → forensic index (element i = index i+1); add each key to - // the pool and remember its slot by tag. Per TITLE, and deliberately so: the - // pool is the caller's and grows across titles, so a title reached with the keys - // already banked finds them by value at the same slots instead of appending - // duplicates. + // Map array position → forensic index (element i = index i+1); add each + // key to the pool and remember its slot by tag. Per TITLE, deliberately: + // the pool grows across titles, so already-banked keys reuse their slots. let mut tag_slot: std::collections::HashMap<u16, usize> = std::collections::HashMap::new(); if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys { for (i, k) in index_keys.iter().enumerate() { @@ -1203,10 +1104,9 @@ fn resolve_fmts_key_map( Some(s) => s, None => { let s = unit_keys.len(); - // CPS-unit id is cosmetic for the mapped decrypt (it indexes by - // slot), so it carries the forensic TAG instead: it is what tells - // the single-CPS short-circuit these are not base CPS unit keys - // (see `FMTS_POOL_TAG_BASE` / `single_base_key_slot`). + // CPS-unit id is cosmetic here (mapped decrypt indexes by + // slot), so it carries the forensic TAG instead, telling + // the single-CPS short-circuit these aren't base keys. unit_keys.push((FMTS_POOL_TAG_BASE.saturating_add(s as u32), *k)); s } @@ -1215,18 +1115,16 @@ fn resolve_fmts_key_map( } } - // ── Build the per-segment LBA ranges: each forensic segment → its tag's key - // AND its index's phase. The mapped decrypt opens only that half and leaves - // the alternate as ciphertext (the muxer drops untouched ciphertext) — - // clean by construction, no garble. A segment straddling an extent boundary - // is left unmapped and tallied (a hard failure below). ──────────────────── + // Build per-segment LBA ranges: each segment → its tag's key and index's + // phase, leaving the alternate as ciphertext. A boundary-straddling + // segment is unmapped and tallied (hard failure below). let mut ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)> = 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`.) + // SPNs are untrusted; 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; @@ -1241,8 +1139,8 @@ fn resolve_fmts_key_map( .unwrap_or(crate::decrypt::Phase::All); let start_byte = seg.start_spn as u64 * 192; let end_byte = (seg.end_spn as u64 + 1) * 192; - // Clip bytes → LBAs through the FORENSIC CLIP's extents (see the anchor note - // above), never the title's. + // Clip bytes → LBAs through the FORENSIC CLIP's extents, never the + // title's. let (Some(a), Some(b)) = ( clip_byte_to_lba(&clip_extents, start_byte), clip_byte_to_lba(&clip_extents, end_byte - 1), @@ -1250,43 +1148,31 @@ fn resolve_fmts_key_map( unresolved += 1; continue; }; - // Only emit a contiguous within-extent range (segments are ~480 KB; a rare - // extent-straddle is left unresolved rather than given a wrong span). + // Only emit a contiguous within-extent range (segments are ~480 KB; + // a rare extent-straddle is left unresolved, not given a wrong span). if b >= a && (b - a) as u64 == (end_byte - 1 - start_byte) / 2048 { ranges.push((a, b + 1, slot, phase)); } else { unresolved += 1; } } - // Every forensic segment must map to an index key. Any that did not is a hole - // in the rip — with the full 32-key set in hand this should never happen, so - // treat it as a hard failure rather than silently emitting a garbled segment. + // Every forensic segment must map to an index key. A hole here should + // never happen with the full 32-key set — treat it as a hard failure. if unresolved != 0 { return Err(crate::error::Error::FmtsKeyMissing.into()); } - // Cover the NON-segment content with the base Unit Key OF THE CPS UNIT THAT LBA - // BELONGS TO: the forensic segments (added above with their index keys) carve - // holes out of the title's content extents; every other content unit uses its own - // CPS unit's base UK. Fill the gaps so the map is a complete positive list — an - // LBA in no range is nav and passes through. - // - // "The" base key is only well defined when the disc has ONE base CPS Unit Key. - // Hardcoding pool slot 0 keyed every non-forensic LBA of every OTHER CPS unit - // with the first unit's key — and that does not fail loudly, it decrypts to - // garbage with `lost_bytes == 0`. `resolve_mux_key_map_cached` reaches this - // resolver BEFORE its own `single_base_key_slot` short-circuit, so that guard - // never gets to make slot 0 correct here. Resolve it per extent instead, the same - // way the multi-CPS path does: from the extent's own ciphertext. + // Cover NON-segment content with each LBA's CPS unit's base key. "The" + // base key is only well defined with ONE base unit — hardcoding slot 0 + // would garble others, since this runs before `single_base_key_slot`. let base_slots: Vec<usize> = match keys { crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => base_key_slots(unit_keys), _ => Vec::new(), }; if base_slots.len() <= 1 { - // One base CPS unit (the overwhelming majority, incl. every single-key UHD): - // its key covers every non-forensic LBA and NO extent sampling is needed — - // an FMTS disc stays at the anchor + phase probes it already pays for. An - // empty pool keeps slot 0, which is what an empty map keys nothing with. + // One base CPS unit (the majority, incl. single-key UHD): its key + // covers every non-forensic LBA, no sampling needed. Empty pool + // keeps slot 0, which an empty map keys nothing with anyway. let base_idx = base_slots.first().copied().unwrap_or(0); let base_gaps = fill_base_key_gaps(&title.extents, &ranges, base_idx); ranges.extend(base_gaps); @@ -1360,22 +1246,9 @@ fn probe_fmts_index_keys( reader.read_sectors(lba, 3, &mut c, false).ok()?; Some(c) }; - // ── ANCHOR — fetch the whole 32-key set from ONE index-1 batch. The key - // service returns ALL forensic index keys ordered (element i = index i+1) - // only for a canonical INDEX-1 sample that decrypts under the index-1 key. - // A forensic segment interleaves TWO variants at the aligned-unit level, so - // index-1's real content is one PHASE (even or odd units) and the alternate - // is a different variant that won't decrypt. We don't know the phase a - // priori, so try PHASE A (even) then PHASE B (odd): whichever is index-1's - // content comes back with the full set. Both phases failing (across the - // read-fault fallback over index-1 segments) ⇒ this disc has no FMTS keys. - // - // The set's SIZE is whatever the source returns (≥ 1) — never assumed. 32 - // is all we have seen, but a disc with a different forensic index count is - // not ruled out, so the map is sized to the returned `len()`, not a const. - // ───────────────────────────────────────────────────────────────────────── - // Batch size = the key service's minimum-samples floor (same as the online - // source), drawn from ONE phase to land a clean single-variant half. + // ANCHOR — fetch the whole key set from ONE index-1 batch (phase + // unknown a priori, so try PHASE A then B). Size is whatever the source + // returns (≥ 1, not assumed 32). Batch = key service's samples floor. const BATCH_UNITS: usize = crate::keysource::MIN_SAMPLE_UNITS; // Read-fault fallback: how many index-1 segments to attempt if the leading one // is unreadable. The 2 phase requests happen per readable segment. @@ -1422,26 +1295,18 @@ fn probe_fmts_index_keys( return Err(crate::error::Error::FmtsKeyMissing.into()); } - // ── PROBE each index's phase. A forensic segment interleaves two variants at - // the aligned-unit level; only ONE parity is this index's real content (the - // other is the alternate variant — a different key, garbles under ours). For - // each index, read a representative tagged segment and count clean decrypts - // of its EVEN vs ODD units under that index's key: the clean half is the - // index's phase. This is the ONE place `is_clean` runs — "the map must be - // right", verified here, once — so the mux decrypt can then trust the map. - // Phase is per-index and shared by every segment carrying that index. ────── + // PROBE each index's phase: only ONE parity is real content, found by + // counting clean EVEN vs ODD decrypts under its key. The ONE place + // `is_clean` runs, so the mux decrypt can trust the map afterward. let mut phase_of_index: std::collections::HashMap<u16, crate::decrypt::Phase> = std::collections::HashMap::new(); let mut all_phases_definite = true; for (i, k) in index_keys.iter().enumerate() { check_halt()?; let tag = (i + 1) as u16; - // Probe this index's parity with the anchor loop's read-fault tolerance: - // try up to MAX_ANCHOR_ATTEMPTS same-index segments (not a single `.find`), - // skipping any whose reads all fault. The outcome distinguishes a genuine - // wrong key (reads succeeded, no clean parity) from a transient live-drive - // read fault (zero decrypt evidence) — the load-bearing distinction so a - // recoverable fault never hard-aborts a rip whose index keys are valid. + // Read-fault-tolerant across up to MAX_ANCHOR_ATTEMPTS segments; + // distinguishes a wrong key (no clean parity) from a transient + // fault (zero evidence), so a recoverable fault never aborts. match probe_index_phase( segments, tag, @@ -1455,23 +1320,16 @@ fn probe_fmts_index_keys( phase_of_index.insert(tag, phase); } IndexProbe::WrongKey => { - // Reads SUCCEEDED but NEITHER parity decrypts clean under this index's - // key on any same-index segment: the key is wrong (or the sample isn't - // this index's real content). The map would be wrong — fail loud rather - // than emit a broken segment. (Preserves the genuine-wrong-key path.) + // Reads SUCCEEDED but NEITHER parity decrypts clean: the key + // is wrong (or the sample isn't this index's content). Fail + // loud rather than emit a broken segment. tracing::warn!(target: "freemkv::keysource", index = tag, "fmts: no clean phase under index key — refusing broken map"); return Err(crate::error::Error::FmtsKeyMissing.into()); } IndexProbe::ReadFault => { - // EVERY probe read of EVERY same-index segment faulted (a transient - // live-drive read fault — e.g. NOT READY 2/04/3E, the common bad-sector - // sense on the BU40N). There is ZERO decrypt evidence, so this is NOT a - // wrong key: the index key is valid and already in hand. Do NOT abort a - // rip whose forensic keys are good. Leave this index's phase unresolved - // so the range-builder below defaults it to `Phase::All` — decrypt BOTH - // parities and let the demux drop the garbled alternate half (the - // coherent-stream outcome the module doc describes for whole-range key - // application). Degraded but complete; never a wrong-key abort. + // EVERY probe read faulted (transient drive fault). Zero + // evidence ⇒ not a wrong key. Leave phase unresolved so the + // range-builder defaults to `Phase::All`. Degraded, never abort. tracing::warn!(target: "freemkv::keysource", index = tag, "fmts: index phase probe read-faulted on every segment — defaulting Phase::All (recoverable read fault, not a wrong key)"); all_phases_definite = false; } @@ -1663,10 +1521,9 @@ fn probe_index_phase( continue; // every read of this segment faulted — try the next same-index one } any_read = true; - // A clean parity (even != odd) or a padding tie (even == odd > 0) resolves the - // phase; even == odd == 0 is this segment's wrong-key signature, but a DIFFERENT - // same-index segment could still anchor (this one's sampled units may all be the - // alternate variant), so keep trying rather than concluding immediately. + // A clean parity or padding tie (even == odd > 0) resolves the phase; + // even == odd == 0 is this segment's wrong-key signature, but a + // different same-index segment could still anchor, so keep trying. if let Ok(phase) = resolve_tie_phase(even, odd) { return IndexProbe::Phase(phase); } @@ -1972,34 +1829,24 @@ pub(crate) fn resolve_mux_key_map_cached( // multi-CPS loop below needs the CPS one. let DiscKeyCache { cps: cache, fmts } = cache; - // 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. + // The base Unit Key pool is always resolved and banked before mux + // (autorip's pre-rip gate; the ISO path's `decrypt_keys()`), so an AACS + // title reaches here with a non-empty pool. if !matches!(keys, crate::decrypt::DecryptKeys::Aacs { .. }) { // CSS / clear: the AACS map keys nothing here — an empty map passes every // unit through (CSS self-descrambles on its own path). return Ok(crate::decrypt::AacsKeyMap::from_ranges(Vec::new())); } - // 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` - // when the disc is not FMTS, or no key source is configured (then the base UK - // path below applies and the forensic units garble → demux drops them). + // FMTS (AACS 2.1): forensic segments need per-index keys the base Unit + // Key can't open. `None` if not FMTS or no key source (base UK path + // below applies and forensic units garble/get dropped). if let Some(map) = resolve_fmts_key_map(reader, title, keys, fetch, format, halt, fmts, cache)? { return Ok(map); } - // The single-CPS short-circuit asks about the BASE CPS unit keys only. It must not - // read the pool's LENGTH: on an FMTS disc `resolve_fmts_key_map` APPENDS this - // disc's forensic index keys to the same caller-owned pool, so once ANY forensic - // title has resolved the pool is `1 + n_index` long for every later title. Keying - // off the length there dropped every subsequent single-CPS title into the - // multi-CPS sampling path (8 random 6144-byte reads per extent, and a - // `DecryptFailed` abort of the WHOLE-disc map for any extent no pooled key opens) - // — making the outcome depend on which playlist happened to resolve FIRST, the - // exact order-independence this function's docs promise. The forensic keys are - // tagged in the pool ([`FMTS_POOL_TAG_BASE`]), so the base count is exact. + // Single-CPS short-circuit asks about BASE keys only, and must not read + // pool LENGTH: FMTS appends forensic keys, making length order-dependent. + // Forensic keys are tagged ([`FMTS_POOL_TAG_BASE`]), so count stays exact. if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys && let Some(slot) = single_base_key_slot(unit_keys) { @@ -2062,13 +1909,9 @@ pub(crate) fn resolve_mux_key_map_cached( idx = pick(&samples, unit_keys); } } - // `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. + // `sample_units` draws REAL content, so no key decrypting clean + // means the key is genuinely absent — fail loud rather than + // silently assign a WRONG key (corrupts with lost_bytes==0). let idx = match idx { // A real decision from this extent's own ciphertext — memoise it. Some(i) => { @@ -2134,12 +1977,9 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>( fetch: Option<crate::sector::KeyFetch>, ) -> io::Result<PipelinedPesStream> { let extents = title.extents.clone(); - // CSS (DVD) key resolution — the shared per-title step (also used by the - // live-drive single-pass `DiscStream`). A `None`/MPEG-PS title cracks its own - // key from the reader in playback order; AACS `.evo` (also MPEG-PS) arrives as - // `Aacs` and is untouched; a clear DVD stays `None`; `raw` skips it entirely. - // Without this a detection-miss CSS DVD would mux scrambled sectors as corrupt - // video. `halt` lets /api/stop interrupt the crack scan. + // CSS (DVD) key resolution — shared per-title step. A `None`/MPEG-PS + // title cracks its own key; AACS arrives as `Aacs` untouched; `raw` + // skips it. `halt` lets /api/stop interrupt the crack scan. crate::css::resolve_dvd_title_key( &mut reader, &extents, @@ -2149,21 +1989,16 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>( raw, halt.as_ref(), )?; - // Unit alignment is an AACS concept: AACS decrypts whole 6144-byte (3-sector) - // units, so the producer must hand the decrypt step 3-sector-aligned batches. - // CSS (DVD) and unencrypted content decrypt per 2048-byte sector — forcing - // 3-sector alignment there rejects any extent whose sector count isn't a - // multiple of 3 (DVD IFO cells routinely aren't) with ExtentNotUnitAligned. + // Unit alignment is an AACS concept (whole 3-sector units); forcing it + // on CSS/unencrypted (per-2048-byte-sector) would reject any extent + // whose sector count isn't a multiple of 3. let unit_align: u16 = match &keys { crate::decrypt::DecryptKeys::Aacs { .. } => 3, _ => 1, }; - // MUX path: read > decrypt > mux. Resolve the proactive AACS key map UP FRONT - // — one key per CPS unit / segment, secured from the configured source and - // recorded against the LBA ranges it covers. The mux then decrypts each unit - // with its KNOWN key and trusts it: no per-unit `is_clean` verdict, no reactive - // key-fetch, no key-server storm. A unit that decrypts to broken TS is the - // muxer's problem, exactly as before. AACS-only; CSS self-cracks per region. + // MUX path: resolve the AACS key map UP FRONT — one key per CPS unit/ + // segment against its LBA ranges — so the mux decrypts with a KNOWN key, + // no per-unit verdict or key-server storm. AACS-only; CSS self-cracks. let key_map = match &keys { crate::decrypt::DecryptKeys::Aacs { .. } => Some(std::sync::Arc::new(resolve_mux_key_map( &mut reader, @@ -2175,50 +2010,30 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>( )?)), _ => None, }; - // The map IS the title's read plan: it says which CPS unit / forensic segment - // each LBA belongs to. Walk ONLY the units it marks as ours — every default / - // CPS unit, and inside an FMTS forensic segment only our-phase units. The - // alternate-phase units are a different device group's variant; a licensed - // player never reads them, and neither do we — they are never fetched, - // decrypted, or handed to the demux, so the demux sees one gapless our-variant - // stream (no ciphertext to trip a concealed-gap resync). A non-forensic map - // returns the extents unchanged, so the common disc reads exactly as before. + // The map IS the title's read plan. Walk ONLY units it marks as ours — + // inside an FMTS segment, only our-phase units (alternate phase is a + // different device group's variant). A non-forensic map is unchanged. let full_extents = extents.clone(); let extents = match &key_map { Some(map) => map.read_plan(&extents, unit_align as u32), None => extents, }; - // The plan and the clips' feed spans must describe the SAME bytes. - // - // A clip's span was measured over the title's full extents at scan time, - // and a frame is placed by the offset it was read from. When a forensic - // segment makes the plan drop alternate-phase units, the mux feeds fewer - // bytes than the spans describe and the two drift apart cumulatively — - // every frame after the first segment looks earlier than it is, and near a - // join it is placed in the wrong clip or dropped. The spans still tile each - // other perfectly, so the trust check cannot see it. - // - // Provenance is only meaningful when the feed matches. When it does not, - // say so and let placement fall back to timestamps, which is what the - // untrusted path exists for. + // The plan and clip feed spans must describe the SAME bytes: if the plan + // drops alternate-phase units, frames drift out of place near a join — + // undetectable by the tile check. Fall back to timestamps if mismatched. let feed_matches_spans = extents == full_extents; let mut decrypting = crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys); if let Some(map) = key_map { decrypting = decrypting.with_key_map(map); } - // Loss-counter handle. The mux does NOT tally decrypt-quality misses: a - // broken-TS unit is the muxer's concern, and a missing key is an up-front - // resolve failure — indistinguishable from bad authoring at this seam, so - // counting it would false-abort a bad-encoded-but-decryptable disc. A genuine - // can't-decrypt surfaces as `Err`; `lost_bytes()` reflects physical read loss - // only (there is no decrypt-loss term to fold in). + // The mux does NOT tally decrypt-quality misses: a broken-TS unit is the + // muxer's concern, a missing key is an up-front resolve failure (`Err`). + // `lost_bytes()` reflects physical read loss only. - // Wrong-substream fix (Silence-of-the-Lambs): before the prefetcher takes - // the reader, probe the feature head through the (plaintext) decrypting - // source and re-route the title's declared AC-3 audio onto the physically - // correct `0x8x` sub-streams. No-op for non-DVD or an empty probe. Reset the - // unit base afterward so the prefetcher's first batch starts clean. + // Wrong-substream fix (Silence-of-the-Lambs): probe the feature head and + // re-route declared AC-3 audio onto the correct `0x8x` sub-streams. + // No-op for non-DVD/empty probe; reset unit base for a clean first batch. let mut title = title; if !feed_matches_spans { tracing::info!( @@ -2283,10 +2098,9 @@ fn build_m2ts_pipeline<R: std::io::Read + Send + 'static>( }; head.truncate(head_len); - // Try FMKV metadata header first; fall back to PMT scan. Only a - // genuine absence of the FMKV magic (`Ok(None)`) falls through to - // the PMT path — a corrupt/truncated FMKV header (`Err`) propagates - // instead of being misreported as a PMT-derived title or NoStreams. + // Try FMKV metadata header first; fall back to PMT scan. Only a genuine + // absence of the FMKV magic (`Ok(None)`) falls through; a corrupt header + // propagates rather than being misreported as PMT-derived. let mut cursor = io::Cursor::new(&head); let (title, head_consumed) = match meta::read_header(&mut cursor)? { Some(m) => { @@ -2447,9 +2261,8 @@ mod tests { assert!(validate_network_addr("host:65535").is_ok()); } - // The decrypt-verdict matrix (raw / unencrypted / AACS-no-key / - // CSS-no-key / css_error) is owned by `Disc::ensure_decryptable[_keys]` and - // tested in `crate::disc` — `input()` now delegates to it, so the matrix is + // The decrypt-verdict matrix is owned by `Disc::ensure_decryptable[_keys]` + // and tested in `crate::disc`; `input()` delegates to it, so it's // asserted once at the source of truth rather than re-tested here. // ── input()/output() routing + validation ───────────────────────────── @@ -3046,11 +2859,9 @@ mod tests { .expect("one frame emitted from the single PES"); // PassthroughParser routes the audio stream (PID 0x1100) to track 0. assert_eq!(frame.track, 0); - // The TS PesAssembler delivers every payload byte AFTER the 9-byte PES - // header to the end of the 184-byte TS payload region (the bounded - // PES_packet_length is not used to trim within a single packet — the - // PES is closed by the next PUSI or by flush at EOF). So the frame is - // the ES bytes followed by the packet's zero padding: total = 184 - 9. + // TS PesAssembler delivers every byte after the 9-byte PES header to + // the end of the 184-byte TS payload (PES is closed by next PUSI or + // flush at EOF, not by PES_packet_length). Frame = ES + zero padding. assert_eq!( frame.data.len(), 184 - 9, @@ -4275,15 +4086,9 @@ mod tests { assert!(got.is_none(), "not a UDF/FMTS disc → Ok(None)"); } - // ── FMTS index-key resolution is memoised per DISC, not per title ───────── - // - // A synthetic AACS 2.1 disc: a real UDF tree (so `read_filesystem` walks it - // for real) carrying `/AACS/IndividualSegment.tbl`, plus a content region whose - // aligned units are genuinely encrypted — the EVEN units of a segment under its - // index key and the ODD units under the alternate interleaved variant's key, - // exactly the layout the phase probe exists to discover. The reader COUNTS its - // reads (metadata vs probe) and the `KeyFetch` double COUNTS its calls, so the - // tests can state the cost of N titles rather than assume it. + // FMTS index-key resolution is memoised per DISC, not per title: EVEN + // units under the index key, ODD under the alternate; the doubles + // COUNT calls so tests can state exact cost. /// First LBA of the synthetic disc's content. Everything below is UDF metadata. const FMTS_CONTENT_LBA: u32 = 10_000; @@ -4425,11 +4230,9 @@ mod tests { subdirs: Vec::new(), }]; if clip { - // The forensic FEATURE clip, as a real AACS 2.1 disc names it: - // `BDMV/STREAM/<clip>.fmts`, whose extents are the byte space the - // segment SPNs are relative to. Declared in UDF only (the reader - // synthesises its content), at exactly the content region: - // partition-relative data LBA + PART_START = FMTS_CONTENT_LBA. + // The forensic FEATURE clip: `BDMV/STREAM/<clip>.fmts`, whose + // extents are the byte space segment SPNs are relative to + // (UDF-declared, at partition-relative LBA = FMTS_CONTENT_LBA). subdirs.push(DirSpec { name: "BDMV".to_string(), icb_lba: 16, @@ -5175,12 +4978,9 @@ mod tests { ); let probes_after_forensic = reader.probe_reads; - // 2) A NON-forensic title of the same disc (a menu playlist: its extents lie - // outside the forensic clip) still has exactly ONE base CPS Unit Key, so it - // must take the single-CPS short-circuit — no sampling reads at all. - // (+9_000 keeps the extent on the fixture's 3-sector aligned-unit grid, so - // the multi-CPS path WOULD succeed here — the test is about it not running at - // all, not about it failing.) + // 2) A NON-forensic title (menu playlist, extents outside the forensic + // clip) still has ONE base CPS Unit Key, so it takes the single-CPS + // short-circuit — no sampling reads (multi-CPS WOULD succeed if run). let menu = multi_cps_title(FMTS_CONTENT_LBA + 9_000, 300); let map = super::resolve_mux_key_map_cached( &mut reader, @@ -5590,19 +5390,8 @@ mod tests { } // ── An unmappable forensic record is a HOLE, and a hole is a hard failure ── - // - // `resolve_fmts_key_map`'s range builder has four independent arms that refuse - // to emit a range for a record: inverted SPNs, an index with no key, clip bytes - // past the clip's end, and a span that is not one contiguous run of sectors. - // Each one tallies `unresolved`, and a non-zero tally aborts the disc. - // - // The tally is what makes those refusals SAFE. Drop it (or the `unresolved != 0` - // check) and the `continue` still fires — so the record's LBAs fall through to - // `fill_base_key_gaps`, which covers them with the BASE Unit Key. The forensic - // units then decrypt to garbage under a key that was never theirs, the map - // reports no error, and the rip completes with `lost_bytes == 0`. That is the - // exact silent-wrong-key shape this module's comments call out; the four tests - // below drive one arm each so no single tally can be deleted unnoticed. + // Four arms refuse to emit a range and tally `unresolved`; drop the tally + // and those LBAs decrypt as garbage under the wrong key, `lost_bytes == 0`. fn fmts_missing_err() -> String { std::io::Error::from(crate::error::Error::FmtsKeyMissing).to_string() @@ -5933,18 +5722,9 @@ mod tests { } } - // ── An operator Stop that lands MID-probe ──────────────────────────────── - // - // The FMTS probes are the heaviest thing this crate does to a live drive: - // hundreds of random 6144-byte reads, each able to stall to the SCSI recovery - // timeout on a marginal disc. The module's hard rule is that `/api/stop` is - // honored at every loop boundary rather than after the whole probe completes. - // - // The existing halt tests all pre-cancel, so the ENTRY poll alone satisfies - // them and the two polls inside the probe loops are unconstrained. Cancelling - // by OUTCOME is not enough either — a later poll still returns `Halted`, so a - // deleted poll looks identical. What distinguishes them is what the drive was - // asked to do after the Stop, so both tests below count reads. + // An operator Stop mid-probe: `/api/stop` must be honored at every loop + // boundary since FMTS probes are the heaviest thing this crate does to a + // live drive; these tests count reads since outcome alone can't tell. fn halted_err() -> String { std::io::Error::from(crate::error::Error::Halted).to_string() @@ -6021,10 +5801,9 @@ mod tests { ) .expect_err("a Stop during the anchor batch must abort the resolve"); assert_eq!(err.to_string(), halted_err(), "the verdict is Halted"); - // The anchor completed (it is one uninterruptible batch by construction); - // the phase probes must NOT have started. Each phase probe reads - // 2 * MIN_SAMPLE_UNITS units per attempted segment, so anything beyond the - // anchor's own reads is the phase loop running past the Stop. + // The anchor completed (uninterruptible by construction); phase + // probes must NOT have started — any reads beyond the anchor's is a + // Stop overrun (each probe reads 2*MIN_SAMPLE_UNITS per segment). let anchor_cost = crate::keysource::MIN_SAMPLE_UNITS as u32; assert!( reader.probe_reads > 0, diff --git a/src/mux/select.rs b/src/mux/select.rs index c288b5ff..be40bc60 100644 --- a/src/mux/select.rs +++ b/src/mux/select.rs @@ -64,15 +64,9 @@ impl StreamSelection { return Ok(()); } - // Validate every listed PID exists in the title before mutating, so an - // unknown PID leaves the title untouched (no partial prune). - // - // Validate PER CLASS. Scanning both classes let a PID listed in the WRONG - // filter pass validation — an audio filter naming a subtitle PID, say — - // and `keeps` then matches it against the audio streams only, so the - // requested track is silently absent from the output. That is exactly the - // "fail loud rather than silently emit an MKV missing a requested track" - // contract this validation exists to enforce. + // Validate every listed PID before mutating (unknown PID → no partial prune), + // PER CLASS: scanning both classes let a PID in the WRONG filter pass, then + // `keeps` matched it only against its own class, silently dropping the track. if let PidFilter::Only(pids) = &self.audio { for &pid in pids { let present = title @@ -109,21 +103,9 @@ impl StreamSelection { i += 1; k }); - // Prune `codec_privates` by the SAME index decision, whatever its length. - // - // This used to run only when `codec_privates.len() == streams.len()`, and - // do nothing otherwise. `codec_privates` is consumed positionally - // (`codec_privates[i]` describes `streams[i]` — see - // `TsMuxer::set_codec_private` / `Mp4Sink::create`), and `m2ts.rs::create` - // documents a longer-than-streams vec as benign ("ignore any trailing - // entries that exceed the track count"). So a title carrying one trailing - // extra entry skipped the prune entirely and every retained stream after - // the first dropped one silently got the PREVIOUS stream's - // codec_private — wrong SPS/PPS on the track, no error, no log. - // - // Indices at or past `keep`'s length can only be entries that already - // exceeded the stream count, i.e. describe no stream; drop them rather - // than leave them dangling behind the pruned list. + // Prune `codec_privates` by the SAME index decision, whatever its length. Old + // bug: guarding on `len == streams.len()` let a trailing extra entry skip the + // prune, so streams after a dropped one got the PREVIOUS codec_private (wrong SPS). let extra = title.codec_privates.len().saturating_sub(keep.len()); if extra > 0 { tracing::debug!( diff --git a/src/mux/stdio.rs b/src/mux/stdio.rs index c43fcb5e..72196214 100644 --- a/src/mux/stdio.rs +++ b/src/mux/stdio.rs @@ -70,13 +70,9 @@ impl StdioStream { } self.header_read = true; if let Some(ref mut r) = self.reader { - // Propagate real header errors. read_header consumes bytes - // from the unbuffered stdin BEFORE it can fail (oversized - // length, bad JSON, partial read), so swallowing the Err - // would leave the stream misaligned and PesFrame::deserialize - // would then read garbage. `?` surfaces the true error; - // Ok(None) (genuine magic mismatch / clean EOF) stays a - // non-error and leaves the empty default title in place. + // Propagate real header errors: read_header consumes stdin bytes BEFORE it can + // fail, so swallowing the Err would misalign the stream and make deserialize read + // garbage. Ok(None) (magic mismatch / clean EOF) stays non-error (default title). if let Some(m) = meta::read_header(r)? { self.disc_title = m.to_title(); self.meta_parsed = true; @@ -129,13 +125,9 @@ impl crate::pes::Stream for StdioStream { } fn headers_ready(&self) -> bool { - // Write side: caller supplied the title up front, so headers are - // always ready. Read side: ready only once an FMKV header was - // actually parsed — gating on `header_read` alone would claim - // readiness for a headerless stream whose codec_private() is None - // for every track, starving the downstream MKV writer of init - // data. A genuinely headerless stream never flips ready (the - // caller must then fall back to its own codec detection). + // Write side: title supplied up front, always ready. Read side: ready only once an + // FMKV header was parsed — gating on `header_read` alone would claim readiness for a + // headerless stream (codec_private() all None), starving the MKV writer of init data. self.writer.is_some() || self.meta_parsed } } diff --git a/src/mux/timeline.rs b/src/mux/timeline.rs index 8761537c..8d09468b 100644 --- a/src/mux/timeline.rs +++ b/src/mux/timeline.rs @@ -4,18 +4,18 @@ //! source PES PTS does not run continuously across a clip join. There are two //! ways to place them, and this module holds both: //! -//! - **From the playlist's marks** ([`SeamPlan`]) — when the title carries +//! - **From the playlist's marks** ([`SeamPlan`](crate::mux::timeline::SeamPlan)) — when the title carries //! PlayItem IN/OUT times, each clip contributes exactly `out - in` and the //! clips are laid end to end. This is exact: it closes forward skips, joins //! overlaps without rewinding, and drops material the playlist excludes. -//! - **By inference** ([`TimelineContinuity::adjust`]) — when there are no +//! - **By inference** ([`TimelineContinuity::adjust`](crate::mux::timeline::TimelineContinuity::adjust)) — when there are no //! usable marks (DVD, HD-DVD, `mkv://` / `m2ts://` sources), a backward PTS -//! jump larger than [`DISCONTINUITY_BACKSTEP_NS`] is read as a join and +//! jump larger than [`DISCONTINUITY_BACKSTEP_NS`](crate::mux::timeline::DISCONTINUITY_BACKSTEP_NS) is read as a join and //! rebased. Inference cannot see a forward skip, because a forward gap is //! indistinguishable from frames lost to damaged media, and cannot see an //! overlap smaller than the reorder threshold. //! -//! [`TimelineContinuity::map`] picks between them: marks when present, +//! [`TimelineContinuity::map`](crate::mux::timeline::TimelineContinuity::map) picks between them: marks when present, //! inference otherwise. Every muxer/sink that consumes the interleaved per-track //! PES stream and emits a monotonic timeline (the MKV muxer, the `demux://` //! elementary-stream sink) goes through it, so the correction lives in exactly @@ -172,16 +172,9 @@ impl SeamPlan { if clips.is_empty() { return None; } - // Trust the spans only if they tile the feed contiguously from 0. - // - // A clip referenced twice reuses its first reference's span (the bytes - // are read once), so equal-to-previous is allowed; anything else — a - // gap, an overlap, a missing span, or a start that is not 0 — means the - // scan's view of the extents and the mux's differ, and a byte offset - // would then select a confidently WRONG clip for every frame. - // - // This is decided BEFORE the marks are read, because it decides whether - // the marks have to be readable at all. + // Trust the spans only if they tile the feed contiguously from 0 (a repeated + // clip reusing its first span is fine). Any gap/overlap/missing span/nonzero + // start means scan and mux disagree, so a byte offset would pick the wrong clip. let mut spans_trusted = true; let mut expect: u64 = 0; let mut prev_span: Option<(u64, u64)> = None; @@ -218,30 +211,9 @@ impl SeamPlan { ); return None; } - // Marks that do not advance across the title are NORMAL, not a - // defect: each clip file carries its own STC, so one clip's IN has - // no ordering relationship to the previous clip's at all. - // - // Inferring a clip from a timestamp cannot read such a table — a - // crossing can be missed, and a missed crossing strands the track - // on a clip it has already left — so this was refused outright. - // That refusal was about INFERENCE, and it cost the titles it was - // meant to protect: they fell back to the very path that cannot - // read them, with a timeline running minutes past the real length. - // - // Provenance does not read the marks across clips. The clip comes - // from the byte offset a frame was read at, and each clip's - // `offset_ns` maps its own private clock onto the output timeline — - // exactly right for an independent STC. Lifting the refusal was - // tried once BEFORE every track carried a source offset, and nine - // audio and subtitle tracks with nothing to place them by pinned - // themselves to clip 0 and dropped most of the title. Now they all - // carry one, so the marks keep a single job: deciding whether a - // frame lies inside its own clip's [in, out]. - // - // `spans_trusted` still gates it. Without usable spans there is no - // byte offset to place by and inference is all that is left, so a - // table inference cannot read must still be refused. + // Non-advancing marks across clips are normal (each clip has its own STC), so + // timestamp inference refuses them, but provenance places by byte offset + + // each clip's `offset_ns`, needing marks only to test `[in, out]` membership. if !spans_trusted && let Some(prev) = out.last() && in_ns <= prev.in_ns @@ -261,17 +233,9 @@ impl SeamPlan { }); cum = cum.saturating_add(out_ns - in_ns); } - // Which of the two placement strategies a title got is the single most - // useful fact about a branched rip, and until now it was invisible: a - // title that fell back to inference looked exactly like one the plan - // handled, and telling them apart meant rebuilding and re-ripping. - // Both flags, once, at plan construction. - // How many DISTINCT feed spans the clips share. A seamlessly branched - // title re-references one clip file from several PlayItems with - // different mark ranges, and those references share a single span - // (the bytes are read once), so a byte offset alone cannot tell them - // apart. `distinct < clips` is therefore the fact that decides whether - // provenance can identify a clip on its own. + // Log both flags once here: which placement strategy a title got was + // previously invisible, and `distinct < clips` reveals when clips share a + // feed span (a seamlessly branched title re-referencing one clip file). let mut distinct_spans = 0usize; let mut seen: Option<(u64, u64)> = None; for c in &out { @@ -321,8 +285,6 @@ impl SeamPlan { .fold(0i64, |a, b| a.saturating_add(b)) } - /// Place a raw PTS for `track`, advancing that track's own clip cursor. - /// /// Which clip owns feed byte `b`, by BINARY SEARCH. /// /// `spans_trusted` guarantees the spans tile the feed contiguously in @@ -373,6 +335,7 @@ impl SeamPlan { first } + /// Place a raw PTS for `track`, advancing that track's own clip cursor. /// `None` means DROP: the frame lies outside every clip's marks, so the /// playlist does not include it. fn place( @@ -396,53 +359,16 @@ impl SeamPlan { let pos = self.cursors[track]; let mut clip = pos.clip; - // Advance this track to the next clip when THIS track's frames say it - // has crossed. Two signatures, because a join is either a skip or an - // overlap: - // - // - SKIP (next IN after this OUT): the frame is simply past this clip's - // OUT mark. - // - OVERLAP (next IN before this OUT, the disc storing the join twice): - // the frame is still inside this clip's range, so "past OUT" never - // fires. But a track's own PTS only ever runs forward inside a clip, - // so the backward step to the next clip's IN is the crossing — and it - // is per track, which is why the cursor has to be per track too. - // - // ── Provenance beats inference ──────────────────────────────────── - // - // If the frame carries the byte offset it was read from, the clip it - // came from is a LOOKUP, not a guess: the feed is the clips' streams - // concatenated in order, so the offset falls in exactly one span. - // - // This is the whole answer to the problem four audit rounds could not - // close by reasoning over timestamps. Inside an overlap, clip k's OUT - // is AFTER clip k+1's IN, so a single timestamp is legitimately inside - // both — and every rule that picked one of them was right for some - // discs and silently wrong for others, losing minutes of content or - // rewinding the output by over a minute. - // - // The heuristics below are kept for sources that stamp no provenance - // (a `mkv://` remux, the deserialize hop), which is also where they - // have always worked: those have no overlap to be ambiguous about. - // `self.spans_trusted` gates this: see `from_clips`. If the recorded - // spans do not tile the feed exactly, an offset means nothing and every - // frame would map to a confidently wrong clip — far worse than the - // heuristics, which are at least approximately right. Degrade instead. + // Advance on a SKIP (past this OUT) or an OVERLAP (backward PTS step to next + // clip's IN, since PTS only runs forward within a clip) — per track. Provenance + // (byte offset) beats inference for overlaps; heuristics below are the fallback. if self.spans_trusted && let Some(b) = src_byte && let Some(found) = self.clip_at_byte(b) { - // One FILE can be referenced by several PlayItems — a seamless - // split, a looped segment, multi-angle — and `bluray.rs` counts its - // extents once, so they all share one span. Provenance therefore - // narrows the frame to the RUN, not to a member of it: every frame - // of the file resolves to the same first index. - // - // Only the timestamp can say which reference a frame belongs to, - // and each carries its own marks. Without this, everything past the - // first PlayItem's OUT was judged against marks it was never inside - // and dropped — half the file missing from the rip while the - // timeline still charged for its duration. + // One FILE can be referenced by several PlayItems sharing one feed span, so + // provenance narrows only to the RUN; timestamp then picks the reference — + // without it, frames past the first PlayItem's OUT were wrongly dropped. let found = self.clip_in_run_for(found, raw_ns); let c = self.clips[found]; let placed = raw_ns >= c.in_ns && raw_ns <= c.out_ns; @@ -460,13 +386,9 @@ impl SeamPlan { // (a clip's file is not trimmed to its marks). Counted, so the // volume gates in the sinks can see it. self.dropped[track] = self.dropped[track].saturating_add(1); - // Once per track, on the FIRST drop only — a join legitimately - // drops a handful of frames and this must not become per-frame - // noise. The heuristic path below has always logged this; the - // provenance path did not, so a title that dropped MOST of its - // frames here produced a volume-gate failure and not one line - // saying which frame, which clip, or which marks it was judged - // against. That is not a diagnosable failure. + // Log only the first drop per track, not per-frame noise; the + // provenance path previously logged nothing here, leaving a + // volume-gate failure with no clue which frame/clip/marks caused it. if self.dropped[track] == 1 { tracing::info!( target: "freemkv::mux", @@ -484,13 +406,9 @@ impl SeamPlan { return Some(raw_ns.saturating_add(c.offset_ns)); } - // Reaching here means this frame carried no byte offset, so it is being - // placed by reading the marks ACROSS clips. Every demuxed source now - // stamps provenance on every track, so under a plan this is not - // expected — and on a table whose marks do not advance the reasoning - // below is meaningless and can strand the track for the rest of the - // title. Say so once per track rather than let it be silent: a mostly - // dropped track already reached a user once this way. + // No byte offset means placing by marks across clips, which isn't expected + // under a plan (every demuxed source stamps provenance) and can strand a + // track if the marks don't advance. Log once per track, not silently. if src_byte.is_none() && self.cursors[track].last_raw_ns.is_none() { tracing::info!( target: "freemkv::mux", @@ -505,42 +423,14 @@ impl SeamPlan { let cur = self.clips[clip]; let next_in = self.clips[clip + 1].in_ns; let past_out = raw_ns > cur.out_ns; - // The step must land ON the next clip's IN mark, not merely below - // the current position: a bound only from above is satisfied by - // every later clip's IN too, and the cursor would run to the end of - // the list on a single backward step. - // A backward step means this track has restarted at the next - // clip's IN. What counts as "at" differs by track, and getting it - // wrong strands a track on the previous clip's offset: - // - // - A track WITH B-frame reorder (any video track) is dense — a - // frame every ~42ms at 24fps — so its first frame of the new clip - // lands ON the mark. Requiring that is what keeps a reorder dip - // near the end of a clip, which is also a backward step and lies - // inside the next clip's range during an overlap, from being read - // as a crossing. This is keyed on REORDER, not on driving epochs: - // a Dolby Vision enhancement layer is a second video track that - // does not drive epochs but does reorder, and giving it the - // permissive rule made its reorder dip look like a join and threw - // it out of step with the base layer it must be co-timed with. - // - Tracks WITHOUT reorder (audio, subtitles) can take any backward - // step as a crossing. They are also sparse: a subtitle may have - // no event near the mark, so its first frame after the join can - // land well past it. Holding those to the video window left them - // on the old clip until their PTS passed its OUT, mistiming them - // by the overlap in between. - // - // Both forms still require the frame to land at or after the next - // IN, which is what stops one backward step walking the cursor to - // the end of the list. + // Must land ON/after next IN or a backward step walks the cursor to the + // list's end. Reorder tracks (e.g. a DV enhancement layer) require landing + // ON the mark so a reorder dip isn't misread as a crossing; sparse tracks accept any step. let stepped_back = pos.last_raw_ns.is_some_and(|last| raw_ns < last) && if has_reorder { - // saturating_abs, not abs: every other comparison in this - // module is saturating because these timestamps come off a - // disc and are not trusted. `abs()` is the one exception - // and it panics on i64::MIN — which saturating_sub can - // produce exactly — taking down the mux thread on one bad - // frame instead of comparing false like its neighbours. + // saturating_abs, not abs: `abs()` panics on i64::MIN, which + // saturating_sub can produce exactly, taking down the mux + // thread on one bad frame instead of comparing false. (raw_ns.saturating_sub(next_in)).saturating_abs() <= CLIP_START_TOLERANCE_NS } else { raw_ns >= next_in.saturating_sub(CLIP_START_TOLERANCE_NS) @@ -553,23 +443,9 @@ impl SeamPlan { } } - // ── The invariant ──────────────────────────────────────────────── - // - // Everything above is a heuristic over marks. THIS is the property they - // exist to serve: a track's output must not run backwards. Checking it - // directly, rather than trusting whichever rule fired, is what closes - // the whole class of defect three audit rounds kept finding here — a - // stale cursor placing a frame at the previous clip's offset (measured - // once at 65 seconds of rewind, with nothing counting it). - // - // The tolerance is DISCONTINUITY_BACKSTEP_NS because B-frame reorder - // legitimately emits out of order by a fraction of a second; a rewind - // that matters is orders of magnitude larger. A candidate that fails it - // is not placed on that clip: the scan below looks for a LATER clip - // containing this frame that does not rewind — which is exactly what a - // genuine overlap crossing looks like — and if none exists the frame is - // dropped and counted, because emitting it anywhere would move the - // track backwards. + // Invariant enforced directly, not just via heuristics: output must never run + // backwards (a stale cursor once caused 65s of uncounted rewind). Tolerance is + // DISCONTINUITY_BACKSTEP_NS (B-frame jitter); a failing candidate is rejected, or dropped. let rewinds = |cand: usize| -> bool { match self.cursors[track].last_out_ns { Some(last) => { @@ -580,10 +456,8 @@ impl SeamPlan { } }; if rewinds(clip) { - // Move the cursor ONLY if a later clip actually accepts this frame. - // Leaving it where it was is what makes a bad frame recoverable: - // advancing on a failed search is how a corrupt PTS strands a track - // for the rest of a clip, since nothing walks the cursor back. + // Move the cursor only if a later clip actually accepts this frame; leaving + // it in place keeps a bad frame recoverable instead of stranding the track. let mut found = None; let mut cand = clip; while cand + 1 < self.clips.len() { @@ -749,18 +623,9 @@ impl TimelineContinuity { clips: &[crate::disc::Clip], content_format: crate::disc::ContentFormat, ) -> Self { - // ONLY Blu-ray. A PlayItem's IN/OUT are positions in the same 45 kHz - // clock the PES PTS runs on, which is what makes placing frames by them - // meaningful. - // - // The other formats' clips carry marks from a different clock entirely: - // HD-DVD fills them from the XPL's title-relative `titleTimeBegin` / - // `titleTimeEnd`, and a DVD's come from cell tables. Those are elapsed - // times within a title, not PES positions — so a plan built from them - // is an identity map with a drop filter, it stops the layer-break - // rebase `adjust` exists to perform, and it drops whatever falls - // outside marks the PTS was never measured against. Those formats keep - // the inference path, which is what they have always used. + // Only Blu-ray: its PlayItem IN/OUT share the 45 kHz clock the PES PTS runs on. + // HD-DVD/DVD marks come from a different clock (XPL times, cell tables) — a + // plan from those would drop content the PTS wasn't measured against, so they keep inference. let seams = match content_format { crate::disc::ContentFormat::BdTs => SeamPlan::from_clips(clips), crate::disc::ContentFormat::MpegPs => None, @@ -896,10 +761,8 @@ impl TimelineContinuity { self.epoch_offsets .iter() .map(|(o, closing)| (raw_pts_ns.saturating_add(*o), *closing)) - // In the TAIL of that epoch: at or just below where it ended. A - // frame landing far below its close is not a straggler at all — it - // is a normal frame of a LATER epoch that merely leads a sparse - // frontier, and demoting it would mis-time it by a whole clip. + // In the TAIL of that epoch (at/just below where it ended); far below + // is a later epoch's normal frame, and demoting it mis-times it by a clip. .filter(|(m, closing)| { *m <= *closing && *m >= closing.saturating_sub(DISCONTINUITY_BACKSTEP_NS) }) @@ -939,34 +802,9 @@ impl TimelineContinuity { if !drives_epoch { let effective = self.passive_offset(track, raw_pts_ns); let mapped = raw_pts_ns.saturating_add(effective); - // Tail-straggler remap: at a REAL (base-video-driven) multi-clip - // boundary the offset has just jumped forward by ~a whole clip, but a - // lagging tail frame from the just-ended clip still carries an - // OLD-epoch raw PTS. Adding the NEW offset flings it ~a clip past the - // frontier and would force a forward-dated split cluster (breaking - // cluster monotonicity). Such a straggler is recognised precisely: its - // current-offset mapping lands more than a backstep PAST the frontier - // AND its PREVIOUS-offset mapping lands in the seam TAIL — at/below the - // frontier but no more than one backstep below it (i.e. it ended just - // before the seam, in the prior epoch). The lower bound is essential: - // a NORMAL new-epoch frame that merely leads the sparse (video-only) - // frontier by >3s ALSO has `prev_mapped <= high` (its prev-offset - // mapping lands ~a whole clip below the frontier), and clamping it - // would demote it into the just-ended clip's epoch, mis-timing that - // audio/subtitle by a whole clip. Requiring `prev_mapped` to sit - // within a backstep below the frontier keeps the remap to genuine - // tail stragglers; a long audio-only tail, a sparse subtitle, or an - // EL frame that simply runs ahead is left on the current offset. - // - // Every comparison below saturates. `high` is derived from an - // untrusted container timestamp (an `mkv://` source's - // CLUSTER_TIMESTAMP × TimestampScale is clamped only against - // `i64::MAX`, so a hostile file can put the frontier AT `i64::MAX`), - // and `raw_pts_ns` can be negative (a SimpleBlock's signed relative - // timestamp). Plain `high + BACKSTEP` / `high - BACKSTEP` would then - // overflow: a panic out of the public `Stream::write` path in an - // overflow-checked build, and in release a wrap to the opposite sign - // that fires the straggler clamp on essentially every passive frame. + // Tail-straggler remap: a lagging old-epoch frame under the new offset would + // fling past the frontier, breaking monotonicity; recognised by current mapping + // past frontier + prev mapping in a bounded seam tail (bound avoids wrongly demoting a normal sparse-leading frame). All ops saturate: `high`/`raw_pts_ns` can be adversarial/negative. if let Some(high) = self.high_ns && mapped > high.saturating_add(DISCONTINUITY_BACKSTEP_NS) && let Some(placed) = self.straggler_offset(raw_pts_ns) @@ -983,12 +821,9 @@ impl TimelineContinuity { }; let adj = raw_pts_ns.saturating_add(self.offset_ns); if adj < high.saturating_sub(DISCONTINUITY_BACKSTEP_NS) { - // Clip-boundary reset (real multi-clip seam): continue just after the - // frontier. Save the previous offset so a lagging non-video tail - // frame can be recognised and remapped to the seam (see above). - // `high - adj` is a backward step, so positive — but both ends are - // untrusted (`high` up to i64::MAX, `adj` down to i64::MIN), so - // `open_epoch` saturates rather than panic in a checked build. + // Clip-boundary reset: continue past the frontier, saving the previous + // offset so a lagging tail frame can be remapped (see above). Both `high` + // and `adj` are untrusted, so `open_epoch` saturates rather than panic. self.open_epoch(high, adj); let adj2 = raw_pts_ns.saturating_add(self.offset_ns); self.high_ns = Some(high.max(adj2)); @@ -1161,10 +996,8 @@ mod tests { fn output_never_rewinds_and_a_bad_frame_never_strands() { let clips = seamless_branching_clips(); - // (1) A clip's own file opening ON its IN mark, after a tail frame past - // the previous clip's OUT already advanced the cursor. The old - // guard read that as a crossing and jumped a clip too far, dropping - // ~28 minutes. + // (1) A clip opening ON its IN mark after a tail frame past the previous + // clip's OUT: the old guard misread this as a crossing, dropping ~28 min. let mut plan = SeamPlan::from_clips(&clips).expect("plan"); let tail = plan .place(7_910_830_000_000, 0, true, None) @@ -1393,16 +1226,9 @@ mod tests { fn no_provenance_still_places_by_marks() { let clips = clips_with_spans(); let mut plan = SeamPlan::from_clips(&clips).expect("plan"); - // `is_some()` alone was the whole assertion here, which passes for a - // frame placed in the WRONG clip — on the one path taken whenever a - // source stamps no offset. - // - // This timestamp sits in the OVERLAP of two clips in the real mark - // table, which is precisely the ambiguity provenance exists to settle, - // so pinning one specific clip would be asserting the coin-flip. The - // invariant that holds either way: the frame is placed with the offset - // of a clip whose marks actually contain it — never a clip it does not - // belong to, and never at the head of the timeline. + // `is_some()` alone would pass even for the WRONG clip. This timestamp sits + // in a real OVERLAP, so pinning one clip would assert a coin-flip; the + // invariant checked instead is that the placement matches SOME clip whose marks contain it. let raw = 7_900_000_000_000i64; let placed = plan .place(raw, 0, true, None) @@ -1615,10 +1441,8 @@ mod tests { ); } - // ...which is why placement must finish the job with the timestamp. - // This used to drop 7/8 of the title: every frame past the first - // range's OUT was judged against marks it was never inside. Each - // range's own frame must now be kept, and land in ITS range. + // ...which is why placement must finish the job with the timestamp: this + // used to drop 7/8 of the title (frames judged against the wrong range's marks). for i in 0..N { let mid = mpls_ticks_to_ns(i * SEG + SEG / 2); let placed = plan @@ -1839,12 +1663,9 @@ mod tests { let last_of_0 = plan .place(c0_out, 0, true, None) .expect("clip 0 OUT is inside clip 0"); - // The next clip opens ON its IN mark. Under the old inference this was a - // 1.79s backward step, below the reorder threshold, so no seam was - // recognised and the join was emitted as duplicate content whose - // timestamps then collided. With the marks known, clip 1 is placed to - // continue exactly where clip 0 ended: one monotonic timeline, no - // rewind, and no collision for the muxer to flatten. + // Old inference saw this 1.79s backward step as below the reorder threshold, + // missing the seam and emitting colliding duplicate content. With marks known, + // clip 1 continues exactly where clip 0 ended: monotonic, no rewind or collision. let first_of_1 = plan.place(c1_in, 0, true, None).expect("clip 1 IN"); assert_eq!( first_of_1, last_of_0, @@ -1966,10 +1787,8 @@ mod tests { let placed_tail = plan.place(tail, 1, false, None).expect("clip 0 tail"); assert_eq!(placed_tail, tail - c0_in, "tail rides clip 0's offset"); - // Its first event in clip 1 steps BACK (the clips overlap) but lands - // 400ms past the IN mark, because the track is sparse and had no event - // sitting on the mark. The old window was +/-250ms, so this was missed - // and the event stayed on clip 0 — mistimed by the overlap. + // First event in clip 1 steps back (overlap) but lands 400ms past IN (sparse + // track). The old +/-250ms window missed this, mistiming it on clip 0. let late = c1_in + 400_000_000; assert!(late < tail, "fixture: this is a backward step"); assert!( @@ -2316,11 +2135,8 @@ mod tests { #[test] fn single_clip_late_subtitle_does_not_inflate_offset() { let mut tc = TimelineContinuity::new(); - // One continuous clip: video advances steadily 0..60s. - // Interleaved, a subtitle track is sparse — it emits a cue at 0s, then - // nothing for a long stretch, then a late cue, then jumps around. Each - // subtitle PTS swings many seconds against the video frontier. - // Drive a realistic interleave. + // One continuous clip: video advances steadily 0..60s, interleaved with a + // sparse subtitle track whose PTS swings many seconds against the frontier. let mut max_out = i64::MIN; for sec in 0..=60 { // Video frame every second. @@ -2365,10 +2181,8 @@ mod tests { for sec in 0..=60 { // Base layer (track 0) drives the epoch. let bl = adj_video(&mut tc, sec * S); - // EL (track 1) re-emits the same time — a passive rider. Its raw PTS - // equals the base layer's, but it arrives just AFTER the base frame - // for the NEXT second sometimes; simulate the overlap by feeding the - // PREVIOUS second's time, which is a backward swing vs the frontier. + // EL (track 1) is a passive rider re-emitting the base layer's PTS but + // arriving late; simulate by feeding the previous second's time (a backward swing). let el_raw = if sec > 0 { (sec - 1) * S } else { 0 }; let el = adj_other(&mut tc, el_raw); assert_eq!(el, el_raw, "EL rides current offset, true PTS preserved"); @@ -2481,11 +2295,8 @@ mod tests { c2, "straggler must not move the frontier" ); - // A NORMAL clip2 audio frame (raw ~1s, current epoch) is NOT remapped — - // it rides the new offset to ~601s, just past the frontier but within a - // backstep (its previous-offset mapping ~1s is below the frontier but the - // current-offset mapping is not a backstep past it, so it is not treated - // as a straggler). + // A normal clip2 audio frame (raw ~1s) rides the new offset to ~601s, within + // a backstep of the frontier, so it is not misclassified as a straggler. let normal = adj_other(&mut tc, S); assert_eq!(normal, S + 600 * S + DISCONTINUITY_GAP_NS); } @@ -2510,11 +2321,9 @@ mod tests { let c2 = adj_video(&mut tc, 0); assert_eq!(c2, 600 * S + DISCONTINUITY_GAP_NS); - // A NORMAL clip-2 audio frame at raw ~5s. Current-offset mapping is - // ~605s, which IS more than a backstep (3s) past the 600s frontier — but - // it lands ~595s BELOW the end of the epoch it would be demoted into, - // far outside that epoch's tail. It is a legitimate new-epoch frame, NOT - // a straggler, and must ride the current offset. + // Raw ~5s frame maps to ~605s, >backstep past the 600s frontier, but its + // prev-offset mapping (~595s) is far outside the epoch's tail, so it's a + // legitimate new-epoch frame, not a straggler, and must ride the current offset. let raw = 5 * S; let out = adj_other(&mut tc, raw); assert_eq!( diff --git a/src/mux/ts.rs b/src/mux/ts.rs index 24f26dfa..8eb944ba 100644 --- a/src/mux/ts.rs +++ b/src/mux/ts.rs @@ -241,7 +241,7 @@ pub struct TsDemuxer { /// Advanced by each `feed` by the bytes consumed; `feed` (no base) leaves /// it at 0 so non-provenance callers stamp `None`. feed_base: u64, - /// True once a caller has threaded a source base via [`feed_at`]. Until + /// True once a caller has threaded a source base via [`Self::feed_at`]. Until /// then no `SourcePos` is stamped (keeps existing callers byte-identical). has_base: bool, } @@ -249,20 +249,17 @@ pub struct TsDemuxer { impl TsDemuxer { /// Create a new demuxer tracking the given PIDs. /// - /// Allocates a flat lookup table of `i16` slots — one per possible PID + /// Allocates a flat lookup table of `i32` slots — one per possible PID /// up to `max(8192, max_pid + 1)`. The 8192 floor matches the BD-TS /// 13-bit PID space (0..0x1FFF); the variable upper bound exists for /// DVD program streams which may use 16-bit stream IDs above 8191. - /// Worst-case allocation is `u16::MAX × 2 bytes ≈ 128 KB` — bounded by + /// Worst-case allocation is `u16::MAX × 4 bytes ≈ 256 KB` — bounded by /// the type, so adversarial input can't drive this beyond predictable /// limits. Empty `pids` yields max_pid 0; the floor still produces a /// valid (wholly-unused) table. pub fn new(pids: &[u16]) -> Self { - // The PID→assembler index is stored as i32 (-1 = untracked). PIDs are - // u16 (≤ 65535) and the assembler index `i` is bounded by the number of - // distinct PIDs (≤ 65536), both far below i32::MAX, so `i as i32` can - // never truncate to a negative value and be mis-read as untracked — - // unlike an i16 table, this is safe in RELEASE, not just under debug. + // PID→assembler index is i32 (-1 = untracked); both PID count and index are + // far below i32::MAX, so `i as i32` can never wrap negative and be misread as untracked. let max_pid = pids.iter().copied().max().unwrap_or(0) as usize; let table_size = (max_pid + 1).max(8192); let mut pid_index = vec![-1i32; table_size]; @@ -297,10 +294,8 @@ impl TsDemuxer { /// `data` in place. Zero-copy on the bulk path; one 192-byte copy /// on the boundary. pub fn feed(&mut self, data: &[u8]) -> Vec<PesPacket> { - // A plain feed carries no provenance. Reset any base a prior - // `feed_at` left behind so mixing the two entry points is safe: - // after this call no `SourcePos` is stamped, and the stale running - // base can't leak a wrong offset into the boundary packet. + // Reset any base a prior `feed_at` left behind, so mixing the two entry + // points is safe and a stale running base can't leak into the next packet. self.feed_base = 0; self.has_base = false; self.feed_inner(data) @@ -403,34 +398,18 @@ impl TsDemuxer { let pusi = ts[1] & 0x40 != 0; // Payload Unit Start Indicator let adaptation = (ts[3] >> 4) & 0x03; - // P3/B1 CONCEALMENT MARKER: a NULL-TS packet (PID 0x1FFF) carrying an - // adaptation-field discontinuity_indicator. NOTE: the in-tree writer that - // laid these down on an undecryptable unit was removed with the pure-decrypt - // passthrough change (the mux no longer conceals), so this recognition now - // only fires on externally-authored markers — a candidate for removal with - // the rest of the retired concealment path. - // As a loss signal it is CC-INDEPENDENT — unlike a tracked PID's 4-bit - // continuity_counter it is CC-INDEPENDENT, so it survives a loss that is - // an exact multiple of 16 packets and a loss at the very start of a PID - // (no prior CC to diff against). The decrypt layer cannot know which - // elementary PID(s) the lost unit carried (the data was undecryptable), - // so force a pending discontinuity on EVERY tracked assembler: the next - // completed PES of each resyncs. Harmless for audio/subtitle (the codec - // gate is a no-op there) and at most one extra GOP on a video track that - // did not actually lose packets — bounded, and only on a degraded disc. + // P3/B1 concealment marker: NULL-TS (0x1FFF) with a discontinuity indicator. + // Only fires on externally-authored markers now (in-tree writer removed with + // pure-decrypt passthrough). Lost unit's PID is unknowable, so force pending discontinuity on every assembler. if pid == NULL_PID && (adaptation == 0x02 || adaptation == 0x03) && (ts[4] as usize) > 0 && (ts[5] & 0x80) != 0 { for a in &mut self.assemblers { - // A concealed unit may have dropped packets belonging to a PES - // currently open on any PID — so that partial is potentially - // TRUNCATED (a hole in the middle of its access unit). Drop it - // like a mid-PES continuity break, and flag pending so the NEXT - // completed PES (the first frame whose data is entirely post-gap) - // resyncs. Mirrors the non-PUSI cc_gap path, applied to every PID - // because the lost unit's PID(s) are unknowable (undecryptable). + // A concealed unit may have dropped packets from any open PES, leaving a + // hole mid-access-unit. Drop it like a mid-PES continuity break and flag + // pending so the next completed PES resyncs (mirrors the non-PUSI cc_gap path). a.buffer.clear(); a.active = false; a.header_remaining = 0; @@ -476,37 +455,26 @@ impl TsDemuxer { let payload = &ts[payload_start..]; - // Continuity check. The 4-bit continuity_counter increments by 1 on - // every payload-bearing packet of a PID; a gap means dropped TS - // packets. The adaptation field's discontinuity_indicator (first AF - // byte, bit 0x80) explicitly flags an intentional break. On a non-PUSI - // continuation that is discontinuous, the partial PES has a hole in it - // — splicing the new payload would corrupt the elementary stream — so - // drop the partial and resync on the next PUSI. + // Continuity check: the 4-bit CC increments per payload packet, so a gap means + // dropped packets. On a discontinuous non-PUSI continuation the partial PES has + // a hole, so splicing would corrupt the ES — drop it and resync on the next PUSI. let cc = ts[3] & 0x0f; // adaptation == 0x02 (AF only) already returned above, so only 0x03 // (AF + payload) can carry an adaptation field here. let discontinuity_flag = adaptation == 0x03 && ts[4] > 0 && (ts[5] & 0x80) != 0; let cc_gap = cc_is_gap(asm.last_cc, cc); asm.last_cc = Some(cc); - // A continuity gap means packets for THIS PID were lost (a damaged source, - // or — for the conceal path — a loss that the CC-independent NULL-TS marker - // above did not already flag). The flag is sticky and rides to the FIRST - // post-gap PES so the codec consumer drops forward to the next keyframe - // (B1). Attribution differs by where the gap lands (see below). + // A gap means packets for this PID were lost. Sticky flag rides to the first + // post-gap PES so the codec consumer drops forward to the next keyframe (B1). let gap = discontinuity_flag || cc_gap; if pusi { // `header_len` is the FULL (uncapped) PES-header length: // 0 = malformed (payload is not a PES start), else 6/9+N. let (pts, dts, header_len) = parse_pes_header(payload); - // Flush the previous PES FIRST — a gap detected on this PUSI packet - // belongs to the PES STARTING now (its data begins after the lost - // packets), NOT the one just completing. So set `pending_discontinuity` - // AFTER start(): it rides the new PES to its own completion. (Setting - // it before would stamp the pre-gap frame; if that frame were a - // keyframe the gate would arm-then-disarm on it and admit the real - // post-gap inter frame with a dangling reference.) + // Flush the previous PES first — a gap on this PUSI packet belongs to + // the PES starting now, not the one completing. Set `pending_discontinuity` + // after start(), else it wrongly stamps the pre-gap frame instead. if let Some(prev) = asm.start(pts, dts, source) { completed.push(prev); } @@ -532,10 +500,8 @@ impl TsDemuxer { } else { // Non-PUSI continuation. if gap { - // Mid-PES hole: the open partial has a gap, so splicing this - // payload would corrupt the ES. Flag pending (consumed at the - // NEXT completed PES — the first post-gap frame) and drop the - // open partial; resync on the next PUSI. + // Mid-PES hole: splicing this payload would corrupt the ES. Flag + // pending (consumed at the next completed PES) and drop the partial. asm.pending_discontinuity = true; if asm.active { tracing::trace!( @@ -764,10 +730,8 @@ fn collect_psi_section(data: &[u8], target_pid: u16, table_id: u8) -> Option<Vec continue; }; let payload = &data[offset + payload_off..offset + BD_SOURCE_PACKET_BYTES]; - // pointer_field is the FIRST payload byte; the section starts - // pointer_field bytes after it. Bound the start to within - // THIS packet's payload — a pointer that runs into the next - // packet is malformed. + // pointer_field is the first payload byte; bound the section start to + // within this packet's payload — a pointer into the next packet is malformed. let pointer = payload[0] as usize; let sec_start = 1 + pointer; if sec_start + 3 > payload.len() || payload[sec_start] != table_id { @@ -783,25 +747,15 @@ fn collect_psi_section(data: &[u8], target_pid: u16, table_id: u8) -> Option<Vec section.truncate(total); return Some(section); } - // Need continuation packets: same PID, no PUSI. Continuity is - // checked with the CANONICAL `cc_is_gap` (ISO/IEC 13818-1 §2.4.3.3) - // shared with `process_packet`, NOT a local `cc != expected` test: - // that copy rejected a legal duplicate packet and counted - // adaptation-field-only packets (which do not increment the CC) - // as gaps, so a spec-conformant PMT was misdiagnosed as desync and - // the title's stream list came back empty. A real CC gap means a - // dropped/reordered packet → the assembled section is corrupt, so - // abandon it rather than splicing in misordered payload. The CC - // lives in the low nibble of the 4th TS-header byte (offset+7 here: - // the BD-TS 4-byte prefix precedes the sync byte). + // Need continuation packets: same PID, no PUSI. Use the canonical `cc_is_gap` + // (§2.4.3.3) shared with `process_packet`, not a local test — a prior local + // `cc != expected` rejected legal duplicates and miscounted AF-only packets as gaps. let mut last_cc = Some(data[offset + 7] & 0x0F); let mut scan = offset + BD_SOURCE_PACKET_BYTES; let mut desync = false; while scan + BD_SOURCE_PACKET_BYTES <= data.len() && section.len() < total { - // Require a corroborated resync point (this sync byte plus the - // follower one packet ahead) before trusting the header. A - // stray 0x47 in corrupt payload would otherwise misread the CC - // and fire a false desync. + // Require a corroborated resync point before trusting the header — a + // stray 0x47 in corrupt payload could otherwise misread the CC. if !is_resync_point(data, scan) { scan += 1; continue; @@ -809,11 +763,8 @@ fn collect_psi_section(data: &[u8], target_pid: u16, table_id: u8) -> Option<Vec let cpid = (((data[scan + 5] & 0x1F) as u16) << 8) | data[scan + 6] as u16; let cpusi = data[scan + 5] & 0x40 != 0; if cpid == target_pid && !cpusi { - // Continuation packets may also carry an adaptation field; - // compute their payload base the same way. `None` = the - // packet carries NO payload (adaptation field only, or a - // malformed AF): §2.4.3.3 does not increment the CC for - // those, so they take no part in the continuity check. + // `None` = no payload (AF-only or malformed AF): §2.4.3.3 doesn't + // increment the CC for those, so they skip the continuity check. let Some(cbase) = psi_payload_base(&data[scan..scan + BD_SOURCE_PACKET_BYTES]) else { scan += BD_SOURCE_PACKET_BYTES; @@ -898,10 +849,8 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> { // Clamp the section end to the reassembled bytes; a malformed // section_len must never drive reads past `pmt`. let end = (3 + section_len - 4).min(pmt.len()); - // Clamp prog_info_len so it cannot push `pos` past `end`. - // ISO 13818-1 requires program_info to fit within the PMT section; - // a crafted value larger than the remaining section would skip all - // ES entries and, in pathological cases, wrap or mis-index. + // Clamp prog_info_len so it can't push `pos` past `end`: a crafted value + // larger than the remaining section would skip all ES entries or mis-index. let prog_info_len = ((((pmt[10] & 0x0F) as usize) << 8) | pmt[11] as usize).min(end.saturating_sub(12)); let mut pos = 12 + prog_info_len; @@ -911,11 +860,8 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> { let es_pid = (((pmt[pos + 1] & 0x1F) as u16) << 8) | pmt[pos + 2] as u16; let es_info_len = (((pmt[pos + 3] & 0x0F) as usize) << 8) | pmt[pos + 4] as usize; - // Single source of truth for stream_type → Codec: reuse - // `Codec::from_coding_type` (the same table the BD STN / - // disc scanner uses) so the two mappings can never drift. - // We only retain the category (video/audio/subtitle) and - // per-kind default attribute logic here. + // Single source of truth for stream_type → Codec: reuse `Codec::from_coding_type` + // (same table the BD STN/disc scanner uses) so the two mappings can't drift. let codec = Codec::from_coding_type(stream_type); let stream = match codec.kind() { CodecKind::Video => { @@ -1499,18 +1445,13 @@ mod tests { #[test] fn short_pes_payload_injects_no_header_bytes() { - // A PUSI packet whose payload is NOT a valid PES start - // (no 00 00 01 start code / too short) must contribute ZERO bytes to - // the assembled elementary stream — otherwise a stray 00 00 01 in the - // garbage masquerades as an Annex-B NAL / PES start code in the codec - // parser. Only the following well-formed continuation bytes survive. + // A PUSI packet with no valid PES start must contribute zero bytes, or a + // stray 00 00 01 in the garbage masquerades as an Annex-B/PES start code. let pid = 0x1011; let mut demux = TsDemuxer::new(&[pid]); - // Garbage PUSI payload with NO valid PES start code (no leading - // 00 00 01). It must parse as malformed → header_len 0 → nothing - // pushed. The bytes include a 00 00 01 03 sequence mid-payload that, - // if leaked, would masquerade as an Annex-B NAL / PES start code. + // Garbage PUSI payload with no leading 00 00 01: must parse as malformed + // (header_len 0). Includes a 00 00 01 03 sequence mid-payload to catch leaks. let mut garbage = vec![0xAAu8; 32]; garbage[8] = 0x00; garbage[9] = 0x00; @@ -1638,10 +1579,8 @@ mod tests { assert!(!tail.is_empty(), "test must actually span two packets"); p1[..tail.len()].copy_from_slice(tail); let mut pkt1 = bdts_packet(p1, pmt_pid, false); - // Continuity counter must increment from the PUSI packet (CC=0) to its - // continuation (CC=1) — `collect_psi_section` rejects a CC gap as a - // desync. The CC lives in the low nibble of TS-header byte 4 (offset 7 - // here, after the 4-byte BD-TS timecode prefix). + // CC must increment from PUSI (CC=0) to continuation (CC=1), or + // `collect_psi_section` rejects it as a desync. pkt1[7] = (pkt1[7] & 0xF0) | 0x01; let mut out = pkt0; @@ -1981,10 +1920,8 @@ mod tests { #[test] fn parse_pes_header_extension_less_stream_ids_report_len_6() { - // ISO 13818-1 Table 2-22: program_stream_map(0xBC), padding(0xBE), - // private_stream_2(0xBF), ECM(0xF0), EMM(0xF1), DSMCC(0xF2), - // H.222.1 type E(0xF8), program_stream_directory(0xFF) carry NO - // standard PES header extension → header_len 6, no PTS/DTS. + // ISO 13818-1 Table 2-22: these stream IDs carry no PES header + // extension → header_len 6, no PTS/DTS. for sid in [0xBCu8, 0xBE, 0xBF, 0xF0, 0xF1, 0xF2, 0xF8, 0xFF] { let buf = [0x00, 0x00, 0x01, sid, 0x00, 0x00, 0x80, 0xC0, 0x0A]; let (pts, dts, hl) = parse_pes_header(&buf); @@ -2031,10 +1968,8 @@ mod tests { #[test] fn parse_pes_header_len_is_uncapped() { - // header_len must be the FULL 9 + header_data_length even when it - // exceeds the slice — the caller relies on this to skip header bytes - // that spill into continuation packets. A capped length would leak - // header bytes into the ES. + // header_len must be the full 9 + header_data_length even past the slice, + // so the caller can skip header bytes spilling into continuation packets. let buf = vec![0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x80, 200]; let (_, _, hl) = parse_pes_header(&buf); assert_eq!( @@ -2145,10 +2080,8 @@ mod tests { 0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x00, 0x00, // PES header (hdr_len 0) 0x77, 0x88, ]; - // TS payload area is 184 bytes. Size the AF so it consumes exactly - // everything except the PES, leaving no zero padding for the - // length-0 (unbounded) video PES to absorb. AF stuffing = 0xBB to - // prove it never leaks into the ES. + // Size the AF to consume exactly everything except the PES (no zero padding + // for the length-0 video PES to absorb). AF stuffing = 0xBB to catch leaks. let payload_area = 184usize; let af_total = payload_area - pes.len(); // bytes incl. length byte let af_field_len = af_total - 1; // bytes after the length byte @@ -2214,10 +2147,8 @@ mod tests { #[test] fn pes_header_spanning_two_packets_is_fully_skipped() { - // A PES header (9 + header_data_length) can exceed the 184-byte - // payload of one TS packet. The spillover header bytes on the next - // continuation packet must be skipped, NOT appended as ES — else a - // bogus 00 00 01 start code corrupts the codec stream. + // A PES header can exceed one TS packet's payload; spillover header bytes on + // the continuation packet must be skipped, not appended, or the ES corrupts. let pid = 0x1011; let mut demux = TsDemuxer::new(&[pid]); // header_data_length = 184 → header_len = 193 > 184 payload. @@ -2438,19 +2369,15 @@ mod tests { #[test] fn scan_streams_oversized_prog_info_len_does_not_panic() { - // Regression: a PMT with prog_info_len larger than the section body - // must not panic, index out of bounds, or silently corrupt `pos`. - // The parser must clamp it and still return None (no valid ES entries - // past the inflated descriptor region). + // Regression: a PMT with prog_info_len larger than the section body must + // not panic or index OOB; the parser must clamp it and return None. let pmt_pid = 0x0100u16; // Build a minimal PAT pointing at pmt_pid. let mut data = pat_packet(pmt_pid); - // Craft a raw PMT TS packet with prog_info_len = 0x0FFF (4095), - // which is far larger than the actual section content. The section - // itself only holds a single H.264 ES entry (5 bytes) so the real - // prog_info_len must be 0. + // Craft a raw PMT with prog_info_len = 0x0FFF (4095), far larger than the + // actual section (a single 5-byte H.264 ES entry; real prog_info_len is 0). let mut body = [0xFFu8; 184]; body[0] = 0x00; // pointer_field let s = 1; @@ -2541,15 +2468,9 @@ mod tests { #[test] fn pes_buffer_cap_resets_on_overflow_and_recovers_on_next_pusi() { - // Feed continuation-only packets that would exceed MAX_PES_BUFFER if - // allowed to accumulate, then verify: - // (a) the assembler buffer never grows past the cap, - // (b) a subsequent valid PUSI + continuation produces a correct PES. - // - // Each continuation packet carries 184 ES bytes. We need enough packets - // to exceed MAX_PES_BUFFER even after the cap resets the buffer between - // overflows. Sending (MAX_PES_BUFFER / 184) + 2 packets guarantees at - // least one cap-trigger regardless of internal doubling. + // Feed continuation-only packets past MAX_PES_BUFFER, then verify the buffer + // never exceeds the cap and a later PUSI + continuation still produces a + // correct PES. (MAX_PES_BUFFER / 184) + 2 packets guarantees a cap-trigger. let pid = 0x1011u16; let mut demux = TsDemuxer::new(&[pid]); diff --git a/src/mux/tsmux.rs b/src/mux/tsmux.rs index 97ae727e..c552745e 100644 --- a/src/mux/tsmux.rs +++ b/src/mux/tsmux.rs @@ -180,24 +180,9 @@ impl<W: Write> TsMuxer<W> { let base = self.base_pts_ns.unwrap_or(pts_ns); let pts_ns = pts_ns.saturating_sub(base); - // For NAL-based video (HEVC, H.264): convert length-prefixed NALUs to - // Annex B (start codes) and prepend codec_private parameter sets on - // the FIRST keyframe only. - // - // Arm `params_written` on the first video keyframe regardless of - // whether it carries data: an empty-data keyframe still anchors - // the stream, and leaving the flag unset would make every later - // non-key frame fail the drop guard above and silently vanish. - // For non-video AND for non-NAL video (MPEG-2, VC-1 — already - // start-code ES, never length-prefixed) the bytes pass through - // unchanged, so borrow `data` directly rather than copying it; only - // NAL video needs an owned Annex-B conversion buffer. - // Take the reusable conversion buffer out of `self` for the duration of - // this frame: that frees the `&mut self` the writer needs below while the - // converted bytes are still borrowed, and keeps the allocation across - // frames instead of making (and dropping) a whole-frame one per picture. - // It is put back at the end of the function, so a mid-frame error path - // costs only the buffer's capacity, never correctness. + // NAL video (HEVC/H.264): convert length-prefixed NALUs to Annex B and prepend + // codec_private params on the first keyframe only (arm `params_written` even if + // data is empty, else later non-key frames fail the drop guard). Others pass through. let mut annex_b = std::mem::take(&mut self.annex_b); let convert = is_video && self.is_nal_video(track); if convert { @@ -208,22 +193,17 @@ impl<W: Write> TsMuxer<W> { annex_b.reserve(data.len() + 1024); if keyframe && !self.params_written[track] { if let Some(ref cp) = self.codec_privates[track] { - // avcC and hvcC are DIFFERENT box layouts; parsing one with - // the other's parser yields no parameter sets at all, and the - // stream is then undecodable. Dispatch on the declared codec, - // matching `demux_sink::annexb_param_sets`. + // avcC/hvcC are different box layouts; parsing one with the other's + // parser yields no params. Dispatch matches `demux_sink::annexb_param_sets`. let params = match self.video_codec[track] { Codec::H264 => avcc_to_annex_b(cp), _ => hvcc_to_annex_b(cp), }; match params { Some(params) => annex_b.extend_from_slice(¶ms), - // A codec_private that EXISTS but will not parse means no - // VPS/SPS/PPS reaches the stream and it is undecodable. - // Arming the flag below is still right — retrying the same - // bytes on the next keyframe cannot succeed — but it must - // not be silent, which is what it was: the mux reported - // success while emitting parameter-set-free video. + // A codec_private that exists but won't parse leaves the stream + // undecodable; still arm the flag (retrying can't succeed) but warn + // instead of silently reporting success on parameter-set-free video. None => tracing::warn!( track, codec = ?self.video_codec[track], @@ -236,15 +216,8 @@ impl<W: Write> TsMuxer<W> { } self.params_written[track] = true; } - // Write the conversion STRAIGHT into the destination. - // `length_prefixed_to_annex_b` allocates a whole-frame Vec of its own - // and we then copied it in, so every video frame cost two full-frame - // allocations and two full-frame copies. At ~200k frames averaging - // ~310 KB of ES on a UHD, that is ~124 GB of pointless memcpy. - // The prefix width is whatever the source's avcC/hvcC declares - // (ISO/IEC 14496-15 `lengthSizeMinusOne + 1`), NOT an assumed 4: - // a 1- or 2-octet-prefixed source read as u32-BE parses no NALs at - // all and its raw bytes are passed through with no start codes. + // Write straight into the destination (avoids ~124 GB of memcpy at UHD frame + // rates). Prefix width comes from the source's avcC/hvcC, not an assumed 4. let length_size = nal_length_size( self.video_codec[track], self.codec_privates[track].as_deref(), @@ -263,22 +236,9 @@ impl<W: Write> TsMuxer<W> { 0 }; - // Video PES may be unbounded (length 0); a 0xBD private_stream_1 - // PES must carry a bounded length, so split oversized audio/sub - // access units into multiple PES packets. - // - // ONLY THE FIRST emitted PES carries the PTS. ISO/IEC 13818-1 §2.4.3.7 - // puts the PTS in the header of the PES packet containing the FIRST byte of - // the access unit; every chunk used to repeat it, so on read-back a demuxer - // (which treats each PUSI as a new access unit) received the second half of - // e.g. an oversized full-screen PGS display set as an independent segment at - // the SAME timestamp, and the display set was emitted as two blocks with - // identical timestamps instead of one. Each PES still necessarily starts on - // its own PUSI packet — that is what delimits a PES — but only the keyframe - // RAI rides the first packet of the first PES. - // - // The write result is held rather than `?`-propagated so the conversion - // buffer goes back into `self` on every path. + // 0xBD private_stream_1 needs a bounded length, so oversized audio/sub units + // split into multiple PES packets; only the first carries the PTS (§2.4.3.7) — + // repeating it made a demuxer read one display set as two same-timestamp blocks. let res = if is_video || es_data.len() <= MAX_BD_PES_PAYLOAD { self.write_pes_chain(track, pid, Some(pts_90k), is_video, keyframe, es_data) } else { @@ -468,10 +428,8 @@ fn build_pes_header(pid: u16, pts_90k: Option<u64>, data_len: usize) -> Vec<u8> header.push(0x01); header.push(stream_id); - // PES packet length. The unbounded form (0) is only spec-legal for - // video; `write_frame` splits oversized 0xBD access units so a private - // stream always fits a bounded u16 length here. The `> 65535` arm - // remains a defensive fallback for video only. + // Unbounded length (0) is spec-legal only for video; `write_frame` splits + // oversized 0xBD units so a private stream always fits a bounded u16 here. if stream_id == pes_stream_id::VIDEO || pes_data_len > u16::MAX as usize { header.push(0x00); header.push(0x00); @@ -729,10 +687,8 @@ mod tests { let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID]); let p = fake_hevc_nal(1, 80); mux.write_frame(0, 0, false, &p).unwrap(); - // The single non-key frame was dropped (no keyframe to anchor), - // so finish() now reports MuxEmpty rather than producing a - // header-only "success". The drop behaviour itself is still - // verified by the empty packet list below. + // The single non-key frame was dropped (no keyframe to anchor), so + // finish() reports MuxEmpty instead of a header-only "success". let err = mux.finish().unwrap_err(); assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); } @@ -746,10 +702,8 @@ mod tests { #[test] fn finish_with_zero_frames_errors_mux_empty() { - // Fix 4: a TsMuxer that never emitted a frame must NOT report a - // clean finish — an m2ts:// sink that wrote only the FMKV header - // (undecryptable ciphertext → no demuxable frames) would otherwise - // be a header-only file published as a successful rip. + // Fix 4: a TsMuxer that never emitted a frame must not report a clean finish, + // or an undecryptable m2ts:// source publishes a header-only file as success. let mut sink: Vec<u8> = Vec::new(); let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID]); let err = mux.finish().unwrap_err(); @@ -758,10 +712,8 @@ mod tests { std::io::ErrorKind::InvalidData, "zero-frame finish must surface MuxEmpty (E9023 → InvalidData)" ); - // The MuxEmpty variant carries the E9023 code, and its io::Error - // mapping is InvalidData (matching the kind above). Asserting both - // pins the variant ⇄ code ⇄ kind wiring without a lossy round-trip - // (From<Error> for io::Error → from-io goes back to IoError/E5000). + // Pins the variant/code/kind wiring directly, since the io::Error round-trip + // (From<Error> for io::Error → from-io) loses fidelity back to IoError/E5000. assert_eq!( crate::error::Error::MuxEmpty.code(), crate::error::E_MUX_EMPTY @@ -803,10 +755,8 @@ mod tests { #[test] fn av_offset_preserved_with_audio_before_first_video() { - // Audio at t=0 arrives BEFORE the first video keyframe at t=1s. - // The global base must be seeded from the VIDEO frame so the - // audio/video PTS offset is preserved (audio earlier ⇒ saturates to - // 0, video lands at +1s = 90000 ticks), not both collapsed to 0. + // Audio at t=0 arrives before the first video keyframe at t=1s; the base + // must seed from video, not both collapsing to 0. let mut sink: Vec<u8> = Vec::new(); { let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID, AUDIO_PID]); @@ -843,10 +793,8 @@ mod tests { #[test] fn oversized_bd_audio_pes_is_split_and_bounded() { - // A private_stream_1 (0xBD) audio frame larger than the bounded PES - // limit must be split into multiple PES, each with a non-zero - // PES_packet_length (never the unbounded 0 form, which is illegal - // for 0xBD). + // An oversized 0xBD audio frame must split into multiple PES, each with a + // non-zero length (the unbounded 0 form is illegal for 0xBD). let mut sink: Vec<u8> = Vec::new(); let big: Vec<u8> = (0..(MAX_BD_PES_PAYLOAD + 5000)) .map(|i| (i & 0xFF) as u8) @@ -885,12 +833,8 @@ mod tests { let mut out = Vec::new(); for p in packets.iter().filter(|p| p.pid == pid) { if p.pusi { - // Read the PES header's own length rather than assuming one: - // 6 bytes (startcode + stream_id + length) + 3 optional-header - // bytes + PES_header_data_length. A CONTINUATION PES carries no PTS - // (ISO/IEC 13818-1 §2.4.3.7), so its header is 9 bytes, not 14 — - // this helper used to hardcode 14 and so silently depended on every - // split chunk repeating the PTS. + // Read the PES header's own length rather than assuming 14: a + // continuation PES carries no PTS (§2.4.3.7), so its header is 9 bytes. assert!(p.payload.len() >= 9, "PUSI payload holds a PES header"); let hdr = 9 + p.payload[8] as usize; out.extend_from_slice(&p.payload[hdr..]); @@ -1177,10 +1121,8 @@ mod tests { #[test] fn no_base_seeded_by_audio_only_stream() { - // If only audio frames are written (no video), base_pts_ns is never - // seeded by them; each frame rebases to itself via unwrap_or(pts_ns), - // so the first audio frame lands at relative 0. Proves audio never - // seeds the global base (which would corrupt later A/V offsets). + // Audio-only never seeds base_pts_ns; each frame rebases to itself via + // unwrap_or(pts_ns), proving audio can't corrupt later A/V offsets. let mut sink: Vec<u8> = Vec::new(); { let mut mux = TsMuxer::new(&mut sink, &[AUDIO_PID]); @@ -1409,11 +1351,8 @@ mod tests { /// correct code. Use a PTS large enough that bits 29..32 are nonzero. #[test] fn pts_high_bits_survive_encoding() { - // Choose pts_ns as an exact multiple of 100_000 so `pts_ns * 9 / - // 100_000` (the muxer's ns -> 90kHz-tick conversion) is exact, no - // truncation to account for. N * 9 lands just above 2^31, so bit 31 - // of the 33-bit PTS field is set — well above anything a small-PTS - // test would exercise. + // pts_ns is an exact multiple of 100_000 so the ns->90kHz conversion is exact; + // N * 9 lands just above 2^31, setting bit 31 of the 33-bit PTS field. const N: u64 = 238_609_295; let big_pts_ticks: u64 = N * 9; let big_pts_ns = (N * 100_000) as i64; diff --git a/src/mux/videomap.rs b/src/mux/videomap.rs index b1c43b91..185962d1 100644 --- a/src/mux/videomap.rs +++ b/src/mux/videomap.rs @@ -1,15 +1,15 @@ //! `VideoMap` — freemkv's reusable, pure-data per-picture video index ("the //! FVI object"). //! -//! A [`VideoMap`] is a header (per-title video facts + provenance root) plus an +//! A [`VideoMap`](crate::mux::videomap::VideoMap) is a header (per-title video facts + provenance root) plus an //! ordered list of per-picture records. Each record carries the per-picture -//! coding truth ([`PictureInfo`], off `frame.coding`) and the byte-exact source -//! provenance ([`SourcePos`], off `frame.source`) that the highway already +//! coding truth ([`PictureInfo`](crate::mux::codec::PictureInfo), off `frame.coding`) and the byte-exact source +//! provenance ([`SourcePos`](crate::pes::SourcePos), off `frame.source`) that the highway already //! stamps — this module never re-parses the elementary stream. //! //! It is a STANDALONE PRIMITIVE, deliberately decoupled from any one sink: //! - The `fvi://` sink ([`crate::mux::fvi_sink`]) owns a `VideoMap`, appends -//! each video [`PesFrame`], and serializes it. +//! each video [`PesFrame`](crate::pes::PesFrame), and serializes it. //! - The same `VideoMap` can later be populated as a side-channel during ANY //! mux (e.g. `iso → mkv` while ALSO emitting a `.fvi` sidecar), and reused //! for seek-indexing, recovery loss-mapping, and diagnostics. diff --git a/src/pes.rs b/src/pes.rs index e18f2a84..a65df0f2 100644 --- a/src/pes.rs +++ b/src/pes.rs @@ -106,10 +106,9 @@ impl PesFrame { /// error (`UnexpectedEof`), not silently treated as EOF — otherwise /// truncated `.pes` data would be accepted as a graceful end. pub fn deserialize(r: &mut dyn std::io::Read) -> std::io::Result<Option<Self>> { - // Probe one byte first to distinguish clean EOF from a truncated - // header. Loop on EINTR so back-to-back signals don't fail a - // recoverable read — symmetric with read_exact's internal retry - // on the rest of the header and the data below. + // Probe one byte first to distinguish clean EOF from a truncated header. + // Loop on EINTR so a recoverable interrupted read doesn't fail — symmetric + // with read_exact's internal retry for the rest of the header and data. let mut first = [0u8; 1]; loop { match r.read(&mut first) { diff --git a/src/platform/fs_type/mod.rs b/src/platform/fs_type/mod.rs index ebabdb5a..6cc82255 100644 --- a/src/platform/fs_type/mod.rs +++ b/src/platform/fs_type/mod.rs @@ -65,7 +65,7 @@ pub fn detect(path: &Path) -> FsType { /// fd-based classification. Same return semantics as [`detect`], but /// takes a `RawFd` so callers that only have an open file (notably -/// [`crate::io::writeback::WritebackPipeline`]) don't have to +/// the writeback pipeline) don't have to /// round-trip through the path. /// /// Only implemented on Linux; other platforms return diff --git a/src/scsi/linux.rs b/src/scsi/linux.rs index 3cd0bdc4..535b56b8 100644 --- a/src/scsi/linux.rs +++ b/src/scsi/linux.rs @@ -134,10 +134,9 @@ impl SgIoTransport { hdr.flags = SG_FLAG_Q_AT_HEAD; let ret = unsafe { libc::ioctl(fd, SG_IO as _, &mut hdr as *mut sg_io_hdr) }; - // Mask DRIVER_SENSE (0x08): it only signals "sense data present", not a - // failure, and a command can complete-with-sense. Matches execute()'s - // `driver_status_real` handling (0.13.23) so a benign sense response - // here is not misread as a transport error. + // Mask DRIVER_SENSE (0x08): it only flags "sense data present", not + // a failure — matches execute()'s driver_status_real handling so a + // benign CHECK CONDITION isn't misread as a transport error. let driver_status_real = hdr.driver_status & !super::DRIVER_SENSE; if ret < 0 || hdr.status != 0 || hdr.host_status != 0 || driver_status_real != 0 { Err(()) @@ -188,16 +187,13 @@ impl Drop for SgIoTransport { let _ = Self::raw_command(self.fd, &[0x1E, 0, 0, 0, 0, 0], 3_000); unsafe { libc::close(self.fd) }; } - // Signal the recovery thread that this transport is gone. Must - // be set before the fd_recovery swap so the recovery thread - // cannot observe dead=false and then store into an fd_recovery - // slot that Drop is no longer going to drain. + // Signal the recovery thread this transport is gone. Must be set + // before the fd_recovery swap so it can't observe dead=false and + // store into a slot Drop is no longer going to drain. self.dead.store(true, std::sync::atomic::Ordering::Release); - // A failed execute() spawns a detached thread that opens a fresh - // fd into fd_recovery; that slot is normally drained at the top of - // the next execute(). If the transport is dropped before another - // execute() runs (the common abort-on-wedge path), the recovered - // fd would otherwise leak. Claim and close it here. + // A failed execute() spawns a thread that opens a fresh fd into + // fd_recovery, normally drained at the top of the next execute(). + // If dropped first (abort-on-wedge), claim and close it here. let recovered = self .fd_recovery .swap(-1, std::sync::atomic::Ordering::Acquire); @@ -242,13 +238,9 @@ impl ScsiTransport for SgIoTransport { data: &mut [u8], timeout_ms: u32, ) -> Result<ScsiResult> { - // Validate the CDB length at the entry point, BEFORE `cdb[0]` below. - // `ScsiTransport` is a pub trait, so an external caller could pass an - // empty CDB and indexing it would panic; an over-length CDB must be - // rejected rather than truncated (see `checked_cdb_len`). Both checks - // live in the shared helper so they cannot drift per platform — this - // backend used to carry its own bespoke empty-CDB guard, which macOS - // and Windows never had. + // Validate CDB length before indexing `cdb[0]`: `ScsiTransport` is + // pub, so an external caller could pass an empty or over-length + // CDB. Shared helper keeps the check identical across platforms. let cmd_len = super::checked_cdb_len(cdb, K_MAX_CDB_SIZE)?; let exec_t0 = std::time::Instant::now(); let opcode = cdb[0]; @@ -304,12 +296,9 @@ impl ScsiTransport for SgIoTransport { hdr.timeout = timeout_ms; hdr.flags = SG_FLAG_Q_AT_HEAD; - // The single blocking syscall. Returns when the device responds, - // when the kernel's timeout fires, or when the kernel's error - // recovery completes its escalation. On a healthy read this is - // <100 ms; on a slow-recovery bad sector it can be tens of - // seconds; on a hung drive it returns at `timeout_ms` with - // `host_status` flagged. + // The single blocking syscall: returns on response, kernel timeout, + // or after the kernel's own error-recovery escalation. <100ms on a + // healthy read; up to `timeout_ms` on a hung drive (host_status set). let ret = unsafe { libc::ioctl(self.fd, SG_IO as _, &mut hdr as *mut sg_io_hdr) }; let exec_elapsed_ms = exec_t0.elapsed().as_millis() as u64; @@ -326,18 +315,9 @@ impl ScsiTransport for SgIoTransport { return Err(Error::IoError { source: errno }); } - // Transport-level failure (kernel timeout, USB bridge wedge, - // bus error). `hdr.status` may still be zero — the SCSI device - // never got to send a status byte. Surface as 0xFF so callers - // (e.g. `drive_has_disc`) can detect the wedge signature. - // - // 0.13.23: mask out `DRIVER_SENSE` (0x08) before treating - // `driver_status` as a transport failure. That bit is set on - // *every* CHECK CONDITION reply just to flag "sense data is - // attached in `sbp`" — it's not an error of its own. Pre-fix - // we collapsed every drive-reported error into a synthetic - // 0xFF wedge signature and discarded the sense data, which - // killed the rip's classification logic on damaged discs. + // Transport-level failure (timeout, bridge wedge, bus error); + // status may be 0, so surface 0xFF for `drive_has_disc` to detect. + // Mask DRIVER_SENSE (0x08) first — it only flags sense-present. let driver_status_real = hdr.driver_status & !super::DRIVER_SENSE; if hdr.host_status != 0 || driver_status_real != 0 { tracing::trace!( @@ -400,12 +380,9 @@ impl ScsiTransport for SgIoTransport { unsafe { libc::close(new_fd) }; return; } - // We stored new_fd into fd_recovery. Check whether Drop - // raced us: if the transport is already dead it won't - // drain fd_recovery, so we must close new_fd ourselves. - // Use a swap to atomically claim the slot we just stored; - // if Drop already swapped it to -1 the swap returns -1 - // and Drop already closed it, so we do nothing. + // Check whether Drop raced us: if the transport is already + // dead it won't drain fd_recovery, so swap to atomically + // claim new_fd and close it; a -1 result means Drop won. if dead.load(std::sync::atomic::Ordering::Acquire) { let claimed = recovery.swap(-1, std::sync::atomic::Ordering::AcqRel); if claimed >= 0 { @@ -421,10 +398,9 @@ impl ScsiTransport for SgIoTransport { }); } - // SCSI-level failure: device responded, returned non-zero status - // (typically 0x02 CHECK CONDITION). Parse the full SPC-4 sense - // triple so callers can route on `ScsiSense::is_medium_error()` - // etc. + // SCSI-level failure: non-zero status (typically CHECK CONDITION). + // Parse the full SPC-4 sense triple so callers can route on + // `ScsiSense::is_medium_error()` etc. if hdr.status != 0 { let parsed = super::parse_sense(&sense, hdr.sb_len_wr); tracing::trace!( @@ -445,9 +421,8 @@ impl ScsiTransport for SgIoTransport { }); } - // Compute in usize so transfers in the 2–4 GiB range (permitted by - // the `> u32::MAX` guard above) don't wrap through an i32 cast and - // report a large successful read as ~0 bytes. A negative resid is + // Compute in usize so 2-4 GiB transfers don't wrap through an i32 + // cast and report a large read as ~0 bytes. Negative resid is // clamped to 0 before subtracting. let resid = hdr.resid.max(0) as usize; let bytes_transferred = data.len().saturating_sub(resid); @@ -467,17 +442,9 @@ impl ScsiTransport for SgIoTransport { } } -// ── Lightweight discovery + presence (Linux) ──────────────────────────────── -// -// `list_drives` walks `/sys/class/scsi_generic/`, filters to type-5 (CD/DVD/BD), -// and runs one INQUIRY each for vendor/model/firmware. Falls back to a -// `/dev/sg0..15` probe when sysfs is unreadable (minimal containers). -// -// `drive_has_disc` issues a single TEST UNIT READY. On the wedge signature -// (kernel returns status `0xff` with no sense — synthesised by `execute()` -// from a non-zero `host_status`) the error bubbles directly to the caller; -// no in-library reset escalation. See the rationale block on -// `drive_has_disc` below. +// Lightweight discovery + presence (Linux): `list_drives` walks sysfs +// type-5 nodes with an INQUIRY each. `drive_has_disc` sends TEST UNIT +// READY; on the wedge signature (status `0xff`, no sense) it bubbles up. /// SCSI peripheral type 5 = "CD-ROM device" (covers DVD, BD-ROM, BD-RE, etc.). /// Stored in `/sys/class/scsi_generic/sgN/device/type` as ASCII decimal. @@ -497,12 +464,9 @@ pub(super) fn list_drives() -> Vec<super::DriveInfo> { continue; } - // Read sysfs-cached identity first. The kernel runs its own INQUIRY - // at device probe time and stashes vendor/model/rev under - // `/sys/class/scsi_generic/sgN/device/`. Those values survive even - // when the drive firmware is wedged below the USB bridge (our own - // INQUIRY times out but sysfs still has the pre-wedge answer), so - // the UI always has a human-readable identity to show. + // Read sysfs-cached identity first: the kernel's own INQUIRY at + // probe time, stashed under `.../sgN/device/`. Survives even when + // the drive is wedged below the USB bridge and our INQUIRY times out. let (sysfs_vendor, sysfs_model, sysfs_firmware) = sysfs_identity(&name); // INQUIRY-only probe — open transport, run INQUIRY, drop. No @@ -571,11 +535,9 @@ fn enumerate_sg_names() -> Vec<String> { continue; } let type_path = format!("/sys/class/scsi_generic/{name}/device/type"); - // By design: only type-5 (optical) sg nodes are collected. - // A non-optical `type` value, or an unreadable `type` file - // (race against device teardown, restricted sysfs in a minimal - // container), is silently skipped — neither is a fatal - // enumeration error, the node simply is not an optical target. + // By design: only type-5 (optical) sg nodes are collected. A + // non-optical or unreadable `type` file (teardown race, + // restricted sysfs) is silently skipped, not a fatal error. match std::fs::read_to_string(&type_path) { Ok(s) if s.trim() == SCSI_TYPE_OPTICAL => names.push(name), Ok(_) => {} // not optical diff --git a/src/scsi/macos.rs b/src/scsi/macos.rs index 56ecf7d5..272613e8 100644 --- a/src/scsi/macos.rs +++ b/src/scsi/macos.rs @@ -146,12 +146,9 @@ impl ScsiTransport for MacScsiTransport { cdb: &[u8], direction: DataDirection, data: &mut [u8], - // NOTE: timeout_ms is currently ignored on macOS. The C shim - // (`macos_shim.c`) hardcodes `SetTimeoutDuration(task, 30000)`, so - // every command uses a fixed 30 s budget regardless of the - // caller's READ_TIMEOUT_MS / READ_RECOVERY_TIMEOUT_MS / TUR value. - // Plumbing it through the shim signature is tracked separately; - // macOS is dev/test-only per the project rules. + // NOTE: timeout_ms is ignored on macOS — the C shim hardcodes a + // fixed 30s `SetTimeoutDuration`, tracked separately since macOS + // is dev/test-only per project rules. _timeout_ms: u32, ) -> Result<ScsiResult> { // Match the Linux guard: a >=4 GiB buffer would wrap when cast to @@ -175,10 +172,9 @@ impl ScsiTransport for MacScsiTransport { let mut task_status: u8 = 0xFF; let mut transfer_count: u64 = 0; - // Reject an over-length CDB rather than truncating it. The guard now - // lives in `scsi::checked_cdb_len`, shared with the Linux and Windows - // backends (which used to truncate here instead of erroring) so it - // cannot drift per platform again. + // Reject an over-length CDB rather than truncating it; the guard + // lives in `scsi::checked_cdb_len`, shared with Linux and Windows + // so it cannot drift per platform again. let cdb_len = super::checked_cdb_len(cdb, K_MAX_CDB_SIZE)?; let kr = unsafe { shim_execute( @@ -195,14 +191,9 @@ impl ScsiTransport for MacScsiTransport { }; if kr != 0 { - // Log the IOKit return before collapsing it. `open()` above decodes - // the shim's sentinel into typed variants rather than flattening - // every failure, but `execute()` discarded `kr` entirely — and this - // file had no tracing at all, where the Linux and Windows backends - // both log their execute failures. That left resource contention - // (another process taking exclusive access mid-rip) and a real - // hardware wedge indistinguishable, with no diagnostic trail on - // either. + // Log the IOKit return before collapsing it — `execute()` used + // to discard `kr` with no tracing, unlike Linux/Windows, leaving + // resource contention and a real hardware wedge indistinguishable. tracing::warn!( target: "freemkv::scsi", opcode = cdb.first().copied().unwrap_or(0), @@ -227,9 +218,8 @@ impl ScsiTransport for MacScsiTransport { Ok(ScsiResult { status: 0, - // Clamp to the buffer length, matching the Linux transport's - // structural bound (data.len().saturating_sub(resid)). A lying - // drive/shim can't then produce a bytes_transferred that + // Clamp to the buffer length (matches Linux's structural bound) + // so a lying drive/shim can't produce a bytes_transferred that // exceeds the buffer a future caller might slice with. bytes_transferred: (transfer_count as usize).min(data.len()), sense, diff --git a/src/scsi/mod.rs b/src/scsi/mod.rs index 1b409b8f..6da597fe 100644 --- a/src/scsi/mod.rs +++ b/src/scsi/mod.rs @@ -214,11 +214,8 @@ mod cdb_len_tests { } // ── SPC-4 sense keys (§4.5.6 Table 28) ───────────────────────────────────── -// -// Broad failure category returned in a CHECK CONDITION reply's sense data. -// Names match the SCSI spec; predicate methods on [`ScsiSense`] (e.g. -// `is_medium_error`, `is_unit_attention`) read more fluently than raw -// constant comparisons at call sites. +// Names match the SCSI spec; predicate methods on [`ScsiSense`] read more +// fluently than raw constant comparisons at call sites. pub const SENSE_KEY_NO_SENSE: u8 = 0x00; pub const SENSE_KEY_RECOVERED_ERROR: u8 = 0x01; @@ -558,49 +555,17 @@ pub fn open(device: &Path) -> Result<Box<dyn ScsiTransport>> { } } -// Note: a top-level `scsi::reset()` used to live here, wrapping a -// platform reset in a thread+recv_timeout so a kernel-wedged ioctl -// couldn't hang the caller. Removed in 0.13.6 along with the -// SG_SCSI_RESET / STOP+START UNIT escalation that needed it. The -// remaining platform reset (Linux: SgIoTransport::reset, available -// for explicit opt-in) does pure userspace state cleanup with bounded -// sleeps — no escape-hatch wrapper required. +// Note: a top-level `scsi::reset()` wrapping platform reset in a +// thread+recv_timeout used to live here; removed in 0.13.6 along with the +// SG_SCSI_RESET / STOP+START UNIT escalation it existed to guard. -// ── USB-layer recovery: rolled back in 0.13.4 ─────────────────────────────── -// -// 0.13.1 – 0.13.3 exposed `scsi::usb_reset()` (`USBDEVFS_RESET` on Linux, -// `IOUSBDeviceInterface::ResetDevice` on macOS) and chained it into -// `drive_has_disc` recovery. Production testing on the LG BU40N USB BD-RE -// confirmed the USB stack resets succeed — dmesg logs -// `usb 3-2: reset high-speed USB device` and the device re-authorises — -// but the drive firmware below the USB bridge stays locked: LUN never -// re-enumerates, TUR still times out, the drive is unusable until -// physical unplug-replug or host reboot. Additional approaches tried -// and discarded: `authorized` 0→1 toggle, usb-storage driver -// unbind/rebind, forced SCSI host rescan, `STOP` + `START UNIT`. -// -// The APIs were removed so no caller can be misled into thinking a -// software-only recovery exists for this class of wedge. If a future -// hardware class surfaces where USB-layer recovery actually helps, the -// code should live here again, gated on a wedge signature — see git -// tag `v0.13.3` for the full implementation. +// USB-layer recovery (`scsi::usb_reset()`) was rolled back in 0.13.4: on +// the LG BU40N the USB reset itself succeeds but firmware below the +// bridge stays locked until unplug-replug. See git tag `v0.13.3`. -// ── Lightweight discovery + presence probes ───────────────────────────────── -// -// These two are the *only* hardware-touching APIs autorip + freemkv CLI use -// outside the rip path itself. They're intentionally cheap: -// -// - `list_drives()` is a one-shot enumeration: filesystem walk for sg/cdrom -// nodes, type-5 filter, single INQUIRY per candidate. No firmware, no -// reset-on-open, no init. Caller caches the result. -// - `drive_has_disc(path)` is a single TEST UNIT READY (six-byte CDB, no -// data transfer) with internal wedge-recovery escalation. Callers in a -// poll loop don't need any other primitive to detect "disc inserted / -// removed" — and they never see the SCSI-vs-USB-reset escalation. -// -// `Drive::open` + `drive.init()` + `Disc::scan` remain heavy and on-demand; -// callers only invoke them once they've decided to actually rip / verify a -// specific drive. +// The only hardware-touching APIs autorip + freemkv CLI use outside the +// rip path: a one-shot `list_drives()` enumeration and a single-TUR +// `drive_has_disc(path)`. `Drive::open`/`init()`/`Disc::scan` stay heavy. /// One optical drive on the system. Returned by [`list_drives`]. The /// fields are populated from a single INQUIRY at enumeration time — @@ -1005,10 +970,9 @@ mod parse_sense_tests { #[test] fn descriptor_format_key_nibble_masked() { - // Descriptor byte 1 low nibble is the sense key. Even though the - // upper nibble of byte 1 is reserved in descriptor format, the - // parser masks &0x0F unconditionally; set the high nibble and - // confirm it doesn't leak. + // Byte 1's upper nibble is reserved in descriptor format; the + // parser masks &0x0F unconditionally, so set garbage there and + // confirm it doesn't leak into the decoded sense key. let mut s = buf32(); s[0] = 0x72; s[1] = 0xF3; // upper nibble garbage + key 3 (MEDIUM ERROR) @@ -1052,10 +1016,9 @@ mod parse_sense_tests { #[test] fn fixed_format_short_buffer_asc_ascq_default_zero() { - // Fixed format needs n>=13 for ASC, n>=14 for ASCQ. A reply that - // only has the key byte (e.g. an 8-byte sense reply, common from - // some bridges) must yield asc=ascq=0, never read past the - // written region. Sense key must still decode. + // Fixed format needs n>=13 for ASC, n>=14 for ASCQ. A short reply + // (e.g. an 8-byte sense, common from some bridges) must yield + // asc=ascq=0, never read past the written region. let mut s = buf32(); s[0] = 0x70; s[2] = 0x04; // HARDWARE ERROR @@ -1325,9 +1288,8 @@ mod cdb_builder_tests { #[test] fn read_buffer_offset_truncates_to_24_bits_low() { // The CDB offset field is 24-bit; the builder takes the low three - // bytes of the u32. A value with a non-zero top byte must encode - // only the low 24 bits (matching the wire field width). This - // documents the actual contract, not a guess. + // bytes of the u32, so a non-zero top byte must not leak into + // the encoded field. Documents the actual wire contract. let cdb = build_read_buffer(0, 0, 0xFF01_0203, 0); assert_eq!(&cdb[3..6], &[0x01, 0x02, 0x03]); } diff --git a/src/scsi/windows.rs b/src/scsi/windows.rs index 25082e82..2d595d55 100644 --- a/src/scsi/windows.rs +++ b/src/scsi/windows.rs @@ -109,12 +109,9 @@ struct StorageAdapterDescriptor { AdapterScansDown: u8, CommandQueueing: u8, AcceleratedTransfer: u8, - // STORAGE_BUS_TYPE is an `int`-sized enum (4 bytes), not a byte. With the - // four preceding `BOOLEAN`s filling offsets 20..24, `BusType` sits at - // offset 24 and the two `USHORT` version fields follow at 28 and 30 — - // matching winioctl.h. (Declaring this `u8` total-sized to 32 by luck but - // pushed BusMajor/BusMinorVersion to offsets 26/28, so any reader of those - // fields got garbage.) + // STORAGE_BUS_TYPE is an `int`-sized enum (4 bytes), not a byte: with + // the preceding BOOLEANs filling 20..24, `BusType` sits at offset 24 + // and the USHORT version fields follow at 28/30, matching winioctl.h. BusType: u32, BusMajorVersion: u16, BusMinorVersion: u16, @@ -266,12 +263,9 @@ impl SptiTransport { return Ok(()); // can't open — skip reset, not fatal } - // Send device reset. The result must be checked: a wrong/unsupported - // IOCTL code fails with ERROR_INVALID_FUNCTION (0x1) and no-ops - // silently — exactly the regression class the doc block above records - // for the two earlier (incorrect) code values. Surface failures so a - // non-functional reset is observable rather than masked by the - // unconditional settle sleep below. + // Send device reset. Result must be checked: a wrong/unsupported + // IOCTL fails with ERROR_INVALID_FUNCTION and no-ops silently — + // surface failures so a non-functional reset isn't masked below. let mut returned: u32 = 0; let ok = unsafe { DeviceIoControl( @@ -290,10 +284,8 @@ impl SptiTransport { tracing::debug!("IOCTL_STORAGE_RESET_DEVICE succeeded"); } else { let err = unsafe { GetLastError() }; - // Not fatal — the caller treats reset as best-effort — but a - // failing reset (especially ERROR_INVALID_FUNCTION = 1) means the - // device was NOT reset, so there is nothing to settle and we must - // not pay the settle-sleep penalty below. + // Not fatal — reset is best-effort — but a failing reset means + // the device was NOT reset, so skip the settle-sleep below. tracing::warn!( last_error = err, ioctl = format_args!("{IOCTL_STORAGE_RESET_DEVICE:#010x}"), @@ -436,13 +428,9 @@ impl ScsiTransport for SptiTransport { let mut sptwb: SptwbDirect = unsafe { std::mem::zeroed() }; - // Reject an over-length CDB rather than truncating it. This used to be - // `cdb.len().min(K_MAX_CDB_SIZE)`, and the copy into `sptwb.spt.Cdb` - // below then took only the first 16 bytes: SPC-4 fixes a command's - // length by its opcode group code, so the shortened CDB is a DIFFERENT - // command, which the drive executes and answers with GOOD status and - // data for a request nobody made. Matches the Linux and macOS backends - // (all three call the same helper). + // Reject an over-length CDB rather than truncating it: SPC-4 fixes + // a command's length by opcode group code, so a truncated CDB is a + // DIFFERENT command the drive would execute and answer as GOOD. let cdb_len = usize::from(super::checked_cdb_len(cdb, K_MAX_CDB_SIZE)?); sptwb.spt.Length = std::mem::size_of::<ScsiPassThroughDirect>() as u16; sptwb.spt.CdbLength = cdb_len as u8; @@ -463,29 +451,14 @@ impl ScsiTransport for SptiTransport { }); } sptwb.spt.DataTransferLength = data.len() as u32; - // Round up to the next whole second so a 1500ms request gets at - // least 2s, not 1s. SPTI's TimeOutValue is u32 seconds with no - // sub-second resolution; biasing toward "more time" is safer than - // truncating (truncation broke 1500ms fast-reads on Drive::read). + // Round up to the next whole second (SPTI's TimeOutValue has no + // sub-second resolution) rather than truncating — truncation + // broke 1500ms fast-reads on Drive::read. sptwb.spt.TimeOutValue = ((timeout_ms + 999) / 1000).max(1); - // AlignmentMask bounce buffer. - // - // IOCTL_SCSI_PASS_THROUGH_DIRECT requires `DataBuffer` to satisfy - // the adapter's `AlignmentMask` (`(ptr & mask) == 0`). On USB - // optical bridges the mask is 0, so the caller's buffer is always - // acceptable and we point straight at it (zero-copy fast path). - // On SCSI/SAS HBAs the mask can be 3/7/… ; if the caller's buffer - // happens to be misaligned the IOCTL fails outright (status 0xFF / - // all reads fail). In that case we transfer through an aligned - // scratch buffer: over-allocate by `mask` extra bytes so an aligned - // base is guaranteed to exist inside it, align the base with - // [`crate::scsi::align_up`], and use that as `DataBuffer`. For a - // FROM-device transfer the result is copied back into `data` after - // the IOCTL; for a TO-device transfer `data` is copied in before. - // - // `bounce` is kept alive for the whole `execute()` body so the - // aligned pointer we hand the driver stays valid across the IOCTL. + // AlignmentMask bounce buffer: `DataBuffer` must satisfy the + // adapter's `AlignmentMask` (0 on USB — zero-copy; nonzero on + // SCSI/SAS HBAs, where misalignment fails the IOCTL outright). let mask = self.alignment_mask as usize; let needs_bounce = !data.is_empty() && mask != 0 && (data.as_mut_ptr() as usize) & mask != 0; @@ -531,17 +504,9 @@ impl ScsiTransport for SptiTransport { }; if ok == 0 { - // Capture the Win32 error before returning. `open()` and `reset()` - // in this file both do; `execute()` did not, and it is the hot path - // — every READ(10) of a rip goes through here. Without it - // ERROR_INVALID_PARAMETER (a struct-layout regression, the very - // class this file's SDK-layout tests exist to catch), - // ERROR_ACCESS_DENIED and ERROR_GEN_FAILURE (a genuinely wedged - // drive) all collapse to the same status-0xFF transport failure - // with nothing in the log to tell a code bug from a hardware one. - // Logged rather than added to the error type: the typed variant is - // public API and the recovery classification is deliberately the - // same for all of them. + // Capture the Win32 error before returning — this hot path used + // to omit it, collapsing struct-layout bugs, access-denied, and + // a genuinely wedged drive into the same status-0xFF with no log. let last_error = unsafe { GetLastError() }; tracing::warn!( target: "freemkv::scsi", @@ -549,13 +514,9 @@ impl ScsiTransport for SptiTransport { last_error, "DeviceIoControl(IOCTL_SCSI_PASS_THROUGH_DIRECT) failed" ); - // Driver-level failure (timeout, handle gone, etc.). Bubble - // up; in-library handle recovery was removed in 0.13.20 along - // with Linux's async fd-recovery and macOS's `try_recover` — - // the kernel mid-layer already did its escalation by the time - // DeviceIoControl returned, and re-issuing reset/reopen here - // is at best redundant and at worst deepens the wedge. Caller - // surfaces the failure to UX. + // Driver-level failure. Bubble up; in-library handle recovery + // was removed in 0.13.20 — the kernel mid-layer already escalated + // by the time DeviceIoControl returned. Caller surfaces to UX. return Err(Error::ScsiError { opcode: cdb.first().copied().unwrap_or(0), status: super::SCSI_STATUS_TRANSPORT_FAILURE, @@ -564,14 +525,9 @@ impl ScsiTransport for SptiTransport { } if sptwb.spt.ScsiStatus != 0 { - // SPTI doesn't surface a "bytes written into sense buffer" - // count separate from SenseInfoLength (input). Pass the full - // K_SENSE_SIZE; parse_sense keys off byte 0's response code - // to handle descriptor (0x72/0x73) vs fixed (0x70/0x71). - // - // 0.13.23: carry the full SPC-4 sense triple in - // `Error::ScsiError::sense` so callers can route on - // `ScsiSense::is_medium_error()` etc. + // SPTI doesn't surface a written-bytes count separate from + // SenseInfoLength; pass the full K_SENSE_SIZE and let + // parse_sense key off byte 0's response code (fixed vs descriptor). let parsed = super::parse_sense(&sptwb.sense, K_SENSE_SIZE as u8); return Err(Error::ScsiError { opcode: cdb.first().copied().unwrap_or(0), diff --git a/src/sector/decrypting.rs b/src/sector/decrypting.rs index 727fb233..9dd9d900 100644 --- a/src/sector/decrypting.rs +++ b/src/sector/decrypting.rs @@ -240,17 +240,9 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> { recovery: bool, fua: bool, ) -> Result<usize> { - // Defense-in-depth: AACS aligned units are 3 sectors (6144 bytes) and - // `decrypt_sectors` anchors units at buffer offset 0. A read that does - // not begin a whole number of units past the encrypted region's base - // (`unit_base`, the clip/extent start_lba) would decrypt every unit - // under the wrong CBC/unit alignment and silently mis-decrypt. Reject - // loud (DecryptFailed) BEFORE reading rather than ever mis-decrypting. - // The gate is measured RELATIVE to `unit_base` (set per-extent by the - // mux read paths via `set_unit_base`), never absolute `lba % 3` — a clip - // whose start_lba is not itself 3-aligned must still gate on its own - // units (else its readable units are wrongly rejected → "Decryption - // failed" on exactly those titles). + // Defense-in-depth: AACS units are 3-sector aligned; misalignment would + // silently mis-decrypt, so reject loud (DecryptFailed) before reading. + // Gated relative to `unit_base` (per-extent), not raw `lba % 3`. if matches!(self.keys, DecryptKeys::Aacs { .. }) && !crate::aacs::content::is_unit_aligned(lba, self.unit_base) { @@ -260,35 +252,23 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> { .inner .read_sectors_fua(lba, count, buf, recovery, fua)?; - // PROACTIVE MAP PATH (the storm-free mux): when a key map is installed the - // mux resolved one key per CPS unit / segment up front, so decrypt each - // aligned unit with its MAPPED key and trust it — no per-unit `is_clean` - // verdict, no reactive key-fetch. A resolver gap surfaces loud from - // `decrypt_sectors_mapped` (DecryptFailed); authored-bad TS just passes - // through for the muxer to drop. + // Proactive map path (storm-free mux): keys were resolved per unit up + // front, so decrypt with the mapped key and trust it, no per-unit + // `is_clean` check. A resolver gap fails loud; bad TS passes through. if let Some(map) = self.key_map.clone() { crate::decrypt::decrypt_sectors_mapped(&mut buf[..n], &self.keys, lba, &map)?; return Ok(n); } - // Decrypt the bytes just read IN PLACE. Scheme-agnostic (None / CSS / AACS). - // With a content map installed the `*_in_content` entry skips units OUTSIDE - // the encrypted extents (clear filesystem / nav pass through untouched); the - // mux installs no map (it reads title extents only). A genuine can't-decrypt - // (no key / misaligned unit) surfaces as `Err` and propagates; otherwise - // every unit gets its key applied and the bytes pass through — a unit that - // decrypts to broken TS is the consumer's concern (the muxer drops it), - // never a read failure. (The decrypt-verify read gate was removed: bad - // sectors are marked by physical read success, not by TS structure.) + // Decrypt `buf` in place (None / CSS / AACS); with a content map, units + // outside the encrypted extents pass through untouched. A can't-decrypt + // fails loud; broken-TS output is the muxer's concern, not a read failure. let content = self.content_ranges.clone(); // cheap Arc bump; frees the &self borrow let content_ref = content.as_deref(); - // No map installed → CSS self-descramble / clear pass-through. A genuine - // can't-decrypt (misaligned unit, or an AACS source reaching here without - // a map — a bug, since AACS decrypts only via the mapped path) surfaces as - // `Err` and propagates; otherwise every unit gets its key applied and the - // bytes pass through — a unit that decrypts to broken TS is the consumer's - // concern (the muxer drops it), never a read failure. + // No map installed: CSS self-descramble / clear pass-through. A can't- + // decrypt (misalignment, or mapless AACS reaching here — a bug) fails + // loud; otherwise units pass through, same as above. Self::decrypt_buf(&mut buf[..n], &mut self.keys, lba, content_ref)?; Ok(n) } @@ -387,11 +367,9 @@ mod tests { assert_eq!(wrapped.inner().last, Some(7200)); } - // TODO: AACS round-trip test — needs a fixture-encrypted unit - // (6144-byte aligned) plus the matching unit key. The cipher - // path itself is exercised by `crate::aacs` unit tests; here - // we only assert the decorator wires the existing helper, not - // that AES-128 is correct. + // TODO: AACS round-trip test needs a fixture-encrypted unit + matching key. + // `crate::aacs` unit tests already exercise the cipher; this would only + // assert the decorator wires the existing helper, not that AES-128 is correct. // --------------------------------------------------------------- // Additional coverage. @@ -532,11 +510,9 @@ mod tests { /// `decrypt_sectors(&mut buf[..n], ...)`. #[test] fn decrypt_span_bounded_by_reported_n() { - // Inner fills a CSS-scrambled-FLAGGED sector but reports n=0, so - // the decrypt span is empty and the buffer must come back - // byte-identical to what the inner source wrote. A whole-`buf` - // decrypt would clear byte 0x14's scramble bits and XOR the data - // region — this asserts that does NOT happen for the n=0 span. + // Inner fills a CSS-scrambled-FLAGGED sector but reports n=0, so the + // decrypt span is empty and the buffer must come back byte-identical. + // A whole-`buf` decrypt would clear the scramble bits / XOR the data. let mut wrapped = DecryptingSectorSource::new( ShortReportSource { report_n: 0 }, DecryptKeys::Css { diff --git a/src/sector/prefetched.rs b/src/sector/prefetched.rs index 35b1f19d..7c97194d 100644 --- a/src/sector/prefetched.rs +++ b/src/sector/prefetched.rs @@ -123,7 +123,7 @@ impl PrefetchedSectorSource { Self::new_with_events(reader, extents, batch_sectors, SECTOR_ALIGNMENT, halt, None) } - /// Same as [`new`] but with a callback fired from the producer + /// Same as [`Self::new`] but with a callback fired from the producer /// thread after each successful batch — used by autorip's mux /// path to surface `BytesRead` progress to the UI without the /// consumer thread having to poll. @@ -138,32 +138,25 @@ impl PrefetchedSectorSource { where S: SectorSource + Send + 'static, { - // A zero batch would make the producer loop forever emitting - // empty batches (sectors = remaining.min(0) = 0, offset never - // advances). All production callers pass a nonzero constant; a - // public-API caller passing 0 is a programming error, so reject - // it rather than spin a thread that never makes progress. + // A zero batch loops forever (sectors = remaining.min(0) = 0, offset + // never advances). All production callers pass nonzero; a 0 here is + // a caller bug, so reject rather than spin a thread with no progress. if batch_sectors == 0 { return Err(crate::error::Error::IoError { source: std::io::Error::from(std::io::ErrorKind::InvalidInput), }); } - // A zero alignment is the sibling programming error, and it is worse: the - // producer thread reaches `remaining % unit_align` and panics with a - // divide-by-zero, which `catch_unwind` then reports as - // `DemuxThreadPanicked` — a panic printed through the process hook and a - // misleading error, out of a public constructor that returned `Ok`. Reject - // it here, exactly as `batch_sectors == 0` is rejected. + // A zero alignment is worse: the producer hits `remaining % unit_align` + // and panics divide-by-zero, surfacing as a misleading DemuxThreadPanicked + // from a constructor that returned `Ok`. Reject it here too. if unit_align == 0 { return Err(crate::error::Error::IoError { source: std::io::Error::from(std::io::ErrorKind::InvalidInput), }); } - // Accumulate in u64 then clamp: extents can derive from - // untrusted nav/MPLS/UDF data, so a naive u32 `sum()` could - // panic in debug / wrap in release on a hostile total. The - // clamp only affects the advisory `capacity_sectors` figure; - // the producer walks each extent independently below. + // Accumulate in u64 then clamp: extents come from untrusted nav/MPLS/UDF + // data, so a naive u32 sum could panic/wrap. Only affects the advisory + // capacity_sectors figure; the producer walks each extent independently. let total_sectors: u32 = extents .iter() .map(|e| e.sector_count as u64) @@ -174,10 +167,8 @@ impl PrefetchedSectorSource { let (recycle_tx, recycle_rx) = bounded::<Vec<u8>>(PREFETCH_CHANNEL_DEPTH + 1); let batch_bytes = batch_sectors as usize * 2048; - // Seed the recycle pool so the producer always has a buffer - // to fill on the first iteration. Without these, the first - // `recycle_rx.recv()` would block forever (no consumer has - // returned a buffer yet). + // Seed the recycle pool so the producer has a buffer on the first + // iteration; otherwise the first recycle_rx.recv() blocks forever. for _ in 0..(PREFETCH_CHANNEL_DEPTH + 1) { let _ = recycle_tx.send(vec![0u8; batch_bytes]); } @@ -185,18 +176,9 @@ impl PrefetchedSectorSource { let producer = std::thread::Builder::new() .name("freemkv-prefetch".into()) .spawn(move || { - // Wrap the whole feed loop in catch_unwind so a panic inside a - // decrypt-on-read SectorSource, the read path, or a panicking - // `event_fn` callback is NOT indistinguishable from a clean - // finish at the demux boundary. On a clean exit (input - // exhausted, halt cancelled, consumer disconnect) the body - // returns and `tx` is dropped → the demux loop reads RecvError - // as EOF, which is correct. On a PANIC we send an explicit error - // sentinel FIRST so the demux loop's `Ok(Err(_))` arm fires and - // propagates a typed error instead of converting the dropped - // channel into a clean `DemuxBatch::Eof` (which would finalize a - // TRUNCATED mux while reporting success). `tx`/`reader`/locals - // are only touched on this thread, so AssertUnwindSafe is sound. + // catch_unwind so a panic (decrypt source, read path, event_fn) isn't + // mistaken for clean EOF: a dropped `tx` alone would finalize a + // TRUNCATED mux as success. Locals are thread-local, so this is sound. let body = std::panic::AssertUnwindSafe(|| { let mut ext_idx = 0usize; let mut offset: u32 = 0; @@ -206,10 +188,9 @@ impl PrefetchedSectorSource { return; } let extent = &extents[ext_idx]; - // AACS aligned units are anchored at THIS extent's start - // LBA, so tell the decrypt-on-read source to gate relative - // to it (clip-anchored), not absolute disc LBA 0. A no-op - // for non-decrypting / CSS / None sources. + // AACS aligned units anchor at THIS extent's start LBA, so gate + // relative to it, not absolute disc LBA 0. No-op for + // non-decrypting / CSS / None sources. reader.set_unit_base(extent.start_lba); let remaining = extent.sector_count.saturating_sub(offset); if remaining == 0 { @@ -217,22 +198,9 @@ impl PrefetchedSectorSource { offset = 0; continue; } - // The AACS aligned unit is SECTOR_ALIGNMENT (3) - // sectors / 6144 bytes; the decrypt step only - // processes full units and silently leaves a - // shorter trailing chunk encrypted. So a batch must - // be a whole number of units — except for the final - // batch of an extent whose `sector_count` is itself - // unit-aligned (then the remaining tail is exactly - // 0 mod 3 and forms full units on its own). - // - // If the trailing sectors of this extent cannot fill - // a complete unit (`remaining < SECTOR_ALIGNMENT` - // with nothing more to read, or a 1-2 sector - // leftover after the last full unit), there is no - // way to hand the decrypt step an aligned chunk — - // surface a typed error instead of emitting - // still-encrypted bytes. + // AACS units are SECTOR_ALIGNMENT (3) sectors; decrypt only + // processes full units, leaving a short tail encrypted. If the + // tail can't fill a unit, error instead of emitting encrypted bytes. if !remaining.is_multiple_of(unit_align as u32) && remaining < unit_align as u32 { @@ -240,23 +208,18 @@ impl PrefetchedSectorSource { return; } let mut sectors = remaining.min(batch_sectors as u32) as u16; - // Trim to a whole number of units. Once trimmed to 0 - // here it means `remaining >= SECTOR_ALIGNMENT` but - // the *batch window* landed on a sub-unit boundary — - // never the trailing-tail case, which the guard - // above already rejected. Clamp to one unit so we - // always make forward progress. + // Trim to a whole number of units. A trim to 0 here means the + // batch window landed on a sub-unit boundary (not the + // trailing-tail case, rejected above); clamp to one unit. if sectors >= unit_align { sectors -= sectors % unit_align; } else { sectors = unit_align; } let bytes = sectors as usize * 2048; - // Park on the recycle channel, but re-poll halt every - // POLL_INTERVAL: a pure-AtomicBool Halt does not - // disconnect the channel, so a blocking recv() would - // never re-reach the cancel check at the loop top. - // Mirrors the BytePrefetcher pattern exactly. + // Re-poll halt every POLL_INTERVAL: a pure-AtomicBool Halt + // doesn't disconnect the channel, so a blocking recv() would + // never re-reach the cancel check. Mirrors BytePrefetcher. let mut buf = loop { match recycle_rx.recv_timeout(POLL_INTERVAL) { Ok(b) => break b, @@ -270,15 +233,9 @@ impl PrefetchedSectorSource { } }; if bytes <= buf.capacity() { - // Re-expose `bytes` without zero-filling pages that - // `read_sectors` is about to overwrite. Sound because the - // enclosing `bytes <= capacity` guard bounds the length, - // and every byte below `capacity` is physically - // initialised: buffers are born `vec![0u8; batch_bytes]` - // and only ever grown via `resize(_, 0)`, so a recycled - // buffer that came back shorter (consumer `truncate`) - // still has initialised backing storage under `set_len`, - // which `read_sectors` then overwrites before any read. + // Re-expose `bytes` without zero-filling; sound because + // buffers are born `vec![0u8; batch_bytes]` and only grown + // via `resize(_, 0)`, so bytes below `capacity` are init'd. unsafe { buf.set_len(bytes) }; } else { buf.resize(bytes, 0); @@ -289,16 +246,9 @@ impl PrefetchedSectorSource { let lba = extent.start_lba.saturating_add(offset); match reader.read_sectors(lba, sectors, &mut buf[..bytes], false) { Ok(n) => { - // A short read must not silently desync the - // stream: advance the extent cursor by the - // sectors actually read, not the requested - // count, and reject a byte count that isn't a - // whole number of sectors (it would split a - // sector and leave the decrypt step a partial - // unit). The sole production inner source - // (FileSectorSource) read_exact's the full - // request, so this is belt-and-braces against - // a future short-reading source. + // A short read must not desync the stream: advance by + // sectors actually read, and reject a non-whole-sector + // count (belt-and-braces; FileSectorSource read_exact's). if n % 2048 != 0 { let _ = tx.send(Err( crate::error::Error::ExtentNotUnitAligned.into() @@ -306,17 +256,9 @@ impl PrefetchedSectorSource { return; } let sectors_read = (n / 2048) as u32; - // A genuine zero-byte read with no error - // would otherwise spin this loop forever. - // It is not end-of-stream either: the - // extent list still has `remaining` - // sectors to serve, so the inner source - // has quit early. Send a terminal - // sentinel — dropping `tx` here instead - // would reach the consumer as a clean EOF - // and finalize a TRUNCATED title as - // success, exactly as the panic sentinel - // below exists to prevent. + // A zero-byte read isn't EOF (extents still have + // `remaining`) and would spin forever; send a terminal + // sentinel instead of a clean EOF that reports success. if sectors_read == 0 { let _ = tx.send(Err(crate::error::Error::SourceTerminated.into())); @@ -346,10 +288,9 @@ impl PrefetchedSectorSource { // Drop tx implicitly — consumer sees RecvError → EOF. }); if std::panic::catch_unwind(body).is_err() { - // Producer panicked mid-stream — surface a typed terminal - // error so the demux thread does NOT read the dropped channel - // as a clean EOF and truncate output. Ignore the send result: - // if the consumer is already gone there is nothing to report. + // Panicked mid-stream — surface a typed error so the demux + // doesn't read the dropped channel as clean EOF and truncate. + // Ignore send failure: consumer already gone, nothing to report. let _ = tx.send(Err(crate::error::Error::DemuxThreadPanicked.into())); } }) @@ -376,26 +317,13 @@ impl PrefetchedSectorSource { /// queries; its `SectorSource` impl becomes invalid after this /// call (data has been moved out). pub fn into_channels(self) -> (Receiver<Batch>, Sender<Vec<u8>>, PrefetchShell) { - // MOVE the three fields out cleanly — never clone. Each of - // `rx` and `recycle_tx` ends up with exactly ONE live copy: - // the one in the returned tuple. The pre-1.0.0 implementation - // cloned both and then `mem::forget`-ed `self`, leaking the - // originals so an extra live receiver + sender survived - // forever. That defeated the channel-disconnection shutdown: - // when the demux consumer exited early (halt, or a `tx.send` - // error in `demux_thread`), the producer's `recycle_rx.recv()` - // and `tx.send()` never saw all-peers-dropped, so the producer - // never returned and `PrefetchShell::drop`'s `join()` hung. - // - // `ManuallyDrop` + `ptr::read` reads each field out by value - // and suppresses `self`'s own `Drop` (which would otherwise - // double-`join`), leaving NO extra live endpoint behind. This - // is the panic-free equivalent of the `Option::take` approach. + // MOVE the fields out, never clone: pre-1.0.0 code cloned + `forget`-ed + // `self`, leaking endpoints that defeated disconnection shutdown and + // hung `PrefetchShell::drop`'s `join()`. `ManuallyDrop` avoids that. let me = std::mem::ManuallyDrop::new(self); - // SAFETY: `me` is `ManuallyDrop`, so none of these fields will - // be dropped by `me`. Each `ptr::read` performs exactly one - // bitwise move out; every field is read exactly once and never - // touched again, so there are no double-frees and no aliasing. + // SAFETY: `me` is `ManuallyDrop` so it drops none of these fields; + // each `ptr::read` moves exactly one field out exactly once, so + // there are no double-frees and no aliasing. let producer = unsafe { std::ptr::read(&me.producer) }; let rx = unsafe { std::ptr::read(&me.rx) }; let recycle = unsafe { std::ptr::read(&me.recycle_tx) }; @@ -420,23 +348,9 @@ impl Drop for PrefetchShell { impl Drop for PrefetchedSectorSource { fn drop(&mut self) { - // Drop the channel endpoints BEFORE joining the producer. - // - // `rx` and `recycle_tx` are sibling fields, so they are dropped only - // AFTER this `Drop::drop` body returns. Joining first therefore joined - // while both endpoints were still alive: a producer parked in the plain - // blocking `tx.send(Ok(buf))` (no timeout, so the `Halt` is never - // re-polled) never observed a disconnect and never returned, and the - // dropping thread blocked in `join()` forever. Any source dropped before - // its extents were drained — an error path, an operator stop, or a - // consuming crate using the public `new` + direct `read_sectors` — hit a - // permanent two-thread deadlock. `BytePrefetcher::drop` already had this - // shape; see `drop_undrained_source_joins_cleanly`. - // - // The endpoints are moved out via `mem::replace` with already-disconnected - // stand-ins (each stand-in's peer is dropped immediately), which drops the - // real ones here and needs neither `Option` fields nor `unsafe` — and - // leaves `into_channels`'s `ptr::read` moves untouched. + // Drop endpoints BEFORE joining: siblings drop only after this body + // returns, and joining first left a producer blocked forever in + // `tx.send`. Swap in disconnected stand-ins to drop the real ones. let (dead_tx, dead_rx) = bounded::<Batch>(0); drop(dead_tx); drop(std::mem::replace(&mut self.rx, dead_rx)); @@ -464,27 +378,18 @@ impl SectorSource for PrefetchedSectorSource { buf: &mut [u8], _recovery: bool, ) -> Result<usize> { - // The producer has already decided the next batch. lba/count - // are advisory; the consumer's fill_extents will advance its - // own bookkeeping using the returned byte count, not the - // requested count. + // The producer already decided the next batch. lba/count are + // advisory; fill_extents advances its own bookkeeping using the + // returned byte count, not the requested count. match self.rx.recv() { Ok(Ok(filled)) => { - // Precondition: the caller's buffer must be large - // enough to hold the producer's batch. If it is not we - // would silently drop `filled[buf.len()..]`, desyncing - // the stream. The production zero-copy path never uses - // this method (it consumes the channel directly via - // `into_channels`), so a too-small buffer here is a - // caller bug — surface it instead of corrupting data. + // Precondition: buf must hold the whole batch, else we'd + // silently drop the tail and desync the stream. Production + // uses `into_channels` directly, so this is a caller bug. if filled.len() > buf.len() { - // Recycle the buffer before returning the error so the - // pool invariant is preserved on every code path. Without - // this, every too-small-buffer error permanently removes - // one buffer from the pool; after PREFETCH_CHANNEL_DEPTH+1 - // such errors the pool is exhausted and the producer - // blocks forever on recycle_rx.recv() while the consumer - // blocks on rx.recv() — permanent deadlock. + // Recycle before erroring, or the pool loses one buffer per + // error; after PREFETCH_CHANNEL_DEPTH+1 errors it's exhausted + // and producer/consumer deadlock on recycle_rx/rx.recv(). let _ = self.recycle_tx.send(filled); return Err(crate::error::Error::IoError { source: std::io::Error::from(std::io::ErrorKind::InvalidInput), @@ -492,26 +397,15 @@ impl SectorSource for PrefetchedSectorSource { } let n = filled.len(); buf[..n].copy_from_slice(&filled[..n]); - // Return the buffer to the recycle pool so the producer - // can re-fill it. Without this the pool (seeded with - // PREFETCH_CHANNEL_DEPTH+1 buffers) drains after that - // many reads and the producer blocks forever on - // `recycle_rx.recv()` while the consumer blocks on the - // next `rx.recv()` — a permanent deadlock. The - // `into_channels` zero-copy path recycles explicitly; - // this direct-read path must do the same. + // Recycle so the producer can re-fill: without it the seeded pool + // drains after PREFETCH_CHANNEL_DEPTH+1 reads and both sides + // deadlock on recycle_rx/rx.recv(), same as `into_channels`. let _ = self.recycle_tx.send(filled); Ok(n) } - // Recover the producer's TYPED error rather than blanket-wrapping - // it as `Error::IoError`. That wrapper is a classification, not a - // container: `is_scsi_transport_failure` matches `IoError`, so a - // wrapped MEDIUM ERROR bad sector reached `fill_extents` looking - // like a wedged bridge and aborted the pass instead of being - // skipped under `skip_errors`. `From<io::Error> for Error` - // downcasts the boxed payload back to the exact variant the - // producer sent (status + sense intact); a genuine OS-level - // `io::Error` — the real dead-bus case — still becomes `IoError`. + // Recover the producer's TYPED error, not a blanket `Error::IoError`: + // that also matches `is_scsi_transport_failure`, so a wrapped MEDIUM + // ERROR bad sector looked like a wedged bridge instead of skippable. Ok(Err(e)) => { // The producer `return`s after every error it sends, so // this is also the moment the source dies. Latch it: the @@ -519,12 +413,9 @@ impl SectorSource for PrefetchedSectorSource { self.producer_failed = true; Err(crate::error::Error::from(e)) } - // Channel closed. Clean EOF only if the producer never - // signalled a failure — otherwise it exited without - // delivering the rest of the extents, and answering `Ok(0)` - // would let `fill_extents` mistake a dead source for a short - // read, zero-fill every remaining sector of the title and - // still report the pass as complete. + // Channel closed. Clean EOF only if the producer never signalled a + // failure — else `Ok(0)` would let fill_extents mistake a dead + // source for a short read and zero-fill the rest as "complete". Err(_) if self.producer_failed => Err(crate::error::Error::SourceTerminated), Err(_) => Ok(0), } @@ -1098,11 +989,9 @@ mod tests { let pf = PrefetchedSectorSource::new(src, extents, 3, None).expect("spawn"); // 9 + 6 + 3 = 18, independent of inner source capacity. assert_eq!(pf.capacity_sectors(), 18); - // Release the producer without draining via the production - // zero-copy path: peel the channels and drop them so the - // producer observes disconnection. (A direct `drop(pf)` is - // also safe now — see `drop_undrained_source_joins_cleanly` - // — but the mux always uses into_channels.) + // Release without draining via the production zero-copy path: peel + // channels and drop them so the producer observes disconnection + // (a direct `drop(pf)` is also safe; see the test below). let (rx, recycle_tx, shell) = pf.into_channels(); drop(rx); drop(recycle_tx); @@ -1410,18 +1299,9 @@ mod tests { // Caller buffer holds only 1 sector — far too small for a 3-sector batch. let mut tiny = vec![0u8; 2048]; - // 5 > pool depth of 3: without the fix the pool exhausts by - // iteration 4 and both threads deadlock. - // 8 >> pool depth (3): WITHOUT the recycle-on-error fix the pool - // is drained after the 3rd error and the 4th read deadlocks - // (producer blocks on recycle_rx, consumer on rx). WITH the fix the - // buffer is returned to the pool on every error path, so the pool - // never drains and all reads complete. Reaching this loop's end is - // the regression assertion. (read_sectors is a non-production - // direct path; the production mux uses the zero-copy into_channels - // path. A correctly-sized read afterward is intentionally NOT - // asserted — that would couple the test to exact producer EOF/tx - // pacing, which is unrelated to the pool-exhaustion invariant.) + // 8 >> pool depth (3): WITHOUT recycle-on-error the pool drains by + // the 3rd error and read 4 deadlocks. WITH the fix every error + // recycles its buffer, so reaching loop end is the regression check. for i in 0..8 { let r = pf.read_sectors(0, 1, &mut tiny, false); assert!( diff --git a/src/session.rs b/src/session.rs index fbbe6185..3412b1e0 100644 --- a/src/session.rs +++ b/src/session.rs @@ -83,20 +83,16 @@ pub fn resolve_keys_for( }; }; - // Build the ordered sources once for the up-front resolve. Sampling reads the - // disc, so skip it when there is no source to validate against (a dropped / - // SSRF-rejected online-only source) — resolution is a miss regardless and the - // read would be pure waste. + // Build the ordered sources once for the up-front resolve. Skip the disc-reading + // sample step when there is no source to validate against (a dropped / SSRF- + // rejected online-only source) — resolution is a miss regardless; the read is waste. let src_vec = sources(); inputs.samples = if src_vec.is_empty() { Vec::new() } else { - // Sample encrypted units from the LARGEST title that has video — the - // main feature carries the most units to validate a key against, while a - // size-inflated STREAMLESS decoy (the biggest title on some obfuscated - // UHDs) would waste the read on the wrong title. Falls back to the - // largest title outright when none has video (title-less/empty discs and - // the pre-scan sampling path). + // Sample from the LARGEST title that HAS video — the main feature carries the + // most validation units, avoiding a size-inflated STREAMLESS decoy on some + // obfuscated UHDs. Falls back to largest title outright when none has video. match disc .titles .iter() @@ -162,7 +158,7 @@ pub struct KeySpec { /// /// Owns the [`Drive`] by value. Consumers that still need the raw drive (e.g. /// to sample ciphertext for key validation, or to move it into a -/// `DiscStream`) reach it via [`Self::drive_mut`] / [`Self::into_drive`]; the +/// `DiscStream`) reach it via [`Self::into_drive`]; the /// scanned [`Disc`] comes out via [`Self::disc`] / [`Self::take_disc`]. pub struct DiscSession { /// The opened drive. `Some` from [`Self::open`] until @@ -221,10 +217,9 @@ impl DiscSession { })?, }; - // Advisory bring-up — non-fatal in every consumer today. Preserve that: - // log and continue, never propagate. (The CLI printed these to stderr / - // discarded them; autorip `tracing::warn`'d them. The advisory SEMANTICS - // are what matter and are preserved identically; the sink is now here.) + // Advisory bring-up — non-fatal in every consumer today. Preserve that: log + // and continue, never propagate (CLI discarded these, autorip warned) — the + // advisory semantics are preserved identically; only the sink moved here. if let Err(e) = drive.wait_ready() { tracing::warn!(target: "freemkv::session", error = %e, "wait_ready advisory failed (continuing)"); } @@ -249,12 +244,9 @@ impl DiscSession { /// Fast disc identification — name/format only, no playlist parse. Wraps /// [`Disc::identify`]. pub fn identify(&mut self) -> Result<DiscId> { - // Same reachability as `scan` / `resolve_keys` below: the PUBLIC - // `stage_drive_as_reader` / `into_drive` move the drive out of the - // session, so this slot can legitimately be empty when a caller reaches - // here. A library must not panic from public API — going through - // `drive_mut` would hit its `.expect("drive present")`. Return the same - // typed `DeviceNotReady` its two siblings already do. + // Same reachability as `scan`/`resolve_keys`: public `stage_drive_as_reader`/ + // `into_drive` move the drive out, so this slot can legitimately be empty. + // A library must not panic from public API, so return typed `DeviceNotReady`. let drive = self.drive.as_mut().ok_or_else(|| Error::DeviceNotReady { path: self.device.clone(), })?; @@ -266,11 +258,9 @@ impl DiscSession { /// set), runs [`Disc::scan`], stores the result, and returns a borrow. pub fn scan(&mut self, opts: ScanOptions) -> Result<&Disc> { let opts = forward_key_material(&mut self.spec, opts); - // `stage_drive_as_reader` is PUBLIC and moves the drive into the reader - // slot, so this slot can legitimately be empty when a caller reaches - // here. A library must not panic from public API, and "no shipped - // consumer calls it in that order" is not the same as "cannot happen" — - // the public surface permits it, so it must be an error. + // `stage_drive_as_reader` is PUBLIC and moves the drive out, so this slot can + // legitimately be empty here. "No shipped consumer calls it in that order" is + // not "cannot happen" — the public surface permits it, so it must be an error. let drive = self.drive.as_mut().ok_or_else(|| Error::DeviceNotReady { path: self.device.clone(), })?; @@ -540,29 +530,9 @@ fn probe_folder_encryption(reader: &mut dyn SectorSource, disc: &Disc) -> Result use crate::consts::SECTOR_BYTES; const UNIT_SECTORS: u32 = 3; - // Anchor on the largest TITLE's FIRST extent, not on the largest extent - // anywhere. - // - // AACS units are 3 sectors, and a unit boundary is only guaranteed at the - // START of a clip. `max_by_key` over every extent picked a mid-file one for - // any clip big enough to be split: the planner caps an allocation - // descriptor at MAX_AD_BYTES = 524287 sectors, every full piece of a split - // file therefore ties on sector_count, and `max_by_key` returns the LAST - // tie — an extent starting (k-1)*524287 sectors in. 524287 % 3 == 1, so - // that start is off the unit boundary for two file sizes in three. - // - // The sampling then reads 6144-byte windows that begin mid-source-packet, - // so the CPI byte it thinks it is testing is content. Both verdicts are - // wrong in a costly direction: a decrypted folder gets rejected as - // encrypted (DirImageEncrypted on something perfectly rippable), or - // genuine ciphertext reads as clear and the mux writes it out as video at - // exit 0. `is_unit_aligned` cannot catch it, because it measures against - // this same wrong base. - // Probe the first extent of the LARGEST title that has video — the movie — - // not merely the largest-by-sector-count title, which on an obfuscated disc - // can be a streamless decoy whose base would mismeasure the ciphertext - // alignment for the actual feature. Falls back to the largest title when - // none has video. + // Anchor on the largest TITLE's FIRST extent (video preferred, skipping an + // obfuscated decoy), never the largest extent anywhere: AACS units are 3 sectors, + // aligned only at a clip's START — misalignment risks a false clean/encrypted verdict. let Some(extent) = disc .titles .iter() @@ -606,11 +576,9 @@ fn probe_folder_encryption(reader: &mut dyn SectorSource, disc: &Disc) -> Result return Ok(true); } } - // Nothing was actually sampled — the largest title is shorter than one - // aligned unit, so there is no evidence either way. "Not encrypted" is the - // dangerous default here: it would clear the structural verdict an `AACS` - // directory raised and rip ciphertext as though it were video, at exit 0. - // With no evidence, keep the structural verdict. + // Nothing was sampled (title shorter than one aligned unit) — no evidence either + // way. "Not encrypted" is the dangerous default: it would clear an `AACS`-directory + // verdict and rip ciphertext as video at exit 0. Keep the structural verdict instead. if sampled == 0 { return Ok(true); } diff --git a/src/testlog.rs b/src/testlog.rs index 80c2f33d..f5575c2b 100644 --- a/src/testlog.rs +++ b/src/testlog.rs @@ -100,14 +100,9 @@ thread_local! { struct Capture; impl tracing::Subscriber for Capture { - // `sometimes`, deliberately NOT the default `always`/`never`. A cacheable - // interest lets `tracing`'s GLOBAL per-callsite cache short-circuit a - // callsite to "off", and under `cargo test`'s parallel harness a cache - // rebuild triggered by ANY other thread can leave it there while a capture - // is live — the capture then observes NOTHING. That is the race that made - // `parse_playlist_unreadable_clip_icb_yields_no_title` flake ~1 run in 15. - // `sometimes` forces `enabled` to be consulted on the emitting thread for - // every event, so a capture always sees its own. + // `sometimes`, not `always`/`never`: a cacheable interest lets tracing's global + // per-callsite cache latch to "off" if another thread's rebuild races a live + // capture. `sometimes` forces `enabled` to run per-event, so captures always see their own. fn register_callsite( &self, _meta: &'static tracing::Metadata<'static>, diff --git a/src/udf.rs b/src/udf.rs index bdf3abc7..69551328 100644 --- a/src/udf.rs +++ b/src/udf.rs @@ -225,7 +225,7 @@ impl UdfFs { self.read_file_limited(reader, path, Some(max_bytes)) } - /// Shared implementation of [`read_file`] / [`read_file_prefix`]. When + /// Shared implementation of [`Self::read_file`] / [`Self::read_file_prefix`]. When /// `max_bytes` is `Some`, reads at most that many bytes and the whole-file /// `MAX_FILE_BYTES` anti-DoS cap on the declared size / extent lengths is /// not applied (the read is already bounded by `max_bytes`). @@ -270,11 +270,9 @@ impl UdfFs { // n bytes (rounded up to a sector) and skip the anti-DoS caps below. let limit = max_bytes.unwrap_or(usize::MAX); - // Tiny files (notably the AACS `*.inf` key files) may store their data - // embedded inline in the ICB (AD type 3), with no out-of-line extents. - // Honor that before the extent path, which would otherwise misparse the - // embedded bytes as allocation descriptors and (since 0.31.0) hard-error - // on the resulting bogus extent length. + // Tiny files (notably AACS `*.inf` key files) may store data embedded inline in + // the ICB (AD type 3), with no out-of-line extents. Honor that before the extent + // path, which would otherwise misparse embedded bytes as ADs and hard-error. if let Some(mut inline) = self.read_inline_data(reader, entry.meta_lba)? { let want = (entry.size as usize).min(limit); if inline.len() > want { @@ -283,16 +281,14 @@ impl UdfFs { return Ok(inline); } - // Read the file's data extents. Multi-extent files (fragmented or split - // across dual layers) would otherwise be silently truncated to the - // first extent, since the buffer is sized to entry.size and truncate() - // can't grow it. + // Read the file's data extents. Multi-extent files (fragmented or split across + // dual layers) would otherwise be silently truncated to the first extent, since + // the buffer is sized to entry.size and truncate() can't grow it. let extents = self.read_icb_extents(reader, entry.meta_lba)?; - // Reject an oversized declared total before allocating: entry.size is a - // raw u64 off the ICB, so a crafted file could otherwise force a - // multi-hundred-MiB / GiB allocation. Only for the UNBOUNDED path — a - // bounded read is limited by `limit` regardless of the declared size. + // Reject an oversized declared total before allocating: entry.size is a raw u64 + // off the ICB, so a crafted file could force a GiB allocation. Only for the + // UNBOUNDED path — a bounded read is already limited by `limit`. if max_bytes.is_none() && entry.size > MAX_FILE_BYTES { return Err(Error::DiscRead { sector: self.partition_start as u64, @@ -301,10 +297,9 @@ impl UdfFs { }); } - // Read the file DATA from the physical partition (partition_start + - // lba), NOT the metadata partition: ICBs are in metadata, data is in - // physical. Pre-allocate to the smaller of declared size and the - // requested prefix (capped so a bogus entry.size can't reserve GiB). + // File DATA lives in the physical partition (partition_start + lba), NOT the + // metadata partition where ICBs live. Pre-allocate to declared-size/prefix, + // capped so a bogus entry.size can't force a GiB-scale reservation. let cap_hint = (entry.size as usize) .min(limit) .min(MAX_FILE_BYTES as usize); @@ -313,10 +308,9 @@ impl UdfFs { 'extents: for ext in extents { let (data_lba, data_len) = (ext.lba, ext.len); if max_bytes.is_none() { - // Anti-DoS guards for the unbounded path: a crafted ICB can - // chain many extents whose running total grows `data` into GiB, - // or declare a single oversized extent. (Skipped when bounded — - // `limit` already caps the read.) + // Anti-DoS guard for the unbounded path: a crafted ICB could chain + // extents whose running total (or a single extent) exceeds the cap. + // Skipped when bounded — `limit` already caps the read. if data.len() as u64 + data_len as u64 > MAX_FILE_BYTES || data_len as u64 > MAX_FILE_BYTES { @@ -328,12 +322,9 @@ impl UdfFs { } } let sector_count = (data_len as u64).div_ceil(2048) as u32; - // ECMA-167 4/14.14.1.1 type 1: allocated but not recorded. The - // extent occupies the file's byte space and its contents are - // defined to be zeros, so emit the zeros WITHOUT reading the media - // (those sectors hold nothing this file wrote). Skipping the extent - // instead would slide every later extent's bytes down by this - // hole's length — silent corruption, no error. + // ECMA-167 4/14.14.1.1 type 1: allocated-not-recorded. Its bytes are defined + // as zeros, so emit zeros WITHOUT reading media. Skipping the extent instead + // would slide every later extent's bytes down by this hole's length. if !ext.recorded { for _ in 0..sector_count { if data.len() >= limit { @@ -605,43 +596,18 @@ impl UdfFs { } }; - // Allocation-descriptor type lives in the ICB Tag flags (low 3 - // bits). The ICB Tag immediately follows the 16-byte descriptor - // tag, and its `flags` u16 is the last field at ICB-tag offset 18 - // → absolute offset 34, for both File Entry (261) and Extended - // File Entry (266). 0 = Short AD (8 bytes), 1 = Long AD (16 bytes), - // 2 = Extended AD (20 bytes), 3 = data embedded inline in the ICB. - // - // This MUST be honoured: a Short AD and a Long AD both carry - // length+lba in their first 8 bytes, so hardcoding an 8-byte - // stride reads descriptor #0 of a Long-AD file correctly but lands - // descriptor #1 in the middle of the first Long AD (its impl_use - // bytes) — garbage that trips the terminator/unknown-type break. - // Large BD-ROM .m2ts streams use Long ADs, so that bug truncated - // every multi-extent title at its first extent (~973 MB-1 GiB). + // AD type lives in ICB Tag flags (low 3 bits) at offset 34: 0=Short(8B), + // 1=Long(16B), 2=Extended(20B), 3=embedded. Must be honoured — a hardcoded + // 8-byte stride on Long-AD files reads impl_use garbage, truncating BD-ROM titles. let icb_flags = u16::from_le_bytes([icb[34], icb[35]]); let ad_type = (icb_flags & 0x07) as usize; - // Type 3 is EMBEDDED data: the descriptor field holds the file's own - // content, not allocation descriptors. Reading it as a run of - // (length, LBA) pairs manufactures extents out of arbitrary file - // bytes and sends the reader to unrelated sectors, which a rip then - // emits as this title's stream at rc=0. `read_directory` refuses - // exactly this shape a few hundred lines below; this is the file half - // of that same decision, and it must be an ERROR rather than an empty - // list — an empty list reaches the caller as a clip that contributed - // nothing while its declared duration and size still count it, which - // is the silent loss pointed the other way. - // - // A real disc CAN store a file this way, but only a tiny one: an ICB - // caps embedded content at well under 2 KiB, and the callers that - // expect such a file (the AACS `*.inf` key files) read it through - // `read_inline_data` before any extent is requested. No .m2ts, .ssif - // or .evo can physically fit, so no retail stream file reaches here. + // Type 3 is EMBEDDED data (field holds file content, not ADs); misreading it as + // (length, LBA) pairs manufactures bogus extents a rip emits as stream data at + // rc=0, so this must ERROR. Only tiny files use it (2 KiB cap, e.g. AACS keys). if ad_type == 3 { - // ...except a zero-length file, which is legally encodable as - // embedded-with-nothing-embedded. It has no extents and no - // content, so it is not a failure — and returning the empty list - // matches what a zero-length file already produces today. + // ...except a zero-length file, legally encodable as embedded-with-nothing: + // no extents and no content, so return the empty list rather than error, + // matching what a zero-length file already produces today. if l_ad == 0 { return Ok(Vec::new()); } @@ -658,19 +624,16 @@ impl UdfFs { let mut extents = Vec::new(); - // Parse the first allocation-descriptor list from the ICB. A type-3 - // descriptor ("next extent of allocation descriptors") points at a - // continuation block in the metadata partition holding more ADs; we - // follow the chain. The hop count is bounded to avoid looping on a - // crafted/corrupt disc. + // Parse the first allocation-descriptor list from the ICB. A type-3 descriptor + // ("next extent of allocation descriptors") points at a continuation block in + // the metadata partition; follow the chain, bounding hops to avoid looping. let mut block = icb; let mut ad_start = ad_offset; let mut ad_bytes = l_ad; const MAX_AD_BLOCKS: usize = 256; - // Set only when a block ends the chain (no continuation pointer). Running - // out of hops instead means the rest of the descriptor list was never - // read, so `extents` describes only PART of the file — see the error's - // own documentation for why handing that back is worse than failing. + // Set only when a block ends the chain (no continuation pointer). Running out + // of hops instead leaves `extents` describing only PART of the file, which is + // worse than failing — see the error's own documentation. let mut chain_ended = false; for _ in 0..MAX_AD_BLOCKS { @@ -703,32 +666,18 @@ impl UdfFs { ]); match extent_type { - // Recorded and allocated. A zero-length type-0 - // descriptor is the AD-list terminator (continuation - // blocks are scanned to the end of the sector, so the - // trailing zero padding must not be read as extents). + // Recorded and allocated. A zero-length type-0 descriptor is the + // AD-list terminator: continuation blocks are scanned to the end of + // the sector, so trailing zero padding must not be read as extents. 0 if data_len == 0 => break, 0 => extents.push(IcbExtent { lba: data_lba, len: data_len, recorded: true, }), - // ECMA-167 4/14.14.1.1 type 1: allocated but NOT recorded. - // The extent is part of the file and occupies `data_len` - // bytes of its byte space — its contents are defined to be - // zeros. It must be KEPT (with `recorded: false`, so no - // caller reads those sectors): dropping it slid every later - // extent's data down by this hole's length, silently - // corrupting the file with no error anywhere. - // Type 2 ("not recorded and not allocated") is the other - // sparse-hole encoding and has the SAME byte-space - // semantics: no on-disc data, but the extent is part of the - // file and occupies `data_len` bytes of it. It used to fall - // into the catch-all below, which exits the descriptor loop - // WITHOUT setting a continuation pointer — so `chain_ended` - // was then set true and the function returned Ok with a - // silently truncated list, defeating the UdfAdChainTooLong - // gate and delivering a short file as a complete one. + // Types 1/2 (ECMA-167 4/14.14.1.1) are sparse holes: no on-disc data, + // but the extent occupies `data_len` bytes of file space, so it must + // be KEPT with `recorded: false` — dropping it silently truncates/corrupts. 1 | 2 => extents.push(IcbExtent { lba: data_lba, len: data_len, @@ -743,17 +692,9 @@ impl UdfFs { } break; } - // Unreachable: `extent_type` is `raw_len >> 30`, a 2-bit - // value, and 0/1/2/3 are now all handled above. Kept as a - // conservative stop rather than a panic, because a library - // must not crash on disc content. - // - // Do not add a new extent type here without handling it - // properly: reaching this `break` leaves `next_block` unset, - // so the loop below takes its `None` arm and sets - // `chain_ended = true` — the list is then returned as Ok, - // silently truncated, with the UdfAdChainTooLong gate - // satisfied. That is exactly how type 2 was being dropped. + // Unreachable: `extent_type` is 2-bit `raw_len >> 30`, 0-3 all + // handled above; kept as a conservative stop, not a panic. New + // types must be handled here, else this silently truncates (as type 2 once was). _ => break, } } @@ -761,23 +702,9 @@ impl UdfFs { match next_block { Some(cont_lba) => { read_sector(reader, self.meta_to_abs(cont_lba)?, &mut block)?; - // A continuation block does NOT begin with allocation - // descriptors — it begins with a 24-byte Allocation Extent - // Descriptor (ECMA-167 4/14.5, tag id 258): the 16-byte - // descriptor tag, then prev_allocation_extent_location - // (Uint32 @16) and length_of_allocation_descriptors - // (Uint32 @20). The real ADs (same Short/Long/Extended type - // as the file) start at offset 24, and their total byte - // length is that @20 field. - // - // Reading from offset 0 parses the AED's own tag header as - // allocation descriptors → one garbage extent, then an - // unknown extent_type break — silently truncating every - // file whose ADs spill into a continuation block (a heavily - // fragmented file, e.g. a Blu-ray 3D interleaved base-view - // .m2ts with ~1600 fragments). A normal few-extent .m2ts - // fits inline and never reaches here, which is why this - // stayed hidden. + // A continuation block starts with a 24-byte Allocation Extent + // Descriptor (ECMA-167 4/14.5, tag 258); real ADs start at offset + // 24 (length @20) — reading from 0 truncates fragmented files (e.g. BD3D). let aed_l_ad = u32::from_le_bytes([block[20], block[21], block[22], block[23]]) as usize; ad_start = 24; @@ -881,17 +808,9 @@ impl UdfFs { ) -> Result<Vec<(u32, u32)>> { let meta_lba = self.entry_at(path)?.meta_lba; let abs = self.extents_abs_at(reader, meta_lba)?; - // Refuse only a hole that actually OCCUPIES byte space. A zero-length - // unrecorded descriptor displaces nothing: every later extent sits at - // the same offset with or without it, so the read plan is identical to - // the one this resolver would have produced anyway, and the callers' - // `sectors > 0 && lba > 0` filter already discards it. - // - // Refusing on `!recorded` alone dropped whole titles off discs that - // ripped correctly — the reverse of the defect the refusal exists for, - // and just as silent. The hazard is a hole with LENGTH: reading it - // splices undefined sectors into the rip, and dropping it slides every - // later extent's byte space. + // Refuse only a hole that OCCUPIES byte space (zero-length displaces + // nothing, so `!recorded` alone wrongly drops fine titles). The hazard is + // LENGTH: reading it splices undefined sectors in; dropping shifts later extents. if abs.iter().any(|e| !e.recorded && e.len > 0) { return Err(Error::UdfUnrecordedExtent { path: path.to_string(), @@ -988,37 +907,14 @@ pub fn read_filesystem(reader: &mut dyn SectorSource) -> Result<UdfFs> { return Err(Error::UdfNotFilesystem); } - // Step 2: Read the Main Volume Descriptor Sequence and find the Partition - // Descriptor (tag 5) and Logical Volume Descriptor (tag 6). - // - // ECMA-167 3/10.2.1 DEFINES the Main VDS by the extent_ad the AVDP carries - // — ExtentLength (bytes) at [16:20], ExtentLocation (LBA) at [20:24] — it - // does NOT fix the sequence at sector 32. Sweeping a hardcoded 32..64 - // window therefore fails to mount a conformant volume that records its VDS - // anywhere else (and reads 32 sectors that belong to something else). - // Follow the pointer; fall back to the customary window only when the - // anchor's extent is unusable (zero length, zero location, or a location - // whose sweep would wrap the address space), which is exactly the - // malformed-anchor case the old constant silently papered over. The - // reserve sequence recorded at [24:32] is not consulted here. + // Step 2: find Partition Descriptor (tag 5) / Logical Volume Descriptor (tag + // 6) in the Main VDS. ECMA-167 3/10.2.1 defines it via the AVDP's extent_ad + // (length [16:20], LBA [20:24]), not a fixed sector 32 — follow the pointer. let vds_len_bytes = u32::from_le_bytes([avdp[16], avdp[17], avdp[18], avdp[19]]); let vds_lba = u32::from_le_bytes([avdp[20], avdp[21], avdp[22], avdp[23]]); - // Candidates in order: the extent the anchor records, then the customary - // location. The recorded one is used only when its SHAPE is usable — - // ECMA-167 3/10.2.1 requires at least 16 sectors (32 768 bytes), and a zero - // location or an address-wrapping length is not a sequence. - // - // Shape is not enough on its own. An anchor can pass all three checks and - // still point somewhere that holds no Volume Descriptor Sequence — a - // mastering tool that wrote the reserve location, a stale anchor on a - // rewritten volume, or deliberate corruption. Selecting the fallback only - // on shape means the branch a damaged disc actually takes has no recovery - // path. So the fallback is retried on OUTCOME too: if the recorded extent - // yields no Partition Descriptor, sweep the customary location before - // giving up. - // - // This is the same principle the Metadata File Location candidate chain - // below uses, and for the same reason. + // Candidates: recorded extent then customary location. Recorded is used only + // if SHAPE is valid (>=16 sectors, non-zero, non-wrapping, ECMA-167 3/10.2.1); + // fallback also retried on OUTCOME if recorded yields no Partition Descriptor. let recorded = match vds_len_bytes.div_ceil(2048) { n if n >= VDS_MIN_SECTORS && vds_lba > 0 && vds_lba.checked_add(n).is_some() => { Some((vds_lba, n.min(VDS_MAX_SECTORS))) @@ -1028,15 +924,9 @@ pub fn read_filesystem(reader: &mut dyn SectorSource) -> Result<UdfFs> { let fallback = (VDS_FALLBACK_START, VDS_MAX_SECTORS); let mut candidates = Vec::with_capacity(2); candidates.extend(recorded); - // Compare the whole extent, not just its start. The two candidates are two - // SWEEPS: an anchor may record the customary LBA and still declare a - // narrower window than the fallback's (16 sectors is the ECMA-167 3/10.2.1 - // minimum, and passes every shape check above). Matching on start alone - // suppressed the fallback as a duplicate and threw the WIDER sweep away, so - // a sequence lying past the declared window — or a Terminating Descriptor - // beyond it — was never reached. `n` is already capped at - // `VDS_MAX_SECTORS`, so tuple equality is exactly "the recorded sweep - // already covers the fallback sweep". + // Compare the whole extent, not just its start: an anchor can record the + // customary LBA but declare a narrower window than the fallback's. Matching + // on start alone would drop the WIDER sweep, missing anything past it. if recorded != Some(fallback) { candidates.push(fallback); } @@ -1087,10 +977,9 @@ pub fn read_filesystem(reader: &mut dyn SectorSource) -> Result<UdfFs> { } if partition_start == 0 { - // No Partition Descriptor in ANY candidate sequence. If a sweep hit a - // read fault, that is the honest answer: "could not read" is transient - // and retryable, "read fine and the bytes say no" is a verdict about - // the disc, and mux::resolve caches the latter for the whole disc. + // No Partition Descriptor in any candidate sequence. A sweep read fault + // is transient/retryable; "read fine, bytes say no" is a real verdict + // that `mux::resolve` caches for the whole disc — don't conflate the two. if let Some(e) = sweep_err { return Err(e); } @@ -1121,42 +1010,17 @@ pub fn read_filesystem(reader: &mut dyn SectorSource) -> Result<UdfFs> { let pm2_type = lvd[pm2_map]; // Second map type if pm2_type == 2 { - // Type 2 = metadata partition. UDF 2.50 2.2.10 records WHERE - // the Metadata File's File Entry lives in the Metadata - // Partition Map itself — Metadata File Location, a - // partition-relative Uint32 at map offset 40. It is not fixed - // at block 0 of the physical partition; block 0 is merely - // where authoring tools usually put it. - // - // Assuming block 0 unconditionally mounts NOTHING on a - // conformant volume that recorded the File Entry anywhere - // else: block 0 then holds something that is not a File - // Entry, `metadata_start` falls back to `partition_start`, - // the File Set Descriptor read at that sector finds that - // other descriptor's tag instead of 256, and the volume is - // rejected outright as `UdfNotFilesystem`. - // - // The recorded location is trusted only when the map's - // partition type identifier actually reads - // "*UDF Metadata Partition": a Virtual (UDF 2.50 2.2.8) or - // Sparable (2.2.9) partition map is also Type 2 and carries - // completely different fields at that offset. And if the - // recorded location turns out not to hold a File Entry, - // block 0 is still tried, so a volume whose map field is - // wrong but whose Metadata File does sit at block 0 keeps - // working exactly as before. + // Type 2 = metadata partition. UDF 2.50 2.2.10 records the Metadata + // File's location in the map (offset 40), not always block 0; trusted + // only when type ID reads "*UDF Metadata Partition" (block 0 still tried as fallback). let recorded = metadata_file_location(&lvd, pm2_map) .and_then(|loc| partition_start.checked_add(loc)); let mut meta_file_lba = partition_start; let mut meta_icb = [0u8; 2048]; let mut meta_tag = 0u16; - // Track whether ANY candidate was readable. "Read fine, and the - // bytes are not a File Entry" is a deterministic verdict about - // the disc; "could not read either candidate" is a transient - // fault. Collapsing them here would report a marginal sector as - // `UdfNotFilesystem`, and `mux::resolve` memoises that negative - // for the whole disc — so one flaky read would silently demote - // every remaining title to the base-key-only path. + // Track whether ANY candidate was readable: "read fine, not a File + // Entry" is deterministic, "could not read either" is transient — + // collapsing them risks `mux::resolve` caching a flaky read as `UdfNotFilesystem`. let mut last_err = None; for cand in recorded.into_iter().chain(std::iter::once(partition_start)) { match read_sector(reader, cand, &mut meta_icb) { @@ -1170,12 +1034,9 @@ pub fn read_filesystem(reader: &mut dyn SectorSource) -> Result<UdfFs> { Err(e) => last_err = Some(e), } } - // Not `meta_tag == 0`: the damaging case is the RECORDED - // candidate faulting while block 0 reads fine and happens to - // hold some other descriptor. `meta_tag` is then non-zero and - // not 266, and without this the fault is laundered into a - // structural negative. Any unread candidate leaves the verdict - // unproven, so the fault wins. + // Not `meta_tag == 0`: the RECORDED candidate can fault while block 0 + // reads fine as some other descriptor, leaving meta_tag non-zero and + // not 266 — without this check the fault is laundered into a verdict. if meta_tag != 266 && let Some(e) = last_err { @@ -1354,12 +1215,9 @@ fn read_directory( let tag = u16::from_le_bytes([icb[0], icb[1]]); - // ECMA-167 4/14.6.8 ICB Tag flags: a Uint16 at offset 34 whose low 3 bits - // say how this entry stores its data — 0 short_ad, 1 long_ad, 2 - // extended_ad, 3 EMBEDDED (the data sits in the entry itself, where the - // allocation descriptors would otherwise be). The field is disc-controlled, - // so the layout below is chosen from it rather than assumed; the same flags - // already drive `read_icb_extents` and `read_inline_data`. + // ECMA-167 4/14.6.8 ICB Tag flags: Uint16 at offset 34, low 3 bits pick + // 0=short_ad, 1=long_ad, 2=extended_ad, 3=EMBEDDED (data in the entry + // itself). Disc-controlled; same flags also drive `read_icb_extents`/`read_inline_data`. let ad_type = u16::from_le_bytes([icb[34], icb[35]]) & 0x07; // Where the allocation-descriptor field of this entry begins, and its @@ -1376,19 +1234,9 @@ fn read_directory( let l_ad = u32::from_le_bytes([icb[172], icb[173], icb[174], icb[175]]) as usize; (176 + l_ea, l_ad) } - // ECMA-167 4/14.9 (File Entry, tag 261) and 4/14.17 (Extended File - // Entry, tag 266) are the only descriptors that can be a directory's - // ICB. Any other tag means the FID's ICB pointer landed on something - // that is not a file entry (a corrupt disc, or a descriptor from - // another structure), so this directory's allocation extent — and - // therefore its contents — cannot be located. - // - // That is a FAILURE, and it must be reported as one. Returning an - // empty DirEntry instead makes a corrupt directory indistinguishable - // from a genuinely empty one: a caller enumerating titles then sees a - // disc whose BDMV/PLAYLIST is simply empty and reports success on a - // rip that produced nothing. The same bad tag on a FILE ICB is already - // a hard error in `read_icb_extents`, so a directory cannot be softer. + // Only tag 261/266 can be a dir's ICB; any other tag must fail (not + // return an empty DirEntry, which would hide corruption as a genuinely + // empty dir). `read_icb_extents` is equally strict for file ICBs. _ => { return Err(Error::DiscRead { sector: icb_abs as u64, @@ -1398,11 +1246,9 @@ fn read_directory( } }; - // An EMBEDDED directory (AD type 3) has no out-of-line extent at all: its - // FIDs are the descriptor field itself. Decoding that field as a - // length/LBA pair would read the first FID's own bytes as an extent and - // enumerate an unrelated sector, which yields a silently EMPTY directory - // (no error, no titles). + // EMBEDDED (AD type 3): FIDs are the descriptor field itself, not an extent. + // Decoding it as a length/LBA pair would enumerate an unrelated sector, + // silently yielding an EMPTY directory instead of an error. let (dir_data, ad_len) = if ad_type == 3 { if ad_off > icb.len() || ad_off + l_ad > icb.len() { return Err(Error::DiscRead { @@ -1413,14 +1259,9 @@ fn read_directory( } (icb[ad_off..ad_off + l_ad].to_vec(), l_ad as u32) } else { - // Out-of-line: read the FIRST allocation descriptor. A short_ad - // (4/14.14.1) and a long_ad (4/14.14.2) both begin with the 4-byte - // extent length followed by the 4-byte logical block number, so one - // decode serves both; an extended_ad (4/14.14.3) puts two further - // length fields first and its lb_num at offset 12. Any other flag - // value is reserved by 4/14.6.8 — decode it as a short_ad, matching - // the documented fallback in `read_icb_extents` rather than failing a - // volume over a byte no real disc sets. + // Out-of-line: read the FIRST allocation descriptor. short_ad/long_ad both + // start with 4-byte length + 4-byte LBA; extended_ad's lb_num is at offset + // 12. Other flag values fall back to short_ad, matching `read_icb_extents`. let lba_at = if ad_type == 2 { ad_off + 12 } else { @@ -1446,11 +1287,9 @@ fn read_directory( icb[lba_at + 3], ]); - // Reject an oversized directory before allocating: ad_len is the - // disc-controlled 30-bit ICB allocation length, so a corrupt value - // could otherwise force a ~1 GiB zeroed allocation (amplified by - // recursion). Real directories are a few KiB; the 1 MiB cap still - // covers a large STREAM/ dir with thousands of .m2ts FIDs. + // Reject oversized dirs before allocating: ad_len is a disc-controlled + // 30-bit length, so corruption could force a ~1 GiB alloc (amplified by + // recursion). 1 MiB cap still covers a large STREAM/ dir's .m2ts FIDs. if ad_len > MAX_DIR_BYTES { return Err(Error::DiscRead { sector: meta_start as u64, @@ -1495,11 +1334,8 @@ fn read_directory( let file_chars = dir_data[pos + 18]; let l_fi = dir_data[pos + 19] as usize; - // FID ICB is a long_ad starting at offset 20: - // [20:24] = extent_length - // [24:28] = extent_location (LBA within metadata partition) - // [28:30] = partition_reference_number - // [30:36] = implementation_use + // FID ICB is a long_ad at offset 20: extent_length[20:24], + // extent_location/LBA[24:28], partition_ref[28:30], implementation_use[30:36]. let icb_lba = u32::from_le_bytes([ dir_data[pos + 24], dir_data[pos + 25], @@ -1510,14 +1346,9 @@ fn read_directory( let is_dir = (file_chars & 0x02) != 0; let is_parent = (file_chars & 0x08) != 0; - // ECMA-167 4/14.4.4 file characteristics bit 2 = Deleted. Such a - // descriptor names a file that no longer exists, and 4/14.4.3 lets its - // ICB field specify an extent of length zero — it need not point at a - // File Entry at all. Following it reads whatever descriptor happens to - // occupy that metadata LBA: on a deleted DIRECTORY that is a hard - // "not a File Entry" error which fails the whole volume enumeration, - // and on a deleted FILE it invents a zero-byte entry that was never - // recorded. + // ECMA-167 4/14.4.4 bit 2 = Deleted; its ICB may be a zero-length extent + // (4/14.4.3) not pointing at a File Entry. Following it would fail whole + // enumeration on a deleted dir, or fabricate a zero-byte entry for a file. let is_deleted = (file_chars & 0x04) != 0; if !is_parent && !is_deleted && l_fi > 0 { @@ -1540,15 +1371,9 @@ fn read_directory( }); } - // Read the ICB to get file size. A read failure here must - // propagate, not become a size of zero: this function already - // fails hard on the budget guard above, and a file reported as - // zero bytes is indistinguishable from a genuinely empty one, so - // an unreadable ICB on a damaged disc would silently change which - // titles a caller considers present. `read_file_size` still - // returns Ok(0) for an ICB whose tag is neither File Entry (261) - // nor Extended File Entry (266), which is a real zero, not a - // failure. + // Read failures must propagate, not become size 0 (indistinguishable + // from a real empty file). `read_file_size` still returns Ok(0) for + // ICBs that aren't File/Extended File Entry tags (261/266) — genuine zero. let file_size = read_file_size(reader, meta_start, icb_lba)?; if is_dir && depth < MAX_DIR_DEPTH { @@ -1567,9 +1392,8 @@ fn read_directory( }); } else { visited.insert(icb_key); - // Recurse into subdirectory. The depth cap guards - // against pathological nesting on a corrupt disc while - // comfortably covering real BD-ROM nesting + // Recurse into subdirectory. The depth cap guards against pathological + // nesting on a corrupt disc while covering real BD-ROM nesting // (e.g. BDMV/BACKUP/BDJO/*.bdjo is 3 levels deep). let subdir = read_directory( reader, @@ -1677,13 +1501,9 @@ pub(crate) fn merge_ranges(ranges: &[(u32, u32)]) -> Vec<(u32, u32)> { // LBAs/lengths, so a corrupt disc could otherwise overflow u32 // (panic in debug, wrap in release). let last_end = last.0.saturating_add(last.1); - // `(start, count)` is HALF-OPEN, so `last_end` is the EXCLUSIVE end and - // two ranges touch exactly when `start == last_end`. Accepting - // `last_end + 1` as well merged across a genuine one-sector hole and - // reported coverage of a sector neither input described — and this - // output is a boundary, not a hint (`disc::merged_extents` -> - // `Disc::encrypted_content_ranges` decides what the decrypting source - // treats as content, where a nav/padding sector must stay outside). + // Half-open ranges touch exactly when `start == last_end`; accepting + // `last_end + 1` too merged across a genuine one-sector hole, falsely + // reporting coverage `Disc::encrypted_content_ranges` treats as real content. if start <= last_end { // Overlapping or adjacent — extend let new_end = start.saturating_add(count).max(last_end); @@ -1780,14 +1600,9 @@ impl BufferedSectorReader<'_> { // Cap to 8192 sectors (16 MiB) so a disc-controlled ad_len cannot // drive a multi-hundred-MiB allocation before any sectors are read. let count = count.min(8192); - // Clamp the range to the u32 LBA space. `start_lba` comes straight off - // the disc (ECMA-167 §14.1 `extent_location` is an unconstrained Uint32), - // so a metadata partition declared near the top of the space made - // `start_lba + offset` below overflow: an 'attempt to add with overflow' - // panic in debug inside the public `Disc::scan`, and in release a wrap to - // a low LBA that filled the sliding cache with a completely different - // region while `cache_start` still claimed the high one. Sectors past - // `u32::MAX` cannot be addressed at all, so dropping them loses nothing. + // Clamp to u32 LBA space: `start_lba` is an unconstrained disc Uint32, so a + // partition near the top overflowed `start_lba + offset` below (debug panic; + // release wrap filled the cache with the wrong region). Unaddressable anyway. let count = count.min(u32::MAX - start_lba); let total = count as usize * 2048; self.cache.resize(total, 0); @@ -1833,15 +1648,9 @@ impl BufferedSectorReader<'_> { let mut done: u64 = 0; let mut hb = crate::progress::Heartbeat::new("udf_prefetch"); for &(start, count) in ranges { - // Clamp each range to the u32 LBA space before walking it. Ranges come - // from `collect_file_ranges`, whose allocation descriptors are - // unconstrained ECMA-167 §14.14.1 Uint32s, and nothing bounds - // `start + count`; a range within one batch of the top of the space - // made `start + offset` and `start + offset + i` below overflow — - // a debug panic inside the public `Disc::scan`, and in release a wrap - // that seeded the PERMANENT cache with this file's bytes keyed at low - // LBAs, so every later single-sector read of those LBAs (the AVDP/VDS - // re-reads) silently parsed the wrong sector. + // Clamp to u32 LBA space: unconstrained Uint32 ADs left `start + count` + // unbounded, so a range near the top overflowed `start + offset` below + // (debug panic; release wrap seeded the permanent cache at low LBAs). let count = count.min(u32::MAX - start); let mut offset = 0u32; while offset < count { @@ -1893,12 +1702,9 @@ impl SectorSource for BufferedSectorReader<'_> { buf[..2048].copy_from_slice(data); return Ok(2048); } - // Check sliding cache. Tested as a DISTANCE from `cache_start`, not - // as `cache_start + cache_sectors`: `cache_start` is a disc-controlled - // LBA (and the batch-read path below sets it verbatim), so the sum - // overflowed for a window near `u32::MAX` — a debug panic inside - // `SectorSource::read_sectors`, and in release a wrap to a small value - // that silently disabled the cache. + // Tested as a DISTANCE from `cache_start`, not `cache_start + cache_sectors`: + // `cache_start` is disc-controlled, so the sum overflowed near `u32::MAX` + // (debug panic; release wrap silently disabled the cache). if lba >= self.cache_start && lba - self.cache_start < self.cache_sectors { let offset = (lba - self.cache_start) as usize * 2048; buf[..2048].copy_from_slice(&self.cache[offset..offset + 2048]); @@ -1912,12 +1718,9 @@ impl SectorSource for BufferedSectorReader<'_> { self.cache_sectors = block as u32; } Err(_) => { - // By design: a `block`-sector batch read that starts - // valid but runs past the last recorded sector fails as - // a unit. Retry the one sector actually requested so a - // batch overrunning the disc tail still serves the live - // LBA instead of erroring; a genuinely bad single sector - // then propagates via `?`. + // By design: a batch read past the last recorded sector fails as a + // unit, so retry just the one sector requested — a genuinely bad + // single sector still propagates via `?`. self.cache.resize(2048, 0); self.inner.read_sectors(lba, 1, &mut self.cache, true)?; self.cache_start = lba; @@ -2030,10 +1833,8 @@ mod tests { let raw_len = (etype << 30) | (dlen & 0x3FFF_FFFF); s[off..off + 4].copy_from_slice(&raw_len.to_le_bytes()); s[off + 4..off + 8].copy_from_slice(&dlba.to_le_bytes()); - // off+8..off+10 = partition reference (0), off+10..off+16 = - // impl_use (0). Leaving these zero is what trips the old - // 8-byte-stride parser into reading a bogus zero-length - // terminator as descriptor #1. + // off+8..16 = partition ref + impl_use, left zero: this is what trips + // the old 8-byte-stride parser into reading a bogus zero-length terminator. off += 16; } s @@ -2041,11 +1842,9 @@ mod tests { /// A continuation block: a bare list of short ADs from byte 0. fn build_cont_block(ads: &[(u32, u32, u32)]) -> [u8; 2048] { - // A continuation block is an Allocation Extent Descriptor (ECMA-167 - // 4/14.5): a 16-byte descriptor tag, then prev_allocation_extent_location - // (Uint32 @16) and length_of_allocation_descriptors (Uint32 @20). The - // actual allocation descriptors begin at offset 24. The parser skips the - // 24-byte header and reads `l_ad` bytes of ADs from there. + // Allocation Extent Descriptor (ECMA-167 4/14.5): 16-byte tag, then + // prev_allocation_extent_location (@16) and length_of_allocation_descriptors + // (@20); ADs begin at offset 24, which the parser skips past. let mut s = [0u8; 2048]; let l_ad = (ads.len() * 8) as u32; s[20..24].copy_from_slice(&l_ad.to_le_bytes()); @@ -2154,14 +1953,9 @@ mod tests { #[test] fn icb_extents_reads_both_entry_types_at_their_own_descriptor_offsets() { - // ECMA-167 4/14.9 puts a File Entry's L_EA at byte 168 and its - // allocation descriptors at 176 + L_EA; 4/14.17 puts an Extended File - // Entry's at 208 and 216 + L_EA. Both are legal ICBs for a file — a - // 261 is what UDF 1.02 DVD-Video discs carry — and real entries do - // record extended attributes, so the offset must be computed, not - // assumed. Refusing a 261 outright loses every file on such a disc; - // computing the offset any other way reads the attribute bytes (or - // the fixed header) as an extent. + // ECMA-167 4/14.9 File Entry: L_EA @168, ADs @176+L_EA. 4/14.17 Extended + // File Entry: @208, @216+L_EA (261 is UDF 1.02 DVD-Video). Offset must be + // computed per-tag, not assumed, or real extended attributes get misread. let want = vec![(4096, 8192), (16384, 2048)]; for tag in [261u16, 266u16] { for l_ea in [0usize, 88] { @@ -2180,11 +1974,9 @@ mod tests { #[test] fn icb_extents_reads_a_descriptor_list_flush_with_the_end_of_the_entry() { - // The extended-attribute area may run right up to the point where the - // final short_ad exactly fills the rest of the 2048-byte logical - // block. That descriptor is wholly inside the entry and must be read: - // a bound that excludes it drops the file's only extent and reports a - // file with no data instead of an error. + // A final short_ad may exactly fill the rest of the 2048-byte block; it's + // still wholly inside the entry and must be read, or the file's only + // extent is dropped and it reports no data instead of an error. for tag in [261u16, 266u16] { let base = if tag == 266 { 216 } else { 176 }; let icb = build_entry_ads(tag, 2048 - base - 8, 8, &[(0, 2048, 4096)], &[]); @@ -2218,12 +2010,9 @@ mod tests { #[test] fn icb_extents_stops_at_the_declared_descriptor_area_length() { - // L_AD says how many bytes of the entry are allocation descriptors. - // What follows is extended-attribute padding, alignment, or nothing - // at all — never descriptors. Reading past L_AD invents extents the - // file does not have, and they land at whatever LBAs those bytes - // happen to spell: for a file being reassembled from its extents that - // is silent corruption, appended to the end of every such file. + // L_AD bounds the allocation descriptors; what follows is padding or + // nothing, never descriptors. Reading past it invents extents from + // whatever bytes are there — silent corruption appended to the file. let icb = build_entry_ads(266, 0, 8, &[(0, 2048, 4096)], &[(0, 2048, 999_999)]); let mut reader = MapReader::new(); reader.put(5, icb); @@ -2238,11 +2027,9 @@ mod tests { #[test] fn icb_extents_does_not_follow_a_zero_length_continuation_pointer() { - // ECMA-167 4/14.14.1.1: an allocation descriptor whose extent length - // is zero designates no extent — including a type-3 "next extent of - // allocation descriptors" pointer. Following one reads whatever - // sector its extent_location happens to name and parses it as an - // Allocation Extent Descriptor, appending extents the file never had. + // ECMA-167 4/14.14.1.1: a zero-length descriptor (incl. type-3 "next + // extent" pointer) designates no extent. Following it anyway reads a + // bogus sector and parses it as an AED, appending extents the file never had. let icb = build_efe(2048, &[(0, 2048, 10), (3, 0, 50)]); let mut reader = MapReader::new(); reader.put(105, icb); // meta_start 100 + meta_lba 5 @@ -2308,15 +2095,9 @@ mod tests { #[test] fn icb_extents_continuation_skips_aed_header_not_read_as_extent() { - // Regression (Blu-ray 3D): a continuation block begins with a 24-byte - // Allocation Extent Descriptor whose 16-byte descriptor tag holds - // NON-zero bytes (tag id 258, CRC, location, ...). Reading allocation - // descriptors from offset 0 parses that tag header as an AD → a garbage - // extent, then an unknown extent_type break → every fragment past the - // first continuation is lost. On a 3D disc that truncated the 25.8 GB - // interleaved base-view feature to ~1.8 GB (113 of ~1600 fragments) and - // then choked the mux on the bogus, non-unit-aligned extent. The parser - // MUST skip the 24-byte AED header and read the real ADs from offset 24. + // Regression (Blu-ray 3D): a continuation block opens with a 24-byte AED whose tag holds + // non-zero bytes (id 258, CRC); reading ADs from offset 0 parses garbage and breaks, + // losing fragments past the first continuation (25.8 GB -> ~1.8 GB). Read ADs from +24. let icb = build_efe(6144, &[(0, 4096, 10), (3, 2048, 50)]); let mut cont = build_cont_block(&[(0, 2048, 20)]); // Stamp a realistic AED descriptor tag (id 258) into the header so a @@ -2337,15 +2118,9 @@ mod tests { #[test] fn icb_extents_long_ad_returns_all_extents_not_just_first() { - // Regression: BD-ROM large .m2ts files use Long ADs (16-byte - // descriptors). The pre-fix parser hardcoded an 8-byte stride, so - // it read descriptor #0 (length+lba align in both layouts) then - // misread descriptor #1 from the middle of the first Long AD — - // a zero terminator — and returned ONLY the first extent. That - // truncated every multi-extent title at ~973 MB-1 GiB. - // - // Four Long ADs, each a near-max Short-AD-sized extent. The fix - // must return all four; the old code returned exactly one. + // Regression: BD-ROM large .m2ts use 16-byte Long ADs. The pre-fix parser hardcoded an + // 8-byte stride, misreading descriptor #1 from mid-Long-AD as a zero terminator, so + // multi-extent titles truncated at ~1 GiB. Four near-max Long ADs; all four must return. let icb = build_efe_long( 4 * 1_000_000_000, &[ @@ -2398,14 +2173,9 @@ mod tests { #[test] fn read_file_long_ad_returns_full_content_not_truncated() { - // Regression for BOTH 0.31.0 bugs through `read_file`: this is the - // exact path `Disc::read_aacs_inputs_from_reader` uses to read - // `/AACS/MKB_RO.inf` + `Unit_Key_RO.inf`. A Long-AD, multi-extent - // file (large UHD/Blu-ray layout) must return ALL its bytes. With the - // pre-fix Short-AD-only parser this read stopped after the first - // extent, which (a) truncated the mux and (b) made autorip's - // `key_files()` see a short/garbage AACS file → `MissingInputs` → - // the online key request was never sent. + // Regression for 0.31.0: `read_file` reads `/AACS/MKB_RO.inf` + `Unit_Key_RO.inf`. The + // pre-fix Short-AD-only parser truncated Long-AD multi-extent files after extent #0, so + // autorip's `key_files()` saw a garbage AACS file and skipped the key request. let icb = build_efe_long(6144, &[(0, 2048, 10), (0, 2048, 30), (0, 2048, 50)]); let mut reader = MapReader::new(); reader.put(5, icb); @@ -2433,15 +2203,9 @@ mod tests { #[test] fn read_aacs_inputs_reads_long_ad_files_in_full() { - // PRECOMMIT proof for the autorip online-keyserver path (no disc / no - // deploy). autorip's key request is gated on Disc::read_aacs_inputs - // (keysource.rs key_files()): it reads /AACS/Unit_Key_RO.inf and - // /AACS/MKB_RO.inf. On a Long-AD disc (UHD / large Blu-ray) the - // pre-0.31.1 Short-AD-only reader truncated those files at their first - // extent, breaking key derivation. This - // fixture lays a Long-AD, multi-extent Unit_Key_RO.inf under /AACS and - // asserts read_aacs_inputs returns its FULL content — i.e. the keyserver - // inputs are complete, so the request is built correctly. + // autorip reads /AACS/Unit_Key_RO.inf + MKB_RO.inf via Disc::read_aacs_inputs. On Long-AD + // discs (UHD/large Blu-ray) the pre-0.31.1 Short-AD-only reader truncated these at the + // first extent, breaking key derivation; assert the full multi-extent content returns. let aacs = DirEntry { name: "AACS".to_string(), is_dir: true, @@ -2484,12 +2248,9 @@ mod tests { #[test] fn read_aacs_inputs_falls_through_to_hddvd_any_dir() { - // HD DVD keeps its AACS material under /ANY!/ (VTKF000.AACS title-key - // file + MKBROM.AACS), NOT /AACS/Unit_Key_RO.inf + /AACS/MKB_RO.inf. The - // role-based candidate lists must fall through to the /ANY!/ files with - // NO disc-type branch, so the online keyserver POST carries the HD DVD - // title-key file (magic "DVD_HD_V_TKF") as inf_b64 + MKBROM as mkb_b64 — - // the server then classifies the disc as HD DVD by that magic. + // HD DVD keeps AACS material under /ANY!/ (VTKF000.AACS + MKBROM.AACS), + // not /AACS/Unit_Key_RO.inf + MKB_RO.inf, so candidate lookup must fall + // through to /ANY!/ with no disc-type branch; the server classifies by magic. let any = DirEntry { name: "ANY!".to_string(), is_dir: true, @@ -2693,12 +2454,9 @@ mod tests { #[test] fn read_file_rejects_cumulative_extents_over_cap() { - // Two extents, each individually within MAX_FILE_BYTES, that together - // exceed it. The cumulative guard must fire on the second extent - // (before reading it) rather than growing `data` past the cap. - // First extent: a single sector (read, data.len() = 2048). Second - // extent: exactly MAX_FILE_BYTES (passes the per-extent cap) — the - // 2048 already buffered pushes the running total over the cap. + // Two extents each within MAX_FILE_BYTES individually but exceeding it + // together: first extent buffers 2048 bytes, second is exactly the cap, + // so the cumulative guard must fire on the second before reading it. let big = MAX_FILE_BYTES as u32; let icb = build_efe_icb_multi(MAX_FILE_BYTES * 2, &[(2048, 100), (big, 200_000)]); let mut reader = MemReader::new(); @@ -2814,15 +2572,9 @@ mod tests { #[test] fn icb_extents_extended_ad_uses_20byte_stride_and_lba_at_off12() { - // ECMA-167 §14.14.3: an Extended AD is 20 bytes and its extent LBA - // is at byte offset +12, NOT +4 (that's RecordedLength). The parser - // branches on ICB-tag flags==2 to a 20-byte stride and lba_off=off+12. - // Three extents must come back with the CORRECT LBAs and lengths. - // - // Each logicalBlockNumber is given four distinct non-zero bytes: the - // field is a Uint32 and every byte of it carries weight, so a byte - // sourced from the wrong offset must change the answer rather than - // landing on a zero that happens to match. + // ECMA-167 §14.14.3: Extended AD is 20 bytes, extent LBA at +12 (not + // +4, which is RecordedLength). Flags==2 selects this 20-byte stride. + // Each LBA uses 4 distinct non-zero bytes so a wrong-offset read changes the result. let icb = build_efe_ext( 3 * 2048, &[ @@ -2849,12 +2601,9 @@ mod tests { #[test] fn icb_extents_short_ad_type1_sparse_extent_is_kept_and_flagged_unrecorded() { - // ECMA-167 4/14.14.1.1: extent type 1 = "allocated but not recorded". - // It carries no on-disc data — but it IS part of the file and occupies - // its declared length of the file's byte space, so it must be returned - // (flagged unrecorded) rather than dropped: dropping it slides every - // later extent down by 2048 bytes. A type-0 extent after it must still - // be reached (the loop must continue past a type-1, not break). + // ECMA-167 4/14.14.1.1: type 1 = allocated-not-recorded, no on-disc data, + // but it still occupies file byte space, so it must be kept (flagged + // unrecorded), not dropped — dropping it would shift later extents. let icb = build_efe( 6144, &[ @@ -2995,18 +2744,9 @@ mod tests { #[test] fn icb_extents_short_ad_type2_sparse_extent_is_kept_and_flagged_unrecorded() { - // ECMA-167 4/14.14.1.1 extent type 2 = "not recorded and not - // allocated" — the other sparse-hole encoding alongside type 1. It has - // the SAME byte-space semantics as type 1: no on-disc data, but it is - // part of the file and occupies its declared length, so it must be - // returned flagged unrecorded rather than dropped. - // - // Before this was handled it fell into the descriptor loop's catch-all - // `_ => break`, which exits mid-list WITHOUT setting a continuation - // pointer — so `chain_ended` was then set true and the function - // returned Ok with a silently truncated extent list. That defeats the - // very completeness gate (`UdfAdChainTooLong`) added alongside it, and - // delivers a short file as a verified complete extraction. + // ECMA-167 4/14.14.1.1 type 2 = "not recorded, not allocated", same + // byte-space semantics as type 1: must be flagged unrecorded, not + // dropped via the catch-all `_ => break` that used to truncate the list. let icb = build_efe( 6144, &[ @@ -3087,10 +2827,9 @@ mod tests { #[test] fn icb_extents_zero_length_type0_terminates_list() { - // ECMA-167: a zero-length type-0 AD terminates the descriptor list. - // Trailing zero padding (all-zero ADs) MUST stop parsing — otherwise - // a stray non-zero AD after the terminator becomes a bogus extent. - // One real extent, then a zero AD, then an AD that must NEVER be read. + // ECMA-167: a zero-length type-0 AD terminates the descriptor list, so + // trailing zero padding must stop parsing before a stray non-zero AD + // after it becomes a bogus extent. let icb = build_efe( 2048, &[ @@ -3177,16 +2916,9 @@ mod tests { #[test] fn parse_udf_name_utf16be_uses_both_bytes_of_each_dchar_and_stops_on_an_odd_tail() { - // UDF 2.50 2.1.1 / ECMA-167 1/7.2.2: compression ID 16 means the - // characters are 16-bit BIG-endian — the FIRST byte of each pair is - // the high half. Discs really do carry names outside Latin-1 (a - // Japanese BD's disc label, a track name), and dropping the high half - // turns each of those into a different character entirely. - // - // The byte run is also given an ODD length, as a truncated or - // corrupt name field has: the trailing lone byte is not half a - // character and must be left alone, not paired with whatever follows - // the buffer. + // ECMA-167 1/7.2.2: id 16 = 16-bit BIG-endian dchars (real discs carry + // non-Latin-1 names). Byte run is deliberately ODD-length, as a truncated + // field would be: the trailing lone byte must not pair with what follows. let raw = [16u8, 0x4E, 0x2D, 0x00, b'A', 0x42]; assert_eq!( parse_udf_name(&raw), @@ -3274,11 +3006,9 @@ mod tests { #[test] fn read_directory_honors_l_iu_offset_for_fid_name() { - // ECMA-167 §14.4 File Identifier Descriptor: the File Identifier - // begins at offset 38 + L_IU. A non-zero L_IU must shift the name - // read; ignoring it would read impl_use bytes as the name. - // 0..2 tag = 257 18 file chars 19 L_FI - // 24..28 ICB LBA 36..38 L_IU 38.. impl_use[L_IU] then FI[L_FI] + // ECMA-167 §14.4 File Identifier: the name begins at offset 38 + L_IU. + // A non-zero L_IU must shift the name read; ignoring it reads impl_use + // bytes as the name instead. let mut dir = [0u8; 2048]; let l_iu: u16 = 4; let mut name_bytes = vec![8u8]; // compression id 8 @@ -3311,12 +3041,9 @@ mod tests { #[test] fn read_directory_skips_parent_fid_entry() { - // ECMA-167 §14.4.3: file characteristics bit 3 (0x08) = "parent" (the - // ".." back-link). It must NOT appear as a named child entry. To - // isolate the parent-flag gate (rather than the L_FI==0 gate that - // real parent FIDs also have), this fixture gives the parent FID a - // VALID non-zero L_FI and a real name: the ONLY reason it must be - // skipped is the parent characteristic bit. + // ECMA-167 §14.4.3: characteristics bit 0x08 = "parent" (the ".." link) + // and must never appear as a named child. Fixture gives it a valid + // non-zero L_FI and real name to isolate that gate from L_FI==0. let mut dir = [0u8; 2048]; let mut name_bytes = vec![8u8]; name_bytes.extend_from_slice(b"PARENT"); @@ -3359,17 +3086,9 @@ mod tests { #[test] fn parse_dstring_decodes_a_utf16be_volume_identifier() { - // ECMA-167 1/7.2.12 d-strings carry the same OSTA CS0 encoding as - // file names: a compression ID then the characters, with the used - // byte count in the LAST byte of the fixed field. Compression ID 16 - // means 16-bit BIG-endian characters — a Volume Identifier decoded - // any other way is the wrong string in the disc label a caller shows - // and in every name it is matched against. - // - // The content deliberately contains a character above Latin-1 (so the - // high byte of the pair carries weight), an embedded NUL pair (which - // pads a fixed-width field and is not a character), and an odd - // trailing byte (which is not half a character). + // ECMA-167 1/7.2.12 d-strings: same OSTA CS0 encoding as file names, used + // byte count in the last field byte. Content covers a char above Latin-1, + // an embedded NUL pair (padding, not a char), and an odd trailing byte. let mut field = [0u8; 32]; let content: [u8; 10] = [16, 0x4E, 0x2D, 0x00, b'A', 0x00, 0x00, 0x00, b'B', 0x43]; field[..content.len()].copy_from_slice(&content); @@ -3426,17 +3145,9 @@ mod tests { #[test] fn read_file_reads_embedded_data_from_both_entry_types_at_their_own_offsets() { - // Embedded data lives in the allocation-descriptor area, which starts - // at 176 + L_EA in a File Entry (ECMA-167 4/14.9) and 216 + L_EA in - // an Extended File Entry (4/14.17). Both entry types can embed, and - // real entries do carry extended attributes. - // - // Getting the offset wrong here does not fail — `read_inline_data` - // returns whatever bytes are at the computed offset, so an AACS - // `*.inf` key file comes back as extended-attribute bytes, and the - // key derivation fails somewhere else entirely. Missing the 261 arm - // is worse: the file falls through to the extent path, where the - // embedded payload is parsed as allocation descriptors. + // Embedded data starts at 176 + L_EA (File Entry, ECMA-167 4/14.9) or + // 216 + L_EA (Extended File Entry, 4/14.17); a wrong offset doesn't fail + // loudly, it just reads extended-attribute bytes as an AACS `*.inf` key. let payload: Vec<u8> = (0..48u8) .map(|i| i.wrapping_mul(11).wrapping_add(3)) .collect(); @@ -3472,10 +3183,9 @@ mod tests { #[test] fn read_file_refuses_embedded_data_that_runs_past_the_entry() { - // L_AD is disc-controlled. One that claims more embedded bytes than - // the entry holds must be refused, not silently clamped: a clamp - // hands back a short prefix of a key file, which reads as a valid but - // wrong record rather than as a failure. + // L_AD is disc-controlled; one claiming more embedded bytes than the + // entry holds must be refused, not clamped to a short-but-valid-looking + // prefix of a key file. let mut icb = build_inline_icb(64, &[0xAB; 64]); icb[212..216].copy_from_slice(&4000u32.to_le_bytes()); // L_AD past the sector let mut reader = MapReader::new(); @@ -3490,11 +3200,9 @@ mod tests { #[test] fn read_sectors_batches_a_cold_single_sector_read_into_a_full_window() { - // The whole point of this reader is that one single-sector request - // pulls a `batch`-sector window off the drive so the neighbouring - // requests — which UDF parsing issues constantly — cost nothing. - // A window sized wrongly still returns correct bytes (the read falls - // back to one sector at a time), so only the command count shows it. + // One single-sector request should pull a `batch`-sector window so the + // neighbouring requests UDF parsing issues constantly cost nothing; a + // wrong window still returns correct bytes, so only the call count shows it. let mut inner = CountReader::new(); { let mut br = BufferedSectorReader::new(&mut inner, 4); @@ -3514,12 +3222,9 @@ mod tests { #[test] fn read_file_trims_embedded_data_to_the_declared_information_length() { - // ECMA-167 4/14.17: Information Length is the file's real size. The - // embedded-data area (L_AD bytes) is padded out to whatever alignment - // the authoring tool chose, so it is routinely LONGER than the file. - // Returning the padding makes every embedded file longer than it is — - // an AACS `*.inf` record parsed with trailing garbage, a nav file - // whose trailing structure count no longer matches its length. + // ECMA-167 4/14.17: Information Length is the real size; the embedded + // area (L_AD bytes) is alignment-padded and routinely longer. Returning + // the padding makes an AACS `*.inf` or nav file parse with trailing garbage. let payload: Vec<u8> = (0..64u8) .map(|i| i.wrapping_mul(7).wrapping_add(1)) .collect(); @@ -3539,11 +3244,9 @@ mod tests { #[test] fn read_file_prefix_caps_embedded_data_at_the_requested_length() { - // `read_file_prefix` exists to bound a read: the AACS `MKB_RO.inf` is - // allocated to ~128 MiB of zero padding and only its leading record - // is wanted. The bound must apply on the EMBEDDED path too — nothing - // else limits it there, since the embedded payload is already in - // hand by the time the cap would be checked. + // `read_file_prefix` bounds a read (e.g. the AACS `MKB_RO.inf`'s ~128 MiB + // of padding, when only the leading record is wanted); that bound must + // apply on the embedded path too, since nothing else limits it there. let payload: Vec<u8> = (0..64u8) .map(|i| i.wrapping_mul(7).wrapping_add(1)) .collect(); @@ -3560,10 +3263,8 @@ mod tests { #[test] fn file_start_lba_descends_only_through_directories_that_match_the_path() { // Each path component must match a child that is BOTH a directory AND - // named for that component. Matching either alone walks into the - // first directory it meets — on a BD-ROM that is `AACS/`, sitting - // right next to `BDMV/` — and then reports the LBA of a file from a - // completely different subtree as if it were the requested one. + // named for it. Matching either alone walks into the first directory + // met — e.g. `AACS/` next to `BDMV/` — and reports an LBA from the wrong subtree. let fs = fs_with( 1000, 0, @@ -3603,9 +3304,8 @@ mod tests { #[test] fn read_file_accepts_a_file_exactly_at_the_size_ceiling() { // `MAX_FILE_BYTES` bounds what an unbounded read may allocate. A file - // whose declared size, and whose single extent, are exactly the - // ceiling is admissible: rejecting it makes the largest legal read - // fail, and the caller cannot tell that from a corrupt disc. + // exactly at the ceiling is admissible: rejecting it makes the largest + // legal read indistinguishable from a corrupt disc. let cap = MAX_FILE_BYTES as u32; let mut reader = MapReader::new(); reader.put(5, build_efe(MAX_FILE_BYTES, &[(0, cap, 40)])); @@ -3619,11 +3319,9 @@ mod tests { #[test] fn read_file_sums_extent_lengths_rather_than_combining_them_some_other_way() { - // The running-total guard exists so that many individually-legal - // extents cannot add up to a GiB allocation. It must compare the SUM - // of what has been read with the ceiling: two 16 MiB extents are - // 32 MiB of file, comfortably legal, and combining them any other way - // refuses a file that is well within bounds. + // The running-total guard stops many legal extents adding up to a GiB + // allocation; it must compare the SUM read against the ceiling, or two + // legal 16 MiB extents (32 MiB total, well within bounds) get refused. const EXT: u32 = 16 * 1024 * 1024; let mut reader = MapReader::new(); reader.put( @@ -3640,12 +3338,9 @@ mod tests { #[test] fn read_inline_data_rejects_oversized_lea() { - // AD type=3 (inline data) with an L_EA so large that ad_offset = - // 216 + L_EA overflows past the 2048-byte ICB. Before the fix, - // the `.min(icb.len())` clamp produced start==end==2048 and the - // function returned Ok(Some(vec![])) — silently dropping the file - // content. AACS key files (Unit_Key_RO.inf) read as 0 bytes and - // decryption failed without a useful diagnostic. + // AD type=3 with L_EA so large that ad_offset = 216 + L_EA overflows past + // the 2048-byte ICB. Before the fix `.min(icb.len())` clamped to + // start==end==2048, silently returning an empty file (e.g. a 0-byte key). let mut icb = [0u8; 2048]; // tag = 266 (Extended File Entry) icb[0..2].copy_from_slice(&266u16.to_le_bytes()); @@ -3717,13 +3412,9 @@ mod tests { #[test] fn prefetch_serves_every_sector_of_the_window_from_the_cache() { - // `prefetch` exists to turn thousands of single-sector reads of the - // metadata partition into a handful of batched commands. It only does - // that if the window it loads is (a) actually read, (b) placed so - // each sector is served from its OWN offset, and (c) consulted - // afterwards. Loading nothing, or misplacing the window, still - // "works" — every read just goes back to the drive, or worse returns - // a neighbouring sector's bytes. + // `prefetch` turns thousands of single-sector metadata reads into a few + // batched commands, but only if the window is actually loaded, placed so + // each sector serves from its own offset, and consulted afterwards. let mut inner = CountReader::new(); { let mut br = BufferedSectorReader::new(&mut inner, 3); @@ -3748,10 +3439,9 @@ mod tests { #[test] fn prefetch_window_does_not_answer_for_the_sector_just_past_its_end() { - // The window covers `count` sectors starting at `start_lba` — the - // sector at start_lba + count belongs to whatever is next, not to - // this window. Answering for it hands back bytes from beyond the - // loaded data. + // The window covers `count` sectors from `start_lba`; the sector at + // start_lba + count belongs to whatever is next, and answering for it + // would hand back bytes from beyond the loaded data. let mut inner = CountReader::new(); let mut br = BufferedSectorReader::new(&mut inner, 8); br.prefetch(100, 8); @@ -3763,11 +3453,9 @@ mod tests { #[test] fn prefetch_ranges_serves_every_sector_of_every_range_from_the_permanent_cache() { - // `prefetch_ranges` seeds the permanent per-sector cache from the - // scattered ranges `metadata_sector_ranges` produced. Each sector of - // each range must be keyed at its own LBA: a key computed from the - // wrong base silently answers later reads of unrelated LBAs — the - // AVDP/VDS re-reads among them — with this file's bytes. + // `prefetch_ranges` seeds the per-sector cache from the scattered ranges + // `metadata_sector_ranges` produced; each sector must be keyed at its + // own LBA, or a wrong-base key answers unrelated later reads (e.g. AVDP/VDS). let mut inner = CountReader::new(); { let mut br = BufferedSectorReader::new(&mut inner, 2); @@ -3791,14 +3479,9 @@ mod tests { #[test] fn metadata_sector_ranges_covers_the_structure_and_each_small_files_extents() { - // The prefetch plan is what decides which sectors are pulled off the - // disc in bulk before scanning. It must cover the UDF structure - // through the end of the metadata partition, every non-STREAM file's - // ICB, and every RECORDED extent of the files small enough to cache — - // and it must not cache the multi-GB STREAM payloads or the extents - // of a file past the size cap. Returning an empty or arbitrary plan - // costs no correctness, only every bulk read, so nothing downstream - // notices; the ranges themselves are the only observable. + // The prefetch plan decides what's bulk-read before scanning: UDF + // structure, every non-STREAM ICB, RECORDED extents under the size cap, + // but never STREAM payloads. A wrong plan only costs perf, never correctness. const PART: u32 = 1000; let fs = UdfFs { root: DirEntry { @@ -3849,23 +3532,9 @@ mod tests { .metadata_sector_ranges(&mut reader) .expect("the plan is built"); - // (0, 1004) structure through the end of the metadata partition - // (1010, 1) INDEX.BDMV's ICB, alone — 1011 is NOT collected, so the - // run stops here rather than reaching across the hole - // (1012, 3) the ICBs of EXACT.BIN, HUGE.BIN, SPARSE.BIN — genuinely - // consecutive, so these do merge - // (1500, 2) INDEX.BDMV's 4096-byte extent - // (1800, 1) EXACT.BIN's extent — at the ceiling, so still cached - // (1900, 1) SPARSE.BIN's recorded extent only - // Absent: 1011 alone (STREAM is not descended at all, not even for - // its ICB), 1600 (00000.M2TS), 1850 (HUGE.BIN's data), 1700 - // (SPARSE.BIN's unrecorded extent). - // - // 1011's absence is asserted, not merely commented. This expectation - // used to read `(1010, 5)` — a single run SPANNING 1011 — which - // contradicted the "Absent: 1011" line above it: `merge_ranges` closed - // one-sector holes, so the plan prefetched the very sector the skip - // policy exists to leave alone. + // (1010,1) is INDEX.BDMV's ICB alone, stopping short of 1011 (STREAM's + // ICB, deliberately never descended) rather than merging across it; this + // used to read (1010,5), a regression where `merge_ranges` closed that gap. assert_eq!( ranges, vec![ @@ -3945,10 +3614,8 @@ mod tests { "prefetch_ranges wrapped a near-u32::MAX LBA to {lba}" ); } - // Clamping the range must drop only the sectors that cannot be - // addressed. Dropping the addressable ones too costs the bulk read - // that is this function's entire purpose, and nothing downstream can - // tell, because the sliding-window path still serves every LBA. + // Clamping must drop only unaddressable sectors — dropping addressable + // ones too silently defeats the bulk read this function exists for. for lba in 0xFFFF_FFF0u32..=0xFFFF_FFFE { assert!( br.prefetched.contains_key(&lba), @@ -4008,10 +3675,8 @@ mod tests { #[test] fn read_directory_budget_exceeded_returns_err() { // A crafted disc emitting more FIDs than MAX_TOTAL_DIR_ENTRIES must be - // rejected rather than visited indefinitely. `budget` is the running - // global counter (threshold = MAX_TOTAL_DIR_ENTRIES); pre-load it to - // within 10 of the cap and feed 51 file FIDs — the walk must error once - // the counter crosses the cap, before consuming all of them. + // rejected, not visited indefinitely: pre-load `budget` to within 10 of + // the cap and feed 51 FIDs — the walk must error before consuming them all. let dir_sector = build_dir_sector_with_file_fids(51, 200); let dir_icb = build_efe_icb(2048, 2048, 50); let mut reader = MemReader::new(); @@ -4039,16 +3704,9 @@ mod tests { #[test] fn read_directory_icb_cycle_does_not_recurse() { - // A directory whose child FID (is_dir=true) points back to the same - // ICB LBA as the parent (a self-referential cycle) must NOT recurse. - // It must be emitted as a leaf entry instead. - // - // Layout: - // meta_lba 5 — root directory ICB, dir data at lba 60 - // lba 60 — one FID: is_dir, ICB at lba 5 (self-reference) - // - // We seed visited with lba 5 (the root we are about to descend into), - // so when the FID points back to lba 5 the cycle is detected immediately. + // A child FID pointing back at the parent's own ICB LBA (self-reference) + // must NOT recurse — it must be emitted as a leaf. `visited` is seeded + // with lba 5 (the root about to be descended into) so the cycle is caught. let mut dir = [0u8; 2048]; let mut name_bytes = vec![8u8]; name_bytes.extend_from_slice(b"LOOP"); @@ -4086,19 +3744,9 @@ mod tests { #[test] fn read_directory_rejects_an_icb_whose_tag_is_not_a_file_entry() { - // ECMA-167 4/14.9 (File Entry, tag 261) and 4/14.17 (Extended File - // Entry, tag 266) are the only descriptors that can be the ICB of a - // directory. Any other tag is a corrupt/foreign descriptor: the - // directory's allocation extent cannot be located, so its contents are - // UNKNOWN — not known to be empty. Returning an empty DirEntry makes a - // corrupt directory indistinguishable from a genuinely empty one, so a - // caller enumerating titles sees a disc that "has no BDMV/PLAYLIST" - // and exits 0. The same tag on a FILE ICB is already a hard error - // (`read_icb_extents`), so this must be one too. - // - // Tag 258 (Allocation Extent Descriptor) is a real UDF descriptor that - // is simply not a File Entry — the exact "pointer landed on the wrong - // structure" shape. + // Only tags 261/266 (ECMA-167 4/14.9, 4/14.17) can be a directory's ICB; + // any other tag (here 258, a real but wrong descriptor) leaves contents + // UNKNOWN — an empty DirEntry would make it look genuinely empty. let mut icb = build_efe_icb(2048, 2048, 60); icb[0..2].copy_from_slice(&258u16.to_le_bytes()); let mut reader = MemReader::new(); @@ -4132,14 +3780,9 @@ mod tests { #[test] fn read_file_unrecorded_extent_contributes_zeros_and_does_not_shift_later_extents() { - // ECMA-167 4/14.14.1.1: extent type 1 is "extent allocated but not - // recorded" — its bytes are logically zeros that STILL OCCUPY the - // file's byte space. Dropping the descriptor entirely slides every - // later extent's data down by the hole's length, so the file's bytes - // land at the wrong offsets with no error at all. - // - // Three 2048-byte extents: recorded 0xAA, unrecorded (the hole), - // recorded 0xCC. The 0xCC extent's data belongs at byte 4096. + // ECMA-167 4/14.14.1.1: type 1 = "allocated but not recorded" — logical + // zeros that STILL OCCUPY file byte space. Dropping the descriptor slides + // later extents down by the hole's length with no error at all. let icb = build_efe( 3 * 2048, &[ @@ -4173,11 +3816,9 @@ mod tests { #[test] fn read_filesystem_follows_the_avdp_main_vds_extent_pointer() { - // ECMA-167 3/10.2.1: the Anchor Volume Descriptor Pointer DEFINES the - // Main Volume Descriptor Sequence by the extent_ad it carries - // (ExtentLength at [16:20], ExtentLocation at [20:24]) — the sequence - // is not fixed at sector 32. A conformant volume that records its VDS - // elsewhere must still mount. + // ECMA-167 3/10.2.1: the AVDP DEFINES the Main VDS via its extent_ad + // (length at [16:20], location at [20:24]) — not fixed at sector 32. + // A conformant volume recording its VDS elsewhere must still mount. use fixture::{DirSpec, MemDisc, PART_START, build_udf_skeleton, file, lay_dir}; // Where this volume really keeps its Volume Descriptor Sequence. @@ -4258,10 +3899,9 @@ mod tests { }, ); - // Point the anchor at LBA 500 with a perfectly well-formed extent_ad. - // Nothing is written there, so the sweep reads zeroed sectors: tag 0, - // no Partition Descriptor. build_udf_skeleton left the real sequence at - // the customary location, which is where the fallback must find it. + // Point the anchor at LBA 500 with a well-formed extent_ad but nothing written + // there, so the sweep reads zeroed sectors (tag 0, no Partition Descriptor). + // The real sequence stays at the customary location for the fallback to find. let mut avdp = vec![0u8; 2048]; avdp[0..2].copy_from_slice(&2u16.to_le_bytes()); avdp[16..20].copy_from_slice(&(16u32 * 2048).to_le_bytes()); @@ -4305,10 +3945,9 @@ mod tests { }, ); - // Move the sequence the skeleton wrote at 32/33/34 out to 50/51, past - // the declared 16-sector window. 32..48 is left as zeroed sectors: - // tag 0, no descriptor of any kind, and crucially no tag-8 Terminating - // Descriptor to stop the wider sweep early. + // Move the sequence from 32/33/34 to 50/51, past the declared 16-sector window. + // 32..48 is left zeroed (tag 0, no descriptor), crucially with no tag-8 + // Terminating Descriptor to stop the wider sweep early. disc.put_bytes(32, &[0u8; 3 * 2048]); let mut pd = [0u8; 2048]; pd[0..2].copy_from_slice(&5u16.to_le_bytes()); @@ -4334,16 +3973,9 @@ mod tests { assert_eq!(fs.root.entries[0].name, "INDEX.BDMV"); } - // ---- UDF 2.50 Metadata Partition coverage. - // - // Every BD-ROM records its file-system metadata in a Metadata Partition - // (UDF 2.50 2.2.10 / BD white paper 3 §"Metadata File"): the Logical - // Volume Descriptor carries TWO partition maps, a Type 1 physical map and - // a Type 2 metadata map, and the directory tree lives inside a Metadata - // File whose own File Entry sits in the physical partition. Nothing in - // this crate built such a volume, so the entire two-map branch of - // `read_filesystem` — the branch every real disc takes — was parsed by no - // test at all. + // ---- UDF 2.50 Metadata Partition coverage. Every BD-ROM's LVD carries TWO + // partition maps (Type 1 physical + Type 2 metadata, UDF 2.50 2.2.10); the tree + // lives in a Metadata File whose FE sits in the physical partition — untested. /// How one synthetic UDF 2.50 metadata-partition volume is laid out. /// Every field a disc controls is a knob so a single builder can produce @@ -4473,11 +4105,9 @@ mod tests { td[0..2].copy_from_slice(&8u16.to_le_bytes()); disc.put_bytes(35, &td); - // The Metadata File's own File Entry, in the PHYSICAL partition. Its - // single allocation descriptor is what defines the whole metadata - // partition. The extended-attribute area is filled with a - // recognisable pattern so an allocation descriptor read from the - // wrong offset cannot silently produce a usable value. + // The Metadata File's own FE, in the PHYSICAL partition — its single allocation + // descriptor defines the whole metadata partition. The EA area is filled with a + // recognisable pattern so a descriptor read from the wrong offset can't be usable. let mut meta_fe = vec![0u8; 2048]; meta_fe[0..2].copy_from_slice(&266u16.to_le_bytes()); meta_fe[208..212].copy_from_slice(&(spec.l_ea as u32).to_le_bytes()); @@ -4502,12 +4132,9 @@ mod tests { disc.put_bytes(PART_START + block, &decoy); } - // Decoy file set at block 0 of the physical partition, laid only when - // the Metadata File's File Entry is not itself there. Any failure to - // resolve the metadata partition lands `metadata_start` on - // `partition_start`, where this tree then mounts — so a reader that - // silently skips the metadata partition produces a plausible, - // successful, WRONG filesystem instead of an error. + // Decoy file set at block 0, laid only when the Metadata File's FE isn't there. + // A failed metadata-partition resolution falls back to `partition_start`, where + // this tree mounts — turning a silent skip into a plausible, WRONG filesystem. if spec.meta_fe_at != 0 { let mut fb_fsd = vec![0u8; 2048]; fb_fsd[0..2].copy_from_slice(&256u16.to_le_bytes()); @@ -4611,15 +4238,9 @@ mod tests { }; let (disc, _meta_start) = build_meta_vol(&spec); - // ONLY the recorded location faults. Block 0 reads fine and holds the - // Volume Descriptor content that lives there on this fixture — not a - // File Entry. This is the damaging shape: `meta_tag` ends up non-zero - // and not 266, so a guard keyed on "nothing read at all" would miss it, - // and the fault would be laundered into `UdfNotFilesystem`. - // - // Denying BOTH candidates does NOT test this: the block-0 fallback read - // fails too, so a read error propagates from further down either way - // and the test passes without the fix. + // ONLY the recorded location faults; block 0 reads fine but holds Volume + // Descriptor content, not a File Entry, so meta_tag is non-zero-not-266 — a + // guard keyed on "nothing read" misses it. Denying both candidates wouldn't test this. let mut reader = FailsMetaCandidates { inner: disc, deny: vec![PART_START + 9], @@ -4637,14 +4258,9 @@ mod tests { #[test] fn read_filesystem_locates_the_metadata_file_where_the_partition_map_records_it() { - // UDF 2.50 2.2.10 Metadata Partition Map: "Metadata File Location — - // the logical block address of the Metadata File within the partition - // identified by Partition Number", a Uint32 at map offset 40. It is - // the ONLY thing on the volume that says where the Metadata File's - // File Entry lives. Block 0 of the physical partition is a common - // choice, not a requirement. - // - // A volume that records the FE anywhere else must still mount. + // UDF 2.50 2.2.10 Metadata Partition Map: Metadata File Location (Uint32 at + // map offset 40) is the ONLY thing that says where the Metadata File's File + // Entry lives; block 0 is common but not required, so it must mount elsewhere. use fixture::PART_START; let spec = MetaVol { meta_file_loc: 9, @@ -4678,19 +4294,9 @@ mod tests { #[test] fn read_filesystem_counts_a_partial_final_metadata_sector() { - // The Metadata File's extent length is a BYTE count (ECMA-167 4/14.14.1 - // extent_length) and nothing requires it to be a whole multiple of the - // 2048-byte logical sector. `metadata_sectors` is consumed as a sector - // COUNT (`metadata_sector_ranges` ends the UDF-structure range at - // metadata_start + metadata_sectors), so a length with a remainder must - // round UP: truncating instead leaves the final, partly-used sector of - // the metadata partition outside the range, and the AACS/disc-info - // caller then never reads the directory bytes living in it. - // - // Every other fixture on this volume uses an exact multiple, where - // rounding up and truncating agree — this is the only case that tells - // them apart. The expected count is worked out by hand: - // 16 910 337 = 8257 * 2048 + 1 -> 8258 sectors. + // extent_length is a BYTE count (ECMA-167 4/14.14.1); metadata_sectors is a + // sector COUNT, so a remainder must round UP or the final partly-used sector + // (and its directory bytes) falls outside the range: 16910337 = 8257*2048+1 -> 8258. let spec = MetaVol { meta_bytes: 16_910_337, ..conformant_meta_vol() @@ -4710,10 +4316,9 @@ mod tests { #[test] fn read_filesystem_still_finds_a_metadata_file_entry_recorded_at_block_zero() { - // The overwhelmingly common layout: the Metadata File's File Entry is - // at block 0 of the physical partition and the map's Metadata File - // Location says so. Honouring the recorded location must not change - // this case. + // The overwhelmingly common layout: the Metadata File's File Entry is at block + // 0 and the map's Metadata File Location says so. Honouring the recorded + // location must not change this ordinary case. let spec = MetaVol { meta_file_loc: 0, meta_fe_at: 0, @@ -4728,11 +4333,9 @@ mod tests { #[test] fn read_filesystem_falls_back_to_block_zero_when_the_recorded_location_holds_no_file_entry() { - // A Metadata File Location that points at a block holding no File - // Entry is a broken volume, not an unmountable one: the Metadata File - // is still discoverable at block 0, where it almost always is. A - // reader that trusted the field blindly would refuse a disc that used - // to mount. + // A Metadata File Location pointing at a block with no File Entry is broken, + // not unmountable: the file is still discoverable at block 0. Trusting the + // field blindly would refuse a disc that used to mount. let spec = MetaVol { meta_file_loc: 999, // nothing is recorded there meta_fe_at: 0, @@ -4748,12 +4351,9 @@ mod tests { #[test] fn read_filesystem_reads_no_metadata_file_location_out_of_a_sparable_partition_map() { - // UDF 2.50 2.2.9 Sparable and 2.2.8 Virtual partition maps are also - // ECMA-167 3/10.7.3 Type 2 maps, and they record entirely unrelated - // fields where 2.2.10 puts the Metadata File Location. Those bytes - // must never be read as a location: here they address a decoy File - // Entry declaring a different extent, which would silently mount a - // completely different metadata partition. + // UDF 2.2.9 Sparable / 2.2.8 Virtual maps are also Type 2 (ECMA-167 3/10.7.3) + // but record unrelated fields where 2.2.10 puts the Metadata File Location — + // misreading those bytes as a location would silently mount a decoy volume. let spec = MetaVol { ident: b"*UDF Sparable Partition", meta_file_loc: 9, @@ -4776,13 +4376,9 @@ mod tests { #[test] fn read_filesystem_reads_a_metadata_file_extent_that_ends_flush_with_the_file_entry() { - // ECMA-167 4/14.17: the allocation descriptors of an Extended File - // Entry begin at 216 + L_EA, and the extended-attribute area may run - // right up to the point where the single short_ad exactly fills the - // rest of the logical block. That descriptor is entirely inside the - // File Entry and must be read: refusing it loses the whole volume, - // because the Metadata File's extent is the only thing that says - // where the file system is. + // ECMA-167 4/14.17: allocation descriptors start at 216 + L_EA, and the EA area + // may run right up to where the short_ad exactly fills the rest of the block. + // That descriptor is still in-bounds and must be read, or the volume is lost. let spec = MetaVol { l_ea: 2048 - 216 - 8, // short_ad occupies the final 8 bytes ..conformant_meta_vol() @@ -4798,15 +4394,9 @@ mod tests { #[test] fn read_filesystem_refuses_a_metadata_file_whose_descriptor_runs_off_the_file_entry() { - // L_EA is a disc-controlled Uint32. One that leaves fewer than eight - // bytes of the 2048-byte File Entry after 216 + L_EA leaves no room - // for the short_ad, so the Metadata File's extent — and with it the - // metadata partition — cannot be located. Reading the descriptor - // anyway runs past the end of the sector buffer. - // - // 1830 is chosen so the descriptor STRADDLES the end (216 + 1830 + 8 - // == 2054): a bound computed the other way round still looks - // satisfied, and then indexes out of the buffer. + // L_EA is disc-controlled; if 216 + L_EA leaves under 8 bytes, reading the + // short_ad anyway runs past the sector buffer. 1830 makes it STRADDLE the end + // (216+1830+8==2054), defeating a bound check computed the other way round. let spec = MetaVol { l_ea: 1830, ..conformant_meta_vol() @@ -4820,15 +4410,9 @@ mod tests { #[test] fn read_filesystem_ignores_a_zero_length_first_partition_map() { - // ECMA-167 3/10.7 walks the partition-map list by each map's own - // length byte, and that byte is disc-controlled. A map declaring - // length 0 does not advance the walk — the "second" map would be the - // first map re-read at the same offset — so the list cannot be walked - // and no metadata partition is resolved. Reading the map at 440 as if - // it were the second one lets a Type 2 byte there hijack the mount: - // here that map's Metadata File Location addresses the real Metadata - // File, so a reader that walks it lands on a whole different, and - // entirely plausible, filesystem. + // ECMA-167 3/10.7 walks partition maps by each map's own length byte. A + // length-0 first map can't advance the walk, so misreading the map at 440 as + // the "second" one lets it hijack the mount onto a different, plausible fs. let spec = MetaVol { pm1_type: 2, pm1_len: 0, @@ -4844,17 +4428,9 @@ mod tests { #[test] fn read_filesystem_seeds_the_cycle_guard_with_the_metadata_relative_root_key() { - // The tree walk's cycle guard keys on (metadata_start, ICB LBA) so - // that two volumes' identical LBAs are not confused. `read_filesystem` - // pre-seeds it with the ROOT's key, so a File Identifier Descriptor - // pointing back at the root is recognised on sight. - // - // Composing that key wrongly (dropping the metadata half, or ANDing - // instead of ORing the two halves) still produces a key — just not - // one the walk will ever compute again. The pre-seed then matches - // nothing, the root is descended a second time, and the tree gains a - // phantom copy of itself under its own root. `metadata_start` is - // non-zero here precisely so that the two halves are distinguishable. + // The cycle guard keys on (metadata_start, ICB LBA), pre-seeded with root's key + // so a self-referential FID is recognised. A wrongly composed key (e.g. ANDing + // instead of ORing the halves) misses the seed, descending root twice. let spec = conformant_meta_vol(); let (mut disc, meta_start) = build_meta_vol(&spec); @@ -4877,13 +4453,9 @@ mod tests { #[test] fn read_filesystem_stops_the_volume_descriptor_sweep_at_the_terminating_descriptor() { - // ECMA-167 3/8.4.2: a Terminating Descriptor ends the Volume - // Descriptor Sequence. Sectors past it are not part of the sequence — - // they are whatever the volume happens to record next, and on a - // rewritten volume that is commonly a stale copy of an earlier - // sequence. Reading on past the terminator lets a stale Partition - // Descriptor overwrite the live one, and every subsequent LBA in the - // mount is then offset by the difference. + // ECMA-167 3/8.4.2: a Terminating Descriptor ends the VDS. Sectors past it are + // not part of it — often a stale copy left by a rewritten volume — so reading on + // lets a stale Partition Descriptor overwrite the live one and offset every LBA. use fixture::{DirSpec, MemDisc, PART_START, build_udf_skeleton, file, lay_dir}; let mut disc = MemDisc::new(); @@ -4918,13 +4490,9 @@ mod tests { #[test] fn read_filesystem_ignores_an_anchor_whose_main_vds_extent_starts_at_block_zero() { - // ECMA-167 3/10.2.1: the anchor's Main VDS extent_ad is the pointer to - // the sequence. Logical block 0 is the start of the volume space — - // reserved area, never a Volume Descriptor Sequence — so an extent - // recorded there is not describing one, however plausible its length. - // Sweeping from 0 reads the reserved area as descriptors and finds no - // Partition Descriptor, so the disc is rejected as not a filesystem; - // the customary window is the right answer for such an anchor. + // ECMA-167 3/10.2.1: block 0 is the reserved area, never a Volume Descriptor + // Sequence, so an anchor pointing there yields no Partition Descriptor and the + // disc looks unmountable — the customary window is the right fallback. use fixture::{DirSpec, MemDisc, PART_START, build_udf_skeleton, file, lay_dir}; let mut disc = MemDisc::new(); @@ -4953,10 +4521,9 @@ mod tests { assert_eq!(child_names(&fs.root), vec!["INDEX.BDMV".to_string()]); } - // ---- directory-descriptor boundary coverage (ECMA-167 4/14.4, 4/14.9, - // 4/14.17). Every field below is disc-controlled, so each boundary is a - // place a malformed image can push the parser off the end of a buffer or - // turn a failure into a plausible-looking success. + // ---- directory-descriptor boundary coverage (ECMA-167 4/14.4, 4/14.9, 4/14.17). + // Every field below is disc-controlled, so each boundary can push the parser off + // the end of a buffer or turn a failure into a plausible-looking success. /// Build a directory ICB with an explicit descriptor tag, extended /// attribute length, RAW (unmasked) allocation-descriptor length field @@ -5036,14 +4603,9 @@ mod tests { #[test] fn read_directory_reads_a_plain_file_entry_tag_261_directory_icb() { - // ECMA-167 4/14.9 File Entry (tag 261) is a directory ICB in its own - // right — UDF 2.50 discs use the Extended File Entry (266), but a - // 261 File Entry is equally valid and is what UDF 1.02 DVD-Video - // discs carry. Its L_EA lives at byte 168 and its allocation - // descriptors begin at 176 + L_EA, NOT at the 208/216 offsets of a - // 266. Dropping the 261 arm, or testing the ad-bounds guard the - // wrong way round, turns every UDF 1.02 directory into a hard read - // error — the disc mounts and reports no titles. + // ECMA-167 4/14.9 File Entry (tag 261) is a valid directory ICB (UDF 1.02 DVD-Video), + // vs the Extended File Entry (266) of UDF 2.50. Its L_EA is at byte 168 and ADs begin at + // 176 + L_EA, NOT the 208/216 of a 266; mishandling it fails every UDF 1.02 directory. let mut fids = Vec::new(); push_fid_iu(&mut fids, "", 5, true, true, 0); // parent (..) push_fid_iu(&mut fids, "VIDEO_TS.IFO", 7, false, false, 0); @@ -5080,19 +4642,9 @@ mod tests { #[test] fn read_directory_reads_a_directory_whose_fids_are_embedded_in_the_icb() { - // ECMA-167 4/14.6.8 ICB Tag flags bits 0-2 select how the entry stores - // its data; 3 means EMBEDDED — the bytes sit in the entry itself, in - // the field where allocation descriptors would otherwise be. There is - // then no out-of-line extent at all. - // - // Decoding that field as a short_ad reads the first FID's own bytes as - // a length/LBA pair: the FID's tag identifier (257) becomes a 257-byte - // "extent" and its tag-header bytes become the LBA, so the walk reads - // an unrelated sector, finds no FID tag there and returns an EMPTY - // directory — a disc whose BDMV enumerates no titles, with no error. - // - // `read_inline_data` already honours these flags for FILES; a - // directory cannot be laxer. + // ECMA-167 4/14.6.8 ICB Tag flags bits 0-2 select storage; 3 = EMBEDDED (bytes sit in + // the entry's AD field, no out-of-line extent). Decoding it as a short_ad reads the first + // FID's bytes as a length/LBA, walking an unrelated sector and returning empty. let mut fids = Vec::new(); push_fid_iu(&mut fids, "", 5, true, true, 0); // parent (..) push_fid_iu(&mut fids, "INDEX.BDMV", 7, false, false, 0); @@ -5109,17 +4661,9 @@ mod tests { #[test] fn read_directory_reads_an_extended_ad_directorys_extent_location() { - // ECMA-167 4/14.14.3 extended_ad is 20 bytes: extent_length (4), - // recorded_length (4), information_length (4), then the extent_location - // lb_addr at offset 12 — NOT at offset 4 where a short_ad/long_ad keeps - // it. ICB Tag flags = 2 selects it (4/14.6.8). - // - // Reading the LBA from offset 4 picks up the RECORDED LENGTH instead, - // so the walk reads whatever sector that number names. Here that is - // sector 2048, which holds nothing: the directory comes back empty. - // - // `read_icb_extents` already reads an extended_ad's location at +12; - // this is the same descriptor in the directory path. + // ECMA-167 4/14.14.3 extended_ad (20B) keeps extent_location at offset 12, NOT offset 4 + // like short_ad/long_ad; ICB Tag flags = 2 selects it (4/14.6.8). Reading offset 4 gets + // recorded_length (here 2048), reading an empty sector so the directory comes back empty. let mut fids = Vec::new(); push_fid_iu(&mut fids, "", 5, true, true, 0); push_fid_iu(&mut fids, "INDEX.BDMV", 7, false, false, 0); @@ -5144,14 +4688,9 @@ mod tests { #[test] fn read_directory_masks_the_extent_type_bits_out_of_the_ad_length() { - // ECMA-167 4/14.14.1.1: the 32-bit field at the head of a short_ad is - // a 30-bit extent length plus a 2-bit extent TYPE in bits 30..31. The - // type bits must be masked off before the value is used as a byte - // count; folding them in yields a length of >= 1 GiB, which then trips - // the MAX_DIR_BYTES guard and turns a perfectly readable directory - // into a read error. - // - // Encoded here as extent type 1 (bits = 01) over a real, tiny length. + // ECMA-167 4/14.14.1.1: a short_ad's head 32-bit field is a 30-bit length plus a 2-bit + // extent TYPE (bits 30..31), which must be masked off before use as a byte count; + // folding them in yields >= 1 GiB, tripping MAX_DIR_BYTES. Encoded here as type 1. let mut fids = Vec::new(); push_fid_iu(&mut fids, "", 5, true, true, 0); push_fid_iu(&mut fids, "INDEX.BDMV", 7, false, false, 0); @@ -5181,16 +4720,9 @@ mod tests { #[test] fn read_directory_ignores_fid_bytes_past_the_declared_directory_length() { - // The directory's information length (the short_ad length field) is - // the authority on how far the FID list extends; the read buffer is - // rounded up to a whole sector, so bytes between the declared end and - // the end of the sector are NOT part of the directory. Scanning one - // descriptor too far invents a file that the disc never recorded — - // stale bytes from a previously deleted FID read as a live entry. - // - // Layout: a 40-byte FID for "A" at 0, then a well-formed FID for - // "GHOST" at 40. The declared length is 78 = 40 + 38, i.e. exactly - // the header of the second FID and not one byte more. + // Information length (short_ad length) bounds the FID list; the sector-rounded buffer + // has trailing bytes past it that aren't the directory, so over-scanning reads a stale + // deleted FID as live. Layout: FID "A" (40B) at 0, "GHOST" at 40, declared length 78. let mut fids = Vec::new(); push_fid_iu(&mut fids, "A", 7, false, false, 0); assert_eq!( @@ -5219,13 +4751,9 @@ mod tests { #[test] fn read_directory_stops_at_a_descriptor_whose_tag_is_not_a_fid() { - // ECMA-167 3/7.2.1: the tag identifier is a 16-bit little-endian - // field; 257 is File Identifier Descriptor. The FID list ends at the - // first descriptor that is not a FID. Reading only the low byte of - // the tag would accept tag 0x0201 (=513, Partition Descriptor's - // neighbourhood) as if it were 257, because 257 is 0x0101 and both of - // its bytes are 0x01 — a single-byte comparison cannot tell them - // apart. Anything after the terminator is then parsed as FIDs. + // ECMA-167 3/7.2.1: the tag identifier is a 16-bit LE field (257 = FID); the FID list + // ends at the first non-FID. A single-byte compare would accept 0x0201 (513) as 257 + // since both share low byte 0x01, wrongly parsing post-terminator bytes as FIDs. let mut fids = Vec::new(); push_fid_iu(&mut fids, "REAL.MPLS", 7, false, false, 0); let mut dir = [0u8; 2048]; @@ -5256,15 +4784,9 @@ mod tests { #[test] fn read_directory_keeps_a_fid_name_that_ends_exactly_at_the_buffer_end() { - // A File Identifier that ends on the last byte of the directory's - // read buffer is entirely present and must be decoded. Treating - // "ends exactly at the end" as "runs past the end" silently drops the - // last file of a directory that happens to fill its final sector — - // and a dropped .m2ts is a missing title, not a visible failure. - // - // The first FID is a parent (..) with a 1959-byte implementation-use - // area, which lands the second FID at offset 2000; its name then runs - // 2038..2048 inclusive. + // A FID ending on the buffer's last byte is fully present and must decode; treating + // "ends exactly at end" as "runs past end" drops the last file of a sector-filling + // directory. Parent FID ends at 2000, so the second FID's name runs 2038..2048. let mut fids = Vec::new(); push_fid_iu(&mut fids, "", 5, true, true, 1959); assert_eq!(fids.len(), 2000, "parent FID must end at 2000"); @@ -5286,10 +4808,9 @@ mod tests { #[test] fn read_directory_stops_instead_of_panicking_when_a_name_runs_past_the_buffer() { - // L_IU (ECMA-167 4/14.4.7) is a disc-controlled 16-bit field, so a - // malformed FID can place the File Identifier tens of kilobytes past - // the end of the directory's read buffer. The scan must stop; slicing - // there would panic out of the public API on a damaged disc. + // L_IU (ECMA-167 4/14.4.7) is disc-controlled, so a malformed FID can + // place the name tens of KB past the buffer end. The scan must stop; + // slicing there would panic out of the public API on a damaged disc. let mut fids = Vec::new(); push_fid_iu(&mut fids, "OK.MPLS", 7, false, false, 0); let mut dir = [0u8; 2048]; @@ -5317,11 +4838,9 @@ mod tests { #[test] fn read_directory_admits_a_tree_that_exactly_fills_the_entry_budget() { - // The global entry budget is an anti-DoS ceiling on a crafted disc. - // A tree that reaches the ceiling exactly is still a legal tree and - // must be enumerated; rejecting it makes the largest admissible disc - // unreadable. Pre-load the counter to one below the cap so that this - // directory's single entry lands exactly on it. + // The entry budget is an anti-DoS ceiling; a tree that exactly reaches + // it is still legal and must be enumerated, or the largest admissible + // disc becomes unreadable. Counter starts one below the cap. let mut fids = Vec::new(); push_fid_iu(&mut fids, "LAST.M2TS", 7, false, false, 0); let mut dir = [0u8; 2048]; @@ -5387,14 +4906,9 @@ mod tests { #[test] fn read_directory_stops_descending_at_the_nesting_cap() { - // MAX_DIR_DEPTH bounds how far a crafted disc can drive recursion. - // The directory sitting AT the cap must be emitted as a leaf and not - // descended into: descending one level further (an off-by-one on the - // comparison), or failing to increment the depth at all, removes the - // bound that the recursion depends on for termination. - // - // The chain laid here is deliberately deeper than the cap, so the - // returned tree's depth is decided by the cap and not by the fixture. + // MAX_DIR_DEPTH bounds recursion; the directory AT the cap must be a + // leaf, not descended into (an off-by-one removes the termination + // bound). Chain is deeper than the cap so depth is cap-decided. let deeper = MAX_DIR_DEPTH + 4; let mut reader = MemReader::new(); lay_dir_chain(&mut reader, deeper); @@ -5411,11 +4925,9 @@ mod tests { ) .expect("a deep but well-formed chain parses"); - // The root is read at depth 0; a directory read at depth d descends - // into its children only while d < MAX_DIR_DEPTH, so the deepest - // directory that is itself READ is the one at depth MAX_DIR_DEPTH, - // and the child named by its FID is emitted as a leaf. That is - // MAX_DIR_DEPTH + 2 nodes on the chain, root and leaf included. + // Root is read at depth 0 and descends only while d < MAX_DIR_DEPTH, so + // the deepest directory actually READ is at MAX_DIR_DEPTH, with its + // child FID emitted as a leaf: MAX_DIR_DEPTH + 2 nodes total. assert_eq!( chain_len(&parsed), MAX_DIR_DEPTH as usize + 2, @@ -5436,14 +4948,9 @@ mod tests { #[test] fn read_directory_advances_past_a_fids_implementation_use_area() { - // ECMA-167 4/14.4.9: the next FID starts at - // (38 + L_IU + L_FI) rounded up to a multiple of 4. Leaving L_IU out - // of the stride lands the scan in the middle of the current - // descriptor, so every file after the first one with a non-empty - // implementation-use area disappears. - // - // L_IU = 4, L_FI = 6 → stride 48. Dropping L_IU gives 40, which lands - // inside the first descriptor's name field. + // ECMA-167 4/14.4.9: next FID starts at (38 + L_IU + L_FI) rounded up + // to 4. Omitting L_IU from the stride lands mid-descriptor, dropping + // every file after one with a non-empty IU area (here: stride 48 vs 40). let mut fids = Vec::new(); push_fid_iu(&mut fids, "AAAAA", 7, false, false, 4); assert_eq!( @@ -5472,11 +4979,9 @@ mod tests { #[test] fn read_directory_reads_an_ad_that_ends_exactly_at_the_icb_sector_end() { - // The allocation descriptor sits at 216 + L_EA (tag 266) or - // 176 + L_EA (tag 261). A short_ad whose last byte is the last byte - // of the 2048-byte ICB sector is entirely present, so the guard must - // reject only descriptors that run PAST the sector. Rejecting the - // exact fit turns a readable directory into a read error. + // AD sits at 216 + L_EA (tag 266) or 176 + L_EA (tag 261). A short_ad + // whose last byte is the sector's last byte is entirely present, so + // the guard must reject only ADs that run PAST it, not an exact fit. for (tag, ad_base) in [(266u16, 216usize), (261u16, 176usize)] { let l_ea = 2048 - 8 - ad_base; // ad_off + 8 == 2048 exactly let mut fids = Vec::new(); @@ -5517,11 +5022,9 @@ mod tests { #[test] fn read_directory_locates_the_ad_after_a_non_empty_extended_attribute_area() { - // ECMA-167 4/14.9 / 4/14.17: the Extended Attributes field occupies - // L_EA bytes immediately before the allocation descriptors, so the - // descriptors begin at base + L_EA. Real UDF file entries do carry - // extended attributes; computing the offset any other way reads the - // attribute bytes (or the fixed header) as an extent. + // ECMA-167 4/14.9 / 4/14.17: Extended Attributes occupy L_EA bytes + // immediately before the ADs, so ADs begin at base + L_EA. Computing + // the offset any other way reads attribute/header bytes as an extent. for (tag, _base) in [(266u16, 216usize), (261u16, 176usize)] { let mut fids = Vec::new(); push_fid_iu(&mut fids, "WITHEA.MPLS", 7, false, false, 0); @@ -5542,18 +5045,9 @@ mod tests { #[test] fn read_directory_skips_a_deleted_fid_and_never_follows_its_icb() { - // ECMA-167 4/14.4.4 File Characteristics bit 2 = Deleted: "the file - // ... has been deleted". 4/14.4.3 then says the ICB field of such a - // descriptor MAY specify an extent of length zero — i.e. it need not - // point at anything at all. - // - // So a deleted FID must be skipped outright. Following its ICB reads - // whatever descriptor happens to sit at metadata LBA 0 (the File Set - // Descriptor, tag 256), which is not a File Entry. On a deleted - // DIRECTORY FID that is a hard read error — one stale descriptor in - // one directory and the entire volume fails to enumerate. On a - // deleted FILE FID it is worse than an error: the phantom name is - // reported as a real zero-byte file. + // ECMA-167 4/14.4.4 Deleted bit: the ICB MAY be a zero-length extent, + // so it must be skipped outright — following it reads whatever sits at + // metadata LBA 0, a hard error (dir) or phantom zero-byte file (file). let mut fids = Vec::new(); push_fid_iu(&mut fids, "", 5, true, true, 0); // parent (..) // Deleted directory FID with a zeroed ICB field, exactly as 4/14.4.3 @@ -5581,16 +5075,9 @@ mod tests { #[test] fn read_directory_reads_every_byte_of_the_short_ad_length_and_location() { - // ECMA-167 4/14.14.1: a short_ad is two little-endian 32-bit fields — - // ExtentLength then ExtentPosition — and each of their four bytes - // carries weight. This fixture gives every byte a distinct non-zero - // value (except the length's most-significant byte, which any legal - // directory keeps at zero — see the oversized-length test), so a - // descriptor byte sourced from the wrong offset changes either the - // reported directory length or the sector the FID list is read from. - // - // length = 0x0002_012C = 131 372 bytes (a large but legal dir) - // location = 0x0102_033C = 16 909 628 (a high-LBA volume) + // ECMA-167 4/14.14.1: short_ad is two 32-bit LE fields (length, then + // location), every byte distinct non-zero here — a wrong-offset read + // changes the reported length or the sector the FID list comes from. let ad_len: u32 = 0x0002_012C; let ad_pos: u32 = 0x0102_033C; let mut fids = Vec::new(); @@ -5621,11 +5108,9 @@ mod tests { #[test] fn read_directory_rejects_a_length_whose_high_byte_alone_exceeds_the_cap() { - // The most-significant byte of the 30-bit ExtentLength is enough on - // its own to demand a 16 MiB allocation — sixteen times the ceiling. - // It must be read, and the directory refused; ignoring that byte - // lets a crafted descriptor through with a length of zero, which - // then reads as a legitimately empty directory. + // The MSB alone of the 30-bit ExtentLength can demand a 16 MiB + // allocation. It must be read and the directory refused; ignoring it + // lets a crafted descriptor through as a legitimately empty directory. for tag in [261u16, 266u16] { let mut reader = MemReader::new(); reader.put(5, build_dir_icb_tagged(tag, 0, 0x0100_0000, 60)); @@ -5641,10 +5126,9 @@ mod tests { #[test] fn read_directory_reads_a_directory_exactly_at_the_size_cap() { - // MAX_DIR_BYTES bounds the allocation a corrupt 30-bit length can - // force. A directory whose declared length is exactly the ceiling is - // admissible and must be read; rejecting it would make the largest - // legal STREAM/ directory unreadable. + // MAX_DIR_BYTES bounds the allocation a corrupt 30-bit length can force. + // A directory declared exactly at the ceiling is admissible and must be + // read; rejecting it would make the largest legal STREAM/ dir unreadable. let mut fids = Vec::new(); push_fid_iu(&mut fids, "00000.M2TS", 7, false, false, 0); let mut dir = [0u8; 2048]; @@ -5662,14 +5146,9 @@ mod tests { #[test] fn read_directory_reads_each_sector_of_a_multi_sector_directory_into_its_own_slot() { - // A directory larger than one sector is read a sector at a time into - // successive 2048-byte windows of one buffer. Every sector must land - // in its OWN window: a later sector written over an earlier one - // destroys FIDs that were already read correctly. - // - // Sector 0 of the FID list holds the only entry; sector 1 is blank. - // If sector 1's read lands at offset 0 it erases the entry, and the - // directory reads as empty rather than failing. + // Multi-sector dirs are read one sector per own window; a later sector + // overwriting an earlier one destroys already-read FIDs. Sector 1 is + // blank — if its read lands at offset 0 it erases sector 0's entry. let mut fids = Vec::new(); push_fid_iu(&mut fids, "FIRST.CLPI", 7, false, false, 0); let mut sector0 = [0u8; 2048]; @@ -5779,10 +5258,8 @@ pub(crate) mod fixture { s[208..212].copy_from_slice(&0u32.to_le_bytes()); // l_ea let ad_size: u32 = if long_ad { 16 } else { 8 }; s[212..216].copy_from_slice(&ad_size.to_le_bytes()); // l_ad - // The allocation descriptor's length field is 32-bit, so a - // declared info_length beyond u32 simply cannot be backed by it — - // which is exactly the disc-vs-reality mismatch a hostile size field - // creates, and what the fixture must be able to express. + // AD length field is 32-bit; a size beyond u32 can't be backed by it, + // which is the disc-vs-reality mismatch a hostile fixture must express. let ad_len = (size.min(u32::MAX as u64) as u32) & 0x3FFF_FFFF; s[216..220].copy_from_slice(&ad_len.to_le_bytes()); s[220..224].copy_from_slice(&data_lba.to_le_bytes()); diff --git a/src/unlock_bridge.rs b/src/unlock_bridge.rs index e12e25e9..042c49df 100644 --- a/src/unlock_bridge.rs +++ b/src/unlock_bridge.rs @@ -45,27 +45,13 @@ impl fu::scsi::ScsiTransport for ScsiAdapter<'_> { bytes_transferred: r.bytes_transferred, sense: r.sense, }), - // libfreemkv's transport returns Err for ANY non-zero SCSI status — - // i.e. a normal drive CHECK CONDITION (ILLEGAL_REQUEST, etc.), NOT - // only a transport-layer fault. Preserve the real status AND the - // parsed sense across the seam: the AACS handshake's wedge guard - // bails on an ILLEGAL_REQUEST sense (so it stops hammering the drive), - // and its diagnosis distinguishes a cert rejection from a dead bus by - // the same status/sense. Collapsing everything to 0xFF/None defeated - // both. Reconstruct the 32-byte sense buffer at the offsets the - // unlock crate reads (sense_key@2 low-nibble, asc@12, ascq@13); a - // genuine transport fault (status 0xFF, no sense) maps through - // unchanged. + // Transport Err covers both real faults and a normal CHECK CONDITION + // status; preserve status+sense so AACS's wedge guard and diagnosis can + // tell a cert rejection from a dead bus (sense_key@2, asc@12, ascq@13). Err(e) => { - // A SCSI status (CHECK CONDITION or a 0xFF transport fault the - // drive layer already tagged) carries its real status + sense via - // extract_scsi_context. Any OTHER error variant is a non-SCSI - // transport/IO-layer fault — ioctl(SG_IO) == -1 (ENODEV/EIO on an - // unplugged bridge) or the fd is gone — i.e. a DEAD BUS, not a - // drive rejection; surface the transport-failure status so the - // unlock crate bails instead of hammering a wedged device. - // (Keying off `sense.is_none()` would be wrong: a CHECK CONDITION - // whose sense didn't parse is a rejection, not a transport fault.) + // ScsiError/DiscRead carry real status+sense via extract_scsi_context; + // any other variant is a non-SCSI transport/IO fault (dead bus, not a + // drive rejection) — surface transport-failure status so unlock bails. let (status, sense) = match &e { crate::error::Error::ScsiError { .. } | crate::error::Error::DiscRead { .. } => { diff --git a/tests/crypto_tests.rs b/tests/crypto_tests.rs index bfdd47ed..6c196e28 100644 --- a/tests/crypto_tests.rs +++ b/tests/crypto_tests.rs @@ -100,10 +100,9 @@ fn aacs_decrypt_unit_roundtrip() { plain[offset] = 0x47; // TS sync byte offset += 192; } - // Flag the unit encrypted via the CPI bits (byte 0) — the authoritative gate - // `decrypt_unit` consults. Set BEFORE snapshotting `expected`: decrypt - // preserves the plaintext header, so the round-tripped unit carries these - // bits too. + // Flag the unit encrypted via the CPI bits (byte 0), the gate `decrypt_unit` + // consults. Set before snapshotting `expected`: decrypt preserves the + // plaintext header, so the round-tripped unit carries these bits too. plain[0] |= 0xC0; // Save original plaintext for comparison @@ -514,10 +513,9 @@ fn css_roundtrip_with_snapshot() { /// Multiple key/seed combinations produce different outputs. #[test] fn css_roundtrip_multiple_keys() { - // The content cipher (dvdcss_unscramble) seeds its LFSRs from - // `key XOR seed`, so the cases must have DISTINCT `key XOR seed` values to - // produce distinct keystreams. (key=0/seed=0 and key=FF/seed=FF both give - // key^seed=0 and are correctly identical — excluded here.) + // The cipher seeds its LFSRs from `key XOR seed`, so cases need DISTINCT + // key^seed values to produce distinct keystreams (key=0/seed=0 and + // key=FF/seed=FF both give key^seed=0 and are excluded here). let cases: &[([u8; 5], [u8; 5])] = &[ ( [0x00, 0x00, 0x00, 0x00, 0x00], @@ -647,11 +645,9 @@ fn css_keyless_recovery_validates_cracked_key() { This is expected: synthetic sectors lack the TAB1 output encoding \ present in real CSS-encrypted DVD sectors." ); - // Never let this test pass vacuously: when the attack can't converge on - // synthetic data, still assert always-true properties of the CSS keystream - // so a real regression is caught on every run — descramble_sector is - // DETERMINISTIC (same key/seed/data → same output) and NON-TRIVIAL (it - // actually transforms the payload, not a silent no-op). + // Don't pass vacuously: if the attack can't converge on synthetic data, + // still assert descramble_sector is deterministic and non-trivial + // (transforms the payload) so a real regression is still caught. for (key, seed) in candidates { let mut base = vec![0x00u8; 2048]; base[0x14] = 0x30; @@ -713,10 +709,8 @@ fn css_recover_title_key_with_exact_plaintext() { The LFSR0 recovery phase may not converge for this combination.", title_key, seed ); - // Never pass vacuously: when LFSR0 recovery can't converge on this - // synthetic sector, still assert always-true properties of the cipher so - // a real regression is caught on every run — descramble_sector is - // DETERMINISTIC and NON-TRIVIAL (actually transforms the payload). + // Don't pass vacuously: if recovery didn't converge, still assert + // descramble_sector is deterministic and non-trivial (transforms payload). let mut a = original.clone(); let mut b = original.clone(); css::lfsr::descramble_sector(&title_key, &mut a); diff --git a/tests/disc_tests.rs b/tests/disc_tests.rs index fad4c065..e6b3c7e7 100644 --- a/tests/disc_tests.rs +++ b/tests/disc_tests.rs @@ -378,18 +378,9 @@ fn resolve_encryption_no_aacs_dir() { assert!(disc.aacs.is_none(), "aacs should be None without /AACS dir"); } -// ── Batch count arithmetic tests ────────────────────────────────────────── -// Regression tests for the u16 truncation bug in the prefetch producer's -// per-batch sector count (`src/sector/prefetched.rs`): when -// `(remaining as u16).min(batch_sectors)` was used instead of -// `remaining.min(batch_sectors as u32) as u16`, any remaining count that is a -// multiple of 65536 truncated to 0. -// -// These tests used to assert against `safe_batch_count`/`buggy_batch_count` -// copies defined in THIS file, so the production expression could be reverted -// with every one of them staying green. They now drive the real producer -// through the public `PrefetchedSectorSource` API and assert on the sector -// count of the batch it actually emits. +// ── Batch count arithmetic: regression for a u16 truncation bug where +// `(remaining as u16).min(batch_sectors)` truncated to 0 when remaining was a +// multiple of 65536. Drives the real producer via the public API, not a local copy. /// Endless zero-filled source: every read succeeds with the full requested /// span, so the producer's batch size is the only thing the returned byte @@ -458,10 +449,9 @@ fn batch_count_exact_boundary() { #[test] fn batch_count_u16_overflow_regression() { - // THE BUG: remaining is a multiple of 65536 → `remaining as u16` is 0, so - // the batch collapses (the unit-alignment clamp below it then floors the - // batch at one 3-sector AACS unit — a 20x throughput cliff on exactly the - // disc sizes that hit it, and an outright stall before that clamp existed). + // THE BUG: remaining a multiple of 65536 → `remaining as u16` is 0, so the + // batch collapses (a 20x throughput cliff post-clamp, an outright stall + // before the unit-alignment clamp existed). let remaining: u32 = 47533152 - 19552; // = 47513600 = 725 * 65536 assert_eq!(remaining, 47513600); assert_eq!( @@ -572,12 +562,9 @@ fn scan_encrypted_resolves_no_keys() { #[test] fn aacs_dir_alone_marks_the_disc_encrypted_and_reports_the_capture_error() { - // Encryption detection is an OR over the two on-disc AACS locations: - // `/AACS` (Blu-ray / UHD, ECMA-167 root) and `/BDMV/AACS` (the BDMV-nested - // variant). This fixture carries ONLY `/AACS`, the standard retail layout, - // so a detector that required BOTH would call a genuinely encrypted disc - // clear — the worst possible failure here, because a "clear" disc is muxed - // straight through and ships ciphertext as if it were video, at exit 0. + // Detection is an OR over `/AACS` and `/BDMV/AACS`. This fixture carries + // only `/AACS` (the standard retail layout); requiring BOTH would call it + // clear — the worst failure, since a "clear" disc muxes ciphertext as video. let mut reader = MockSectorReader::new(); build_udf_with_aacs_dir(&mut reader); @@ -587,10 +574,9 @@ fn aacs_dir_alone_marks_the_disc_encrypted_and_reports_the_capture_error() { disc.encrypted, "a disc carrying /AACS is encrypted even though /BDMV/AACS is absent" ); - // Encrypted => the scan attempts the (lookup-free) AACS input capture. This - // fixture's /AACS is empty, so that capture fails and the failure must be - // PRESERVED on the disc: callers render it, and its absence is what a scan - // that never attempted the capture at all would look like. + // Encrypted => scan attempts the AACS input capture. This fixture's /AACS + // is empty, so capture fails and the failure must be PRESERVED on the disc + // (its absence would look identical to never having attempted capture). assert!( disc.aacs_error.is_some(), "the failed AACS capture on an encrypted disc must be surfaced, not dropped" diff --git a/tests/fvi_pipeline.rs b/tests/fvi_pipeline.rs index 8b5baa0c..6f91331f 100644 --- a/tests/fvi_pipeline.rs +++ b/tests/fvi_pipeline.rs @@ -294,11 +294,9 @@ fn fvi_sink_indexes_real_mpeg2_pipeline_output() { vec![0, 3], "stamped src sectors must reach the .fvi in arrival order; got {src_sectors:?}" ); - // Per FVI_FORMAT.md §9, `src.byte` is the offset of the AU's first byte - // WITHIN its 2048-byte `src.sector`, so it is always < 2048. The two - // stamped sources sit 14 bytes into their sector (just past the pack - // header), so the within-sector byte is exact (14) — provenance is - // byte-exact, carried unchanged from demux. + // Per FVI_FORMAT.md §9, `src.byte` is the AU's offset WITHIN its 2048-byte + // `src.sector` (always < 2048). These sources sit 14 bytes into their + // sector (past the pack header), so byte must be exactly 14. for r in &records { if let Some(byte) = r["src"]["byte"].as_u64() { assert!( diff --git a/tests/pass_n_patch_fix.rs b/tests/pass_n_patch_fix.rs index 4867fa10..dc677978 100644 --- a/tests/pass_n_patch_fix.rs +++ b/tests/pass_n_patch_fix.rs @@ -25,22 +25,9 @@ fn decrypt_sectors_with_none_keys_is_noop() { /// Test: decrypt_sectors with CSS keys descrambles sectors. #[test] fn css_decrypt_of_an_uncrackable_sector_still_descrambles() { - // A scrambled sector whose header is uniformly periodic yields a crib, so - // the supplied key IS checked — and this arbitrary key is not the right - // one, so the crib check rejects it and the re-crack from this synthetic - // body finds nothing. - // - // That combination does NOT fail the rip. `attack_crib` is a heuristic: it - // predicts that a periodic header run continues past 0x80, and when that - // prediction does not hold it reports a mismatch even for a CORRECT key — - // whereupon the re-crack fails because the crib was never valid. Crib - // mismatch plus crack failure is the signature of a crib false positive, - // and the cached key stays the best available evidence. - // - // This test previously asserted DecryptFailed, matching a round-9 change - // that made real DVDs unrippable. CSS is not AACS: an AACS - // unit key either opens a unit or does not, whereas a CSS title key is - // recovered from data whose recoverability varies sector by sector. + // Wrong key: the header crib rejects it and re-crack finds nothing, but that + // is not a rip failure — `attack_crib` is a heuristic, so this mismatch just + // means the cached key stands (CSS recoverability varies per sector, unlike AACS). let mut sector = vec![0xFFu8; 2048]; // Scrambled DVD sectors are MPEG-2 PS packs. The descramble policy requires // the pack start code as well as the flag bits, because byte 0x14 means diff --git a/tests/scan_iso.rs b/tests/scan_iso.rs index 2ccacff0..ae5da88c 100644 --- a/tests/scan_iso.rs +++ b/tests/scan_iso.rs @@ -145,11 +145,9 @@ fn scan_iso_matches_manual_scan_image_path() { assert_eq!(disc.encrypted, manual.encrypted, "encrypted flag"); assert_eq!(disc.format, manual.format, "disc format"); - // Independent expectations (not parity against a re-run of the same - // composition): the scanned Disc must match KNOWN properties of the fixture - // itself — its capacity (max LBA + 1), its PVD volume id ("TEST_DISC"), and - // that a UDF with no /AACS directory is unencrypted. These would fail even if - // scan_iso and the manual path drifted together. + // Independent expectations, not parity against a re-run: the scanned Disc + // must match KNOWN fixture properties (capacity, PVD volume id, unencrypted + // with no /AACS) — these would fail even if both scan paths drifted together. assert_eq!(disc.capacity_sectors, expected_capacity, "capacity value"); assert_eq!( disc.volume_id, "TEST_DISC", diff --git a/tests/scsi_error_decoding.rs b/tests/scsi_error_decoding.rs index 3ab36fe9..6a5b1073 100644 --- a/tests/scsi_error_decoding.rs +++ b/tests/scsi_error_decoding.rs @@ -338,10 +338,8 @@ fn test_healthy_short_transfer_reports_partial_bytes() { } // ── 13. Error::Display does not leak English in the SCSI variant ────────── -// -// Library rule (project docs): no English in error display, only "E{code}" -// + structured data. Regression guard for the SCSI variant specifically -// because it's the most-emitted error in the rip path. +// Library rule: no English in error display, only "E{code}" + structured data. +// Guards the SCSI variant since it's the most-emitted error in the rip path. #[test] fn test_scsi_error_display_format_is_codes_only() { diff --git a/tests/streams.rs b/tests/streams.rs index bd629a26..38ced83c 100644 --- a/tests/streams.rs +++ b/tests/streams.rs @@ -629,9 +629,8 @@ fn mkvstream_meta_sets_title() { #[test] fn mkvstream_roundtrip_bdts() { // Write BD-TS packets through MkvStream and verify the pipeline works. - // Without real codec headers (SPS/PPS), the muxer stays in scanning phase. - // With a title that has no video streams (audio-only), codec scanning is - // skipped and the muxer enters streaming mode immediately, producing EBML output. + // Without real codec headers (SPS/PPS) the muxer would stay in scanning + // phase, but an audio-only title skips codec scanning and streams EBML output immediately. let dt = DiscTitle { playlist: "Audio Only".into(), diff --git a/tests/udf_tests.rs b/tests/udf_tests.rs index db05b364..f3f4acfa 100644 --- a/tests/udf_tests.rs +++ b/tests/udf_tests.rs @@ -352,14 +352,9 @@ fn read_filesystem_with_subdirectory() { let mut reader = MockSectorReader::new(); let partition_start: u32 = 512; - // Layout (all relative to partition_start which equals metadata_start): - // meta LBA 0 = FSD - // meta LBA 1 = root ICB - // meta LBA 2 = root dir data - // meta LBA 3 = BDMV ICB - // meta LBA 4 = BDMV dir data - // meta LBA 5 = test.mpls file ICB - // meta LBA 10 = test.mpls file data (partition-relative) + // Layout, meta LBAs relative to partition_start (== metadata_start): 0=FSD, + // 1=root ICB, 2=root dir data, 3=BDMV ICB, 4=BDMV dir data, 5=test.mpls + // ICB, 10=test.mpls file data. reader.set_sector(256, make_avdp_sector(32)); reader.set_sector(32, make_pvd_sector("DISC_WITH_BDMV"));