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<Vid,UnlockError> + 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.
This commit is contained in:
@@ -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<UnitKey> {
|
||||||
|
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<MediaKey, crate::error::Error> {
|
||||||
|
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<u8> = 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-23
@@ -14,7 +14,7 @@ pub(crate) const AACS_IV: [u8; 16] = [
|
|||||||
pub const ALIGNED_UNIT_LEN: usize = 6144;
|
pub const ALIGNED_UNIT_LEN: usize = 6144;
|
||||||
|
|
||||||
/// An AACS aligned unit spans this many 2048-byte sectors (3).
|
/// 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
|
/// 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`,
|
/// 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
|
lba.saturating_sub(unit_base) % ALIGNED_UNIT_SECTORS == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Size of one sector.
|
use crate::consts::SECTOR_BYTES;
|
||||||
const SECTOR_LEN: usize = 2048;
|
|
||||||
|
|
||||||
/// Transport stream packet spacing in Blu-ray m2ts (192 bytes = 4 TP_extra + 188 TS).
|
use crate::consts::BD_SOURCE_PACKET_BYTES;
|
||||||
const TS_PACKET_LEN: usize = 192;
|
|
||||||
|
|
||||||
/// TS sync byte.
|
/// TS sync byte.
|
||||||
const TS_SYNC: u8 = 0x47;
|
const TS_SYNC: u8 = 0x47;
|
||||||
@@ -133,7 +131,7 @@ pub fn ts_sync_count(unit: &[u8]) -> usize {
|
|||||||
if unit[offset] == TS_SYNC {
|
if unit[offset] == TS_SYNC {
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
offset += TS_PACKET_LEN;
|
offset += BD_SOURCE_PACKET_BYTES;
|
||||||
}
|
}
|
||||||
count
|
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.
|
/// Number of BD-TS packets in the unit — the maximum possible sync count.
|
||||||
pub fn ts_packet_total(unit: &[u8]) -> usize {
|
pub fn ts_packet_total(unit: &[u8]) -> usize {
|
||||||
// One sync byte per 192-byte BD-TS packet (at offset 4 of each). The old
|
// 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`.
|
// form `4 + k·192`.
|
||||||
unit.len() / TS_PACKET_LEN
|
unit.len() / BD_SOURCE_PACKET_BYTES
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ts_syncs_intact(unit: &[u8]) -> bool {
|
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).
|
/// 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.
|
/// 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]) {
|
pub fn decrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) {
|
||||||
for sector_start in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_LEN) {
|
for sector_start in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) {
|
||||||
if sector_start + SECTOR_LEN > unit.len() {
|
if sector_start + SECTOR_BYTES > unit.len() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// First 16 bytes of each sector are plaintext
|
// First 16 bytes of each sector are plaintext
|
||||||
aes_cbc_decrypt(
|
aes_cbc_decrypt(
|
||||||
read_data_key,
|
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;
|
let mut off = 4;
|
||||||
while off < ALIGNED_UNIT_LEN {
|
while off < ALIGNED_UNIT_LEN {
|
||||||
unit[off] = TS_SYNC;
|
unit[off] = TS_SYNC;
|
||||||
off += TS_PACKET_LEN;
|
off += BD_SOURCE_PACKET_BYTES;
|
||||||
}
|
}
|
||||||
let key = [0u8; 16];
|
let key = [0u8; 16];
|
||||||
assert!(!is_aacs_scrambled(&unit));
|
assert!(!is_aacs_scrambled(&unit));
|
||||||
@@ -401,7 +399,9 @@ mod tests {
|
|||||||
let unit = vec![0u8; ALIGNED_UNIT_LEN];
|
let unit = vec![0u8; ALIGNED_UNIT_LEN];
|
||||||
assert_eq!(ts_packet_total(&unit), 32);
|
assert_eq!(ts_packet_total(&unit), 32);
|
||||||
// Confirm the loop visits exactly that many stride positions.
|
// 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));
|
assert_eq!(visited, ts_packet_total(&unit));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,7 +419,7 @@ mod tests {
|
|||||||
let mut placed = 0;
|
let mut placed = 0;
|
||||||
while off < ALIGNED_UNIT_LEN && placed < n {
|
while off < ALIGNED_UNIT_LEN && placed < n {
|
||||||
unit[off] = TS_SYNC;
|
unit[off] = TS_SYNC;
|
||||||
off += TS_PACKET_LEN;
|
off += BD_SOURCE_PACKET_BYTES;
|
||||||
placed += 1;
|
placed += 1;
|
||||||
}
|
}
|
||||||
unit
|
unit
|
||||||
@@ -443,7 +443,7 @@ mod tests {
|
|||||||
let mut off = 4;
|
let mut off = 4;
|
||||||
while off < ALIGNED_UNIT_LEN {
|
while off < ALIGNED_UNIT_LEN {
|
||||||
clear[off] = TS_SYNC;
|
clear[off] = TS_SYNC;
|
||||||
off += TS_PACKET_LEN;
|
off += BD_SOURCE_PACKET_BYTES;
|
||||||
}
|
}
|
||||||
assert_eq!(ts_sync_count(&clear), 32);
|
assert_eq!(ts_sync_count(&clear), 32);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -500,7 +500,7 @@ mod tests {
|
|||||||
let mut offset = 4;
|
let mut offset = 4;
|
||||||
while offset < ALIGNED_UNIT_LEN {
|
while offset < ALIGNED_UNIT_LEN {
|
||||||
plain[offset] = TS_SYNC;
|
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
|
// 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.
|
// TS syncs, which is exactly what `is_aacs_scrambled` (raw-sync) detects.
|
||||||
@@ -541,7 +541,7 @@ mod tests {
|
|||||||
if unit[off] == TS_SYNC {
|
if unit[off] == TS_SYNC {
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
off += TS_PACKET_LEN;
|
off += BD_SOURCE_PACKET_BYTES;
|
||||||
}
|
}
|
||||||
// Assert against the single canonical packet count, not the old
|
// Assert against the single canonical packet count, not the old
|
||||||
// `(len - 4) / 192 + 1` form that `ts_packet_total` corrected away from.
|
// `(len - 4) / 192 + 1` form that `ts_packet_total` corrected away from.
|
||||||
@@ -584,7 +584,7 @@ mod tests {
|
|||||||
let mut off = 4;
|
let mut off = 4;
|
||||||
while off < ALIGNED_UNIT_LEN {
|
while off < ALIGNED_UNIT_LEN {
|
||||||
unit[off] = TS_SYNC;
|
unit[off] = TS_SYNC;
|
||||||
off += TS_PACKET_LEN;
|
off += BD_SOURCE_PACKET_BYTES;
|
||||||
}
|
}
|
||||||
unit
|
unit
|
||||||
}
|
}
|
||||||
@@ -877,10 +877,10 @@ mod tests {
|
|||||||
|
|
||||||
// Forward: CBC-encrypt unit[s+16 .. s+2048] per sector under AACS IV.
|
// Forward: CBC-encrypt unit[s+16 .. s+2048] per sector under AACS IV.
|
||||||
let cipher = Aes128::new(GenericArray::from_slice(&rdk));
|
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 mut prev = AACS_IV;
|
||||||
let body = s + 16;
|
let body = s + 16;
|
||||||
let end = s + SECTOR_LEN;
|
let end = s + SECTOR_BYTES;
|
||||||
let nblocks = (end - body) / 16;
|
let nblocks = (end - body) / 16;
|
||||||
for i in 0..nblocks {
|
for i in 0..nblocks {
|
||||||
let off = body + i * 16;
|
let off = body + i * 16;
|
||||||
@@ -901,7 +901,7 @@ mod tests {
|
|||||||
"decrypt_bus must invert per-sector bus encrypt"
|
"decrypt_bus must invert per-sector bus encrypt"
|
||||||
);
|
);
|
||||||
// Each sector's first 16 bytes equal the original (never touched).
|
// 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]);
|
assert_eq!(&unit[s..s + 16], &plain[s..s + 16]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -934,9 +934,9 @@ mod tests {
|
|||||||
aacs_encrypt_unit(&mut unit, &unit_key);
|
aacs_encrypt_unit(&mut unit, &unit_key);
|
||||||
// Layer 2: bus-encrypt on top (per-sector, bytes 16..2048).
|
// Layer 2: bus-encrypt on top (per-sector, bytes 16..2048).
|
||||||
let cipher = Aes128::new(GenericArray::from_slice(&rdk));
|
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 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;
|
let off = s + 16 + i * 16;
|
||||||
for j in 0..16 {
|
for j in 0..16 {
|
||||||
unit[off + j] ^= prev[j];
|
unit[off + j] ^= prev[j];
|
||||||
|
|||||||
+26
-9
@@ -1573,29 +1573,46 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_verify_host_cert_from_keydb() {
|
fn test_verify_host_cert_from_keydb() {
|
||||||
// Verify the host cert from our KEYDB
|
// Exercise verify_cert against a real AACS 1.0 host certificate.
|
||||||
let keydb_path = match std::env::var("KEYDB_PATH").ok() {
|
//
|
||||||
|
// 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),
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
let certificate = match std::fs::read(&cert_path) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
let db = crate::aacs::KeyDb::load(&keydb_path).unwrap();
|
// Direct HostCert construction — no parser. Only `certificate` feeds
|
||||||
if let Some(hc) = db.host_certs.first() {
|
// 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);
|
let valid = verify_cert(&hc.certificate);
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"Host cert verification: {}",
|
"Host cert verification: {}",
|
||||||
if valid { "PASS" } else { "FAIL" }
|
if valid { "PASS" } else { "FAIL" }
|
||||||
);
|
);
|
||||||
// Note: our cert is revoked but should still have valid LA signature
|
// Note: a revoked cert should still carry a valid LA signature.
|
||||||
// If it doesn't verify, the LA public key might be wrong
|
// If it doesn't verify, the LA public key might be wrong.
|
||||||
if !valid {
|
if !valid {
|
||||||
eprintln!(" (cert may use different LA key or format)");
|
eprintln!(" (cert may use different LA key or format)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
// Hardening additions
|
// Hardening additions
|
||||||
|
|||||||
-1011
File diff suppressed because it is too large
Load Diff
+107
-307
@@ -1,7 +1,7 @@
|
|||||||
//! AACS key resolution — VUK derivation, MKB processing, disc hash, unit key parsing.
|
//! AACS key resolution — VUK derivation, MKB processing, disc hash, unit key parsing.
|
||||||
|
|
||||||
use super::decrypt::aes_ecb_decrypt;
|
use super::decrypt::aes_ecb_decrypt;
|
||||||
use super::keydb::DeviceKey;
|
use super::types::DeviceKey;
|
||||||
|
|
||||||
// ── AACS version ────────────────────────────────────────────────────────────
|
// ── AACS version ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1529,16 +1529,10 @@ fn match_keydb_unit_keys(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::super::decrypt::{ALIGNED_UNIT_LEN, aes_ecb_encrypt};
|
use super::super::provider::SuppliedKey;
|
||||||
use super::super::keydb::{DiscEntry, KeyDb};
|
use super::super::types::DiscEntry;
|
||||||
use super::*;
|
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
|
/// 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
|
/// whose declared `num_unit_keys` exceeds the keys actually present in the
|
||||||
/// buffer, instead of silently returning a short list. A truncated list
|
/// 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]);
|
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]
|
#[test]
|
||||||
fn test_decrypt_unit_key_from_vuk() {
|
fn test_decrypt_unit_key_from_vuk() {
|
||||||
// Test the full chain: VUK → decrypt encrypted unit key → unit key
|
// VUK → encrypted unit key → unit key roundtrip. The keydb-sourced
|
||||||
// Use a known disc from KEYDB that has both VUK and unit keys
|
// variant of this test (which scanned a real KEYDB for VUK + unit
|
||||||
let path = match keydb_path() {
|
// keys) moved to freemkv-keysources; this rebuilt version exercises
|
||||||
Some(p) => p,
|
// the same AES-G primitive (decrypt_unit_key ∘ aes_ecb_encrypt under a
|
||||||
None => return,
|
// 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
|
||||||
let db = KeyDb::load(&path).unwrap();
|
// half stays.
|
||||||
|
use super::super::decrypt::aes_ecb_encrypt;
|
||||||
// Find a disc with VUK and unit keys
|
let vuk = [0x5Au8; 16];
|
||||||
let entry = db
|
// A few representative "decrypted" unit keys.
|
||||||
.disc_entries
|
for expected_uk in [[0x11u8; 16], [0x22u8; 16], [0xCDu8; 16]] {
|
||||||
.values()
|
let encrypted = aes_ecb_encrypt(&vuk, &expected_uk);
|
||||||
.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);
|
|
||||||
let decrypted = decrypt_unit_key(&vuk, &encrypted);
|
let decrypted = decrypt_unit_key(&vuk, &encrypted);
|
||||||
assert_eq!(
|
assert_eq!(decrypted, expected_uk, "unit key roundtrip under VUK");
|
||||||
&decrypted, expected_uk,
|
|
||||||
"Unit key {} roundtrip failed for {}",
|
|
||||||
num, entry.title
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
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]
|
#[test]
|
||||||
@@ -1834,7 +1717,7 @@ mod tests {
|
|||||||
mkb.extend_from_slice(&[0xAB; 16]);
|
mkb.extend_from_slice(&[0xAB; 16]);
|
||||||
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
|
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
|
||||||
let records_len = mkb.len();
|
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);
|
assert_eq!(mkb_content_len(&mkb), records_len);
|
||||||
// No padding → returns the full length.
|
// No padding → returns the full length.
|
||||||
assert_eq!(mkb_content_len(&mkb[..records_len]), records_len);
|
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(&[0xAB; 16]);
|
||||||
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
|
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
|
||||||
let records_len = mkb.len();
|
let records_len = mkb.len();
|
||||||
mkb.extend(std::iter::repeat(0u8).take(1024));
|
mkb.extend(std::iter::repeat_n(0u8, 1024));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
trim_mkb(mkb).len(),
|
trim_mkb(mkb).len(),
|
||||||
records_len,
|
records_len,
|
||||||
@@ -2197,44 +2080,6 @@ mod tests {
|
|||||||
assert_eq!(mkb_find_mk_dv(&mkb), Some(expected));
|
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
|
/// 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,
|
/// 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.
|
/// 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 uk_ro = minimal_unit_key_ro();
|
||||||
let zero_vid = [0u8; 16];
|
let zero_vid = [0u8; 16];
|
||||||
|
|
||||||
// Populate keydb with a non-matching VID entry (path 2 would
|
// A provider carrying a dummy processing key but NO disc entry that
|
||||||
// miss anyway) plus dummy processing/device keys (paths 3/4
|
// matches this disc. `disc_entry: None` preserves the negative-miss
|
||||||
// would also miss, but the short-circuit means they're never
|
// the test asserts: with VID=0, paths 1/2/3 are skipped and the
|
||||||
// attempted).
|
// path-4/5 hash lookup must MISS (a SuppliedKey returns its
|
||||||
let mut keydb = KeyDb::empty();
|
// disc_entry unconditionally, so the planted entry would WRONGLY hit
|
||||||
keydb.disc_entries.insert(
|
// path 4 — None keeps the miss).
|
||||||
"0xDEADBEEF".to_string(),
|
let keydb = SuppliedKey {
|
||||||
DiscEntry {
|
device_keys: Vec::new(),
|
||||||
disc_hash: "0xDEADBEEF".to_string(),
|
processing_keys: vec![[0u8; 16]],
|
||||||
title: "fixture".to_string(),
|
media_keys: Vec::new(),
|
||||||
media_key: Some([0x11u8; 16]),
|
disc_entry: None,
|
||||||
disc_id: Some([0x22u8; 16]),
|
};
|
||||||
vuk: None,
|
|
||||||
unit_keys: Vec::new(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
keydb.processing_keys.push([0u8; 16]);
|
|
||||||
|
|
||||||
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
|
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
|
||||||
let ctx = ResolveContext {
|
let ctx = ResolveContext {
|
||||||
@@ -2307,19 +2147,20 @@ mod tests {
|
|||||||
// keyed lowercase too, so we have to lowercase here.
|
// keyed lowercase too, so we have to lowercase here.
|
||||||
let hash_hex = disc_hash_hex(&hash).to_lowercase();
|
let hash_hex = disc_hash_hex(&hash).to_lowercase();
|
||||||
|
|
||||||
let mut keydb = KeyDb::empty();
|
|
||||||
let known_vuk = [0xABu8; 16];
|
let known_vuk = [0xABu8; 16];
|
||||||
keydb.disc_entries.insert(
|
let keydb = SuppliedKey {
|
||||||
hash_hex.clone(),
|
device_keys: Vec::new(),
|
||||||
DiscEntry {
|
processing_keys: Vec::new(),
|
||||||
|
media_keys: Vec::new(),
|
||||||
|
disc_entry: Some(DiscEntry {
|
||||||
disc_hash: hash_hex,
|
disc_hash: hash_hex,
|
||||||
title: "fixture".to_string(),
|
title: "fixture".to_string(),
|
||||||
media_key: None,
|
media_key: None,
|
||||||
disc_id: None,
|
disc_id: None,
|
||||||
vuk: Some(known_vuk),
|
vuk: Some(known_vuk),
|
||||||
unit_keys: Vec::new(),
|
unit_keys: Vec::new(),
|
||||||
},
|
}),
|
||||||
);
|
};
|
||||||
|
|
||||||
let vid = [0u8; 16];
|
let vid = [0u8; 16];
|
||||||
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
|
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
|
||||||
@@ -2349,18 +2190,19 @@ mod tests {
|
|||||||
// `minimal_unit_key_ro` declares CPS unit 1; supply a matching
|
// `minimal_unit_key_ro` declares CPS unit 1; supply a matching
|
||||||
// pre-decrypted unit key in the KEYDB entry.
|
// pre-decrypted unit key in the KEYDB entry.
|
||||||
let known_uk = [0xCDu8; 16];
|
let known_uk = [0xCDu8; 16];
|
||||||
let mut keydb = KeyDb::empty();
|
let keydb = SuppliedKey {
|
||||||
keydb.disc_entries.insert(
|
device_keys: Vec::new(),
|
||||||
hash_hex.clone(),
|
processing_keys: Vec::new(),
|
||||||
DiscEntry {
|
media_keys: Vec::new(),
|
||||||
|
disc_entry: Some(DiscEntry {
|
||||||
disc_hash: hash_hex,
|
disc_hash: hash_hex,
|
||||||
title: "fixture".to_string(),
|
title: "fixture".to_string(),
|
||||||
media_key: None,
|
media_key: None,
|
||||||
disc_id: None,
|
disc_id: None,
|
||||||
vuk: None,
|
vuk: None,
|
||||||
unit_keys: vec![(1, known_uk)],
|
unit_keys: vec![(1, known_uk)],
|
||||||
},
|
}),
|
||||||
);
|
};
|
||||||
|
|
||||||
let vid = [0u8; 16];
|
let vid = [0u8; 16];
|
||||||
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
|
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
|
||||||
@@ -2377,7 +2219,6 @@ mod tests {
|
|||||||
assert_eq!(resolved.key_source, 5);
|
assert_eq!(resolved.key_source, 5);
|
||||||
assert_eq!(resolved.unit_keys, vec![(1, known_uk)]);
|
assert_eq!(resolved.unit_keys, vec![(1, known_uk)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_keys_path5_rejects_partial_unit_key_coverage() {
|
fn resolve_keys_path5_rejects_partial_unit_key_coverage() {
|
||||||
// If the disc declares a CPS unit that's not in the KEYDB
|
// 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 uk_ro = minimal_unit_key_ro();
|
||||||
let hash = disc_hash(&uk_ro);
|
let hash = disc_hash(&uk_ro);
|
||||||
let hash_hex = disc_hash_hex(&hash).to_lowercase();
|
let hash_hex = disc_hash_hex(&hash).to_lowercase();
|
||||||
|
|
||||||
// KEYDB has a key for CPS unit 99, but the disc declares unit 1.
|
// KEYDB has a key for CPS unit 99, but the disc declares unit 1.
|
||||||
let mut keydb = KeyDb::empty();
|
let keydb = SuppliedKey {
|
||||||
keydb.disc_entries.insert(
|
device_keys: Vec::new(),
|
||||||
hash_hex.clone(),
|
processing_keys: Vec::new(),
|
||||||
DiscEntry {
|
media_keys: Vec::new(),
|
||||||
|
disc_entry: Some(DiscEntry {
|
||||||
disc_hash: hash_hex,
|
disc_hash: hash_hex,
|
||||||
title: "fixture".to_string(),
|
title: "fixture".to_string(),
|
||||||
media_key: None,
|
media_key: None,
|
||||||
disc_id: None,
|
disc_id: None,
|
||||||
vuk: None,
|
vuk: None,
|
||||||
unit_keys: vec![(99, [0xEEu8; 16])],
|
unit_keys: vec![(99, [0xEEu8; 16])],
|
||||||
},
|
}),
|
||||||
);
|
};
|
||||||
|
|
||||||
let vid = [0u8; 16];
|
let vid = [0u8; 16];
|
||||||
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
|
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
|
||||||
@@ -2417,17 +2258,14 @@ mod tests {
|
|||||||
"partial CPS-unit coverage must not produce a half-decrypted result"
|
"partial CPS-unit coverage must not produce a half-decrypted result"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_keys_path2_5_mk_pool_brute_resolves_unkeyed_disc() {
|
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
|
// 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
|
// 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.
|
// 2.5 must km_verifies that MK against the MKB and resolve.
|
||||||
use super::super::decrypt::aes_ecb_encrypt as enc;
|
use super::super::decrypt::aes_ecb_encrypt as enc;
|
||||||
|
|
||||||
let km = [0x11u8; 16];
|
let km = [0x11u8; 16];
|
||||||
let vid = [0x22u8; 16];
|
let vid = [0x22u8; 16];
|
||||||
|
|
||||||
// MKB: 0x10 type/version + 0x86 verify record whose mk_dv decrypts under
|
// MKB: 0x10 type/version + 0x86 verify record whose mk_dv decrypts under
|
||||||
// km to the AACS verify magic, so km_verifies(mkb, km) == true.
|
// km to the AACS verify magic, so km_verifies(mkb, km) == true.
|
||||||
let mut vd = [0u8; 16];
|
let mut vd = [0u8; 16];
|
||||||
@@ -2439,26 +2277,21 @@ mod tests {
|
|||||||
probe::km_verifies(&mkb, &km),
|
probe::km_verifies(&mkb, &km),
|
||||||
"fixture: km must verify the MKB"
|
"fixture: km must verify the MKB"
|
||||||
);
|
);
|
||||||
|
|
||||||
// This disc's inf (its hash will NOT be in keydb).
|
// This disc's inf (its hash will NOT be in keydb).
|
||||||
let uk_ro = minimal_unit_key_ro();
|
let uk_ro = minimal_unit_key_ro();
|
||||||
|
// The sibling's MK is lifted directly into the MK pool: a KeyDb
|
||||||
// keydb: a SIBLING disc carries our km, keyed by the sibling's own
|
// aggregated per-disc media_keys into media_keys(), but SuppliedKey
|
||||||
// hash + VID (neither matches THIS disc) — so only the MK-pool brute
|
// does NOT harvest its disc_entry's media_key — it has an explicit
|
||||||
// (km_verifies) can find it.
|
// media_keys field. `disc_entry: None` preserves the miss on this
|
||||||
let mut keydb = KeyDb::empty();
|
// disc's own hash/VID (the sibling matches neither), so ONLY the
|
||||||
keydb.disc_entries.insert(
|
// MK-pool brute (km_verifies) can resolve it — exactly the path under
|
||||||
"0xsibling".to_string(),
|
// test.
|
||||||
DiscEntry {
|
let keydb = SuppliedKey {
|
||||||
disc_hash: "0xsibling".to_string(),
|
device_keys: Vec::new(),
|
||||||
title: "sibling".to_string(),
|
processing_keys: Vec::new(),
|
||||||
media_key: Some(km),
|
media_keys: vec![km],
|
||||||
disc_id: Some([0x99u8; 16]),
|
disc_entry: None,
|
||||||
vuk: None,
|
};
|
||||||
unit_keys: Vec::new(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
|
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
|
||||||
let ctx = ResolveContext {
|
let ctx = ResolveContext {
|
||||||
unit_key_ro: &uk_ro,
|
unit_key_ro: &uk_ro,
|
||||||
@@ -2479,7 +2312,6 @@ mod tests {
|
|||||||
"VUK must derive from the verified Km + this disc's VID"
|
"VUK must derive from the verified Km + this disc's VID"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_content_cert_parse() {
|
fn test_content_cert_parse() {
|
||||||
// AACS 1.0 cert
|
// AACS 1.0 cert
|
||||||
@@ -2489,7 +2321,6 @@ mod tests {
|
|||||||
let cc = parse_content_cert(&data).unwrap();
|
let cc = parse_content_cert(&data).unwrap();
|
||||||
assert_eq!(cc.version, AacsVersion::V10);
|
assert_eq!(cc.version, AacsVersion::V10);
|
||||||
assert!(!cc.bus_encryption);
|
assert!(!cc.bus_encryption);
|
||||||
|
|
||||||
// AACS 2.0 with bus encryption
|
// AACS 2.0 with bus encryption
|
||||||
data[0] = 0x01; // AACS 2.0
|
data[0] = 0x01; // AACS 2.0
|
||||||
data[1] = 0x01; // bus encryption enabled
|
data[1] = 0x01; // bus encryption enabled
|
||||||
@@ -2497,13 +2328,10 @@ mod tests {
|
|||||||
assert_eq!(cc.version, AacsVersion::V20);
|
assert_eq!(cc.version, AacsVersion::V20);
|
||||||
assert!(cc.bus_encryption);
|
assert!(cc.bus_encryption);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
// Hardening additions
|
// Hardening additions
|
||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ── VUK derivation: spec relation VUK = AES-D(MK, VID) XOR VID ─────────
|
// ── VUK derivation: spec relation VUK = AES-D(MK, VID) XOR VID ─────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn derive_vuk_matches_spec_relation_explicitly() {
|
fn derive_vuk_matches_spec_relation_explicitly() {
|
||||||
// Independently compute AES-ECB-D(mk, vid) XOR vid and confirm
|
// Independently compute AES-ECB-D(mk, vid) XOR vid and confirm
|
||||||
@@ -2524,7 +2352,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
assert_eq!(derive_vuk(&mk, &vid), expected);
|
assert_eq!(derive_vuk(&mk, &vid), expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decrypt_unit_key_is_plain_aes_ecb_decrypt_under_vuk() {
|
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);
|
// 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);
|
let enc_uk = enc(&vuk, &uk);
|
||||||
assert_eq!(decrypt_unit_key(&vuk, &enc_uk), uk);
|
assert_eq!(decrypt_unit_key(&vuk, &enc_uk), uk);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Unit_Key_RO stride: 48 (V10) vs 64 (V20/V21) ──────────────────────
|
// ── Unit_Key_RO stride: 48 (V10) vs 64 (V20/V21) ──────────────────────
|
||||||
|
|
||||||
/// Build a Unit_Key_RO.inf carrying `num_uk` keys at a given stride,
|
/// 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.
|
/// where key `i` is filled with byte `0x10 + i`. uk_pos = 0x60.
|
||||||
fn build_unit_key_ro(num_uk: usize, stride: usize) -> Vec<u8> {
|
fn build_unit_key_ro(num_uk: usize, stride: usize) -> Vec<u8> {
|
||||||
@@ -2561,7 +2386,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
data
|
data
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stride_v10_is_48_v20_is_64_and_picks_distinct_keys() {
|
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).
|
// 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.len(), 2);
|
||||||
assert_eq!(v20.encrypted_keys[0].1, [0x10; 16]);
|
assert_eq!(v20.encrypted_keys[0].1, [0x10; 16]);
|
||||||
assert_eq!(v20.encrypted_keys[1].1, [0x11; 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
|
// 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.
|
// is read at +48 (not +64) so it is NOT the planted 0x11 block.
|
||||||
let v10 = parse_unit_key_ro(&data, AacsVersion::V10).unwrap();
|
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"
|
"48-byte stride must read different bytes than 64-byte stride"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn v21_uses_same_64_byte_stride_as_v20() {
|
fn v21_uses_same_64_byte_stride_as_v20() {
|
||||||
// V21 shares V20's 64-byte stride (the enum match groups V20|V21).
|
// 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!(v20.encrypted_keys, v21.encrypted_keys);
|
||||||
assert_eq!(v21.version, AacsVersion::V21);
|
assert_eq!(v21.version, AacsVersion::V21);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── parse_unit_key_ro: early returns / boundaries ──────────────────────
|
// ── parse_unit_key_ro: early returns / boundaries ──────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_unit_key_ro_rejects_too_short_header() {
|
fn parse_unit_key_ro_rejects_too_short_header() {
|
||||||
// < 20 bytes → None (header fields at 16-18 would index OOB).
|
// < 20 bytes → None (header fields at 16-18 would index OOB).
|
||||||
assert!(parse_unit_key_ro(&[0u8; 19], AacsVersion::V10).is_none());
|
assert!(parse_unit_key_ro(&[0u8; 19], AacsVersion::V10).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_unit_key_ro_rejects_uk_pos_past_end() {
|
fn parse_unit_key_ro_rejects_uk_pos_past_end() {
|
||||||
// uk_pos points past the buffer → the `uk_pos + 2 > len` guard
|
// 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
|
data[0..4].copy_from_slice(&1000u32.to_be_bytes()); // uk_pos = 1000
|
||||||
assert!(parse_unit_key_ro(&data, AacsVersion::V10).is_none());
|
assert!(parse_unit_key_ro(&data, AacsVersion::V10).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_unit_key_ro_zero_keys_returns_empty_set() {
|
fn parse_unit_key_ro_zero_keys_returns_empty_set() {
|
||||||
// num_unit_keys == 0 → a valid file with no encrypted keys (early
|
// 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!(parsed.encrypted_keys.is_empty());
|
||||||
assert_eq!(parsed.app_type, 1);
|
assert_eq!(parsed.app_type, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_unit_key_ro_truncated_key_region_returns_none() {
|
fn parse_unit_key_ro_truncated_key_region_returns_none() {
|
||||||
// keys_start + 16 > len → None (the first key can't fit).
|
// 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
|
data[uk_pos + 1] = 1; // 1 key declared
|
||||||
assert!(parse_unit_key_ro(&data, AacsVersion::V10).is_none());
|
assert!(parse_unit_key_ro(&data, AacsVersion::V10).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_unit_key_ro_rejects_when_keys_run_off_end() {
|
fn parse_unit_key_ro_rejects_when_keys_run_off_end() {
|
||||||
// Finding #5: 3 keys declared but the buffer holds only 2 strides plus
|
// 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"
|
"a buffer declaring more keys than it contains must be rejected"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_unit_key_ro_app_type_and_skb_flag() {
|
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].
|
// 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();
|
let p2 = parse_unit_key_ro(&data, AacsVersion::V10).unwrap();
|
||||||
assert!(!p2.use_skb_mkb);
|
assert!(!p2.use_skb_mkb);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_unit_key_ro_cps_unit_numbers_are_1_based() {
|
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.
|
// The disc's CPS unit numbers are emitted as (i+1) — keys.rs:162.
|
||||||
@@ -2684,7 +2498,6 @@ mod tests {
|
|||||||
vec![1, 2, 3]
|
vec![1, 2, 3]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_unit_key_ro_title_cps_mapping_first_play_top_menu_then_titles() {
|
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
|
// [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();
|
let p = parse_unit_key_ro(&data, AacsVersion::V20).unwrap();
|
||||||
assert_eq!(p.title_cps_unit, vec![7, 9, 3, 4]);
|
assert_eq!(p.title_cps_unit, vec![7, 9, 3, 4]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── MKB record framing: rec_len is BE24 incl. 4-byte header ────────────
|
// ── MKB record framing: rec_len is BE24 incl. 4-byte header ────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mkb_version_uses_be24_length_and_reads_offset_8() {
|
fn mkb_version_uses_be24_length_and_reads_offset_8() {
|
||||||
// Type 0x10, BE24 length 0x0C (12). Body starts at pos+4: Type field
|
// Type 0x10, BE24 length 0x0C (12). Body starts at pos+4: Type field
|
||||||
@@ -2712,7 +2523,6 @@ mod tests {
|
|||||||
// version = 0x01020304.
|
// version = 0x01020304.
|
||||||
assert_eq!(mkb_version(&mkb), Some(0x0102_0304));
|
assert_eq!(mkb_version(&mkb), Some(0x0102_0304));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mkb_type_category_c_20_is_uhd() {
|
fn mkb_type_category_c_20_is_uhd() {
|
||||||
// Type 0x10 record, BE24 length 0x0C (12). MKBType field (body
|
// 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::from_raw(MKB_21_CATEGORY_C), MkbType::CategoryC21);
|
||||||
assert_eq!(MkbType::CategoryC21.generation(), AacsVersion::V21);
|
assert_eq!(MkbType::CategoryC21.generation(), AacsVersion::V21);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mkb_type_prerecorded_is_bluray_v10() {
|
fn mkb_type_prerecorded_is_bluray_v10() {
|
||||||
// Type 0x10 record with MKB_TYPE_4_PRERECORDED (0x00041003) — a
|
// Type 0x10 record with MKB_TYPE_4_PRERECORDED (0x00041003) — a
|
||||||
@@ -2742,7 +2551,6 @@ mod tests {
|
|||||||
assert!(!MkbType::Prerecorded.is_uhd());
|
assert!(!MkbType::Prerecorded.is_uhd());
|
||||||
assert_eq!(MkbType::Prerecorded.generation(), AacsVersion::V10);
|
assert_eq!(MkbType::Prerecorded.generation(), AacsVersion::V10);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mkb_type_none_when_no_0x10_record() {
|
fn mkb_type_none_when_no_0x10_record() {
|
||||||
// A buffer whose only record is a 0x81 (verify-media-key) record and
|
// 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_type(&mkb), None);
|
||||||
assert_eq!(mkb_is_uhd(&mkb), None);
|
assert_eq!(mkb_is_uhd(&mkb), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mkb_find_mk_dv_skips_short_verify_record() {
|
fn mkb_find_mk_dv_skips_short_verify_record() {
|
||||||
// A 0x81 record with rec_len < 20 carries no full mk_dv; the finder
|
// 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]);
|
mkb.extend_from_slice(&[0x00; 4]);
|
||||||
assert_eq!(mkb_find_mk_dv(&mkb), Some(expected));
|
assert_eq!(mkb_find_mk_dv(&mkb), Some(expected));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mkb_find_mk_dv_stops_on_overrun_length() {
|
fn mkb_find_mk_dv_stops_on_overrun_length() {
|
||||||
// A rec_len that runs past the buffer ends the walk (break), so no
|
// 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
|
let mkb = [0x81, 0x00, 0xFF, 0xFF, 0x00, 0x00]; // claims 65535 bytes
|
||||||
assert_eq!(mkb_find_mk_dv(&mkb), None);
|
assert_eq!(mkb_find_mk_dv(&mkb), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mkb_find_mk_dv_stops_on_zero_length_record() {
|
fn mkb_find_mk_dv_stops_on_zero_length_record() {
|
||||||
// rec_len < 4 (here 0) breaks the walk — guards against an infinite
|
// 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];
|
let mkb = [0x81, 0x00, 0x00, 0x00, 0x99];
|
||||||
assert_eq!(mkb_find_mk_dv(&mkb), None);
|
assert_eq!(mkb_find_mk_dv(&mkb), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── mkb_content_len / trim_mkb ─────────────────────────────────────────
|
// ── mkb_content_len / trim_mkb ─────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mkb_content_len_stops_at_zero_type_padding_byte() {
|
fn mkb_content_len_stops_at_zero_type_padding_byte() {
|
||||||
// A type==0 byte marks the start of padding (records done). Two real
|
// 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)
|
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x08]); // padding starts (type 0)
|
||||||
assert_eq!(mkb_content_len(&mkb), content);
|
assert_eq!(mkb_content_len(&mkb), content);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mkb_content_len_returns_full_len_when_no_padding() {
|
fn mkb_content_len_returns_full_len_when_no_padding() {
|
||||||
let mut mkb = vec![0x10, 0x00, 0x00, 0x08, 0, 0, 0, 1];
|
let mut mkb = vec![0x10, 0x00, 0x00, 0x08, 0, 0, 0, 1];
|
||||||
mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 9, 9, 9, 9]);
|
mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 9, 9, 9, 9]);
|
||||||
assert_eq!(mkb_content_len(&mkb), mkb.len());
|
assert_eq!(mkb_content_len(&mkb), mkb.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn trim_mkb_leaves_exactly_sized_buffer_untouched() {
|
fn trim_mkb_leaves_exactly_sized_buffer_untouched() {
|
||||||
// n == mkb.len() (no padding) → the `n < mkb.len()` guard is false,
|
// 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];
|
let mkb = vec![0x10, 0x00, 0x00, 0x08, 0, 0, 0, 1];
|
||||||
assert_eq!(trim_mkb(mkb.clone()), mkb);
|
assert_eq!(trim_mkb(mkb.clone()), mkb);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Content Certificate parsing ────────────────────────────────────────
|
// ── Content Certificate parsing ────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_content_cert_rejects_short_buffer() {
|
fn parse_content_cert_rejects_short_buffer() {
|
||||||
// < 8 bytes → None (cc_id slice [2..8] would index OOB).
|
// < 8 bytes → None (cc_id slice [2..8] would index OOB).
|
||||||
assert!(parse_content_cert(&[0x00; 7]).is_none());
|
assert!(parse_content_cert(&[0x00; 7]).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_content_cert_extracts_cc_id_and_nonzero_type_is_v20() {
|
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.
|
// [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_eq!(cc.cc_id, [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
|
||||||
assert!(!cc.bus_encryption);
|
assert!(!cc.bus_encryption);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_content_cert_bus_encryption_only_reads_bit0() {
|
fn parse_content_cert_bus_encryption_only_reads_bit0() {
|
||||||
// bus_encryption = (data[1] & 0x01) != 0. A high bit set (0x02) with
|
// bus_encryption = (data[1] & 0x01) != 0. A high bit set (0x02) with
|
||||||
@@ -2841,9 +2638,7 @@ mod tests {
|
|||||||
data[1] = 0x03; // bit 0 set
|
data[1] = 0x03; // bit 0 set
|
||||||
assert!(parse_content_cert(&data).unwrap().bus_encryption);
|
assert!(parse_content_cert(&data).unwrap().bus_encryption);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── resolve: version → stride wiring + V21 upgrade on variant MKB ──────
|
// ── resolve: version → stride wiring + V21 upgrade on variant MKB ──────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_keys_v2_upgrades_to_v21_on_variant_mkb() {
|
fn resolve_keys_v2_upgrades_to_v21_on_variant_mkb() {
|
||||||
// resolve_keys_v2 parses with the V20 64-byte stride but upgrades the
|
// 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 uk_ro = build_unit_key_ro(1, 64);
|
||||||
let hash = disc_hash(&uk_ro);
|
let hash = disc_hash(&uk_ro);
|
||||||
let hash_hex = disc_hash_hex(&hash).to_lowercase();
|
let hash_hex = disc_hash_hex(&hash).to_lowercase();
|
||||||
|
let keydb = SuppliedKey {
|
||||||
let mut keydb = KeyDb::empty();
|
device_keys: Vec::new(),
|
||||||
keydb.disc_entries.insert(
|
processing_keys: Vec::new(),
|
||||||
hash_hex.clone(),
|
media_keys: Vec::new(),
|
||||||
DiscEntry {
|
disc_entry: Some(DiscEntry {
|
||||||
disc_hash: hash_hex,
|
disc_hash: hash_hex,
|
||||||
title: "fixture".to_string(),
|
title: "fixture".to_string(),
|
||||||
media_key: None,
|
media_key: None,
|
||||||
disc_id: None,
|
disc_id: None,
|
||||||
vuk: Some([0x5Au8; 16]),
|
vuk: Some([0x5Au8; 16]),
|
||||||
unit_keys: Vec::new(),
|
unit_keys: Vec::new(),
|
||||||
},
|
}),
|
||||||
);
|
};
|
||||||
|
|
||||||
// MKB with a 0x83 variant record makes is_variant_mkb true.
|
// MKB with a 0x83 variant record makes is_variant_mkb true.
|
||||||
let mut mkb = vec![0x10, 0x00, 0x00, 0x08, 0, 0, 0, 1];
|
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 uk_ro = build_unit_key_ro(1, 64);
|
||||||
let hash = disc_hash(&uk_ro);
|
let hash = disc_hash(&uk_ro);
|
||||||
let hash_hex = disc_hash_hex(&hash).to_lowercase();
|
let hash_hex = disc_hash_hex(&hash).to_lowercase();
|
||||||
let mut keydb = KeyDb::empty();
|
let keydb = SuppliedKey {
|
||||||
keydb.disc_entries.insert(
|
device_keys: Vec::new(),
|
||||||
hash_hex.clone(),
|
processing_keys: Vec::new(),
|
||||||
DiscEntry {
|
media_keys: Vec::new(),
|
||||||
|
disc_entry: Some(DiscEntry {
|
||||||
disc_hash: hash_hex,
|
disc_hash: hash_hex,
|
||||||
title: "f".to_string(),
|
title: "f".to_string(),
|
||||||
media_key: None,
|
media_key: None,
|
||||||
disc_id: None,
|
disc_id: None,
|
||||||
vuk: Some([0x5Au8; 16]),
|
vuk: Some([0x5Au8; 16]),
|
||||||
unit_keys: Vec::new(),
|
unit_keys: Vec::new(),
|
||||||
},
|
}),
|
||||||
);
|
};
|
||||||
let mkb = vec![0x10, 0x00, 0x00, 0x08, 0, 0, 0, 1];
|
let mkb = vec![0x10, 0x00, 0x00, 0x08, 0, 0, 0, 1];
|
||||||
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
|
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
|
||||||
let ctx = ResolveContext {
|
let ctx = ResolveContext {
|
||||||
@@ -2923,18 +2719,19 @@ mod tests {
|
|||||||
let uk_ro = build_unit_key_ro(1, 48);
|
let uk_ro = build_unit_key_ro(1, 48);
|
||||||
let hash = disc_hash(&uk_ro);
|
let hash = disc_hash(&uk_ro);
|
||||||
let hash_hex = disc_hash_hex(&hash).to_lowercase();
|
let hash_hex = disc_hash_hex(&hash).to_lowercase();
|
||||||
let mut keydb = KeyDb::empty();
|
let keydb = SuppliedKey {
|
||||||
keydb.disc_entries.insert(
|
device_keys: Vec::new(),
|
||||||
hash_hex.clone(),
|
processing_keys: Vec::new(),
|
||||||
DiscEntry {
|
media_keys: Vec::new(),
|
||||||
|
disc_entry: Some(DiscEntry {
|
||||||
disc_hash: hash_hex,
|
disc_hash: hash_hex,
|
||||||
title: "f".to_string(),
|
title: "f".to_string(),
|
||||||
media_key: None,
|
media_key: None,
|
||||||
disc_id: None,
|
disc_id: None,
|
||||||
vuk: Some([1u8; 16]),
|
vuk: Some([1u8; 16]),
|
||||||
unit_keys: Vec::new(),
|
unit_keys: Vec::new(),
|
||||||
},
|
}),
|
||||||
);
|
};
|
||||||
// Content cert: AACS2 + bus encryption enabled.
|
// Content cert: AACS2 + bus encryption enabled.
|
||||||
let mut cc = vec![0u8; 8];
|
let mut cc = vec![0u8; 8];
|
||||||
cc[0] = 0x01;
|
cc[0] = 0x01;
|
||||||
@@ -2960,18 +2757,19 @@ mod tests {
|
|||||||
let hash = disc_hash(&data);
|
let hash = disc_hash(&data);
|
||||||
let hash_hex = disc_hash_hex(&hash).to_lowercase();
|
let hash_hex = disc_hash_hex(&hash).to_lowercase();
|
||||||
let vuk = [0x77u8; 16];
|
let vuk = [0x77u8; 16];
|
||||||
let mut keydb = KeyDb::empty();
|
let keydb = SuppliedKey {
|
||||||
keydb.disc_entries.insert(
|
device_keys: Vec::new(),
|
||||||
hash_hex.clone(),
|
processing_keys: Vec::new(),
|
||||||
DiscEntry {
|
media_keys: Vec::new(),
|
||||||
|
disc_entry: Some(DiscEntry {
|
||||||
disc_hash: hash_hex,
|
disc_hash: hash_hex,
|
||||||
title: "f".to_string(),
|
title: "f".to_string(),
|
||||||
media_key: None,
|
media_key: None,
|
||||||
disc_id: None,
|
disc_id: None,
|
||||||
vuk: Some(vuk),
|
vuk: Some(vuk),
|
||||||
unit_keys: Vec::new(),
|
unit_keys: Vec::new(),
|
||||||
},
|
}),
|
||||||
);
|
};
|
||||||
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
|
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
|
||||||
let ctx = ResolveContext {
|
let ctx = ResolveContext {
|
||||||
unit_key_ro: &data,
|
unit_key_ro: &data,
|
||||||
@@ -2997,18 +2795,19 @@ mod tests {
|
|||||||
let uk_ro = minimal_unit_key_ro();
|
let uk_ro = minimal_unit_key_ro();
|
||||||
let vid = [0x42u8; 16];
|
let vid = [0x42u8; 16];
|
||||||
let mk = [0x24u8; 16];
|
let mk = [0x24u8; 16];
|
||||||
let mut keydb = KeyDb::empty();
|
let keydb = SuppliedKey {
|
||||||
keydb.disc_entries.insert(
|
device_keys: Vec::new(),
|
||||||
"0xnotthishash".to_string(),
|
processing_keys: Vec::new(),
|
||||||
DiscEntry {
|
media_keys: Vec::new(),
|
||||||
|
disc_entry: Some(DiscEntry {
|
||||||
disc_hash: "0xnotthishash".to_string(),
|
disc_hash: "0xnotthishash".to_string(),
|
||||||
title: "sibling".to_string(),
|
title: "sibling".to_string(),
|
||||||
media_key: Some(mk),
|
media_key: Some(mk),
|
||||||
disc_id: Some(vid),
|
disc_id: Some(vid),
|
||||||
vuk: None,
|
vuk: None,
|
||||||
unit_keys: Vec::new(),
|
unit_keys: Vec::new(),
|
||||||
},
|
}),
|
||||||
);
|
};
|
||||||
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
|
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
|
||||||
let ctx = ResolveContext {
|
let ctx = ResolveContext {
|
||||||
unit_key_ro: &uk_ro,
|
unit_key_ro: &uk_ro,
|
||||||
@@ -3282,18 +3081,19 @@ mod tests {
|
|||||||
let uk_ro = build_unit_key_ro(1, 64);
|
let uk_ro = build_unit_key_ro(1, 64);
|
||||||
let hash_hex = disc_hash_hex(&disc_hash(&uk_ro)).to_lowercase();
|
let hash_hex = disc_hash_hex(&disc_hash(&uk_ro)).to_lowercase();
|
||||||
let vuk = [0x77u8; 16];
|
let vuk = [0x77u8; 16];
|
||||||
let mut keydb = KeyDb::empty();
|
let keydb = SuppliedKey {
|
||||||
keydb.disc_entries.insert(
|
device_keys: Vec::new(),
|
||||||
hash_hex.clone(),
|
processing_keys: Vec::new(),
|
||||||
DiscEntry {
|
media_keys: Vec::new(),
|
||||||
|
disc_entry: Some(DiscEntry {
|
||||||
disc_hash: hash_hex,
|
disc_hash: hash_hex,
|
||||||
title: "f".to_string(),
|
title: "f".to_string(),
|
||||||
media_key: None,
|
media_key: None,
|
||||||
disc_id: None,
|
disc_id: None,
|
||||||
vuk: Some(vuk),
|
vuk: Some(vuk),
|
||||||
unit_keys: Vec::new(),
|
unit_keys: Vec::new(),
|
||||||
},
|
}),
|
||||||
);
|
};
|
||||||
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
|
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
|
||||||
let ctx = ResolveContext {
|
let ctx = ResolveContext {
|
||||||
unit_key_ro: &uk_ro,
|
unit_key_ro: &uk_ro,
|
||||||
|
|||||||
+9
-2
@@ -14,13 +14,20 @@
|
|||||||
//! The VUK decrypts title keys from AACS/Unit_Key_RO.inf on disc.
|
//! The VUK decrypts title keys from AACS/Unit_Key_RO.inf on disc.
|
||||||
//! Title keys decrypt m2ts stream content (AES-128-CBC).
|
//! Title keys decrypt m2ts stream content (AES-128-CBC).
|
||||||
|
|
||||||
|
pub mod boil;
|
||||||
pub mod decrypt;
|
pub mod decrypt;
|
||||||
pub mod handshake;
|
pub mod handshake;
|
||||||
pub mod keydb;
|
|
||||||
pub mod keys;
|
pub mod keys;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
|
pub mod trace;
|
||||||
|
pub mod types;
|
||||||
pub mod variants;
|
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.
|
// 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.
|
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
|
||||||
pub use decrypt::{
|
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,
|
decrypt_unit_full, decrypt_unit_try_keys, is_aacs_scrambled, is_unit_aligned, ts_packet_total,
|
||||||
ts_sync_count, unit_key_validates,
|
ts_sync_count, unit_key_validates,
|
||||||
};
|
};
|
||||||
pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb};
|
|
||||||
pub use keys::probe;
|
pub use keys::probe;
|
||||||
pub use keys::{
|
pub use keys::{
|
||||||
AacsVersion, ContentCert, MKB_20_CATEGORY_C, MKB_21_CATEGORY_C, MKB_TYPE_3_RECORDABLE,
|
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,
|
resolve_keys_v21, resolve_keys_with_reason, trim_mkb,
|
||||||
};
|
};
|
||||||
pub use provider::KeyProvider;
|
pub use provider::KeyProvider;
|
||||||
|
pub use types::{DeviceKey, DiscEntry, HostCert};
|
||||||
pub use variants::{
|
pub use variants::{
|
||||||
KEY_CORRECTION_DATA_PLACEHOLDER, MediaKeyVariantError, MkbRecord, ProcessingKeyMatch,
|
KEY_CORRECTION_DATA_PLACEHOLDER, MediaKeyVariantError, MkbRecord, ProcessingKeyMatch,
|
||||||
derive_media_key_variant, is_variant_mkb, variant_nonce, walk_mkb, walk_processing_key,
|
derive_media_key_variant, is_variant_mkb, variant_nonce, walk_mkb, walk_processing_key,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
//! [`lookup_disc_by_hash`]: KeyProvider::lookup_disc_by_hash
|
//! [`lookup_disc_by_hash`]: KeyProvider::lookup_disc_by_hash
|
||||||
//! [`lookup_disc_by_vid`]: KeyProvider::lookup_disc_by_vid
|
//! [`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.
|
/// Source of AACS key material.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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])>,
|
||||||
|
}
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
//! variant path.
|
//! variant path.
|
||||||
|
|
||||||
use super::decrypt::aes_ecb_decrypt;
|
use super::decrypt::aes_ecb_decrypt;
|
||||||
use super::keydb::DeviceKey;
|
use super::types::DeviceKey;
|
||||||
|
|
||||||
// ── Public constants ──────────────────────────────────────────────────────
|
// ── Public constants ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
+23
-17
@@ -6,6 +6,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Reference: https://github.com/lw/BluRay/wiki/CLPI
|
//! Reference: https://github.com/lw/BluRay/wiki/CLPI
|
||||||
|
|
||||||
|
use crate::consts::{BD_SOURCE_PACKET_BYTES, SECTOR_BYTES};
|
||||||
use crate::disc::Extent;
|
use crate::disc::Extent;
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
@@ -167,13 +168,15 @@ impl ClipInfo {
|
|||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
// SPN → byte offset: spn × 192
|
// SPN → byte offset → sector range. Note: the caller adds the file's
|
||||||
// Byte offset → sectors: offset / 2048
|
// starting LBA from UDF. The start sector FLOORS (the extent begins in
|
||||||
// Note: the caller needs to add the file's starting LBA from UDF
|
// whichever sector contains its first byte) and the end sector CEILS
|
||||||
let start_byte = start_spn as u64 * 192;
|
// (the extent must cover through the sector holding its last byte), so
|
||||||
let end_byte = end_spn as u64 * 192;
|
// a sub-sector-aligned range still spans every sector it touches.
|
||||||
let start_sector = (start_byte / 2048) as u32;
|
let start_byte = start_spn as u64 * BD_SOURCE_PACKET_BYTES as u64;
|
||||||
let end_sector = end_byte.div_ceil(2048) as u32;
|
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 {
|
vec![Extent {
|
||||||
start_lba: start_sector, // relative to m2ts file start
|
start_lba: start_sector, // relative to m2ts file start
|
||||||
@@ -1112,15 +1115,18 @@ mod tests {
|
|||||||
let data = build_clpi(1000, Some(&cpi));
|
let data = build_clpi(1000, Some(&cpi));
|
||||||
let clip = parse(&data).expect("should parse");
|
let clip = parse(&data).expect("should parse");
|
||||||
|
|
||||||
let p0 = (0u64 << 19) + (0u64 << 8); // PTS of first EP
|
let p0 = 0u64; // PTS of first EP
|
||||||
let p1 = (100u64 << 19) + (0u64 << 8); // PTS of second EP
|
let p1 = 100u64 << 19; // PTS of second EP
|
||||||
let extents = clip.get_extents(p0, p1);
|
let extents = clip.get_extents(p0, p1);
|
||||||
assert_eq!(extents.len(), 1);
|
assert_eq!(extents.len(), 1);
|
||||||
// start_spn = 0, end_spn = big_spn. SPN→byte ×192, byte→sector /2048.
|
// Mirror production: SPN→byte ×packet, byte→sector with start FLOORed
|
||||||
let start_byte = 0u64 * 192;
|
// and end CEILed (same constants as get_extents).
|
||||||
let end_byte = big_spn as u64 * 192;
|
let start_spn: u64 = 0;
|
||||||
let start_sector = (start_byte / 2048) as u32;
|
let end_spn = big_spn as u64;
|
||||||
let end_sector = end_byte.div_ceil(2048) as u32;
|
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].start_lba, start_sector);
|
||||||
assert_eq!(extents[0].sector_count, end_sector - start_sector);
|
assert_eq!(extents[0].sector_count, end_sector - start_sector);
|
||||||
// Concretely: 0x20000 × 192 / 2048 = 12288 sectors.
|
// Concretely: 0x20000 × 192 / 2048 = 12288 sectors.
|
||||||
@@ -1144,7 +1150,7 @@ mod tests {
|
|||||||
let cpi = build_cpi(0x1011, &[(0, 50, 0x1000)], &[(0, 0)]);
|
let cpi = build_cpi(0x1011, &[(0, 50, 0x1000)], &[(0, 0)]);
|
||||||
let data = build_clpi(1000, Some(&cpi));
|
let data = build_clpi(1000, Some(&cpi));
|
||||||
let clip = parse(&data).expect("should parse");
|
let clip = parse(&data).expect("should parse");
|
||||||
let p = (50u64 << 19) + (0u64 << 8);
|
let p = 50u64 << 19;
|
||||||
// in == out → start_spn == end_spn → empty.
|
// in == out → start_spn == end_spn → empty.
|
||||||
assert!(clip.get_extents(p, p).is_empty());
|
assert!(clip.get_extents(p, p).is_empty());
|
||||||
}
|
}
|
||||||
@@ -1227,11 +1233,11 @@ mod tests {
|
|||||||
let stream_header_bits = &packed_bytes[6..16];
|
let stream_header_bits = &packed_bytes[6..16];
|
||||||
|
|
||||||
// stream EP data: fine_start points past the 1 coarse entry.
|
// 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();
|
let mut stream_ep = Vec::new();
|
||||||
stream_ep.extend_from_slice(&fine_start.to_be_bytes());
|
stream_ep.extend_from_slice(&fine_start.to_be_bytes());
|
||||||
// exactly ONE coarse entry (8 bytes), though header claims 255.
|
// 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());
|
stream_ep.extend_from_slice(&0x20000u32.to_be_bytes());
|
||||||
// one fine entry (4 bytes)
|
// one fine entry (4 bytes)
|
||||||
stream_ep.extend_from_slice(&(((5u32 & 0x7FF) << 17) | 100).to_be_bytes());
|
stream_ep.extend_from_slice(&(((5u32 & 0x7FF) << 17) | 100).to_be_bytes());
|
||||||
|
|||||||
@@ -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;
|
||||||
+19
-20
@@ -47,8 +47,7 @@
|
|||||||
use super::lfsr::descramble_sector;
|
use super::lfsr::descramble_sector;
|
||||||
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
|
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
|
||||||
|
|
||||||
/// Sector layout constants.
|
use crate::consts::SECTOR_BYTES;
|
||||||
const SECTOR_SIZE: usize = 2048;
|
|
||||||
const ENCRYPTED_START: usize = 0x80; // byte 128
|
const ENCRYPTED_START: usize = 0x80; // byte 128
|
||||||
const SEED_OFFSET: usize = 0x54; // sector seed at bytes 0x54-0x58
|
const SEED_OFFSET: usize = 0x54; // sector seed at bytes 0x54-0x58
|
||||||
const FLAG_BYTE: usize = 0x14;
|
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
|
/// Returns the recovered key only if it actually descrambles the sector back
|
||||||
/// to `plain` — guarding against the rare spurious LFSR-seed match.
|
/// to `plain` — guarding against the rare spurious LFSR-seed match.
|
||||||
pub fn recover_title_key(sector: &[u8], plain: &[u8]) -> Option<[u8; 5]> {
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
if sector[FLAG_BYTE] & 0x30 == 0 {
|
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();
|
let mut test = sector.to_vec();
|
||||||
test[FLAG_BYTE] |= 0x10; // ensure scramble flag set for the descrambler
|
test[FLAG_BYTE] |= 0x10; // ensure scramble flag set for the descrambler
|
||||||
descramble_sector(title, &mut test);
|
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]
|
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
|
/// cycles), the known plaintext at 0x80 is taken to be the periodic run
|
||||||
/// continuing forward, and [`recover_title_key_from_plain`] is applied.
|
/// continuing forward, and [`recover_title_key_from_plain`] is applied.
|
||||||
pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> {
|
pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> {
|
||||||
if sector.len() < SECTOR_SIZE {
|
if sector.len() < SECTOR_BYTES {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if sector[FLAG_BYTE] & 0x30 == 0 {
|
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
|
/// "did the cached key descramble correctly?" oracle (the predicted plaintext
|
||||||
/// must reappear at 0x80), and the cracker uses it as its known plaintext.
|
/// must reappear at 0x80), and the cracker uses it as its known plaintext.
|
||||||
pub(crate) fn attack_crib(sector: &[u8]) -> Option<[u8; 10]> {
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
let mut best_plen: usize = 0;
|
let mut best_plen: usize = 0;
|
||||||
@@ -352,7 +351,7 @@ mod tests {
|
|||||||
/// EXACTLY the cipher `descramble_sector` inverts. Returns
|
/// EXACTLY the cipher `descramble_sector` inverts. Returns
|
||||||
/// (scrambled_sector, full_plaintext_body).
|
/// (scrambled_sector, full_plaintext_body).
|
||||||
fn synth_sector(title_key: &[u8; 5], seed: &[u8; 5], plain: &[u8]) -> (Vec<u8>, Vec<u8>) {
|
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[0..4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]);
|
||||||
plaintext[FLAG_BYTE] = 0x10;
|
plaintext[FLAG_BYTE] = 0x10;
|
||||||
plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed);
|
plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed);
|
||||||
@@ -374,7 +373,7 @@ mod tests {
|
|||||||
seed: &[u8; 5],
|
seed: &[u8; 5],
|
||||||
period: usize,
|
period: usize,
|
||||||
) -> (Vec<u8>, Vec<u8>) {
|
) -> (Vec<u8>, Vec<u8>) {
|
||||||
let mut plaintext = vec![0u8; SECTOR_SIZE];
|
let mut plaintext = vec![0u8; SECTOR_BYTES];
|
||||||
plaintext[FLAG_BYTE] = 0x10;
|
plaintext[FLAG_BYTE] = 0x10;
|
||||||
|
|
||||||
// A clean periodic run occupying the tail of the cleartext header
|
// 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");
|
recover_title_key(§or, &PES).expect("recover_title_key returned None");
|
||||||
descramble_sector(&recovered, &mut sector);
|
descramble_sector(&recovered, &mut sector);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
§or[ENCRYPTED_START..SECTOR_SIZE],
|
§or[ENCRYPTED_START..SECTOR_BYTES],
|
||||||
&body[ENCRYPTED_START..SECTOR_SIZE],
|
&body[ENCRYPTED_START..SECTOR_BYTES],
|
||||||
"recovered key did not descramble the full body for \
|
"recovered key did not descramble the full body for \
|
||||||
title={title_key:02x?} seed={seed:02x?}"
|
title={title_key:02x?} seed={seed:02x?}"
|
||||||
);
|
);
|
||||||
@@ -483,8 +482,8 @@ mod tests {
|
|||||||
let mut test = sector.clone();
|
let mut test = sector.clone();
|
||||||
descramble_sector(&cracked, &mut test);
|
descramble_sector(&cracked, &mut test);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
&test[ENCRYPTED_START..SECTOR_SIZE],
|
&test[ENCRYPTED_START..SECTOR_BYTES],
|
||||||
&body[ENCRYPTED_START..SECTOR_SIZE],
|
&body[ENCRYPTED_START..SECTOR_BYTES],
|
||||||
"crack_title_key key did not round-trip the body (period {period})"
|
"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");
|
recover_title_key(§or, &PES).expect("recover_title_key returned None");
|
||||||
descramble_sector(&recovered, &mut sector);
|
descramble_sector(&recovered, &mut sector);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
§or[ENCRYPTED_START..SECTOR_SIZE],
|
§or[ENCRYPTED_START..SECTOR_BYTES],
|
||||||
&body[ENCRYPTED_START..SECTOR_SIZE],
|
&body[ENCRYPTED_START..SECTOR_BYTES],
|
||||||
"descramble with recovered key did not reproduce the body \
|
"descramble with recovered key did not reproduce the body \
|
||||||
for title={title_key:02x?} seed={seed:02x?}"
|
for title={title_key:02x?} seed={seed:02x?}"
|
||||||
);
|
);
|
||||||
@@ -527,21 +526,21 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recover_rejects_sector_one_byte_short() {
|
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;
|
sector[FLAG_BYTE] = 0x30;
|
||||||
assert!(recover_title_key(§or, &PES).is_none());
|
assert!(recover_title_key(§or, &PES).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recover_rejects_unscrambled_sector() {
|
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());
|
assert!(recover_title_key(§or, &PES).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recover_high_flag_bits_are_not_scramble() {
|
fn recover_high_flag_bits_are_not_scramble() {
|
||||||
for &flag in &[0x40u8, 0x80, 0xC0] {
|
for &flag in &[0x40u8, 0x80, 0xC0] {
|
||||||
let mut sector = vec![0x11u8; SECTOR_SIZE];
|
let mut sector = vec![0x11u8; SECTOR_BYTES];
|
||||||
sector[FLAG_BYTE] = flag;
|
sector[FLAG_BYTE] = flag;
|
||||||
assert!(
|
assert!(
|
||||||
recover_title_key(§or, &PES).is_none(),
|
recover_title_key(§or, &PES).is_none(),
|
||||||
@@ -553,7 +552,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn crack_high_flag_bits_are_not_scramble() {
|
fn crack_high_flag_bits_are_not_scramble() {
|
||||||
for &flag in &[0x40u8, 0x80, 0xC0] {
|
for &flag in &[0x40u8, 0x80, 0xC0] {
|
||||||
let mut sector = vec![0x11u8; SECTOR_SIZE];
|
let mut sector = vec![0x11u8; SECTOR_BYTES];
|
||||||
sector[FLAG_BYTE] = flag;
|
sector[FLAG_BYTE] = flag;
|
||||||
assert!(
|
assert!(
|
||||||
crack_title_key(§or).is_none(),
|
crack_title_key(§or).is_none(),
|
||||||
@@ -564,7 +563,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn crack_rejects_sector_one_byte_short() {
|
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 {
|
if sector.len() > FLAG_BYTE {
|
||||||
sector[FLAG_BYTE] = 0x30;
|
sector[FLAG_BYTE] = 0x30;
|
||||||
}
|
}
|
||||||
@@ -576,7 +575,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn crack_full_path_never_panics() {
|
fn crack_full_path_never_panics() {
|
||||||
for seed in 0u32..3 {
|
for seed in 0u32..3 {
|
||||||
let mut sector = vec![0u8; SECTOR_SIZE];
|
let mut sector = vec![0u8; SECTOR_BYTES];
|
||||||
sector[FLAG_BYTE] = 0x30;
|
sector[FLAG_BYTE] = 0x30;
|
||||||
let mut x = seed.wrapping_mul(2_654_435_761).wrapping_add(7);
|
let mut x = seed.wrapping_mul(2_654_435_761).wrapping_add(7);
|
||||||
for b in sector.iter_mut().skip(0x80) {
|
for b in sector.iter_mut().skip(0x80) {
|
||||||
|
|||||||
@@ -116,7 +116,9 @@ pub fn sample_rate_hz(s: SampleRate) -> u32 {
|
|||||||
match s {
|
match s {
|
||||||
SampleRate::S44_1 => 44100,
|
SampleRate::S44_1 => 44100,
|
||||||
SampleRate::S48 => 48000,
|
SampleRate::S48 => 48000,
|
||||||
|
SampleRate::S88_2 => 88200,
|
||||||
SampleRate::S96 => 96000,
|
SampleRate::S96 => 96000,
|
||||||
|
SampleRate::S176_4 => 176400,
|
||||||
SampleRate::S192 => 192000,
|
SampleRate::S192 => 192000,
|
||||||
SampleRate::S48_96 => 96000,
|
SampleRate::S48_96 => 96000,
|
||||||
SampleRate::S48_192 => 192000,
|
SampleRate::S48_192 => 192000,
|
||||||
|
|||||||
+2
-2
@@ -583,7 +583,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One STN stream entry: stream_entry (len(1)=3, type(1)=0x01, pid(2))
|
/// 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.
|
/// the mpls.rs test builders.
|
||||||
fn se_video(pid: u16, coding_type: u8) -> Vec<u8> {
|
fn se_video(pid: u16, coding_type: u8) -> Vec<u8> {
|
||||||
let mut out = vec![3u8, 0x01];
|
let mut out = vec![3u8, 0x01];
|
||||||
@@ -962,7 +962,7 @@ mod tests {
|
|||||||
|
|
||||||
/// A clip whose .clpi is missing contributes NO size and NO extent
|
/// A clip whose .clpi is missing contributes NO size and NO extent
|
||||||
/// (bluray.rs only fetches extents inside the `if let Ok(clpi_data)`
|
/// (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.
|
/// recorded with packet count 0. Never panics on the missing read.
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_playlist_missing_clpi_yields_no_extent_no_size() {
|
fn parse_playlist_missing_clpi_yields_no_extent_no_size() {
|
||||||
|
|||||||
+363
-167
@@ -13,6 +13,171 @@ pub(super) struct HandshakeResult {
|
|||||||
pub read_data_key: Option<[u8; 16]>,
|
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 {
|
impl Disc {
|
||||||
/// SCSI handshake — drives the VID-acquisition flow and returns
|
/// SCSI handshake — drives the VID-acquisition flow and returns
|
||||||
/// a structured `HandshakeResult` for downstream key resolution.
|
/// 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
|
/// 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
|
/// cert route gathers them. An empty result is the graceful no-cert signal
|
||||||
/// (the caller turns it into [`Error::AacsNoHostCert`]).
|
/// (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();
|
let mut host_certs: Vec<crate::aacs::HostCert> = Vec::new();
|
||||||
if let Some(c) = &opts.credentials {
|
if let Some(c) = &opts.credentials {
|
||||||
host_certs.extend(c.host_certs.iter().cloned());
|
host_certs.extend(c.host_certs.iter().cloned());
|
||||||
}
|
}
|
||||||
for src in &opts.key_sources {
|
for src in &opts.key_sources {
|
||||||
host_certs.extend(src.host_certs());
|
host_certs.extend(src.host_certs(mkb));
|
||||||
}
|
}
|
||||||
host_certs
|
host_certs
|
||||||
}
|
}
|
||||||
@@ -86,19 +254,17 @@ impl Disc {
|
|||||||
session: &mut crate::drive::Drive,
|
session: &mut crate::drive::Drive,
|
||||||
opts: &ScanOptions,
|
opts: &ScanOptions,
|
||||||
) -> (Option<HandshakeResult>, Option<Error>) {
|
) -> (Option<HandshakeResult>, Option<Error>) {
|
||||||
use crate::aacs;
|
// OEM VID shortcut: a matching firmware unlocker stashed the disc's
|
||||||
|
// Volume ID at drive `init()` (the new `unlock()` folds in the old
|
||||||
// OEM VID shortcut. Resolve the SAME unlocker that would unlock this
|
// `read_volume_id`). Use it and SKIP the cert handshake — the OEM path
|
||||||
// drive and ask it for the VID via its OEM mechanism. Cloning the
|
// decouples the VID from the host cert + HRL. It yields no
|
||||||
// DriveId first releases the immutable borrow before we hand the
|
// `read_data_key`; a bus-encrypted disc that needs the bus key is caught
|
||||||
// mutable transport to the registry.
|
// by the bus-key gate in `resolve_vid_only`.
|
||||||
let drive_id = session.drive_id.clone();
|
if let Some(volume_id) = session.oem_vid() {
|
||||||
match crate::unlock::unlocker_read_volume_id(session.scsi_mut(), &drive_id) {
|
|
||||||
Ok(Some(volume_id)) => {
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
target: "freemkv::disc",
|
target: "freemkv::disc",
|
||||||
phase = "oem_vid_ok",
|
phase = "oem_vid_ok",
|
||||||
"Got the disc's Volume ID from the drive unlocker; skipping the AACS host-certificate handshake."
|
"Volume ID supplied by the drive unlocker at init; skipping the AACS host-certificate handshake."
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
Some(HandshakeResult {
|
Some(HandshakeResult {
|
||||||
@@ -108,162 +274,31 @@ impl Disc {
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
target: "freemkv::disc",
|
target: "freemkv::disc",
|
||||||
phase = "oem_vid_none",
|
phase = "oem_vid_none",
|
||||||
"Drive unlocker has no Volume ID for this disc; trying the AACS host-certificate handshake next."
|
"No drive-unlocker Volume ID; running the in-tree AACS host-certificate handshake (AacsCertUnlocker)."
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
// Cert path: the in-tree `AacsCertUnlocker` peer absorbs the host-cert
|
||||||
|
// mutual-auth. It collects host certs from the key sources + credentials,
|
||||||
|
// runs `aacs_authenticate` per cert (wedge-guarded), and on success reads
|
||||||
|
// the VID + read_data_key. Its `UnlockError` is folded back to the same
|
||||||
|
// `Error` variants this function has always surfaced, so `scan_with`'s
|
||||||
|
// error rendering and the path-1 disc-hash → VUK fallback are unchanged.
|
||||||
|
let unlocker = AacsCertUnlocker { opts };
|
||||||
|
match unlocker.authenticate(session) {
|
||||||
|
Ok(hs) => (Some(hs), None),
|
||||||
Err(e) => {
|
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!(
|
|
||||||
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."
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
None,
|
|
||||||
Some(Error::AacsNoHostCert {
|
|
||||||
path: "<no host cert>".into(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target: "freemkv::disc",
|
target: "freemkv::disc",
|
||||||
phase = "vid_cert_rejected",
|
phase = "cert_handshake_outcome",
|
||||||
host_cert_count,
|
outcome = ?cert_unlock_outcome(&e),
|
||||||
tried = host_cert_count.min(MAX_CERT_ATTEMPTS),
|
"AACS cert handshake produced no VID; a key source may still supply this disc's key."
|
||||||
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))
|
(None, Some(unlock_error_to_error(e)))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a keys-free AACS state that carries only the Volume ID (+ version
|
/// 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 if bus_encryption => 2,
|
||||||
None => 1,
|
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
|
// 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
|
// the real record length (same as `read_aacs_inputs`). Without this the
|
||||||
// MKB stashed on `AacsState` — which `Disc::inputs()` and the device/
|
// MKB stashed on `AacsState` — which `Disc::inputs()` and the device/
|
||||||
@@ -712,6 +770,90 @@ mod tests {
|
|||||||
assert_eq!(st.read_data_key, Some(rdk));
|
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
|
/// With NO handshake, volume_id defaults to all-zero (encrypt.rs
|
||||||
/// `.unwrap_or([0u8; 16])`) and read_data_key is None.
|
/// `.unwrap_or([0u8; 16])`) and read_data_key is None.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -798,10 +940,13 @@ mod tests {
|
|||||||
/// A minimal in-test KeySource that yields no keys but a fixed cert list.
|
/// A minimal in-test KeySource that yields no keys but a fixed cert list.
|
||||||
struct CertSource(Vec<aacs::HostCert>);
|
struct CertSource(Vec<aacs::HostCert>);
|
||||||
impl crate::KeySource for CertSource {
|
impl crate::KeySource for CertSource {
|
||||||
fn next_key(&mut self, _inputs: &crate::keysource::DiscInputs) -> Option<crate::disc::Key> {
|
fn get_uk(
|
||||||
None
|
&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()
|
self.0.clone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -809,7 +954,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn collect_host_certs_empty_when_no_credentials_no_sources() {
|
fn collect_host_certs_empty_when_no_credentials_no_sources() {
|
||||||
let opts = ScanOptions::default();
|
let opts = ScanOptions::default();
|
||||||
assert!(Disc::collect_host_certs(&opts).is_empty());
|
assert!(Disc::collect_host_certs(&opts, None).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -820,7 +965,7 @@ mod tests {
|
|||||||
}),
|
}),
|
||||||
..Default::default()
|
..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.len(), 1);
|
||||||
assert_eq!(certs[0].private_key, [1u8; 20]);
|
assert_eq!(certs[0].private_key, [1u8; 20]);
|
||||||
}
|
}
|
||||||
@@ -831,7 +976,7 @@ mod tests {
|
|||||||
key_sources: vec![Box::new(CertSource(vec![fake_cert(2)]))],
|
key_sources: vec![Box::new(CertSource(vec![fake_cert(2)]))],
|
||||||
..Default::default()
|
..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.len(), 1);
|
||||||
assert_eq!(certs[0].private_key, [2u8; 20]);
|
assert_eq!(certs[0].private_key, [2u8; 20]);
|
||||||
}
|
}
|
||||||
@@ -851,11 +996,62 @@ mod tests {
|
|||||||
],
|
],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let mut tags: Vec<u8> = Disc::collect_host_certs(&opts)
|
let mut tags: Vec<u8> = Disc::collect_host_certs(&opts, None)
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| c.private_key[0])
|
.map(|c| c.private_key[0])
|
||||||
.collect();
|
.collect();
|
||||||
tags.sort_unstable();
|
tags.sort_unstable();
|
||||||
assert_eq!(tags, vec![1, 2, 3]);
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-7
@@ -24,7 +24,7 @@ use std::io::Write;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::Ordering;
|
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
|
/// AACS aligned unit = 3 sectors / 6144 bytes. Content reads are issued in
|
||||||
/// multiples of this so the decrypt step always sees whole units.
|
/// multiples of this so the decrypt step always sees whole units.
|
||||||
const AACS_UNIT_SECTORS: u32 = 3;
|
const AACS_UNIT_SECTORS: u32 = 3;
|
||||||
@@ -282,7 +282,7 @@ impl Disc {
|
|||||||
for &(abs_lba, byte_len) in &pf.extents {
|
for &(abs_lba, byte_len) in &pf.extents {
|
||||||
extents.push(crate::disc::Extent {
|
extents.push(crate::disc::Extent {
|
||||||
start_lba: abs_lba,
|
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 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 {
|
'extents: for &(abs_lba, byte_len) in &pf.extents {
|
||||||
if written >= pf.size {
|
if written >= pf.size {
|
||||||
break;
|
break;
|
||||||
@@ -467,7 +467,7 @@ fn extract_one_file<S: SectorSource>(
|
|||||||
// the per-extent re-anchoring in the mux read paths
|
// the per-extent re-anchoring in the mux read paths
|
||||||
// (`mux/disc.rs`, `sector/prefetched.rs`). No-op for CSS / None.
|
// (`mux/disc.rs`, `sector/prefetched.rs`). No-op for CSS / None.
|
||||||
dec.set_unit_base(abs_lba);
|
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;
|
let mut sector_off: u32 = 0;
|
||||||
while sector_off < sectors {
|
while sector_off < sectors {
|
||||||
let mut batch = (sectors - sector_off).min(READ_BATCH_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;
|
batch -= batch % AACS_UNIT_SECTORS;
|
||||||
}
|
}
|
||||||
let lba = abs_lba + sector_off;
|
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 read_ok = read_batch(dec, lba, batch, &mut buf[..want]);
|
||||||
let chunk_bytes = want as u64;
|
let chunk_bytes = want as u64;
|
||||||
// Clip the chunk to the remaining file size on the final extent.
|
// 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
|
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).
|
// ad_type 0 = Short AD (icb flags low 3 bits at offset 34).
|
||||||
s[34..36].copy_from_slice(&0u16.to_le_bytes());
|
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[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[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
|
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
|
// AD #0
|
||||||
s[216..220].copy_from_slice(&(ext_len & 0x3FFF_FFFF).to_le_bytes());
|
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());
|
s[220..224].copy_from_slice(&data_lba_a.to_le_bytes());
|
||||||
|
|||||||
+63
-17
@@ -370,8 +370,12 @@ pub enum SampleRate {
|
|||||||
S44_1,
|
S44_1,
|
||||||
/// 48 kHz — standard BD/DVD/UHD audio
|
/// 48 kHz — standard BD/DVD/UHD audio
|
||||||
S48,
|
S48,
|
||||||
|
/// 88.2 kHz — 44.1 kHz-family high-res TrueHD (music BD)
|
||||||
|
S88_2,
|
||||||
/// 96 kHz — high-res BD audio
|
/// 96 kHz — high-res BD audio
|
||||||
S96,
|
S96,
|
||||||
|
/// 176.4 kHz — 44.1 kHz-family high-res TrueHD (music BD)
|
||||||
|
S176_4,
|
||||||
/// 192 kHz — highest BD audio (LPCM)
|
/// 192 kHz — highest BD audio (LPCM)
|
||||||
S192,
|
S192,
|
||||||
/// 48/96 kHz combo (secondary audio resampled)
|
/// 48/96 kHz combo (secondary audio resampled)
|
||||||
@@ -432,16 +436,26 @@ pub struct Extent {
|
|||||||
pub sector_count: u32,
|
pub sector_count: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Correct the channel count of a title's TrueHD audio streams by probing the
|
/// Correct a title's TrueHD audio-stream metadata by probing the first
|
||||||
/// first decrypted access units. The MPLS `audio_format` field declares the
|
/// decrypted access units — channel count, real sample rate, and Atmos
|
||||||
/// BASE layout (often 5.1) even for a 7.1/Atmos TrueHD track; the real count is
|
/// detection in a single major-sync read. The MPLS descriptors declare the BASE
|
||||||
/// in the MLP major sync. `reader` must yield DECRYPTED sectors (the m2ts is
|
/// layout (often 5.1 / a container-guessed rate) even for a 7.1/Atmos TrueHD
|
||||||
/// AACS-encrypted, so this can only run at mux time, not scan). Reads a bounded
|
/// track; the truth is in the MLP major sync. `reader` must yield DECRYPTED
|
||||||
/// window of the title's first extent. Also regenerates the stream's codec
|
/// sectors (the m2ts is AACS-encrypted, so this can only run at mux time, not
|
||||||
/// label when it was the basic descriptor for the (now corrected) count —
|
/// scan). Reads a bounded window of the title's first extent.
|
||||||
/// richer editorial labels (e.g. "Dolby Atmos") are left untouched.
|
///
|
||||||
|
/// 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) {
|
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
|
let pids: Vec<u16> = title
|
||||||
.streams
|
.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 {
|
let Some(payload) = payloads.get(&a.pid) else {
|
||||||
continue;
|
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;
|
continue;
|
||||||
};
|
};
|
||||||
let new_ch = AudioChannels::from_count(count);
|
|
||||||
if new_ch == AudioChannels::Unknown || new_ch == a.channels {
|
// Whether the label is still the plain descriptor (no richer editorial
|
||||||
continue;
|
// label). Captured against the CURRENT channels before any correction so
|
||||||
}
|
// a label promotion only happens when nothing editorial is present.
|
||||||
let was_basic =
|
let was_basic =
|
||||||
a.label == crate::labels::generate_audio_label(&a.codec, &a.channels, a.secondary);
|
a.label == crate::labels::generate_audio_label(&a.codec, &a.channels, a.secondary);
|
||||||
|
|
||||||
|
// (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;
|
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 {
|
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 {
|
match self {
|
||||||
SampleRate::S44_1 => 44100.0,
|
SampleRate::S44_1 => 44100.0,
|
||||||
SampleRate::S48 | SampleRate::S48_96 | SampleRate::S48_192 => 48000.0,
|
SampleRate::S48 | SampleRate::S48_96 | SampleRate::S48_192 => 48000.0,
|
||||||
|
SampleRate::S88_2 => 88200.0,
|
||||||
SampleRate::S96 => 96000.0,
|
SampleRate::S96 => 96000.0,
|
||||||
|
SampleRate::S176_4 => 176400.0,
|
||||||
SampleRate::S192 => 192000.0,
|
SampleRate::S192 => 192000.0,
|
||||||
SampleRate::Unknown => 48000.0,
|
SampleRate::Unknown => 48000.0,
|
||||||
}
|
}
|
||||||
@@ -851,7 +893,9 @@ impl SampleRate {
|
|||||||
match hz {
|
match hz {
|
||||||
44100 => SampleRate::S44_1,
|
44100 => SampleRate::S44_1,
|
||||||
48000 => SampleRate::S48,
|
48000 => SampleRate::S48,
|
||||||
|
88200 => SampleRate::S88_2,
|
||||||
96000 => SampleRate::S96,
|
96000 => SampleRate::S96,
|
||||||
|
176400 => SampleRate::S176_4,
|
||||||
192000 => SampleRate::S192,
|
192000 => SampleRate::S192,
|
||||||
_ => SampleRate::Unknown,
|
_ => SampleRate::Unknown,
|
||||||
}
|
}
|
||||||
@@ -1037,7 +1081,9 @@ enum_str!(
|
|||||||
[
|
[
|
||||||
("44.1kHz", SampleRate::S44_1),
|
("44.1kHz", SampleRate::S44_1),
|
||||||
("48kHz", SampleRate::S48),
|
("48kHz", SampleRate::S48),
|
||||||
|
("88.2kHz", SampleRate::S88_2),
|
||||||
("96kHz", SampleRate::S96),
|
("96kHz", SampleRate::S96),
|
||||||
|
("176.4kHz", SampleRate::S176_4),
|
||||||
("192kHz", SampleRate::S192),
|
("192kHz", SampleRate::S192),
|
||||||
("48/96kHz", SampleRate::S48_96),
|
("48/96kHz", SampleRate::S48_96),
|
||||||
("48/192kHz", SampleRate::S48_192),
|
("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
|
/// VUK → unit keys. A source hands in its FULL device-key set, because
|
||||||
/// choosing which one applies *is* the MKB walk (derivation), and all
|
/// choosing which one applies *is* the MKB walk (derivation), and all
|
||||||
/// derivation lives here — never in a source.
|
/// 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
|
/// Processing key(s) (AACS PK). libfreemkv applies each against the MKB
|
||||||
/// → media key → VUK → unit keys.
|
/// → media key → VUK → unit keys.
|
||||||
Processing(Vec<[u8; 16]>),
|
Processing(Vec<[u8; 16]>),
|
||||||
@@ -5023,7 +5069,7 @@ mod tests {
|
|||||||
let good = mf.ranges_with(&[SectorStatus::Finished]);
|
let good = mf.ranges_with(&[SectorStatus::Finished]);
|
||||||
let bad_ranges = mf.ranges_with(&[SectorStatus::NonTrimmed]);
|
let bad_ranges = mf.ranges_with(&[SectorStatus::NonTrimmed]);
|
||||||
let disc_bytes = sectors as u64 * 2048;
|
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
|
// The first failing batch starts at LBA 320; everything before it read
|
||||||
// cleanly and must be Finished.
|
// cleanly and must be Finished.
|
||||||
|
|||||||
+1
-1
@@ -2588,7 +2588,7 @@ mod tests {
|
|||||||
range_sectors: 1,
|
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");
|
assert!(!timed_out, "range must not time out immediately");
|
||||||
// With correct initialization bytes_good_now == range_bytes_good,
|
// With correct initialization bytes_good_now == range_bytes_good,
|
||||||
// so the `bytes_good_now > range_bytes_good` branch does NOT fire
|
// so the `bytes_good_now > range_bytes_good` branch does NOT fire
|
||||||
|
|||||||
+97
-16
@@ -63,6 +63,12 @@ pub struct Drive {
|
|||||||
/// `init()`, if any matched. `None` means no unlocker matched and the
|
/// `init()`, if any matched. `None` means no unlocker matched and the
|
||||||
/// drive runs in stock mode (host-cert AACS handshake carries discs).
|
/// drive runs in stock mode (host-cert AACS handshake carries discs).
|
||||||
unlocker_name: Option<String>,
|
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).
|
/// True once `init()` has run (whether or not an unlocker matched).
|
||||||
init_ran: bool,
|
init_ran: bool,
|
||||||
/// Lazily-computed registry-match name for `platform_name()`'s `&str`
|
/// Lazily-computed registry-match name for `platform_name()`'s `&str`
|
||||||
@@ -108,6 +114,7 @@ impl Drive {
|
|||||||
Ok(Drive {
|
Ok(Drive {
|
||||||
scsi: transport,
|
scsi: transport,
|
||||||
unlocker_name: None,
|
unlocker_name: None,
|
||||||
|
oem_vid: None,
|
||||||
init_ran: false,
|
init_ran: false,
|
||||||
matched_name_cache: std::sync::OnceLock::new(),
|
matched_name_cache: std::sync::OnceLock::new(),
|
||||||
drive_id,
|
drive_id,
|
||||||
@@ -128,6 +135,7 @@ impl Drive {
|
|||||||
Drive {
|
Drive {
|
||||||
scsi,
|
scsi,
|
||||||
unlocker_name: None,
|
unlocker_name: None,
|
||||||
|
oem_vid: None,
|
||||||
init_ran: false,
|
init_ran: false,
|
||||||
matched_name_cache: std::sync::OnceLock::new(),
|
matched_name_cache: std::sync::OnceLock::new(),
|
||||||
drive_id: DriveId {
|
drive_id: DriveId {
|
||||||
@@ -216,6 +224,14 @@ impl Drive {
|
|||||||
self.scsi.as_mut()
|
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<()> {
|
pub fn wait_ready(&mut self) -> Result<()> {
|
||||||
let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00];
|
let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||||
let t0 = std::time::Instant::now();
|
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);
|
let r = crate::unlock::route_unlock(self.scsi.as_mut(), &self.drive_id);
|
||||||
self.init_ran = true;
|
self.init_ran = true;
|
||||||
let r = match r {
|
let r = match r {
|
||||||
Ok(Some(name)) => {
|
Ok(Some((name, vid))) => {
|
||||||
self.unlocker_name = Some(name);
|
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
|
// The matched unlocker may also be able to raise the drive to
|
||||||
// its maximum read speed. Best-effort: a failure here must NOT
|
// its maximum read speed. Best-effort: a failure here must NOT
|
||||||
// fail the rip — a slow drive still rips. Log and continue.
|
// 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 cdb = Arc::new(Mutex::new(Vec::new()));
|
||||||
let to = Arc::new(Mutex::new(0u32));
|
let to = Arc::new(Mutex::new(0u32));
|
||||||
let t = RecordingTransport {
|
let t = RecordingTransport {
|
||||||
@@ -1323,7 +1350,11 @@ mod command_tests {
|
|||||||
last_timeout: to.clone(),
|
last_timeout: to.clone(),
|
||||||
outcome,
|
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]
|
#[test]
|
||||||
@@ -1331,7 +1362,11 @@ mod command_tests {
|
|||||||
// Drive::read issues READ(10) (0x28). LBA bytes 2..5 big-endian,
|
// 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
|
// transfer length bytes 7..8 big-endian (MMC-6). No FUA on this
|
||||||
// path (byte 1 == 0). Distinct nibbles catch a swapped shift.
|
// 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 mut buf = vec![0u8; 4096];
|
||||||
let n = d.read(0x00AB_CDEF, 2, &mut buf, false).unwrap();
|
let n = d.read(0x00AB_CDEF, 2, &mut buf, false).unwrap();
|
||||||
assert_eq!(n, 4096, "returns transport bytes_transferred");
|
assert_eq!(n, 4096, "returns transport bytes_transferred");
|
||||||
@@ -1346,12 +1381,20 @@ mod command_tests {
|
|||||||
fn read_recovery_flag_selects_60s_timeout() {
|
fn read_recovery_flag_selects_60s_timeout() {
|
||||||
// recovery=true must use READ_RECOVERY_TIMEOUT_MS (60 s); false
|
// recovery=true must use READ_RECOVERY_TIMEOUT_MS (60 s); false
|
||||||
// uses READ_TIMEOUT_MS (10 s). Doc: patch pass vs copy sweep.
|
// 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];
|
let mut buf = vec![0u8; 2048];
|
||||||
d.read(0, 1, &mut buf, true).unwrap();
|
d.read(0, 1, &mut buf, true).unwrap();
|
||||||
assert_eq!(*to.lock().unwrap(), crate::scsi::READ_RECOVERY_TIMEOUT_MS);
|
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();
|
d2.read(0, 1, &mut buf, false).unwrap();
|
||||||
assert_eq!(*to2.lock().unwrap(), crate::scsi::READ_TIMEOUT_MS);
|
assert_eq!(*to2.lock().unwrap(), crate::scsi::READ_TIMEOUT_MS);
|
||||||
}
|
}
|
||||||
@@ -1366,7 +1409,11 @@ mod command_tests {
|
|||||||
asc: 0x11,
|
asc: 0x11,
|
||||||
ascq: 0x05,
|
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 mut buf = vec![0u8; 2048];
|
||||||
let err = d.read(0x1234, 1, &mut buf, false).unwrap_err();
|
let err = d.read(0x1234, 1, &mut buf, false).unwrap_err();
|
||||||
match err {
|
match err {
|
||||||
@@ -1387,7 +1434,11 @@ mod command_tests {
|
|||||||
fn read_transport_failure_status_preserved_for_marginal_routing() {
|
fn read_transport_failure_status_preserved_for_marginal_routing() {
|
||||||
// Status 0xFF (TRANSPORT_FAILURE) with no sense must surface in
|
// Status 0xFF (TRANSPORT_FAILURE) with no sense must surface in
|
||||||
// DiscRead.status so is_scsi_transport_failure() routes it.
|
// 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,
|
crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||||
None,
|
None,
|
||||||
));
|
));
|
||||||
@@ -1402,7 +1453,11 @@ mod command_tests {
|
|||||||
// When the halt flag is set, checked_exec returns Halted BEFORE
|
// When the halt flag is set, checked_exec returns Halted BEFORE
|
||||||
// execute(); the error must be Halted (not DiscRead), so the
|
// execute(); the error must be Halted (not DiscRead), so the
|
||||||
// recovery loop distinguishes user-stop from a read failure.
|
// 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();
|
d.halt();
|
||||||
let mut buf = vec![0u8; 2048];
|
let mut buf = vec![0u8; 2048];
|
||||||
let err = d.read(0, 1, &mut buf, false).unwrap_err();
|
let err = d.read(0, 1, &mut buf, false).unwrap_err();
|
||||||
@@ -1417,7 +1472,11 @@ mod command_tests {
|
|||||||
fn clear_halt_reenables_reads() {
|
fn clear_halt_reenables_reads() {
|
||||||
// halt() then clear_halt() must allow reads again — the flag is
|
// halt() then clear_halt() must allow reads again — the flag is
|
||||||
// not sticky.
|
// 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.halt();
|
||||||
d.clear_halt();
|
d.clear_halt();
|
||||||
let mut buf = vec![0u8; 2048];
|
let mut buf = vec![0u8; 2048];
|
||||||
@@ -1429,7 +1488,11 @@ mod command_tests {
|
|||||||
// Single-shot contract: Drive::read returns exactly what the
|
// Single-shot contract: Drive::read returns exactly what the
|
||||||
// transport reported, never a smaller count silently. Transport
|
// transport reported, never a smaller count silently. Transport
|
||||||
// says a full 32-sector batch (65536 bytes) succeeded.
|
// 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];
|
let mut buf = vec![0u8; 65536];
|
||||||
assert_eq!(d.read(0, 32, &mut buf, false).unwrap(), 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 reads = Arc::new(Mutex::new(Vec::new()));
|
||||||
let t = ChunkingTransport {
|
let t = ChunkingTransport {
|
||||||
max_bytes,
|
max_bytes,
|
||||||
@@ -1501,7 +1570,10 @@ mod command_tests {
|
|||||||
fail_on,
|
fail_on,
|
||||||
seen: 0,
|
seen: 0,
|
||||||
};
|
};
|
||||||
(Drive::from_transport_for_test(Box::new(t)), reads)
|
ChunkingHarness {
|
||||||
|
drive: Drive::from_transport_for_test(Box::new(t)),
|
||||||
|
reads,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1509,7 +1581,10 @@ mod command_tests {
|
|||||||
// max_transfer = 4 sectors (4 * 2048 = 8192 bytes). A read of 10
|
// 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),
|
// 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.
|
// (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 mut buf = vec![0u8; 10 * 2048];
|
||||||
let n = d.read(0, 10, &mut buf, false).unwrap();
|
let n = d.read(0, 10, &mut buf, false).unwrap();
|
||||||
assert_eq!(n, 10 * 2048, "returns total bytes across all chunks");
|
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
|
// Same 4-sector cap; fail the 2nd chunk (index 1), which covers
|
||||||
// LBA 4. The error must be DiscRead with sector = 4 (the failing
|
// LBA 4. The error must be DiscRead with sector = 4 (the failing
|
||||||
// chunk's LBA), NOT the request base LBA 0.
|
// 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 mut buf = vec![0u8; 10 * 2048];
|
||||||
let err = d.read(0, 10, &mut buf, false).unwrap_err();
|
let err = d.read(0, 10, &mut buf, false).unwrap_err();
|
||||||
match err {
|
match err {
|
||||||
@@ -1546,7 +1624,10 @@ mod command_tests {
|
|||||||
fn read_small_request_is_single_unchunked_read() {
|
fn read_small_request_is_single_unchunked_read() {
|
||||||
// count <= max_sectors must take the single-read path unchanged: a
|
// count <= max_sectors must take the single-read path unchanged: a
|
||||||
// 3-sector read under a 4-sector cap is exactly one READ(10).
|
// 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];
|
let mut buf = vec![0u8; 3 * 2048];
|
||||||
assert_eq!(d.read(0, 3, &mut buf, false).unwrap(), 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");
|
assert_eq!(*reads.lock().unwrap(), vec![(0, 3)], "single CDB, no split");
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ pub const E_VID_CDB_UNAVAILABLE: u16 = 7021;
|
|||||||
pub const E_NO_DISC_KEY: u16 = 7022;
|
pub const E_NO_DISC_KEY: u16 = 7022;
|
||||||
pub const E_CSS_KEY_MISSING: u16 = 7023;
|
pub const E_CSS_KEY_MISSING: u16 = 7023;
|
||||||
pub const E_AACS_NO_HOST_CERT: u16 = 7024;
|
pub const E_AACS_NO_HOST_CERT: u16 = 7024;
|
||||||
|
pub const E_AACS_BUS_KEY_UNAVAILABLE: u16 = 7025;
|
||||||
|
|
||||||
// Keydb (8xxx)
|
// Keydb (8xxx)
|
||||||
pub const E_KEYDB_CONNECT: u16 = 8000;
|
pub const E_KEYDB_CONNECT: u16 = 8000;
|
||||||
@@ -347,6 +348,17 @@ pub enum Error {
|
|||||||
AacsNoHostCert {
|
AacsNoHostCert {
|
||||||
path: String,
|
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)
|
// Keydb (8xxx)
|
||||||
KeydbConnect {
|
KeydbConnect {
|
||||||
@@ -560,6 +572,7 @@ impl Error {
|
|||||||
Error::NoDiscKey { .. } => E_NO_DISC_KEY,
|
Error::NoDiscKey { .. } => E_NO_DISC_KEY,
|
||||||
Error::CssKeyMissing => E_CSS_KEY_MISSING,
|
Error::CssKeyMissing => E_CSS_KEY_MISSING,
|
||||||
Error::AacsNoHostCert { .. } => E_AACS_NO_HOST_CERT,
|
Error::AacsNoHostCert { .. } => E_AACS_NO_HOST_CERT,
|
||||||
|
Error::AacsBusKeyUnavailable => E_AACS_BUS_KEY_UNAVAILABLE,
|
||||||
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
|
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
|
||||||
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
|
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
|
||||||
Error::KeydbInvalid => E_KEYDB_INVALID,
|
Error::KeydbInvalid => E_KEYDB_INVALID,
|
||||||
|
|||||||
+11
-11
@@ -232,7 +232,7 @@ pub struct DvdSubtitleAttr {
|
|||||||
|
|
||||||
const VMG_MAGIC: &[u8; 12] = b"DVDVIDEO-VMG";
|
const VMG_MAGIC: &[u8; 12] = b"DVDVIDEO-VMG";
|
||||||
const VTS_MAGIC: &[u8; 12] = b"DVDVIDEO-VTS";
|
const VTS_MAGIC: &[u8; 12] = b"DVDVIDEO-VTS";
|
||||||
const SECTOR_SIZE: usize = 2048;
|
use crate::consts::SECTOR_BYTES;
|
||||||
|
|
||||||
// ── Helper: safe binary reads ───────────────────────────────────────────────
|
// ── 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.
|
// 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.
|
// 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)
|
let tt_srpt_offset = (tt_srpt_sector as usize)
|
||||||
.checked_mul(SECTOR_SIZE)
|
.checked_mul(SECTOR_BYTES)
|
||||||
.ok_or(Error::IfoParse)?;
|
.ok_or(Error::IfoParse)?;
|
||||||
|
|
||||||
// TT_SRPT may be beyond what we read; if so, it's embedded in the file data
|
// 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
|
// Parse PGC information table
|
||||||
let pgcit_offset = (pgcit_sector as usize)
|
let pgcit_offset = (pgcit_sector as usize)
|
||||||
.checked_mul(SECTOR_SIZE)
|
.checked_mul(SECTOR_BYTES)
|
||||||
.ok_or(Error::IfoParse)?;
|
.ok_or(Error::IfoParse)?;
|
||||||
let titles = parse_pgcit(&vts_data, pgcit_offset, titles_info)?;
|
let titles = parse_pgcit(&vts_data, pgcit_offset, titles_info)?;
|
||||||
|
|
||||||
@@ -1562,8 +1562,8 @@ mod tests {
|
|||||||
assert!(!c.is_secondary_block_piece());
|
assert!(!c.is_secondary_block_piece());
|
||||||
|
|
||||||
// block_mode=1 (first cell of block), block_type=1 (angle block):
|
// block_mode=1 (first cell of block), block_type=1 (angle block):
|
||||||
// 0b01_01_0000 = 0x50. This is the angle we KEEP — not secondary.
|
// 0b0101_0000 = 0x50. This is the angle we KEEP — not secondary.
|
||||||
let c = CellCategory::decode(0b01_01_0000);
|
let c = CellCategory::decode(0b0101_0000);
|
||||||
assert_eq!(c.block_mode, 1);
|
assert_eq!(c.block_mode, 1);
|
||||||
assert_eq!(c.block_type, 1);
|
assert_eq!(c.block_type, 1);
|
||||||
assert!(!c.is_plain_feature());
|
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_mode=2 (in block) / 3 (last of block) of an angle block
|
||||||
// (block_type=1) → secondary.
|
// (block_type=1) → secondary.
|
||||||
assert!(CellCategory::decode(0b10_01_0000).is_secondary_block_piece());
|
assert!(CellCategory::decode(0b1001_0000).is_secondary_block_piece());
|
||||||
assert!(CellCategory::decode(0b11_01_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.
|
// 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,
|
// The low flags (seamless_play bit3, interleaved bit2, stc bit1,
|
||||||
// seamless_angle bit0) on an otherwise-plain cell must NOT make it
|
// seamless_angle bit0) on an otherwise-plain cell must NOT make it
|
||||||
@@ -1617,8 +1617,8 @@ mod tests {
|
|||||||
chapters: 2,
|
chapters: 2,
|
||||||
duration_secs: 100.0,
|
duration_secs: 100.0,
|
||||||
cells: vec![
|
cells: vec![
|
||||||
cell(0, 9, 0b10_01_0000), // in-block cell of angle block → drop
|
cell(0, 9, 0b1001_0000), // in-block cell of angle block → drop
|
||||||
cell(10, 19, 0b11_01_0000), // last cell of angle block → drop
|
cell(10, 19, 0b1101_0000), // last cell of angle block → drop
|
||||||
cell(20, 119, 0x00), // feature starts here
|
cell(20, 119, 0x00), // feature starts here
|
||||||
cell(120, 219, 0x00),
|
cell(120, 219, 0x00),
|
||||||
],
|
],
|
||||||
@@ -1640,7 +1640,7 @@ mod tests {
|
|||||||
let t = DvdTitle {
|
let t = DvdTitle {
|
||||||
chapters: 1,
|
chapters: 1,
|
||||||
duration_secs: 100.0,
|
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],
|
chapter_times: vec![0.0],
|
||||||
palette: None,
|
palette: None,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ use std::path::Path;
|
|||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::sector::SectorSource;
|
use crate::sector::SectorSource;
|
||||||
|
|
||||||
const SECTOR_SIZE: usize = 2048;
|
use crate::consts::SECTOR_BYTES;
|
||||||
|
|
||||||
/// Bytes-read threshold per `posix_fadvise(DONTNEED)` drop on the
|
/// Bytes-read threshold per `posix_fadvise(DONTNEED)` drop on the
|
||||||
/// read side. Mirrors `WRITEBACK_CHUNK_BYTES` so the read-side page
|
/// read side. Mirrors `WRITEBACK_CHUNK_BYTES` so the read-side page
|
||||||
@@ -134,7 +134,7 @@ impl FileSectorSource {
|
|||||||
.metadata()
|
.metadata()
|
||||||
.map_err(|e| Error::IoError { source: e })?
|
.map_err(|e| Error::IoError { source: e })?
|
||||||
.len();
|
.len();
|
||||||
let sectors = len / SECTOR_SIZE as u64;
|
let sectors = len / SECTOR_BYTES as u64;
|
||||||
if sectors > u32::MAX as u64 {
|
if sectors > u32::MAX as u64 {
|
||||||
return Err(Error::IsoTooLarge {
|
return Err(Error::IsoTooLarge {
|
||||||
path: path.to_string_lossy().into_owned(),
|
path: path.to_string_lossy().into_owned(),
|
||||||
@@ -170,7 +170,7 @@ impl SectorSource for FileSectorSource {
|
|||||||
_recovery: bool,
|
_recovery: bool,
|
||||||
) -> Result<usize> {
|
) -> Result<usize> {
|
||||||
let count = count as u32;
|
let count = count as u32;
|
||||||
let bytes = count as usize * SECTOR_SIZE;
|
let bytes = count as usize * SECTOR_BYTES;
|
||||||
debug_assert!(
|
debug_assert!(
|
||||||
out.len() >= bytes,
|
out.len() >= bytes,
|
||||||
"FileSectorSource::read_sectors: out len {} < requested {}",
|
"FileSectorSource::read_sectors: out len {} < requested {}",
|
||||||
@@ -180,7 +180,7 @@ impl SectorSource for FileSectorSource {
|
|||||||
if count == 0 {
|
if count == 0 {
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
let offset = lba as u64 * SECTOR_SIZE as u64;
|
let offset = lba as u64 * SECTOR_BYTES as u64;
|
||||||
self.file
|
self.file
|
||||||
.seek(SeekFrom::Start(offset))
|
.seek(SeekFrom::Start(offset))
|
||||||
.map_err(|e| Error::IoError { source: e })?;
|
.map_err(|e| Error::IoError { source: e })?;
|
||||||
@@ -224,7 +224,7 @@ mod tests {
|
|||||||
/// verify any sector by content alone.
|
/// verify any sector by content alone.
|
||||||
fn make_iso(path: &std::path::Path, sectors: u32) {
|
fn make_iso(path: &std::path::Path, sectors: u32) {
|
||||||
let mut f = std::fs::File::create(path).unwrap();
|
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 {
|
for n in 0..sectors {
|
||||||
let b = (n & 0xff) as u8;
|
let b = (n & 0xff) as u8;
|
||||||
chunk.iter_mut().for_each(|c| *c = b);
|
chunk.iter_mut().for_each(|c| *c = b);
|
||||||
@@ -249,7 +249,7 @@ mod tests {
|
|||||||
let mut src = FileSectorSource::open(&path).unwrap();
|
let mut src = FileSectorSource::open(&path).unwrap();
|
||||||
assert_eq!(src.capacity_sectors(), total);
|
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 {
|
for lba in 0..total {
|
||||||
src.read_sectors(lba, 1, &mut got, false).unwrap();
|
src.read_sectors(lba, 1, &mut got, false).unwrap();
|
||||||
let expected = (lba & 0xff) as u8;
|
let expected = (lba & 0xff) as u8;
|
||||||
@@ -270,12 +270,12 @@ mod tests {
|
|||||||
let mut src = FileSectorSource::open(&path).unwrap();
|
let mut src = FileSectorSource::open(&path).unwrap();
|
||||||
|
|
||||||
let span_lba = TEST_SPAN_SECTORS - 2;
|
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();
|
src.read_sectors(span_lba, 4, &mut buf4, false).unwrap();
|
||||||
for i in 0..4 {
|
for i in 0..4 {
|
||||||
let lba = span_lba + i as u32;
|
let lba = span_lba + i as u32;
|
||||||
let expected = (lba & 0xff) as u8;
|
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}");
|
assert_eq!(*b, expected, "byte mismatch at sub-sector {i}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,7 +291,7 @@ mod tests {
|
|||||||
make_iso(&path, total);
|
make_iso(&path, total);
|
||||||
|
|
||||||
let mut src = FileSectorSource::open(&path).unwrap();
|
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)
|
src.read_sectors(TEST_SPAN_SECTORS + 1, 1, &mut got, false)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -311,7 +311,7 @@ mod tests {
|
|||||||
let mut src = FileSectorSource::open(&path).unwrap();
|
let mut src = FileSectorSource::open(&path).unwrap();
|
||||||
assert_eq!(src.capacity_sectors(), total);
|
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(0, 1, &mut got, false).unwrap();
|
||||||
src.read_sectors(total - 1, 1, &mut got, false).unwrap();
|
src.read_sectors(total - 1, 1, &mut got, false).unwrap();
|
||||||
let expected = ((total - 1) & 0xff) as u8;
|
let expected = ((total - 1) & 0xff) as u8;
|
||||||
@@ -330,15 +330,15 @@ mod tests {
|
|||||||
|
|
||||||
let mut src = FileSectorSource::open(&path).unwrap();
|
let mut src = FileSectorSource::open(&path).unwrap();
|
||||||
let req = (TEST_SPAN_SECTORS + 1) as u16;
|
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];
|
let mut big = vec![0u8; req_bytes];
|
||||||
src.read_sectors(0, req, &mut big, false).unwrap();
|
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 last_lba = req as u32 - 1;
|
||||||
let exp = (last_lba & 0xff) as u8;
|
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!(
|
assert!(
|
||||||
big[last_off..last_off + SECTOR_SIZE]
|
big[last_off..last_off + SECTOR_BYTES]
|
||||||
.iter()
|
.iter()
|
||||||
.all(|b| *b == exp)
|
.all(|b| *b == exp)
|
||||||
);
|
);
|
||||||
@@ -405,7 +405,7 @@ mod tests {
|
|||||||
assert_eq!(src.capacity_sectors(), 4);
|
assert_eq!(src.capacity_sectors(), 4);
|
||||||
|
|
||||||
// Request 2 sectors starting at LBA 3 → sector 4 doesn't exist.
|
// 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 r = src.read_sectors(3, 2, &mut buf, false);
|
||||||
let err = r.expect_err("reading past EOF must error, not short-read");
|
let err = r.expect_err("reading past EOF must error, not short-read");
|
||||||
let io: std::io::Error = err.into();
|
let io: std::io::Error = err.into();
|
||||||
@@ -418,21 +418,21 @@ mod tests {
|
|||||||
|
|
||||||
/// On a successful full read the returned count MUST equal
|
/// On a successful full read the returned count MUST equal
|
||||||
/// `count * 2048` exactly — the declared byte count. Grounding:
|
/// `count * 2048` exactly — the declared byte count. Grounding:
|
||||||
/// `Ok(bytes)` where `bytes = count * SECTOR_SIZE`.
|
/// `Ok(bytes)` where `bytes = count * SECTOR_BYTES`.
|
||||||
#[test]
|
#[test]
|
||||||
fn full_read_returns_exact_declared_bytes() {
|
fn full_read_returns_exact_declared_bytes() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let path = dir.path().join("exact.iso");
|
let path = dir.path().join("exact.iso");
|
||||||
make_iso(&path, 16);
|
make_iso(&path, 16);
|
||||||
let mut src = FileSectorSource::open(&path).unwrap();
|
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();
|
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
|
/// Capacity is `file_len / 2048` (floor); trailing bytes that don't
|
||||||
/// complete a sector are NOT counted. A file of 4 sectors + 100
|
/// 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`.
|
/// integer division in `open`.
|
||||||
#[test]
|
#[test]
|
||||||
fn capacity_floors_partial_trailing_sector() {
|
fn capacity_floors_partial_trailing_sector() {
|
||||||
@@ -494,7 +494,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn dontneed_eviction_does_not_affect_data() {
|
fn dontneed_eviction_does_not_affect_data() {
|
||||||
// 32 MiB default chunk = 16384 sectors; read a bit past it.
|
// 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 dir = tempdir().unwrap();
|
||||||
let path = dir.path().join("drop.iso");
|
let path = dir.path().join("drop.iso");
|
||||||
make_iso(&path, total);
|
make_iso(&path, total);
|
||||||
@@ -502,15 +502,15 @@ mod tests {
|
|||||||
// Read in 16-sector batches to keep the loop fast while still
|
// Read in 16-sector batches to keep the loop fast while still
|
||||||
// crossing the drop boundary by byte count.
|
// crossing the drop boundary by byte count.
|
||||||
let batch = 16u16;
|
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;
|
let mut lba = 0u32;
|
||||||
while lba + batch as u32 <= total {
|
while lba + batch as u32 <= total {
|
||||||
src.read_sectors(lba, batch, &mut got, false).unwrap();
|
src.read_sectors(lba, batch, &mut got, false).unwrap();
|
||||||
for i in 0..batch as u32 {
|
for i in 0..batch as u32 {
|
||||||
let expected = ((lba + i) & 0xff) as u8;
|
let expected = ((lba + i) & 0xff) as u8;
|
||||||
let off = i as usize * SECTOR_SIZE;
|
let off = i as usize * SECTOR_BYTES;
|
||||||
assert!(
|
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 {}",
|
"DONTNEED eviction corrupted sector {}",
|
||||||
lba + i
|
lba + i
|
||||||
);
|
);
|
||||||
|
|||||||
+315
-117
@@ -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
|
//! libfreemkv performs NO key lookup. An application resolves a disc's keys
|
||||||
//! through one or more [`KeySource`]s, each a dumb adapter over a backing store
|
//! through one or more [`KeySource`]s, each an adapter over a backing store (a
|
||||||
//! (a keydb file, a key server, the mapfile cache): given the disc's
|
//! keydb file, a key server, the mapfile cache). A source's job is to return the
|
||||||
//! [`DiscInputs`] it returns the raw [`Key`] at whatever level it holds. The
|
//! disc's terminal **Unit Keys** ([`crate::aacs::UnitKey`]). It knows what
|
||||||
//! library then derives down and decrypts via `Disc::decrypt_with`.
|
//! 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`
|
//! libfreemkv still OWNS the crypto: the boil-down primitives and the AES live
|
||||||
//! crate — keeping all key *policy* (which store, which order, online vs local)
|
//! here. A source owns only PATH ORCHESTRATION — deciding which primitive to
|
||||||
//! out of the library while all key *mechanism* (the AACS derivation chain)
|
//! call with what input for the material it happens to hold. Source
|
||||||
//! stays in it.
|
//! 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::disc::Key;
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
/// The public AACS inputs a key source needs to look a disc up. Captured at
|
/// 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
|
/// 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>,
|
pub volume_label: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A key source: a stateful provider that hands a disc's candidate [`Key`]s out
|
/// A lazy view of a disc's AACS material, handed to [`KeySource::get_uk`] so a
|
||||||
/// **one at a time**, in whatever order it judges best for its backing store.
|
/// 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
|
/// "Lazy" by contract: each accessor returns only what the source asks for, so a
|
||||||
/// holds at whatever level it has (device / processing / media / volume / unit).
|
/// source that already holds terminal Unit Keys never touches the MKB or
|
||||||
/// It performs NO AACS derivation and NO validation: `Disc::decrypt_with`
|
/// samples. (Today the backing [`DiscInputsCtx`] is eagerly populated from a
|
||||||
/// derives down AND validates against real ciphertext, returning `Err` for a key
|
/// scan-time [`DiscInputs`]; the trait keeps the lazy signature so a future
|
||||||
/// that does not decrypt this disc. That keeps every derivation step and the one
|
/// implementation can fetch on demand without a source-API break.)
|
||||||
/// validation gate in the library, across AACS 1.0 / 2.0 / 2.1 / 2.x.
|
pub trait ResolveCtx {
|
||||||
///
|
/// SHA-1 of `Unit_Key_RO.inf`, `0x`-prefixed hex — the per-disc lookup key.
|
||||||
/// The source is the one that knows how many candidates it has and in what order
|
fn disc_hash(&self) -> &str;
|
||||||
/// to try them — a keydb holds a per-disc UK *and* VUK *and* a device-key pool,
|
/// The disc's human title (UDF/ISO volume identifier), when captured.
|
||||||
/// so it hands them out cheapest/most-specific first (UK ▸ VK ▸ MK ▸ DK) and
|
fn title(&self) -> Option<&str>;
|
||||||
/// reports exhaustion when its list runs out; an online key service or a mapfile
|
/// Volume ID, or `None` when no authenticated handshake ran (the all-zero
|
||||||
/// cache hold exactly one. The caller drives the loop: `next_key` →
|
/// sentinel) — VID-dependent derivation (`MK → VUK`) is then impossible.
|
||||||
/// `Disc::decrypt_with` → on `Err`, ask again → until a key decrypts or the
|
fn vid(&self) -> Option<Vid>;
|
||||||
/// source returns `None` (a genuine "no key for this disc"). Compose several
|
/// Raw MKB bytes (may be empty when not captured).
|
||||||
/// sources, in the caller's chosen order, with the companion
|
fn mkb(&self) -> Result<&[u8], Error>;
|
||||||
/// `freemkv-keysources` crate's `MultiSource`.
|
/// The disc's encrypted title keys, parsed from `Unit_Key_RO.inf` the same
|
||||||
pub trait KeySource {
|
/// way the library's resolver parses them ([`crate::aacs::parse_unit_key_ro`]),
|
||||||
/// Hand the NEXT candidate key for this disc, or `None` once this source is
|
/// in on-disc order. Feed straight into [`crate::aacs::uk_from_vuk`].
|
||||||
/// exhausted. Stateful: the source tracks what it already handed out this
|
fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error>;
|
||||||
/// session, so asking again after a rejected key yields the next candidate
|
/// Up to `n` encrypted on-disc content sample units, for a source that
|
||||||
/// (or `None`) — it never re-offers a key or re-hits a one-shot backend (an
|
/// validates a candidate server-side against real ciphertext.
|
||||||
/// online service is asked at most once).
|
fn samples(&self, n: usize) -> Result<Vec<Vec<u8>>, Error>;
|
||||||
///
|
/// Raw `Unit_Key_RO.inf` bytes, verbatim. Most sources derive locally from
|
||||||
/// `None` means only "no more candidates from this source"; it does NOT by
|
/// the parsed [`Self::enc_title_keys`]; a source that forwards the on-disc
|
||||||
/// itself distinguish a genuine "no key for this disc" from a source
|
/// structure to a server doing its OWN derivation (an online key service)
|
||||||
/// failure (I/O, network, parse). After exhaustion the caller must consult
|
/// needs the unparsed blob. Empty when not captured. Defaults to empty so
|
||||||
/// [`KeySource::errored`] to tell the two apart — a failed source records
|
/// existing/foreign `ResolveCtx` impls keep compiling unchanged.
|
||||||
/// the failure there and still returns `None` here.
|
fn unit_key_ro(&self) -> &[u8] {
|
||||||
fn next_key(&mut self, inputs: &DiscInputs) -> Option<Key>;
|
&[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether this source needs [`DiscInputs::samples`] populated (encrypted
|
/// [`ResolveCtx`] over a scan-time [`DiscInputs`].
|
||||||
/// content samples) — true for a source that validates against ciphertext
|
///
|
||||||
/// server-side, false for one that keys purely on disc identity. The caller
|
/// Pre-parses the encrypted title keys at construction (so `enc_title_keys` can
|
||||||
/// reads samples (an extra disc read) only when some source needs them.
|
/// hand back a borrowed slice) at the version-appropriate `Unit_Key_RO.inf`
|
||||||
fn needs_samples(&self) -> bool {
|
/// stride — `version_u8` is the disc's AACS major (1 → 48-byte V10 stride, else
|
||||||
false
|
/// 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"`,
|
/// A short, stable identifier for this source kind (`"keydb"`, `"online"`,
|
||||||
/// `"mapfile"`, …). For logging which source produced a key, and for
|
/// `"mapfile"`, …). For logging which source produced a key, and for
|
||||||
/// composition/ordering logic that needs to tell sources apart. A format
|
/// composition/ordering. A format string, not user-facing English.
|
||||||
/// string, not user-facing English.
|
|
||||||
fn label(&self) -> &'static str {
|
fn label(&self) -> &'static str {
|
||||||
"source"
|
"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
|
/// Drive `sources` until one resolves Unit Keys that decrypt `disc`. Returns
|
||||||
/// SCSI mutual-auth handshake (the OEM/AACS baseline route). A host cert is
|
/// `true` at the first source whose keys validate and commit, `false` once every
|
||||||
/// the *second* kind of AACS material a source may hold, distinct from the
|
/// source is exhausted (the genuine "no key for this disc"). Thin wrapper over
|
||||||
/// decryption keys handed out by [`KeySource::next_key`]: it unlocks the
|
/// [`resolve_and_apply_traced`] that discards the trace.
|
||||||
/// 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.
|
|
||||||
pub fn resolve_and_apply(
|
pub fn resolve_and_apply(
|
||||||
sources: &mut dyn KeySource,
|
sources: &[Box<dyn KeySource>],
|
||||||
inputs: &DiscInputs,
|
inputs: &DiscInputs,
|
||||||
disc: &mut crate::Disc,
|
disc: &mut crate::Disc,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
while let Some(key) = sources.next_key(inputs) {
|
resolve_and_apply_traced(sources, inputs, disc).0
|
||||||
if disc.decrypt_with(key, &inputs.samples).is_ok() {
|
}
|
||||||
return true;
|
|
||||||
|
/// 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
|
/// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::disc::Key;
|
use crate::aacs::UnitKey;
|
||||||
|
|
||||||
// ── DiscInputs structural tests ────────────────────────────────────────────
|
|
||||||
|
|
||||||
// ── KeySource default-method behaviour ────────────────────────────────────
|
// ── KeySource default-method behaviour ────────────────────────────────────
|
||||||
|
|
||||||
/// KeySource::needs_samples() defaults to false.
|
/// KeySource::host_certs() defaults to empty regardless of the MKB argument.
|
||||||
/// Spec: doc says "false for one that keys purely on disc identity."
|
/// Spec: a source holding no cert returns the empty vec; the `mkb` param is
|
||||||
/// Mutation: defaulting to true forces an extra disc-read for every source,
|
/// forward-looking and the default ignores it.
|
||||||
/// even local keydb lookups that don't need ciphertext samples.
|
/// Mutation: a default returning a non-empty vec would inject phantom certs
|
||||||
|
/// into the OEM handshake.
|
||||||
#[test]
|
#[test]
|
||||||
fn key_source_needs_samples_defaults_to_false() {
|
fn key_source_host_certs_defaults_to_empty() {
|
||||||
struct MinimalSource;
|
struct MinimalSource;
|
||||||
impl KeySource for MinimalSource {
|
impl KeySource for MinimalSource {
|
||||||
fn next_key(&mut self, _inputs: &DiscInputs) -> Option<Key> {
|
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||||
None
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let s = MinimalSource;
|
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.
|
/// DiscInputsCtx maps DiscInputs faithfully: zero VID → None, non-zero VID →
|
||||||
/// Spec: doc says "A store that treats absence as not-an-error leaves this false."
|
/// Some; title from volume_label; samples truncate to n; enc_title_keys
|
||||||
/// Mutation: defaulting to true would make every source appear errored, causing
|
/// parses Unit_Key_RO.inf at the version stride.
|
||||||
/// the caller to report "key service unreachable" for a simple miss.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn key_source_errored_defaults_to_false() {
|
fn disc_inputs_ctx_maps_fields() {
|
||||||
struct MinimalSource;
|
// Build a minimal V10 Unit_Key_RO.inf with one key (stride 48):
|
||||||
impl KeySource for MinimalSource {
|
// uk_pos = 32, num_uk = 1, key at uk_pos + 48 = 80.
|
||||||
fn next_key(&mut self, _inputs: &DiscInputs) -> Option<Key> {
|
let mut uk_ro = vec![0u8; 96];
|
||||||
None
|
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;
|
let mut disc = crate::Disc {
|
||||||
assert!(!s.errored(), "errored must default to false");
|
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"]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -385,7 +385,7 @@ mod tests {
|
|||||||
meta.titles.get("fra").map(String::as_str),
|
meta.titles.get("fra").map(String::as_str),
|
||||||
Some("Aurora Drift (Partie Deux)")
|
Some("Aurora Drift (Partie Deux)")
|
||||||
);
|
);
|
||||||
assert!(meta.descriptions.get("eng").is_none());
|
assert!(!meta.descriptions.contains_key("eng"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
meta.descriptions.get("fra").map(String::as_str),
|
meta.descriptions.get("fra").map(String::as_str),
|
||||||
Some("Suite du film fictif.")
|
Some("Suite du film fictif.")
|
||||||
|
|||||||
@@ -323,6 +323,11 @@ fn parse_language_streams_text(text: &str) -> Vec<StreamLabel> {
|
|||||||
labels
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+1
-1
@@ -288,7 +288,7 @@ mod tests {
|
|||||||
// ignores; the point is that read_to_end stops at the cap rather
|
// ignores; the point is that read_to_end stops at the cap rather
|
||||||
// than following a (potentially huge) declared size.
|
// than following a (potentially huge) declared size.
|
||||||
let mut payload = MINIMAL_CLASS.to_vec();
|
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(
|
let mut jar = open(build_stored_zip(
|
||||||
"Padded.class",
|
"Padded.class",
|
||||||
&payload,
|
&payload,
|
||||||
|
|||||||
+35
-2
@@ -441,15 +441,37 @@ fn codec_hint_adds_detail(hint: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn generate_audio_label(
|
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,
|
codec: &crate::disc::Codec,
|
||||||
channels: &crate::disc::AudioChannels,
|
channels: &crate::disc::AudioChannels,
|
||||||
_secondary: bool,
|
_secondary: bool,
|
||||||
|
atmos: bool,
|
||||||
) -> String {
|
) -> String {
|
||||||
use crate::disc::{AudioChannels, Codec};
|
use crate::disc::{AudioChannels, Codec};
|
||||||
|
|
||||||
// Full marketing names for disc audio codecs.
|
// Full marketing names for disc audio codecs.
|
||||||
// These are codec brand identifiers, not user-facing English prose.
|
// 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::TrueHd => "Dolby TrueHD",
|
||||||
Codec::Ac3 => "Dolby Digital",
|
Codec::Ac3 => "Dolby Digital",
|
||||||
Codec::Ac3Plus => "Dolby Digital Plus",
|
Codec::Ac3Plus => "Dolby Digital Plus",
|
||||||
@@ -465,6 +487,15 @@ pub(crate) fn generate_audio_label(
|
|||||||
_ => return String::new(),
|
_ => 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
|
// Channel layout
|
||||||
let channel_str = match channels {
|
let channel_str = match channels {
|
||||||
AudioChannels::Mono => "1.0",
|
AudioChannels::Mono => "1.0",
|
||||||
@@ -1090,7 +1121,9 @@ mod registry_tests {
|
|||||||
// and as a marker for "these parsers exist."
|
// and as a marker for "these parsers exist."
|
||||||
let _ = (name, detect, parse);
|
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.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -536,8 +536,8 @@ mod tests {
|
|||||||
fn parse_token_all_regions_recognized() {
|
fn parse_token_all_regions_recognized() {
|
||||||
for region in REGIONS {
|
for region in REGIONS {
|
||||||
let token = format!("eng_MLP_{}_", region);
|
let token = format!("eng_MLP_{}_", region);
|
||||||
let l =
|
let l = parse_token_inner(&token, None)
|
||||||
parse_token_inner(&token, None).expect(&format!("region {} should parse", region));
|
.unwrap_or_else(|| panic!("region {region} should parse"));
|
||||||
assert_eq!(l.variant, *region, "region {} should be in variant", region);
|
assert_eq!(l.variant, *region, "region {} should be in variant", region);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -87,6 +87,7 @@
|
|||||||
|
|
||||||
pub mod aacs;
|
pub mod aacs;
|
||||||
pub(crate) mod clpi;
|
pub(crate) mod clpi;
|
||||||
|
pub mod consts;
|
||||||
pub mod css;
|
pub mod css;
|
||||||
pub mod decrypt;
|
pub mod decrypt;
|
||||||
pub mod diag;
|
pub mod diag;
|
||||||
@@ -167,7 +168,7 @@ pub use identity::DriveId;
|
|||||||
// and registers it once at process start via `register_unlocker`. At
|
// and registers it once at process start via `register_unlocker`. At
|
||||||
// drive-prep the registry is walked in order; the first matching unlocker
|
// drive-prep the registry is walked in order; the first matching unlocker
|
||||||
// runs, else the drive falls through to the host-cert AACS handshake.
|
// 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) ────────────────────────────────────────────────
|
// ─── Decryption (AACS / CSS) ────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
|
|||||||
+38
-22
@@ -161,7 +161,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
|
|||||||
|
|
||||||
// Primary video
|
// Primary video
|
||||||
for _ in 0..n_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);
|
streams.push(entry);
|
||||||
spos = next;
|
spos = next;
|
||||||
} else {
|
} else {
|
||||||
@@ -170,7 +170,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
|
|||||||
}
|
}
|
||||||
// Primary audio
|
// Primary audio
|
||||||
for _ in 0..n_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);
|
streams.push(entry);
|
||||||
spos = next;
|
spos = next;
|
||||||
} else {
|
} else {
|
||||||
@@ -179,7 +179,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
|
|||||||
}
|
}
|
||||||
// PG subtitles
|
// PG subtitles
|
||||||
for _ in 0..n_pg {
|
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);
|
streams.push(entry);
|
||||||
spos = next;
|
spos = next;
|
||||||
} else {
|
} else {
|
||||||
@@ -188,7 +188,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
|
|||||||
}
|
}
|
||||||
// IG (skip but advance)
|
// IG (skip but advance)
|
||||||
for _ in 0..n_ig {
|
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;
|
spos = next;
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
@@ -196,7 +196,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
|
|||||||
}
|
}
|
||||||
// Secondary audio
|
// Secondary audio
|
||||||
for _ in 0..n_sec_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.stream_type = 5;
|
||||||
entry.secondary = true;
|
entry.secondary = true;
|
||||||
streams.push(entry);
|
streams.push(entry);
|
||||||
@@ -213,7 +213,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
|
|||||||
}
|
}
|
||||||
// Secondary video (PiP)
|
// Secondary video (PiP)
|
||||||
for _ in 0..n_sec_video {
|
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.stream_type = 6;
|
||||||
entry.secondary = true;
|
entry.secondary = true;
|
||||||
streams.push(entry);
|
streams.push(entry);
|
||||||
@@ -240,7 +240,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
|
|||||||
}
|
}
|
||||||
// Secondary PG (PiP subtitles) — must consume to keep spos aligned
|
// Secondary PG (PiP subtitles) — must consume to keep spos aligned
|
||||||
for _ in 0..n_pip_pg {
|
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;
|
entry.secondary = true;
|
||||||
streams.push(entry);
|
streams.push(entry);
|
||||||
// Skip reference data: num_refs(1) + reserved(1) + refs + padding
|
// 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
|
// Dolby Vision enhancement layer
|
||||||
for _ in 0..n_dv {
|
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.stream_type = 7;
|
||||||
entry.secondary = true;
|
entry.secondary = true;
|
||||||
streams.push(entry);
|
streams.push(entry);
|
||||||
@@ -317,6 +317,21 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
|
|||||||
|
|
||||||
/// Parse one stream entry from the STN table.
|
/// Parse one stream entry from the STN table.
|
||||||
/// Returns (StreamEntry, next position) or None.
|
/// 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)> {
|
fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(StreamEntry, usize)> {
|
||||||
if pos + 2 > item.len() {
|
if pos + 2 > item.len() {
|
||||||
return None;
|
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
|
// 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.
|
// sub-path stream fell through to PID 0 and were dropped by the mux.
|
||||||
let pid_off = match item[pos + 1] {
|
let pid_off = match item[pos + 1] {
|
||||||
0x01 => 2,
|
STREAM_ENTRY_PLAYITEM_CLIP => 2,
|
||||||
0x02 => 4,
|
STREAM_ENTRY_SUBPATH_SUBCLIP => 4,
|
||||||
0x03 | 0x04 => 3,
|
STREAM_ENTRY_SUBPATH_CLIP | STREAM_ENTRY_SUBPATH_DV_EL => 3,
|
||||||
_ => 0,
|
_ => 0,
|
||||||
};
|
};
|
||||||
// Bound the PID read by the entry's declared end (se_end), not just by
|
// Bound the PID read by the entry's declared end (se_end), not just by
|
||||||
@@ -631,7 +646,7 @@ mod tests {
|
|||||||
) -> Vec<u8> {
|
) -> Vec<u8> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
out.push(3);
|
out.push(3);
|
||||||
out.push(0x01);
|
out.push(STREAM_ENTRY_PLAYITEM_CLIP);
|
||||||
out.extend_from_slice(&pid.to_be_bytes());
|
out.extend_from_slice(&pid.to_be_bytes());
|
||||||
// attrs: coding_type(1) + format_rate(1) + language(3)
|
// attrs: coding_type(1) + format_rate(1) + language(3)
|
||||||
let attrs = vec![
|
let attrs = vec![
|
||||||
@@ -649,7 +664,7 @@ mod tests {
|
|||||||
fn build_stream_entry_pg(pid: u16, coding_type: u8, lang: &[u8; 3]) -> Vec<u8> {
|
fn build_stream_entry_pg(pid: u16, coding_type: u8, lang: &[u8; 3]) -> Vec<u8> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
out.push(3);
|
out.push(3);
|
||||||
out.push(0x01);
|
out.push(STREAM_ENTRY_PLAYITEM_CLIP);
|
||||||
out.extend_from_slice(&pid.to_be_bytes());
|
out.extend_from_slice(&pid.to_be_bytes());
|
||||||
// attrs: coding_type(1) + language(3)
|
// attrs: coding_type(1) + language(3)
|
||||||
let attrs = vec![coding_type, lang[0], lang[1], lang[2]];
|
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() {
|
fn stream_entry_type2_pid_at_offset_4() {
|
||||||
// Build a primary-audio entry with stream_entry type 0x02.
|
// Build a primary-audio entry with stream_entry type 0x02.
|
||||||
// se_len = 5: type(1) + subpath_id(1) + subclip_id(1) + pid(2)
|
// se_len = 5: type(1) + subpath_id(1) + subclip_id(1) + pid(2)
|
||||||
let mut se = Vec::new();
|
let mut se = vec![
|
||||||
se.push(5); // se_len
|
5, // se_len
|
||||||
se.push(0x02); // type: SubPath SubClip
|
STREAM_ENTRY_SUBPATH_SUBCLIP, // type: SubPath SubClip
|
||||||
se.push(0xAA); // subpath_id (must NOT be read as PID hi)
|
0xAA, // subpath_id (must NOT be read as PID hi)
|
||||||
se.push(0xBB); // subclip_id
|
0xBB, // subclip_id
|
||||||
|
];
|
||||||
se.extend_from_slice(&0x1100u16.to_be_bytes()); // real PID at +4
|
se.extend_from_slice(&0x1100u16.to_be_bytes()); // real PID at +4
|
||||||
// stream_attributes: audio coding(1)+fmt(1)+lang(3)
|
// stream_attributes: audio coding(1)+fmt(1)+lang(3)
|
||||||
let attrs = vec![0x83u8, (6 << 4) | 1, b'e', b'n', b'g'];
|
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() {
|
fn stream_entry_type4_pid_at_offset_3() {
|
||||||
let mut se = Vec::new();
|
let mut se = Vec::new();
|
||||||
se.push(4); // se_len: type(1)+subpath_id(1)+pid(2)
|
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.push(0x07); // subpath_id (not PID)
|
||||||
se.extend_from_slice(&0x1015u16.to_be_bytes()); // PID at +3
|
se.extend_from_slice(&0x1015u16.to_be_bytes()); // PID at +3
|
||||||
let attrs = vec![0x24u8, (8 << 4) | 1, 0x12]; // HEVC video attrs
|
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).
|
// Audio-slot entry but coding_type 0x90 (PGS): attrs = 0x90 + lang(3).
|
||||||
let mut se = Vec::new();
|
let mut se = Vec::new();
|
||||||
se.push(3);
|
se.push(3);
|
||||||
se.push(0x01);
|
se.push(STREAM_ENTRY_PLAYITEM_CLIP);
|
||||||
se.extend_from_slice(&0x1100u16.to_be_bytes());
|
se.extend_from_slice(&0x1100u16.to_be_bytes());
|
||||||
let attrs = vec![0x90u8, b'j', b'p', b'n']; // PG layout: coding + lang
|
let attrs = vec![0x90u8, b'j', b'p', b'n']; // PG layout: coding + lang
|
||||||
se.push(attrs.len() as u8);
|
se.push(attrs.len() as u8);
|
||||||
@@ -1214,7 +1230,7 @@ mod tests {
|
|||||||
fn truncated_stream_entry_stops_without_panic() {
|
fn truncated_stream_entry_stops_without_panic() {
|
||||||
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
|
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
|
||||||
// Second "entry" declares se_len=200 but supplies no body → 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(
|
let data = build_mpls(
|
||||||
&[(b"00001", 1, 0, 9000000)],
|
&[(b"00001", 1, 0, 9000000)],
|
||||||
(2, 0, 0, 0, 0, 0, 0, 0), // claims 2 video
|
(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() {
|
fn zero_length_stream_attributes_yields_no_stream() {
|
||||||
let mut se = Vec::new();
|
let mut se = Vec::new();
|
||||||
se.push(3);
|
se.push(3);
|
||||||
se.push(0x01);
|
se.push(STREAM_ENTRY_PLAYITEM_CLIP);
|
||||||
se.extend_from_slice(&0x1011u16.to_be_bytes());
|
se.extend_from_slice(&0x1011u16.to_be_bytes());
|
||||||
se.push(0); // sa_len = 0 → parse_stream_entry returns None
|
se.push(0); // sa_len = 0 → parse_stream_entry returns None
|
||||||
let data = build_mpls(
|
let data = build_mpls(
|
||||||
|
|||||||
@@ -977,9 +977,9 @@ mod tests {
|
|||||||
let mut parser = Mpeg2Parser::new();
|
let mut parser = Mpeg2Parser::new();
|
||||||
|
|
||||||
let mut pic1 = make_picture_header(PICTURE_TYPE_I);
|
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
|
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();
|
let mut stream = pic1.clone();
|
||||||
stream.extend_from_slice(&pic2);
|
stream.extend_from_slice(&pic2);
|
||||||
@@ -1005,7 +1005,7 @@ mod tests {
|
|||||||
let mut au = make_picture_header(PICTURE_TYPE_I);
|
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, SEQ_EXT_CODE, 0x88, 0x00]); // pic coding ext
|
||||||
au.extend_from_slice(&[0x00, 0x00, 0x01, 0x01]); // slice
|
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)));
|
let frames = parse_then_flush(&mut parser, &make_pes(au.clone(), Some(0)));
|
||||||
assert_eq!(frames.len(), 1);
|
assert_eq!(frames.len(), 1);
|
||||||
@@ -1025,12 +1025,12 @@ mod tests {
|
|||||||
let mut parser = Mpeg2Parser::new();
|
let mut parser = Mpeg2Parser::new();
|
||||||
|
|
||||||
let mut pic1 = make_picture_header(PICTURE_TYPE_I);
|
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)));
|
let frames1 = parser.parse(&make_pes(pic1, Some(90000)));
|
||||||
assert!(frames1.is_empty(), "buffered until flush");
|
assert!(frames1.is_empty(), "buffered until flush");
|
||||||
|
|
||||||
let mut pic2 = make_picture_header(2);
|
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)));
|
let frames2 = parser.parse(&make_pes(pic2, Some(180000)));
|
||||||
assert!(frames2.is_empty(), "same GOP — still buffered");
|
assert!(frames2.is_empty(), "same GOP — still buffered");
|
||||||
|
|
||||||
|
|||||||
+307
-2
@@ -14,9 +14,19 @@
|
|||||||
|
|
||||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
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;
|
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
|
/// 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
|
/// 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
|
/// 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 {
|
pub struct TrueHdParser {
|
||||||
buf: Vec<u8>,
|
buf: Vec<u8>,
|
||||||
next_pts_ns: i64,
|
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 {
|
impl Default for TrueHdParser {
|
||||||
@@ -39,6 +55,7 @@ impl TrueHdParser {
|
|||||||
Self {
|
Self {
|
||||||
buf: Vec::with_capacity(32768),
|
buf: Vec::with_capacity(32768),
|
||||||
next_pts_ns: 0,
|
next_pts_ns: 0,
|
||||||
|
au_duration_ns: AU_DURATION_NS,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +202,17 @@ impl CodecParser for TrueHdParser {
|
|||||||
& 0xFFFF_FFFE)
|
& 0xFFFF_FFFE)
|
||||||
== 0xF872_6FBA;
|
== 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 {
|
frames.push(Frame {
|
||||||
coding: None,
|
coding: None,
|
||||||
source: None,
|
source: None,
|
||||||
@@ -194,7 +222,7 @@ impl CodecParser for TrueHdParser {
|
|||||||
duration_ns: None,
|
duration_ns: None,
|
||||||
});
|
});
|
||||||
self.buf.drain(..unit_bytes);
|
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
|
// 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
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -720,4 +835,194 @@ mod tests {
|
|||||||
let f = parser.parse(&make_pes(vec![0x0B, 0x77, 0x00], Some(0)));
|
let f = parser.parse(&make_pes(vec![0x0B, 0x77, 0x00], Some(0)));
|
||||||
assert!(f.is_empty());
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1050,10 +1050,10 @@ mod tests {
|
|||||||
/// codecPrivate, or the A-segment decodes against the wrong entry point.
|
/// codecPrivate, or the A-segment decodes against the wrong entry point.
|
||||||
#[test]
|
#[test]
|
||||||
fn vc1_emits_entry_point_revert_to_first_value() {
|
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_a = vec![0x00, 0x00, 0x01, SC_ENTRY_POINT, 0x11, 0x22];
|
||||||
let ep_b = vec![0x00, 0x00, 0x01, SC_ENTRY_POINT, 0x33, 0x44, 0x55];
|
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();
|
let mut parser = Vc1Parser::new();
|
||||||
|
|
||||||
@@ -1106,10 +1106,10 @@ mod tests {
|
|||||||
/// seek points carry valid decoder state (SMPTE 421M).
|
/// seek points carry valid decoder state (SMPTE 421M).
|
||||||
#[test]
|
#[test]
|
||||||
fn vc1_reasserts_active_headers_at_bare_keyframe() {
|
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_a = vec![0x00, 0x00, 0x01, SC_ENTRY_POINT, 0x11, 0x22];
|
||||||
let ep_b = vec![0x00, 0x00, 0x01, SC_ENTRY_POINT, 0x33, 0x44, 0x55];
|
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();
|
let mut parser = Vc1Parser::new();
|
||||||
|
|
||||||
@@ -1154,10 +1154,10 @@ mod tests {
|
|||||||
/// violating SMPTE 421M. After the fix, assembly is always seq-then-entry.
|
/// violating SMPTE 421M. After the fix, assembly is always seq-then-entry.
|
||||||
#[test]
|
#[test]
|
||||||
fn vc1_keyframe_prefix_order_seq_unchanged_entry_redefined() {
|
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_a = vec![0x00, 0x00, 0x01, SC_ENTRY_POINT, 0x11, 0x22];
|
||||||
let ep_b = vec![0x00, 0x00, 0x01, SC_ENTRY_POINT, 0x33, 0x44, 0x55];
|
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();
|
let mut parser = Vc1Parser::new();
|
||||||
|
|
||||||
|
|||||||
@@ -268,15 +268,15 @@ mod tests {
|
|||||||
/// absorb). ISO 13818-1 packet layout: sync 0x47 at TS offset 0 (BD off 4).
|
/// 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> {
|
fn bdts_pes_packet(pid: u16, payload: &[u8]) -> Vec<u8> {
|
||||||
const SYNC: u8 = 0x47;
|
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];
|
let mut pes = vec![0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x00, 0x00];
|
||||||
pes.extend_from_slice(payload);
|
pes.extend_from_slice(payload);
|
||||||
assert!(pes.len() <= TS_PAYLOAD);
|
assert!(pes.len() <= TS_PAYLOAD_BYTES);
|
||||||
let mut pkt = vec![0u8; 192];
|
let mut pkt = vec![0u8; 192];
|
||||||
pkt[4] = SYNC;
|
pkt[4] = SYNC;
|
||||||
pkt[5] = (((pid >> 8) as u8) & 0x1F) | 0x40; // PUSI
|
pkt[5] = (((pid >> 8) as u8) & 0x1F) | 0x40; // PUSI
|
||||||
pkt[6] = (pid & 0xFF) as u8;
|
pkt[6] = (pid & 0xFF) as u8;
|
||||||
let pad = TS_PAYLOAD - pes.len();
|
let pad = TS_PAYLOAD_BYTES - pes.len();
|
||||||
if pad == 0 {
|
if pad == 0 {
|
||||||
pkt[7] = 0x10; // payload only
|
pkt[7] = 0x10; // payload only
|
||||||
pkt[8..8 + pes.len()].copy_from_slice(&pes);
|
pkt[8..8 + pes.len()].copy_from_slice(&pes);
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
use std::io::{self, Write};
|
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
|
/// Header is 4 bytes, leaving 184 bytes for the adaptation field area
|
||||||
/// plus payload. With a 1-byte `adaptation_field_length` prefix the
|
/// plus payload. With a 1-byte `adaptation_field_length` prefix the
|
||||||
/// field body + stuffing can be at most 183 bytes.
|
/// 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 SYNC_BYTE: u8 = 0x47;
|
||||||
const STUFF_BYTE: u8 = 0xFF;
|
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
|
/// 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.
|
/// called the caller is responsible for filling the packet exactly.
|
||||||
pub(super) struct Packet {
|
pub(super) struct Packet {
|
||||||
buf: [u8; TS_PACKET_SIZE],
|
buf: [u8; TS_PACKET_BYTES],
|
||||||
len: usize,
|
len: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Packet {
|
impl Packet {
|
||||||
pub(super) fn new() -> Self {
|
pub(super) fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
buf: [0u8; TS_PACKET_SIZE],
|
buf: [0u8; TS_PACKET_BYTES],
|
||||||
len: 0,
|
len: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,14 +36,14 @@ impl Packet {
|
|||||||
/// never reached by the sole caller (mod.rs sizes every field to sum
|
/// never reached by the sole caller (mod.rs sizes every field to sum
|
||||||
/// to 188); the bound prevents a future caller from corrupting memory.
|
/// to 188); the bound prevents a future caller from corrupting memory.
|
||||||
fn push(&mut self, b: u8) {
|
fn push(&mut self, b: u8) {
|
||||||
if self.len < TS_PACKET_SIZE {
|
if self.len < TS_PACKET_BYTES {
|
||||||
self.buf[self.len] = b;
|
self.buf[self.len] = b;
|
||||||
self.len += 1;
|
self.len += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extend(&mut self, bytes: &[u8]) {
|
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.buf[self.len..self.len + n].copy_from_slice(&bytes[..n]);
|
||||||
self.len += n;
|
self.len += n;
|
||||||
}
|
}
|
||||||
@@ -111,7 +111,7 @@ impl Packet {
|
|||||||
/// packet past 188 bytes — overflow is a muxer invariant break, not
|
/// packet past 188 bytes — overflow is a muxer invariant break, not
|
||||||
/// something to silently emit.
|
/// something to silently emit.
|
||||||
pub(super) fn append_payload(&mut self, payload: &[u8]) -> io::Result<()> {
|
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());
|
return Err(Error::M2tsPacketMalformed.into());
|
||||||
}
|
}
|
||||||
self.extend(payload);
|
self.extend(payload);
|
||||||
@@ -123,7 +123,7 @@ impl Packet {
|
|||||||
/// For PSI packets only — payload-carrying packets reserve room for
|
/// For PSI packets only — payload-carrying packets reserve room for
|
||||||
/// stuffing via `append_adaptation`.
|
/// stuffing via `append_adaptation`.
|
||||||
pub(super) fn pad_to_188(&mut self) {
|
pub(super) fn pad_to_188(&mut self) {
|
||||||
while self.len < TS_PACKET_SIZE {
|
while self.len < TS_PACKET_BYTES {
|
||||||
self.push(STUFF_BYTE);
|
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
|
// Hard check, not a debug_assert: a non-188-byte packet would
|
||||||
// corrupt the transport stream, so refuse to write it in any
|
// corrupt the transport stream, so refuse to write it in any
|
||||||
// build rather than emitting a short/long packet silently.
|
// 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());
|
return Err(Error::M2tsPacketMalformed.into());
|
||||||
}
|
}
|
||||||
self.inner.write_all(bytes)
|
self.inner.write_all(bytes)
|
||||||
|
|||||||
+11
-12
@@ -32,8 +32,7 @@ const SUPPORTED_VERSION: u8 = 1;
|
|||||||
/// Index of the version byte within [`MAGIC`].
|
/// Index of the version byte within [`MAGIC`].
|
||||||
const VERSION_BYTE: usize = 5;
|
const VERSION_BYTE: usize = 5;
|
||||||
|
|
||||||
/// BD-TS packet size (header must be padded to this boundary).
|
use crate::consts::BD_SOURCE_PACKET_BYTES;
|
||||||
const PACKET_SIZE: usize = 192;
|
|
||||||
|
|
||||||
/// Metadata embedded in an m2ts file.
|
/// Metadata embedded in an m2ts file.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[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.
|
// 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 json_len = u32::try_from(json.len()).map_err(|_| crate::error::Error::NoMetadata)?;
|
||||||
let raw_len = 8 + 4 + json.len(); // magic + len + json
|
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;
|
let padding = padded_len - raw_len;
|
||||||
|
|
||||||
w.write_all(&MAGIC)?;
|
w.write_all(&MAGIC)?;
|
||||||
w.write_all(&json_len.to_be_bytes())?;
|
w.write_all(&json_len.to_be_bytes())?;
|
||||||
w.write_all(&json)?;
|
w.write_all(&json)?;
|
||||||
if padding > 0 {
|
if padding > 0 {
|
||||||
// Padding is at most PACKET_SIZE-1 bytes — stack buffer, no heap alloc.
|
// Padding is at most BD_SOURCE_PACKET_BYTES-1 bytes — stack buffer, no heap alloc.
|
||||||
let pad = [0u8; PACKET_SIZE];
|
let pad = [0u8; BD_SOURCE_PACKET_BYTES];
|
||||||
w.write_all(&pad[..padding])?;
|
w.write_all(&pad[..padding])?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -375,13 +374,13 @@ pub fn read_header(r: &mut impl Read) -> io::Result<Option<M2tsMeta>> {
|
|||||||
let meta: M2tsMeta =
|
let meta: M2tsMeta =
|
||||||
serde_json::from_slice(&json_buf).map_err(|_| crate::error::Error::NoMetadata)?;
|
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).
|
// a stack buffer, no heap allocation).
|
||||||
let raw_len = 8 + 4 + json_len;
|
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;
|
let padding = padded_len - raw_len;
|
||||||
if padding > 0 {
|
if padding > 0 {
|
||||||
let mut skip = [0u8; PACKET_SIZE];
|
let mut skip = [0u8; BD_SOURCE_PACKET_BYTES];
|
||||||
r.read_exact(&mut skip[..padding])?;
|
r.read_exact(&mut skip[..padding])?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,7 +511,7 @@ mod tests {
|
|||||||
assert_eq!(back.streams.len(), 1);
|
assert_eq!(back.streams.len(), 1);
|
||||||
// Header is padded to a 192-byte boundary; the cursor must land
|
// Header is padded to a 192-byte boundary; the cursor must land
|
||||||
// exactly there so the following BD-TS data stays aligned.
|
// 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]
|
#[test]
|
||||||
@@ -643,7 +642,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn write_header_pads_to_192_byte_boundary() {
|
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.
|
// (192). Test a range of JSON sizes by varying stream count.
|
||||||
for n_streams in 0..6 {
|
for n_streams in 0..6 {
|
||||||
let mut t = DiscTitle::empty();
|
let mut t = DiscTitle::empty();
|
||||||
@@ -665,7 +664,7 @@ mod tests {
|
|||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
write_header(&mut buf, &meta).unwrap();
|
write_header(&mut buf, &meta).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
buf.len() % PACKET_SIZE,
|
buf.len() % BD_SOURCE_PACKET_BYTES,
|
||||||
0,
|
0,
|
||||||
"header for {n_streams} streams (len {}) not 192-aligned",
|
"header for {n_streams} streams (len {}) not 192-aligned",
|
||||||
buf.len()
|
buf.len()
|
||||||
@@ -766,7 +765,7 @@ mod tests {
|
|||||||
let mut cur = io::Cursor::new(buf);
|
let mut cur = io::Cursor::new(buf);
|
||||||
read_header(&mut cur).unwrap().expect("header present");
|
read_header(&mut cur).unwrap().expect("header present");
|
||||||
assert_eq!(cur.position() as usize, header_len);
|
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];
|
let mut next = [0u8; 1];
|
||||||
use std::io::Read as _;
|
use std::io::Read as _;
|
||||||
cur.read_exact(&mut next).unwrap();
|
cur.read_exact(&mut next).unwrap();
|
||||||
|
|||||||
+1
-1
@@ -4113,7 +4113,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Read the body bytes of a direct TrackEntry child element by ID.
|
/// 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 (te_start, te_size) = first_track_entry(data);
|
||||||
let (_, body_start, body_size) = master_children(data, te_start, te_size)
|
let (_, body_start, body_size) = master_children(data, te_start, te_size)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -729,8 +729,16 @@ fn parse_track(
|
|||||||
};
|
};
|
||||||
let res = Resolution::from_height(ph);
|
let res = Resolution::from_height(ph);
|
||||||
let chs = AudioChannels::from_count(ch);
|
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
|
SampleRate::S96
|
||||||
|
} else if sr >= 88200.0 {
|
||||||
|
SampleRate::S88_2
|
||||||
|
} else if (44100.0..48000.0).contains(&sr) {
|
||||||
|
SampleRate::S44_1
|
||||||
} else {
|
} else {
|
||||||
SampleRate::S48
|
SampleRate::S48
|
||||||
};
|
};
|
||||||
|
|||||||
+44
-42
@@ -6,11 +6,9 @@
|
|||||||
//!
|
//!
|
||||||
//! This demuxer extracts PES packets from selected PIDs, with PTS/DTS timestamps.
|
//! This demuxer extracts PES packets from selected PIDs, with PTS/DTS timestamps.
|
||||||
|
|
||||||
/// BD transport stream packet size (4-byte extra header + 188-byte TS).
|
use crate::consts::BD_SOURCE_PACKET_BYTES;
|
||||||
const BD_TS_PACKET_SIZE: usize = 192;
|
|
||||||
|
|
||||||
/// Standard TS packet size.
|
use crate::consts::TS_PACKET_BYTES;
|
||||||
const TS_PACKET_SIZE: usize = 188;
|
|
||||||
|
|
||||||
/// TS sync byte.
|
/// TS sync byte.
|
||||||
const SYNC_BYTE: u8 = 0x47;
|
const SYNC_BYTE: u8 = 0x47;
|
||||||
@@ -259,13 +257,13 @@ impl TsDemuxer {
|
|||||||
// call, complete it from the head of `data` without touching
|
// call, complete it from the head of `data` without touching
|
||||||
// the rest of `data`.
|
// the rest of `data`.
|
||||||
if !self.remainder.is_empty() {
|
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 {
|
if data.len() < need {
|
||||||
// Still not a full packet — accumulate and wait.
|
// Still not a full packet — accumulate and wait.
|
||||||
self.remainder.extend_from_slice(data);
|
self.remainder.extend_from_slice(data);
|
||||||
return completed;
|
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(&self.remainder);
|
||||||
boundary[self.remainder.len()..].copy_from_slice(&data[..need]);
|
boundary[self.remainder.len()..].copy_from_slice(&data[..need]);
|
||||||
self.remainder.clear();
|
self.remainder.clear();
|
||||||
@@ -279,10 +277,10 @@ impl TsDemuxer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Aligned-packets fast path — reads directly out of `data`.
|
// Aligned-packets fast path — reads directly out of `data`.
|
||||||
while offset + BD_TS_PACKET_SIZE <= data.len() {
|
while offset + BD_SOURCE_PACKET_BYTES <= data.len() {
|
||||||
let packet = &data[offset..offset + BD_TS_PACKET_SIZE];
|
let packet = &data[offset..offset + BD_SOURCE_PACKET_BYTES];
|
||||||
let src = self.pkt_source(offset);
|
let src = self.pkt_source(offset);
|
||||||
offset += BD_TS_PACKET_SIZE;
|
offset += BD_SOURCE_PACKET_BYTES;
|
||||||
self.process_packet(packet, src, &mut completed);
|
self.process_packet(packet, src, &mut completed);
|
||||||
}
|
}
|
||||||
// Advance the running base past every byte consumed this feed so the
|
// 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).
|
// prevent unbounded growth on a desynchronised stream).
|
||||||
if offset < data.len() {
|
if offset < data.len() {
|
||||||
let leftover = &data[offset..];
|
let leftover = &data[offset..];
|
||||||
if leftover.len() < BD_TS_PACKET_SIZE {
|
if leftover.len() < BD_SOURCE_PACKET_BYTES {
|
||||||
self.remainder.extend_from_slice(leftover);
|
self.remainder.extend_from_slice(leftover);
|
||||||
} else {
|
} else {
|
||||||
self.remainder.clear();
|
self.remainder.clear();
|
||||||
@@ -353,7 +351,7 @@ impl TsDemuxer {
|
|||||||
4
|
4
|
||||||
};
|
};
|
||||||
|
|
||||||
if payload_start >= TS_PACKET_SIZE {
|
if payload_start >= TS_PACKET_BYTES {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// adaptation == 0x02 → AF only, no payload.
|
// 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) {
|
if data.get(offset + 4) != Some(&SYNC_BYTE) {
|
||||||
return false;
|
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,
|
Some(&b) => b == SYNC_BYTE,
|
||||||
None => true, // last packet in the buffer — no follower to corroborate
|
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
|
/// 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
|
/// 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
|
/// 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> {
|
fn psi_payload_base(pkt: &[u8]) -> Option<usize> {
|
||||||
// TS header is pkt[4..]; byte pkt[7] holds AFC in bits 5:4.
|
// TS header is pkt[4..]; byte pkt[7] holds AFC in bits 5:4.
|
||||||
let afc = (pkt[7] >> 4) & 0x03;
|
let afc = (pkt[7] >> 4) & 0x03;
|
||||||
@@ -555,7 +553,7 @@ fn psi_payload_base(pkt: &[u8]) -> Option<usize> {
|
|||||||
// payload starts after it.
|
// payload starts after it.
|
||||||
let af_len = pkt[8] as usize;
|
let af_len = pkt[8] as usize;
|
||||||
let base = 9 + af_len; // 4 + 4 + 1(length byte) + af_len
|
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)
|
Some(base)
|
||||||
} else {
|
} else {
|
||||||
None // AF overruns the packet
|
None // AF overruns the packet
|
||||||
@@ -587,7 +585,7 @@ fn psi_payload_base(pkt: &[u8]) -> Option<usize> {
|
|||||||
/// matching section is found.
|
/// matching section is found.
|
||||||
fn collect_psi_section(data: &[u8], target_pid: u16, table_id: u8) -> Option<Vec<u8>> {
|
fn collect_psi_section(data: &[u8], target_pid: u16, table_id: u8) -> Option<Vec<u8>> {
|
||||||
let mut offset = 0;
|
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) {
|
if !is_resync_point(data, offset) {
|
||||||
offset += 1;
|
offset += 1;
|
||||||
continue;
|
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
|
// Locate the payload (pointer_field) accounting for any
|
||||||
// adaptation field. A packet with no payload (AF only) or an
|
// adaptation field. A packet with no payload (AF only) or an
|
||||||
// AF that overruns the packet is skipped.
|
// 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 {
|
else {
|
||||||
offset += BD_TS_PACKET_SIZE;
|
offset += BD_SOURCE_PACKET_BYTES;
|
||||||
continue;
|
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 is the FIRST payload byte; the section starts
|
||||||
// pointer_field bytes after it. Bound the start to within
|
// pointer_field bytes after it. Bound the start to within
|
||||||
// THIS packet's payload — a pointer that runs into the next
|
// 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 pointer = payload[0] as usize;
|
||||||
let sec_start = 1 + pointer;
|
let sec_start = 1 + pointer;
|
||||||
if sec_start + 3 > payload.len() || payload[sec_start] != table_id {
|
if sec_start + 3 > payload.len() || payload[sec_start] != table_id {
|
||||||
offset += BD_TS_PACKET_SIZE;
|
offset += BD_SOURCE_PACKET_BYTES;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let section_len =
|
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
|
// dropped/duplicated packet → the assembled section is corrupt, so
|
||||||
// abandon it rather than splicing in misordered payload.
|
// abandon it rather than splicing in misordered payload.
|
||||||
let mut expected_cc = ((data[offset + 7] & 0x0F) + 1) & 0x0F;
|
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;
|
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
|
// Require a corroborated resync point (this sync byte plus the
|
||||||
// follower one packet ahead) before trusting the header. A
|
// follower one packet ahead) before trusting the header. A
|
||||||
// stray 0x47 in corrupt payload would otherwise misread the CC
|
// 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;
|
expected_cc = (cc + 1) & 0x0F;
|
||||||
// Continuation packets may also carry an adaptation
|
// Continuation packets may also carry an adaptation
|
||||||
// field; compute their payload base the same way.
|
// field; compute their payload base the same way.
|
||||||
if let Some(cbase) = psi_payload_base(&data[scan..scan + BD_TS_PACKET_SIZE]) {
|
if let Some(cbase) =
|
||||||
section.extend_from_slice(&data[scan + cbase..scan + BD_TS_PACKET_SIZE]);
|
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 {
|
if desync {
|
||||||
// Restart PSI assembly from the next packet after this PUSI;
|
// Restart PSI assembly from the next packet after this PUSI;
|
||||||
// a later clean copy of the section may still appear.
|
// a later clean copy of the section may still appear.
|
||||||
offset += BD_TS_PACKET_SIZE;
|
offset += BD_SOURCE_PACKET_BYTES;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if section.len() >= total {
|
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.
|
// Incomplete section (truncated input) — stop looking.
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
offset += BD_TS_PACKET_SIZE;
|
offset += BD_SOURCE_PACKET_BYTES;
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -833,7 +835,7 @@ mod tests {
|
|||||||
/// continuity_counter, carrying `payload` (truncated/padded to 184 bytes,
|
/// continuity_counter, carrying `payload` (truncated/padded to 184 bytes,
|
||||||
/// payload-only adaptation).
|
/// payload-only adaptation).
|
||||||
fn ts_payload_packet(pid: u16, pusi: bool, cc: u8, payload: &[u8]) -> Vec<u8> {
|
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[4] = SYNC_BYTE;
|
||||||
pkt[5] = ((pid >> 8) as u8) & 0x1F;
|
pkt[5] = ((pid >> 8) as u8) & 0x1F;
|
||||||
if pusi {
|
if pusi {
|
||||||
@@ -934,7 +936,7 @@ mod tests {
|
|||||||
/// Wrap a 188-byte TS packet body in a 192-byte BD-TS packet
|
/// Wrap a 188-byte TS packet body in a 192-byte BD-TS packet
|
||||||
/// (4-byte timecode prefix the scanner skips).
|
/// (4-byte timecode prefix the scanner skips).
|
||||||
fn bdts_packet(body: [u8; 184], pid: u16, pusi: bool) -> Vec<u8> {
|
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.
|
// 4-byte timecode prefix is ignored; leave zero.
|
||||||
pkt[4] = SYNC_BYTE;
|
pkt[4] = SYNC_BYTE;
|
||||||
pkt[5] = ((pid >> 8) as u8) & 0x1F;
|
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`
|
/// Build a 192-byte BD-TS data packet on `pid` carrying `payload`
|
||||||
/// (payload-only adaptation, truncated/padded to fit one packet).
|
/// (payload-only adaptation, truncated/padded to fit one packet).
|
||||||
fn data_packet(pid: u16, pusi: bool, payload: &[u8]) -> Vec<u8> {
|
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[4] = SYNC_BYTE;
|
||||||
pkt[5] = ((pid >> 8) as u8) & 0x1F;
|
pkt[5] = ((pid >> 8) as u8) & 0x1F;
|
||||||
if pusi {
|
if pusi {
|
||||||
@@ -1018,7 +1020,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
pkt[6] = (pid & 0xFF) as u8;
|
pkt[6] = (pid & 0xFF) as u8;
|
||||||
pkt[7] = 0x10; // payload only, no adaptation field
|
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);
|
let n = payload.len().min(room);
|
||||||
pkt[8..8 + n].copy_from_slice(&payload[..n]);
|
pkt[8..8 + n].copy_from_slice(&payload[..n]);
|
||||||
pkt
|
pkt
|
||||||
@@ -1029,7 +1031,7 @@ mod tests {
|
|||||||
/// payload base computation in scan_streams.
|
/// payload base computation in scan_streams.
|
||||||
fn pmt_packet_with_af(pmt_pid: u16, entries: &[(u8, u16)]) -> Vec<u8> {
|
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 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[4] = SYNC_BYTE;
|
||||||
pkt[5] = (((pmt_pid >> 8) as u8) & 0x1F) | 0x40; // PUSI set
|
pkt[5] = (((pmt_pid >> 8) as u8) & 0x1F) | 0x40; // PUSI set
|
||||||
pkt[6] = (pmt_pid & 0xFF) as u8;
|
pkt[6] = (pmt_pid & 0xFF) as u8;
|
||||||
@@ -1039,7 +1041,7 @@ mod tests {
|
|||||||
pkt[10] = 0xFF; // stuffing
|
pkt[10] = 0xFF; // stuffing
|
||||||
// Payload (PSI) begins at 4 + 4 + 1 + af_len = 11.
|
// Payload (PSI) begins at 4 + 4 + 1 + af_len = 11.
|
||||||
let payload_off = 4 + 4 + 1 + af_len as usize;
|
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
|
body[0] = 0x00; // pointer_field
|
||||||
let s = 1;
|
let s = 1;
|
||||||
body[s] = 0x02; // table_id = PMT
|
body[s] = 0x02; // table_id = PMT
|
||||||
@@ -1270,9 +1272,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let mut pmt = pmt_two_packets(pmt_pid, &entries);
|
let mut pmt = pmt_two_packets(pmt_pid, &entries);
|
||||||
// Corrupt the continuation packet's CC. pmt is exactly two BD-TS
|
// 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).
|
// 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;
|
pmt[cc_off] = (pmt[cc_off] & 0xF0) | 0x05;
|
||||||
|
|
||||||
let mut data = pat_packet(pmt_pid);
|
let mut data = pat_packet(pmt_pid);
|
||||||
@@ -1296,16 +1298,16 @@ mod tests {
|
|||||||
/// bytes the demuxer must produce, unlike `data_packet` which leaves
|
/// bytes the demuxer must produce, unlike `data_packet` which leaves
|
||||||
/// zero padding that a length-0 (unbounded) PES would absorb as ES.
|
/// zero padding that a length-0 (unbounded) PES would absorb as ES.
|
||||||
fn es_packet_exact(pid: u16, pusi: bool, payload: &[u8]) -> Vec<u8> {
|
fn es_packet_exact(pid: u16, pusi: bool, payload: &[u8]) -> Vec<u8> {
|
||||||
const TS_PAYLOAD: usize = 184;
|
use crate::consts::TS_PAYLOAD_BYTES;
|
||||||
assert!(payload.len() <= TS_PAYLOAD);
|
assert!(payload.len() <= TS_PAYLOAD_BYTES);
|
||||||
let mut pkt = vec![0u8; BD_TS_PACKET_SIZE];
|
let mut pkt = vec![0u8; BD_SOURCE_PACKET_BYTES];
|
||||||
pkt[4] = SYNC_BYTE;
|
pkt[4] = SYNC_BYTE;
|
||||||
pkt[5] = ((pid >> 8) as u8) & 0x1F;
|
pkt[5] = ((pid >> 8) as u8) & 0x1F;
|
||||||
if pusi {
|
if pusi {
|
||||||
pkt[5] |= 0x40;
|
pkt[5] |= 0x40;
|
||||||
}
|
}
|
||||||
pkt[6] = (pid & 0xFF) as u8;
|
pkt[6] = (pid & 0xFF) as u8;
|
||||||
let pad = TS_PAYLOAD - payload.len();
|
let pad = TS_PAYLOAD_BYTES - payload.len();
|
||||||
if pad == 0 {
|
if pad == 0 {
|
||||||
pkt[7] = 0x10; // payload only
|
pkt[7] = 0x10; // payload only
|
||||||
pkt[8..8 + payload.len()].copy_from_slice(payload);
|
pkt[8..8 + payload.len()].copy_from_slice(payload);
|
||||||
@@ -1550,7 +1552,7 @@ mod tests {
|
|||||||
demux.feed(&es_packet_exact(pid, true, &start));
|
demux.feed(&es_packet_exact(pid, true, &start));
|
||||||
// …then an AF-only continuation packet whose "payload" bytes must
|
// …then an AF-only continuation packet whose "payload" bytes must
|
||||||
// be discarded.
|
// 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[4] = SYNC_BYTE;
|
||||||
afonly[5] = ((pid >> 8) as u8) & 0x1F; // no PUSI
|
afonly[5] = ((pid >> 8) as u8) & 0x1F; // no PUSI
|
||||||
afonly[6] = (pid & 0xFF) as u8;
|
afonly[6] = (pid & 0xFF) as u8;
|
||||||
@@ -1576,7 +1578,7 @@ mod tests {
|
|||||||
// the TS packet. The AF bytes must NOT appear in the ES.
|
// the TS packet. The AF bytes must NOT appear in the ES.
|
||||||
let pid = 0x1011;
|
let pid = 0x1011;
|
||||||
let mut demux = TsDemuxer::new(&[pid]);
|
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[4] = SYNC_BYTE;
|
||||||
pkt[5] = (((pid >> 8) as u8) & 0x1F) | 0x40; // PUSI
|
pkt[5] = (((pid >> 8) as u8) & 0x1F) | 0x40; // PUSI
|
||||||
pkt[6] = (pid & 0xFF) as u8;
|
pkt[6] = (pid & 0xFF) as u8;
|
||||||
@@ -1616,7 +1618,7 @@ mod tests {
|
|||||||
// A larger value runs past the packet and must be discarded.
|
// A larger value runs past the packet and must be discarded.
|
||||||
let pid = 0x1011;
|
let pid = 0x1011;
|
||||||
let mut demux = TsDemuxer::new(&[pid]);
|
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[4] = SYNC_BYTE;
|
||||||
pkt[5] = (((pid >> 8) as u8) & 0x1F) | 0x40;
|
pkt[5] = (((pid >> 8) as u8) & 0x1F) | 0x40;
|
||||||
pkt[6] = (pid & 0xFF) as u8;
|
pkt[6] = (pid & 0xFF) as u8;
|
||||||
@@ -1808,7 +1810,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn scan_streams_no_pat_returns_none() {
|
fn scan_streams_no_pat_returns_none() {
|
||||||
// Without a PAT (table_id 0x00 on PID 0) there is no program to find.
|
// 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());
|
assert!(scan_streams(&data).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-13
@@ -8,7 +8,7 @@ use super::hevc::{hvcc_to_annex_b, length_prefixed_to_annex_b};
|
|||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
|
|
||||||
const SYNC_BYTE: u8 = 0x47;
|
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
|
/// PID range treated as video (HEVC, triggers Annex-B conversion + RAI
|
||||||
/// on keyframes). Both `write_frame` and `build_pes_header` consult this
|
/// 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;
|
let remaining = pes_len - offset;
|
||||||
|
|
||||||
// Invariant: TP_extra(4) + TS_header(4) + AF(af_bytes) + payload(payload_len) = 192,
|
// 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.
|
// RAI on first packet of a keyframe video PES requires AF with flags=0x40.
|
||||||
let want_rai = first && keyframe && is_video;
|
let want_rai = first && keyframe && is_video;
|
||||||
|
|
||||||
// Pick payload_len and af_bytes per case.
|
// Pick payload_len and af_bytes per case.
|
||||||
let (af_bytes, payload_len): (usize, usize) = if want_rai {
|
let (af_bytes, payload_len): (usize, usize) = if want_rai {
|
||||||
// Minimum AF = 2 bytes (length=1, flags=0x40). Payload caps at 182.
|
// 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);
|
let p = remaining.min(max_payload);
|
||||||
(TS_PAYLOAD - p, p)
|
(TS_PAYLOAD_BYTES - p, p)
|
||||||
} else if remaining >= TS_PAYLOAD {
|
} else if remaining >= TS_PAYLOAD_BYTES {
|
||||||
(0, TS_PAYLOAD) // no AF, full payload
|
(0, TS_PAYLOAD_BYTES) // no AF, full payload
|
||||||
} else {
|
} else {
|
||||||
// Stuffing-only AF, payload = remaining.
|
// Stuffing-only AF, payload = remaining.
|
||||||
(TS_PAYLOAD - remaining, remaining)
|
(TS_PAYLOAD_BYTES - remaining, remaining)
|
||||||
};
|
};
|
||||||
|
|
||||||
// TP_extra_header (4 bytes — arrival time, set to 0)
|
// 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 {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
const BD_PACKET_SIZE: usize = 192;
|
use crate::consts::BD_SOURCE_PACKET_BYTES;
|
||||||
const VIDEO_PID: u16 = 0x1011;
|
const VIDEO_PID: u16 = 0x1011;
|
||||||
|
|
||||||
/// Parsed BD-TS packet (192 bytes total: 4 TP_extra + 4 TS header + 184 body).
|
/// 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.
|
/// Walk 192-byte BD-TS packets.
|
||||||
fn parse_bd_ts(buf: &[u8]) -> Vec<TsPacket> {
|
fn parse_bd_ts(buf: &[u8]) -> Vec<TsPacket> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for chunk in buf.chunks(BD_PACKET_SIZE) {
|
for chunk in buf.chunks(BD_SOURCE_PACKET_BYTES) {
|
||||||
if chunk.len() != BD_PACKET_SIZE {
|
if chunk.len() != BD_SOURCE_PACKET_BYTES {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Skip TP_extra_header (4 bytes), parse TS header.
|
// Skip TP_extra_header (4 bytes), parse TS header.
|
||||||
@@ -762,9 +762,13 @@ mod tests {
|
|||||||
mux.finish().unwrap();
|
mux.finish().unwrap();
|
||||||
}
|
}
|
||||||
assert!(!sink.is_empty());
|
assert!(!sink.is_empty());
|
||||||
assert_eq!(sink.len() % BD_PACKET_SIZE, 0, "output must be 192-aligned");
|
assert_eq!(
|
||||||
for chunk in sink.chunks(BD_PACKET_SIZE) {
|
sink.len() % BD_SOURCE_PACKET_BYTES,
|
||||||
assert_eq!(chunk.len(), BD_PACKET_SIZE);
|
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");
|
assert_eq!(chunk[4], SYNC_BYTE, "TS sync byte at offset 4");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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
|
/// Bytes per `src.sector` unit (`docs/FVI_FORMAT.md` §6.2, §9). The highway's
|
||||||
/// [`SourcePos`] counts 2048-byte logical sectors.
|
/// [`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) ────────────────────────────────
|
// ── Logical model (serialization-independent) ────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
+18
-18
@@ -244,6 +244,22 @@ fn cstr_to_str(bytes: &[u8]) -> &str {
|
|||||||
std::str::from_utf8(&bytes[..end]).unwrap_or("")
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::K_MAX_CDB_SIZE;
|
use super::K_MAX_CDB_SIZE;
|
||||||
@@ -256,7 +272,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn oversized_cdb_returns_invalid_cdb_length() {
|
fn oversized_cdb_returns_invalid_cdb_length() {
|
||||||
// Build a CDB one byte over the limit.
|
// 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
|
// Replicate the guard logic from MacScsiTransport::execute so
|
||||||
// this test runs on Linux CI as well (no IOKit present there).
|
// this test runs on Linux CI as well (no IOKit present there).
|
||||||
let result: Result<(), Error> = if long_cdb.len() > K_MAX_CDB_SIZE {
|
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.
|
/// A CDB exactly at the limit must not trigger the guard.
|
||||||
#[test]
|
#[test]
|
||||||
fn max_length_cdb_does_not_trigger_guard() {
|
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;
|
let triggered = cdb.len() > K_MAX_CDB_SIZE;
|
||||||
assert!(
|
assert!(
|
||||||
!triggered,
|
!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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-1
@@ -992,7 +992,8 @@ mod scsi_sense_predicate_tests {
|
|||||||
// Each is_* predicate matches exactly its one key and no other.
|
// Each is_* predicate matches exactly its one key and no other.
|
||||||
// Catches a copy-paste bug where e.g. is_not_ready compared the
|
// Catches a copy-paste bug where e.g. is_not_ready compared the
|
||||||
// wrong constant.
|
// 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_MEDIUM_ERROR, ScsiSense::is_medium_error),
|
||||||
(SENSE_KEY_HARDWARE_ERROR, ScsiSense::is_hardware_error),
|
(SENSE_KEY_HARDWARE_ERROR, ScsiSense::is_hardware_error),
|
||||||
(SENSE_KEY_NOT_READY, ScsiSense::is_not_ready),
|
(SENSE_KEY_NOT_READY, ScsiSense::is_not_ready),
|
||||||
@@ -1126,6 +1127,7 @@ mod inquiry_tests {
|
|||||||
//! - vendor identification: bytes 8..16 (8 ASCII chars)
|
//! - vendor identification: bytes 8..16 (8 ASCII chars)
|
||||||
//! - product identification: bytes 16..32 (16 ASCII chars)
|
//! - product identification: bytes 16..32 (16 ASCII chars)
|
||||||
//! - product revision level: bytes 32..36 (4 ASCII chars)
|
//! - product revision level: bytes 32..36 (4 ASCII chars)
|
||||||
|
//!
|
||||||
//! Fields are space-padded ASCII; the parser trims surrounding
|
//! Fields are space-padded ASCII; the parser trims surrounding
|
||||||
//! whitespace.
|
//! whitespace.
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+4
-7
@@ -150,14 +150,11 @@ mod tests {
|
|||||||
speeds: Arc<Mutex<Vec<u16>>>,
|
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 {
|
impl Spy {
|
||||||
fn new(
|
fn new(capacity: u32) -> SpyHarness {
|
||||||
capacity: u32,
|
|
||||||
) -> (
|
|
||||||
Self,
|
|
||||||
Arc<Mutex<Vec<(u32, u16, bool)>>>,
|
|
||||||
Arc<Mutex<Vec<u16>>>,
|
|
||||||
) {
|
|
||||||
let reads = Arc::new(Mutex::new(Vec::new()));
|
let reads = Arc::new(Mutex::new(Vec::new()));
|
||||||
let speeds = Arc::new(Mutex::new(Vec::new()));
|
let speeds = Arc::new(Mutex::new(Vec::new()));
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -667,7 +667,7 @@ mod tests {
|
|||||||
assert!(err.is_err(), "zero batch_sectors must be rejected");
|
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
|
/// recycle pool seeds PREFETCH_CHANNEL_DEPTH+1 (3) buffers; before
|
||||||
/// the fix the direct path dropped each drained buffer, so the 4th
|
/// the fix the direct path dropped each drained buffer, so the 4th
|
||||||
/// call deadlocked. Watchdog-guarded.
|
/// call deadlocked. Watchdog-guarded.
|
||||||
|
|||||||
+134
-108
@@ -12,18 +12,46 @@
|
|||||||
//! drive is left untouched and the caller falls back to the standard
|
//! drive is left untouched and the caller falls back to the standard
|
||||||
//! host-certificate AACS handshake (the "OEM route").
|
//! host-certificate AACS handshake (the "OEM route").
|
||||||
|
|
||||||
|
use crate::aacs::Vid;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::identity::DriveId;
|
use crate::identity::DriveId;
|
||||||
use crate::scsi::ScsiTransport;
|
use crate::scsi::ScsiTransport;
|
||||||
use std::sync::RwLock;
|
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.
|
/// A pluggable drive-capability provider.
|
||||||
///
|
///
|
||||||
/// Unlockers are optional drive-capability providers. libfreemkv's AACS
|
/// Unlockers are optional drive-capability providers. libfreemkv's AACS
|
||||||
/// layer is the always-present baseline; it uses an unlocker's capabilities
|
/// layer is the always-present baseline; it uses an unlocker's capabilities
|
||||||
/// when one matches, and does the full cert handshake when none do.
|
/// when one matches, and does the in-tree cert handshake (the
|
||||||
/// Implement only the capabilities your drive supports — the rest default
|
/// `AacsCertUnlocker` peer) when none do.
|
||||||
/// to no-op.
|
|
||||||
///
|
///
|
||||||
/// Implementors own everything about *how* a particular drive family is
|
/// Implementors own everything about *how* a particular drive family is
|
||||||
/// driven: firmware upload, vendor CDBs, variant logic. libfreemkv only
|
/// 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.
|
/// True if this unlocker handles the given drive.
|
||||||
fn matches(&self, id: &DriveId) -> bool;
|
fn matches(&self, id: &DriveId) -> bool;
|
||||||
|
|
||||||
/// Put the drive into extended-access mode (firmware/bootloader/whatever THIS
|
/// Put the drive into extended-access mode (firmware/bootloader/whatever
|
||||||
/// unlocker needs). The one required capability.
|
/// THIS unlocker needs) AND return the disc's Volume ID — folding the old
|
||||||
fn unlock_drive(&self, scsi: &mut dyn ScsiTransport, id: &DriveId) -> Result<()>;
|
/// `unlock_drive()` + `read_volume_id()` into one step. A firmware unlocker
|
||||||
|
/// that cannot unlock returns [`UnlockError::FirmwareNotUnlockable`]; one
|
||||||
/// Read the disc Volume ID directly, bypassing the AACS cert handshake.
|
/// that unlocks but has no OEM VID path returns [`UnlockError::VidUnavailable`].
|
||||||
/// None → libfreemkv falls back to the cert-based read. Default: no-op.
|
/// Either error makes libfreemkv fall through to the in-tree cert handshake.
|
||||||
fn read_volume_id(
|
fn unlock(
|
||||||
&self,
|
&self,
|
||||||
_scsi: &mut dyn ScsiTransport,
|
scsi: &mut dyn ScsiTransport,
|
||||||
_id: &DriveId,
|
id: &DriveId,
|
||||||
) -> Result<Option<[u8; 16]>> {
|
) -> std::result::Result<Vid, UnlockError>;
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Raise the drive to its maximum read speed. Default: no-op.
|
/// Raise the drive to its maximum read speed. Default: no-op.
|
||||||
fn set_max_read_speed(&self, _scsi: &mut dyn ScsiTransport, _id: &DriveId) -> Result<()> {
|
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:
|
/// Returns:
|
||||||
/// * `Ok(Some(name))` — a registered unlocker matched and unlocked the
|
/// * `Ok(Some((name, vid)))` — a registered unlocker matched, put the drive
|
||||||
/// drive; `name` is its [`Unlocker::name`].
|
/// into extended mode, and returned the OEM Volume ID. The caller stashes
|
||||||
/// * `Ok(None)` — no unlocker matched; the drive was left untouched and
|
/// the VID for the handshake phase and need not run the cert handshake.
|
||||||
/// the caller should fall through to the host-cert handshake.
|
/// * `Ok(None)` — no unlocker matched, OR the matching unlocker failed
|
||||||
/// * `Err(_)` — an unlocker matched but its `unlock_drive` failed.
|
/// ([`UnlockError`], logged). Either way the drive is usable in stock mode
|
||||||
pub(crate) fn route_unlock(scsi: &mut dyn ScsiTransport, id: &DriveId) -> Result<Option<String>> {
|
/// 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() {
|
let reg = match REGISTRY.read() {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
// A poisoned lock means a prior unlocker panicked; treat as
|
// 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() {
|
for u in reg.iter() {
|
||||||
if u.matches(id) {
|
if u.matches(id) {
|
||||||
let name = u.name().to_string();
|
let name = u.name().to_string();
|
||||||
u.unlock_drive(scsi, id)?;
|
match u.unlock(scsi, id) {
|
||||||
return Ok(Some(name));
|
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(None)
|
Ok(None)
|
||||||
@@ -200,17 +217,17 @@ mod tests {
|
|||||||
DriveId::from_inquiry(&inquiry, "")
|
DriveId::from_inquiry(&inquiry, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fake unlocker that records whether it ran, matches on vendor id, and
|
/// Fake unlocker that records whether its `unlock` ran, matches on vendor
|
||||||
/// optionally serves a Volume ID (mirroring the read_volume_id capability)
|
/// id, and serves a Volume ID (`Some` → `Ok(Vid)`; `None` →
|
||||||
/// or records a set_max_read_speed call.
|
/// `Err(VidUnavailable)`, i.e. matched-but-no-OEM-VID → cert fallback) or
|
||||||
|
/// records a `set_max_read_speed` call.
|
||||||
struct FakeUnlocker {
|
struct FakeUnlocker {
|
||||||
want_vendor: String,
|
want_vendor: String,
|
||||||
ran: Arc<AtomicBool>,
|
ran: Arc<AtomicBool>,
|
||||||
/// VID this unlocker returns: `Some(vid)` (capability present),
|
/// VID this unlocker returns: `Some(vid)` → `unlock` yields `Ok(Vid)`;
|
||||||
/// `None` (no OEM path → cert fallback). `vid_ran` records whether
|
/// `None` → `unlock` yields `Err(UnlockError::VidUnavailable)` so
|
||||||
/// read_volume_id was consulted.
|
/// `route_unlock` falls through to the cert handshake.
|
||||||
vid: Option<[u8; 16]>,
|
vid: Option<[u8; 16]>,
|
||||||
vid_ran: Arc<AtomicBool>,
|
|
||||||
/// Records whether set_max_read_speed was invoked.
|
/// Records whether set_max_read_speed was invoked.
|
||||||
speed_ran: Arc<AtomicBool>,
|
speed_ran: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
@@ -219,14 +236,13 @@ mod tests {
|
|||||||
Self {
|
Self {
|
||||||
want_vendor: vendor.into(),
|
want_vendor: vendor.into(),
|
||||||
ran,
|
ran,
|
||||||
vid: None,
|
// Default: a successful unlock returning an all-zero VID.
|
||||||
vid_ran: Arc::new(AtomicBool::new(false)),
|
vid: Some([0u8; 16]),
|
||||||
speed_ran: Arc::new(AtomicBool::new(false)),
|
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 = vid;
|
||||||
self.vid_ran = vid_ran;
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
fn with_speed(mut self, speed_ran: Arc<AtomicBool>) -> Self {
|
fn with_speed(mut self, speed_ran: Arc<AtomicBool>) -> Self {
|
||||||
@@ -241,17 +257,16 @@ mod tests {
|
|||||||
fn matches(&self, id: &DriveId) -> bool {
|
fn matches(&self, id: &DriveId) -> bool {
|
||||||
id.vendor_id.trim() == self.want_vendor
|
id.vendor_id.trim() == self.want_vendor
|
||||||
}
|
}
|
||||||
fn unlock_drive(&self, _scsi: &mut dyn ScsiTransport, _id: &DriveId) -> Result<()> {
|
fn unlock(
|
||||||
self.ran.store(true, Ordering::SeqCst);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
fn read_volume_id(
|
|
||||||
&self,
|
&self,
|
||||||
_scsi: &mut dyn ScsiTransport,
|
_scsi: &mut dyn ScsiTransport,
|
||||||
_id: &DriveId,
|
_id: &DriveId,
|
||||||
) -> Result<Option<[u8; 16]>> {
|
) -> std::result::Result<Vid, UnlockError> {
|
||||||
self.vid_ran.store(true, Ordering::SeqCst);
|
self.ran.store(true, Ordering::SeqCst);
|
||||||
Ok(self.vid)
|
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<()> {
|
fn set_max_read_speed(&self, _scsi: &mut dyn ScsiTransport, _id: &DriveId) -> Result<()> {
|
||||||
self.speed_ran.store(true, Ordering::SeqCst);
|
self.speed_ran.store(true, Ordering::SeqCst);
|
||||||
@@ -259,8 +274,21 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A registered, matching unlocker runs; a non-matching identity leaves
|
/// `UnlockError` is `PartialEq` and a crate `Error` folds into
|
||||||
/// the registry untouched and routes to the OEM (cert) fallback.
|
/// `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
|
/// Both assertions live in one test because the registry is process-wide
|
||||||
/// and tests share it — running them as one case keeps the ordering
|
/// 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));
|
let ran = Arc::new(AtomicBool::new(false));
|
||||||
register_unlocker(Box::new(FakeUnlocker::new("MATCHVND", ran.clone())));
|
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 mut scsi = NoopTransport;
|
||||||
let matched = route_unlock(&mut scsi, &fake_id("MATCHVND")).unwrap();
|
let matched = route_unlock(&mut scsi, &fake_id("MATCHVND")).unwrap();
|
||||||
assert_eq!(matched.as_deref(), Some("fake"), "matching unlocker runs");
|
assert_eq!(
|
||||||
assert!(ran.load(Ordering::SeqCst), "unlock_drive() was invoked");
|
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);
|
ran.store(false, Ordering::SeqCst);
|
||||||
let none = route_unlock(&mut scsi, &fake_id("OTHERVND")).unwrap();
|
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!(
|
assert!(
|
||||||
!ran.load(Ordering::SeqCst),
|
!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
|
/// `route_unlock` returns the FIRST matching unlocker's VID. A matching
|
||||||
/// `read_volume_id`. A matching unlocker that returns `Some(vid)` yields
|
/// unlocker that yields `Ok(Vid)` returns that VID (OEM path — cert
|
||||||
/// that VID (the OEM path — cert handshake skipped). A matching unlocker
|
/// handshake skipped). A matching unlocker whose `unlock` errors (no OEM
|
||||||
/// that returns `None`, or no match at all, yields `Ok(None)` (cert
|
/// VID), or no match at all, yields `Ok(None)` (cert fallback).
|
||||||
/// fallback).
|
|
||||||
///
|
///
|
||||||
/// Distinct vendor ids keep this independent of the other registry test
|
/// Distinct vendor ids keep this independent of the other registry test
|
||||||
/// despite the process-wide shared registry.
|
/// despite the process-wide shared registry.
|
||||||
#[test]
|
#[test]
|
||||||
fn unlocker_read_volume_id_routes_match_else_cert() {
|
fn route_unlock_returns_vid_else_cert() {
|
||||||
let mut scsi = NoopTransport;
|
let mut scsi = NoopTransport;
|
||||||
|
|
||||||
// Unlocker WITH an OEM VID capability. Vendor ids are exactly 8
|
// Unlocker WITH an OEM VID. Vendor ids are exactly 8 chars: INQUIRY
|
||||||
// chars: INQUIRY field [8..16] has no null padding to trim, so the
|
// field [8..16] has no null padding to trim, so `matches` is exact.
|
||||||
// trimmed compare in `matches` is exact.
|
|
||||||
let vid = [0x5Au8; 16];
|
let vid = [0x5Au8; 16];
|
||||||
let vid_ran = Arc::new(AtomicBool::new(false));
|
|
||||||
register_unlocker(Box::new(
|
register_unlocker(Box::new(
|
||||||
FakeUnlocker::new("VIDVNDOR", Arc::new(AtomicBool::new(false)))
|
FakeUnlocker::new("VIDVNDOR", Arc::new(AtomicBool::new(false))).with_vid(Some(vid)),
|
||||||
.with_vid(Some(vid), vid_ran.clone()),
|
|
||||||
));
|
));
|
||||||
|
|
||||||
// Matching identity → read_volume_id consulted, its VID used.
|
// Matching identity → its VID is returned.
|
||||||
let got = unlocker_read_volume_id(&mut scsi, &fake_id("VIDVNDOR")).unwrap();
|
let got = route_unlock(&mut scsi, &fake_id("VIDVNDOR")).unwrap();
|
||||||
assert_eq!(got, Some(vid), "matching unlocker's OEM VID is used");
|
assert_eq!(
|
||||||
assert!(
|
got.map(|(_, v)| v),
|
||||||
vid_ran.load(Ordering::SeqCst),
|
Some(Vid(vid)),
|
||||||
"read_volume_id() was consulted"
|
"matching unlocker's OEM VID is used"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Unlocker that MATCHES but has NO OEM VID path (read_volume_id → None).
|
// Unlocker that MATCHES but has NO OEM VID path (unlock → Err) → cert.
|
||||||
let none_ran = Arc::new(AtomicBool::new(false));
|
|
||||||
register_unlocker(Box::new(
|
register_unlocker(Box::new(
|
||||||
FakeUnlocker::new("NOVIDVND", Arc::new(AtomicBool::new(false)))
|
FakeUnlocker::new("NOVIDVND", Arc::new(AtomicBool::new(false))).with_vid(None),
|
||||||
.with_vid(None, none_ran.clone()),
|
|
||||||
));
|
));
|
||||||
let got = unlocker_read_volume_id(&mut scsi, &fake_id("NOVIDVND")).unwrap();
|
let got = route_unlock(&mut scsi, &fake_id("NOVIDVND")).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
got.is_none(),
|
got.is_none(),
|
||||||
"unlocker without OEM VID falls through to cert"
|
"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.
|
// No matching unlocker → Ok(None), cert fallback.
|
||||||
let got = unlocker_read_volume_id(&mut scsi, &fake_id("UNKNWNVD")).unwrap();
|
let got = route_unlock(&mut scsi, &fake_id("UNKNWNVD")).unwrap();
|
||||||
assert!(got.is_none(), "no match → cert fallback");
|
assert!(got.is_none(), "no match → cert fallback");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,7 +451,11 @@ mod tests {
|
|||||||
register_unlocker(Box::new(FakeUnlocker::new("DUPEVNDR", second_ran.clone())));
|
register_unlocker(Box::new(FakeUnlocker::new("DUPEVNDR", second_ran.clone())));
|
||||||
|
|
||||||
let matched = route_unlock(&mut scsi, &fake_id("DUPEVNDR")).unwrap();
|
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!(
|
assert!(
|
||||||
first_ran.load(Ordering::SeqCst),
|
first_ran.load(Ordering::SeqCst),
|
||||||
"the FIRST-registered matching unlocker ran"
|
"the FIRST-registered matching unlocker ran"
|
||||||
|
|||||||
@@ -84,8 +84,11 @@ struct ScriptedSectorReader {
|
|||||||
trace: Arc<Mutex<Vec<(u32, u16, bool)>>>,
|
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 {
|
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()));
|
let trace = Arc::new(Mutex::new(Vec::new()));
|
||||||
(
|
(
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
Reference in New Issue
Block a user