From decb87a250e2e3c2540cae26d75b9a15a17631ab Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:19:24 -0700 Subject: [PATCH] AACS pipeline reshape + TrueHD metadata + central consts + clippy/fmt clean - AACS: delete in-lib keydb parser (Step 3); boil-down primitives (mk_from_dk/vuk_from_mk/uk_from_vuk) + newtypes; KeySource->get_uk(ctx)+ ResolveCtx; Unlocker->unlock()->Result + AacsCertUnlocker; OEM bus-key gate (AacsBusKeyUnavailable); structured ResolutionTrace (Step 4). - TrueHD: sample-rate from major-sync, Atmos label, 44.1k AU duration. - consts: central media/format constants module; 17 duplicate const-defs centralized (sector/TS-packet/source-packet); mpls stream-entry + category codes named. - clippy --all-targets -D warnings clean (1.86); fmt clean; 2199 lib tests. --- src/aacs/boil.rs | 200 ++++++ src/aacs/decrypt.rs | 46 +- src/aacs/handshake.rs | 49 +- src/aacs/keydb.rs | 1011 ------------------------------ src/aacs/keys.rs | 414 ++++-------- src/aacs/mod.rs | 11 +- src/aacs/provider.rs | 2 +- src/aacs/trace.rs | 144 +++++ src/aacs/types.rs | 45 ++ src/aacs/variants.rs | 2 +- src/clpi.rs | 40 +- src/consts.rs | 33 + src/css/stevenson.rs | 39 +- src/diag.rs | 2 + src/disc/bluray.rs | 4 +- src/disc/encrypt.rs | 546 ++++++++++------ src/disc/extract.rs | 14 +- src/disc/mod.rs | 82 ++- src/disc/patch.rs | 2 +- src/drive/mod.rs | 113 +++- src/error.rs | 13 + src/ifo.rs | 24 +- src/io/file_sector_source/mod.rs | 46 +- src/keysource.rs | 432 +++++++++---- src/labels/bdmt.rs | 2 +- src/labels/ctrm.rs | 5 + src/labels/jar.rs | 2 +- src/labels/mod.rs | 37 +- src/labels/pixelogic.rs | 4 +- src/lib.rs | 3 +- src/mpls.rs | 60 +- src/mux/codec/mpeg2.rs | 10 +- src/mux/codec/truehd.rs | 309 ++++++++- src/mux/codec/vc1.rs | 12 +- src/mux/demux_thread.rs | 6 +- src/mux/m2ts_mux/packet.rs | 18 +- src/mux/meta.rs | 23 +- src/mux/mkv.rs | 2 +- src/mux/mkvstream.rs | 10 +- src/mux/ts.rs | 86 +-- src/mux/tsmux.rs | 30 +- src/mux/videomap.rs | 2 +- src/scsi/macos.rs | 36 +- src/scsi/mod.rs | 4 +- src/sector/mod.rs | 11 +- src/sector/prefetched.rs | 2 +- src/unlock.rs | 246 ++++---- tests/passn_handler_ab.rs | 5 +- 48 files changed, 2208 insertions(+), 2031 deletions(-) create mode 100644 src/aacs/boil.rs delete mode 100644 src/aacs/keydb.rs create mode 100644 src/aacs/trace.rs create mode 100644 src/aacs/types.rs create mode 100644 src/consts.rs diff --git a/src/aacs/boil.rs b/src/aacs/boil.rs new file mode 100644 index 0000000..27222fe --- /dev/null +++ b/src/aacs/boil.rs @@ -0,0 +1,200 @@ +//! AACS derivation "boil-down" — one public home for the key chain. +//! +//! Thin newtypes at the API boundary and three wrapper functions over the +//! existing crypto. Nothing here re-implements a primitive: every function +//! delegates to the already-audited code in [`super::keys`] and +//! [`super::variants`], so the boil-down cannot drift from production math. +//! +//! The newtypes wrap bare `[u8; 16]` ONLY at this boundary — the crypto +//! internals continue to operate on raw arrays. They exist so a caller threads +//! the chain `DK → MK → VUK → UK` without confusing one 16-byte secret for +//! another, not to refactor the resolver. +//! +//! Chain (matches `aacs::keys::resolve_keys_classical` path 1 and +//! `aacs::keys::resolve_keys_v21` path 1 byte-for-byte): +//! +//! ```text +//! mk_from_dk(device_keys, mkb, vid) → MediaKey (Km) +//! vuk_from_mk(MediaKey, Vid) → Vuk (= AES-G(Km, VID)) +//! uk_from_vuk(Vuk, enc_title_keys) → [UnitKey] (decrypt_unit_key each) +//! ``` + +use super::keys::{decrypt_unit_key, derive_vuk}; +use super::types::DeviceKey; +use super::variants::{KEY_CORRECTION_DATA_PLACEHOLDER, derive_media_key_variant, walk_mkb}; + +/// Volume ID (16 bytes) — read from the disc via the SCSI handshake / OEM path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Vid(pub [u8; 16]); + +/// Media Key (Km, 16 bytes) — the MKB-scoped key derived from device keys. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MediaKey(pub [u8; 16]); + +/// Volume Unique Key (VUK / Kvu, 16 bytes) — derived from `MediaKey` + `Vid`, +/// decrypts the per-disc encrypted title keys in `Unit_Key_RO.inf`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Vuk(pub [u8; 16]); + +/// One decrypted per-CPS-unit AACS title key. +/// +/// `idx` is the POSITIONAL index of the encrypted title key within the slice +/// handed to [`uk_from_vuk`] (i.e. its order in `Unit_Key_RO.inf`'s key-storage +/// area). The CPS-unit *number* association (the `u32` in +/// `ResolvedKeys::unit_keys`) is a higher-level concern owned by +/// [`super::keys::parse_unit_key_ro`], which pairs each positional key with its +/// declared CPS unit; this primitive only does the AES, so it surfaces position. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UnitKey { + pub idx: u32, + pub key: [u8; 16], +} + +/// Derive the Volume Unique Key from a Media Key and Volume ID. +/// +/// Wraps [`derive_vuk`] verbatim: `VUK = AES-128-ECB-DECRYPT(MK, VID) XOR VID`. +/// This is byte-identical to the inline `derive_vuk(&mk, ctx.volume_id)` call in +/// every classical resolver path AND to the `Kvu = AES-G(Km, VID)` step inside +/// [`derive_media_key_variant`] (AES-G and `derive_vuk` are the same math), so +/// `vuk_from_mk(mk_from_dk(..)?, vid)` reproduces the V21 variant VUK exactly. +pub fn vuk_from_mk(mk: MediaKey, vid: Vid) -> Vuk { + Vuk(derive_vuk(&mk.0, &vid.0)) +} + +/// Decrypt the disc's encrypted title keys with a VUK. +/// +/// Wraps [`decrypt_unit_key`] (AES-128-ECB-DECRYPT) per entry, mirroring the +/// `derive_uks` closure in `resolve_keys_classical` / `resolve_keys_v21`. The +/// returned `UnitKey::idx` is the slice position; pair with CPS-unit numbers via +/// [`super::keys::parse_unit_key_ro`] when the numbering matters. +pub fn uk_from_vuk(vuk: Vuk, enc_title_keys: &[[u8; 16]]) -> Vec { + enc_title_keys + .iter() + .enumerate() + .map(|(i, enc)| UnitKey { + idx: i as u32, + key: decrypt_unit_key(&vuk.0, enc), + }) + .collect() +} + +/// Derive the Media Key (Km) from device keys via the Media Key Variant chain. +/// +/// Wraps [`walk_mkb`] + [`derive_media_key_variant`] with exactly the arguments +/// `resolve_keys_v21` path 1 passes: the placeholder Key Correction Data and the +/// disc Volume ID. Returns the FIRST tuple element `Km` (the Media Key) — the +/// resolver treats `Km` as the media key and derives the VUK from it as +/// `Kvu = AES-G(Km, VID)`, which equals [`vuk_from_mk`]`(MediaKey(km), vid)`. The +/// variant fn's second element is that already-derived `Kvu`; returning `Km` +/// keeps this primitive at the "media key" level so the chain composes. +/// +/// Because the integrator KCD is unavailable in-tree (the placeholder is +/// rejected by the variant chain), this returns `Err` for every real disc today +/// — byte-for-byte identical to `resolve_keys_v21` path 1, which the resolver +/// also leaves unreachable in production. All variant-chain failures collapse to +/// [`Error::AacsMkUnavailable`] (E7018): no numeric distinction is load-bearing +/// at this boundary, and the variant error carries no English to preserve. +pub fn mk_from_dk( + device_keys: &[DeviceKey], + mkb: &[u8], + vid: Vid, +) -> Result { + let records = walk_mkb(mkb); + match derive_media_key_variant( + &records, + device_keys, + &KEY_CORRECTION_DATA_PLACEHOLDER, + &vid.0, + ) { + Ok((km, _kvu)) => Ok(MediaKey(km)), + Err(_) => Err(crate::error::Error::AacsMkUnavailable), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::aacs::decrypt::aes_ecb_encrypt; + use crate::aacs::keys::{decrypt_unit_key, derive_vuk}; + + /// `vuk_from_mk` must equal the inline `derive_vuk` path bit-for-bit, for + /// several known (MK, VID) vectors. + #[test] + fn vuk_from_mk_matches_inline_derive_vuk() { + let cases: [([u8; 16], [u8; 16]); 3] = [ + ([0x5A; 16], [0xA5; 16]), + ([0x11; 16], [0x22; 16]), + ( + [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, + 0x0D, 0x0E, 0x0F, + ], + [ + 0xF0, 0xE1, 0xD2, 0xC3, 0xB4, 0xA5, 0x96, 0x87, 0x78, 0x69, 0x5A, 0x4B, 0x3C, + 0x2D, 0x1E, 0x0F, + ], + ), + ]; + for (mk, vid) in cases { + let inline = derive_vuk(&mk, &vid); + let boiled = vuk_from_mk(MediaKey(mk), Vid(vid)); + assert_eq!(boiled.0, inline, "vuk_from_mk must equal derive_vuk"); + } + } + + /// `uk_from_vuk` must equal the inline `decrypt_unit_key` path bit-for-bit + /// and carry positional indices 0..n. Built by encrypting known plaintext + /// title keys under the VUK (the same primitive the resolver inverts). + #[test] + fn uk_from_vuk_matches_inline_decrypt_unit_key() { + let vuk = [0x5Au8; 16]; + let plain_keys = [[0x11u8; 16], [0x22u8; 16], [0xCDu8; 16]]; + let enc: Vec<[u8; 16]> = plain_keys + .iter() + .map(|k| aes_ecb_encrypt(&vuk, k)) + .collect(); + + let boiled = uk_from_vuk(Vuk(vuk), &enc); + assert_eq!(boiled.len(), enc.len()); + for (i, uk) in boiled.iter().enumerate() { + assert_eq!(uk.idx, i as u32, "idx must be the positional index"); + // Matches the inline derive_uks closure: decrypt_unit_key(vuk, enc). + assert_eq!(uk.key, decrypt_unit_key(&vuk, &enc[i])); + // And recovers the original plaintext title key. + assert_eq!( + uk.key, plain_keys[i], + "VUK roundtrip recovers the title key" + ); + } + } + + /// `uk_from_vuk` on an empty slice yields no keys (no panic, no phantom idx). + #[test] + fn uk_from_vuk_empty_is_empty() { + assert!(uk_from_vuk(Vuk([0u8; 16]), &[]).is_empty()); + } + + /// `mk_from_dk` returns `Err(AacsMkUnavailable)` for the placeholder-KCD + /// path that production also leaves unreachable — never a wrong key, never a + /// panic — on both an empty MKB and a non-variant MKB. + #[test] + fn mk_from_dk_errors_without_integrator_kcd() { + let dk = DeviceKey { + key: [0x11; 16], + node: 1, + uv: 1, + u_mask_shift: 0, + }; + // Empty MKB → not a variant MKB → Err. + let e = mk_from_dk(std::slice::from_ref(&dk), &[], Vid([0x09; 16])); + assert!(matches!(e, Err(crate::error::Error::AacsMkUnavailable))); + + // A variant-looking MKB (0x82 record) still cannot complete without the + // integrator KCD, so it also errors — never silently yields a key. + let mut mkb: Vec = Vec::new(); + mkb.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]); // variant data record + mkb.extend_from_slice(&[0xAB; 16]); + let e2 = mk_from_dk(&[dk], &mkb, Vid([0x09; 16])); + assert!(matches!(e2, Err(crate::error::Error::AacsMkUnavailable))); + } +} diff --git a/src/aacs/decrypt.rs b/src/aacs/decrypt.rs index 4917207..7b6eeae 100644 --- a/src/aacs/decrypt.rs +++ b/src/aacs/decrypt.rs @@ -14,7 +14,7 @@ pub(crate) const AACS_IV: [u8; 16] = [ pub const ALIGNED_UNIT_LEN: usize = 6144; /// An AACS aligned unit spans this many 2048-byte sectors (3). -pub const ALIGNED_UNIT_SECTORS: u32 = (ALIGNED_UNIT_LEN / SECTOR_LEN) as u32; +pub const ALIGNED_UNIT_SECTORS: u32 = (ALIGNED_UNIT_LEN / SECTOR_BYTES) as u32; /// Whether `lba` sits on an AACS aligned-unit boundary, measured **relative to /// the encrypted region's base LBA** (`unit_base` = the clip/extent `start_lba`, @@ -40,11 +40,9 @@ pub fn is_unit_aligned(lba: u32, unit_base: u32) -> bool { lba.saturating_sub(unit_base) % ALIGNED_UNIT_SECTORS == 0 } -/// Size of one sector. -const SECTOR_LEN: usize = 2048; +use crate::consts::SECTOR_BYTES; -/// Transport stream packet spacing in Blu-ray m2ts (192 bytes = 4 TP_extra + 188 TS). -const TS_PACKET_LEN: usize = 192; +use crate::consts::BD_SOURCE_PACKET_BYTES; /// TS sync byte. const TS_SYNC: u8 = 0x47; @@ -133,7 +131,7 @@ pub fn ts_sync_count(unit: &[u8]) -> usize { if unit[offset] == TS_SYNC { count += 1; } - offset += TS_PACKET_LEN; + offset += BD_SOURCE_PACKET_BYTES; } count } @@ -141,9 +139,9 @@ pub fn ts_sync_count(unit: &[u8]) -> usize { /// Number of BD-TS packets in the unit — the maximum possible sync count. pub fn ts_packet_total(unit: &[u8]) -> usize { // One sync byte per 192-byte BD-TS packet (at offset 4 of each). The old - // `(len - 4) / TS_PACKET_LEN + 1` over-counted by one for lengths of the + // `(len - 4) / BD_SOURCE_PACKET_BYTES + 1` over-counted by one for lengths of the // form `4 + k·192`. - unit.len() / TS_PACKET_LEN + unit.len() / BD_SOURCE_PACKET_BYTES } fn ts_syncs_intact(unit: &[u8]) -> bool { @@ -298,14 +296,14 @@ pub fn decrypt_unit_try_keys(unit: &mut [u8], unit_keys: &[[u8; 16]]) -> Option< /// Remove bus encryption from an aligned unit (AACS 2.0 / UHD). /// Bus encryption uses read_data_key, decrypting bytes 16..2047 of each 2048-byte sector. pub fn decrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) { - for sector_start in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_LEN) { - if sector_start + SECTOR_LEN > unit.len() { + for sector_start in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) { + if sector_start + SECTOR_BYTES > unit.len() { break; } // First 16 bytes of each sector are plaintext aes_cbc_decrypt( read_data_key, - &mut unit[sector_start + 16..sector_start + SECTOR_LEN], + &mut unit[sector_start + 16..sector_start + SECTOR_BYTES], ); } } @@ -385,7 +383,7 @@ mod tests { let mut off = 4; while off < ALIGNED_UNIT_LEN { unit[off] = TS_SYNC; - off += TS_PACKET_LEN; + off += BD_SOURCE_PACKET_BYTES; } let key = [0u8; 16]; assert!(!is_aacs_scrambled(&unit)); @@ -401,7 +399,9 @@ mod tests { let unit = vec![0u8; ALIGNED_UNIT_LEN]; assert_eq!(ts_packet_total(&unit), 32); // Confirm the loop visits exactly that many stride positions. - let visited = (4..ALIGNED_UNIT_LEN).step_by(TS_PACKET_LEN).count(); + let visited = (4..ALIGNED_UNIT_LEN) + .step_by(BD_SOURCE_PACKET_BYTES) + .count(); assert_eq!(visited, ts_packet_total(&unit)); } @@ -419,7 +419,7 @@ mod tests { let mut placed = 0; while off < ALIGNED_UNIT_LEN && placed < n { unit[off] = TS_SYNC; - off += TS_PACKET_LEN; + off += BD_SOURCE_PACKET_BYTES; placed += 1; } unit @@ -443,7 +443,7 @@ mod tests { let mut off = 4; while off < ALIGNED_UNIT_LEN { clear[off] = TS_SYNC; - off += TS_PACKET_LEN; + off += BD_SOURCE_PACKET_BYTES; } assert_eq!(ts_sync_count(&clear), 32); assert!( @@ -500,7 +500,7 @@ mod tests { let mut offset = 4; while offset < ALIGNED_UNIT_LEN { plain[offset] = TS_SYNC; - offset += TS_PACKET_LEN; + offset += BD_SOURCE_PACKET_BYTES; } // No flag set: CBC-encrypting the body below scrambles packets 1..31's // TS syncs, which is exactly what `is_aacs_scrambled` (raw-sync) detects. @@ -541,7 +541,7 @@ mod tests { if unit[off] == TS_SYNC { count += 1; } - off += TS_PACKET_LEN; + off += BD_SOURCE_PACKET_BYTES; } // Assert against the single canonical packet count, not the old // `(len - 4) / 192 + 1` form that `ts_packet_total` corrected away from. @@ -584,7 +584,7 @@ mod tests { let mut off = 4; while off < ALIGNED_UNIT_LEN { unit[off] = TS_SYNC; - off += TS_PACKET_LEN; + off += BD_SOURCE_PACKET_BYTES; } unit } @@ -877,10 +877,10 @@ mod tests { // Forward: CBC-encrypt unit[s+16 .. s+2048] per sector under AACS IV. let cipher = Aes128::new(GenericArray::from_slice(&rdk)); - for s in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_LEN) { + for s in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) { let mut prev = AACS_IV; let body = s + 16; - let end = s + SECTOR_LEN; + let end = s + SECTOR_BYTES; let nblocks = (end - body) / 16; for i in 0..nblocks { let off = body + i * 16; @@ -901,7 +901,7 @@ mod tests { "decrypt_bus must invert per-sector bus encrypt" ); // Each sector's first 16 bytes equal the original (never touched). - for s in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_LEN) { + for s in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) { assert_eq!(&unit[s..s + 16], &plain[s..s + 16]); } } @@ -934,9 +934,9 @@ mod tests { aacs_encrypt_unit(&mut unit, &unit_key); // Layer 2: bus-encrypt on top (per-sector, bytes 16..2048). let cipher = Aes128::new(GenericArray::from_slice(&rdk)); - for s in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_LEN) { + for s in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) { let mut prev = AACS_IV; - for i in 0..((SECTOR_LEN - 16) / 16) { + for i in 0..((SECTOR_BYTES - 16) / 16) { let off = s + 16 + i * 16; for j in 0..16 { unit[off + j] ^= prev[j]; diff --git a/src/aacs/handshake.rs b/src/aacs/handshake.rs index b1aa414..011573f 100644 --- a/src/aacs/handshake.rs +++ b/src/aacs/handshake.rs @@ -1573,27 +1573,44 @@ mod tests { #[test] fn test_verify_host_cert_from_keydb() { - // Verify the host cert from our KEYDB - let keydb_path = match std::env::var("KEYDB_PATH").ok() { + // Exercise verify_cert against a real AACS 1.0 host certificate. + // + // libfreemkv no longer parses keydb.cfg (the parser lives in + // freemkv-keysources), so the cert bytes are read from a raw 92-byte + // certificate file named by HOST_CERT_PATH instead of being pulled + // from a parsed KeyDb. This keeps verify_cert (private to this module, + // so it cannot move to keysources) covered against genuine LA-signed + // bytes without re-introducing a keydb dependency here. Inert in CI + // (env unset), matching the prior KEYDB_PATH gating. + let cert_path = match std::env::var("HOST_CERT_PATH").ok() { Some(p) => std::path::PathBuf::from(p), - None => return, // skip if KEYDB_PATH not set + None => return, }; - if !keydb_path.exists() { + if !cert_path.exists() { return; } + let certificate = match std::fs::read(&cert_path) { + Ok(b) => b, + Err(_) => return, + }; - let db = crate::aacs::KeyDb::load(&keydb_path).unwrap(); - if let Some(hc) = db.host_certs.first() { - let valid = verify_cert(&hc.certificate); - eprintln!( - "Host cert verification: {}", - if valid { "PASS" } else { "FAIL" } - ); - // Note: our cert is revoked but should still have valid LA signature - // If it doesn't verify, the LA public key might be wrong - if !valid { - eprintln!(" (cert may use different LA key or format)"); - } + // Direct HostCert construction — no parser. Only `certificate` feeds + // verify_cert; the other fields are inert placeholders. + let hc = crate::aacs::HostCert { + private_key: [0u8; 20], + certificate, + private_key_v2: None, + certificate_v2: None, + }; + let valid = verify_cert(&hc.certificate); + eprintln!( + "Host cert verification: {}", + if valid { "PASS" } else { "FAIL" } + ); + // Note: a revoked cert should still carry a valid LA signature. + // If it doesn't verify, the LA public key might be wrong. + if !valid { + eprintln!(" (cert may use different LA key or format)"); } } diff --git a/src/aacs/keydb.rs b/src/aacs/keydb.rs deleted file mode 100644 index b21874d..0000000 --- a/src/aacs/keydb.rs +++ /dev/null @@ -1,1011 +0,0 @@ -//! AACS Key Database parsing — KEYDB.cfg format. - -use std::collections::HashMap; - -/// Upper bound on the on-disk keydb.cfg size accepted by [`KeyDb::load`]. -/// The real public UHD keydb is a few MiB; 64 MiB is generous headroom while -/// still bounding the worst-case allocation from a hostile/corrupt file. -const MAX_KEYDB_BYTES: u64 = 64 * 1024 * 1024; - -/// Upper bound on parsed disc entries. The real public keydb carries -/// ~170k+ entries, so the cap sits well above that while still bounding -/// memory against a pathological input. Surplus lines are ignored. -const MAX_DISC_ENTRIES: usize = 500_000; - -/// Parsed AACS key database. -#[derive(Debug)] -pub struct KeyDb { - /// Device keys for MKB processing - pub device_keys: Vec, - /// Processing keys (pre-computed media keys for specific MKB versions) - pub processing_keys: Vec<[u8; 16]>, - /// Host certificate + private key for SCSI authentication - pub host_certs: Vec, - /// Per-disc VUK entries indexed by disc hash (hex lowercase) - pub disc_entries: HashMap, -} - -/// A device key for MKB subset-difference tree processing. -#[derive(Debug, Clone)] -pub struct DeviceKey { - pub key: [u8; 16], - pub node: u16, - pub uv: u32, - pub u_mask_shift: u8, -} - -/// Host certificate + private key for AACS SCSI authentication. -#[derive(Debug, Clone)] -pub struct HostCert { - /// AACS 1.0: 20 bytes. AACS 2.0: 32 bytes. - pub private_key: [u8; 20], - /// AACS 1.0: 92 bytes. AACS 2.0: 132 bytes. - pub certificate: Vec, - /// AACS 2.0 host private key (P-256, 32 bytes). None for AACS 1.0 only. - pub private_key_v2: Option<[u8; 32]>, - /// AACS 2.0 host certificate (type 0x11). None for AACS 1.0 only. - pub certificate_v2: Option>, -} - -/// A per-disc entry from the key database. -#[derive(Debug, Clone)] -pub struct DiscEntry { - /// Disc hash (20 bytes, hex) - pub disc_hash: String, - /// Disc title - pub title: String, - /// Media Key (16 bytes) — from MKB processing - pub media_key: Option<[u8; 16]>, - /// Disc ID (16 bytes) - pub disc_id: Option<[u8; 16]>, - /// Volume Unique Key (16 bytes) — decrypts title keys - pub vuk: Option<[u8; 16]>, - /// Unit keys (title keys) indexed by CPS unit number - pub unit_keys: Vec<(u32, [u8; 16])>, -} - -/// Parse a hex string like "0xABCD..." into bytes. -/// -/// Operates on bytes, not `&str` char boundaries: the keydb is -/// third-party content, so a non-ASCII scalar (e.g. a 4-byte UTF-8 -/// codepoint) must not panic on a mid-codepoint slice. Any non-hex -/// byte yields `None`. -pub(crate) fn parse_hex(s: &str) -> Option> { - let s = s.trim().trim_start_matches("0x").trim_start_matches("0X"); - let bytes = s.as_bytes(); - if bytes.len() % 2 != 0 { - return None; - } - let mut out = Vec::with_capacity(bytes.len() / 2); - for pair in bytes.chunks_exact(2) { - let hi = (pair[0] as char).to_digit(16)?; - let lo = (pair[1] as char).to_digit(16)?; - out.push((hi * 16 + lo) as u8); - } - Some(out) -} - -/// Parse hex into a fixed-size array. -pub(crate) fn parse_hex16(s: &str) -> Option<[u8; 16]> { - let v = parse_hex(s)?; - if v.len() != 16 { - return None; - } - let mut out = [0u8; 16]; - out.copy_from_slice(&v); - Some(out) -} - -pub(crate) fn parse_hex20(s: &str) -> Option<[u8; 20]> { - let v = parse_hex(s)?; - if v.len() != 20 { - return None; - } - let mut out = [0u8; 20]; - out.copy_from_slice(&v); - Some(out) -} - -impl KeyDb { - /// Construct an empty KeyDb. Used by unit tests; production code - /// reaches a populated KeyDb via [`KeyDb::load`] or [`KeyDb::parse`]. - pub fn empty() -> Self { - KeyDb { - device_keys: Vec::new(), - processing_keys: Vec::new(), - host_certs: Vec::new(), - disc_entries: HashMap::new(), - } - } - - /// Parse a KEYDB.cfg file from a string. - pub fn parse(data: &str) -> Self { - let mut db = KeyDb { - device_keys: Vec::new(), - processing_keys: Vec::new(), - host_certs: Vec::new(), - disc_entries: HashMap::new(), - }; - - for line in data.lines() { - let line = line.trim(); - - // Skip comments and empty lines - if line.is_empty() || line.starts_with(';') || line.starts_with('#') { - continue; - } - - // Device Key. - // Two shapes are accepted: - // 1. Positioned DK: `| DK | DEVICE_KEY 0x... | DEVICE_NODE 0x... | KEY_UV 0x... | KEY_U_MASK_SHIFT 0x...` - // → loaded into `device_keys` (deterministic tree walk via `calc_pk_from_dk`). - // 2. Orphan DK: `| DK | DEVICE_KEY 0x...` with no position fields. - // → loaded into `processing_keys` (brute walker / terminal validation). - // Per AACS spec a "PK" IS a DK at terminal position, so both row types - // are DKs in the unified model; only the metadata differs. - if line.starts_with("| DK") { - if let Some(dk) = Self::parse_device_key(line) { - db.device_keys.push(dk); - } else if let Some(key) = Self::parse_orphan_dk(line) { - db.processing_keys.push(key); - } - continue; - } - - // Processing Key - if line.starts_with("| PK") { - if let Some(pk) = Self::parse_processing_key(line) { - db.processing_keys.push(pk); - } - continue; - } - - // Host Certificate (AACS 2.0). - // - // An HC2 row normally augments the preceding HC (AACS 1.0) row. - // KEYDB line ordering is third-party, so an HC2 row may appear - // before any HC row; rather than silently dropping the AACS 2.0 - // credentials, carry them on a fresh HostCert with an empty v1 - // cert (the v1 private_key/certificate stay zero/empty and are - // ignored by the v1 handshake, which guards on cert length). - if line.starts_with("| HC2") { - if let Some((pk, cert)) = Self::parse_host_cert_v2(line) { - if let Some(hc) = db.host_certs.last_mut() { - hc.private_key_v2 = Some(pk); - hc.certificate_v2 = Some(cert); - } else { - db.host_certs.push(HostCert { - private_key: [0u8; 20], - certificate: Vec::new(), - private_key_v2: Some(pk), - certificate_v2: Some(cert), - }); - } - } - continue; - } - - // Host Certificate (AACS 1.0) - if line.starts_with("| HC") { - if let Some(hc) = Self::parse_host_cert(line) { - db.host_certs.push(hc); - } - continue; - } - - // Disc entry: starts with 0x - if line.starts_with("0x") && line.contains(" = ") { - if db.disc_entries.len() >= MAX_DISC_ENTRIES { - continue; - } - if let Some(entry) = Self::parse_disc_entry(line) { - db.disc_entries.insert(entry.disc_hash.clone(), entry); - } - } - } - - db - } - - /// Load a KEYDB.cfg from disk. - /// - /// A read failure (missing/unreadable file, non-UTF-8 content) surfaces - /// as [`crate::error::Error::KeydbLoad`] carrying the path, per the - /// library contract that a missing/unparseable keydb is a structured - /// error and not a raw `io::Error`. Note that [`Self::parse`] itself is - /// lenient: a syntactically valid but key-less file parses to an empty - /// [`KeyDb`] rather than an error — callers needing a non-empty db must - /// check the parsed contents. - pub fn load(path: &std::path::Path) -> crate::error::Result { - // Stat-and-cap before reading so a hostile/corrupt file can't force an - // unbounded allocation. A file at or over the cap is rejected outright. - if let Ok(meta) = std::fs::metadata(path) { - if meta.len() > MAX_KEYDB_BYTES { - return Err(crate::error::Error::KeydbLoad { - path: path.display().to_string(), - }); - } - } - let data = std::fs::read_to_string(path).map_err(|_| crate::error::Error::KeydbLoad { - path: path.display().to_string(), - })?; - Ok(Self::parse(&data)) - } - - /// Look up a disc by its hash. Returns the VUK if found. - pub fn find_vuk(&self, disc_hash: &str) -> Option<[u8; 16]> { - let hash = disc_hash - .trim() - .to_lowercase() - .trim_start_matches("0x") - .to_string(); - // Try with 0x prefix and without - self.disc_entries - .get(&format!("0x{hash}")) - .or_else(|| self.disc_entries.get(&hash)) - .and_then(|e| e.vuk) - } - - /// Look up a disc by its hash. Returns the full entry. - pub fn find_disc(&self, disc_hash: &str) -> Option<&DiscEntry> { - let hash = disc_hash - .trim() - .to_lowercase() - .trim_start_matches("0x") - .to_string(); - self.disc_entries - .get(&format!("0x{hash}")) - .or_else(|| self.disc_entries.get(&hash)) - } - - /// Iterate every disc entry. Used by Path 3 (scan for matching VID). - pub fn iter_disc_entries(&self) -> impl Iterator { - self.disc_entries.values() - } -} - -// ── KeyProvider impl ────────────────────────────────────────────────────────── -// -// Lets `KeyDb` plug into `resolve_keys` via the trait. Cloning happens in the -// bulk methods because the trait returns owned `Vec`s (so HTTP-backed providers -// don't need to retain state across calls). - -impl super::provider::KeyProvider for KeyDb { - fn device_keys(&self) -> Vec { - self.device_keys.clone() - } - fn processing_keys(&self) -> Vec<[u8; 16]> { - self.processing_keys.clone() - } - fn media_keys(&self) -> Vec<[u8; 16]> { - // Every per-disc Media Key in the db. The resolver dedups; MKs are - // MKB-scoped so the same value recurs across a pressing's discs. - self.iter_disc_entries() - .filter_map(|e| e.media_key) - .collect() - } - fn host_certs(&self) -> Vec { - self.host_certs.clone() - } - fn lookup_disc_by_hash(&self, disc_hash: &[u8; 20]) -> Option { - use std::fmt::Write; - // Lowercase hex written straight into the pre-sized buffer: find_disc - // lowercases its input anyway, so emitting 'x' here avoids a wasted - // to_lowercase() round-trip, and write! avoids 20 temporary Strings. - let mut hex = String::with_capacity(42); - hex.push_str("0x"); - for b in disc_hash { - let _ = write!(hex, "{b:02x}"); - } - self.find_disc(&hex).cloned() - } - fn lookup_disc_by_vid(&self, volume_id: &[u8; 16]) -> Option { - self.iter_disc_entries() - .find(|e| matches!(e.disc_id, Some(id) if &id == volume_id)) - .cloned() - } -} - -// ── Private parsers (re-open the inherent impl) ───────────────────────────── - -impl KeyDb { - fn parse_device_key(line: &str) -> Option { - // | DK | DEVICE_KEY 0x... | DEVICE_NODE 0x... | KEY_UV 0x... | KEY_U_MASK_SHIFT 0x... - let key_str = line.split("DEVICE_KEY").nth(1)?.split('|').next()?.trim(); - let node_str = line.split("DEVICE_NODE").nth(1)?.split('|').next()?.trim(); - let uv_str = line.split("KEY_UV").nth(1)?.split('|').next()?.trim(); - let shift_str = line - .split("KEY_U_MASK_SHIFT") - .nth(1)? - .split(';') - .next()? - .split('|') - .next()? - .trim(); - - Some(DeviceKey { - key: parse_hex16(key_str)?, - node: u16::from_str_radix(node_str.trim_start_matches("0x"), 16).ok()?, - uv: u32::from_str_radix(uv_str.trim_start_matches("0x"), 16).ok()?, - u_mask_shift: u8::from_str_radix(shift_str.trim_start_matches("0x"), 16).ok()?, - }) - } - - fn parse_processing_key(line: &str) -> Option<[u8; 16]> { - // | PK | 0x... - let parts: Vec<&str> = line.split('|').collect(); - if parts.len() >= 3 { - let key_str = parts[2].split(';').next()?.trim(); - return parse_hex16(key_str); - } - None - } - - /// Parse an orphan DK row: a `| DK |` line carrying only the - /// `DEVICE_KEY` field (no position metadata). The key is then - /// treated like a terminal/unpositioned label by the resolver - /// (Path 2's brute walker). Returns `None` if the line carries - /// any position field — those are positioned DKs and parsed by - /// [`Self::parse_device_key`] instead. - fn parse_orphan_dk(line: &str) -> Option<[u8; 16]> { - if line.contains("DEVICE_NODE") - || line.contains("KEY_UV") - || line.contains("KEY_U_MASK_SHIFT") - { - return None; - } - let key_str = line - .split("DEVICE_KEY") - .nth(1)? - .split('|') - .next()? - .split(';') - .next()? - .trim(); - parse_hex16(key_str) - } - - fn parse_host_cert(line: &str) -> Option { - // | HC | HOST_PRIV_KEY 0x... | HOST_CERT 0x... - let priv_str = line - .split("HOST_PRIV_KEY") - .nth(1)? - .split('|') - .next()? - .trim(); - let cert_str = line - .split("HOST_CERT") - .nth(1)? - .split(';') - .next()? - .split('|') - .next()? - .trim(); - - let certificate = parse_hex(cert_str)?; - // AACS 1.0 host certs are 92 bytes; drop malformed/short rows at - // parse time so the handshake never attempts junk (mirrors the v2 - // path, which enforces >= 132). - if certificate.len() < 92 { - return None; - } - - Some(HostCert { - private_key: parse_hex20(priv_str)?, - certificate, - private_key_v2: None, - certificate_v2: None, - }) - } - - /// Parse AACS 2.0 host cert: `| HC2 | HOST_PRIV_KEY 0x... | HOST_CERT 0x...` - fn parse_host_cert_v2(line: &str) -> Option<([u8; 32], Vec)> { - let priv_str = line - .split("HOST_PRIV_KEY") - .nth(1)? - .split('|') - .next()? - .trim(); - let cert_str = line - .split("HOST_CERT") - .nth(1)? - .split(';') - .next()? - .split('|') - .next()? - .trim(); - - let priv_bytes = parse_hex(priv_str)?; - if priv_bytes.len() != 32 { - return None; - } - let mut pk = [0u8; 32]; - pk.copy_from_slice(&priv_bytes); - - let cert = parse_hex(cert_str)?; - if cert.len() < 132 { - return None; - } - - Some((pk, cert)) - } - - fn parse_disc_entry(line: &str) -> Option { - // 0x = | D | <date> | M | 0x<mk> | I | 0x<id> | V | 0x<vuk> | U | <unit_keys> - let (hash_part, rest) = line.split_once(" = ")?; - let disc_hash = hash_part.trim().to_lowercase(); - - // Extract title (before first |) - let title_part = rest.split(" | ").next().unwrap_or("").trim(); - // Clean title: "TITLE_NAME (Display Title)" → use display title if - // present. keydb.cfg is untrusted third-party content, so a title with - // ')' before '(' (e.g. "FILM) (X") would make start+1 > end; guard the - // slice and fall back to the whole title. - let title = match (title_part.find('('), title_part.rfind(')')) { - (Some(start), Some(end)) => title_part - .get(start + 1..end) - .map(str::to_string) - .unwrap_or_else(|| title_part.to_string()), - _ => title_part.to_string(), - }; - - // Parse fields by tag - let mut media_key = None; - let mut disc_id = None; - let mut vuk = None; - let mut unit_keys = Vec::new(); - - let parts: Vec<&str> = rest.split(" | ").collect(); - let mut i = 0; - while i < parts.len() { - match parts[i].trim() { - "M" => { - if i + 1 < parts.len() { - media_key = parse_hex16(parts[i + 1].trim()); - i += 1; - } - } - "I" => { - if i + 1 < parts.len() { - disc_id = parse_hex16(parts[i + 1].trim()); - i += 1; - } - } - "V" => { - if i + 1 < parts.len() { - vuk = parse_hex16(parts[i + 1].trim()); - i += 1; - } - } - "U" => { - if i + 1 < parts.len() { - // Unit keys: "1-0xKEY" or "1-0xKEY ; comment" - let uk_str = parts[i + 1].split(';').next().unwrap_or("").trim(); - for uk in uk_str.split(' ') { - let uk = uk.trim(); - if let Some((num, key)) = uk.split_once('-') { - if let Ok(n) = num.parse::<u32>() { - if let Some(k) = parse_hex16(key) { - unit_keys.push((n, k)); - } - } - } - } - i += 1; - } - } - _ => {} - } - i += 1; - } - - Some(DiscEntry { - disc_hash, - title, - media_key, - disc_id, - vuk, - unit_keys, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Get KEYDB path from KEYDB_PATH environment variable. Returns None if not set or not found. - fn keydb_path() -> Option<std::path::PathBuf> { - let path = std::path::PathBuf::from(std::env::var("KEYDB_PATH").ok()?); - if path.exists() { Some(path) } else { None } - } - - #[test] - fn test_parse_disc_entry() { - // All-zero placeholders — synthetic; no real key material in code. - let z40 = "00".repeat(20); - let z32 = "00".repeat(16); - let line = format!( - "0x{z40} = SAMPLE_FILM (Sample Film) | D | 2024-01-01 | M | 0x{z32} | I | 0x{z32} | V | 0x{z32} | U | 1-0x{z32} ; MKBv77" - ); - let entry = KeyDb::parse_disc_entry(&line).unwrap(); - assert_eq!(entry.title, "Sample Film"); - assert!(entry.media_key.is_some()); - assert!(entry.vuk.is_some()); - assert_eq!(entry.unit_keys.len(), 1); - assert_eq!(entry.unit_keys[0].0, 1); - } - - // NOTE: key fields below use obvious repeated-byte / zero placeholders - // (0x01.., 0x02.., 0x03.., 0x00..). NEVER put real — or real-looking — host, - // device, or processing key material in code; these tests exercise the - // parser's field-splitting only, not any genuine key. - - #[test] - fn test_parse_device_key() { - let line = "| DK | DEVICE_KEY 0x00000000000000000000000000000000 | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; MKBv01-MKBv48"; - let dk = KeyDb::parse_device_key(line).unwrap(); - assert_eq!(dk.node, 0x0800); - assert_eq!(dk.u_mask_shift, 0x17); - } - - #[test] - fn test_orphan_dk_row_loads_into_processing_keys() { - // `| DK |` row without position fields = an orphan DK. Per the - // unified model the resolver treats it like a terminal/PK - // candidate: it lands in `processing_keys` and the brute walker - // handles it. - let cfg = r#" -| DK | DEVICE_KEY 0x01010101010101010101010101010101 ; orphan, no position fields -| DK | DEVICE_KEY 0x02020202020202020202020202020202 | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; positioned MKBv01-MKBv48 -| PK | 0x03030303030303030303030303030303 ; legacy PK row still works -"#; - let db = KeyDb::parse(cfg); - assert_eq!( - db.device_keys.len(), - 1, - "positioned DK row should land in device_keys" - ); - // Orphan DK + legacy PK row both end up in processing_keys. - assert_eq!( - db.processing_keys.len(), - 2, - "orphan DK row + legacy PK row both belong in processing_keys" - ); - assert_eq!(db.processing_keys[0][..4], [0x01, 0x01, 0x01, 0x01]); - assert_eq!(db.processing_keys[1][..4], [0x03, 0x03, 0x03, 0x03]); - } - - #[test] - fn test_parse_orphan_dk_rejects_lines_with_position_fields() { - // The parser must NOT pick up a positioned DK row as an orphan - // (that would double-count). parse_orphan_dk explicitly checks. - let positioned = "| DK | DEVICE_KEY 0x02020202020202020202020202020202 | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17"; - assert!( - KeyDb::parse_orphan_dk(positioned).is_none(), - "positioned DK must not match orphan parser" - ); - let orphan = "| DK | DEVICE_KEY 0x01010101010101010101010101010101"; - let key = KeyDb::parse_orphan_dk(orphan).expect("orphan should parse"); - assert_eq!(key[..4], [0x01, 0x01, 0x01, 0x01]); - } - - #[test] - fn test_parse_host_cert() { - // 20-byte priv + 92-byte cert, all zeros — placeholders, not a key. - let line = format!( - "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{} ; Revoked", - "00".repeat(20), - "00".repeat(92) - ); - let hc = KeyDb::parse_host_cert(&line).unwrap(); - assert_eq!(hc.private_key, [0u8; 20]); - assert_eq!(hc.certificate.len(), 92); - } - - #[test] - fn test_parse_hex_rejects_non_ascii_without_panic() { - // A 4-byte UTF-8 scalar has byte-len 4 (passes the even check); the - // old &str-slice path panicked on the mid-codepoint boundary. The - // byte-wise parser must instead return None. - assert!(parse_hex("😀").is_none()); - // Mixed: leading hex then a 2-byte UTF-8 scalar (byte-len even). - assert!(parse_hex("ABé").is_none()); - // Sanity: well-formed hex still parses. - assert_eq!(parse_hex("0x00FF"), Some(vec![0x00, 0xFF])); - // Odd byte length still rejected. - assert!(parse_hex("ABC").is_none()); - } - - #[test] - fn test_hc2_before_hc_is_not_dropped() { - // An HC2 row appearing before any HC row must still land its AACS 2.0 - // credentials on a HostCert rather than being silently discarded. - let cfg = format!( - "| HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n", - "00".repeat(32), - "00".repeat(132) - ); - let db = KeyDb::parse(&cfg); - assert_eq!( - db.host_certs.len(), - 1, - "HC2-only row must create a HostCert" - ); - assert!(db.host_certs[0].private_key_v2.is_some()); - assert!(db.host_certs[0].certificate_v2.is_some()); - assert!( - db.host_certs[0].certificate.is_empty(), - "v1 cert stays empty for an HC2-only carrier" - ); - } - - #[test] - fn test_hc2_after_hc_augments_existing() { - let cfg = format!( - "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n| HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n", - "00".repeat(20), - "00".repeat(92), - "00".repeat(32), - "00".repeat(132) - ); - let db = KeyDb::parse(&cfg); - assert_eq!(db.host_certs.len(), 1, "HC2 augments the preceding HC"); - assert_eq!(db.host_certs[0].certificate.len(), 92); - assert!(db.host_certs[0].certificate_v2.is_some()); - } - - #[test] - fn test_parse_host_cert_rejects_short_v1_cert() { - // A too-short AACS 1.0 cert must be dropped at parse time. - let line = format!( - "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}", - "00".repeat(20), - "00".repeat(10) - ); - assert!(KeyDb::parse_host_cert(&line).is_none()); - } - - #[test] - fn test_parse_full_keydb() { - let path = match keydb_path() { - Some(p) => p, - None => return, - }; // skip if not available - - let db = KeyDb::load(&path).unwrap(); - - assert_eq!(db.device_keys.len(), 4); - assert_eq!(db.processing_keys.len(), 3); - assert!(!db.host_certs.is_empty()); - assert!(db.disc_entries.len() > 170000); - - // Look up any disc entry carrying a full key set. - let entry = db - .disc_entries - .values() - .find(|e| e.vuk.is_some() && e.media_key.is_some() && !e.unit_keys.is_empty()) - .expect("no disc entry with a full key set"); - assert!(entry.media_key.is_some()); - assert!(entry.vuk.is_some()); - assert!(!entry.unit_keys.is_empty()); - - eprintln!( - "Parsed {} disc entries, {} DK, {} PK", - db.disc_entries.len(), - db.device_keys.len(), - db.processing_keys.len() - ); - } - - // ════════════════════════════════════════════════════════════════════ - // Hardening additions - // ════════════════════════════════════════════════════════════════════ - - use super::super::provider::KeyProvider; - - // ── parse_hex / parse_hex16 / parse_hex20 ────────────────────────────── - - #[test] - fn parse_hex_strips_lower_and_upper_prefixes() { - // Both lower- and upper-case prefixes are stripped (trim_start_matches - // "0x" then "0X"). Without one of those strips a value would be off by - // a nibble or fail length checks. - assert_eq!(parse_hex("0xABCD"), Some(vec![0xAB, 0xCD])); - assert_eq!(parse_hex("0XABCD"), Some(vec![0xAB, 0xCD])); - assert_eq!(parse_hex("ABCD"), Some(vec![0xAB, 0xCD])); - } - - #[test] - fn parse_hex_mixed_case_nibbles() { - // to_digit(16) accepts both cases. - assert_eq!(parse_hex("aB"), Some(vec![0xAB])); - assert_eq!(parse_hex("Ff00"), Some(vec![0xFF, 0x00])); - } - - #[test] - fn parse_hex_rejects_non_hex_digit() { - // 'G' is not a hex digit → None (not silently 0). - assert!(parse_hex("0xGG").is_none()); - assert!(parse_hex("12ZZ").is_none()); - } - - #[test] - fn parse_hex_empty_is_empty_vec() { - // Empty (or bare "0x") → Some(empty): even byte-length 0 passes, and - // there are no nibbles to reject. parse_hex16/20 then reject on length. - assert_eq!(parse_hex(""), Some(vec![])); - assert_eq!(parse_hex("0x"), Some(vec![])); - } - - #[test] - fn parse_hex16_enforces_exactly_16_bytes() { - assert!(parse_hex16(&format!("0x{}", "00".repeat(15))).is_none()); - assert!(parse_hex16(&format!("0x{}", "00".repeat(17))).is_none()); - assert_eq!( - parse_hex16(&format!("0x{}", "00".repeat(16))), - Some([0u8; 16]) - ); - } - - #[test] - fn parse_hex20_enforces_exactly_20_bytes() { - assert!(parse_hex20(&format!("0x{}", "00".repeat(19))).is_none()); - assert_eq!( - parse_hex20(&format!("0x{}", "11".repeat(20))), - Some([0x11u8; 20]) - ); - } - - // ── Disc entry field parsing ─────────────────────────────────────────── - - #[test] - fn disc_entry_hash_is_lowercased() { - // The disc_hash key is lowercased so HashMap lookups are - // case-insensitive (find_disc lowercases its query too). - let z32 = "00".repeat(16); - let line = format!("0xABCDEF = T | M | 0x{z32}"); - let e = KeyDb::parse_disc_entry(&line).unwrap(); - assert_eq!(e.disc_hash, "0xabcdef"); - } - - #[test] - fn disc_entry_title_uses_display_in_parens() { - // "RAW_NAME (Display Name)" → title is the parenthesised display name. - let line = "0x00 = RAW_NAME (Display Name) | M | 0x".to_string() + &"00".repeat(16); - let e = KeyDb::parse_disc_entry(&line).unwrap(); - assert_eq!(e.title, "Display Name"); - } - - #[test] - fn disc_entry_title_without_parens_uses_whole() { - let line = "0x00 = PlainTitle | M | 0x".to_string() + &"00".repeat(16); - let e = KeyDb::parse_disc_entry(&line).unwrap(); - assert_eq!(e.title, "PlainTitle"); - } - - #[test] - fn disc_entry_malformed_parens_falls_back_to_whole_title() { - // ')' before '(' would make start+1 > end; the guarded get() returns - // None and the parser falls back to the whole title (no panic). - let line = "0x00 = FILM) (X | M | 0x".to_string() + &"00".repeat(16); - let e = KeyDb::parse_disc_entry(&line).unwrap(); - assert_eq!(e.title, "FILM) (X"); - } - - #[test] - fn disc_entry_parses_all_tagged_fields() { - // M, I, V, U each populate their field. U accepts "n-0xKEY". - let m = "11".repeat(16); - let i = "22".repeat(16); - let v = "33".repeat(16); - let u = "44".repeat(16); - let line = format!("0xAA = T | M | 0x{m} | I | 0x{i} | V | 0x{v} | U | 2-0x{u}"); - let e = KeyDb::parse_disc_entry(&line).unwrap(); - assert_eq!(e.media_key, Some([0x11u8; 16])); - assert_eq!(e.disc_id, Some([0x22u8; 16])); - assert_eq!(e.vuk, Some([0x33u8; 16])); - assert_eq!(e.unit_keys, vec![(2, [0x44u8; 16])]); - } - - #[test] - fn disc_entry_multiple_unit_keys_space_separated() { - // The U field carries space-separated "n-0xKEY" pairs. - let k1 = "01".repeat(16); - let k2 = "02".repeat(16); - let line = format!("0xAA = T | U | 1-0x{k1} 2-0x{k2}"); - let e = KeyDb::parse_disc_entry(&line).unwrap(); - assert_eq!(e.unit_keys, vec![(1, [0x01u8; 16]), (2, [0x02u8; 16])]); - } - - #[test] - fn disc_entry_unit_key_strips_trailing_comment() { - // "U | 1-0xKEY ; comment" — the ';' comment must be stripped before - // splitting unit keys. - let k = "05".repeat(16); - let line = format!("0xAA = T | U | 1-0x{k} ; MKBv77 note"); - let e = KeyDb::parse_disc_entry(&line).unwrap(); - assert_eq!(e.unit_keys, vec![(1, [0x05u8; 16])]); - } - - #[test] - fn disc_entry_skips_unparseable_unit_key_pair() { - // A bad nibble in one unit key drops just that pair (parse_hex16 → - // None), keeping the valid ones — no panic, no half-garbage key. - let good = "07".repeat(16); - let line = format!("0xAA = T | U | 1-0xZZ 2-0x{good}"); - let e = KeyDb::parse_disc_entry(&line).unwrap(); - assert_eq!(e.unit_keys, vec![(2, [0x07u8; 16])]); - } - - #[test] - fn disc_entry_field_with_short_hex_is_none_not_panic() { - // A 30-hex-char (15-byte) M value fails parse_hex16 → media_key None. - let short = "00".repeat(15); - let line = format!("0xAA = T | M | 0x{short}"); - let e = KeyDb::parse_disc_entry(&line).unwrap(); - assert!(e.media_key.is_none()); - } - - // ── find_disc / find_vuk: prefix-agnostic lookup ─────────────────────── - - #[test] - fn find_disc_matches_with_and_without_0x_and_case() { - let v = "33".repeat(16); - let line = format!("0xABCDEF = T | V | 0x{v}"); - let db = KeyDb::parse(&line); - // Stored key is "0xabcdef". Query in several shapes. - assert!(db.find_disc("0xABCDEF").is_some()); - assert!(db.find_disc("ABCDEF").is_some()); // no prefix - assert!(db.find_disc("0xabcdef").is_some()); - assert!(db.find_disc(" 0xAbCdEf ").is_some()); // padded + mixed case - assert_eq!(db.find_vuk("ABCDEF"), Some([0x33u8; 16])); - assert!(db.find_disc("0xDEADBE").is_none()); - } - - // ── KeyProvider impl over KeyDb ──────────────────────────────────────── - - #[test] - fn provider_lookup_by_hash_formats_lowercase_hex() { - // lookup_disc_by_hash writes the 20-byte hash as lowercase hex with a - // 0x prefix; it must hit an entry keyed that way. - let hash = [ - 0x00u8, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, - 0xEE, 0xFF, 0x01, 0x02, 0x03, 0x04, - ]; - let hex = format!( - "0x{}", - hash.iter().map(|b| format!("{b:02x}")).collect::<String>() - ); - let mut db = KeyDb::empty(); - db.disc_entries.insert( - hex.clone(), - DiscEntry { - disc_hash: hex, - title: "t".to_string(), - media_key: None, - disc_id: None, - vuk: Some([0x9u8; 16]), - unit_keys: Vec::new(), - }, - ); - let found = db.lookup_disc_by_hash(&hash).expect("hash lookup hit"); - assert_eq!(found.vuk, Some([0x9u8; 16])); - // A different hash misses. - assert!(db.lookup_disc_by_hash(&[0xFFu8; 20]).is_none()); - } - - #[test] - fn provider_lookup_by_vid_matches_disc_id() { - let vid = [0x42u8; 16]; - let mut db = KeyDb::empty(); - db.disc_entries.insert( - "0xa".to_string(), - DiscEntry { - disc_hash: "0xa".to_string(), - title: "t".to_string(), - media_key: Some([1u8; 16]), - disc_id: Some(vid), - vuk: None, - unit_keys: Vec::new(), - }, - ); - assert!(db.lookup_disc_by_vid(&vid).is_some()); - assert!(db.lookup_disc_by_vid(&[0x00u8; 16]).is_none()); - } - - #[test] - fn provider_media_keys_collects_every_per_disc_mk() { - // media_keys() returns every entry's Some(media_key). MKs are - // MKB-scoped, so the resolver dedups later; the provider returns all. - let mut db = KeyDb::empty(); - for (i, mk) in [[0x1u8; 16], [0x2u8; 16]].iter().enumerate() { - db.disc_entries.insert( - format!("0x{i}"), - DiscEntry { - disc_hash: format!("0x{i}"), - title: "t".to_string(), - media_key: Some(*mk), - disc_id: None, - vuk: None, - unit_keys: Vec::new(), - }, - ); - } - // An entry with no MK contributes nothing. - db.disc_entries.insert( - "0x9".to_string(), - DiscEntry { - disc_hash: "0x9".to_string(), - title: "t".to_string(), - media_key: None, - disc_id: None, - vuk: None, - unit_keys: Vec::new(), - }, - ); - let mut mks = db.media_keys(); - mks.sort(); - assert_eq!(mks, vec![[0x1u8; 16], [0x2u8; 16]]); - } - - // ── Comments / blank lines / unknown lines ───────────────────────────── - - #[test] - fn parse_ignores_comments_and_blank_lines() { - let cfg = "\n; a comment\n# another\n \n"; - let db = KeyDb::parse(cfg); - assert!(db.device_keys.is_empty()); - assert!(db.processing_keys.is_empty()); - assert!(db.disc_entries.is_empty()); - assert!(db.host_certs.is_empty()); - } - - #[test] - fn parse_empty_or_keyless_file_is_lenient_not_error() { - // parse() never errors; a keyless file is an empty KeyDb (documented - // contract — load() errors only on read failure, not empty content). - let db = KeyDb::parse("; nothing here\n"); - assert_eq!(db.disc_entries.len(), 0); - } - - #[test] - fn parse_device_key_requires_all_four_fields() { - // Missing KEY_U_MASK_SHIFT → parse_device_key returns None; with no - // position fields at all it would be an orphan DK instead. Here the - // line has DEVICE_NODE + KEY_UV but no shift → neither parser accepts - // it as a positioned DK, and parse_orphan_dk rejects it (has position - // fields), so nothing is loaded. - let line = "| DK | DEVICE_KEY 0x00000000000000000000000000000000 | DEVICE_NODE 0x0800 | KEY_UV 0x00000400"; - assert!(KeyDb::parse_device_key(line).is_none()); - let db = KeyDb::parse(line); - assert!(db.device_keys.is_empty()); - assert!(db.processing_keys.is_empty()); - } - - #[test] - fn parse_host_cert_v2_rejects_wrong_priv_len_and_short_cert() { - // v2 priv must be exactly 32 bytes; cert must be >= 132. - let bad_priv = format!( - "| HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}", - "00".repeat(31), - "00".repeat(132) - ); - assert!(KeyDb::parse_host_cert_v2(&bad_priv).is_none()); - let short_cert = format!( - "| HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}", - "00".repeat(32), - "00".repeat(131) - ); - assert!(KeyDb::parse_host_cert_v2(&short_cert).is_none()); - } - - #[test] - fn parse_processing_key_pk_row() { - // "| PK | 0x..." → 16-byte processing key. A trailing comment is - // stripped at ';'. - let line = format!("| PK | 0x{} ; MKBv64", "AB".repeat(16)); - let pk = KeyDb::parse_processing_key(&line).unwrap(); - assert_eq!(pk, [0xABu8; 16]); - } -} diff --git a/src/aacs/keys.rs b/src/aacs/keys.rs index 4e2dcb7..eb9213d 100644 --- a/src/aacs/keys.rs +++ b/src/aacs/keys.rs @@ -1,7 +1,7 @@ //! AACS key resolution — VUK derivation, MKB processing, disc hash, unit key parsing. use super::decrypt::aes_ecb_decrypt; -use super::keydb::DeviceKey; +use super::types::DeviceKey; // ── AACS version ──────────────────────────────────────────────────────────── @@ -1529,16 +1529,10 @@ fn match_keydb_unit_keys( #[cfg(test)] mod tests { - use super::super::decrypt::{ALIGNED_UNIT_LEN, aes_ecb_encrypt}; - use super::super::keydb::{DiscEntry, KeyDb}; + use super::super::provider::SuppliedKey; + use super::super::types::DiscEntry; use super::*; - /// Get KEYDB path from KEYDB_PATH environment variable. Returns None if not set or not found. - fn keydb_path() -> Option<std::path::PathBuf> { - let path = std::path::PathBuf::from(std::env::var("KEYDB_PATH").ok()?); - if path.exists() { Some(path) } else { None } - } - /// Finding #5 regression: parse_unit_key_ro must REJECT a Unit_Key_RO.inf /// whose declared `num_unit_keys` exceeds the keys actually present in the /// buffer, instead of silently returning a short list. A truncated list @@ -1604,135 +1598,24 @@ mod tests { let _ = derive_media_key_from_dk(&mkb, &[dk]); } - #[test] - fn test_vuk_derivation() { - // Pick any UHD entry with a known MK, VID, and VUK from KEYDB. - // VUK = AES-DEC(MK, VID) XOR VID - let path = match keydb_path() { - Some(p) => p, - None => return, - }; - - let db = KeyDb::load(&path).unwrap(); - - // Find a disc with both MK, disc_id, and VUK so we can verify derivation - let entry = db - .disc_entries - .values() - .find(|e| e.media_key.is_some() && e.disc_id.is_some() && e.vuk.is_some()) - .expect("No disc with MK + VID + VUK"); - - let mk = entry.media_key.unwrap(); - let vid = entry.disc_id.unwrap(); - let expected_vuk = entry.vuk.unwrap(); - - let derived = derive_vuk(&mk, &vid); - assert_eq!( - derived, expected_vuk, - "VUK derivation failed for disc: {} (hash {})", - entry.title, entry.disc_hash - ); - eprintln!("VUK derivation verified for: {}", entry.title); - } - #[test] fn test_decrypt_unit_key_from_vuk() { - // Test the full chain: VUK → decrypt encrypted unit key → unit key - // Use a known disc from KEYDB that has both VUK and unit keys - let path = match keydb_path() { - Some(p) => p, - None => return, - }; - - let db = KeyDb::load(&path).unwrap(); - - // Find a disc with VUK and unit keys - let entry = db - .disc_entries - .values() - .find(|e| e.vuk.is_some() && !e.unit_keys.is_empty()) - .expect("No disc with VUK + unit keys"); - - eprintln!( - "Testing unit key decrypt for: {} ({})", - entry.title, entry.disc_hash - ); - eprintln!(" VUK: {:02X?}", entry.vuk.unwrap()); - for (num, key) in &entry.unit_keys { - eprintln!(" Unit key {}: {:02X?}", num, key); - } - - // The unit keys in KEYDB are already decrypted — we can verify the chain - // by encrypting with VUK and then decrypting - let vuk = entry.vuk.unwrap(); - for (num, expected_uk) in &entry.unit_keys { - let encrypted = aes_ecb_encrypt(&vuk, expected_uk); + // 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. + use super::super::decrypt::aes_ecb_encrypt; + let vuk = [0x5Au8; 16]; + // A few representative "decrypted" unit keys. + for expected_uk in [[0x11u8; 16], [0x22u8; 16], [0xCDu8; 16]] { + let encrypted = aes_ecb_encrypt(&vuk, &expected_uk); let decrypted = decrypt_unit_key(&vuk, &encrypted); - assert_eq!( - &decrypted, expected_uk, - "Unit key {} roundtrip failed for {}", - num, entry.title - ); + assert_eq!(decrypted, expected_uk, "unit key roundtrip under VUK"); } - eprintln!(" All {} unit key roundtrips passed", entry.unit_keys.len()); - } - - #[test] - fn test_decrypt_real_unit() { - // Try decrypting a real encrypted aligned unit from a UHD sample. - // This disc is AACS 2.0 (BEE) so unit key alone won't work — - // we need bus decryption first. But this verifies the pipeline. - // Path comes from ENCRYPTED_UNIT_PATH (same env-driven pattern as the - // KEYDB_PATH / MKB_SAMPLE_DIR fixtures); no-ops in CI when unset. - let unit_path = match std::env::var("ENCRYPTED_UNIT_PATH").ok() { - Some(p) => std::path::PathBuf::from(p), - None => return, - }; - if !unit_path.exists() { - return; - } - - let original = std::fs::read(&unit_path).unwrap(); - assert_eq!(original.len(), ALIGNED_UNIT_LEN); - assert!( - super::super::decrypt::is_aacs_scrambled(&original), - "Unit should be encrypted" - ); - - let kp = match keydb_path() { - Some(p) => p, - None => return, - }; - let db = KeyDb::load(&kp).unwrap(); - - // Candidate entries: any UHD entry that carries unit keys. - let candidate_entries: Vec<&DiscEntry> = db - .disc_entries - .values() - .filter(|e| !e.unit_keys.is_empty()) - .collect(); - - eprintln!("Found {} entries with unit keys", candidate_entries.len()); - - // Try each entry's unit keys - for entry in &candidate_entries { - let keys: Vec<[u8; 16]> = entry.unit_keys.iter().map(|(_, k)| *k).collect(); - let mut unit = original.clone(); - - if let Some(res) = super::super::decrypt::decrypt_unit_try_keys(&mut unit, &keys) { - eprintln!( - "SUCCESS: Decrypted with entry {} ({res:?})", - entry.disc_hash - ); - // Count TS sync bytes - let ts = (0..32).filter(|&i| unit[4 + i * 192] == 0x47).count(); - eprintln!(" TS sync bytes: {}/32", ts); - return; - } - } - - // Expected: none work because this is AACS 2.0 and needs bus decryption first - eprintln!("No unit key worked (expected for AACS 2.0 BEE disc — needs read_data_key)"); } #[test] @@ -1834,7 +1717,7 @@ mod tests { mkb.extend_from_slice(&[0xAB; 16]); mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); let records_len = mkb.len(); - mkb.extend(std::iter::repeat(0u8).take(128 * 1024)); // padding + mkb.extend(std::iter::repeat_n(0u8, 128 * 1024)); // padding assert_eq!(mkb_content_len(&mkb), records_len); // No padding → returns the full length. assert_eq!(mkb_content_len(&mkb[..records_len]), records_len); @@ -1869,7 +1752,7 @@ mod tests { mkb.extend_from_slice(&[0xAB; 16]); mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); let records_len = mkb.len(); - mkb.extend(std::iter::repeat(0u8).take(1024)); + mkb.extend(std::iter::repeat_n(0u8, 1024)); assert_eq!( trim_mkb(mkb).len(), records_len, @@ -2197,44 +2080,6 @@ mod tests { assert_eq!(mkb_find_mk_dv(&mkb), Some(expected)); } - #[test] - fn test_resolve_keys_vuk_path() { - // Test the full resolve chain using VUK path - let path = match keydb_path() { - Some(p) => p, - None => return, - }; - let db = KeyDb::load(&path).unwrap(); - - // Find any BD entry that carries a VUK and unit keys, then exercise - // the lookup-by-hash + VUK-derivation chain against it. - let entry = db - .disc_entries - .values() - .find(|e| e.vuk.is_some() && !e.unit_keys.is_empty() && e.disc_id.is_some()); - if entry.is_none() { - return; - } - let entry = entry.unwrap(); - let vuk = entry.vuk.unwrap(); - let vid = entry.disc_id.unwrap(); - let hash_hex = format!("0x{}", entry.disc_hash.trim_start_matches("0x")); - - // We need the actual Unit_Key_RO.inf from the disc to compute disc hash. - // Since we don't have it, we can at least test that the KEYDB lookup - // works with a known hash. - let found = db.find_disc(&hash_hex); - assert!(found.is_some()); - assert_eq!(found.unwrap().vuk, Some(vuk)); - - // Verify VUK derivation if we have MK + VID - if let Some(mk) = entry.media_key { - let derived = derive_vuk(&mk, &vid); - assert_eq!(derived, vuk, "VUK derivation mismatch"); - eprintln!("VUK derivation verified"); - } - } - /// Build a minimal Unit_Key_RO.inf with `num_unit_keys = 1`. The /// disc hash won't be in any synthetic keydb so path 1 misses, /// which lets us isolate the path-2/3/4 short-circuit behavior. @@ -2262,23 +2107,18 @@ mod tests { let uk_ro = minimal_unit_key_ro(); let zero_vid = [0u8; 16]; - // Populate keydb with a non-matching VID entry (path 2 would - // miss anyway) plus dummy processing/device keys (paths 3/4 - // would also miss, but the short-circuit means they're never - // attempted). - let mut keydb = KeyDb::empty(); - keydb.disc_entries.insert( - "0xDEADBEEF".to_string(), - DiscEntry { - disc_hash: "0xDEADBEEF".to_string(), - title: "fixture".to_string(), - media_key: Some([0x11u8; 16]), - disc_id: Some([0x22u8; 16]), - vuk: None, - unit_keys: Vec::new(), - }, - ); - keydb.processing_keys.push([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). + let keydb = SuppliedKey { + device_keys: Vec::new(), + processing_keys: vec![[0u8; 16]], + media_keys: Vec::new(), + disc_entry: None, + }; let providers: &[&dyn super::super::KeyProvider] = &[&keydb]; let ctx = ResolveContext { @@ -2307,19 +2147,20 @@ mod tests { // keyed lowercase too, so we have to lowercase here. let hash_hex = disc_hash_hex(&hash).to_lowercase(); - let mut keydb = KeyDb::empty(); let known_vuk = [0xABu8; 16]; - keydb.disc_entries.insert( - hash_hex.clone(), - DiscEntry { + let keydb = SuppliedKey { + device_keys: Vec::new(), + processing_keys: Vec::new(), + media_keys: Vec::new(), + disc_entry: Some(DiscEntry { disc_hash: hash_hex, title: "fixture".to_string(), media_key: None, disc_id: None, vuk: Some(known_vuk), unit_keys: Vec::new(), - }, - ); + }), + }; let vid = [0u8; 16]; let providers: &[&dyn super::super::KeyProvider] = &[&keydb]; @@ -2349,18 +2190,19 @@ mod tests { // `minimal_unit_key_ro` declares CPS unit 1; supply a matching // pre-decrypted unit key in the KEYDB entry. let known_uk = [0xCDu8; 16]; - let mut keydb = KeyDb::empty(); - keydb.disc_entries.insert( - hash_hex.clone(), - DiscEntry { + let keydb = SuppliedKey { + device_keys: Vec::new(), + processing_keys: Vec::new(), + media_keys: Vec::new(), + disc_entry: Some(DiscEntry { disc_hash: hash_hex, title: "fixture".to_string(), media_key: None, disc_id: None, vuk: None, unit_keys: vec![(1, known_uk)], - }, - ); + }), + }; let vid = [0u8; 16]; let providers: &[&dyn super::super::KeyProvider] = &[&keydb]; @@ -2377,7 +2219,6 @@ mod tests { assert_eq!(resolved.key_source, 5); assert_eq!(resolved.unit_keys, vec![(1, known_uk)]); } - #[test] fn resolve_keys_path5_rejects_partial_unit_key_coverage() { // If the disc declares a CPS unit that's not in the KEYDB @@ -2388,20 +2229,20 @@ mod tests { let uk_ro = minimal_unit_key_ro(); let hash = disc_hash(&uk_ro); let hash_hex = disc_hash_hex(&hash).to_lowercase(); - // KEYDB has a key for CPS unit 99, but the disc declares unit 1. - let mut keydb = KeyDb::empty(); - keydb.disc_entries.insert( - hash_hex.clone(), - DiscEntry { + let keydb = SuppliedKey { + device_keys: Vec::new(), + processing_keys: Vec::new(), + media_keys: Vec::new(), + disc_entry: Some(DiscEntry { disc_hash: hash_hex, title: "fixture".to_string(), media_key: None, disc_id: None, vuk: None, unit_keys: vec![(99, [0xEEu8; 16])], - }, - ); + }), + }; let vid = [0u8; 16]; let providers: &[&dyn super::super::KeyProvider] = &[&keydb]; @@ -2417,17 +2258,14 @@ mod tests { "partial CPS-unit coverage must not produce a half-decrypted result" ); } - #[test] fn resolve_keys_path2_5_mk_pool_brute_resolves_unkeyed_disc() { // The keyless-disc case: this disc's own hash/VID are NOT in keydb, but its // Media Key IS — filed under a sibling disc that shares its MKB. Path // 2.5 must km_verifies that MK against the MKB and resolve. use super::super::decrypt::aes_ecb_encrypt as enc; - let km = [0x11u8; 16]; let vid = [0x22u8; 16]; - // MKB: 0x10 type/version + 0x86 verify record whose mk_dv decrypts under // km to the AACS verify magic, so km_verifies(mkb, km) == true. let mut vd = [0u8; 16]; @@ -2439,26 +2277,21 @@ mod tests { probe::km_verifies(&mkb, &km), "fixture: km must verify the MKB" ); - // This disc's inf (its hash will NOT be in keydb). let uk_ro = minimal_unit_key_ro(); - - // keydb: a SIBLING disc carries our km, keyed by the sibling's own - // hash + VID (neither matches THIS disc) — so only the MK-pool brute - // (km_verifies) can find it. - let mut keydb = KeyDb::empty(); - keydb.disc_entries.insert( - "0xsibling".to_string(), - DiscEntry { - disc_hash: "0xsibling".to_string(), - title: "sibling".to_string(), - media_key: Some(km), - disc_id: Some([0x99u8; 16]), - vuk: None, - unit_keys: Vec::new(), - }, - ); - + // 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. + let keydb = SuppliedKey { + device_keys: Vec::new(), + processing_keys: Vec::new(), + media_keys: vec![km], + disc_entry: None, + }; let providers: &[&dyn super::super::KeyProvider] = &[&keydb]; let ctx = ResolveContext { unit_key_ro: &uk_ro, @@ -2479,7 +2312,6 @@ mod tests { "VUK must derive from the verified Km + this disc's VID" ); } - #[test] fn test_content_cert_parse() { // AACS 1.0 cert @@ -2489,7 +2321,6 @@ mod tests { let cc = parse_content_cert(&data).unwrap(); assert_eq!(cc.version, AacsVersion::V10); assert!(!cc.bus_encryption); - // AACS 2.0 with bus encryption data[0] = 0x01; // AACS 2.0 data[1] = 0x01; // bus encryption enabled @@ -2497,13 +2328,10 @@ mod tests { assert_eq!(cc.version, AacsVersion::V20); assert!(cc.bus_encryption); } - // ════════════════════════════════════════════════════════════════════ // Hardening additions // ════════════════════════════════════════════════════════════════════ - // ── VUK derivation: spec relation VUK = AES-D(MK, VID) XOR VID ───────── - #[test] fn derive_vuk_matches_spec_relation_explicitly() { // Independently compute AES-ECB-D(mk, vid) XOR vid and confirm @@ -2524,7 +2352,6 @@ mod tests { } assert_eq!(derive_vuk(&mk, &vid), expected); } - #[test] fn decrypt_unit_key_is_plain_aes_ecb_decrypt_under_vuk() { // The encrypted unit key in Unit_Key_RO.inf is AES-ECB-E(VUK, uk); @@ -2536,9 +2363,7 @@ mod tests { let enc_uk = enc(&vuk, &uk); assert_eq!(decrypt_unit_key(&vuk, &enc_uk), uk); } - // ── Unit_Key_RO stride: 48 (V10) vs 64 (V20/V21) ────────────────────── - /// Build a Unit_Key_RO.inf carrying `num_uk` keys at a given stride, /// where key `i` is filled with byte `0x10 + i`. uk_pos = 0x60. fn build_unit_key_ro(num_uk: usize, stride: usize) -> Vec<u8> { @@ -2561,7 +2386,6 @@ mod tests { } data } - #[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 (keys.rs:30-35). @@ -2574,7 +2398,6 @@ mod tests { assert_eq!(v20.encrypted_keys.len(), 2); assert_eq!(v20.encrypted_keys[0].1, [0x10; 16]); assert_eq!(v20.encrypted_keys[1].1, [0x11; 16]); - // Same buffer, V10 stride: key 1 still lands at uk_pos+48, but key 2 // is read at +48 (not +64) so it is NOT the planted 0x11 block. let v10 = parse_unit_key_ro(&data, AacsVersion::V10).unwrap(); @@ -2584,7 +2407,6 @@ mod tests { "48-byte stride must read different bytes than 64-byte stride" ); } - #[test] fn v21_uses_same_64_byte_stride_as_v20() { // V21 shares V20's 64-byte stride (the enum match groups V20|V21). @@ -2594,15 +2416,12 @@ mod tests { assert_eq!(v20.encrypted_keys, v21.encrypted_keys); assert_eq!(v21.version, AacsVersion::V21); } - // ── parse_unit_key_ro: early returns / boundaries ────────────────────── - #[test] fn parse_unit_key_ro_rejects_too_short_header() { // < 20 bytes → None (header fields at 16-18 would index OOB). assert!(parse_unit_key_ro(&[0u8; 19], AacsVersion::V10).is_none()); } - #[test] fn parse_unit_key_ro_rejects_uk_pos_past_end() { // uk_pos points past the buffer → the `uk_pos + 2 > len` guard @@ -2611,7 +2430,6 @@ mod tests { data[0..4].copy_from_slice(&1000u32.to_be_bytes()); // uk_pos = 1000 assert!(parse_unit_key_ro(&data, AacsVersion::V10).is_none()); } - #[test] fn parse_unit_key_ro_zero_keys_returns_empty_set() { // num_unit_keys == 0 → a valid file with no encrypted keys (early @@ -2625,7 +2443,6 @@ mod tests { assert!(parsed.encrypted_keys.is_empty()); assert_eq!(parsed.app_type, 1); } - #[test] fn parse_unit_key_ro_truncated_key_region_returns_none() { // keys_start + 16 > len → None (the first key can't fit). @@ -2635,7 +2452,6 @@ mod tests { data[uk_pos + 1] = 1; // 1 key declared assert!(parse_unit_key_ro(&data, AacsVersion::V10).is_none()); } - #[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 @@ -2656,7 +2472,6 @@ mod tests { "a buffer declaring more keys than it contains must be rejected" ); } - #[test] fn parse_unit_key_ro_app_type_and_skb_flag() { // app_type at [16], num_bdmv_dir at [17], use_skb_mkb = bit 7 of [18]. @@ -2673,7 +2488,6 @@ mod tests { let p2 = parse_unit_key_ro(&data, AacsVersion::V10).unwrap(); assert!(!p2.use_skb_mkb); } - #[test] fn parse_unit_key_ro_cps_unit_numbers_are_1_based() { // The disc's CPS unit numbers are emitted as (i+1) — keys.rs:162. @@ -2684,7 +2498,6 @@ mod tests { vec![1, 2, 3] ); } - #[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 @@ -2698,9 +2511,7 @@ mod tests { let p = parse_unit_key_ro(&data, AacsVersion::V20).unwrap(); assert_eq!(p.title_cps_unit, vec![7, 9, 3, 4]); } - // ── MKB record framing: rec_len is BE24 incl. 4-byte header ──────────── - #[test] fn mkb_version_uses_be24_length_and_reads_offset_8() { // Type 0x10, BE24 length 0x0C (12). Body starts at pos+4: Type field @@ -2712,7 +2523,6 @@ mod tests { // version = 0x01020304. assert_eq!(mkb_version(&mkb), Some(0x0102_0304)); } - #[test] fn mkb_type_category_c_20_is_uhd() { // Type 0x10 record, BE24 length 0x0C (12). MKBType field (body @@ -2729,7 +2539,6 @@ mod tests { assert_eq!(MkbType::from_raw(MKB_21_CATEGORY_C), MkbType::CategoryC21); assert_eq!(MkbType::CategoryC21.generation(), AacsVersion::V21); } - #[test] fn mkb_type_prerecorded_is_bluray_v10() { // Type 0x10 record with MKB_TYPE_4_PRERECORDED (0x00041003) — a @@ -2742,7 +2551,6 @@ mod tests { assert!(!MkbType::Prerecorded.is_uhd()); assert_eq!(MkbType::Prerecorded.generation(), AacsVersion::V10); } - #[test] fn mkb_type_none_when_no_0x10_record() { // A buffer whose only record is a 0x81 (verify-media-key) record and @@ -2752,7 +2560,6 @@ mod tests { assert_eq!(mkb_type(&mkb), None); assert_eq!(mkb_is_uhd(&mkb), None); } - #[test] fn mkb_find_mk_dv_skips_short_verify_record() { // A 0x81 record with rec_len < 20 carries no full mk_dv; the finder @@ -2765,7 +2572,6 @@ mod tests { mkb.extend_from_slice(&[0x00; 4]); assert_eq!(mkb_find_mk_dv(&mkb), Some(expected)); } - #[test] fn mkb_find_mk_dv_stops_on_overrun_length() { // A rec_len that runs past the buffer ends the walk (break), so no @@ -2773,7 +2579,6 @@ mod tests { let mkb = [0x81, 0x00, 0xFF, 0xFF, 0x00, 0x00]; // claims 65535 bytes assert_eq!(mkb_find_mk_dv(&mkb), None); } - #[test] fn mkb_find_mk_dv_stops_on_zero_length_record() { // rec_len < 4 (here 0) breaks the walk — guards against an infinite @@ -2781,9 +2586,7 @@ mod tests { let mkb = [0x81, 0x00, 0x00, 0x00, 0x99]; assert_eq!(mkb_find_mk_dv(&mkb), None); } - // ── mkb_content_len / trim_mkb ───────────────────────────────────────── - #[test] fn mkb_content_len_stops_at_zero_type_padding_byte() { // A type==0 byte marks the start of padding (records done). Two real @@ -2794,14 +2597,12 @@ mod tests { mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x08]); // padding starts (type 0) assert_eq!(mkb_content_len(&mkb), content); } - #[test] fn mkb_content_len_returns_full_len_when_no_padding() { let mut mkb = vec![0x10, 0x00, 0x00, 0x08, 0, 0, 0, 1]; mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 9, 9, 9, 9]); assert_eq!(mkb_content_len(&mkb), mkb.len()); } - #[test] fn trim_mkb_leaves_exactly_sized_buffer_untouched() { // n == mkb.len() (no padding) → the `n < mkb.len()` guard is false, @@ -2809,15 +2610,12 @@ mod tests { let mkb = vec![0x10, 0x00, 0x00, 0x08, 0, 0, 0, 1]; assert_eq!(trim_mkb(mkb.clone()), mkb); } - // ── Content Certificate parsing ──────────────────────────────────────── - #[test] fn parse_content_cert_rejects_short_buffer() { // < 8 bytes → None (cc_id slice [2..8] would index OOB). assert!(parse_content_cert(&[0x00; 7]).is_none()); } - #[test] fn parse_content_cert_extracts_cc_id_and_nonzero_type_is_v20() { // [0]=type, [1]=bus-enc bit0, [2..8]=cc_id. Any non-0x00 type → V20. @@ -2830,7 +2628,6 @@ mod tests { assert_eq!(cc.cc_id, [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]); assert!(!cc.bus_encryption); } - #[test] fn parse_content_cert_bus_encryption_only_reads_bit0() { // bus_encryption = (data[1] & 0x01) != 0. A high bit set (0x02) with @@ -2841,9 +2638,7 @@ mod tests { data[1] = 0x03; // bit 0 set assert!(parse_content_cert(&data).unwrap().bus_encryption); } - // ── resolve: version → stride wiring + V21 upgrade on variant MKB ────── - #[test] fn resolve_keys_v2_upgrades_to_v21_on_variant_mkb() { // resolve_keys_v2 parses with the V20 64-byte stride but upgrades the @@ -2852,19 +2647,19 @@ mod tests { let uk_ro = build_unit_key_ro(1, 64); let hash = disc_hash(&uk_ro); let hash_hex = disc_hash_hex(&hash).to_lowercase(); - - let mut keydb = KeyDb::empty(); - keydb.disc_entries.insert( - hash_hex.clone(), - DiscEntry { + let keydb = SuppliedKey { + device_keys: Vec::new(), + processing_keys: Vec::new(), + media_keys: Vec::new(), + disc_entry: Some(DiscEntry { disc_hash: hash_hex, title: "fixture".to_string(), media_key: None, disc_id: None, vuk: Some([0x5Au8; 16]), unit_keys: Vec::new(), - }, - ); + }), + }; // MKB with a 0x83 variant record makes is_variant_mkb true. let mut mkb = vec![0x10, 0x00, 0x00, 0x08, 0, 0, 0, 1]; @@ -2893,18 +2688,19 @@ mod tests { let uk_ro = build_unit_key_ro(1, 64); let hash = disc_hash(&uk_ro); let hash_hex = disc_hash_hex(&hash).to_lowercase(); - let mut keydb = KeyDb::empty(); - keydb.disc_entries.insert( - hash_hex.clone(), - DiscEntry { + let keydb = SuppliedKey { + device_keys: Vec::new(), + processing_keys: Vec::new(), + media_keys: Vec::new(), + disc_entry: Some(DiscEntry { disc_hash: hash_hex, title: "f".to_string(), media_key: None, disc_id: None, vuk: Some([0x5Au8; 16]), unit_keys: Vec::new(), - }, - ); + }), + }; let mkb = vec![0x10, 0x00, 0x00, 0x08, 0, 0, 0, 1]; let providers: &[&dyn super::super::KeyProvider] = &[&keydb]; let ctx = ResolveContext { @@ -2923,18 +2719,19 @@ mod tests { let uk_ro = build_unit_key_ro(1, 48); let hash = disc_hash(&uk_ro); let hash_hex = disc_hash_hex(&hash).to_lowercase(); - let mut keydb = KeyDb::empty(); - keydb.disc_entries.insert( - hash_hex.clone(), - DiscEntry { + let keydb = SuppliedKey { + device_keys: Vec::new(), + processing_keys: Vec::new(), + media_keys: Vec::new(), + disc_entry: Some(DiscEntry { disc_hash: hash_hex, title: "f".to_string(), media_key: None, disc_id: None, vuk: Some([1u8; 16]), unit_keys: Vec::new(), - }, - ); + }), + }; // Content cert: AACS2 + bus encryption enabled. let mut cc = vec![0u8; 8]; cc[0] = 0x01; @@ -2960,18 +2757,19 @@ mod tests { let hash = disc_hash(&data); let hash_hex = disc_hash_hex(&hash).to_lowercase(); let vuk = [0x77u8; 16]; - let mut keydb = KeyDb::empty(); - keydb.disc_entries.insert( - hash_hex.clone(), - DiscEntry { + let keydb = SuppliedKey { + device_keys: Vec::new(), + processing_keys: Vec::new(), + media_keys: Vec::new(), + disc_entry: Some(DiscEntry { disc_hash: hash_hex, title: "f".to_string(), media_key: None, disc_id: None, vuk: Some(vuk), unit_keys: Vec::new(), - }, - ); + }), + }; let providers: &[&dyn super::super::KeyProvider] = &[&keydb]; let ctx = ResolveContext { unit_key_ro: &data, @@ -2997,18 +2795,19 @@ mod tests { let uk_ro = minimal_unit_key_ro(); let vid = [0x42u8; 16]; let mk = [0x24u8; 16]; - let mut keydb = KeyDb::empty(); - keydb.disc_entries.insert( - "0xnotthishash".to_string(), - DiscEntry { + let keydb = SuppliedKey { + device_keys: Vec::new(), + processing_keys: Vec::new(), + media_keys: Vec::new(), + disc_entry: Some(DiscEntry { disc_hash: "0xnotthishash".to_string(), title: "sibling".to_string(), media_key: Some(mk), disc_id: Some(vid), vuk: None, unit_keys: Vec::new(), - }, - ); + }), + }; let providers: &[&dyn super::super::KeyProvider] = &[&keydb]; let ctx = ResolveContext { unit_key_ro: &uk_ro, @@ -3282,18 +3081,19 @@ mod tests { let uk_ro = build_unit_key_ro(1, 64); let hash_hex = disc_hash_hex(&disc_hash(&uk_ro)).to_lowercase(); let vuk = [0x77u8; 16]; - let mut keydb = KeyDb::empty(); - keydb.disc_entries.insert( - hash_hex.clone(), - DiscEntry { + let keydb = SuppliedKey { + device_keys: Vec::new(), + processing_keys: Vec::new(), + media_keys: Vec::new(), + disc_entry: Some(DiscEntry { disc_hash: hash_hex, title: "f".to_string(), media_key: None, disc_id: None, vuk: Some(vuk), unit_keys: Vec::new(), - }, - ); + }), + }; let providers: &[&dyn super::super::KeyProvider] = &[&keydb]; let ctx = ResolveContext { unit_key_ro: &uk_ro, diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index 95ab38b..289d172 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -14,13 +14,20 @@ //! The VUK decrypts title keys from AACS/Unit_Key_RO.inf on disc. //! Title keys decrypt m2ts stream content (AES-128-CBC). +pub mod boil; pub mod decrypt; pub mod handshake; -pub mod keydb; pub mod keys; pub mod provider; +pub mod trace; +pub mod types; pub mod variants; +// Boil-down derivation primitives (thin newtypes + wrappers over the crypto). +pub use boil::{MediaKey, UnitKey, Vid, Vuk, mk_from_dk, uk_from_vuk, vuk_from_mk}; +// Structured, English-free resolution trace. +pub use trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace, UnlockOutcome, UnlockStep}; + // Explicit re-exports — only items needed by external consumers and sibling crate modules. // AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs. pub use decrypt::{ @@ -28,7 +35,6 @@ pub use decrypt::{ decrypt_unit_full, decrypt_unit_try_keys, is_aacs_scrambled, is_unit_aligned, ts_packet_total, ts_sync_count, unit_key_validates, }; -pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb}; pub use keys::probe; pub use keys::{ AacsVersion, ContentCert, MKB_20_CATEGORY_C, MKB_21_CATEGORY_C, MKB_TYPE_3_RECORDABLE, @@ -40,6 +46,7 @@ pub use keys::{ resolve_keys_v21, resolve_keys_with_reason, trim_mkb, }; pub use provider::KeyProvider; +pub use types::{DeviceKey, DiscEntry, HostCert}; pub use variants::{ KEY_CORRECTION_DATA_PLACEHOLDER, MediaKeyVariantError, MkbRecord, ProcessingKeyMatch, derive_media_key_variant, is_variant_mkb, variant_nonce, walk_mkb, walk_processing_key, diff --git a/src/aacs/provider.rs b/src/aacs/provider.rs index 6f379a1..b7715bc 100644 --- a/src/aacs/provider.rs +++ b/src/aacs/provider.rs @@ -37,7 +37,7 @@ //! [`lookup_disc_by_hash`]: KeyProvider::lookup_disc_by_hash //! [`lookup_disc_by_vid`]: KeyProvider::lookup_disc_by_vid -use super::keydb::{DeviceKey, DiscEntry, HostCert}; +use super::types::{DeviceKey, DiscEntry, HostCert}; /// Source of AACS key material. /// diff --git a/src/aacs/trace.rs b/src/aacs/trace.rs new file mode 100644 index 0000000..b5a5943 --- /dev/null +++ b/src/aacs/trace.rs @@ -0,0 +1,144 @@ +//! Structured resolution trace — what the unlock + key-resolution attempt did. +//! +//! No user-facing English. Every step's STATE is a typed enum variant; +//! applications RENDER these into localized text (the library never does). This +//! module only DEFINES the shape and is wired through the resolve/handshake +//! return path far enough to compile. +//! +//! The `who` of each step is the source's `label()` / unlocker's `name()` — a +//! stable identifier string (a NAME, like a codec id, NOT user-facing prose), +//! carried verbatim so an app renderer never has to match an enum back to a name +//! it already has. Only the OUTCOME / path enums are structured states the app +//! maps to i18n English. + +/// The full trace of a resolution attempt: the unlock phase, then the +/// key-resolution phase. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct ResolutionTrace { + /// One step per unlocker consulted, in consultation order. + pub unlock: Vec<UnlockStep>, + /// One step per key source consulted, in consultation order. + pub keys: Vec<KeyStep>, +} + +impl ResolutionTrace { + /// An empty trace (no steps recorded). + pub fn new() -> Self { + Self::default() + } +} + +// ── Unlock phase ──────────────────────────────────────────────────────────── + +/// One unlocker's contribution to the unlock phase. `who` is the unlocker's +/// `name()` (a stable identifier, e.g. `"LibreDrive"`), carried verbatim. +#[derive(Debug, Clone, PartialEq)] +pub struct UnlockStep { + pub who: String, + pub outcome: UnlockOutcome, +} + +/// What an unlocker did. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnlockOutcome { + /// The drive was unlocked (or already usable) and a VID is available. + Unlocked, + /// This unlocker cannot unlock this drive's firmware. + FirmwareNotUnlockable, + /// No non-revoked host cert was usable for the auth attempt. `mkb` is the + /// disc MKB generation when known. + NoUsableHostCert { mkb: Option<u32> }, + /// Every available host cert was revoked on this drive's HRL. `mkb` is the + /// disc MKB generation when known. + CertRevoked { mkb: Option<u32> }, + /// The drive rejected the auth handshake (non-revocation rejection / wedge). + HandshakeRejected, + /// Auth succeeded (or was skipped) but the Volume ID could not be read. + VidUnavailable, +} + +// ── Key-resolution phase ──────────────────────────────────────────────────── + +/// One key source's contribution to the key-resolution phase, including the +/// derivation path it walked. `who` is the source's `label()` (a stable +/// identifier, e.g. `"keydb"` / `"online"`), carried verbatim. +#[derive(Debug, Clone, PartialEq)] +pub struct KeyStep { + pub who: String, + pub path: Vec<KeyNode>, + pub outcome: KeyOutcome, +} + +/// A node on the derivation path a source walked. Ordered as encountered; not +/// every path hits every node. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyNode { + /// The source matched this disc (by hash / VID). + MatchedDisc, + /// The source had no entry for this disc. + NoEntry, + /// Pre-decrypted unit keys were found. + FoundUnitKeys, + /// A VUK was found. + FoundVuk, + /// A Media Key was found. + FoundMediaKey, + /// A VID is required to proceed. + NeedVid, + /// The VID came from the unlock phase. + VidFromUnlock, + /// The VID came from the keydb entry. + VidFromKeydb, + /// No VID was available. + NoVid, + /// A VUK was derived (from MK + VID). + DerivedVuk, + /// Unit keys were derived (from VUK). + DerivedUnitKeys, +} + +/// The terminal outcome of a source's resolution attempt. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyOutcome { + /// Usable unit keys were produced. + Resolved, + /// Derivation material existed but no VID was available to finish. + MissingVid, + /// No usable key from this source. + NoKey, +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The trace types are constructible, derive the required traits, and an + /// empty trace round-trips. Pins the structural contract apps build against. + #[test] + fn trace_is_constructible_and_comparable() { + let t = ResolutionTrace { + unlock: vec![UnlockStep { + who: "AACS cert".to_string(), + outcome: UnlockOutcome::NoUsableHostCert { mkb: Some(68) }, + }], + keys: vec![KeyStep { + who: "keydb".to_string(), + path: vec![ + KeyNode::MatchedDisc, + KeyNode::FoundVuk, + KeyNode::DerivedUnitKeys, + ], + outcome: KeyOutcome::Resolved, + }], + }; + // Clone + PartialEq (derive contract the renderers rely on). + assert_eq!(t.clone(), t); + // `who` is the source's name carried verbatim. + assert_eq!(t.keys[0].who, "keydb"); + assert_eq!(t.unlock[0].who, "AACS cert"); + // Default / new is empty. + assert_eq!(ResolutionTrace::new(), ResolutionTrace::default()); + assert!(ResolutionTrace::new().unlock.is_empty()); + assert!(ResolutionTrace::new().keys.is_empty()); + } +} diff --git a/src/aacs/types.rs b/src/aacs/types.rs new file mode 100644 index 0000000..1d9403a --- /dev/null +++ b/src/aacs/types.rs @@ -0,0 +1,45 @@ +//! AACS primitive types shared across the resolve chain. +//! +//! These structs describe AACS key material (device keys, host +//! certificates, per-disc entries). They carry no parsing logic — the +//! keydb.cfg format lives in the `freemkv-keysources` crate. libfreemkv +//! owns only the crypto and these value types that flow through it. + +/// A device key for MKB subset-difference tree processing. +#[derive(Debug, Clone)] +pub struct DeviceKey { + pub key: [u8; 16], + pub node: u16, + pub uv: u32, + pub u_mask_shift: u8, +} + +/// Host certificate + private key for AACS SCSI authentication. +#[derive(Debug, Clone)] +pub struct HostCert { + /// AACS 1.0: 20 bytes. AACS 2.0: 32 bytes. + pub private_key: [u8; 20], + /// AACS 1.0: 92 bytes. AACS 2.0: 132 bytes. + pub certificate: Vec<u8>, + /// AACS 2.0 host private key (P-256, 32 bytes). None for AACS 1.0 only. + pub private_key_v2: Option<[u8; 32]>, + /// AACS 2.0 host certificate (type 0x11). None for AACS 1.0 only. + pub certificate_v2: Option<Vec<u8>>, +} + +/// A per-disc entry from the key database. +#[derive(Debug, Clone)] +pub struct DiscEntry { + /// Disc hash (20 bytes, hex) + pub disc_hash: String, + /// Disc title + pub title: String, + /// Media Key (16 bytes) — from MKB processing + pub media_key: Option<[u8; 16]>, + /// Disc ID (16 bytes) + pub disc_id: Option<[u8; 16]>, + /// Volume Unique Key (16 bytes) — decrypts title keys + pub vuk: Option<[u8; 16]>, + /// Unit keys (title keys) indexed by CPS unit number + pub unit_keys: Vec<(u32, [u8; 16])>, +} diff --git a/src/aacs/variants.rs b/src/aacs/variants.rs index 4d2f3ed..dc34d27 100644 --- a/src/aacs/variants.rs +++ b/src/aacs/variants.rs @@ -56,7 +56,7 @@ //! variant path. use super::decrypt::aes_ecb_decrypt; -use super::keydb::DeviceKey; +use super::types::DeviceKey; // ── Public constants ────────────────────────────────────────────────────── diff --git a/src/clpi.rs b/src/clpi.rs index 08cf076..768bf58 100644 --- a/src/clpi.rs +++ b/src/clpi.rs @@ -6,6 +6,7 @@ //! //! Reference: https://github.com/lw/BluRay/wiki/CLPI +use crate::consts::{BD_SOURCE_PACKET_BYTES, SECTOR_BYTES}; use crate::disc::Extent; use crate::error::{Error, Result}; @@ -167,13 +168,15 @@ impl ClipInfo { return Vec::new(); } - // SPN → byte offset: spn × 192 - // Byte offset → sectors: offset / 2048 - // Note: the caller needs to add the file's starting LBA from UDF - let start_byte = start_spn as u64 * 192; - let end_byte = end_spn as u64 * 192; - let start_sector = (start_byte / 2048) as u32; - let end_sector = end_byte.div_ceil(2048) as u32; + // SPN → byte offset → sector range. Note: the caller adds the file's + // starting LBA from UDF. The start sector FLOORS (the extent begins in + // whichever sector contains its first byte) and the end sector CEILS + // (the extent must cover through the sector holding its last byte), so + // a sub-sector-aligned range still spans every sector it touches. + let start_byte = start_spn as u64 * BD_SOURCE_PACKET_BYTES as u64; + let end_byte = end_spn as u64 * BD_SOURCE_PACKET_BYTES as u64; + let start_sector = (start_byte / SECTOR_BYTES as u64) as u32; + let end_sector = end_byte.div_ceil(SECTOR_BYTES as u64) as u32; vec![Extent { start_lba: start_sector, // relative to m2ts file start @@ -1112,15 +1115,18 @@ mod tests { let data = build_clpi(1000, Some(&cpi)); let clip = parse(&data).expect("should parse"); - let p0 = (0u64 << 19) + (0u64 << 8); // PTS of first EP - let p1 = (100u64 << 19) + (0u64 << 8); // PTS of second EP + let p0 = 0u64; // PTS of first EP + let p1 = 100u64 << 19; // PTS of second EP let extents = clip.get_extents(p0, p1); assert_eq!(extents.len(), 1); - // start_spn = 0, end_spn = big_spn. SPN→byte ×192, byte→sector /2048. - let start_byte = 0u64 * 192; - let end_byte = big_spn as u64 * 192; - let start_sector = (start_byte / 2048) as u32; - let end_sector = end_byte.div_ceil(2048) as u32; + // Mirror production: SPN→byte ×packet, byte→sector with start FLOORed + // and end CEILed (same constants as get_extents). + let start_spn: u64 = 0; + let end_spn = big_spn as u64; + let start_byte = start_spn * BD_SOURCE_PACKET_BYTES as u64; + let end_byte = end_spn * BD_SOURCE_PACKET_BYTES as u64; + let start_sector = (start_byte / SECTOR_BYTES as u64) as u32; + let end_sector = end_byte.div_ceil(SECTOR_BYTES as u64) as u32; assert_eq!(extents[0].start_lba, start_sector); assert_eq!(extents[0].sector_count, end_sector - start_sector); // Concretely: 0x20000 × 192 / 2048 = 12288 sectors. @@ -1144,7 +1150,7 @@ mod tests { let cpi = build_cpi(0x1011, &[(0, 50, 0x1000)], &[(0, 0)]); let data = build_clpi(1000, Some(&cpi)); let clip = parse(&data).expect("should parse"); - let p = (50u64 << 19) + (0u64 << 8); + let p = 50u64 << 19; // in == out → start_spn == end_spn → empty. assert!(clip.get_extents(p, p).is_empty()); } @@ -1227,11 +1233,11 @@ mod tests { let stream_header_bits = &packed_bytes[6..16]; // stream EP data: fine_start points past the 1 coarse entry. - let fine_start: u32 = 4 + 1 * 8; + let fine_start: u32 = 4 + 8; // 4-byte header + 1 coarse entry x 8 bytes let mut stream_ep = Vec::new(); stream_ep.extend_from_slice(&fine_start.to_be_bytes()); // exactly ONE coarse entry (8 bytes), though header claims 255. - stream_ep.extend_from_slice(&((0u32 << 14) | 10).to_be_bytes()); + stream_ep.extend_from_slice(&10u32.to_be_bytes()); stream_ep.extend_from_slice(&0x20000u32.to_be_bytes()); // one fine entry (4 bytes) stream_ep.extend_from_slice(&(((5u32 & 0x7FF) << 17) | 100).to_be_bytes()); diff --git a/src/consts.rs b/src/consts.rs new file mode 100644 index 0000000..50c2a82 --- /dev/null +++ b/src/consts.rs @@ -0,0 +1,33 @@ +//! Physical media constants — the single source of truth. +//! +//! Naming convention: a constant is prefixed by the **narrowest scope where it +//! is valid**. A value common to all optical media carries no prefix; a value +//! specific to a container/format/disc-type is prefixed by it +//! (`TS_`, `BD_`, …). Define each physical quantity here exactly once and import +//! it — never re-declare a bare literal or a local copy. + +/// Bytes per logical sector on every optical medium freemkv reads +/// (Blu-ray, DVD-Video, CD-ROM Mode 1). Universal — hence unprefixed. +pub const SECTOR_BYTES: usize = 2048; + +/// Bytes per MPEG-2 transport-stream packet. Common to all MPEG-TS, not just +/// Blu-ray — prefixed by the format, not a disc type. +pub const TS_PACKET_BYTES: usize = 188; + +/// Bytes in an MPEG-2 transport-stream packet header: sync byte, the +/// flags/PID word, and the adaptation/continuity byte. +pub const TS_HEADER_BYTES: usize = 4; + +/// Bytes in the arrival-timestamp prefix a Blu-ray M2TS prepends to each TS +/// packet to form a source packet. Same width as a TS header but a distinct +/// quantity ([`TS_HEADER_BYTES`]) — do not conflate. +pub const BD_TIMESTAMP_PREFIX_BYTES: usize = 4; + +/// Bytes of payload in an MPEG-2 transport-stream packet: +/// [`TS_PACKET_BYTES`] minus the [`TS_HEADER_BYTES`] header. +pub const TS_PAYLOAD_BYTES: usize = TS_PACKET_BYTES - TS_HEADER_BYTES; + +/// Bytes per Blu-ray M2TS *source packet*: a TS packet ([`TS_PACKET_BYTES`]) +/// prefixed with the [`BD_TIMESTAMP_PREFIX_BYTES`] arrival-timestamp header. +/// A BDAV/M2TS construct only — DVD VOBs have no source packets — hence `BD_`. +pub const BD_SOURCE_PACKET_BYTES: usize = TS_PACKET_BYTES + BD_TIMESTAMP_PREFIX_BYTES; diff --git a/src/css/stevenson.rs b/src/css/stevenson.rs index 750e6c5..84798a8 100644 --- a/src/css/stevenson.rs +++ b/src/css/stevenson.rs @@ -47,8 +47,7 @@ use super::lfsr::descramble_sector; use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5}; -/// Sector layout constants. -const SECTOR_SIZE: usize = 2048; +use crate::consts::SECTOR_BYTES; const ENCRYPTED_START: usize = 0x80; // byte 128 const SEED_OFFSET: usize = 0x54; // sector seed at bytes 0x54-0x58 const FLAG_BYTE: usize = 0x14; @@ -188,7 +187,7 @@ fn recover_title_key_from_plain( /// Returns the recovered key only if it actually descrambles the sector back /// to `plain` — guarding against the rare spurious LFSR-seed match. pub fn recover_title_key(sector: &[u8], plain: &[u8]) -> Option<[u8; 5]> { - if sector.len() < SECTOR_SIZE || plain.len() < 10 { + if sector.len() < SECTOR_BYTES || plain.len() < 10 { return None; } if sector[FLAG_BYTE] & 0x30 == 0 { @@ -219,7 +218,7 @@ fn descramble_matches(sector: &[u8], title: &[u8; 5], plain: &[u8]) -> bool { let mut test = sector.to_vec(); test[FLAG_BYTE] |= 0x10; // ensure scramble flag set for the descrambler descramble_sector(title, &mut test); - let n = plain.len().min(SECTOR_SIZE - ENCRYPTED_START); + let n = plain.len().min(SECTOR_BYTES - ENCRYPTED_START); test[ENCRYPTED_START..ENCRYPTED_START + n] == plain[..n] } @@ -232,7 +231,7 @@ fn descramble_matches(sector: &[u8], title: &[u8; 5], plain: &[u8]) -> bool { /// cycles), the known plaintext at 0x80 is taken to be the periodic run /// continuing forward, and [`recover_title_key_from_plain`] is applied. pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> { - if sector.len() < SECTOR_SIZE { + if sector.len() < SECTOR_BYTES { return None; } if sector[FLAG_BYTE] & 0x30 == 0 { @@ -276,7 +275,7 @@ pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> { /// "did the cached key descramble correctly?" oracle (the predicted plaintext /// must reappear at 0x80), and the cracker uses it as its known plaintext. pub(crate) fn attack_crib(sector: &[u8]) -> Option<[u8; 10]> { - if sector.len() < SECTOR_SIZE || sector[FLAG_BYTE] & 0x30 == 0 { + if sector.len() < SECTOR_BYTES || sector[FLAG_BYTE] & 0x30 == 0 { return None; } let mut best_plen: usize = 0; @@ -352,7 +351,7 @@ mod tests { /// EXACTLY the cipher `descramble_sector` inverts. Returns /// (scrambled_sector, full_plaintext_body). fn synth_sector(title_key: &[u8; 5], seed: &[u8; 5], plain: &[u8]) -> (Vec<u8>, Vec<u8>) { - let mut plaintext = vec![0u8; SECTOR_SIZE]; + let mut plaintext = vec![0u8; SECTOR_BYTES]; plaintext[0..4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); plaintext[FLAG_BYTE] = 0x10; plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed); @@ -374,7 +373,7 @@ mod tests { seed: &[u8; 5], period: usize, ) -> (Vec<u8>, Vec<u8>) { - let mut plaintext = vec![0u8; SECTOR_SIZE]; + let mut plaintext = vec![0u8; SECTOR_BYTES]; plaintext[FLAG_BYTE] = 0x10; // A clean periodic run occupying the tail of the cleartext header @@ -460,8 +459,8 @@ mod tests { recover_title_key(§or, &PES).expect("recover_title_key returned None"); descramble_sector(&recovered, &mut sector); assert_eq!( - §or[ENCRYPTED_START..SECTOR_SIZE], - &body[ENCRYPTED_START..SECTOR_SIZE], + §or[ENCRYPTED_START..SECTOR_BYTES], + &body[ENCRYPTED_START..SECTOR_BYTES], "recovered key did not descramble the full body for \ title={title_key:02x?} seed={seed:02x?}" ); @@ -483,8 +482,8 @@ mod tests { let mut test = sector.clone(); descramble_sector(&cracked, &mut test); assert_eq!( - &test[ENCRYPTED_START..SECTOR_SIZE], - &body[ENCRYPTED_START..SECTOR_SIZE], + &test[ENCRYPTED_START..SECTOR_BYTES], + &body[ENCRYPTED_START..SECTOR_BYTES], "crack_title_key key did not round-trip the body (period {period})" ); } @@ -515,8 +514,8 @@ mod tests { recover_title_key(§or, &PES).expect("recover_title_key returned None"); descramble_sector(&recovered, &mut sector); assert_eq!( - §or[ENCRYPTED_START..SECTOR_SIZE], - &body[ENCRYPTED_START..SECTOR_SIZE], + §or[ENCRYPTED_START..SECTOR_BYTES], + &body[ENCRYPTED_START..SECTOR_BYTES], "descramble with recovered key did not reproduce the body \ for title={title_key:02x?} seed={seed:02x?}" ); @@ -527,21 +526,21 @@ mod tests { #[test] fn recover_rejects_sector_one_byte_short() { - let mut sector = vec![0u8; SECTOR_SIZE - 1]; + let mut sector = vec![0u8; SECTOR_BYTES - 1]; sector[FLAG_BYTE] = 0x30; assert!(recover_title_key(§or, &PES).is_none()); } #[test] fn recover_rejects_unscrambled_sector() { - let sector = vec![0x00u8; SECTOR_SIZE]; + let sector = vec![0x00u8; SECTOR_BYTES]; assert!(recover_title_key(§or, &PES).is_none()); } #[test] fn recover_high_flag_bits_are_not_scramble() { for &flag in &[0x40u8, 0x80, 0xC0] { - let mut sector = vec![0x11u8; SECTOR_SIZE]; + let mut sector = vec![0x11u8; SECTOR_BYTES]; sector[FLAG_BYTE] = flag; assert!( recover_title_key(§or, &PES).is_none(), @@ -553,7 +552,7 @@ mod tests { #[test] fn crack_high_flag_bits_are_not_scramble() { for &flag in &[0x40u8, 0x80, 0xC0] { - let mut sector = vec![0x11u8; SECTOR_SIZE]; + let mut sector = vec![0x11u8; SECTOR_BYTES]; sector[FLAG_BYTE] = flag; assert!( crack_title_key(§or).is_none(), @@ -564,7 +563,7 @@ mod tests { #[test] fn crack_rejects_sector_one_byte_short() { - let mut sector = vec![0u8; SECTOR_SIZE - 1]; + let mut sector = vec![0u8; SECTOR_BYTES - 1]; if sector.len() > FLAG_BYTE { sector[FLAG_BYTE] = 0x30; } @@ -576,7 +575,7 @@ mod tests { #[test] fn crack_full_path_never_panics() { for seed in 0u32..3 { - let mut sector = vec![0u8; SECTOR_SIZE]; + let mut sector = vec![0u8; SECTOR_BYTES]; sector[FLAG_BYTE] = 0x30; let mut x = seed.wrapping_mul(2_654_435_761).wrapping_add(7); for b in sector.iter_mut().skip(0x80) { diff --git a/src/diag.rs b/src/diag.rs index 23d49b5..25eaec5 100644 --- a/src/diag.rs +++ b/src/diag.rs @@ -116,7 +116,9 @@ pub fn sample_rate_hz(s: SampleRate) -> u32 { match s { SampleRate::S44_1 => 44100, SampleRate::S48 => 48000, + SampleRate::S88_2 => 88200, SampleRate::S96 => 96000, + SampleRate::S176_4 => 176400, SampleRate::S192 => 192000, SampleRate::S48_96 => 96000, SampleRate::S48_192 => 192000, diff --git a/src/disc/bluray.rs b/src/disc/bluray.rs index 54520e4..8c11af7 100644 --- a/src/disc/bluray.rs +++ b/src/disc/bluray.rs @@ -583,7 +583,7 @@ mod tests { } /// One STN stream entry: stream_entry (len(1)=3, type(1)=0x01, pid(2)) - /// + stream_attributes (len(1) + coding_type(1) + payload). Matches + /// plus stream_attributes (len(1) + coding_type(1) + payload). Matches /// the mpls.rs test builders. fn se_video(pid: u16, coding_type: u8) -> Vec<u8> { let mut out = vec![3u8, 0x01]; @@ -962,7 +962,7 @@ mod tests { /// A clip whose .clpi is missing contributes NO size and NO extent /// (bluray.rs only fetches extents inside the `if let Ok(clpi_data)` - /// + `if let Ok(clip_info)` blocks), but the Clip entry is still + /// and `if let Ok(clip_info)` blocks), but the Clip entry is still /// recorded with packet count 0. Never panics on the missing read. #[test] fn parse_playlist_missing_clpi_yields_no_extent_no_size() { diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index e5f5c5f..f0d7b4b 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -13,6 +13,171 @@ pub(super) struct HandshakeResult { pub read_data_key: Option<[u8; 16]>, } +/// In-tree AACS host-certificate cert-auth "unlocker" — the Drive-level peer of +/// the external firmware [`crate::unlock::Unlocker`]s. +/// +/// It is NOT a registry `dyn Unlocker`: the cert handshake helpers +/// ([`crate::aacs::handshake::aacs_authenticate`] et al.) operate on a concrete +/// `&mut Drive`, whereas the registry trait hands out a `&mut dyn ScsiTransport` +/// for external firmware unlockers (and keeps their unit tests trivially +/// fakeable). So the firmware path stays transport-level and registry-routed, +/// while this cert path is an in-tree Drive-level peer invoked directly by +/// [`Disc::do_handshake`]. Both produce a Volume ID under the shared +/// [`crate::unlock::UnlockError`] taxonomy. +struct AacsCertUnlocker<'a> { + opts: &'a ScanOptions, +} + +impl AacsCertUnlocker<'_> { + /// Run the host-certificate mutual-auth handshake: collect non-compiled-in + /// host certs from the key sources + credentials, try each (wedge-guarded), + /// and on success read the Volume ID + `read_data_key` (the AACS 2.0 bus + /// key). Returns a structured [`crate::unlock::UnlockError`] on every + /// no-VID outcome. + fn authenticate( + &self, + session: &mut crate::drive::Drive, + ) -> std::result::Result<HandshakeResult, crate::unlock::UnlockError> { + use crate::aacs; + use crate::unlock::UnlockError; + + // MKB generation (best-effort) — forwarded to each source's + // `host_certs(mkb)` so a source MAY select a generation-appropriate cert + // (the default impl ignores it). A read failure leaves it `None`. + let mkb_gen = aacs::read_mkb_from_drive(session) + .ok() + .and_then(|m| aacs::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: NoUsableHostCert (folded to AacsNoHostCert + // by the caller, preserving the graceful path-1 disc-hash → VUK fallback). + let host_certs = Disc::collect_host_certs(self.opts, mkb_gen); + if host_certs.is_empty() { + tracing::warn!( + target: "freemkv::disc", + phase = "handshake_no_host_cert", + "No AACS host certificate available from any key source, so the host-certificate handshake can't run." + ); + return Err(UnlockError::NoUsableHostCert { mkb: mkb_gen }); + } + let host_cert_count = host_certs.len(); + tracing::debug!( + target: "freemkv::disc", + phase = "handshake_start", + host_cert_count, + "handshake starting" + ); + + // Cert-attempt wedge guard. An earlier version fired up to 16 AACS + // authenticate attempts back-to-back with no pause — 80-160 SCSI + // REPORT_KEY/SEND_KEY commands in a few hundred ms, which can drive + // consumer optical drives into a fast-fail firmware wedge (every CDB + // returns ILLEGAL_REQUEST until power-cycled). Defense-in-depth: cap + // attempts, sleep between, bail early on the drive's wedge sense. + const MAX_CERT_ATTEMPTS: usize = 3; + const PER_CERT_BACKOFF_MS: u64 = 1000; + let mut last_err_code: Option<u16> = None; + for (idx, hc) in host_certs.iter().take(MAX_CERT_ATTEMPTS).enumerate() { + if idx > 0 { + std::thread::sleep(std::time::Duration::from_millis(PER_CERT_BACKOFF_MS)); + } + match aacs::handshake::aacs_authenticate(session, &hc.private_key, &hc.certificate) { + Ok(mut auth) => { + let volume_id = match aacs::handshake::read_volume_id(session, &mut auth) { + Ok(vid) => vid, + Err(e) => { + tracing::warn!( + target: "freemkv::disc", + phase = "handshake_vid_read_failed", + cert_index = idx, + error_code = e.code(), + "auth ok but volume ID read failed" + ); + return Err(UnlockError::VidUnavailable); + } + }; + let read_data_key = aacs::handshake::read_data_keys(session, &mut auth) + .ok() + .map(|(rdk, _)| rdk); + tracing::debug!( + target: "freemkv::disc", + phase = "handshake_ok", + cert_index = idx, + has_read_data_key = read_data_key.is_some(), + ); + return Ok(HandshakeResult { + volume_id, + read_data_key, + }); + } + Err(e) => { + last_err_code = Some(e.code()); + // Read the wedge sense off the structured ScsiSense, NOT + // `e.code()` (a flat constant for every ScsiError). On + // ILLEGAL_REQUEST the drive is signalling it won't talk to us + // — trying more certs worsens the wedge, so bail immediately. + let sense = e.scsi_sense(); + if sense.map(|s| s.is_illegal_request()).unwrap_or(false) { + tracing::warn!( + target: "freemkv::disc", + phase = "handshake_wedge_detected", + cert_index = idx, + sense_key = sense.map(|s| s.sense_key), + asc = sense.map(|s| s.asc), + ascq = sense.map(|s| s.ascq), + "drive returned ILLEGAL_REQUEST during auth; bailing out to avoid wedge" + ); + return Err(UnlockError::HandshakeRejected); + } + continue; + } + } + } + tracing::info!( + target: "freemkv::disc", + phase = "vid_cert_rejected", + host_cert_count, + tried = host_cert_count.min(MAX_CERT_ATTEMPTS), + last_error_code = last_err_code, + "The drive rejected the AACS host certificate, so no Volume ID was obtained." + ); + Err(UnlockError::HandshakeRejected) + } +} + +/// Map an [`crate::unlock::UnlockError`] from the cert path back to the +/// `Error` variant `do_handshake_cert` has always surfaced, so `scan_with`'s +/// rendering and the path-1 disc-hash → VUK fallback are byte-for-byte +/// unchanged. (`NoUsableHostCert` keeps the `<no host cert>` sentinel.) +fn unlock_error_to_error(e: crate::unlock::UnlockError) -> Error { + use crate::unlock::UnlockError; + match e { + UnlockError::NoUsableHostCert { .. } => Error::AacsNoHostCert { + path: "<no host cert>".into(), + }, + UnlockError::VidUnavailable => Error::AacsVidUnavailable, + UnlockError::HandshakeRejected + | UnlockError::CertRevoked { .. } + | UnlockError::FirmwareNotUnlockable + | UnlockError::Scsi(_) => Error::AacsHostCertRejected, + } +} + +/// Map a cert-path [`crate::unlock::UnlockError`] to a structured +/// [`crate::aacs::UnlockOutcome`] for the resolution trace (English-free). +fn cert_unlock_outcome(e: &crate::unlock::UnlockError) -> crate::aacs::UnlockOutcome { + use crate::aacs::UnlockOutcome; + use crate::unlock::UnlockError; + match e { + UnlockError::FirmwareNotUnlockable => UnlockOutcome::FirmwareNotUnlockable, + UnlockError::NoUsableHostCert { mkb } => UnlockOutcome::NoUsableHostCert { mkb: *mkb }, + UnlockError::CertRevoked { mkb } => UnlockOutcome::CertRevoked { mkb: *mkb }, + UnlockError::VidUnavailable => UnlockOutcome::VidUnavailable, + UnlockError::HandshakeRejected | UnlockError::Scsi(_) => UnlockOutcome::HandshakeRejected, + } +} + impl Disc { /// SCSI handshake — drives the VID-acquisition flow and returns /// a structured `HandshakeResult` for downstream key resolution. @@ -71,13 +236,16 @@ impl Disc { /// 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`]). - fn collect_host_certs(opts: &ScanOptions) -> Vec<crate::aacs::HostCert> { + /// `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<u32>) -> Vec<crate::aacs::HostCert> { let mut host_certs: Vec<crate::aacs::HostCert> = Vec::new(); if let Some(c) = &opts.credentials { host_certs.extend(c.host_certs.iter().cloned()); } for src in &opts.key_sources { - host_certs.extend(src.host_certs()); + host_certs.extend(src.host_certs(mkb)); } host_certs } @@ -86,184 +254,51 @@ impl Disc { session: &mut crate::drive::Drive, opts: &ScanOptions, ) -> (Option<HandshakeResult>, Option<Error>) { - use crate::aacs; - - // OEM VID shortcut. Resolve the SAME unlocker that would unlock this - // drive and ask it for the VID via its OEM mechanism. Cloning the - // DriveId first releases the immutable borrow before we hand the - // mutable transport to the registry. - let drive_id = session.drive_id.clone(); - match crate::unlock::unlocker_read_volume_id(session.scsi_mut(), &drive_id) { - Ok(Some(volume_id)) => { - tracing::debug!( - target: "freemkv::disc", - phase = "oem_vid_ok", - "Got the disc's Volume ID from the drive unlocker; skipping the AACS host-certificate handshake." - ); - return ( - Some(HandshakeResult { - volume_id, - read_data_key: None, - }), - None, - ); - } - Ok(None) => { - tracing::debug!( - target: "freemkv::disc", - phase = "oem_vid_none", - "Drive unlocker has no Volume ID for this disc; trying the AACS host-certificate handshake next." - ); - } - Err(e) => { - tracing::warn!( - target: "freemkv::disc", - phase = "oem_vid_failed", - error_code = e.code(), - "Drive unlocker errored while reading the Volume ID; trying the AACS host-certificate handshake next." - ); - } - } - - // No VID from the unlocker → try the cert handshake (host certs are - // served by the key sources). Even on a firmware-unlocked drive we try - // it: the drive may still honour a cert. If it doesn't, that is not a - // failure here — no VID is fine for a keydb VK/UK, and the wedge guard - // bounds the attempts. We log the outcome and continue. - - // Host certs are keysource-served, never compiled in. Collect them from - // BOTH places the caller may carry them: - // 1. the explicit `DriveCredentials` (certs the app pre-extracted), and - // 2. the key-source layer (`KeySource::host_certs()` across every - // registered source — the keydb source exposes its `| HC |`/`| HC2 |` - // rows here; an online source whose cert-serving isn't yet designed - // contributes none). - // The two are unioned so either wiring works. With ZERO certs from any - // source the OEM cert route cannot run: we fail GRACEFULLY with - // `AacsNoHostCert` (no panic, no generic failure). Resolution then - // proceeds with VID=zero and relies on the path-1 disc-hash → VUK lookup, - // which drops the error when it hits. - let host_certs = Self::collect_host_certs(opts); - if host_certs.is_empty() { - tracing::warn!( + // OEM VID shortcut: a matching firmware 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`. + if let Some(volume_id) = session.oem_vid() { + tracing::debug!( target: "freemkv::disc", - phase = "handshake_no_host_cert", - "No AACS host certificate available from any key source, so the host-certificate handshake can't run. Continuing without a Volume ID; a key source may still supply this disc's key." + phase = "oem_vid_ok", + "Volume ID supplied by the drive unlocker at init; skipping the AACS host-certificate handshake." ); return ( - None, - Some(Error::AacsNoHostCert { - path: "<no host cert>".into(), + Some(HandshakeResult { + volume_id, + read_data_key: None, }), + None, ); } - let host_certs: &[aacs::HostCert] = &host_certs; - - let host_cert_count = host_certs.len(); tracing::debug!( target: "freemkv::disc", - phase = "handshake_start", - host_cert_count, - "handshake starting" + phase = "oem_vid_none", + "No drive-unlocker Volume ID; running the in-tree AACS host-certificate handshake (AacsCertUnlocker)." ); - // Cert-attempt wedge guard. An earlier version fired up to 16 - // AACS authenticate attempts back-to-back with no pause. Each - // attempt is 5-10 SCSI REPORT_KEY/SEND_KEY exchanges. On a disc - // whose host cert isn't in the KEYDB (or one the drive rejects), - // that's 80-160 SCSI commands hammered at the drive in a few - // hundred milliseconds — and consumer optical drives can respond - // by entering a fast-fail firmware wedge state where every - // subsequent CDB returns ILLEGAL_REQUEST/INVALID_FIELD_IN_CDB - // (sense 05/24) until power-cycled. Observed live on a UHD scan: - // KEYDB miss → many cert attempts in a tight loop → wedge → - // forced power cycle to recover. - // - // Defense-in-depth: cap attempts, sleep between, and bail - // early on the drive's wedge sense so any later regression - // can't undo the protection silently. - const MAX_CERT_ATTEMPTS: usize = 3; - const PER_CERT_BACKOFF_MS: u64 = 1000; - let mut last_err_code: Option<u16> = None; - for (idx, hc) in host_certs.iter().take(MAX_CERT_ATTEMPTS).enumerate() { - if idx > 0 { - std::thread::sleep(std::time::Duration::from_millis(PER_CERT_BACKOFF_MS)); - } - match aacs::handshake::aacs_authenticate(session, &hc.private_key, &hc.certificate) { - Ok(mut auth) => { - let volume_id = match aacs::handshake::read_volume_id(session, &mut auth) { - Ok(vid) => vid, - Err(e) => { - tracing::warn!( - target: "freemkv::disc", - phase = "handshake_vid_read_failed", - cert_index = idx, - error_code = e.code(), - "auth ok but volume ID read failed" - ); - return (None, Some(Error::AacsVidUnavailable)); - } - }; - let read_data_key = aacs::handshake::read_data_keys(session, &mut auth) - .ok() - .map(|(rdk, _)| rdk); - tracing::debug!( - target: "freemkv::disc", - phase = "handshake_ok", - cert_index = idx, - has_read_data_key = read_data_key.is_some(), - ); - return ( - Some(HandshakeResult { - volume_id, - read_data_key, - }), - None, - ); - } - Err(e) => { - last_err_code = Some(e.code()); - // Log the real SCSI sense triple, not `e.code()` — - // `code()` collapses every ScsiError to the flat - // E_SCSI_ERROR constant and carries no sense key, - // so it has no diagnostic value for auth-failure - // routing. - let sense = e.scsi_sense(); - // Drive wedge senses (ILLEGAL_REQUEST, sense key - // 0x05). The drive isn't merely rejecting our - // cert — it's signalling it won't talk to us - // anymore. Trying more certs makes the wedge worse, - // so bail out immediately. NOTE: this must read the - // sense key off the structured ScsiSense, NOT off - // `e.code()`; `code()` is a flat constant for every - // ScsiError so the old `(code >> 8) & 0xFF` guard - // never matched and was dead code (the very wedge - // this defense exists to prevent could recur). - if sense.map(|s| s.is_illegal_request()).unwrap_or(false) { - tracing::warn!( - target: "freemkv::disc", - phase = "handshake_wedge_detected", - cert_index = idx, - sense_key = sense.map(|s| s.sense_key), - asc = sense.map(|s| s.asc), - ascq = sense.map(|s| s.ascq), - "drive returned ILLEGAL_REQUEST during auth; bailing out to avoid wedge" - ); - return (None, Some(Error::AacsHostCertRejected)); - } - continue; - } + // 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. + let unlocker = AacsCertUnlocker { opts }; + match unlocker.authenticate(session) { + Ok(hs) => (Some(hs), None), + Err(e) => { + tracing::info!( + target: "freemkv::disc", + phase = "cert_handshake_outcome", + outcome = ?cert_unlock_outcome(&e), + "AACS cert handshake produced no VID; a key source may still supply this disc's key." + ); + (None, Some(unlock_error_to_error(e))) } } - tracing::info!( - target: "freemkv::disc", - phase = "vid_cert_rejected", - host_cert_count, - tried = host_cert_count.min(MAX_CERT_ATTEMPTS), - last_error_code = last_err_code, - "The drive rejected the AACS host certificate, so no Volume ID was obtained. Continuing; a key source may still supply this disc's key." - ); - (None, Some(Error::AacsHostCertRejected)) } /// Build a keys-free AACS state that carries only the Volume ID (+ version @@ -298,6 +333,29 @@ impl Disc { None if bus_encryption => 2, None => 1, }; + + // OEM bus-key gate (wrong-keys guard). A bus-encrypted disc (Content + // Certificate bus-encryption bit set) still carries bus encryption on + // its sectors; descrambling needs the `read_data_key` (bus key), which + // ONLY the AACS host-certificate cert-auth handshake produces. A + // VID-only OEM unlock path returns `read_data_key: None`, and a VID + // alone does NOT remove bus encryption — so if a handshake ran (live + // drive) and yielded a VID but no bus key on a bus-encrypted disc, the + // bytes would decrypt to garbage. Fail loudly here instead. + // + // Gated on `handshake.is_some()` so the two preserved cases never + // regress: (1) file-backed/ISO scans reach here with `handshake = None` + // and have already had bus encryption removed at read time; (2) AACS 1.0 + // BD is not bus-encrypted, so `bus_encryption` is false and the gate is + // skipped (its `read_data_key` is legitimately absent). + if bus_encryption && handshake.is_some_and(|h| h.read_data_key.is_none()) { + tracing::warn!( + target: "freemkv::disc", + phase = "bus_key_unavailable", + "Disc declares bus encryption but the handshake produced no read_data_key; a VID-only/OEM unlock cannot remove bus encryption. Refusing to proceed with a key that would decrypt to garbage." + ); + return Err(Error::AacsBusKeyUnavailable); + } // MKB_RO/RW are allocated to a fixed ~128 MiB and zero-padded; trim to // the real record length (same as `read_aacs_inputs`). Without this the // MKB stashed on `AacsState` — which `Disc::inputs()` and the device/ @@ -712,6 +770,90 @@ 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. + // --------------------------------------------------------------- + + fn disc_with_cert(cert_type: u8, bus_encryption: bool) -> (MemDisc, udf::UdfFs) { + let mut disc = MemDisc::new(); + let udf = build_aacs_fs( + &mut disc, + &[ + AacsFile { + name: "Unit_Key_RO.inf", + icb_lba: 60, + data_lba: 5000, + contents: vec![0xAB; 32], + }, + AacsFile { + name: "Content000.cer", + icb_lba: 62, + data_lba: 6000, + contents: build_content_cert(cert_type, bus_encryption), + }, + ], + ); + (disc, udf) + } + + /// Live-drive (handshake Some) + bus_encryption cert + NO read_data_key + /// → AacsBusKeyUnavailable. This is the wrong-keys guard: a VID-only/OEM + /// unlock cannot remove bus encryption. + #[test] + fn resolve_vid_only_bus_encrypted_live_drive_without_rdk_errors() { + let (mut disc, udf) = disc_with_cert(0x01, true); + let hs = HandshakeResult { + volume_id: [0x11u8; 16], + read_data_key: None, + }; + let err = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)) + .expect_err("bus-encrypted disc with no bus key must hard-error"); + assert!(matches!(err, Error::AacsBusKeyUnavailable)); + } + + /// Live-drive + bus_encryption cert + read_data_key PRESENT → Ok (the cert + /// handshake produced the bus key, as required). + #[test] + fn resolve_vid_only_bus_encrypted_live_drive_with_rdk_ok() { + let (mut disc, udf) = disc_with_cert(0x01, true); + let hs = HandshakeResult { + volume_id: [0x11u8; 16], + read_data_key: Some([0x22u8; 16]), + }; + let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("bus key present → ok"); + assert!(st.bus_encryption); + assert_eq!(st.read_data_key, Some([0x22u8; 16])); + } + + /// ISO scan (handshake None) of a bus_encryption disc → Ok. Bus encryption + /// was already removed at read time; the gate must NOT fire without a + /// handshake (no UHD-ISO-mux regression). + #[test] + fn resolve_vid_only_bus_encrypted_iso_no_handshake_ok() { + let (mut disc, udf) = disc_with_cert(0x01, true); + let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("ISO bus disc → ok"); + assert!(st.bus_encryption); + assert_eq!(st.read_data_key, None); + } + + /// AACS 1.0 BD (V10 cert, bus_encryption off) on a live drive with NO + /// read_data_key → Ok. read_data_key is legitimately absent for AACS 1.0; + /// the gate must NOT fire when bus_encryption is false. + #[test] + fn resolve_vid_only_aacs10_live_drive_without_rdk_ok() { + let (mut disc, udf) = disc_with_cert(0x00, false); + let hs = HandshakeResult { + volume_id: [0x11u8; 16], + read_data_key: None, + }; + let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("AACS 1.0 → ok"); + assert!(!st.bus_encryption); + assert_eq!(st.read_data_key, None); + } + /// With NO handshake, volume_id defaults to all-zero (encrypt.rs /// `.unwrap_or([0u8; 16])`) and read_data_key is None. #[test] @@ -798,10 +940,13 @@ mod tests { /// A minimal in-test KeySource that yields no keys but a fixed cert list. struct CertSource(Vec<aacs::HostCert>); impl crate::KeySource for CertSource { - fn next_key(&mut self, _inputs: &crate::keysource::DiscInputs) -> Option<crate::disc::Key> { - None + fn get_uk( + &self, + _ctx: &dyn crate::keysource::ResolveCtx, + ) -> Result<Vec<crate::aacs::UnitKey>> { + Ok(Vec::new()) } - fn host_certs(&self) -> Vec<aacs::HostCert> { + fn host_certs(&self, _mkb: Option<u32>) -> Vec<aacs::HostCert> { self.0.clone() } } @@ -809,7 +954,7 @@ mod tests { #[test] fn collect_host_certs_empty_when_no_credentials_no_sources() { let opts = ScanOptions::default(); - assert!(Disc::collect_host_certs(&opts).is_empty()); + assert!(Disc::collect_host_certs(&opts, None).is_empty()); } #[test] @@ -820,7 +965,7 @@ mod tests { }), ..Default::default() }; - let certs = Disc::collect_host_certs(&opts); + let certs = Disc::collect_host_certs(&opts, None); assert_eq!(certs.len(), 1); assert_eq!(certs[0].private_key, [1u8; 20]); } @@ -831,7 +976,7 @@ mod tests { key_sources: vec![Box::new(CertSource(vec![fake_cert(2)]))], ..Default::default() }; - let certs = Disc::collect_host_certs(&opts); + let certs = Disc::collect_host_certs(&opts, None); assert_eq!(certs.len(), 1); assert_eq!(certs[0].private_key, [2u8; 20]); } @@ -851,11 +996,62 @@ mod tests { ], ..Default::default() }; - let mut tags: Vec<u8> = Disc::collect_host_certs(&opts) + let mut tags: Vec<u8> = Disc::collect_host_certs(&opts, None) .iter() .map(|c| c.private_key[0]) .collect(); tags.sort_unstable(); 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. + // --------------------------------------------------------------- + + #[test] + fn unlock_error_maps_to_legacy_error_variants() { + use crate::unlock::UnlockError; + // No host cert keeps the AacsNoHostCert sentinel path. + match unlock_error_to_error(UnlockError::NoUsableHostCert { mkb: Some(68) }) { + Error::AacsNoHostCert { path } => assert_eq!(path, "<no host cert>"), + other => panic!("expected AacsNoHostCert, got {other:?}"), + } + assert!(matches!( + unlock_error_to_error(UnlockError::VidUnavailable), + Error::AacsVidUnavailable + )); + assert!(matches!( + unlock_error_to_error(UnlockError::HandshakeRejected), + Error::AacsHostCertRejected + )); + assert!(matches!( + unlock_error_to_error(UnlockError::CertRevoked { mkb: None }), + Error::AacsHostCertRejected + )); + } + + #[test] + fn cert_unlock_outcome_maps_to_structured_trace_step() { + use crate::aacs::UnlockOutcome; + use crate::unlock::UnlockError; + assert_eq!( + cert_unlock_outcome(&UnlockError::NoUsableHostCert { mkb: Some(77) }), + UnlockOutcome::NoUsableHostCert { mkb: Some(77) } + ); + assert_eq!( + cert_unlock_outcome(&UnlockError::VidUnavailable), + UnlockOutcome::VidUnavailable + ); + assert_eq!( + cert_unlock_outcome(&UnlockError::HandshakeRejected), + UnlockOutcome::HandshakeRejected + ); + // A SCSI/transport error folds to HandshakeRejected at the trace layer. + assert_eq!( + cert_unlock_outcome(&UnlockError::Scsi(4000)), + UnlockOutcome::HandshakeRejected + ); + } } diff --git a/src/disc/extract.rs b/src/disc/extract.rs index d508ac6..b4b68d2 100644 --- a/src/disc/extract.rs +++ b/src/disc/extract.rs @@ -24,7 +24,7 @@ use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::atomic::Ordering; -const SECTOR_LEN: usize = 2048; +use crate::consts::SECTOR_BYTES; /// AACS aligned unit = 3 sectors / 6144 bytes. Content reads are issued in /// multiples of this so the decrypt step always sees whole units. const AACS_UNIT_SECTORS: u32 = 3; @@ -282,7 +282,7 @@ impl Disc { for &(abs_lba, byte_len) in &pf.extents { extents.push(crate::disc::Extent { start_lba: abs_lba, - sector_count: (byte_len as u64).div_ceil(SECTOR_LEN as u64) as u32, + sector_count: (byte_len as u64).div_ceil(SECTOR_BYTES as u64) as u32, }); } } @@ -450,7 +450,7 @@ fn extract_one_file<S: SectorSource>( } let mut written: u64 = 0; - let mut buf = vec![0u8; READ_BATCH_SECTORS as usize * SECTOR_LEN]; + let mut buf = vec![0u8; READ_BATCH_SECTORS as usize * SECTOR_BYTES]; 'extents: for &(abs_lba, byte_len) in &pf.extents { if written >= pf.size { break; @@ -467,7 +467,7 @@ fn extract_one_file<S: SectorSource>( // the per-extent re-anchoring in the mux read paths // (`mux/disc.rs`, `sector/prefetched.rs`). No-op for CSS / None. dec.set_unit_base(abs_lba); - let sectors = (byte_len as u64).div_ceil(SECTOR_LEN as u64) as u32; + let sectors = (byte_len as u64).div_ceil(SECTOR_BYTES as u64) as u32; let mut sector_off: u32 = 0; while sector_off < sectors { let mut batch = (sectors - sector_off).min(READ_BATCH_SECTORS); @@ -486,7 +486,7 @@ fn extract_one_file<S: SectorSource>( batch -= batch % AACS_UNIT_SECTORS; } let lba = abs_lba + sector_off; - let want = batch as usize * SECTOR_LEN; + let want = batch as usize * SECTOR_BYTES; let read_ok = read_batch(dec, lba, batch, &mut buf[..want]); let chunk_bytes = want as u64; // Clip the chunk to the remaining file size on the final extent. @@ -1000,11 +1000,11 @@ mod tests { s[0..2].copy_from_slice(&266u16.to_le_bytes()); // Extended File Entry // ad_type 0 = Short AD (icb flags low 3 bits at offset 34). s[34..36].copy_from_slice(&0u16.to_le_bytes()); - let size = sectors_each * SECTOR_LEN as u32 * 2; + let size = sectors_each * SECTOR_BYTES as u32 * 2; s[56..64].copy_from_slice(&(size as u64).to_le_bytes()); // info_length s[208..212].copy_from_slice(&0u32.to_le_bytes()); // l_ea s[212..216].copy_from_slice(&16u32.to_le_bytes()); // l_ad = 2 Short ADs - let ext_len = sectors_each * SECTOR_LEN as u32; // bytes, type-0 recorded + let ext_len = sectors_each * SECTOR_BYTES as u32; // bytes, type-0 recorded // AD #0 s[216..220].copy_from_slice(&(ext_len & 0x3FFF_FFFF).to_le_bytes()); s[220..224].copy_from_slice(&data_lba_a.to_le_bytes()); diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 0d48c86..10d9482 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -370,8 +370,12 @@ pub enum SampleRate { S44_1, /// 48 kHz — standard BD/DVD/UHD audio S48, + /// 88.2 kHz — 44.1 kHz-family high-res TrueHD (music BD) + S88_2, /// 96 kHz — high-res BD audio S96, + /// 176.4 kHz — 44.1 kHz-family high-res TrueHD (music BD) + S176_4, /// 192 kHz — highest BD audio (LPCM) S192, /// 48/96 kHz combo (secondary audio resampled) @@ -432,16 +436,26 @@ pub struct Extent { pub sector_count: u32, } -/// Correct the channel count of a title's TrueHD audio streams by probing the -/// first decrypted access units. The MPLS `audio_format` field declares the -/// BASE layout (often 5.1) even for a 7.1/Atmos TrueHD track; the real count is -/// in the MLP major sync. `reader` must yield DECRYPTED sectors (the m2ts is -/// AACS-encrypted, so this can only run at mux time, not scan). Reads a bounded -/// window of the title's first extent. Also regenerates the stream's codec -/// label when it was the basic descriptor for the (now corrected) count — -/// richer editorial labels (e.g. "Dolby Atmos") are left untouched. +/// Correct a title's TrueHD audio-stream metadata by probing the first +/// decrypted access units — channel count, real sample rate, and Atmos +/// detection in a single major-sync read. The MPLS descriptors declare the BASE +/// layout (often 5.1 / a container-guessed rate) even for a 7.1/Atmos TrueHD +/// track; the truth is in the MLP major sync. `reader` must yield DECRYPTED +/// sectors (the m2ts is AACS-encrypted, so this can only run at mux time, not +/// scan). Reads a bounded window of the title's first extent. +/// +/// Corrections, each individually guarded so a malformed field never writes a +/// wrong header: +/// - **Channels**: from the presentation channel masks (as before). +/// - **Sample rate**: from the whitelisted rate nibble; left untouched on an +/// unknown rate or no major sync. +/// - **Atmos**: when a 4th substream is detected AND the stream still carries +/// the basic descriptor label, the label is promoted to the Atmos form; +/// richer editorial labels (e.g. an existing "Dolby Atmos") are left intact. pub(crate) fn correct_truehd_channels(reader: &mut dyn SectorSource, title: &mut DiscTitle) { - use crate::mux::codec::truehd::truehd_channels_from_stream; + use crate::mux::codec::truehd::{ + truehd_channels, truehd_sample_rate_hz, truehd_sync_info_from_stream, + }; let pids: Vec<u16> = title .streams @@ -496,18 +510,44 @@ pub(crate) fn correct_truehd_channels(reader: &mut dyn SectorSource, title: &mut let Some(payload) = payloads.get(&a.pid) else { continue; }; - let Some(count) = truehd_channels_from_stream(payload) else { + // One major-sync read yields channels, sample rate and the Atmos signal. + let Some(info) = truehd_sync_info_from_stream(payload) else { continue; }; - let new_ch = AudioChannels::from_count(count); - if new_ch == AudioChannels::Unknown || new_ch == a.channels { - continue; - } + + // Whether the label is still the plain descriptor (no richer editorial + // label). Captured against the CURRENT channels before any correction so + // a label promotion only happens when nothing editorial is present. let was_basic = a.label == crate::labels::generate_audio_label(&a.codec, &a.channels, a.secondary); - a.channels = new_ch; + + // (1) Channels — only when the major sync resolves a different layout. + if let Some(count) = truehd_channels(info.format_info) { + let new_ch = AudioChannels::from_count(count); + if new_ch != AudioChannels::Unknown && new_ch != a.channels { + a.channels = new_ch; + } + } + + // (2) Sample rate — whitelisted rates only; an unknown nibble or a rate + // that maps to no enum variant leaves the container value untouched + // (never write a wrong SamplingFrequency). + if let Some(hz) = truehd_sample_rate_hz(info.format_info) { + let new_sr = SampleRate::from_hz(hz); + if new_sr != SampleRate::Unknown && new_sr != a.sample_rate { + a.sample_rate = new_sr; + } + } + + // (3) Label — refresh to the corrected channels; promote to the Atmos + // form only when the stream carried the basic descriptor (no editorial + // Atmos already) AND a 4th substream was positively detected. if was_basic { - a.label = crate::labels::generate_audio_label(&a.codec, &new_ch, a.secondary); + a.label = if info.is_atmos == Some(true) { + crate::labels::generate_audio_label_atmos(&a.codec, &a.channels, a.secondary) + } else { + crate::labels::generate_audio_label(&a.codec, &a.channels, a.secondary) + }; } } } @@ -840,7 +880,9 @@ impl SampleRate { match self { SampleRate::S44_1 => 44100.0, SampleRate::S48 | SampleRate::S48_96 | SampleRate::S48_192 => 48000.0, + SampleRate::S88_2 => 88200.0, SampleRate::S96 => 96000.0, + SampleRate::S176_4 => 176400.0, SampleRate::S192 => 192000.0, SampleRate::Unknown => 48000.0, } @@ -851,7 +893,9 @@ impl SampleRate { match hz { 44100 => SampleRate::S44_1, 48000 => SampleRate::S48, + 88200 => SampleRate::S88_2, 96000 => SampleRate::S96, + 176400 => SampleRate::S176_4, 192000 => SampleRate::S192, _ => SampleRate::Unknown, } @@ -1037,7 +1081,9 @@ enum_str!( [ ("44.1kHz", SampleRate::S44_1), ("48kHz", SampleRate::S48), + ("88.2kHz", SampleRate::S88_2), ("96kHz", SampleRate::S96), + ("176.4kHz", SampleRate::S176_4), ("192kHz", SampleRate::S192), ("48/96kHz", SampleRate::S48_96), ("48/192kHz", SampleRate::S48_192), @@ -1894,7 +1940,7 @@ pub enum Key { /// VUK → unit keys. A source hands in its FULL device-key set, because /// choosing which one applies *is* the MKB walk (derivation), and all /// derivation lives here — never in a source. - Device(Vec<crate::aacs::keydb::DeviceKey>), + Device(Vec<crate::aacs::types::DeviceKey>), /// Processing key(s) (AACS PK). libfreemkv applies each against the MKB /// → media key → VUK → unit keys. Processing(Vec<[u8; 16]>), @@ -5023,7 +5069,7 @@ mod tests { let good = mf.ranges_with(&[SectorStatus::Finished]); let bad_ranges = mf.ranges_with(&[SectorStatus::NonTrimmed]); let disc_bytes = sectors as u64 * 2048; - const SEC: u64 = 2048; + const SEC: u64 = crate::consts::SECTOR_BYTES as u64; // The first failing batch starts at LBA 320; everything before it read // cleanly and must be Finished. diff --git a/src/disc/patch.rs b/src/disc/patch.rs index 8284299..0d69cad 100644 --- a/src/disc/patch.rs +++ b/src/disc/patch.rs @@ -2588,7 +2588,7 @@ mod tests { range_sectors: 1, }; - let timed_out = check_range_watchdog(&mut state, &frame, &*shared); + let timed_out = check_range_watchdog(&mut state, &frame, &shared); assert!(!timed_out, "range must not time out immediately"); // With correct initialization bytes_good_now == range_bytes_good, // so the `bytes_good_now > range_bytes_good` branch does NOT fire diff --git a/src/drive/mod.rs b/src/drive/mod.rs index b30e4da..b460bb3 100644 --- a/src/drive/mod.rs +++ b/src/drive/mod.rs @@ -63,6 +63,12 @@ pub struct Drive { /// `init()`, if any matched. `None` means no unlocker matched and the /// drive runs in stock mode (host-cert AACS handshake carries discs). unlocker_name: Option<String>, + /// The OEM Volume ID the matching unlocker returned from `unlock()` at + /// `init()`, stashed for the AACS handshake phase (which reads it via + /// [`Drive::oem_vid`] instead of a separate VID read). `None` when no + /// unlocker matched or the matching unlocker produced no VID — the cert + /// handshake then acquires the VID. + oem_vid: Option<[u8; 16]>, /// True once `init()` has run (whether or not an unlocker matched). init_ran: bool, /// Lazily-computed registry-match name for `platform_name()`'s `&str` @@ -108,6 +114,7 @@ impl Drive { Ok(Drive { scsi: transport, unlocker_name: None, + oem_vid: None, init_ran: false, matched_name_cache: std::sync::OnceLock::new(), drive_id, @@ -128,6 +135,7 @@ impl Drive { Drive { scsi, unlocker_name: None, + oem_vid: None, init_ran: false, matched_name_cache: std::sync::OnceLock::new(), drive_id: DriveId { @@ -216,6 +224,14 @@ impl Drive { self.scsi.as_mut() } + /// The OEM Volume ID a matching [`crate::unlock::Unlocker`] returned at + /// [`Drive::init`], if any. The AACS handshake uses this to skip the cert + /// handshake when an unlocker already supplied the VID. `None` when no + /// unlocker matched or it produced no VID. + pub(crate) fn oem_vid(&self) -> Option<[u8; 16]> { + self.oem_vid + } + pub fn wait_ready(&mut self) -> Result<()> { let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00]; let t0 = std::time::Instant::now(); @@ -397,8 +413,11 @@ impl Drive { let r = crate::unlock::route_unlock(self.scsi.as_mut(), &self.drive_id); self.init_ran = true; let r = match r { - Ok(Some(name)) => { + Ok(Some((name, vid))) => { self.unlocker_name = Some(name); + // Stash the OEM Volume ID the unlocker returned for the AACS + // handshake phase (do_handshake reads it via `oem_vid()`). + self.oem_vid = Some(vid.0); // The matched unlocker may also be able to raise the drive to // its maximum read speed. Best-effort: a failure here must NOT // fail the rip — a slow drive still rips. Log and continue. @@ -1315,7 +1334,15 @@ mod command_tests { } } - fn recording(outcome: TransportOutcome) -> (Drive, Arc<Mutex<Vec<u8>>>, Arc<Mutex<u32>>) { + /// A drive under test plus the handles that observe it: captured CDB bytes + /// and the timeout counter. + struct RecordingHarness { + drive: Drive, + cdb: Arc<Mutex<Vec<u8>>>, + timeouts: Arc<Mutex<u32>>, + } + + fn recording(outcome: TransportOutcome) -> RecordingHarness { let cdb = Arc::new(Mutex::new(Vec::new())); let to = Arc::new(Mutex::new(0u32)); let t = RecordingTransport { @@ -1323,7 +1350,11 @@ mod command_tests { last_timeout: to.clone(), outcome, }; - (Drive::from_transport_for_test(Box::new(t)), cdb, to) + RecordingHarness { + drive: Drive::from_transport_for_test(Box::new(t)), + cdb, + timeouts: to, + } } #[test] @@ -1331,7 +1362,11 @@ mod command_tests { // Drive::read issues READ(10) (0x28). LBA bytes 2..5 big-endian, // transfer length bytes 7..8 big-endian (MMC-6). No FUA on this // path (byte 1 == 0). Distinct nibbles catch a swapped shift. - let (mut d, cdb, _to) = recording(TransportOutcome::Ok(4096)); + let RecordingHarness { + drive: mut d, + cdb, + timeouts: _to, + } = recording(TransportOutcome::Ok(4096)); let mut buf = vec![0u8; 4096]; let n = d.read(0x00AB_CDEF, 2, &mut buf, false).unwrap(); assert_eq!(n, 4096, "returns transport bytes_transferred"); @@ -1346,12 +1381,20 @@ mod command_tests { fn read_recovery_flag_selects_60s_timeout() { // recovery=true must use READ_RECOVERY_TIMEOUT_MS (60 s); false // uses READ_TIMEOUT_MS (10 s). Doc: patch pass vs copy sweep. - let (mut d, _cdb, to) = recording(TransportOutcome::Ok(2048)); + let RecordingHarness { + drive: mut d, + cdb: _cdb, + timeouts: to, + } = recording(TransportOutcome::Ok(2048)); let mut buf = vec![0u8; 2048]; d.read(0, 1, &mut buf, true).unwrap(); assert_eq!(*to.lock().unwrap(), crate::scsi::READ_RECOVERY_TIMEOUT_MS); - let (mut d2, _c2, to2) = recording(TransportOutcome::Ok(2048)); + let RecordingHarness { + drive: mut d2, + cdb: _c2, + timeouts: to2, + } = recording(TransportOutcome::Ok(2048)); d2.read(0, 1, &mut buf, false).unwrap(); assert_eq!(*to2.lock().unwrap(), crate::scsi::READ_TIMEOUT_MS); } @@ -1366,7 +1409,11 @@ mod command_tests { asc: 0x11, ascq: 0x05, }; - let (mut d, _cdb, _to) = recording(TransportOutcome::Scsi(0x02, Some(sense))); + let RecordingHarness { + drive: mut d, + cdb: _cdb, + timeouts: _to, + } = recording(TransportOutcome::Scsi(0x02, Some(sense))); let mut buf = vec![0u8; 2048]; let err = d.read(0x1234, 1, &mut buf, false).unwrap_err(); match err { @@ -1387,7 +1434,11 @@ mod command_tests { fn read_transport_failure_status_preserved_for_marginal_routing() { // Status 0xFF (TRANSPORT_FAILURE) with no sense must surface in // DiscRead.status so is_scsi_transport_failure() routes it. - let (mut d, _cdb, _to) = recording(TransportOutcome::Scsi( + let RecordingHarness { + drive: mut d, + cdb: _cdb, + timeouts: _to, + } = recording(TransportOutcome::Scsi( crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE, None, )); @@ -1402,7 +1453,11 @@ mod command_tests { // When the halt flag is set, checked_exec returns Halted BEFORE // execute(); the error must be Halted (not DiscRead), so the // recovery loop distinguishes user-stop from a read failure. - let (mut d, cdb, _to) = recording(TransportOutcome::Ok(2048)); + let RecordingHarness { + drive: mut d, + cdb, + timeouts: _to, + } = recording(TransportOutcome::Ok(2048)); d.halt(); let mut buf = vec![0u8; 2048]; let err = d.read(0, 1, &mut buf, false).unwrap_err(); @@ -1417,7 +1472,11 @@ mod command_tests { fn clear_halt_reenables_reads() { // halt() then clear_halt() must allow reads again — the flag is // not sticky. - let (mut d, _cdb, _to) = recording(TransportOutcome::Ok(2048)); + let RecordingHarness { + drive: mut d, + cdb: _cdb, + timeouts: _to, + } = recording(TransportOutcome::Ok(2048)); d.halt(); d.clear_halt(); let mut buf = vec![0u8; 2048]; @@ -1429,7 +1488,11 @@ mod command_tests { // Single-shot contract: Drive::read returns exactly what the // transport reported, never a smaller count silently. Transport // says a full 32-sector batch (65536 bytes) succeeded. - let (mut d, _cdb, _to) = recording(TransportOutcome::Ok(65536)); + let RecordingHarness { + drive: mut d, + cdb: _cdb, + timeouts: _to, + } = recording(TransportOutcome::Ok(65536)); let mut buf = vec![0u8; 65536]; assert_eq!(d.read(0, 32, &mut buf, false).unwrap(), 65536); } @@ -1493,7 +1556,13 @@ mod command_tests { } } - fn chunking(max_bytes: usize, fail_on: Option<usize>) -> (Drive, Arc<Mutex<Vec<(u32, u16)>>>) { + /// A drive under test plus the handle recording each `(lba, count)` read. + struct ChunkingHarness { + drive: Drive, + reads: Arc<Mutex<Vec<(u32, u16)>>>, + } + + fn chunking(max_bytes: usize, fail_on: Option<usize>) -> ChunkingHarness { let reads = Arc::new(Mutex::new(Vec::new())); let t = ChunkingTransport { max_bytes, @@ -1501,7 +1570,10 @@ mod command_tests { fail_on, seen: 0, }; - (Drive::from_transport_for_test(Box::new(t)), reads) + ChunkingHarness { + drive: Drive::from_transport_for_test(Box::new(t)), + reads, + } } #[test] @@ -1509,7 +1581,10 @@ mod command_tests { // max_transfer = 4 sectors (4 * 2048 = 8192 bytes). A read of 10 // sectors at LBA 0 must split into 3 READ(10) CDBs: (0,4), (4,4), // (8,2). The assembled buffer is the full 10*2048 bytes. - let (mut d, reads) = chunking(4 * 2048, None); + let ChunkingHarness { + drive: mut d, + reads, + } = chunking(4 * 2048, None); let mut buf = vec![0u8; 10 * 2048]; let n = d.read(0, 10, &mut buf, false).unwrap(); assert_eq!(n, 10 * 2048, "returns total bytes across all chunks"); @@ -1526,7 +1601,10 @@ mod command_tests { // Same 4-sector cap; fail the 2nd chunk (index 1), which covers // LBA 4. The error must be DiscRead with sector = 4 (the failing // chunk's LBA), NOT the request base LBA 0. - let (mut d, reads) = chunking(4 * 2048, Some(1)); + let ChunkingHarness { + drive: mut d, + reads, + } = chunking(4 * 2048, Some(1)); let mut buf = vec![0u8; 10 * 2048]; let err = d.read(0, 10, &mut buf, false).unwrap_err(); match err { @@ -1546,7 +1624,10 @@ mod command_tests { fn read_small_request_is_single_unchunked_read() { // count <= max_sectors must take the single-read path unchanged: a // 3-sector read under a 4-sector cap is exactly one READ(10). - let (mut d, reads) = chunking(4 * 2048, None); + let ChunkingHarness { + drive: mut d, + reads, + } = chunking(4 * 2048, None); let mut buf = vec![0u8; 3 * 2048]; assert_eq!(d.read(0, 3, &mut buf, false).unwrap(), 3 * 2048); assert_eq!(*reads.lock().unwrap(), vec![(0, 3)], "single CDB, no split"); diff --git a/src/error.rs b/src/error.rs index b9fbfb6..002a4cb 100644 --- a/src/error.rs +++ b/src/error.rs @@ -87,6 +87,7 @@ pub const E_VID_CDB_UNAVAILABLE: u16 = 7021; pub const E_NO_DISC_KEY: u16 = 7022; pub const E_CSS_KEY_MISSING: u16 = 7023; pub const E_AACS_NO_HOST_CERT: u16 = 7024; +pub const E_AACS_BUS_KEY_UNAVAILABLE: u16 = 7025; // Keydb (8xxx) pub const E_KEYDB_CONNECT: u16 = 8000; @@ -347,6 +348,17 @@ pub enum Error { AacsNoHostCert { path: String, }, + /// A bus-encrypted disc (AACS 2.0 / UHD, Content Certificate bus-encryption + /// bit set) was scanned on a live drive, the Volume ID was obtained, but no + /// `read_data_key` (bus key) was produced — so the on-disc bytes are still + /// bus-encrypted and would decrypt to garbage. The bus key is derivable ONLY + /// from the AACS host-certificate cert-auth handshake; a VID-only OEM unlock + /// path (which returns no bus key) is insufficient for such a disc. Surfaced + /// instead of silently producing a corrupt rip. NOT raised for AACS 1.0 BD + /// (no bus encryption, `read_data_key` legitimately absent) nor for + /// file-backed (ISO) scans, where bus encryption was already removed at read + /// time and no handshake runs. + AacsBusKeyUnavailable, // Keydb (8xxx) KeydbConnect { @@ -560,6 +572,7 @@ impl Error { Error::NoDiscKey { .. } => E_NO_DISC_KEY, Error::CssKeyMissing => E_CSS_KEY_MISSING, Error::AacsNoHostCert { .. } => E_AACS_NO_HOST_CERT, + Error::AacsBusKeyUnavailable => E_AACS_BUS_KEY_UNAVAILABLE, Error::KeydbConnect { .. } => E_KEYDB_CONNECT, Error::KeydbHttp { .. } => E_KEYDB_HTTP, Error::KeydbInvalid => E_KEYDB_INVALID, diff --git a/src/ifo.rs b/src/ifo.rs index 2b8f5d8..341d454 100644 --- a/src/ifo.rs +++ b/src/ifo.rs @@ -232,7 +232,7 @@ pub struct DvdSubtitleAttr { const VMG_MAGIC: &[u8; 12] = b"DVDVIDEO-VMG"; const VTS_MAGIC: &[u8; 12] = b"DVDVIDEO-VTS"; -const SECTOR_SIZE: usize = 2048; +use crate::consts::SECTOR_BYTES; // ── Helper: safe binary reads ─────────────────────────────────────────────── @@ -347,7 +347,7 @@ pub fn parse_vmg(reader: &mut dyn SectorSource, udf: &UdfFs) -> Result<DvdInfo> // Read TT_SRPT — it's at the given sector offset relative to the start of VIDEO_TS.IFO. // In the IFO file data we already have, sector offsets are relative to the IFO start. let tt_srpt_offset = (tt_srpt_sector as usize) - .checked_mul(SECTOR_SIZE) + .checked_mul(SECTOR_BYTES) .ok_or(Error::IfoParse)?; // TT_SRPT may be beyond what we read; if so, it's embedded in the file data @@ -481,7 +481,7 @@ fn parse_vts( // Parse PGC information table let pgcit_offset = (pgcit_sector as usize) - .checked_mul(SECTOR_SIZE) + .checked_mul(SECTOR_BYTES) .ok_or(Error::IfoParse)?; let titles = parse_pgcit(&vts_data, pgcit_offset, titles_info)?; @@ -1562,8 +1562,8 @@ mod tests { assert!(!c.is_secondary_block_piece()); // block_mode=1 (first cell of block), block_type=1 (angle block): - // 0b01_01_0000 = 0x50. This is the angle we KEEP — not secondary. - let c = CellCategory::decode(0b01_01_0000); + // 0b0101_0000 = 0x50. This is the angle we KEEP — not secondary. + let c = CellCategory::decode(0b0101_0000); assert_eq!(c.block_mode, 1); assert_eq!(c.block_type, 1); assert!(!c.is_plain_feature()); @@ -1571,10 +1571,10 @@ mod tests { // block_mode=2 (in block) / 3 (last of block) of an angle block // (block_type=1) → secondary. - assert!(CellCategory::decode(0b10_01_0000).is_secondary_block_piece()); - assert!(CellCategory::decode(0b11_01_0000).is_secondary_block_piece()); + assert!(CellCategory::decode(0b1001_0000).is_secondary_block_piece()); + assert!(CellCategory::decode(0b1101_0000).is_secondary_block_piece()); // First cell of the block (block_mode=1) is NEVER secondary. - assert!(!CellCategory::decode(0b01_01_0000).is_secondary_block_piece()); + assert!(!CellCategory::decode(0b0101_0000).is_secondary_block_piece()); // The low flags (seamless_play bit3, interleaved bit2, stc bit1, // seamless_angle bit0) on an otherwise-plain cell must NOT make it @@ -1617,9 +1617,9 @@ mod tests { chapters: 2, duration_secs: 100.0, cells: vec![ - cell(0, 9, 0b10_01_0000), // in-block cell of angle block → drop - cell(10, 19, 0b11_01_0000), // last cell of angle block → drop - cell(20, 119, 0x00), // feature starts here + cell(0, 9, 0b1001_0000), // in-block cell of angle block → drop + cell(10, 19, 0b1101_0000), // last cell of angle block → drop + cell(20, 119, 0x00), // feature starts here cell(120, 219, 0x00), ], chapter_times: vec![0.0, 50.0], @@ -1640,7 +1640,7 @@ mod tests { let t = DvdTitle { chapters: 1, duration_secs: 100.0, - cells: vec![cell(0, 9, 0b10_01_0000), cell(10, 19, 0b11_01_0000)], + cells: vec![cell(0, 9, 0b1001_0000), cell(10, 19, 0b1101_0000)], chapter_times: vec![0.0], palette: None, }; diff --git a/src/io/file_sector_source/mod.rs b/src/io/file_sector_source/mod.rs index b3e48a2..b5f47c4 100644 --- a/src/io/file_sector_source/mod.rs +++ b/src/io/file_sector_source/mod.rs @@ -72,7 +72,7 @@ use std::path::Path; use crate::error::{Error, Result}; use crate::sector::SectorSource; -const SECTOR_SIZE: usize = 2048; +use crate::consts::SECTOR_BYTES; /// Bytes-read threshold per `posix_fadvise(DONTNEED)` drop on the /// read side. Mirrors `WRITEBACK_CHUNK_BYTES` so the read-side page @@ -134,7 +134,7 @@ impl FileSectorSource { .metadata() .map_err(|e| Error::IoError { source: e })? .len(); - let sectors = len / SECTOR_SIZE as u64; + let sectors = len / SECTOR_BYTES as u64; if sectors > u32::MAX as u64 { return Err(Error::IsoTooLarge { path: path.to_string_lossy().into_owned(), @@ -170,7 +170,7 @@ impl SectorSource for FileSectorSource { _recovery: bool, ) -> Result<usize> { let count = count as u32; - let bytes = count as usize * SECTOR_SIZE; + let bytes = count as usize * SECTOR_BYTES; debug_assert!( out.len() >= bytes, "FileSectorSource::read_sectors: out len {} < requested {}", @@ -180,7 +180,7 @@ impl SectorSource for FileSectorSource { if count == 0 { return Ok(0); } - let offset = lba as u64 * SECTOR_SIZE as u64; + let offset = lba as u64 * SECTOR_BYTES as u64; self.file .seek(SeekFrom::Start(offset)) .map_err(|e| Error::IoError { source: e })?; @@ -224,7 +224,7 @@ mod tests { /// verify any sector by content alone. fn make_iso(path: &std::path::Path, sectors: u32) { let mut f = std::fs::File::create(path).unwrap(); - let mut chunk = vec![0u8; SECTOR_SIZE]; + let mut chunk = vec![0u8; SECTOR_BYTES]; for n in 0..sectors { let b = (n & 0xff) as u8; chunk.iter_mut().for_each(|c| *c = b); @@ -249,7 +249,7 @@ mod tests { let mut src = FileSectorSource::open(&path).unwrap(); assert_eq!(src.capacity_sectors(), total); - let mut got = vec![0u8; SECTOR_SIZE]; + let mut got = vec![0u8; SECTOR_BYTES]; for lba in 0..total { src.read_sectors(lba, 1, &mut got, false).unwrap(); let expected = (lba & 0xff) as u8; @@ -270,12 +270,12 @@ mod tests { let mut src = FileSectorSource::open(&path).unwrap(); let span_lba = TEST_SPAN_SECTORS - 2; - let mut buf4 = vec![0u8; SECTOR_SIZE * 4]; + let mut buf4 = vec![0u8; SECTOR_BYTES * 4]; src.read_sectors(span_lba, 4, &mut buf4, false).unwrap(); for i in 0..4 { let lba = span_lba + i as u32; let expected = (lba & 0xff) as u8; - for b in &buf4[i * SECTOR_SIZE..(i + 1) * SECTOR_SIZE] { + for b in &buf4[i * SECTOR_BYTES..(i + 1) * SECTOR_BYTES] { assert_eq!(*b, expected, "byte mismatch at sub-sector {i}"); } } @@ -291,7 +291,7 @@ mod tests { make_iso(&path, total); let mut src = FileSectorSource::open(&path).unwrap(); - let mut got = vec![0u8; SECTOR_SIZE]; + let mut got = vec![0u8; SECTOR_BYTES]; src.read_sectors(TEST_SPAN_SECTORS + 1, 1, &mut got, false) .unwrap(); @@ -311,7 +311,7 @@ mod tests { let mut src = FileSectorSource::open(&path).unwrap(); assert_eq!(src.capacity_sectors(), total); - let mut got = vec![0u8; SECTOR_SIZE]; + let mut got = vec![0u8; SECTOR_BYTES]; src.read_sectors(0, 1, &mut got, false).unwrap(); src.read_sectors(total - 1, 1, &mut got, false).unwrap(); let expected = ((total - 1) & 0xff) as u8; @@ -330,15 +330,15 @@ mod tests { let mut src = FileSectorSource::open(&path).unwrap(); let req = (TEST_SPAN_SECTORS + 1) as u16; - let req_bytes = req as usize * SECTOR_SIZE; + let req_bytes = req as usize * SECTOR_BYTES; let mut big = vec![0u8; req_bytes]; src.read_sectors(0, req, &mut big, false).unwrap(); - assert!(big[..SECTOR_SIZE].iter().all(|b| *b == 0)); + assert!(big[..SECTOR_BYTES].iter().all(|b| *b == 0)); let last_lba = req as u32 - 1; let exp = (last_lba & 0xff) as u8; - let last_off = (req as usize - 1) * SECTOR_SIZE; + let last_off = (req as usize - 1) * SECTOR_BYTES; assert!( - big[last_off..last_off + SECTOR_SIZE] + big[last_off..last_off + SECTOR_BYTES] .iter() .all(|b| *b == exp) ); @@ -405,7 +405,7 @@ mod tests { assert_eq!(src.capacity_sectors(), 4); // Request 2 sectors starting at LBA 3 → sector 4 doesn't exist. - let mut buf = vec![0u8; 2 * SECTOR_SIZE]; + let mut buf = vec![0u8; 2 * SECTOR_BYTES]; let r = src.read_sectors(3, 2, &mut buf, false); let err = r.expect_err("reading past EOF must error, not short-read"); let io: std::io::Error = err.into(); @@ -418,21 +418,21 @@ mod tests { /// On a successful full read the returned count MUST equal /// `count * 2048` exactly — the declared byte count. Grounding: - /// `Ok(bytes)` where `bytes = count * SECTOR_SIZE`. + /// `Ok(bytes)` where `bytes = count * SECTOR_BYTES`. #[test] fn full_read_returns_exact_declared_bytes() { let dir = tempdir().unwrap(); let path = dir.path().join("exact.iso"); make_iso(&path, 16); let mut src = FileSectorSource::open(&path).unwrap(); - let mut buf = vec![0u8; 5 * SECTOR_SIZE]; + let mut buf = vec![0u8; 5 * SECTOR_BYTES]; let n = src.read_sectors(2, 5, &mut buf, false).unwrap(); - assert_eq!(n, 5 * SECTOR_SIZE, "must return exactly count*2048 bytes"); + assert_eq!(n, 5 * SECTOR_BYTES, "must return exactly count*2048 bytes"); } /// Capacity is `file_len / 2048` (floor); trailing bytes that don't /// complete a sector are NOT counted. A file of 4 sectors + 100 - /// extra bytes reports capacity 4. Grounding: `len / SECTOR_SIZE` + /// extra bytes reports capacity 4. Grounding: `len / SECTOR_BYTES` /// integer division in `open`. #[test] fn capacity_floors_partial_trailing_sector() { @@ -494,7 +494,7 @@ mod tests { #[test] fn dontneed_eviction_does_not_affect_data() { // 32 MiB default chunk = 16384 sectors; read a bit past it. - let total = (READ_DROP_CHUNK_BYTES_DEFAULT / SECTOR_SIZE as u64) as u32 + 64; + let total = (READ_DROP_CHUNK_BYTES_DEFAULT / SECTOR_BYTES as u64) as u32 + 64; let dir = tempdir().unwrap(); let path = dir.path().join("drop.iso"); make_iso(&path, total); @@ -502,15 +502,15 @@ mod tests { // Read in 16-sector batches to keep the loop fast while still // crossing the drop boundary by byte count. let batch = 16u16; - let mut got = vec![0u8; batch as usize * SECTOR_SIZE]; + let mut got = vec![0u8; batch as usize * SECTOR_BYTES]; let mut lba = 0u32; while lba + batch as u32 <= total { src.read_sectors(lba, batch, &mut got, false).unwrap(); for i in 0..batch as u32 { let expected = ((lba + i) & 0xff) as u8; - let off = i as usize * SECTOR_SIZE; + let off = i as usize * SECTOR_BYTES; assert!( - got[off..off + SECTOR_SIZE].iter().all(|x| *x == expected), + got[off..off + SECTOR_BYTES].iter().all(|x| *x == expected), "DONTNEED eviction corrupted sector {}", lba + i ); diff --git a/src/keysource.rs b/src/keysource.rs index f55d913..841ebd2 100644 --- a/src/keysource.rs +++ b/src/keysource.rs @@ -1,18 +1,25 @@ -//! Key sources — the lookup layer that hands libfreemkv a [`Key`]. +//! Key sources — the layer that hands libfreemkv a disc's terminal Unit Keys. //! -//! libfreemkv performs NO key lookup. An application resolves a key for a disc -//! through one or more [`KeySource`]s, each a dumb adapter over a backing store -//! (a keydb file, a key server, the mapfile cache): given the disc's -//! [`DiscInputs`] it returns the raw [`Key`] at whatever level it holds. The -//! library then derives down and decrypts via `Disc::decrypt_with`. +//! libfreemkv performs NO key lookup. An application resolves a disc's keys +//! through one or more [`KeySource`]s, each an adapter over a backing store (a +//! keydb file, a key server, the mapfile cache). A source's job is to return the +//! disc's terminal **Unit Keys** ([`crate::aacs::UnitKey`]). It knows what +//! material it holds (a DK / MK / VUK / pre-decrypted UK) and what it must fetch +//! from the disc (VID, MKB, encrypted title keys, content samples) to get there; +//! it orchestrates the derivation by calling libfreemkv's own boil-down crypto +//! primitives ([`crate::aacs::mk_from_dk`] / [`crate::aacs::vuk_from_mk`] / +//! [`crate::aacs::uk_from_vuk`]) through the [`ResolveCtx`] handed to it. //! -//! Source implementations are published in the companion `freemkv-keysources` -//! crate — keeping all key *policy* (which store, which order, online vs local) -//! out of the library while all key *mechanism* (the AACS derivation chain) -//! stays in it. +//! libfreemkv still OWNS the crypto: the boil-down primitives and the AES live +//! here. A source owns only PATH ORCHESTRATION — deciding which primitive to +//! call with what input for the material it happens to hold. Source +//! implementations are published in the companion `freemkv-keysources` crate, +//! keeping key *policy* (which store, which order, online vs local) out of the +//! library. -use crate::aacs::HostCert; +use crate::aacs::{HostCert, UnitKey, Vid}; use crate::disc::Key; +use crate::error::Error; /// The public AACS inputs a key source needs to look a disc up. Captured at /// scan; contains no secrets — only the disc identity and the on-disc AACS @@ -44,107 +51,227 @@ pub struct DiscInputs { pub volume_label: Option<String>, } -/// A key source: a stateful provider that hands a disc's candidate [`Key`]s out -/// **one at a time**, in whatever order it judges best for its backing store. +/// A lazy view of a disc's AACS material, handed to [`KeySource::get_uk`] so a +/// source can drive the derivation chain without holding the disc reader. /// -/// Dumb by contract — a source queries its store and yields the raw material it -/// holds at whatever level it has (device / processing / media / volume / unit). -/// It performs NO AACS derivation and NO validation: `Disc::decrypt_with` -/// derives down AND validates against real ciphertext, returning `Err` for a key -/// that does not decrypt this disc. That keeps every derivation step and the one -/// validation gate in the library, across AACS 1.0 / 2.0 / 2.1 / 2.x. -/// -/// The source is the one that knows how many candidates it has and in what order -/// to try them — a keydb holds a per-disc UK *and* VUK *and* a device-key pool, -/// so it hands them out cheapest/most-specific first (UK ▸ VK ▸ MK ▸ DK) and -/// reports exhaustion when its list runs out; an online key service or a mapfile -/// cache hold exactly one. The caller drives the loop: `next_key` → -/// `Disc::decrypt_with` → on `Err`, ask again → until a key decrypts or the -/// source returns `None` (a genuine "no key for this disc"). Compose several -/// sources, in the caller's chosen order, with the companion -/// `freemkv-keysources` crate's `MultiSource`. -pub trait KeySource { - /// Hand the NEXT candidate key for this disc, or `None` once this source is - /// exhausted. Stateful: the source tracks what it already handed out this - /// session, so asking again after a rejected key yields the next candidate - /// (or `None`) — it never re-offers a key or re-hits a one-shot backend (an - /// online service is asked at most once). - /// - /// `None` means only "no more candidates from this source"; it does NOT by - /// itself distinguish a genuine "no key for this disc" from a source - /// failure (I/O, network, parse). After exhaustion the caller must consult - /// [`KeySource::errored`] to tell the two apart — a failed source records - /// the failure there and still returns `None` here. - fn next_key(&mut self, inputs: &DiscInputs) -> Option<Key>; +/// "Lazy" by contract: each accessor returns only what the source asks for, so a +/// source that already holds terminal Unit Keys never touches the MKB or +/// samples. (Today the backing [`DiscInputsCtx`] is eagerly populated from a +/// scan-time [`DiscInputs`]; the trait keeps the lazy signature so a future +/// implementation can fetch on demand without a source-API break.) +pub trait ResolveCtx { + /// SHA-1 of `Unit_Key_RO.inf`, `0x`-prefixed hex — the per-disc lookup key. + fn disc_hash(&self) -> &str; + /// The disc's human title (UDF/ISO volume identifier), when captured. + fn title(&self) -> Option<&str>; + /// Volume ID, or `None` when no authenticated handshake ran (the all-zero + /// sentinel) — VID-dependent derivation (`MK → VUK`) is then impossible. + fn vid(&self) -> Option<Vid>; + /// Raw MKB bytes (may be empty when not captured). + fn mkb(&self) -> Result<&[u8], Error>; + /// The disc's encrypted title keys, parsed from `Unit_Key_RO.inf` the same + /// way the library's resolver parses them ([`crate::aacs::parse_unit_key_ro`]), + /// in on-disc order. Feed straight into [`crate::aacs::uk_from_vuk`]. + fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error>; + /// Up to `n` encrypted on-disc content sample units, for a source that + /// validates a candidate server-side against real ciphertext. + fn samples(&self, n: usize) -> Result<Vec<Vec<u8>>, Error>; + /// Raw `Unit_Key_RO.inf` bytes, verbatim. Most sources derive locally from + /// the parsed [`Self::enc_title_keys`]; a source that forwards the on-disc + /// structure to a server doing its OWN derivation (an online key service) + /// needs the unparsed blob. Empty when not captured. Defaults to empty so + /// existing/foreign `ResolveCtx` impls keep compiling unchanged. + fn unit_key_ro(&self) -> &[u8] { + &[] + } +} - /// Whether this source needs [`DiscInputs::samples`] populated (encrypted - /// content samples) — true for a source that validates against ciphertext - /// server-side, false for one that keys purely on disc identity. The caller - /// reads samples (an extra disc read) only when some source needs them. - fn needs_samples(&self) -> bool { - false +/// [`ResolveCtx`] over a scan-time [`DiscInputs`]. +/// +/// Pre-parses the encrypted title keys at construction (so `enc_title_keys` can +/// hand back a borrowed slice) at the version-appropriate `Unit_Key_RO.inf` +/// stride — `version_u8` is the disc's AACS major (1 → 48-byte V10 stride, else +/// 64-byte V20/V21 stride), matching the library resolver's dispatch. +pub struct DiscInputsCtx<'a> { + inner: &'a DiscInputs, + enc_keys: Vec<[u8; 16]>, +} + +impl<'a> DiscInputsCtx<'a> { + /// Build a context over `inputs`, parsing the encrypted title keys at the + /// stride for AACS major `version_u8` (1 = V10, else V20/V21). + pub fn new(inputs: &'a DiscInputs, version_u8: u8) -> Self { + use crate::aacs::{AacsVersion, parse_unit_key_ro}; + let enc_keys = if inputs.unit_key_ro.is_empty() { + Vec::new() + } else { + let version = if version_u8 == 1 { + AacsVersion::V10 + } else { + AacsVersion::V20 + }; + parse_unit_key_ro(&inputs.unit_key_ro, version) + .map(|f| f.encrypted_keys.into_iter().map(|(_, k)| k).collect()) + .unwrap_or_default() + }; + Self { + inner: inputs, + enc_keys, + } + } +} + +impl ResolveCtx for DiscInputsCtx<'_> { + fn disc_hash(&self) -> &str { + &self.inner.disc_hash + } + fn title(&self) -> Option<&str> { + self.inner.volume_label.as_deref() + } + fn vid(&self) -> Option<Vid> { + if self.inner.volume_id == [0u8; 16] { + None + } else { + Some(Vid(self.inner.volume_id)) + } + } + fn mkb(&self) -> Result<&[u8], Error> { + Ok(&self.inner.mkb) + } + fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error> { + Ok(&self.enc_keys) + } + fn samples(&self, n: usize) -> Result<Vec<Vec<u8>>, Error> { + Ok(self.inner.samples.iter().take(n).cloned().collect()) + } + fn unit_key_ro(&self) -> &[u8] { + &self.inner.unit_key_ro + } +} + +/// A key source: an adapter over a backing store that resolves a disc's terminal +/// Unit Keys. +/// +/// Dumb about *policy*, smart about *its own material*: given a [`ResolveCtx`] a +/// source looks the disc up in its store and, from whatever level of material it +/// holds, orchestrates the derivation down to Unit Keys using the library's +/// boil-down crypto primitives — never re-implementing AES. A source that holds +/// pre-decrypted Unit Keys returns them directly; one that holds a VUK calls +/// [`crate::aacs::uk_from_vuk`]; one that holds device keys calls +/// [`crate::aacs::mk_from_dk`] → [`crate::aacs::vuk_from_mk`] → `uk_from_vuk`. +/// +/// Returning an empty `Vec` means "no key for this disc from this source"; an +/// `Err` means the source itself failed (I/O, parse, network). The caller +/// ([`resolve_and_apply`]) tries each source in order and validates the returned +/// keys against real ciphertext before committing them, so a wrong key from one +/// source transparently falls through to the next. +pub trait KeySource { + /// Resolve this disc's terminal Unit Keys from this source. An empty `Vec` + /// is a genuine "no key here"; `Err` is a source failure. + fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error>; + + /// The AACS host certificate(s) this source can supply for the live-drive + /// SCSI mutual-auth handshake (the OEM/AACS baseline route). `mkb` is the + /// disc's MKB generation when known, so a source MAY return only certs whose + /// generation matches (the default ignores it). A host cert unlocks the + /// authenticated bus so the drive reports the Volume ID and bus key; it is + /// **perishable** (revocable on a drive's HRL), so it is served by a source, + /// never compiled in. A source holding no cert returns the empty vec. + fn host_certs(&self, _mkb: Option<u32>) -> Vec<HostCert> { + Vec::new() } /// A short, stable identifier for this source kind (`"keydb"`, `"online"`, /// `"mapfile"`, …). For logging which source produced a key, and for - /// composition/ordering logic that needs to tell sources apart. A format - /// string, not user-facing English. + /// composition/ordering. A format string, not user-facing English. fn label(&self) -> &'static str { "source" } - - /// Whether this source FAILED (I/O, network, parse) rather than simply - /// having no key. Checked after exhaustion so the caller can tell a genuine - /// "no key for this disc" apart from "the key service was unreachable". A - /// store that treats absence as not-an-error (a missing keydb / mapfile) - /// leaves this `false`. - fn errored(&self) -> bool { - false - } - - /// The AACS host certificate(s) this source can supply for the live-drive - /// SCSI mutual-auth handshake (the OEM/AACS baseline route). A host cert is - /// the *second* kind of AACS material a source may hold, distinct from the - /// decryption keys handed out by [`KeySource::next_key`]: it unlocks the - /// authenticated bus so the drive will report the Volume ID and bus key, - /// whereas the keys decrypt content once the disc is read. - /// - /// Returned, never compiled in: a host cert is **perishable** — it can be - /// revoked on a given drive's Host Revocation List (carried forward by newer - /// discs' MKBs), so it must be rotatable, hence served by a source rather - /// than baked into the binary. A source that holds no cert (a mapfile, or an - /// online service whose cert-serving isn't yet designed) returns the empty - /// vec — the default. The handshake collects across every source and tries - /// each candidate; with no candidate from any source the OEM route fails - /// gracefully ([`crate::Error::AacsNoHostCert`]), it never panics. - fn host_certs(&self) -> Vec<HostCert> { - Vec::new() - } } -/// Drive `sources` until one key decrypts `disc`. Loops [`KeySource::next_key`] -/// and hands each candidate to [`crate::Disc::decrypt_with`] (which validates it -/// against `inputs.samples` and only mutates the disc on success), returning -/// `true` at the first key that decrypts and `false` once every source is -/// exhausted — the genuine "no key for this disc". THE shared key-resolution -/// loop: every application uses it instead of re-rolling the candidate/retry -/// logic, so the "no key" verdict is identical everywhere. -/// -/// Lives in the library, not a key-source crate: resolution is decryption -/// *mechanism* — it validates candidates against disc content via -/// `Disc::decrypt_with` — and the library owns all mechanism. A key source only -/// hands out candidate keys; what's done with them is not its concern. +/// Drive `sources` until one resolves Unit Keys that decrypt `disc`. Returns +/// `true` at the first source whose keys validate and commit, `false` once every +/// source is exhausted (the genuine "no key for this disc"). Thin wrapper over +/// [`resolve_and_apply_traced`] that discards the trace. pub fn resolve_and_apply( - sources: &mut dyn KeySource, + sources: &[Box<dyn KeySource>], inputs: &DiscInputs, disc: &mut crate::Disc, ) -> bool { - while let Some(key) = sources.next_key(inputs) { - if disc.decrypt_with(key, &inputs.samples).is_ok() { - return true; + resolve_and_apply_traced(sources, inputs, disc).0 +} + +/// Like [`resolve_and_apply`] but also returns a structured +/// [`crate::aacs::ResolutionTrace`] recording, per source, what happened — for +/// applications to render. ZERO English; the trace is typed enums only. +/// +/// One-shot per source: each source's [`KeySource::get_uk`] is called exactly +/// once with a [`DiscInputsCtx`] over `inputs`. Non-empty Unit Keys are mapped +/// to terminal [`Key::Unit`]s and applied via [`crate::Disc::decrypt_with`], +/// which validates them against `inputs.samples` and only mutates the disc on +/// success — so a wrong/partial key set is rejected and the loop continues. +/// +/// CPS-unit numbering: a source returns Unit Keys carrying the POSITIONAL index +/// from [`crate::aacs::uk_from_vuk`]; the library's canonical CPS-unit number is +/// `position + 1` (matching [`crate::aacs::parse_unit_key_ro`]'s `(i + 1)`), so +/// the committed `AacsState.unit_keys` is byte-identical to the library-resolved +/// path. The number is cosmetic for descramble (the decrypt path strips it and +/// tries every key) but is kept faithful to the resolver's convention. +pub fn resolve_and_apply_traced( + sources: &[Box<dyn KeySource>], + inputs: &DiscInputs, + disc: &mut crate::Disc, +) -> (bool, crate::aacs::ResolutionTrace) { + use crate::aacs::trace::{KeyNode, KeyOutcome, KeyStep}; + + let mut trace = crate::aacs::ResolutionTrace::new(); + + // AACS major drives the Unit_Key_RO.inf stride the ctx parses at. Default to + // the V20/V21 stride when there is no AACS state (it is the common live case; + // a non-AACS disc has nothing to resolve and the loop simply finds nothing). + let version_u8 = disc.aacs.as_ref().map(|a| a.version).unwrap_or(2); + let ctx = DiscInputsCtx::new(inputs, version_u8); + + for source in sources { + // `who` is the source's own stable identifier — no enum to map back to. + let who = source.label().to_string(); + match source.get_uk(&ctx) { + Ok(uks) if !uks.is_empty() => { + // Positional index → canonical CPS-unit number (position + 1). + let unit_keys: Vec<(u32, [u8; 16])> = uks + .iter() + .map(|uk| (uk.idx.saturating_add(1), uk.key)) + .collect(); + if disc + .decrypt_with(Key::Unit(unit_keys), &inputs.samples) + .is_ok() + { + trace.keys.push(KeyStep { + who, + path: vec![KeyNode::FoundUnitKeys, KeyNode::DerivedUnitKeys], + outcome: KeyOutcome::Resolved, + }); + return (true, trace); + } + // Keys produced but rejected by validation — record and continue. + trace.keys.push(KeyStep { + who, + path: vec![KeyNode::FoundUnitKeys], + outcome: KeyOutcome::NoKey, + }); + } + // Empty (no key here) or a source failure — both are "no key from + // this source"; move on to the next. + Ok(_) | Err(_) => { + trace.keys.push(KeyStep { + who, + path: vec![KeyNode::NoEntry], + outcome: KeyOutcome::NoKey, + }); + } } } - false + (false, trace) } /// Read up to `n` ENCRYPTED 6144-byte aligned units from `title`'s body, raw (no @@ -219,41 +346,112 @@ pub fn read_encrypted_units( #[cfg(test)] mod tests { use super::*; - use crate::disc::Key; - - // ── DiscInputs structural tests ──────────────────────────────────────────── + use crate::aacs::UnitKey; // ── KeySource default-method behaviour ──────────────────────────────────── - /// KeySource::needs_samples() defaults to false. - /// Spec: doc says "false for one that keys purely on disc identity." - /// Mutation: defaulting to true forces an extra disc-read for every source, - /// even local keydb lookups that don't need ciphertext samples. + /// KeySource::host_certs() defaults to empty regardless of the MKB argument. + /// Spec: a source holding no cert returns the empty vec; the `mkb` param is + /// forward-looking and the default ignores it. + /// Mutation: a default returning a non-empty vec would inject phantom certs + /// into the OEM handshake. #[test] - fn key_source_needs_samples_defaults_to_false() { + fn key_source_host_certs_defaults_to_empty() { struct MinimalSource; impl KeySource for MinimalSource { - fn next_key(&mut self, _inputs: &DiscInputs) -> Option<Key> { - None + fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> { + Ok(Vec::new()) } } let s = MinimalSource; - assert!(!s.needs_samples(), "needs_samples must default to false"); + assert!(s.host_certs(None).is_empty()); + assert!(s.host_certs(Some(68)).is_empty()); } - /// KeySource::errored() defaults to false. - /// Spec: doc says "A store that treats absence as not-an-error leaves this false." - /// Mutation: defaulting to true would make every source appear errored, causing - /// the caller to report "key service unreachable" for a simple miss. + /// DiscInputsCtx maps DiscInputs faithfully: zero VID → None, non-zero VID → + /// Some; title from volume_label; samples truncate to n; enc_title_keys + /// parses Unit_Key_RO.inf at the version stride. #[test] - fn key_source_errored_defaults_to_false() { - struct MinimalSource; - impl KeySource for MinimalSource { - fn next_key(&mut self, _inputs: &DiscInputs) -> Option<Key> { - None + fn disc_inputs_ctx_maps_fields() { + // Build a minimal V10 Unit_Key_RO.inf with one key (stride 48): + // uk_pos = 32, num_uk = 1, key at uk_pos + 48 = 80. + let mut uk_ro = vec![0u8; 96]; + let uk_pos = 32usize; + uk_ro[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes()); + uk_ro[uk_pos] = 0x00; + uk_ro[uk_pos + 1] = 0x01; // num_unit_keys = 1 + let key_bytes = [0x7Eu8; 16]; + uk_ro[80..96].copy_from_slice(&key_bytes); + + let inputs = DiscInputs { + disc_hash: "0xABC".into(), + volume_id: [0u8; 16], + mkb: vec![1, 2, 3], + unit_key_ro: uk_ro, + samples: vec![vec![9u8; 4], vec![8u8; 4], vec![7u8; 4]], + volume_label: Some("TITLE_X".into()), + }; + + // Zero VID → None. + let ctx = DiscInputsCtx::new(&inputs, 1); + assert_eq!(ctx.disc_hash(), "0xABC"); + assert_eq!(ctx.title(), Some("TITLE_X")); + assert!(ctx.vid().is_none(), "all-zero VID is the no-VID sentinel"); + assert_eq!(ctx.mkb().unwrap(), &[1, 2, 3]); + assert_eq!(ctx.enc_title_keys().unwrap(), &[key_bytes]); + assert_eq!(ctx.samples(2).unwrap().len(), 2, "samples truncates to n"); + + // Non-zero VID → Some(vid). + let mut inputs2 = inputs.clone(); + inputs2.volume_id = [0x42u8; 16]; + let ctx2 = DiscInputsCtx::new(&inputs2, 1); + assert_eq!(ctx2.vid(), Some(Vid([0x42u8; 16]))); + } + + /// `resolve_and_apply_traced` records each step's `who` as the source's own + /// `label()`, carried verbatim — no enum round-trip. A source with a custom + /// label surfaces it as-is in the trace. + #[test] + fn trace_who_is_the_source_label_verbatim() { + struct LabeledSource(&'static str); + impl KeySource for LabeledSource { + fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> { + Ok(Vec::new()) + } + fn label(&self) -> &'static str { + self.0 } } - let s = MinimalSource; - assert!(!s.errored(), "errored must default to false"); + let mut disc = crate::Disc { + volume_id: String::new(), + meta_title: None, + format: crate::DiscFormat::BluRay, + capacity_sectors: 0, + capacity_bytes: 0, + layers: 1, + titles: Vec::new(), + region: crate::disc::DiscRegion::Free, + aacs: None, + css: None, + encrypted: false, + aacs_error: None, + css_error: None, + content_format: crate::ContentFormat::BdTs, + }; + let inputs = DiscInputs { + disc_hash: "0x00".into(), + volume_id: [0u8; 16], + mkb: Vec::new(), + unit_key_ro: Vec::new(), + samples: Vec::new(), + volume_label: None, + }; + let sources: Vec<Box<dyn KeySource>> = vec![ + Box::new(LabeledSource("keydb")), + Box::new(LabeledSource("my-custom-source")), + ]; + let (_ok, trace) = resolve_and_apply_traced(&sources, &inputs, &mut disc); + let whos: Vec<&str> = trace.keys.iter().map(|s| s.who.as_str()).collect(); + assert_eq!(whos, vec!["keydb", "my-custom-source"]); } } diff --git a/src/labels/bdmt.rs b/src/labels/bdmt.rs index 9597973..3d3e633 100644 --- a/src/labels/bdmt.rs +++ b/src/labels/bdmt.rs @@ -385,7 +385,7 @@ mod tests { meta.titles.get("fra").map(String::as_str), Some("Aurora Drift (Partie Deux)") ); - assert!(meta.descriptions.get("eng").is_none()); + assert!(!meta.descriptions.contains_key("eng")); assert_eq!( meta.descriptions.get("fra").map(String::as_str), Some("Suite du film fictif.") diff --git a/src/labels/ctrm.rs b/src/labels/ctrm.rs index d9a0087..cdbb3b7 100644 --- a/src/labels/ctrm.rs +++ b/src/labels/ctrm.rs @@ -323,6 +323,11 @@ 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. +#[allow(clippy::items_after_test_module)] #[cfg(test)] mod tests { use super::*; diff --git a/src/labels/jar.rs b/src/labels/jar.rs index 4fe1411..ebcad1a 100644 --- a/src/labels/jar.rs +++ b/src/labels/jar.rs @@ -288,7 +288,7 @@ mod tests { // ignores; the point is that read_to_end stops at the cap rather // than following a (potentially huge) declared size. let mut payload = MINIMAL_CLASS.to_vec(); - payload.extend(std::iter::repeat(0u8).take(4096)); + payload.extend(std::iter::repeat_n(0u8, 4096)); let mut jar = open(build_stored_zip( "Padded.class", &payload, diff --git a/src/labels/mod.rs b/src/labels/mod.rs index 015a54e..4f588f5 100644 --- a/src/labels/mod.rs +++ b/src/labels/mod.rs @@ -441,15 +441,37 @@ fn codec_hint_adds_detail(hint: &str) -> bool { } pub(crate) fn generate_audio_label( + codec: &crate::disc::Codec, + channels: &crate::disc::AudioChannels, + secondary: bool, +) -> String { + generate_audio_label_inner(codec, channels, secondary, false) +} + +/// Atmos-aware variant: same codec/channel string as [`generate_audio_label`] +/// with the object-audio marker folded into the codec brand +/// (e.g. "Dolby TrueHD Atmos 7.1"). The "Atmos" string lives here in the label +/// layer, not in the core parser. Used when a bitstream probe detected an Atmos +/// substream and the stream still carries the basic (non-editorial) label. +pub(crate) fn generate_audio_label_atmos( + codec: &crate::disc::Codec, + channels: &crate::disc::AudioChannels, + secondary: bool, +) -> String { + generate_audio_label_inner(codec, channels, secondary, true) +} + +fn generate_audio_label_inner( codec: &crate::disc::Codec, channels: &crate::disc::AudioChannels, _secondary: bool, + atmos: bool, ) -> String { use crate::disc::{AudioChannels, Codec}; // Full marketing names for disc audio codecs. // These are codec brand identifiers, not user-facing English prose. - let codec_name = match codec { + let base_name = match codec { Codec::TrueHd => "Dolby TrueHD", Codec::Ac3 => "Dolby Digital", Codec::Ac3Plus => "Dolby Digital Plus", @@ -465,6 +487,15 @@ pub(crate) fn generate_audio_label( _ => return String::new(), }; + // Atmos is an object-audio extension riding a lossless carrier (TrueHD or + // DD+). Fold the marker into the brand name; "Atmos" is a label-layer + // string, never asserted by the core parser. + let codec_name = if atmos && matches!(codec, Codec::TrueHd | Codec::Ac3Plus) { + std::borrow::Cow::Owned(format!("{base_name} Atmos")) + } else { + std::borrow::Cow::Borrowed(base_name) + }; + // Channel layout let channel_str = match channels { AudioChannels::Mono => "1.0", @@ -1090,7 +1121,9 @@ mod registry_tests { // and as a marker for "these parsers exist." let _ = (name, detect, parse); } - assert!(!PARSERS.is_empty(), "PARSERS array must not be empty"); + // The loop above touches every registry entry; iterating a non-empty + // fixed-size array is the assertion (a `.is_empty()` check would be + // const-folded). The test fails to compile if the tuple shape changes. } } diff --git a/src/labels/pixelogic.rs b/src/labels/pixelogic.rs index 914a9f9..da9860d 100644 --- a/src/labels/pixelogic.rs +++ b/src/labels/pixelogic.rs @@ -536,8 +536,8 @@ mod tests { fn parse_token_all_regions_recognized() { for region in REGIONS { let token = format!("eng_MLP_{}_", region); - let l = - parse_token_inner(&token, None).expect(&format!("region {} should parse", region)); + let l = parse_token_inner(&token, None) + .unwrap_or_else(|| panic!("region {region} should parse")); assert_eq!(l.variant, *region, "region {} should be in variant", region); } } diff --git a/src/lib.rs b/src/lib.rs index 9e2dc8d..d60067f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -87,6 +87,7 @@ pub mod aacs; pub(crate) mod clpi; +pub mod consts; pub mod css; pub mod decrypt; pub mod diag; @@ -167,7 +168,7 @@ pub use identity::DriveId; // and registers it once at process start via `register_unlocker`. At // drive-prep the registry is walked in order; the first matching unlocker // runs, else the drive falls through to the host-cert AACS handshake. -pub use unlock::{Unlocker, register_unlocker}; +pub use unlock::{UnlockError, Unlocker, register_unlocker}; // ─── Decryption (AACS / CSS) ──────────────────────────────────────────────── // diff --git a/src/mpls.rs b/src/mpls.rs index 33320f9..ab75fc2 100644 --- a/src/mpls.rs +++ b/src/mpls.rs @@ -161,7 +161,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> { // Primary video for _ in 0..n_video { - if let Some((entry, next)) = parse_stream_entry(item, spos, 1) { + if let Some((entry, next)) = parse_stream_entry(item, spos, STREAM_CATEGORY_VIDEO) { streams.push(entry); spos = next; } else { @@ -170,7 +170,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> { } // Primary audio for _ in 0..n_audio { - if let Some((entry, next)) = parse_stream_entry(item, spos, 2) { + if let Some((entry, next)) = parse_stream_entry(item, spos, STREAM_CATEGORY_AUDIO) { streams.push(entry); spos = next; } else { @@ -179,7 +179,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> { } // PG subtitles for _ in 0..n_pg { - if let Some((entry, next)) = parse_stream_entry(item, spos, 3) { + if let Some((entry, next)) = parse_stream_entry(item, spos, STREAM_CATEGORY_PG_SUBTITLE) { streams.push(entry); spos = next; } else { @@ -188,7 +188,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> { } // IG (skip but advance) for _ in 0..n_ig { - if let Some((_, next)) = parse_stream_entry(item, spos, 4) { + if let Some((_, next)) = parse_stream_entry(item, spos, STREAM_CATEGORY_IG) { spos = next; } else { break; @@ -196,7 +196,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> { } // Secondary audio for _ in 0..n_sec_audio { - if let Some((mut entry, next)) = parse_stream_entry(item, spos, 2) { + if let Some((mut entry, next)) = parse_stream_entry(item, spos, STREAM_CATEGORY_AUDIO) { entry.stream_type = 5; entry.secondary = true; streams.push(entry); @@ -213,7 +213,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> { } // Secondary video (PiP) for _ in 0..n_sec_video { - if let Some((mut entry, next)) = parse_stream_entry(item, spos, 1) { + if let Some((mut entry, next)) = parse_stream_entry(item, spos, STREAM_CATEGORY_VIDEO) { entry.stream_type = 6; entry.secondary = true; streams.push(entry); @@ -240,7 +240,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> { } // Secondary PG (PiP subtitles) — must consume to keep spos aligned for _ in 0..n_pip_pg { - if let Some((mut entry, next)) = parse_stream_entry(item, spos, 3) { + if let Some((mut entry, next)) = parse_stream_entry(item, spos, STREAM_CATEGORY_PG_SUBTITLE) { entry.secondary = true; streams.push(entry); // Skip reference data: num_refs(1) + reserved(1) + refs + padding @@ -256,7 +256,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> { } // Dolby Vision enhancement layer for _ in 0..n_dv { - if let Some((mut entry, next)) = parse_stream_entry(item, spos, 1) { + if let Some((mut entry, next)) = parse_stream_entry(item, spos, STREAM_CATEGORY_VIDEO) { entry.stream_type = 7; entry.secondary = true; streams.push(entry); @@ -317,6 +317,21 @@ pub fn parse(data: &[u8]) -> Result<Playlist> { /// Parse one stream entry from the STN table. /// Returns (StreamEntry, next position) or None. +/// BD `stream_entry()` type codes (the `stream_entry_type` field). Determine +/// where the PID sits within the entry — see `parse_stream_entry`. +const STREAM_ENTRY_PLAYITEM_CLIP: u8 = 0x01; // stream in the PlayItem's Clip +const STREAM_ENTRY_SUBPATH_SUBCLIP: u8 = 0x02; // stream in a SubPath SubClip +const STREAM_ENTRY_SUBPATH_CLIP: u8 = 0x03; // stream in a SubPath clip +const STREAM_ENTRY_SUBPATH_DV_EL: u8 = 0x04; // SubPath Dolby Vision enhancement layer + +/// STN-table primary stream categories — the `stream_type` tag carried on each +/// [`StreamEntry`]. Secondary streams reuse the primary category and set the +/// `secondary` flag rather than carrying a distinct code. +const STREAM_CATEGORY_VIDEO: u8 = 1; +const STREAM_CATEGORY_AUDIO: u8 = 2; +const STREAM_CATEGORY_PG_SUBTITLE: u8 = 3; +const STREAM_CATEGORY_IG: u8 = 4; + fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(StreamEntry, usize)> { if pos + 2 > item.len() { return None; @@ -337,9 +352,9 @@ fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(Strea // 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. let pid_off = match item[pos + 1] { - 0x01 => 2, - 0x02 => 4, - 0x03 | 0x04 => 3, + STREAM_ENTRY_PLAYITEM_CLIP => 2, + STREAM_ENTRY_SUBPATH_SUBCLIP => 4, + STREAM_ENTRY_SUBPATH_CLIP | STREAM_ENTRY_SUBPATH_DV_EL => 3, _ => 0, }; // Bound the PID read by the entry's declared end (se_end), not just by @@ -631,7 +646,7 @@ mod tests { ) -> Vec<u8> { let mut out = Vec::new(); out.push(3); - out.push(0x01); + out.push(STREAM_ENTRY_PLAYITEM_CLIP); out.extend_from_slice(&pid.to_be_bytes()); // attrs: coding_type(1) + format_rate(1) + language(3) let attrs = vec![ @@ -649,7 +664,7 @@ mod tests { fn build_stream_entry_pg(pid: u16, coding_type: u8, lang: &[u8; 3]) -> Vec<u8> { let mut out = Vec::new(); out.push(3); - out.push(0x01); + out.push(STREAM_ENTRY_PLAYITEM_CLIP); out.extend_from_slice(&pid.to_be_bytes()); // attrs: coding_type(1) + language(3) let attrs = vec![coding_type, lang[0], lang[1], lang[2]]; @@ -1046,11 +1061,12 @@ mod tests { fn stream_entry_type2_pid_at_offset_4() { // Build a primary-audio entry with stream_entry type 0x02. // se_len = 5: type(1) + subpath_id(1) + subclip_id(1) + pid(2) - let mut se = Vec::new(); - se.push(5); // se_len - se.push(0x02); // type: SubPath SubClip - se.push(0xAA); // subpath_id (must NOT be read as PID hi) - se.push(0xBB); // subclip_id + let mut se = vec![ + 5, // se_len + STREAM_ENTRY_SUBPATH_SUBCLIP, // type: SubPath SubClip + 0xAA, // subpath_id (must NOT be read as PID hi) + 0xBB, // subclip_id + ]; se.extend_from_slice(&0x1100u16.to_be_bytes()); // real PID at +4 // stream_attributes: audio coding(1)+fmt(1)+lang(3) let attrs = vec![0x83u8, (6 << 4) | 1, b'e', b'n', b'g']; @@ -1074,7 +1090,7 @@ mod tests { fn stream_entry_type4_pid_at_offset_3() { let mut se = Vec::new(); se.push(4); // se_len: type(1)+subpath_id(1)+pid(2) - se.push(0x04); // type 4 (DV EL) + se.push(STREAM_ENTRY_SUBPATH_DV_EL); // type 4 (DV EL) se.push(0x07); // subpath_id (not PID) se.extend_from_slice(&0x1015u16.to_be_bytes()); // PID at +3 let attrs = vec![0x24u8, (8 << 4) | 1, 0x12]; // HEVC video attrs @@ -1158,7 +1174,7 @@ mod tests { // Audio-slot entry but coding_type 0x90 (PGS): attrs = 0x90 + lang(3). let mut se = Vec::new(); se.push(3); - se.push(0x01); + se.push(STREAM_ENTRY_PLAYITEM_CLIP); se.extend_from_slice(&0x1100u16.to_be_bytes()); let attrs = vec![0x90u8, b'j', b'p', b'n']; // PG layout: coding + lang se.push(attrs.len() as u8); @@ -1214,7 +1230,7 @@ mod tests { fn truncated_stream_entry_stops_without_panic() { let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None); // Second "entry" declares se_len=200 but supplies no body → None. - let bad = vec![200u8, 0x01]; + let bad = vec![200u8, STREAM_ENTRY_PLAYITEM_CLIP]; let data = build_mpls( &[(b"00001", 1, 0, 9000000)], (2, 0, 0, 0, 0, 0, 0, 0), // claims 2 video @@ -1259,7 +1275,7 @@ mod tests { fn zero_length_stream_attributes_yields_no_stream() { let mut se = Vec::new(); se.push(3); - se.push(0x01); + se.push(STREAM_ENTRY_PLAYITEM_CLIP); se.extend_from_slice(&0x1011u16.to_be_bytes()); se.push(0); // sa_len = 0 → parse_stream_entry returns None let data = build_mpls( diff --git a/src/mux/codec/mpeg2.rs b/src/mux/codec/mpeg2.rs index d977a10..ed30874 100644 --- a/src/mux/codec/mpeg2.rs +++ b/src/mux/codec/mpeg2.rs @@ -977,9 +977,9 @@ mod tests { let mut parser = Mpeg2Parser::new(); let mut pic1 = make_picture_header(PICTURE_TYPE_I); - pic1.extend_from_slice(&vec![0x11; 100]); + pic1.extend_from_slice(&[0x11; 100]); let mut pic2 = make_picture_header(2); // P - pic2.extend_from_slice(&vec![0x22; 100]); + pic2.extend_from_slice(&[0x22; 100]); let mut stream = pic1.clone(); stream.extend_from_slice(&pic2); @@ -1005,7 +1005,7 @@ mod tests { let mut au = make_picture_header(PICTURE_TYPE_I); au.extend_from_slice(&[0x00, 0x00, 0x01, SEQ_EXT_CODE, 0x88, 0x00]); // pic coding ext au.extend_from_slice(&[0x00, 0x00, 0x01, 0x01]); // slice - au.extend_from_slice(&vec![0x77; 50]); + au.extend_from_slice(&[0x77; 50]); let frames = parse_then_flush(&mut parser, &make_pes(au.clone(), Some(0))); assert_eq!(frames.len(), 1); @@ -1025,12 +1025,12 @@ mod tests { let mut parser = Mpeg2Parser::new(); let mut pic1 = make_picture_header(PICTURE_TYPE_I); - pic1.extend_from_slice(&vec![0x11; 50]); + pic1.extend_from_slice(&[0x11; 50]); let frames1 = parser.parse(&make_pes(pic1, Some(90000))); assert!(frames1.is_empty(), "buffered until flush"); let mut pic2 = make_picture_header(2); - pic2.extend_from_slice(&vec![0x22; 50]); + pic2.extend_from_slice(&[0x22; 50]); let frames2 = parser.parse(&make_pes(pic2, Some(180000))); assert!(frames2.is_empty(), "same GOP — still buffered"); diff --git a/src/mux/codec/truehd.rs b/src/mux/codec/truehd.rs index 7f0d8a4..de7074a 100644 --- a/src/mux/codec/truehd.rs +++ b/src/mux/codec/truehd.rs @@ -14,9 +14,19 @@ use super::{CodecParser, Frame, PesPacket, pts_to_ns}; -/// Duration of one TrueHD access unit in nanoseconds (1/1200 second). +/// Duration of one TrueHD access unit in nanoseconds for the 48 kHz family +/// (48 / 96 / 192 kHz). `access_unit_size = 40 << (ratebits & 7)` and +/// `sample_rate = 48000 << (ratebits & 7)`; the shared shift cancels in +/// `samples_per_AU / sample_rate = 40/48000 = 1/1200 s`, so this constant is +/// exact for the whole 48 kHz family — 48, 96 and 192 kHz alike. Used as the +/// default until a major sync reveals the actual rate family. const AU_DURATION_NS: i64 = 833_333; +/// Duration of one TrueHD access unit in nanoseconds for the 44.1 kHz family +/// (44.1 / 88.2 / 176.4 kHz): `40/44100 = 1/1102.5 s = 907_029.478… ns`. The +/// 48 kHz constant would run ~8.95 % fast on these (rare) streams. +const AU_DURATION_NS_441: i64 = 907_029; + /// Hard cap on the reassembly buffer. A valid TrueHD/MAT access unit is /// well under 32 KiB; if the buffer grows far past that without yielding a /// frame the stream is malformed, so we drop it and resync rather than grow @@ -26,6 +36,12 @@ const MAX_TRUEHD_BUF: usize = 256 * 1024; pub struct TrueHdParser { buf: Vec<u8>, next_pts_ns: i64, + /// Per-AU PTS increment. Defaults to the 48 kHz-family value (833_333) and + /// is refined to the 44.1 kHz-family value once the first major sync reveals + /// the actual rate. Stays at the default for streams whose major sync is not + /// yet seen (head of stream) — preserving byte-identical timing for the + /// common 48 kHz case. + au_duration_ns: i64, } impl Default for TrueHdParser { @@ -39,6 +55,7 @@ impl TrueHdParser { Self { buf: Vec::with_capacity(32768), next_pts_ns: 0, + au_duration_ns: AU_DURATION_NS, } } @@ -185,6 +202,17 @@ impl CodecParser for TrueHdParser { & 0xFFFF_FFFE) == 0xF872_6FBA; + // On a major sync the 32-bit `format_info` word (immediately after + // the 4-byte sync, i.e. AU bytes 8..12) carries the rate nibble. + // Refine the per-AU PTS increment to the actual rate family. The + // 48 kHz family resolves to the unchanged 833_333 default, so the + // common case stays byte-identical; only the 44.1 kHz family shifts. + if is_major_sync && unit_bytes >= 12 { + let format_info = + u32::from_be_bytes([self.buf[8], self.buf[9], self.buf[10], self.buf[11]]); + self.au_duration_ns = truehd_au_duration_ns(format_info); + } + frames.push(Frame { coding: None, source: None, @@ -194,7 +222,7 @@ impl CodecParser for TrueHdParser { duration_ns: None, }); self.buf.drain(..unit_bytes); - self.next_pts_ns += AU_DURATION_NS; + self.next_pts_ns += self.au_duration_ns; } // Bound memory on malformed input: a stream that never yields a @@ -257,6 +285,93 @@ pub fn truehd_channels_from_stream(data: &[u8]) -> Option<u8> { None } +/// Real sample rate (Hz) from a TrueHD major-sync `format_info` word. +/// +/// The 4-bit `ratebits` nibble sits in `format_info` bits 31..28 (the top +/// nibble), the same word `truehd_channels` reads for the channel masks. The +/// MLP rate formula is `(ratebits & 8 ? 44100 : 48000) << (ratebits & 7)`; +/// rather than evaluate it blindly this is a **strict whitelist** of the only +/// six rates that occur on real BD/UHD TrueHD. Every other code — the invalid +/// `0xF`, the formula-only `0x3`/`0xB`, and all reserved values — returns +/// `None`, so a malformed or unexpected field can never produce a wrong +/// `SamplingFrequency`; the caller falls back to its container-derived rate. +pub fn truehd_sample_rate_hz(format_info: u32) -> Option<u32> { + match (format_info >> 28) & 0xF { + 0x0 => Some(48000), + 0x1 => Some(96000), + 0x2 => Some(192000), + 0x8 => Some(44100), + 0x9 => Some(88200), + 0xA => Some(176400), + _ => None, + } +} + +/// Per-AU PTS increment (ns) for the rate family encoded in `format_info`. +/// +/// Derived from the same whitelisted rate as [`truehd_sample_rate_hz`]: the +/// 44.1 kHz family (44.1 / 88.2 / 176.4 kHz) is `907_029` ns; everything else — +/// the entire 48 kHz family AND any unrecognised rate — keeps the exact current +/// `833_333` default, so the common case and all unknown/garbage inputs are +/// byte-identical to prior behaviour. +pub fn truehd_au_duration_ns(format_info: u32) -> i64 { + match truehd_sample_rate_hz(format_info) { + Some(44100) | Some(88200) | Some(176400) => AU_DURATION_NS_441, + _ => AU_DURATION_NS, + } +} + +/// First TrueHD major sync found in a demuxed elementary-stream chunk: the +/// `format_info` word plus the Atmos signal. A single scan that the per-field +/// helpers below share, so the host probes the bitstream once for channels, +/// sample rate and Atmos. +pub struct TrueHdSyncInfo { + /// The 32-bit word immediately after the 0xF8726FBA sync (channel masks + + /// rate nibble). Feed to `truehd_channels` / `truehd_sample_rate_hz`. + pub format_info: u32, + /// `num_substreams >= 4` ⟺ a 4th (Atmos object/OAMD) substream is present. + /// `num_substreams = msync[16] >> 4`, where `msync[0]` is the sync's 0xF8. + /// `None` when the AU is too short to reach that byte — never guess Atmos. + pub is_atmos: Option<bool>, +} + +/// Scan a demuxed TrueHD chunk for the first major sync and return its +/// `format_info` and Atmos signal. The stream may interleave AC-3; the scan +/// advances one byte at a time and matches the sync word anywhere. +pub fn truehd_sync_info_from_stream(data: &[u8]) -> Option<TrueHdSyncInfo> { + let mut p = 0; + while p + 8 <= data.len() { + let w = u32::from_be_bytes([data[p], data[p + 1], data[p + 2], data[p + 3]]); + if (w & 0xFFFF_FFFE) == 0xF872_6FBA { + let format_info = + u32::from_be_bytes([data[p + 4], data[p + 5], data[p + 6], data[p + 7]]); + // num_substreams is the top nibble of the 17th sync byte (p + 16). + // .get() yields None — not a panic and not a false Atmos — when the + // AU is truncated before that byte. + let is_atmos = data.get(p + 16).map(|&b| (b >> 4) >= 4); + return Some(TrueHdSyncInfo { + format_info, + is_atmos, + }); + } + p += 1; + } + None +} + +/// Real sample rate (Hz) from the first major sync in a demuxed chunk, or +/// `None` if no major sync is found or its rate code is not whitelisted. +pub fn truehd_sample_rate_from_stream(data: &[u8]) -> Option<u32> { + truehd_sync_info_from_stream(data).and_then(|s| truehd_sample_rate_hz(s.format_info)) +} + +/// Whether the first major sync in a demuxed chunk carries an Atmos substream. +/// `None` when no major sync is found or the AU is too short to read the +/// substream count — callers must treat `None` as "not Atmos" (never label). +pub fn truehd_is_atmos_from_stream(data: &[u8]) -> Option<bool> { + truehd_sync_info_from_stream(data).and_then(|s| s.is_atmos) +} + #[cfg(test)] mod tests { use super::*; @@ -720,4 +835,194 @@ mod tests { let f = parser.parse(&make_pes(vec![0x0B, 0x77, 0x00], Some(0))); assert!(f.is_empty()); } + + // --- #2 sample rate from the major-sync rate nibble --- + + /// Build a `format_info` word with the given `ratebits` (top nibble) and a + /// 7.1 8-channel mask (ch8 = 0x1F) in the low 13 bits — exactly the layout + /// §1.A pins, so the rate nibble and the channel masks are co-located in one + /// real word. + fn format_info_with(ratebits: u32) -> u32 { + ((ratebits & 0xF) << 28) | 0x1F + } + + #[test] + fn sample_rate_whitelist_real_rates() { + assert_eq!(truehd_sample_rate_hz(format_info_with(0x0)), Some(48000)); + assert_eq!(truehd_sample_rate_hz(format_info_with(0x1)), Some(96000)); + assert_eq!(truehd_sample_rate_hz(format_info_with(0x2)), Some(192000)); + assert_eq!(truehd_sample_rate_hz(format_info_with(0x8)), Some(44100)); + assert_eq!(truehd_sample_rate_hz(format_info_with(0x9)), Some(88200)); + assert_eq!(truehd_sample_rate_hz(format_info_with(0xA)), Some(176400)); + } + + #[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. + for bad in [0x3u32, 0x7, 0xB, 0xC, 0xD, 0xE, 0xF] { + assert_eq!( + truehd_sample_rate_hz(format_info_with(bad)), + None, + "ratebits {bad:#x} must not yield a rate" + ); + } + } + + #[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. + let fi = format_info_with(0x1); + assert_eq!(truehd_sample_rate_hz(fi), Some(96000)); + assert_eq!(truehd_channels(fi), Some(8)); + } + + #[test] + fn sample_rate_from_stream_scans_major_sync() { + // [junk][0xF8726FBA][format_info: ratebits=0x1 (96k), ch8=0x1F] + let mut data = vec![0xAA, 0xBB]; + data.extend_from_slice(&0xF872_6FBAu32.to_be_bytes()); + data.extend_from_slice(&format_info_with(0x1).to_be_bytes()); + assert_eq!(truehd_sample_rate_from_stream(&data), Some(96000)); + } + + #[test] + fn sample_rate_from_stream_none_without_sync() { + let data = vec![0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88]; + assert_eq!(truehd_sample_rate_from_stream(&data), None); + } + + // --- #3 per-AU duration: family-aware, 48 kHz family byte-identical --- + + #[test] + fn au_duration_48k_family_unchanged() { + // 48 / 96 / 192 kHz (ratebits 0x0/0x1/0x2) all keep the exact current + // 833_333 constant — the common case must never shift. + for rb in [0x0u32, 0x1, 0x2] { + assert_eq!(truehd_au_duration_ns(format_info_with(rb)), 833_333); + } + } + + #[test] + fn au_duration_441k_family_is_907029() { + // 44.1 / 88.2 / 176.4 kHz (ratebits 0x8/0x9/0xA) → 907_029 ns. + for rb in [0x8u32, 0x9, 0xA] { + assert_eq!(truehd_au_duration_ns(format_info_with(rb)), 907_029); + } + } + + #[test] + fn au_duration_unknown_rate_keeps_default() { + // An unrecognised/garbage rate nibble must not pick the 44.1 k value + // (note 0xF & 8 != 0): it falls back to the 833_333 default. + for rb in [0x3u32, 0x7, 0xB, 0xF] { + assert_eq!(truehd_au_duration_ns(format_info_with(rb)), 833_333); + } + } + + #[test] + fn parser_44k_major_sync_sets_907029_increment() { + // Two AUs: the first carries a major sync with ratebits=0x8 (44.1 k). + // After the parser reads it, the per-AU PTS increment must be 907_029. + let mut parser = TrueHdParser::new(); + let mut a1 = make_truehd_unit(200); + a1[4..8].copy_from_slice(&0xF872_6FBAu32.to_be_bytes()); // major sync + a1[8..12].copy_from_slice(&format_info_with(0x8).to_be_bytes()); // 44.1 k + let mut data = a1; + data.extend_from_slice(&make_truehd_unit(200)); + let frames = parser.parse(&make_pes(data, Some(90000))); + assert_eq!(frames.len(), 2); + assert_eq!( + frames[1].pts_ns - frames[0].pts_ns, + 907_029, + "44.1 k-family AU increments by 907_029 once the major sync is read" + ); + } + + #[test] + fn parser_48k_major_sync_keeps_833333_increment() { + // Regression: a 48 k-family (ratebits=0x0) major sync keeps the exact + // current 833_333 increment. + let mut parser = TrueHdParser::new(); + let mut a1 = make_truehd_unit(200); + a1[4..8].copy_from_slice(&0xF872_6FBAu32.to_be_bytes()); + a1[8..12].copy_from_slice(&format_info_with(0x0).to_be_bytes()); // 48 k + let mut data = a1; + data.extend_from_slice(&make_truehd_unit(200)); + let frames = parser.parse(&make_pes(data, Some(90000))); + assert_eq!(frames.len(), 2); + assert_eq!(frames[1].pts_ns - frames[0].pts_ns, 833_333); + } + + // --- #1 Atmos detection from num_substreams (msync[16] >> 4) --- + + /// Build a demuxed chunk with one major sync whose 17th sync byte (offset + /// 16 from the 0xF8) has top nibble `num_substreams`. The AU is padded past + /// byte 16 so the substream count is reachable. + fn major_sync_with_substreams(num_substreams: u8) -> Vec<u8> { + let mut data = vec![0x00, 0x00]; // leading junk; scan is byte-aligned + let sync_off = data.len(); + data.extend_from_slice(&0xF872_6FBAu32.to_be_bytes()); // bytes [off..off+4] + data.extend_from_slice(&format_info_with(0x0).to_be_bytes()); // format_info + // Pad up to and including byte `sync_off + 16`. + while data.len() <= sync_off + 16 { + data.push(0x00); + } + data[sync_off + 16] = (num_substreams & 0xF) << 4; + data + } + + #[test] + fn atmos_true_when_four_substreams() { + // num_substreams = 4 → byte 16 = 0x40 → Atmos object substream present. + let data = major_sync_with_substreams(4); + assert_eq!(truehd_is_atmos_from_stream(&data), Some(true)); + } + + #[test] + fn atmos_false_when_three_substreams() { + // num_substreams = 3 (plain 7.1 TrueHD) → byte 16 = 0x30 → not Atmos. + let data = major_sync_with_substreams(3); + assert_eq!(truehd_is_atmos_from_stream(&data), Some(false)); + } + + #[test] + fn atmos_none_when_au_too_short_for_substream_byte() { + // Major sync present but the chunk ends before byte sync_off+16 → None, + // never a false Atmos. Sync at offset 0; only format_info follows. + let mut data = 0xF872_6FBAu32.to_be_bytes().to_vec(); + data.extend_from_slice(&format_info_with(0x0).to_be_bytes()); // 8 bytes total + assert_eq!(truehd_is_atmos_from_stream(&data), None); + } + + #[test] + fn atmos_none_without_major_sync() { + let data = vec![0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88]; + assert_eq!(truehd_is_atmos_from_stream(&data), None); + } + + #[test] + fn sync_info_combines_channels_rate_and_atmos() { + // One scan yields all three facts: 7.1 channels, 96 kHz, 4 substreams. + let data = { + let mut d = vec![0x00, 0x00]; + let off = d.len(); + d.extend_from_slice(&0xF872_6FBAu32.to_be_bytes()); + d.extend_from_slice(&format_info_with(0x1).to_be_bytes()); // 96k + 7.1 + while d.len() <= off + 16 { + d.push(0x00); + } + d[off + 16] = 0x40; // 4 substreams + d + }; + let info = truehd_sync_info_from_stream(&data).expect("major sync found"); + assert_eq!(truehd_channels(info.format_info), Some(8)); + assert_eq!(truehd_sample_rate_hz(info.format_info), Some(96000)); + assert_eq!(info.is_atmos, Some(true)); + } } diff --git a/src/mux/codec/vc1.rs b/src/mux/codec/vc1.rs index 470600e..e5bee00 100644 --- a/src/mux/codec/vc1.rs +++ b/src/mux/codec/vc1.rs @@ -1050,10 +1050,10 @@ mod tests { /// codecPrivate, or the A-segment decodes against the wrong entry point. #[test] fn vc1_emits_entry_point_revert_to_first_value() { - let sh = vec![0x00, 0x00, 0x01, SC_SEQUENCE_HEADER, 0xAA, 0xBB]; + let sh = [0x00, 0x00, 0x01, SC_SEQUENCE_HEADER, 0xAA, 0xBB]; let ep_a = vec![0x00, 0x00, 0x01, SC_ENTRY_POINT, 0x11, 0x22]; let ep_b = vec![0x00, 0x00, 0x01, SC_ENTRY_POINT, 0x33, 0x44, 0x55]; - let frame = vec![0x00, 0x00, 0x01, SC_FRAME, 0x77]; + let frame = [0x00, 0x00, 0x01, SC_FRAME, 0x77]; let mut parser = Vc1Parser::new(); @@ -1106,10 +1106,10 @@ mod tests { /// seek points carry valid decoder state (SMPTE 421M). #[test] fn vc1_reasserts_active_headers_at_bare_keyframe() { - let sh_a = vec![0x00, 0x00, 0x01, SC_SEQUENCE_HEADER, 0xAA, 0xBB]; + let sh_a = [0x00, 0x00, 0x01, SC_SEQUENCE_HEADER, 0xAA, 0xBB]; let ep_a = vec![0x00, 0x00, 0x01, SC_ENTRY_POINT, 0x11, 0x22]; let ep_b = vec![0x00, 0x00, 0x01, SC_ENTRY_POINT, 0x33, 0x44, 0x55]; - let frame = vec![0x00, 0x00, 0x01, SC_FRAME, 0x77]; + let frame = [0x00, 0x00, 0x01, SC_FRAME, 0x77]; let mut parser = Vc1Parser::new(); @@ -1154,10 +1154,10 @@ mod tests { /// violating SMPTE 421M. After the fix, assembly is always seq-then-entry. #[test] fn vc1_keyframe_prefix_order_seq_unchanged_entry_redefined() { - let sh = vec![0x00, 0x00, 0x01, SC_SEQUENCE_HEADER, 0xAA, 0xBB, 0xCC]; + let sh = [0x00, 0x00, 0x01, SC_SEQUENCE_HEADER, 0xAA, 0xBB, 0xCC]; let ep_a = vec![0x00, 0x00, 0x01, SC_ENTRY_POINT, 0x11, 0x22]; let ep_b = vec![0x00, 0x00, 0x01, SC_ENTRY_POINT, 0x33, 0x44, 0x55]; - let frame = vec![0x00, 0x00, 0x01, SC_FRAME, 0x77]; + let frame = [0x00, 0x00, 0x01, SC_FRAME, 0x77]; let mut parser = Vc1Parser::new(); diff --git a/src/mux/demux_thread.rs b/src/mux/demux_thread.rs index 5e76dc6..dbace30 100644 --- a/src/mux/demux_thread.rs +++ b/src/mux/demux_thread.rs @@ -268,15 +268,15 @@ mod tests { /// absorb). ISO 13818-1 packet layout: sync 0x47 at TS offset 0 (BD off 4). fn bdts_pes_packet(pid: u16, payload: &[u8]) -> Vec<u8> { const SYNC: u8 = 0x47; - const TS_PAYLOAD: usize = 184; + use crate::consts::TS_PAYLOAD_BYTES; let mut pes = vec![0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x00, 0x00]; pes.extend_from_slice(payload); - assert!(pes.len() <= TS_PAYLOAD); + assert!(pes.len() <= TS_PAYLOAD_BYTES); let mut pkt = vec![0u8; 192]; pkt[4] = SYNC; pkt[5] = (((pid >> 8) as u8) & 0x1F) | 0x40; // PUSI pkt[6] = (pid & 0xFF) as u8; - let pad = TS_PAYLOAD - pes.len(); + let pad = TS_PAYLOAD_BYTES - pes.len(); if pad == 0 { pkt[7] = 0x10; // payload only pkt[8..8 + pes.len()].copy_from_slice(&pes); diff --git a/src/mux/m2ts_mux/packet.rs b/src/mux/m2ts_mux/packet.rs index 5eb9a4e..b2dd812 100644 --- a/src/mux/m2ts_mux/packet.rs +++ b/src/mux/m2ts_mux/packet.rs @@ -7,11 +7,11 @@ use crate::error::Error; use std::io::{self, Write}; -const TS_PACKET_SIZE: usize = 188; +use crate::consts::TS_PACKET_BYTES; /// Header is 4 bytes, leaving 184 bytes for the adaptation field area /// plus payload. With a 1-byte `adaptation_field_length` prefix the /// field body + stuffing can be at most 183 bytes. -const MAX_AF_LEN: usize = TS_PACKET_SIZE - 4 - 1; +const MAX_AF_LEN: usize = TS_PACKET_BYTES - 4 - 1; const SYNC_BYTE: u8 = 0x47; const STUFF_BYTE: u8 = 0xFF; @@ -20,14 +20,14 @@ const STUFF_BYTE: u8 = 0xFF; /// bytes when [`pad_to_188`](Self::pad_to_188) is called; if it's not /// called the caller is responsible for filling the packet exactly. pub(super) struct Packet { - buf: [u8; TS_PACKET_SIZE], + buf: [u8; TS_PACKET_BYTES], len: usize, } impl Packet { pub(super) fn new() -> Self { Self { - buf: [0u8; TS_PACKET_SIZE], + buf: [0u8; TS_PACKET_BYTES], len: 0, } } @@ -36,14 +36,14 @@ impl Packet { /// never reached by the sole caller (mod.rs sizes every field to sum /// to 188); the bound prevents a future caller from corrupting memory. fn push(&mut self, b: u8) { - if self.len < TS_PACKET_SIZE { + if self.len < TS_PACKET_BYTES { self.buf[self.len] = b; self.len += 1; } } fn extend(&mut self, bytes: &[u8]) { - let n = bytes.len().min(TS_PACKET_SIZE - self.len); + let n = bytes.len().min(TS_PACKET_BYTES - self.len); self.buf[self.len..self.len + n].copy_from_slice(&bytes[..n]); self.len += n; } @@ -111,7 +111,7 @@ impl Packet { /// packet past 188 bytes — overflow is a muxer invariant break, not /// something to silently emit. pub(super) fn append_payload(&mut self, payload: &[u8]) -> io::Result<()> { - if self.len + payload.len() > TS_PACKET_SIZE { + if self.len + payload.len() > TS_PACKET_BYTES { return Err(Error::M2tsPacketMalformed.into()); } self.extend(payload); @@ -123,7 +123,7 @@ impl Packet { /// For PSI packets only — payload-carrying packets reserve room for /// stuffing via `append_adaptation`. pub(super) fn pad_to_188(&mut self) { - while self.len < TS_PACKET_SIZE { + while self.len < TS_PACKET_BYTES { self.push(STUFF_BYTE); } } @@ -155,7 +155,7 @@ impl<W: Write> PacketWriter<W> { // Hard check, not a debug_assert: a non-188-byte packet would // corrupt the transport stream, so refuse to write it in any // build rather than emitting a short/long packet silently. - if bytes.len() != TS_PACKET_SIZE { + if bytes.len() != TS_PACKET_BYTES { return Err(Error::M2tsPacketMalformed.into()); } self.inner.write_all(bytes) diff --git a/src/mux/meta.rs b/src/mux/meta.rs index 7bbd166..90807b9 100644 --- a/src/mux/meta.rs +++ b/src/mux/meta.rs @@ -32,8 +32,7 @@ const SUPPORTED_VERSION: u8 = 1; /// Index of the version byte within [`MAGIC`]. const VERSION_BYTE: usize = 5; -/// BD-TS packet size (header must be padded to this boundary). -const PACKET_SIZE: usize = 192; +use crate::consts::BD_SOURCE_PACKET_BYTES; /// Metadata embedded in an m2ts file. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -310,15 +309,15 @@ pub fn write_header(w: &mut impl Write, meta: &M2tsMeta) -> io::Result<()> { // real stream 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(PACKET_SIZE) * PACKET_SIZE; + let padded_len = raw_len.div_ceil(BD_SOURCE_PACKET_BYTES) * BD_SOURCE_PACKET_BYTES; let padding = padded_len - raw_len; w.write_all(&MAGIC)?; w.write_all(&json_len.to_be_bytes())?; w.write_all(&json)?; if padding > 0 { - // Padding is at most PACKET_SIZE-1 bytes — stack buffer, no heap alloc. - let pad = [0u8; PACKET_SIZE]; + // Padding is at most BD_SOURCE_PACKET_BYTES-1 bytes — stack buffer, no heap alloc. + let pad = [0u8; BD_SOURCE_PACKET_BYTES]; w.write_all(&pad[..padding])?; } Ok(()) @@ -375,13 +374,13 @@ pub fn read_header(r: &mut impl Read) -> io::Result<Option<M2tsMeta>> { let meta: M2tsMeta = serde_json::from_slice(&json_buf).map_err(|_| crate::error::Error::NoMetadata)?; - // Skip padding to next 192-byte boundary (at most PACKET_SIZE-1 bytes → + // Skip padding to next 192-byte boundary (at most BD_SOURCE_PACKET_BYTES-1 bytes → // a stack buffer, no heap allocation). let raw_len = 8 + 4 + json_len; - let padded_len = raw_len.div_ceil(PACKET_SIZE) * PACKET_SIZE; + let padded_len = raw_len.div_ceil(BD_SOURCE_PACKET_BYTES) * BD_SOURCE_PACKET_BYTES; let padding = padded_len - raw_len; if padding > 0 { - let mut skip = [0u8; PACKET_SIZE]; + let mut skip = [0u8; BD_SOURCE_PACKET_BYTES]; r.read_exact(&mut skip[..padding])?; } @@ -512,7 +511,7 @@ mod tests { assert_eq!(back.streams.len(), 1); // Header is padded to a 192-byte boundary; the cursor must land // exactly there so the following BD-TS data stays aligned. - assert_eq!(cursor.position() as usize % PACKET_SIZE, 0); + assert_eq!(cursor.position() as usize % BD_SOURCE_PACKET_BYTES, 0); } #[test] @@ -643,7 +642,7 @@ mod tests { #[test] fn write_header_pads_to_192_byte_boundary() { - // The total written length must always be a multiple of PACKET_SIZE + // The total written length must always be a multiple of BD_SOURCE_PACKET_BYTES // (192). Test a range of JSON sizes by varying stream count. for n_streams in 0..6 { let mut t = DiscTitle::empty(); @@ -665,7 +664,7 @@ mod tests { let mut buf = Vec::new(); write_header(&mut buf, &meta).unwrap(); assert_eq!( - buf.len() % PACKET_SIZE, + buf.len() % BD_SOURCE_PACKET_BYTES, 0, "header for {n_streams} streams (len {}) not 192-aligned", buf.len() @@ -766,7 +765,7 @@ mod tests { let mut cur = io::Cursor::new(buf); read_header(&mut cur).unwrap().expect("header present"); assert_eq!(cur.position() as usize, header_len); - assert_eq!(header_len % PACKET_SIZE, 0); + assert_eq!(header_len % BD_SOURCE_PACKET_BYTES, 0); let mut next = [0u8; 1]; use std::io::Read as _; cur.read_exact(&mut next).unwrap(); diff --git a/src/mux/mkv.rs b/src/mux/mkv.rs index 1c409df..b27d41e 100644 --- a/src/mux/mkv.rs +++ b/src/mux/mkv.rs @@ -4113,7 +4113,7 @@ mod tests { } /// Read the body bytes of a direct TrackEntry child element by ID. - fn track_entry_child_body<'a>(data: &'a [u8], id: u32) -> Option<&'a [u8]> { + fn track_entry_child_body(data: &[u8], id: u32) -> Option<&[u8]> { let (te_start, te_size) = first_track_entry(data); let (_, body_start, body_size) = master_children(data, te_start, te_size) .into_iter() diff --git a/src/mux/mkvstream.rs b/src/mux/mkvstream.rs index ec0dfae..9b57b92 100644 --- a/src/mux/mkvstream.rs +++ b/src/mux/mkvstream.rs @@ -729,8 +729,16 @@ fn parse_track( }; let res = Resolution::from_height(ph); let chs = AudioChannels::from_count(ch); - let srs = if sr >= 96000.0 { + let srs = if sr >= 192000.0 { + SampleRate::S192 + } else if sr >= 176400.0 { + SampleRate::S176_4 + } else if sr >= 96000.0 { SampleRate::S96 + } else if sr >= 88200.0 { + SampleRate::S88_2 + } else if (44100.0..48000.0).contains(&sr) { + SampleRate::S44_1 } else { SampleRate::S48 }; diff --git a/src/mux/ts.rs b/src/mux/ts.rs index d6a32a8..e0937fa 100644 --- a/src/mux/ts.rs +++ b/src/mux/ts.rs @@ -6,11 +6,9 @@ //! //! This demuxer extracts PES packets from selected PIDs, with PTS/DTS timestamps. -/// BD transport stream packet size (4-byte extra header + 188-byte TS). -const BD_TS_PACKET_SIZE: usize = 192; +use crate::consts::BD_SOURCE_PACKET_BYTES; -/// Standard TS packet size. -const TS_PACKET_SIZE: usize = 188; +use crate::consts::TS_PACKET_BYTES; /// TS sync byte. const SYNC_BYTE: u8 = 0x47; @@ -259,13 +257,13 @@ impl TsDemuxer { // call, complete it from the head of `data` without touching // the rest of `data`. if !self.remainder.is_empty() { - let need = BD_TS_PACKET_SIZE - self.remainder.len(); + let need = BD_SOURCE_PACKET_BYTES - self.remainder.len(); if data.len() < need { // Still not a full packet — accumulate and wait. self.remainder.extend_from_slice(data); return completed; } - let mut boundary = [0u8; BD_TS_PACKET_SIZE]; + let mut boundary = [0u8; BD_SOURCE_PACKET_BYTES]; boundary[..self.remainder.len()].copy_from_slice(&self.remainder); boundary[self.remainder.len()..].copy_from_slice(&data[..need]); self.remainder.clear(); @@ -279,10 +277,10 @@ impl TsDemuxer { } // Aligned-packets fast path — reads directly out of `data`. - while offset + BD_TS_PACKET_SIZE <= data.len() { - let packet = &data[offset..offset + BD_TS_PACKET_SIZE]; + while offset + BD_SOURCE_PACKET_BYTES <= data.len() { + let packet = &data[offset..offset + BD_SOURCE_PACKET_BYTES]; let src = self.pkt_source(offset); - offset += BD_TS_PACKET_SIZE; + offset += BD_SOURCE_PACKET_BYTES; self.process_packet(packet, src, &mut completed); } // Advance the running base past every byte consumed this feed so the @@ -295,7 +293,7 @@ impl TsDemuxer { // prevent unbounded growth on a desynchronised stream). if offset < data.len() { let leftover = &data[offset..]; - if leftover.len() < BD_TS_PACKET_SIZE { + if leftover.len() < BD_SOURCE_PACKET_BYTES { self.remainder.extend_from_slice(leftover); } else { self.remainder.clear(); @@ -353,7 +351,7 @@ impl TsDemuxer { 4 }; - if payload_start >= TS_PACKET_SIZE { + if payload_start >= TS_PACKET_BYTES { return; } // adaptation == 0x02 → AF only, no payload. @@ -532,7 +530,7 @@ fn is_resync_point(data: &[u8], offset: usize) -> bool { if data.get(offset + 4) != Some(&SYNC_BYTE) { return false; } - match data.get(offset + BD_TS_PACKET_SIZE + 4) { + match data.get(offset + BD_SOURCE_PACKET_BYTES + 4) { Some(&b) => b == SYNC_BYTE, None => true, // last packet in the buffer — no follower to corroborate } @@ -544,7 +542,7 @@ fn is_resync_point(data: &[u8], offset: usize) -> bool { /// Accounts for the adaptation_field_control (bits 5:4 of the 4th TS header /// byte). Returns `None` when the packet carries no payload (AFC 0b10 = AF /// only, or the reserved 0b00) or when the adaptation field length runs past -/// the packet. `pkt` must be at least [`BD_TS_PACKET_SIZE`] bytes. +/// the packet. `pkt` must be at least [`BD_SOURCE_PACKET_BYTES`] bytes. fn psi_payload_base(pkt: &[u8]) -> Option<usize> { // TS header is pkt[4..]; byte pkt[7] holds AFC in bits 5:4. let afc = (pkt[7] >> 4) & 0x03; @@ -555,7 +553,7 @@ fn psi_payload_base(pkt: &[u8]) -> Option<usize> { // payload starts after it. let af_len = pkt[8] as usize; let base = 9 + af_len; // 4 + 4 + 1(length byte) + af_len - if base < BD_TS_PACKET_SIZE { + if base < BD_SOURCE_PACKET_BYTES { Some(base) } else { None // AF overruns the packet @@ -587,7 +585,7 @@ fn psi_payload_base(pkt: &[u8]) -> Option<usize> { /// matching section is found. fn collect_psi_section(data: &[u8], target_pid: u16, table_id: u8) -> Option<Vec<u8>> { let mut offset = 0; - while offset + BD_TS_PACKET_SIZE <= data.len() { + while offset + BD_SOURCE_PACKET_BYTES <= data.len() { if !is_resync_point(data, offset) { offset += 1; continue; @@ -599,12 +597,13 @@ fn collect_psi_section(data: &[u8], target_pid: u16, table_id: u8) -> Option<Vec // Locate the payload (pointer_field) accounting for any // adaptation field. A packet with no payload (AF only) or an // AF that overruns the packet is skipped. - let Some(payload_off) = psi_payload_base(&data[offset..offset + BD_TS_PACKET_SIZE]) + let Some(payload_off) = + psi_payload_base(&data[offset..offset + BD_SOURCE_PACKET_BYTES]) else { - offset += BD_TS_PACKET_SIZE; + offset += BD_SOURCE_PACKET_BYTES; continue; }; - let payload = &data[offset + payload_off..offset + BD_TS_PACKET_SIZE]; + 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 @@ -612,7 +611,7 @@ fn collect_psi_section(data: &[u8], target_pid: u16, table_id: u8) -> Option<Vec let pointer = payload[0] as usize; let sec_start = 1 + pointer; if sec_start + 3 > payload.len() || payload[sec_start] != table_id { - offset += BD_TS_PACKET_SIZE; + offset += BD_SOURCE_PACKET_BYTES; continue; } let section_len = @@ -631,9 +630,9 @@ fn collect_psi_section(data: &[u8], target_pid: u16, table_id: u8) -> Option<Vec // dropped/duplicated packet → the assembled section is corrupt, so // abandon it rather than splicing in misordered payload. let mut expected_cc = ((data[offset + 7] & 0x0F) + 1) & 0x0F; - let mut scan = offset + BD_TS_PACKET_SIZE; + let mut scan = offset + BD_SOURCE_PACKET_BYTES; let mut desync = false; - while scan + BD_TS_PACKET_SIZE <= data.len() && section.len() < total { + 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 @@ -653,16 +652,19 @@ fn collect_psi_section(data: &[u8], target_pid: u16, table_id: u8) -> Option<Vec expected_cc = (cc + 1) & 0x0F; // Continuation packets may also carry an adaptation // field; compute their payload base the same way. - if let Some(cbase) = psi_payload_base(&data[scan..scan + BD_TS_PACKET_SIZE]) { - section.extend_from_slice(&data[scan + cbase..scan + BD_TS_PACKET_SIZE]); + if let Some(cbase) = + psi_payload_base(&data[scan..scan + BD_SOURCE_PACKET_BYTES]) + { + section + .extend_from_slice(&data[scan + cbase..scan + BD_SOURCE_PACKET_BYTES]); } } - scan += BD_TS_PACKET_SIZE; + scan += BD_SOURCE_PACKET_BYTES; } if desync { // Restart PSI assembly from the next packet after this PUSI; // a later clean copy of the section may still appear. - offset += BD_TS_PACKET_SIZE; + offset += BD_SOURCE_PACKET_BYTES; continue; } if section.len() >= total { @@ -672,7 +674,7 @@ fn collect_psi_section(data: &[u8], target_pid: u16, table_id: u8) -> Option<Vec // Incomplete section (truncated input) — stop looking. return None; } - offset += BD_TS_PACKET_SIZE; + offset += BD_SOURCE_PACKET_BYTES; } None } @@ -833,7 +835,7 @@ mod tests { /// continuity_counter, carrying `payload` (truncated/padded to 184 bytes, /// payload-only adaptation). fn ts_payload_packet(pid: u16, pusi: bool, cc: u8, payload: &[u8]) -> Vec<u8> { - let mut pkt = vec![0u8; BD_TS_PACKET_SIZE]; + let mut pkt = vec![0u8; BD_SOURCE_PACKET_BYTES]; pkt[4] = SYNC_BYTE; pkt[5] = ((pid >> 8) as u8) & 0x1F; if pusi { @@ -934,7 +936,7 @@ mod tests { /// Wrap a 188-byte TS packet body in a 192-byte BD-TS packet /// (4-byte timecode prefix the scanner skips). fn bdts_packet(body: [u8; 184], pid: u16, pusi: bool) -> Vec<u8> { - let mut pkt = vec![0u8; BD_TS_PACKET_SIZE]; + let mut pkt = vec![0u8; BD_SOURCE_PACKET_BYTES]; // 4-byte timecode prefix is ignored; leave zero. pkt[4] = SYNC_BYTE; pkt[5] = ((pid >> 8) as u8) & 0x1F; @@ -1010,7 +1012,7 @@ mod tests { /// Build a 192-byte BD-TS data packet on `pid` carrying `payload` /// (payload-only adaptation, truncated/padded to fit one packet). fn data_packet(pid: u16, pusi: bool, payload: &[u8]) -> Vec<u8> { - let mut pkt = vec![0u8; BD_TS_PACKET_SIZE]; + let mut pkt = vec![0u8; BD_SOURCE_PACKET_BYTES]; pkt[4] = SYNC_BYTE; pkt[5] = ((pid >> 8) as u8) & 0x1F; if pusi { @@ -1018,7 +1020,7 @@ mod tests { } pkt[6] = (pid & 0xFF) as u8; pkt[7] = 0x10; // payload only, no adaptation field - let room = TS_PACKET_SIZE - 4; // 184 ES bytes after the 4-byte TS header + let room = TS_PACKET_BYTES - 4; // 184 ES bytes after the 4-byte TS header let n = payload.len().min(room); pkt[8..8 + n].copy_from_slice(&payload[..n]); pkt @@ -1029,7 +1031,7 @@ mod tests { /// payload base computation in scan_streams. fn pmt_packet_with_af(pmt_pid: u16, entries: &[(u8, u16)]) -> Vec<u8> { let af_len: u8 = 2; // 1 flags byte + 1 stuffing byte - let mut pkt = vec![0u8; BD_TS_PACKET_SIZE]; + let mut pkt = vec![0u8; BD_SOURCE_PACKET_BYTES]; pkt[4] = SYNC_BYTE; pkt[5] = (((pmt_pid >> 8) as u8) & 0x1F) | 0x40; // PUSI set pkt[6] = (pmt_pid & 0xFF) as u8; @@ -1039,7 +1041,7 @@ mod tests { pkt[10] = 0xFF; // stuffing // Payload (PSI) begins at 4 + 4 + 1 + af_len = 11. let payload_off = 4 + 4 + 1 + af_len as usize; - let mut body = vec![0xFFu8; BD_TS_PACKET_SIZE - payload_off]; + let mut body = vec![0xFFu8; BD_SOURCE_PACKET_BYTES - payload_off]; body[0] = 0x00; // pointer_field let s = 1; body[s] = 0x02; // table_id = PMT @@ -1270,9 +1272,9 @@ mod tests { } let mut pmt = pmt_two_packets(pmt_pid, &entries); // Corrupt the continuation packet's CC. pmt is exactly two BD-TS - // packets; the second starts at BD_TS_PACKET_SIZE. Its CC (low nibble + // packets; the second starts at BD_SOURCE_PACKET_BYTES. Its CC (low nibble // of offset+7) was set to 1 by pmt_two_packets; flip it to a gap (5). - let cc_off = BD_TS_PACKET_SIZE + 7; + let cc_off = BD_SOURCE_PACKET_BYTES + 7; pmt[cc_off] = (pmt[cc_off] & 0xF0) | 0x05; let mut data = pat_packet(pmt_pid); @@ -1296,16 +1298,16 @@ mod tests { /// bytes the demuxer must produce, unlike `data_packet` which leaves /// zero padding that a length-0 (unbounded) PES would absorb as ES. fn es_packet_exact(pid: u16, pusi: bool, payload: &[u8]) -> Vec<u8> { - const TS_PAYLOAD: usize = 184; - assert!(payload.len() <= TS_PAYLOAD); - let mut pkt = vec![0u8; BD_TS_PACKET_SIZE]; + use crate::consts::TS_PAYLOAD_BYTES; + assert!(payload.len() <= TS_PAYLOAD_BYTES); + let mut pkt = vec![0u8; BD_SOURCE_PACKET_BYTES]; pkt[4] = SYNC_BYTE; pkt[5] = ((pid >> 8) as u8) & 0x1F; if pusi { pkt[5] |= 0x40; } pkt[6] = (pid & 0xFF) as u8; - let pad = TS_PAYLOAD - payload.len(); + let pad = TS_PAYLOAD_BYTES - payload.len(); if pad == 0 { pkt[7] = 0x10; // payload only pkt[8..8 + payload.len()].copy_from_slice(payload); @@ -1550,7 +1552,7 @@ mod tests { demux.feed(&es_packet_exact(pid, true, &start)); // …then an AF-only continuation packet whose "payload" bytes must // be discarded. - let mut afonly = vec![0u8; BD_TS_PACKET_SIZE]; + let mut afonly = vec![0u8; BD_SOURCE_PACKET_BYTES]; afonly[4] = SYNC_BYTE; afonly[5] = ((pid >> 8) as u8) & 0x1F; // no PUSI afonly[6] = (pid & 0xFF) as u8; @@ -1576,7 +1578,7 @@ mod tests { // the TS packet. The AF bytes must NOT appear in the ES. let pid = 0x1011; let mut demux = TsDemuxer::new(&[pid]); - let mut pkt = vec![0u8; BD_TS_PACKET_SIZE]; + let mut pkt = vec![0u8; BD_SOURCE_PACKET_BYTES]; pkt[4] = SYNC_BYTE; pkt[5] = (((pid >> 8) as u8) & 0x1F) | 0x40; // PUSI pkt[6] = (pid & 0xFF) as u8; @@ -1616,7 +1618,7 @@ mod tests { // A larger value runs past the packet and must be discarded. let pid = 0x1011; let mut demux = TsDemuxer::new(&[pid]); - let mut pkt = vec![0u8; BD_TS_PACKET_SIZE]; + let mut pkt = vec![0u8; BD_SOURCE_PACKET_BYTES]; pkt[4] = SYNC_BYTE; pkt[5] = (((pid >> 8) as u8) & 0x1F) | 0x40; pkt[6] = (pid & 0xFF) as u8; @@ -1808,7 +1810,7 @@ mod tests { #[test] fn scan_streams_no_pat_returns_none() { // Without a PAT (table_id 0x00 on PID 0) there is no program to find. - let data = vec![0u8; BD_TS_PACKET_SIZE * 2]; // all zero, no sync bytes + let data = vec![0u8; BD_SOURCE_PACKET_BYTES * 2]; // all zero, no sync bytes assert!(scan_streams(&data).is_none()); } diff --git a/src/mux/tsmux.rs b/src/mux/tsmux.rs index e532bde..285c2a3 100644 --- a/src/mux/tsmux.rs +++ b/src/mux/tsmux.rs @@ -8,7 +8,7 @@ use super::hevc::{hvcc_to_annex_b, length_prefixed_to_annex_b}; use std::io::{self, Write}; const SYNC_BYTE: u8 = 0x47; -const TS_PAYLOAD: usize = 184; +use crate::consts::TS_PAYLOAD_BYTES; /// PID range treated as video (HEVC, triggers Annex-B conversion + RAI /// on keyframes). Both `write_frame` and `build_pes_header` consult this @@ -204,21 +204,21 @@ impl<W: Write> TsMuxer<W> { let remaining = pes_len - offset; // Invariant: TP_extra(4) + TS_header(4) + AF(af_bytes) + payload(payload_len) = 192, - // i.e. af_bytes + payload_len = TS_PAYLOAD (184). + // i.e. af_bytes + payload_len = TS_PAYLOAD_BYTES (184). // RAI on first packet of a keyframe video PES requires AF with flags=0x40. let want_rai = first && keyframe && is_video; // Pick payload_len and af_bytes per case. let (af_bytes, payload_len): (usize, usize) = if want_rai { // Minimum AF = 2 bytes (length=1, flags=0x40). Payload caps at 182. - let max_payload = TS_PAYLOAD - 2; + let max_payload = TS_PAYLOAD_BYTES - 2; let p = remaining.min(max_payload); - (TS_PAYLOAD - p, p) - } else if remaining >= TS_PAYLOAD { - (0, TS_PAYLOAD) // no AF, full payload + (TS_PAYLOAD_BYTES - p, p) + } else if remaining >= TS_PAYLOAD_BYTES { + (0, TS_PAYLOAD_BYTES) // no AF, full payload } else { // Stuffing-only AF, payload = remaining. - (TS_PAYLOAD - remaining, remaining) + (TS_PAYLOAD_BYTES - remaining, remaining) }; // TP_extra_header (4 bytes — arrival time, set to 0) @@ -363,7 +363,7 @@ fn build_pes_header(pid: u16, pts_90k: u64, data_len: usize) -> Vec<u8> { mod tests { use super::*; - const BD_PACKET_SIZE: usize = 192; + use crate::consts::BD_SOURCE_PACKET_BYTES; const VIDEO_PID: u16 = 0x1011; /// Parsed BD-TS packet (192 bytes total: 4 TP_extra + 4 TS header + 184 body). @@ -381,8 +381,8 @@ mod tests { /// Walk 192-byte BD-TS packets. fn parse_bd_ts(buf: &[u8]) -> Vec<TsPacket> { let mut out = Vec::new(); - for chunk in buf.chunks(BD_PACKET_SIZE) { - if chunk.len() != BD_PACKET_SIZE { + for chunk in buf.chunks(BD_SOURCE_PACKET_BYTES) { + if chunk.len() != BD_SOURCE_PACKET_BYTES { break; } // Skip TP_extra_header (4 bytes), parse TS header. @@ -762,9 +762,13 @@ mod tests { mux.finish().unwrap(); } assert!(!sink.is_empty()); - assert_eq!(sink.len() % BD_PACKET_SIZE, 0, "output must be 192-aligned"); - for chunk in sink.chunks(BD_PACKET_SIZE) { - assert_eq!(chunk.len(), BD_PACKET_SIZE); + assert_eq!( + sink.len() % BD_SOURCE_PACKET_BYTES, + 0, + "output must be 192-aligned" + ); + for chunk in sink.chunks(BD_SOURCE_PACKET_BYTES) { + assert_eq!(chunk.len(), BD_SOURCE_PACKET_BYTES); assert_eq!(chunk[4], SYNC_BYTE, "TS sync byte at offset 4"); } } diff --git a/src/mux/videomap.rs b/src/mux/videomap.rs index 5533082..e121e98 100644 --- a/src/mux/videomap.rs +++ b/src/mux/videomap.rs @@ -46,7 +46,7 @@ pub const FVI_TIMESCALE: u64 = 1_000_000_000; /// Bytes per `src.sector` unit (`docs/FVI_FORMAT.md` §6.2, §9). The highway's /// [`SourcePos`] counts 2048-byte logical sectors. -pub const FVI_SECTOR_SIZE: u32 = 2048; +pub const FVI_SECTOR_SIZE: u32 = crate::consts::SECTOR_BYTES as u32; // ── Logical model (serialization-independent) ──────────────────────────────── diff --git a/src/scsi/macos.rs b/src/scsi/macos.rs index 270a36b..b416285 100644 --- a/src/scsi/macos.rs +++ b/src/scsi/macos.rs @@ -244,6 +244,22 @@ fn cstr_to_str(bytes: &[u8]) -> &str { std::str::from_utf8(&bytes[..end]).unwrap_or("") } +pub(super) fn drive_has_disc(path: &Path) -> Result<bool> { + let mut transport = MacScsiTransport::open(path)?; + let cdb = [crate::scsi::SCSI_TEST_UNIT_READY, 0, 0, 0, 0, 0]; + let mut buf = [0u8; 0]; + match transport.execute( + &cdb, + crate::scsi::DataDirection::None, + &mut buf, + crate::scsi::TUR_TIMEOUT_MS, + ) { + Ok(_) => Ok(true), + Err(ref e) if e.scsi_sense().is_some_and(|s| s.is_not_ready()) => Ok(false), + Err(e) => Err(e), + } +} + #[cfg(test)] mod tests { use super::K_MAX_CDB_SIZE; @@ -256,7 +272,7 @@ mod tests { #[test] fn oversized_cdb_returns_invalid_cdb_length() { // Build a CDB one byte over the limit. - let long_cdb = vec![0u8; K_MAX_CDB_SIZE + 1]; + let long_cdb = [0u8; K_MAX_CDB_SIZE + 1]; // Replicate the guard logic from MacScsiTransport::execute so // this test runs on Linux CI as well (no IOKit present there). let result: Result<(), Error> = if long_cdb.len() > K_MAX_CDB_SIZE { @@ -279,7 +295,7 @@ mod tests { /// A CDB exactly at the limit must not trigger the guard. #[test] fn max_length_cdb_does_not_trigger_guard() { - let cdb = vec![0u8; K_MAX_CDB_SIZE]; + let cdb = [0u8; K_MAX_CDB_SIZE]; let triggered = cdb.len() > K_MAX_CDB_SIZE; assert!( !triggered, @@ -287,19 +303,3 @@ mod tests { ); } } - -pub(super) fn drive_has_disc(path: &Path) -> Result<bool> { - let mut transport = MacScsiTransport::open(path)?; - let cdb = [crate::scsi::SCSI_TEST_UNIT_READY, 0, 0, 0, 0, 0]; - let mut buf = [0u8; 0]; - match transport.execute( - &cdb, - crate::scsi::DataDirection::None, - &mut buf, - crate::scsi::TUR_TIMEOUT_MS, - ) { - Ok(_) => Ok(true), - Err(ref e) if e.scsi_sense().is_some_and(|s| s.is_not_ready()) => Ok(false), - Err(e) => Err(e), - } -} diff --git a/src/scsi/mod.rs b/src/scsi/mod.rs index d36531c..7a23b5c 100644 --- a/src/scsi/mod.rs +++ b/src/scsi/mod.rs @@ -992,7 +992,8 @@ mod scsi_sense_predicate_tests { // Each is_* predicate matches exactly its one key and no other. // Catches a copy-paste bug where e.g. is_not_ready compared the // wrong constant. - let cases: &[(u8, fn(&ScsiSense) -> bool)] = &[ + type SenseCase = (u8, fn(&ScsiSense) -> bool); + let cases: &[SenseCase] = &[ (SENSE_KEY_MEDIUM_ERROR, ScsiSense::is_medium_error), (SENSE_KEY_HARDWARE_ERROR, ScsiSense::is_hardware_error), (SENSE_KEY_NOT_READY, ScsiSense::is_not_ready), @@ -1126,6 +1127,7 @@ mod inquiry_tests { //! - vendor identification: bytes 8..16 (8 ASCII chars) //! - product identification: bytes 16..32 (16 ASCII chars) //! - product revision level: bytes 32..36 (4 ASCII chars) + //! //! Fields are space-padded ASCII; the parser trims surrounding //! whitespace. use super::*; diff --git a/src/sector/mod.rs b/src/sector/mod.rs index 2201895..965447b 100644 --- a/src/sector/mod.rs +++ b/src/sector/mod.rs @@ -150,14 +150,11 @@ mod tests { speeds: Arc<Mutex<Vec<u16>>>, } + /// A `Spy` under test plus the handles recording its reads and speed sets. + type SpyHarness = (Spy, Arc<Mutex<Vec<(u32, u16, bool)>>>, Arc<Mutex<Vec<u16>>>); + impl Spy { - fn new( - capacity: u32, - ) -> ( - Self, - Arc<Mutex<Vec<(u32, u16, bool)>>>, - Arc<Mutex<Vec<u16>>>, - ) { + fn new(capacity: u32) -> SpyHarness { let reads = Arc::new(Mutex::new(Vec::new())); let speeds = Arc::new(Mutex::new(Vec::new())); ( diff --git a/src/sector/prefetched.rs b/src/sector/prefetched.rs index 0425af4..d2b962c 100644 --- a/src/sector/prefetched.rs +++ b/src/sector/prefetched.rs @@ -667,7 +667,7 @@ mod tests { assert!(err.is_err(), "zero batch_sectors must be rejected"); } - /// >3 sequential direct `read_sectors` calls must succeed. The + /// More than 3 sequential direct `read_sectors` calls must succeed. The /// recycle pool seeds PREFETCH_CHANNEL_DEPTH+1 (3) buffers; before /// the fix the direct path dropped each drained buffer, so the 4th /// call deadlocked. Watchdog-guarded. diff --git a/src/unlock.rs b/src/unlock.rs index fcb659d..0114d1d 100644 --- a/src/unlock.rs +++ b/src/unlock.rs @@ -12,18 +12,46 @@ //! drive is left untouched and the caller falls back to the standard //! host-certificate AACS handshake (the "OEM route"). +use crate::aacs::Vid; use crate::error::Result; use crate::identity::DriveId; use crate::scsi::ScsiTransport; use std::sync::RwLock; +/// Why an [`Unlocker::unlock`] attempt produced no Volume ID. Structured and +/// English-free — applications render it. `Scsi` wraps the numeric error code +/// from [`crate::error::Error::code`] (the `Error` itself is not `Clone`). +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum UnlockError { + /// This unlocker cannot put this drive's firmware into extended mode. + FirmwareNotUnlockable, + /// No usable (non-revoked) host certificate was available for the auth + /// attempt. `mkb` is the disc MKB generation when known. + NoUsableHostCert { mkb: Option<u32> }, + /// Every available host cert was revoked on this drive's HRL. `mkb` is the + /// disc MKB generation when known. + CertRevoked { mkb: Option<u32> }, + /// The drive rejected the auth handshake (non-revocation rejection / wedge). + HandshakeRejected, + /// Auth succeeded (or was skipped) but the Volume ID could not be read. + VidUnavailable, + /// A SCSI/transport error; carries the numeric [`crate::error::Error`] code. + Scsi(u16), +} + +impl From<crate::error::Error> for UnlockError { + fn from(e: crate::error::Error) -> Self { + UnlockError::Scsi(e.code()) + } +} + /// A pluggable drive-capability provider. /// /// Unlockers are optional drive-capability providers. libfreemkv's AACS /// layer is the always-present baseline; it uses an unlocker's capabilities -/// when one matches, and does the full cert handshake when none do. -/// Implement only the capabilities your drive supports — the rest default -/// to no-op. +/// when one matches, and does the in-tree cert handshake (the +/// `AacsCertUnlocker` peer) when none do. /// /// Implementors own everything about *how* a particular drive family is /// driven: firmware upload, vendor CDBs, variant logic. libfreemkv only @@ -35,19 +63,17 @@ pub trait Unlocker: Send + Sync { /// True if this unlocker handles the given drive. fn matches(&self, id: &DriveId) -> bool; - /// Put the drive into extended-access mode (firmware/bootloader/whatever THIS - /// unlocker needs). The one required capability. - fn unlock_drive(&self, scsi: &mut dyn ScsiTransport, id: &DriveId) -> Result<()>; - - /// Read the disc Volume ID directly, bypassing the AACS cert handshake. - /// None → libfreemkv falls back to the cert-based read. Default: no-op. - fn read_volume_id( + /// Put the drive into extended-access mode (firmware/bootloader/whatever + /// THIS unlocker needs) AND return the disc's Volume ID — folding the old + /// `unlock_drive()` + `read_volume_id()` into one step. A firmware unlocker + /// that cannot unlock returns [`UnlockError::FirmwareNotUnlockable`]; one + /// that unlocks but has no OEM VID path returns [`UnlockError::VidUnavailable`]. + /// Either error makes libfreemkv fall through to the in-tree cert handshake. + fn unlock( &self, - _scsi: &mut dyn ScsiTransport, - _id: &DriveId, - ) -> Result<Option<[u8; 16]>> { - Ok(None) - } + scsi: &mut dyn ScsiTransport, + id: &DriveId, + ) -> std::result::Result<Vid, UnlockError>; /// Raise the drive to its maximum read speed. Default: no-op. fn set_max_read_speed(&self, _scsi: &mut dyn ScsiTransport, _id: &DriveId) -> Result<()> { @@ -71,15 +97,22 @@ pub fn register_unlocker(u: Box<dyn Unlocker>) { } } -/// Walk the registry in order and run the first matching unlocker. +/// Walk the registry in order and run the first matching unlocker, returning +/// its name AND the Volume ID it produced. /// /// Returns: -/// * `Ok(Some(name))` — a registered unlocker matched and unlocked the -/// drive; `name` is its [`Unlocker::name`]. -/// * `Ok(None)` — no unlocker matched; the drive was left untouched and -/// the caller should fall through to the host-cert handshake. -/// * `Err(_)` — an unlocker matched but its `unlock_drive` failed. -pub(crate) fn route_unlock(scsi: &mut dyn ScsiTransport, id: &DriveId) -> Result<Option<String>> { +/// * `Ok(Some((name, vid)))` — a registered unlocker matched, put the drive +/// into extended mode, and returned the OEM Volume ID. The caller stashes +/// the VID for the handshake phase and need not run the cert handshake. +/// * `Ok(None)` — no unlocker matched, OR the matching unlocker failed +/// ([`UnlockError`], logged). Either way the drive is usable in stock mode +/// and the caller falls through to the in-tree cert handshake. Folding an +/// unlock failure into `Ok(None)` keeps drive `init()` infallible — a drive +/// that simply isn't firmware-unlockable must not fail init. +pub(crate) fn route_unlock( + scsi: &mut dyn ScsiTransport, + id: &DriveId, +) -> Result<Option<(String, Vid)>> { let reg = match REGISTRY.read() { Ok(r) => r, // A poisoned lock means a prior unlocker panicked; treat as @@ -89,37 +122,21 @@ pub(crate) fn route_unlock(scsi: &mut dyn ScsiTransport, id: &DriveId) -> Result for u in reg.iter() { if u.matches(id) { let name = u.name().to_string(); - u.unlock_drive(scsi, id)?; - return Ok(Some(name)); - } - } - Ok(None) -} - -/// Walk the registry in order and ask the first matching unlocker for the -/// OEM Volume ID. -/// -/// Mirrors [`route_unlock`]'s resolution so the SAME identified unlocker -/// that unlocks the drive is the one consulted for VID. Returns: -/// * `Ok(Some(vid))` — a matching unlocker served the VID via its OEM -/// path (no cert handshake needed; VID is decoupled from the HRL). -/// * `Ok(None)` — no unlocker matched, or the matching unlocker has no -/// OEM VID path; the caller falls through to the cert-based VID read. -/// * `Err(_)` — the matching unlocker's `read_volume_id` failed (e.g. the -/// OEM CDB returned a malformed response). -pub(crate) fn unlocker_read_volume_id( - scsi: &mut dyn ScsiTransport, - id: &DriveId, -) -> Result<Option<[u8; 16]>> { - let reg = match REGISTRY.read() { - Ok(r) => r, - // Poisoned lock ⇒ treat as "no unlocker available" so the cert - // fallback still runs. - Err(_) => return Ok(None), - }; - for u in reg.iter() { - if u.matches(id) { - return u.read_volume_id(scsi, id); + match u.unlock(scsi, id) { + Ok(vid) => return Ok(Some((name, vid))), + Err(e) => { + // A firmware unlocker that can't unlock / has no OEM VID: + // fall through to the cert handshake. Debug-only structured + // log (variant identifiers, no English prose). + tracing::warn!( + target: "freemkv::unlock", + unlocker = %name, + outcome = ?e, + "unlocker matched but produced no VID; falling through to cert handshake" + ); + return Ok(None); + } + } } } Ok(None) @@ -200,17 +217,17 @@ mod tests { DriveId::from_inquiry(&inquiry, "") } - /// Fake unlocker that records whether it ran, matches on vendor id, and - /// optionally serves a Volume ID (mirroring the read_volume_id capability) - /// or records a set_max_read_speed call. + /// Fake unlocker that records whether its `unlock` ran, matches on vendor + /// id, and serves a Volume ID (`Some` → `Ok(Vid)`; `None` → + /// `Err(VidUnavailable)`, i.e. matched-but-no-OEM-VID → cert fallback) or + /// records a `set_max_read_speed` call. struct FakeUnlocker { want_vendor: String, ran: Arc<AtomicBool>, - /// VID this unlocker returns: `Some(vid)` (capability present), - /// `None` (no OEM path → cert fallback). `vid_ran` records whether - /// read_volume_id was consulted. + /// VID this unlocker returns: `Some(vid)` → `unlock` yields `Ok(Vid)`; + /// `None` → `unlock` yields `Err(UnlockError::VidUnavailable)` so + /// `route_unlock` falls through to the cert handshake. vid: Option<[u8; 16]>, - vid_ran: Arc<AtomicBool>, /// Records whether set_max_read_speed was invoked. speed_ran: Arc<AtomicBool>, } @@ -219,14 +236,13 @@ mod tests { Self { want_vendor: vendor.into(), ran, - vid: None, - vid_ran: Arc::new(AtomicBool::new(false)), + // Default: a successful unlock returning an all-zero VID. + vid: Some([0u8; 16]), speed_ran: Arc::new(AtomicBool::new(false)), } } - fn with_vid(mut self, vid: Option<[u8; 16]>, vid_ran: Arc<AtomicBool>) -> Self { + fn with_vid(mut self, vid: Option<[u8; 16]>) -> Self { self.vid = vid; - self.vid_ran = vid_ran; self } fn with_speed(mut self, speed_ran: Arc<AtomicBool>) -> Self { @@ -241,17 +257,16 @@ mod tests { fn matches(&self, id: &DriveId) -> bool { id.vendor_id.trim() == self.want_vendor } - fn unlock_drive(&self, _scsi: &mut dyn ScsiTransport, _id: &DriveId) -> Result<()> { - self.ran.store(true, Ordering::SeqCst); - Ok(()) - } - fn read_volume_id( + fn unlock( &self, _scsi: &mut dyn ScsiTransport, _id: &DriveId, - ) -> Result<Option<[u8; 16]>> { - self.vid_ran.store(true, Ordering::SeqCst); - Ok(self.vid) + ) -> std::result::Result<Vid, UnlockError> { + self.ran.store(true, Ordering::SeqCst); + match self.vid { + Some(v) => Ok(Vid(v)), + None => Err(UnlockError::VidUnavailable), + } } fn set_max_read_speed(&self, _scsi: &mut dyn ScsiTransport, _id: &DriveId) -> Result<()> { self.speed_ran.store(true, Ordering::SeqCst); @@ -259,8 +274,21 @@ mod tests { } } - /// A registered, matching unlocker runs; a non-matching identity leaves - /// the registry untouched and routes to the OEM (cert) fallback. + /// `UnlockError` is `PartialEq` and a crate `Error` folds into + /// `Scsi(code)` — the conversion `?`-callers rely on, English-free. + #[test] + fn unlock_error_from_crate_error_carries_code() { + let e: UnlockError = crate::error::Error::AacsVidUnavailable.into(); + assert_eq!(e, UnlockError::Scsi(crate::error::E_AACS_VID_UNAVAILABLE)); + assert_ne!( + UnlockError::NoUsableHostCert { mkb: Some(1) }, + UnlockError::NoUsableHostCert { mkb: Some(2) } + ); + } + + /// A registered, matching unlocker runs and returns its name + VID; a + /// non-matching identity leaves the registry untouched and routes to the + /// cert fallback (`None`). /// /// Both assertions live in one test because the registry is process-wide /// and tests share it — running them as one case keeps the ordering @@ -270,70 +298,64 @@ mod tests { let ran = Arc::new(AtomicBool::new(false)); register_unlocker(Box::new(FakeUnlocker::new("MATCHVND", ran.clone()))); - // Matching identity → unlocker runs, returns its name. + // Matching identity → unlocker runs, returns its name + VID. let mut scsi = NoopTransport; let matched = route_unlock(&mut scsi, &fake_id("MATCHVND")).unwrap(); - assert_eq!(matched.as_deref(), Some("fake"), "matching unlocker runs"); - assert!(ran.load(Ordering::SeqCst), "unlock_drive() was invoked"); + assert_eq!( + matched.as_ref().map(|(n, _)| n.as_str()), + Some("fake"), + "matching unlocker runs" + ); + assert!(ran.load(Ordering::SeqCst), "unlock() was invoked"); - // Non-matching identity → no unlocker runs, OEM path (None). + // Non-matching identity → no unlocker runs, cert path (None). ran.store(false, Ordering::SeqCst); let none = route_unlock(&mut scsi, &fake_id("OTHERVND")).unwrap(); - assert!(none.is_none(), "no match → OEM/cert fallback"); + assert!(none.is_none(), "no match → cert fallback"); assert!( !ran.load(Ordering::SeqCst), - "unlock_drive() not invoked on no-match" + "unlock() not invoked on no-match" ); } - /// `unlocker_read_volume_id` consults the FIRST matching unlocker's - /// `read_volume_id`. A matching unlocker that returns `Some(vid)` yields - /// that VID (the OEM path — cert handshake skipped). A matching unlocker - /// that returns `None`, or no match at all, yields `Ok(None)` (cert - /// fallback). + /// `route_unlock` returns the FIRST matching unlocker's VID. A matching + /// unlocker that yields `Ok(Vid)` returns that VID (OEM path — cert + /// handshake skipped). A matching unlocker whose `unlock` errors (no OEM + /// VID), or no match at all, yields `Ok(None)` (cert fallback). /// /// Distinct vendor ids keep this independent of the other registry test /// despite the process-wide shared registry. #[test] - fn unlocker_read_volume_id_routes_match_else_cert() { + fn route_unlock_returns_vid_else_cert() { let mut scsi = NoopTransport; - // Unlocker WITH an OEM VID capability. Vendor ids are exactly 8 - // chars: INQUIRY field [8..16] has no null padding to trim, so the - // trimmed compare in `matches` is exact. + // Unlocker WITH an OEM VID. Vendor ids are exactly 8 chars: INQUIRY + // field [8..16] has no null padding to trim, so `matches` is exact. let vid = [0x5Au8; 16]; - let vid_ran = Arc::new(AtomicBool::new(false)); register_unlocker(Box::new( - FakeUnlocker::new("VIDVNDOR", Arc::new(AtomicBool::new(false))) - .with_vid(Some(vid), vid_ran.clone()), + FakeUnlocker::new("VIDVNDOR", Arc::new(AtomicBool::new(false))).with_vid(Some(vid)), )); - // Matching identity → read_volume_id consulted, its VID used. - let got = unlocker_read_volume_id(&mut scsi, &fake_id("VIDVNDOR")).unwrap(); - assert_eq!(got, Some(vid), "matching unlocker's OEM VID is used"); - assert!( - vid_ran.load(Ordering::SeqCst), - "read_volume_id() was consulted" + // Matching identity → its VID is returned. + let got = route_unlock(&mut scsi, &fake_id("VIDVNDOR")).unwrap(); + assert_eq!( + got.map(|(_, v)| v), + Some(Vid(vid)), + "matching unlocker's OEM VID is used" ); - // Unlocker that MATCHES but has NO OEM VID path (read_volume_id → None). - let none_ran = Arc::new(AtomicBool::new(false)); + // Unlocker that MATCHES but has NO OEM VID path (unlock → Err) → cert. register_unlocker(Box::new( - FakeUnlocker::new("NOVIDVND", Arc::new(AtomicBool::new(false))) - .with_vid(None, none_ran.clone()), + FakeUnlocker::new("NOVIDVND", Arc::new(AtomicBool::new(false))).with_vid(None), )); - let got = unlocker_read_volume_id(&mut scsi, &fake_id("NOVIDVND")).unwrap(); + let got = route_unlock(&mut scsi, &fake_id("NOVIDVND")).unwrap(); assert!( got.is_none(), "unlocker without OEM VID falls through to cert" ); - assert!( - none_ran.load(Ordering::SeqCst), - "read_volume_id() consulted even when it returns None" - ); - // No matching unlocker → Ok(None), nothing consulted. - let got = unlocker_read_volume_id(&mut scsi, &fake_id("UNKNWNVD")).unwrap(); + // No matching unlocker → Ok(None), cert fallback. + let got = route_unlock(&mut scsi, &fake_id("UNKNWNVD")).unwrap(); assert!(got.is_none(), "no match → cert fallback"); } @@ -429,7 +451,11 @@ mod tests { register_unlocker(Box::new(FakeUnlocker::new("DUPEVNDR", second_ran.clone()))); let matched = route_unlock(&mut scsi, &fake_id("DUPEVNDR")).unwrap(); - assert_eq!(matched.as_deref(), Some("fake"), "a match was routed"); + assert_eq!( + matched.as_ref().map(|(n, _)| n.as_str()), + Some("fake"), + "a match was routed" + ); assert!( first_ran.load(Ordering::SeqCst), "the FIRST-registered matching unlocker ran" diff --git a/tests/passn_handler_ab.rs b/tests/passn_handler_ab.rs index 79eee81..c3a23fb 100644 --- a/tests/passn_handler_ab.rs +++ b/tests/passn_handler_ab.rs @@ -84,8 +84,11 @@ struct ScriptedSectorReader { trace: Arc<Mutex<Vec<(u32, u16, bool)>>>, } +/// A `ScriptedSectorReader` plus the handle recording its `(lba, count, ok)` trace. +type ScriptedHarness = (ScriptedSectorReader, Arc<Mutex<Vec<(u32, u16, bool)>>>); + impl ScriptedSectorReader { - fn new(capacity: u32) -> (Self, Arc<Mutex<Vec<(u32, u16, bool)>>>) { + fn new(capacity: u32) -> ScriptedHarness { let trace = Arc::new(Mutex::new(Vec::new())); ( Self {