Compare commits

...
8 Commits
Author SHA1 Message Date
Matthew Jackson 9c80ef8245 libfreemkv 0.31.9: ~3x fewer AES ops in the subset-difference PK walk
calc_pk_from_dk derived all three children (left/pk/right) at every tree
level but used only the one it descended into; the Processing Key only
matters at the final node. Derive just the descended child per level + the
PK once at the end. Bit-for-bit identical; speeds every DK->MK derivation
(disc decryption + unpositioned-DK recovery). 60.8s -> 22.9s on a UHD
worst-case recovery scan.
2026-06-17 15:43:39 -07:00
Matthew Jackson dc87962e50 libfreemkv 0.31.8: recover unpositioned device key position from an MKB
Add recover_dk_position: boil a position-less device key down against a
disc MKB to its invariant subset-difference position (node/uv/u_mask_shift)
— zero-descent probe + ancestor walk-up, hoisted verify. Consolidate the
SD-walk surface (drop the research-only probe::walk_pk_against_tables;
make derive_media_key_from_pk_walked internal).
2026-06-17 15:29:51 -07:00
Matthew Jackson 4221cd6a86 libfreemkv 0.31.7: expose Processing Key from the DK walk 2026-06-17 13:18:34 -07:00
Matthew Jackson dda4e7482b aacs: unify the SD-walk; derive_media_key_from_dk now exposes the PK
Two byte-identical copies of the subset-difference walk lived in keys.rs
and variants.rs. Consolidate the pure helpers (aesg3, calc_v_mask,
calc_pk_from_dk) into keys.rs (pub(super)); variants.rs imports them.

Add derive_media_key_and_pk_from_dk(mkb, dks) -> Option<(mk, pk)>, which
returns the intermediate Processing Key the walk already computes;
derive_media_key_from_dk becomes a thin wrapper. This lets callers bank
the PK on a DK boil instead of re-deriving it via a second, divergent
walk (the classical-vs-variant cvalues order made that miss silently).

216 AACS tests pass (incl. a new (mk,pk) regression); precommit (1.86) green.
2026-06-17 13:11:03 -07:00
Matthew Jackson f80551f278 udf: read AACS MKB length-aware; honor inline allocation descriptors
read_file rejected the padded ~128 MiB MKB_RO.inf via the 0.31.0
MAX_FILE_BYTES cap, so read_aacs_inputs failed and the online
key-resolve path never contacted the keyserver. Read the MKB's real
record length from its header and read exactly that. Also honor
inline/embedded (AD type 3) files so small AACS .inf files read from
the ICB payload instead of being misparsed as allocation descriptors.

Release 0.31.6.
2026-06-08 19:20:41 -07:00
Matthew Jackson 41a6d89cd1 libfreemkv 0.31.5: do not force monotonic block timestamps on video
B-frame video PTS is legitimately non-monotonic in decode/storage order; the
audio-oriented monotonic nudge was clobbering it to prev+1ms, which decoders
flagged as non-monotonic DTS (thousands per title). Apply the nudge to
audio/subtitle only; video keeps its true PES PTS. + regression test.
2026-06-08 09:00:35 -07:00
Matthew Jackson f79c2a0aa9 libfreemkv 0.31.4: prune 144 vacuous tests (keep spec-grounded subset) 2026-06-08 07:28:55 -07:00
Matthew Jackson d181362460 libfreemkv 0.31.3: use RFC 5737 doc address in network URL test fixture 2026-06-07 22:42:09 -07:00
56 changed files with 616 additions and 2325 deletions
+83
View File
@@ -1,5 +1,88 @@
# Changelog
## 0.31.9 (2026-06-17)
### Performance
- Subset-difference PK walk (`calc_pk_from_dk`): at each tree level derive only
the child actually descended into (not both siblings) and compute the
Processing Key once at the final node — ~3x fewer AES block ops per walk.
Bit-for-bit identical output; speeds every Device-Key → Media-Key derivation
(disc decryption AND unpositioned-DK recovery).
## 0.31.8 (2026-06-17)
### Added
- `recover_dk_position(mkb, key) -> Option<DeviceKey>`: recover an unpositioned
device key's subset-difference position (`node`/`uv`/`u_mask_shift`) by
scanning a disc MKB — the zero-descent slot probe and ancestor walk-up, with
the verify reduced to the hoisted terminal Processing Key. Lets a caller boil
a position-less device key against a real disc.
### Changed
- Consolidate the SD walk surface: remove the research-only
`probe::walk_pk_against_tables` (the single PK→MK engine is reached via
`derive_media_key_from_pk`); make `derive_media_key_from_pk_walked` internal.
## 0.31.7 (2026-06-17)
### Added
- `derive_media_key_and_pk_from_dk(mkb, dks) -> Option<(media_key, processing_key)>`:
expose the intermediate Processing Key the subset-difference walk already
computes, so callers can record the full key lineage on a Device-Key walk.
### Changed
- Unify the subset-difference walk: the duplicate `aesg3`/`calc_v_mask`/
`calc_pk_from_dk` copies are consolidated to one definition. No behavior change.
## 0.31.6 (2026-06-09)
### Fixed
- UDF `read_file`: the AACS Media Key Block (`MKB_RO.inf`) is allocated to a
fixed ~128 MiB on UHD discs and zero-padded, but the real record stream is
only a few MiB. 0.31.0 added a 64 MiB `MAX_FILE_BYTES` cap on `read_file`,
which rejected the padded MKB outright — so `read_aacs_inputs` failed and the
online key-resolve path reported "could not read this disc's key files" and
**never contacted the keyserver** (a regression on every disc whose MKB
exceeds 64 MiB). The MKB is now read length-aware: a bounded prefix is read,
the real record length is found via the MKB header, and exactly that is
returned — never the padding, and never tripping the cap. Validated
end-to-end against a real UHD ISO (MKB reads and trims to its record length).
- UDF `read_file`: honor inline/embedded allocation descriptors (ICB Tag flags
low bits == 3). Tiny files (some AACS `*.inf` key files) store their data
embedded directly in the ICB with no out-of-line extents; these are now read
from the ICB payload instead of misparsing the embedded bytes as allocation
descriptors (which could hard-error since 0.31.0).
## 0.31.5 (2026-06-08)
### Fixed
- MKV mux: stop forcing strictly-monotonic block timestamps on the **video**
track. The monotonic nudge (added for audio PES that collide on a
millisecond) was clobbering B-frame video PTS — which are legitimately
non-monotonic in decode/storage order — to prev+1ms. A `copy` remux
preserved the wrong value, but decoding derived DTS from the HEVC POC and
found them colliding, emitting thousands of "non monotonically increasing
dts" warnings per title. Video now keeps its true PES PTS (Matroska
SimpleBlock permits non-monotonic block timestamps); audio/subtitle tracks
still get the nudge. Verified: a re-mux drops the warning count to zero.
## 0.31.4 (2026-06-08)
Test cleanup — no runtime changes. Removed 144 unit tests flagged in adversarial
review as vacuous (assert-current-behavior / duplicate / non-failing), leaving
the spec-grounded, mutation-verified subset. 1927 tests remain, all green.
## 0.31.3 (2026-06-08)
Test fixture hygiene — no runtime changes. Replaced a private-range example
address in a `network://` URL test with an RFC 5737 documentation address so
the public-repo leak-guard stays green.
## 0.31.2 (2026-06-08)
Test-hardening release — no runtime changes. Adds a comprehensive,
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "0.31.2"
version = "0.31.9"
edition = "2024"
rust-version = "1.86"
license = "AGPL-3.0-only"
-11
View File
@@ -766,17 +766,6 @@ mod tests {
}
}
#[test]
fn decrypt_bus_processes_all_three_sectors() {
// 6144 / 2048 = 3 sectors. Confirm the loop covers all three: corrupt
// the body of sector 2 (the last) and confirm decrypt_bus touches it
// (i.e. it isn't skipped). We do this by checking that round-tripping
// only works when all three are processed — encrypt all 3, decrypt,
// expect full recovery (covered above); here assert the step count.
let starts: Vec<usize> = (0..ALIGNED_UNIT_LEN).step_by(SECTOR_LEN).collect();
assert_eq!(starts, vec![0, 2048, 4096]);
}
// ── decrypt_unit_full: bus-then-AACS ordering, and clear passthrough ───
#[test]
+238 -53
View File
@@ -240,7 +240,7 @@ const PK_WALK_MAX_DEPTH_CAP: u8 = 5;
///
/// The BFS frontier grows as `2^max_depth`; `max_depth` is clamped to
/// [`PK_WALK_MAX_DEPTH_CAP`] so a large value cannot exhaust memory.
pub fn derive_media_key_from_pk_walked(
pub(crate) fn derive_media_key_from_pk_walked(
mkb: &[u8],
processing_keys: &[[u8; 16]],
max_depth: u8,
@@ -251,9 +251,8 @@ pub fn derive_media_key_from_pk_walked(
walk_pk_against_tables_impl(processing_keys, &uvs, &cvalues, &mk_dv, max_depth)
}
/// Core Subset-Difference PK walk over explicit record bodies. Shared by
/// [`derive_media_key_from_pk_walked`] (production, records auto-selected) and
/// [`probe::walk_pk_against_tables`] (harness, records caller-pinned).
/// Core Subset-Difference PK walk over explicit record bodies. The single PK→MK
/// walk engine, reached in production via [`derive_media_key_from_pk`].
fn walk_pk_against_tables_impl(
processing_keys: &[[u8; 16]],
uvs: &[u8],
@@ -414,30 +413,6 @@ pub mod probe {
None => false,
}
}
/// Run the exact production Subset-Difference PK walk
/// ([`super::derive_media_key_from_pk_walked`]) but against
/// CALLER-SUPPLIED record bodies — so a harness can pin a specific
/// Media-Key-Data table (record `0x05` on AACS 2.x UHD MKBs, which the
/// production `mkb_find_cvalues` now selects) and the matching `0x04`
/// Subset-Difference Index, across ALL entries.
///
/// `subdiff` is the type-0x04 body (5-byte entries
/// `[u_mask_shift][uv:be32]`); `cvalues` is the chosen cvalue table
/// (16-byte entries); `mk_dv` is from the verify record. Each entry in
/// `keys` is tried as a terminal PK and as an SD node-key descending via
/// `AES-G3(K, 0|1|2)` for `max_depth` levels — identical logic to the
/// production walk (`max_depth` is clamped to the same internal cap to
/// bound the `2^depth` frontier). Returns the verified Media Key, if any.
pub fn walk_pk_against_tables(
keys: &[[u8; 16]],
subdiff: &[u8],
cvalues: &[u8],
mk_dv: &[u8; 16],
max_depth: u8,
) -> Option<[u8; 16]> {
super::walk_pk_against_tables_impl(keys, subdiff, cvalues, mk_dv, max_depth)
}
}
/// Find Verify Media Key Record (type 0x81 for AACS 1.0, 0x86 for AACS 2.0/2.1) in MKB.
@@ -611,7 +586,10 @@ const AESG3_SEED: [u8; 16] = [
/// AACS-G3: derive a subkey from a parent key.
/// seed[15] += inc, then AES-DEC(key, seed) XOR seed.
fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
///
/// Shared with [`super::variants`] (its variant chain runs the same SD
/// tree); a single definition keeps the two walks byte-identical.
pub(super) fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
let mut seed = AESG3_SEED;
seed[15] = seed[15].wrapping_add(inc);
let mut out = aes_ecb_decrypt(key, &seed);
@@ -621,8 +599,8 @@ fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
out
}
/// Compute v_mask from a UV value.
fn calc_v_mask(uv: u32) -> u32 {
/// Compute v_mask from a UV value. Shared with [`super::variants`].
pub(super) fn calc_v_mask(uv: u32) -> u32 {
let mut v_mask: u32 = 0xFFFF_FFFF;
while (uv & !v_mask) == 0 && v_mask != 0 {
v_mask <<= 1;
@@ -631,11 +609,20 @@ fn calc_v_mask(uv: u32) -> u32 {
}
/// Derive processing key from device key using subset-difference tree traversal.
fn calc_pk_from_dk(dk: &[u8; 16], uv: u32, v_mask: u32, dev_key_v_mask: u32) -> [u8; 16] {
// Initial derivation: left_child = aesg3(dk, 0), pk = aesg3(dk, 1), right_child = aesg3(dk, 2)
let mut left_child = aesg3(dk, 0);
let mut pk = aesg3(dk, 1);
let mut right_child = aesg3(dk, 2);
/// Shared with [`super::variants`].
pub(super) fn calc_pk_from_dk(
dk: &[u8; 16],
uv: u32,
v_mask: u32,
dev_key_v_mask: u32,
) -> [u8; 16] {
// Descend from the device node to the record node, following the record's
// `uv` bits. At each level only the child we descend INTO is needed (the
// sibling is computed but never used), and the Processing Key is the
// `aesg3(.,1)` of the FINAL node — so we derive ONE child per level and the
// PK once at the end, instead of left/pk/right at every level. Identical
// result, ~3x fewer block ops. (left child = `aesg3(node,0)`, right = `,2`.)
let mut node = *dk;
let mut current_v_mask = dev_key_v_mask;
// The subset-difference tree is at most 32 levels deep (u32 mask), so the
@@ -658,24 +645,40 @@ fn calc_pk_from_dk(dk: &[u8; 16], uv: u32, v_mask: u32, dev_key_v_mask: u32) ->
}
}
let curr_key = if bit_pos < 0 || (uv & (1u32 << bit_pos as u32)) == 0 {
left_child
let inc = if bit_pos < 0 || (uv & (1u32 << bit_pos as u32)) == 0 {
0 // left child
} else {
right_child
2 // right child
};
left_child = aesg3(&curr_key, 0);
pk = aesg3(&curr_key, 1);
right_child = aesg3(&curr_key, 2);
node = aesg3(&node, inc);
current_v_mask = ((current_v_mask as i32) >> 1) as u32;
}
pk
aesg3(&node, 1)
}
/// Derive Media Key from MKB using device keys (subset-difference tree).
///
/// Thin wrapper over [`derive_media_key_and_pk_from_dk`] that drops the
/// intermediate Processing Key. Callers that need the PK lineage (e.g.
/// the key service banking DK·PK·MK) should call the `_and_pk_` form.
pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option<[u8; 16]> {
derive_media_key_and_pk_from_dk(mkb, device_keys).map(|(mk, _pk)| mk)
}
/// Derive both the Media Key and the intermediate Processing Key from an
/// MKB using device keys (subset-difference tree).
///
/// Identical walk to [`derive_media_key_from_dk`]; this form additionally
/// returns the Processing Key `Kp` derived at the matching subset-difference
/// node — the value `calc_pk_from_dk` produces immediately before it
/// validates into the Media Key. Returns `Some((mk, pk))` for the first DK
/// that walks a uv slot whose Processing Key validates against the MKB.
pub fn derive_media_key_and_pk_from_dk(
mkb: &[u8],
device_keys: &[DeviceKey],
) -> Option<([u8; 16], [u8; 16])> {
let mk_dv = mkb_find_mk_dv(mkb)?;
let uvs = mkb_find_subdiff_records(mkb)?;
let cvalues = mkb_find_cvalues(mkb)?;
@@ -735,7 +738,7 @@ pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option
if let Some(mk) =
validate_processing_key(&pk, cv, &uvs[1 + uvs_idx * 5..], &mk_dv)
{
return Some(mk);
return Some((mk, pk));
}
}
}
@@ -745,6 +748,108 @@ pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option
None
}
/// Recover the subset-difference position (`node`, `uv`, `u_mask_shift`) of an
/// UNPOSITIONED device key by scanning a disc MKB. A device key alone (just the
/// 16 bytes) cannot be walked — the walk needs its tree node. This finds that
/// node empirically: for each MKB subset-difference record, it tries the device
/// at the record's node AND at every ancestor v-position (the device may sit one
/// or more levels ABOVE the record, descending via AES-G to reach it), deriving
/// the candidate Processing Key DIRECTLY (one [`calc_pk_from_dk`] per candidate,
/// no full re-walk) and checking it validates against that record's cvalue.
///
/// On the first verifying candidate it pins `(uv, u_mask_shift)` — invariant for
/// the key across all discs — and resolves a gate-passing `node` (a one-time
/// ≤32-try search at the single hit). Returns a [`DeviceKey`] ready to bank and
/// reuse on every future disc via [`derive_media_key_from_dk`]. `None` if the
/// key does not apply to this MKB.
///
/// Cost is `O(slots × tree_depth)` — linear in the MKB's subset-difference
/// index, not the quartic cost of re-deriving per candidate.
pub fn recover_dk_position(mkb: &[u8], key: &[u8; 16]) -> Option<DeviceKey> {
let mk_dv = mkb_find_mk_dv(mkb)?;
let uvs = mkb_find_subdiff_records(mkb)?;
let cvalues = mkb_find_cvalues(mkb)?;
let num_uvs = uvs
.chunks(5)
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
.count();
let n_cv = cvalues.len() / 16;
// Hoisted ONCE for the whole scan: the Processing Key the device produces if
// it sits EXACTLY at a record (zero descent) is `AES-G3(key, 1)` — it does
// not depend on the record, so the zero-descent probe of every slot reuses
// this single value instead of re-deriving it per slot.
let pk_zero_descent = aesg3(key, 1);
for i in 0..num_uvs {
if i >= n_cv {
break;
}
let u_mask_shift = uvs[5 * i];
if u_mask_shift >= 32 {
continue;
}
let p_uv = &uvs[1 + 5 * i..];
let uv_r = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
if uv_r == 0 {
continue;
}
let v_mask = calc_v_mask(uv_r);
let cv = &cvalues[i * 16..(i + 1) * 16];
let uv_bytes = &uvs[1 + i * 5..];
// Zero descent (device sits at this slot's node): the cheapest, most
// common case — one verify against the hoisted PK, no descent.
if validate_processing_key(&pk_zero_descent, cv, uv_bytes, &mk_dv).is_some() {
return resolve_dk_node(mkb, key, uv_r, u_mask_shift);
}
// Descent: the device is an ANCESTOR of the slot. Walk the depth bit up
// from the slot's lowest set bit; each level descends from the device's
// node to the slot's node. (k = p is the zero-descent case handled
// above, so start one above it.)
let p = uv_r.trailing_zeros();
for k in (p + 1)..32 {
let uv_d = if k + 1 >= 32 {
1u32 << k
} else {
(uv_r & (0xFFFF_FFFFu32 << (k + 1))) | (1u32 << k)
};
let pk = calc_pk_from_dk(key, uv_r, v_mask, calc_v_mask(uv_d));
if validate_processing_key(&pk, cv, uv_bytes, &mk_dv).is_some() {
return resolve_dk_node(mkb, key, uv_d, u_mask_shift);
}
}
}
None
}
/// Resolve a positioned [`DeviceKey`] for an orphan `key` known to sit at
/// `(uv, u_mask_shift)`: find a `device_number` (node) that passes the walk's
/// subset-difference gate on `mkb`. The derived key is independent of the exact
/// node (it only gates), so any gating node yields the same Media Key — a
/// one-time ≤32-try search, run only once at the recovered position.
fn resolve_dk_node(mkb: &[u8], key: &[u8; 16], uv: u32, u_mask_shift: u8) -> Option<DeviceKey> {
for b in 0..u_mask_shift {
let dk = DeviceKey {
key: *key,
node: ((uv ^ (1u32 << b)) & 0xFFFF) as u16,
uv,
u_mask_shift,
};
if derive_media_key_from_dk(mkb, std::slice::from_ref(&dk)).is_some() {
return Some(dk);
}
}
// Degenerate MKB (no gating bit): fall back to the node itself.
Some(DeviceKey {
key: *key,
node: (uv & 0xFFFF) as u16,
uv,
u_mask_shift,
})
}
/// MKB disc structure format code.
const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83;
/// MKB pack buffer size.
@@ -1602,12 +1707,11 @@ mod tests {
#[test]
fn probe_walk_pk_against_tables_accepts_planted_pk_rejects_corrupt() {
// Lock in the shared SD walk used by both production
// (`derive_media_key_from_pk_walked`) and the independent-reproduction
// harness (`probe::walk_pk_against_tables`). Plant a terminal PK whose
// derived Media Key satisfies a synthetic verify record; confirm the
// walk ACCEPTS it against caller-supplied SD/cvalue tables and REJECTS a
// 1-byte corruption.
// Lock in the shared SD walk (`walk_pk_against_tables_impl`) used by the
// production PK path (`derive_media_key_from_pk`). Plant a terminal PK
// whose derived Media Key satisfies a synthetic verify record; confirm
// the walk ACCEPTS it against caller-supplied SD/cvalue tables and
// REJECTS a 1-byte corruption.
use super::super::decrypt::aes_ecb_encrypt as enc;
let pk: [u8; 16] = [
@@ -1634,14 +1738,14 @@ mod tests {
subdiff.extend_from_slice(&uv);
assert_eq!(
probe::walk_pk_against_tables(std::slice::from_ref(&pk), &subdiff, &cv, &mk_dv, 1),
walk_pk_against_tables_impl(std::slice::from_ref(&pk), &subdiff, &cv, &mk_dv, 1),
Some(mk),
"planted terminal PK must verify"
);
let mut bad = pk;
bad[0] ^= 0xFF;
assert_eq!(
probe::walk_pk_against_tables(std::slice::from_ref(&bad), &subdiff, &cv, &mk_dv, 1),
walk_pk_against_tables_impl(std::slice::from_ref(&bad), &subdiff, &cv, &mk_dv, 1),
None,
"corrupted PK must be rejected"
);
@@ -2741,4 +2845,85 @@ mod tests {
let mkb = [0x05, 0x00, 0x00, 0x04]; // type 0x05, no body
assert!(probe::mkb_record_body(&mkb, 0x05).is_none());
}
#[test]
fn derive_media_key_and_pk_from_dk_returns_intermediate_pk() {
// Regression: a classical DK boil must yield the intermediate
// Processing Key, not just the Media Key. The key service banks the
// PK lineage (DK·PK·MK·VUK·UK); before the `_and_pk_` form existed it
// recovered the MK here but lost the PK silently.
//
// Build a minimal classical MKB (no 0x82/0x83) with:
// - 0x04 Subset-Difference: u_mask_shift=3, uv=0x00000002
// - 0x05 cvalues: one cvalue C planted so AES-D(Kp, C) XOR uv == mk
// - 0x86 Verify Media Key: mk_dv = AES-E(mk, magic || pad)
// and a DK with node=4, uv=2, u_mask_shift=3 so dev_key_v_mask ==
// v_mask: the calc_pk_from_dk loop is a no-op and Kp == aesg3(dk, 1).
use super::super::decrypt::aes_ecb_encrypt as enc;
let dk_bytes: [u8; 16] = [
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
0xFF, 0x00,
];
// Expected Processing Key for the no-op walk.
let expected_pk = aesg3(&dk_bytes, 1);
// Plant a known Media Key.
let mk: [u8; 16] = [
0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
0xAE, 0xAF,
];
// uv (big-endian) = 0x00000002; validate XORs uv into mk[12..16].
let uv_bytes: [u8; 4] = [0x00, 0x00, 0x00, 0x02];
// cvalue C = AES-E(Kp, mk_raw) where mk_raw = mk with the uv XOR
// pre-undone, so the validate step XORs uv back in and recovers mk.
let mut mk_raw = mk;
for a in 0..4 {
mk_raw[12 + a] ^= uv_bytes[a];
}
let cvalue = enc(&expected_pk, &mk_raw);
// mk_dv = AES-E(mk, magic || pad); validate decrypts it under mk and
// checks the leading 8 bytes against the verify magic.
let mut plaintext_vd = [0u8; 16];
plaintext_vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
plaintext_vd[8..].copy_from_slice(&[0x11; 8]);
let mk_dv = enc(&mk, &plaintext_vd);
// Assemble the MKB. Type/Version (0x10) header first.
let mut mkb = vec![
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
];
// 0x04 Subset-Difference: body = u_mask_shift(0x03) || uv(4 bytes).
mkb.extend_from_slice(&[0x04, 0x00, 0x00, 0x09]);
mkb.extend_from_slice(&[0x03]);
mkb.extend_from_slice(&uv_bytes);
// 0x05 cvalues: one 16-byte cvalue (mkb_find_cvalues prefers 0x05).
mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&cvalue);
// 0x86 Verify Media Key: mk_dv.
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&mk_dv);
let dk = DeviceKey {
key: dk_bytes,
node: 4,
uv: 2,
u_mask_shift: 3,
};
// The new `_and_pk_` form returns BOTH the MK and the intermediate PK.
let dks = [dk];
let (got_mk, got_pk) = derive_media_key_and_pk_from_dk(&mkb, &dks)
.expect("classical DK boil must derive (mk, pk)");
assert_eq!(got_mk, mk, "recovered Media Key must match the planted MK");
assert_eq!(
got_pk, expected_pk,
"returned Processing Key must equal aesg3(dk, 1) for the no-op walk"
);
// And the thin wrapper must still return just the MK.
assert_eq!(derive_media_key_from_dk(&mkb, &dks), Some(mk));
}
}
+3 -3
View File
@@ -31,10 +31,10 @@ pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb};
pub use keys::probe;
pub use keys::{
AacsVersion, ContentCert, ResolveContext, ResolvedKeys, UnitKeyFile, decrypt_unit_key,
derive_media_key_from_dk, derive_media_key_from_pk, derive_media_key_from_pk_walked,
derive_media_key_and_pk_from_dk, derive_media_key_from_dk, derive_media_key_from_pk,
derive_vuk, disc_hash, disc_hash_hex, mkb_content_len, mkb_version, parse_content_cert,
parse_unit_key_ro, read_mkb_from_drive, resolve_keys_v1, resolve_keys_v2, resolve_keys_v21,
trim_mkb,
parse_unit_key_ro, read_mkb_from_drive, recover_dk_position, resolve_keys_v1, resolve_keys_v2,
resolve_keys_v21, trim_mkb,
};
pub use provider::KeyProvider;
pub use variants::{
+34 -67
View File
@@ -177,66 +177,11 @@ fn aes_g(x1: &[u8; 16], x2: &[u8; 16]) -> [u8; 16] {
// ── Subset-difference walk that exposes (Kp, uv) ──────────────────────────
/// AES-G3 seed register initial value.
const AESG3_SEED: [u8; 16] = [
0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9,
];
/// AES-G3 single step: AES-G against the seed register at offset `inc`.
fn aesg3_step(key: &[u8; 16], inc: u8) -> [u8; 16] {
let mut seed = AESG3_SEED;
seed[15] = seed[15].wrapping_add(inc);
aes_g(key, &seed)
}
fn calc_v_mask(uv: u32) -> u32 {
let mut v_mask: u32 = 0xFFFF_FFFF;
while (uv & !v_mask) == 0 && v_mask != 0 {
v_mask <<= 1;
}
v_mask
}
fn calc_pk_from_dk(dk: &[u8; 16], uv: u32, v_mask: u32, dev_key_v_mask: u32) -> [u8; 16] {
let mut left_child = aesg3_step(dk, 0);
let mut pk = aesg3_step(dk, 1);
let mut right_child = aesg3_step(dk, 2);
let mut current_v_mask = dev_key_v_mask;
// Bound the walk to the 32-level depth of a u32 subset-difference tree.
// `current_v_mask` advances via an arithmetic `>> 1` which sign-extends, so
// a disc-supplied v_mask coarser than dev_key_v_mask would otherwise drive
// current_v_mask up to 0xFFFF_FFFF and spin forever — a crafted MKB must
// not hang the rip thread (this runs before the KCD placeholder gate).
let mut steps = 0u32;
while current_v_mask != v_mask {
if steps >= 32 {
break;
}
steps += 1;
let mut bit_pos: i32 = -1;
for i in (0..32).rev() {
if (current_v_mask & (1u32 << i)) == 0 {
bit_pos = i;
break;
}
}
let curr_key = if bit_pos < 0 || (uv & (1u32 << bit_pos as u32)) == 0 {
left_child
} else {
right_child
};
left_child = aesg3_step(&curr_key, 0);
pk = aesg3_step(&curr_key, 1);
right_child = aesg3_step(&curr_key, 2);
current_v_mask = ((current_v_mask as i32) >> 1) as u32;
}
pk
}
// `calc_v_mask` and `calc_pk_from_dk` (and the AES-G3 seed step they ride
// on) are shared with the classical walk in [`super::keys`] — a single
// definition keeps the variant SD tree byte-identical to the classical one.
// (`aesg3` itself is imported separately in the test module.)
use super::keys::{calc_pk_from_dk, calc_v_mask};
/// Outcome of a subset-difference walk against an MKB. Carries the
/// processing key and the matching `uv` slot — both needed as inputs
@@ -271,6 +216,27 @@ fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
/// Walk an MKB and return the first `(Kp, uv, cvalue)` that
/// `device_keys` covers. Returns `None` if no DK walks any uv.
///
/// This is the AACS-2.1 **variant** walk; the classical walk lives in
/// [`super::keys::derive_media_key_and_pk_from_dk`]. The two are kept
/// separate on purpose and select MKB records in DELIBERATELY different
/// order:
///
/// - cvalues: this variant walk tries record `0x07`-then-`0x05`; the
/// classical walk tries `0x05`-then-`0x07`. On a variant MKB the
/// small `0x07` Explicit-Subset-Difference record carries the
/// cvalue the Precursor chain consumes, whereas a classical UHD MKB
/// keeps its 1:1 cvalue table in the large `0x05` record (see the
/// note on [`super::keys::probe::mkb_cvalues`]). They must NOT be
/// unified to one order — each is correct for its own MKB shape.
/// - finders: this walk operates on parsed [`MkbRecord`]s (needed
/// because the variant chain also reads `0x82`/`0x83`); the
/// classical walk operates on raw MKB bytes. Same framing, different
/// input type.
///
/// Consequence: do NOT route the classical DK path through this function
/// — on a classical MKB the `0x07`-first selection picks the wrong (or
/// missing) cvalue and the magic check fails, so it returns `None`.
pub fn walk_processing_key(
records: &[MkbRecord],
device_keys: &[DeviceKey],
@@ -544,6 +510,10 @@ pub fn derive_media_key_variant(
#[cfg(test)]
mod tests {
use super::*;
// These three live in `super::keys` now (consolidated SD-walk helpers);
// `use super::*` does not re-export the parent module's private `use`
// imports, so pull them in directly for the tests below.
use super::super::keys::{aesg3, calc_pk_from_dk};
#[test]
fn calc_pk_from_dk_terminates_on_nonconvergent_mask() {
@@ -708,7 +678,7 @@ mod tests {
/// agreeing with uv on bits 3+ (the u_mask=1 region). dk.uv ==
/// MKB.uv and dk.u_mask_shift == MKB.u_mask_shift make
/// `dev_key_v_mask == v_mask`, so `calc_pk_from_dk` loops zero
/// times — Kp = aesg3_step(dk, 1).
/// times — Kp = aesg3(dk, 1).
/// - one cvalue in record 0x07 chosen so AES-D(Kp, C) ⊕ uv produces a
/// Kmp whose byte-15 is exactly `kmp15`.
/// - record 0x82 with a 16-byte body (acts as both Variant Data
@@ -731,12 +701,12 @@ mod tests {
// Pick a known DK; with dk.uv == MKB.uv (==2) and
// dk.u_mask_shift == MKB.u_mask_shift (==3), dev_key_v_mask
// equals the MKB's v_mask and the calc_pk_from_dk loop is a
// no-op — Kp = aesg3_step(dk, 1).
// no-op — Kp = aesg3(dk, 1).
let dk_bytes: [u8; 16] = [
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
0xFF, 0x00,
];
let kp = aesg3_step(&dk_bytes, 1);
let kp = aesg3(&dk_bytes, 1);
// Plant Kmp with chosen byte-15, then compute C such that
// AES-D(Kp, C) ⊕ uv == Kmp. uv=2 → low-4 bytes XOR is 00 00 00 02.
@@ -1002,10 +972,7 @@ mod tests {
let (recs, dk, planted_kp, _) = synthetic_variant_setup(0x00);
let m = walk_processing_key(&recs, &[dk]).expect("variant MKB yields a match");
assert_eq!(m.uv, 2, "matched the planted uv");
assert_eq!(
m.kp, planted_kp,
"Kp equals aesg3_step(dk,1) for the no-op walk"
);
assert_eq!(m.kp, planted_kp, "Kp equals aesg3(dk,1) for the no-op walk");
assert_eq!(m.cvalue_index, 0);
}
-33
View File
@@ -1184,24 +1184,6 @@ mod tests {
}
}
/// EP-map num_streams == 0 → empty maps (explicit guard). A CPI whose
/// EP map header declares zero stream PID entries carries no EP data.
#[test]
fn ep_map_zero_streams_yields_empty() {
let mut ep_map = Vec::new();
ep_map.push(0); // reserved
ep_map.push(0); // num_streams = 0
ep_map.extend_from_slice(&[0u8; 16]); // filler so len checks pass
let mut cpi = Vec::new();
cpi.extend_from_slice(&((2 + ep_map.len()) as u32).to_be_bytes());
cpi.extend_from_slice(&[0u8; 2]);
cpi.extend_from_slice(&ep_map);
let data = build_clpi(1000, Some(&cpi));
let clip = parse(&data).expect("should parse");
assert!(clip.ep_coarse.is_empty());
assert!(clip.ep_fine.is_empty());
}
/// ep_map_offset that points past the EP map (`ep_map_offset + 4 >
/// ep_map.len()`) → empty maps (bounds guard), not panic. Patch the
/// EP_map_start field to a huge value.
@@ -1271,21 +1253,6 @@ mod tests {
assert_eq!(clip.ep_coarse[0].pts_coarse, 10);
}
/// CLPI between 40 and 60 bytes: passes the len<40 guard, but
/// source_packet_count needs [56..60]. Must yield 0, not panic.
/// (Mirrors parse_truncated_clipinfo_no_panic but asserts EP empty.)
#[test]
fn clipinfo_40_to_60_bytes_empty_ep() {
for len in 40..60usize {
let mut data = vec![0u8; len];
data[0..4].copy_from_slice(b"HDMV");
let clip = parse(&data).expect("short CLPI parses");
assert_eq!(clip.source_packet_count, 0);
assert!(clip.ep_coarse.is_empty());
assert!(clip.streams.is_empty());
}
}
/// resolved_ep_map: the LAST coarse group's fine range extends to
/// ep_fine.len() (no "next coarse" bound). Verify all trailing fine
/// entries are assigned to the final coarse group.
-36
View File
@@ -593,42 +593,6 @@ mod tests {
// ── CSS constant-table integrity ───────────────────────────────────────
/// The CSSCryptKey lookup tables are each a full 256-entry byte table and
/// the variant tables are 32 entries (one per CSS variant). The cipher
/// indexes CRYPT_TAB0..3 with arbitrary bytes (0..256) and indexes
/// VARIANTS / PERM_VARIANT with the css_variant (0..32). A short table
/// would index out of bounds.
///
/// Grounding: crypt_key indexes `CRYPT_TABx[idx]` where idx is a u8 cast
/// to usize (0..256); `VARIANTS[css_variant]` and `PERM_VARIANT[k][variant]`
/// with variant 0..32.
/// Mutation: drop the last entry of CRYPT_TAB0 (make it [u8;255]) ->
/// compile error / length assert fails.
#[test]
fn crypt_tables_have_spec_lengths() {
assert_eq!(CRYPT_TAB0.len(), 256);
assert_eq!(CRYPT_TAB1.len(), 256);
assert_eq!(CRYPT_TAB2.len(), 256);
assert_eq!(CRYPT_TAB3.len(), 256);
assert_eq!(VARIANTS.len(), 32, "one CSS variant byte per variant 0..32");
assert_eq!(PERM_VARIANT.len(), 2);
assert_eq!(PERM_VARIANT[0].len(), 32);
assert_eq!(PERM_VARIANT[1].len(), 32);
assert_eq!(
PERM_CHALLENGE.len(),
3,
"one challenge perm per key_type 0..3"
);
for p in &PERM_CHALLENGE {
assert_eq!(
p.len(),
10,
"challenge permutation covers all 10 challenge bytes"
);
}
assert_eq!(SECRET.len(), 5);
}
/// Each PERM_CHALLENGE row is a permutation of indices 0..10 (it reorders
/// the 10 challenge bytes). A non-permutation would drop/duplicate
/// challenge bytes, weakening or corrupting the bus key derivation.
-46
View File
@@ -378,31 +378,6 @@ mod tests {
}
}
/// recover_title_key with exactly 10 plaintext bytes is accepted at the
/// length guard (it may still return None from the attack, but must not be
/// rejected by the `plain.len() < 10` check). Pins the boundary at the
/// inclusive value 10.
///
/// Grounding: `if sector.len() < SECTOR_SIZE || plain.len() < 10 { None }`
/// — 10 is the minimum accepted length.
/// Mutation: change `< 10` to `< 11` -> a 10-byte plaintext would be
/// rejected. We detect acceptance by observing the function runs the
/// attack (it returns None for this synthetic data, but a 9-byte plain
/// returns None *at the guard*; to distinguish, we assert a 9-byte input
/// is rejected and a 10-byte input is not panicking and consistent).
#[test]
fn recover_accepts_exactly_10_plain_bytes() {
let mut sector = vec![0x00u8; SECTOR_SIZE];
sector[FLAG_BYTE] = 0x30;
sector[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
let plain9 = [0u8; 9];
let plain10 = [0u8; 10];
// 9 bytes: rejected at the guard.
assert!(recover_title_key(&sector, &plain9).is_none());
// 10 bytes: passes the guard and runs to completion without panic.
let _ = recover_title_key(&sector, &plain10);
}
// ── crack_title_key early-return guards (flag uses bits 4-5) ────────────
/// crack_title_key uses the same bits-4-5 scramble field. A sector with
@@ -466,27 +441,6 @@ mod tests {
}
}
/// recover_title_key, when it DOES return a key, XORs the sector seed into
/// the recovered raw key (the final step `result_key[i] ^= seed[i]`).
/// We cannot easily force a hit on this crate's (non-functional) attack,
/// so instead pin the structural guard that the seed is read from the
/// documented offset 0x54..0x59 and that a None result is returned for an
/// all-zero scrambled sector (the search exhausts without a match rather
/// than panicking on the seed XOR).
///
/// Grounding: SEED_OFFSET == 0x54; seed slice is `sector[0x54..0x59]`.
/// Mutation: change SEED_OFFSET to 0x55 -> the seed slice shifts; the
/// search still completes (None) but on a functional path the recovered
/// key would be wrong. This test pins the no-panic completion only.
#[test]
fn recover_all_zero_scrambled_completes_none() {
let mut sector = vec![0x00u8; SECTOR_SIZE];
sector[FLAG_BYTE] = 0x30;
let plain = [0x00u8, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x80, 0x05, 0x21];
// All-zero body: the textbook attack finds no consistent state.
assert!(recover_title_key(&sector, &plain).is_none());
}
/// Build a scrambled sector with known plaintext (both an MPEG PES header
/// at 0x80 and an exact-plaintext probe), then assert that the Stevenson
/// recovery actually recovers a key whose descramble round-trips the body.
-54
View File
@@ -595,60 +595,6 @@ mod tests {
}
}
/// seed_lfsr0 applies the per-byte TAB4 bit-reversal to the 4 bytes of the
/// packed LFSR0 seed value. The seeding expression for the all-zero key is
/// `(0<<17)|(0<<9)|((0<<1)+8-(0&7)) == 8`, so the raw lfsr0 = 0x00000008.
/// Each byte is then TAB4-reversed and re-packed big-endian-ish per the
/// code. Byte (lfsr0 & 0xFF) == 0x08 -> TAB4[0x08] == 0x10 placed in the
/// top byte (<<24). The other three source bytes are 0 -> TAB4[0]=0. So
/// the seed for an all-zero key must be 0x10 << 24 == 0x10000000.
///
/// Grounding: seed_lfsr0 body + TAB4[0x08] = bit-reverse(0x08=0b00001000)
/// = 0b00010000 = 0x10.
/// Mutation: change the `<< 24` on the first TAB4 term to `<< 16` -> the
/// expected seed changes and the round-trip-anchored value below fails.
#[test]
fn seed_lfsr0_zero_key_matches_spec_packing() {
// We cannot call seed_lfsr0 directly (private), but decrypt_key seeds
// LFSR0 with it. Instead pin the documented TAB4 anchor the seed
// relies on, plus the algebraic seed value, so a regression in either
// the packing constant or TAB4 is caught.
assert_eq!(
TAB4[0x08], 0x10,
"bit-reverse(0x08) == 0x10 drives the zero-key seed"
);
// Algebraic check of the raw (pre-TAB4) seed for an all-zero key.
let key = [0u8; 5];
let raw = ((key[4] as u32) << 17)
| ((key[3] as u32) << 9)
| (((key[2] as u32) << 1) + 8 - (key[2] as u32 & 7));
assert_eq!(
raw, 8,
"all-zero key packs to raw LFSR0 seed 8 per the CSS formula"
);
}
/// decrypt_key never panics and always returns exactly 5 bytes across the
/// full single-byte input space for both invert values. This is the
/// "never panic / never truncate" property for the key-mangling core.
///
/// Grounding: return type is [u8; 5]; all table indexes are masked to byte
/// range inside css_step.
/// Mutation: (sanity) it is a type-level guarantee; the loop also exercises
/// every TAB1 index 0..256 via p_crypted, catching an out-of-range index
/// if a table were shortened.
#[test]
fn decrypt_key_total_over_byte_space() {
for invert in [0x00u8, 0xFF] {
for b in 0u16..256 {
let key = [b as u8; 5];
let crypted = [b as u8, 0, 255, b as u8, 0];
let out = decrypt_key(invert, &key, &crypted);
let _ = out; // length is [u8;5] by type; the call must not panic.
}
}
}
/// The invert byte (0x00 vs 0xFF) selects the LFSR0 output index in
/// css_step via `TAB4[(o_lfsr0 ^ invert) as usize]`. For a non-degenerate
/// key it must change the keystream and hence the result. (Pins that the
-23
View File
@@ -381,27 +381,4 @@ mod tests {
assert!(res.is_none());
assert_eq!(src.reads.borrow().len(), 0);
}
/// crack_key only invokes the (expensive) per-sector cracker on SCRAMBLED
/// sectors. Clear sectors are scanned (counted) but never cracked, so a
/// long run of clear sectors returns None after exhausting the extent
/// rather than producing a spurious key. This pins the `is_scrambled(&buf)`
/// gate.
///
/// Grounding: `if read.is_ok() && is_scrambled(&buf) { crack::... }`.
/// Mutation: drop the `&& is_scrambled(&buf)` gate -> crack runs the
/// 169-pattern Stevenson attack on every clear sector. Functionally this
/// would still return None for our zeroed data, but it would be vastly
/// slower; we cannot time it deterministically, so this test primarily
/// documents the contract and confirms a clear scan terminates with None.
#[test]
fn crack_key_clear_sectors_yield_none() {
let mut src = MockSource::new(0x00);
let extents = [Extent {
start_lba: 0,
sector_count: 100,
}];
assert!(crack_key(&mut src, &extents).is_none());
assert_eq!(src.reads.borrow().len(), 100);
}
}
-38
View File
@@ -143,25 +143,6 @@ mod tests {
}
}
/// All five tables have exactly the lengths the CSS cipher requires.
/// TAB3 is 9-bit-indexed (the LFSR1 low word carries a 9th bit), hence
/// 512 entries; every other table is byte-indexed (256). A truncated or
/// padded table would index out of bounds or read stale data inside the
/// LFSR loops.
///
/// Grounding: lfsr.rs indexes TAB3 with `*lfsr1_lo as usize` where
/// `lfsr1_lo` can be up to 0x1FF (9 bits), so TAB3 MUST be >= 512 long.
/// Mutation: change `[u8; 512]` to `[u8; 256]` (drop the second half) ->
/// fails to compile / length assert fails.
#[test]
fn table_lengths_match_css_index_widths() {
assert_eq!(TAB1.len(), 256, "TAB1 is byte-indexed");
assert_eq!(TAB2.len(), 256, "TAB2 is byte-indexed");
assert_eq!(TAB3.len(), 512, "TAB3 is 9-bit-indexed (LFSR1 low word)");
assert_eq!(TAB4.len(), 256, "TAB4 is byte-indexed");
assert_eq!(TAB5.len(), 256, "TAB5 is byte-indexed");
}
/// TAB1 is a bijection on 0..256. CSS uses it as an invertible output
/// permutation in css_DecryptKey's chained-XOR rounds; if two inputs
/// collided, the key mangling would not be invertible.
@@ -234,25 +215,6 @@ mod tests {
}
}
/// TAB3's value depends only on the bottom 3 bits and the top group:
/// within a 128-entry block (constant i>>7) every 8-aligned run repeats.
/// Specifically TAB3[i] == TAB3[i & 0x187] (mask keeping bits 0..2 and
/// bits 7..8). This is the structural redundancy the generating formula
/// implies and a different cross-check on the same data.
///
/// Mutation: change TAB3[16] (currently a repeat of TAB3[0]=0x00) to
/// 0x24 -> the repeat check fails.
#[test]
fn tab3_repeats_within_block() {
for (i, &v) in TAB3.iter().enumerate() {
let canonical = (i & 0b1_1000_0111) & 0x1FF;
assert_eq!(
v, TAB3[canonical],
"TAB3[{i:#05x}] should repeat TAB3[{canonical:#05x}]"
);
}
}
/// TAB4 is the exact bit-reversal of each byte (CSS uses it to permute
/// LFSR0 bytes on seed and output). TAB4[b] reverses b's 8 bits MSB<->LSB.
/// Therefore it is also an involution: TAB4[TAB4[b]] == b.
+34 -9
View File
@@ -1307,15 +1307,40 @@ impl Disc {
.read_file(reader, "/AACS/Unit_Key_RO.inf")
.or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
.map_err(|_| Error::AacsNoKeys)?;
let mkb = udf_fs
.read_file(reader, "/AACS/MKB_RO.inf")
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RW.inf"))
.map_err(|_| Error::AacsNoKeys)?;
// Trim trailing padding to the real MKB content length, never zeroing
// an unrecognised MKB (see `crate::aacs::trim_mkb` — restores the
// pre-0.31.0 guard so the online key service never receives an empty
// MKB).
Ok((inf, crate::aacs::trim_mkb(mkb)))
let mkb = Self::read_mkb_content(reader, udf_fs)?;
Ok((inf, mkb))
}
/// Read the AACS MKB's real record stream — NOT its zero padding.
///
/// `MKB_RO.inf` / `MKB_RW.inf` are allocated to a fixed ~128 MiB and
/// zero-padded; the actual record stream is a few MiB. We read a bounded
/// prefix, find the record-stream length via [`crate::aacs::mkb_content_len`]
/// and return exactly that, growing the prefix if the records run past it.
/// This avoids reading 100+ MiB of padding on every scan AND avoids the
/// `read_file` `MAX_FILE_BYTES` cap that (since 0.31.0) rejected the padded
/// 128 MiB MKB outright — which made `read_aacs_inputs` fail and autorip
/// report "could not read this disc's key files" without ever contacting
/// the keyserver.
fn read_mkb_content(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> Result<Vec<u8>> {
const START_BYTES: usize = 16 * 1024 * 1024;
const MAX_BYTES: usize = 64 * 1024 * 1024;
let mut want = START_BYTES;
loop {
let buf = udf_fs
.read_file_prefix(reader, "/AACS/MKB_RO.inf", want)
.or_else(|_| udf_fs.read_file_prefix(reader, "/AACS/MKB_RW.inf", want))
.map_err(|_| Error::AacsNoKeys)?;
let n = crate::aacs::mkb_content_len(&buf);
// `n` strictly inside `buf` => the record walk reached the padding
// boundary (full content captured). `buf` shorter than `want` =>
// the whole file is already read. Otherwise the records may run
// past the prefix — grow and retry, bounded by MAX_BYTES.
if (n > 0 && n < buf.len()) || buf.len() < want || want >= MAX_BYTES {
return Ok(crate::aacs::trim_mkb(buf));
}
want = (want * 2).min(MAX_BYTES);
}
}
/// Read a disc's AACS key-input files from an ISO image: returns
-28
View File
@@ -157,11 +157,6 @@ mod tests {
assert_eq!(mask_string("café9"), "AAAé0");
}
#[test]
fn mask_string_empty_is_empty() {
assert_eq!(mask_string(""), "");
}
#[test]
fn mask_bytes_matches_string_masking_for_ascii() {
// mask_bytes is the byte-wise analogue: letters→b'A', digits→b'0'.
@@ -178,19 +173,6 @@ mod tests {
assert_eq!(mask_bytes(&input), vec![0x00, b'A', 0x20, b'0', 0xFF, b'-']);
}
#[test]
fn mask_bytes_length_preserved() {
// Masking is 1:1 — output length always equals input length so
// fixed-offset fields stay aligned.
let input = vec![0u8; 96];
assert_eq!(mask_bytes(&input).len(), 96);
}
#[test]
fn mask_bytes_empty_is_empty() {
assert!(mask_bytes(&[]).is_empty());
}
#[test]
fn feature_table_has_no_duplicate_codes() {
// capture_drive_data iterates FEATURES once per code; a duplicate
@@ -211,14 +193,4 @@ mod tests {
"AACS feature 0x010D must be captured"
);
}
#[test]
fn feature_table_codes_are_sorted_ascending() {
// The table is maintained in ascending MMC-6 code order; a code
// inserted out of order is a maintenance smell that this pins.
let codes: Vec<u16> = FEATURES.iter().map(|&(c, _)| c).collect();
let mut sorted = codes.clone();
sorted.sort_unstable();
assert_eq!(codes, sorted, "FEATURES must stay in ascending code order");
}
}
-20
View File
@@ -1341,17 +1341,6 @@ mod command_tests {
assert_eq!(d.get_config_feature(0x0000), None);
}
#[test]
fn get_config_feature_clamps_overlong_transfer_count() {
// Doc: a bridge reporting more bytes than the 256-byte buffer
// must be clamped (end = bytes_transferred.min(buf.len())) — no
// slice panic. FixedTransport reports min(payload,buf)=256 here,
// so we get buf[8..256] = 248 bytes, never a panic.
let mut d = drive_with(vec![0xAB; 1024]);
let got = d.get_config_feature(0x010C).unwrap();
assert_eq!(got.len(), 256 - 8, "clamped to buffer, header stripped");
}
// ── report_key / mode_sense / read_buffer empty-vs-some ─────────
#[test]
@@ -1408,15 +1397,6 @@ mod command_tests {
));
}
#[test]
fn ready_predicates_false_without_driver() {
// is_ready / is_unlocked default false when no platform driver.
let d = drive_with(vec![]);
assert!(!d.is_ready());
assert!(!d.is_unlocked());
assert!(!d.has_profile());
}
// ── decode_read_capacity additional boundaries ──────────────────
#[test]
-24
View File
@@ -1455,28 +1455,4 @@ mod tests {
let expected = format!("E{}: 5/3", E_MUX_TRACK_RANGE);
assert_eq!(e.to_string(), expected);
}
/// All keydb error codes are 8xxx.
/// Mutation: creating E_KEYDB_PARSE = 9004 (colliding with E_KEYDB_LOAD range)
/// breaks the CLI's range-based keydb error dispatch.
#[test]
fn keydb_error_codes_all_in_8xxx_range() {
let keydb_codes = [
E_KEYDB_CONNECT,
E_KEYDB_HTTP,
E_KEYDB_INVALID,
E_KEYDB_WRITE,
E_KEYDB_PARSE,
E_KEYDB_LOAD,
E_KEYDB_UNSUPPORTED_SCHEME,
E_KEYDB_TOO_MANY_REDIRECTS,
];
for code in keydb_codes {
assert!(
(8000..9000).contains(&code),
"keydb code {} must be in 8xxx range",
code
);
}
}
}
-189
View File
@@ -127,41 +127,6 @@ pub fn ignore(_event: Event) {}
mod tests {
use super::*;
/// EventKind::BytesRead carries bytes and total as u64.
/// Mutation: making `bytes` a u32 silently truncates progress on large discs (>4 GiB).
#[test]
fn bytes_read_fields_are_u64() {
// A 4K UHD disc is ~100 GiB — bytes must be u64 to hold it.
let event = Event {
kind: EventKind::BytesRead {
bytes: u64::MAX,
total: u64::MAX,
},
};
match event.kind {
EventKind::BytesRead { bytes, total } => {
assert_eq!(bytes, u64::MAX);
assert_eq!(total, u64::MAX);
}
_ => panic!("wrong variant"),
}
}
/// EventKind::SectorSkipped carries a u64 sector number.
/// Mutation: using u32 truncates LBAs > 4 GiB sectors (large BD-R discs).
#[test]
fn sector_skipped_field_is_u64() {
let event = Event {
kind: EventKind::SectorSkipped { sector: u64::MAX },
};
match event.kind {
EventKind::SectorSkipped { sector } => {
assert_eq!(sector, u64::MAX);
}
_ => panic!("wrong variant"),
}
}
/// BatchSizeReason::Shrunk != BatchSizeReason::Probed.
/// These two variants carry distinct meanings (error vs. recovery); they
/// must not compare as equal.
@@ -172,14 +137,6 @@ mod tests {
assert_ne!(BatchSizeReason::Shrunk, BatchSizeReason::Probed);
}
/// BatchSizeReason::Shrunk == BatchSizeReason::Shrunk (reflexive equality).
/// Mutation: a broken PartialEq impl that always returns false would fail this.
#[test]
fn batch_size_reason_eq_is_reflexive() {
assert_eq!(BatchSizeReason::Shrunk, BatchSizeReason::Shrunk);
assert_eq!(BatchSizeReason::Probed, BatchSizeReason::Probed);
}
/// BatchSizeReason is Clone + Copy: cloning does not move the original.
/// This is required because EventKind::BatchSizeChanged embeds it by value.
/// Mutation: removing Copy would require the caller to clone explicitly;
@@ -190,150 +147,4 @@ mod tests {
let _r2 = r; // copy, not move
let _r3 = r; // r still usable after copy
}
/// EventKind::BatchSizeChanged can be constructed and destructured.
/// Mutation: renaming the `reason` field to `cause` breaks all pattern-matches.
#[test]
fn batch_size_changed_constructs_and_destructs() {
let event = Event {
kind: EventKind::BatchSizeChanged {
new_size: 32,
reason: BatchSizeReason::Shrunk,
},
};
match event.kind {
EventKind::BatchSizeChanged { new_size, reason } => {
assert_eq!(new_size, 32);
assert_eq!(reason, BatchSizeReason::Shrunk);
}
_ => panic!("wrong variant"),
}
}
/// EventKind::ExtentStart carries all three fields at u64.
/// Mutation: making start_sector a u32 truncates large-disc LBAs.
#[test]
fn extent_start_fields_are_correct_types() {
let event = Event {
kind: EventKind::ExtentStart {
index: 0,
start_sector: u64::MAX,
sector_count: u64::MAX,
},
};
match event.kind {
EventKind::ExtentStart {
index,
start_sector,
sector_count,
} => {
assert_eq!(index, 0);
assert_eq!(start_sector, u64::MAX);
assert_eq!(sector_count, u64::MAX);
}
_ => panic!("wrong variant"),
}
}
/// EventKind::Complete carries bytes (u64) and errors (u32).
/// Mutation: making bytes a u32 truncates total-bytes-written on large outputs.
#[test]
fn complete_fields_are_correct_types() {
let event = Event {
kind: EventKind::Complete {
bytes: u64::MAX,
errors: u32::MAX,
},
};
match event.kind {
EventKind::Complete { bytes, errors } => {
assert_eq!(bytes, u64::MAX);
assert_eq!(errors, u32::MAX);
}
_ => panic!("wrong variant"),
}
}
/// ignore() accepts any Event variant without panicking.
/// This is trivially true but ensures the function signature matches all
/// EventKind variants (would fail to compile if a new variant is added
/// without updating the function or the test).
/// Mutation: making ignore() generic over a wrong type causes a compile error.
#[test]
fn ignore_accepts_any_event() {
ignore(Event {
kind: EventKind::DriveReady,
});
ignore(Event {
kind: EventKind::BytesRead { bytes: 0, total: 0 },
});
ignore(Event {
kind: EventKind::SectorSkipped { sector: 0 },
});
ignore(Event {
kind: EventKind::Complete {
bytes: 0,
errors: 0,
},
});
ignore(Event {
kind: EventKind::BatchSizeChanged {
new_size: 16,
reason: BatchSizeReason::Probed,
},
});
}
/// EventKind::SpeedChange carries speed_kbs as u16.
/// Spec: 0xFFFF is the sentinel meaning "max speed" (value from CD-ROM MMC spec).
/// Mutation: using u8 for speed_kbs truncates values > 255 to 0 or wrong values.
#[test]
fn speed_change_sentinel_max_is_0xffff() {
let event = Event {
kind: EventKind::SpeedChange { speed_kbs: 0xFFFF },
};
match event.kind {
EventKind::SpeedChange { speed_kbs } => {
assert_eq!(
speed_kbs, 0xFFFF,
"0xFFFF is the max-speed sentinel (MMC spec)"
);
}
_ => panic!("wrong variant"),
}
}
/// EventKind::ReadError carries an error with a sector field.
/// Mutation: using i64 for sector would allow negative sector values (nonsensical).
#[test]
fn read_error_carries_error_and_sector() {
use crate::error::Error;
let event = Event {
kind: EventKind::ReadError {
sector: 99_999,
error: Error::Halted,
},
};
match event.kind {
EventKind::ReadError { sector, .. } => {
assert_eq!(sector, 99_999u64);
}
_ => panic!("wrong variant"),
}
}
/// EventKind::Retry carries attempt as u32 (1-based).
/// Mutation: u8 for attempt overflows after 255 retries without warning.
#[test]
fn retry_attempt_is_u32() {
let event = Event {
kind: EventKind::Retry { attempt: u32::MAX },
};
match event.kind {
EventKind::Retry { attempt } => {
assert_eq!(attempt, u32::MAX);
}
_ => panic!("wrong variant"),
}
}
}
-80
View File
@@ -177,73 +177,6 @@ mod tests {
// ── New comprehensive tests ────────────────────────────────────────────────
/// Default::default() produces a fresh, uncancelled token (same as new()).
/// Spec: doc says "The Default impl forwards to new() — both produce a fresh,
/// uncancelled token."
/// Mutation: having Default initialize to `cancelled=true` would break all
/// callers that rely on a default-constructed Halt being uncancelled.
#[test]
fn default_produces_uncancelled_token() {
let h = Halt::default();
assert!(
!h.is_cancelled(),
"Default::default() must produce uncancelled token"
);
}
/// Multiple clones of the same Halt all observe a cancel from any one of them.
/// Mutation: cloning the Arc by value (separate allocation) means clones don't share state.
#[test]
fn multiple_clones_all_share_same_flag() {
let h0 = Halt::new();
let h1 = h0.clone();
let h2 = h0.clone();
let h3 = h1.clone();
// None cancelled yet.
assert!(!h0.is_cancelled());
assert!(!h1.is_cancelled());
assert!(!h2.is_cancelled());
assert!(!h3.is_cancelled());
// Cancel via h2; all others must observe it.
h2.cancel();
assert!(h0.is_cancelled());
assert!(h1.is_cancelled());
assert!(h3.is_cancelled());
}
/// is_cancelled is non-destructive — reading the flag multiple times returns
/// the same result.
/// Mutation: using swap(false) instead of load would clear the flag on read.
#[test]
fn is_cancelled_is_non_destructive() {
let h = Halt::new();
h.cancel();
assert!(h.is_cancelled());
assert!(h.is_cancelled(), "second read must also return true");
assert!(h.is_cancelled(), "third read must also return true");
}
/// from_arc followed by cancel(), then as_arc() load: the raw Arc must see the write.
/// This is the round-trip that proves from_arc and as_arc are exact inverses.
/// Mutation: from_arc doing `Arc::new(flag.load(...))` (copy not share) breaks this.
#[test]
fn from_arc_and_as_arc_are_inverses() {
let original = Arc::new(AtomicBool::new(false));
let halt = Halt::from_arc(original.clone());
// Cancel via the Halt; read via the original Arc.
halt.cancel();
assert!(
original.load(Ordering::Relaxed),
"cancel() must be visible via the original Arc"
);
// The Arc retrieved by as_arc() must be the same one.
let retrieved = halt.as_arc();
assert!(
std::ptr::eq(Arc::as_ptr(retrieved), Arc::as_ptr(&original)),
"as_arc must return the same Arc pointer as was passed to from_arc"
);
}
/// POLL_INTERVAL is 250ms — a specific value that the multi-thread halt
/// loops depend on for responsiveness guarantees.
/// Mutation: setting POLL_INTERVAL to 5s makes stop requests take 5s to notice.
@@ -255,17 +188,4 @@ mod tests {
"POLL_INTERVAL must be 250ms for the guaranteed ~quarter-second cancel latency"
);
}
/// cancel() then clone: the clone of an already-cancelled Halt starts cancelled.
/// Mutation: cloning by re-reading the bool (not the Arc) would give a fresh false.
#[test]
fn clone_of_cancelled_halt_is_also_cancelled() {
let h = Halt::new();
h.cancel();
let cloned = h.clone();
assert!(
cloned.is_cancelled(),
"clone of a cancelled Halt must itself be cancelled"
);
}
}
-65
View File
@@ -326,39 +326,6 @@ mod tests {
);
}
/// match_key trims whitespace from all four fields.
/// Spec: comment says "All fields trimmed for consistent matching."
/// Mutation: removing .trim() from one field adds trailing spaces to the key.
#[test]
fn match_key_trims_all_fields() {
let mut inquiry = vec![0u8; 96];
// Pad vendor_id and product_id with trailing spaces (as drives do).
inquiry[8..16].copy_from_slice(b"HL-DT-ST"); // no padding room
inquiry[16..32].copy_from_slice(b"BD-RE BU40N "); // 5 trailing spaces
inquiry[32..36].copy_from_slice(b"1.03");
inquiry[36..43].copy_from_slice(b"NM00000");
let id = DriveId::from_inquiry(&inquiry, "211810241934");
// No trailing spaces in the key.
assert_eq!(id.match_key(), "HL-DT-ST|BD-RE BU40N|1.03|NM00000");
}
/// Display trims all four fields and does not include the firmware date.
/// Mutation: not trimming product_id adds trailing spaces to the display string.
#[test]
fn display_trims_fields() {
let mut inquiry = vec![0u8; 96];
inquiry[8..16].copy_from_slice(b"HL-DT-ST");
inquiry[16..32].copy_from_slice(b"BD-RE BU40N ");
inquiry[32..36].copy_from_slice(b"1.03");
inquiry[36..43].copy_from_slice(b"NM00000");
let id = DriveId::from_inquiry(&inquiry, "ignored");
let s = id.to_string();
// No double spaces from un-trimmed padding.
assert!(!s.contains(" "), "display must trim fields: `{s}`");
assert!(s.contains("HL-DT-ST"), "vendor present: `{s}`");
assert!(s.contains("BD-RE BU40N"), "product present: `{s}`");
}
/// from_inquiry stores the raw inquiry bytes in raw_inquiry unchanged.
/// Mutation: copying only a slice of inquiry into raw_inquiry truncates it.
#[test]
@@ -372,23 +339,6 @@ mod tests {
);
}
/// from_inquiry leaves serial_number and raw_gc_010c empty.
/// These are only available from a live drive probe via from_drive().
/// Mutation: populating serial_number in from_inquiry would violate the contract.
#[test]
fn from_inquiry_leaves_serial_and_gc_empty() {
let inquiry = vec![0u8; 96];
let id = DriveId::from_inquiry(&inquiry, "");
assert!(
id.serial_number.is_empty(),
"serial_number must be empty from from_inquiry"
);
assert!(
id.raw_gc_010c.is_empty(),
"raw_gc_010c must be empty from from_inquiry"
);
}
/// GET CONFIGURATION failure (transport error) must not abort the
/// identity probe — firmware_date is empty, raw_gc_010c is empty.
/// Mutation: propagating the GET_CONFIGURATION error with `?` aborts from_drive.
@@ -435,19 +385,4 @@ mod tests {
"raw_gc_010c must be empty when GC fails"
);
}
/// match_key uses '|' as the separator between all four fields.
/// Mutation: using ':' or ' ' as separator changes the key format.
#[test]
fn match_key_uses_pipe_separator() {
let inquiry = vec![0u8; 96];
let id = DriveId::from_inquiry(&inquiry, "");
let key = id.match_key();
// Should have exactly 3 pipes (4 fields separated by 3 '|' chars).
let pipe_count = key.chars().filter(|&c| c == '|').count();
assert_eq!(
pipe_count, 3,
"match_key must have exactly 3 '|' separators, got {pipe_count} in `{key}`"
);
}
}
-64
View File
@@ -999,15 +999,6 @@ mod tests {
assert!((secs - 10.0 * 3600.0).abs() < 0.01, "got {secs}");
}
/// bcd_byte boundary: 0x9A has lo=0xA (>9) → invalid → 0. And 0xA0 has
/// hi=0xA (>9) → 0. Confirms BOTH nibbles are validated.
#[test]
fn bcd_byte_partial_invalid_nibble() {
assert_eq!(bcd_byte(0x9A), 0); // lo nibble invalid
assert_eq!(bcd_byte(0xA0), 0); // hi nibble invalid
assert_eq!(bcd_byte(0x90), 90); // both valid
}
/// sub_slice uses saturating_add so an offset near usize::MAX cannot
/// wrap and bypass the bounds check. Must return Err, not panic/OOB.
#[test]
@@ -1068,17 +1059,6 @@ mod tests {
assert_eq!(attr2.codec, Codec::Unknown(1));
}
/// Audio channels = (b1>>4 & 0x0F) + 1 (stored as channels-minus-1).
/// b1 = 0x70 → 7+1 = 8 channels (7.1). Verify the +1 and nibble.
#[test]
fn audio_attr_channel_count_plus_one() {
let mut data = vec![0u8; 8];
data[0] = 0x00; // AC3
data[1] = 0x70; // channels-1 = 7
let attr = parse_audio_attr(&data, 0).unwrap();
assert_eq!(attr.channels, 8);
}
/// Audio language bytes [offset+2..+4]: when both bytes are 0x00 the
/// language is the empty string (unspecified), per source.
#[test]
@@ -1169,40 +1149,6 @@ mod tests {
assert_eq!(streams[8].sub_stream_id, Some(0x87));
}
/// parse_pgc: cells are 24-byte records; first_sector at cell+8,
/// last_sector at cell+20 (both u32 BE). The cell table starts at
/// PGC + cell_playback_offset (read from PGC+0xE8 as u16 BE). Build a
/// PGC with a non-trivial cell_playback_offset and verify cells.
#[test]
fn pgc_cell_offsets_first_and_last_sector() {
let mut pgc = vec![0u8; 0xEA];
pgc[0x02] = 1; // nr_programs
pgc[0x03] = 1; // nr_cells = 1
// PGC-level BCD time zero so duration is recomputed from cells.
// cell_playback_offset at 0xE8 (u16 BE) = 0xEA.
pgc[0xE8] = 0x00;
pgc[0xE9] = 0xEA;
pgc.resize(0xEA + 24, 0);
let co = 0xEA;
// cell BCD time at +4..+8: 0h 0m 10s, no frames.
pgc[co + 6] = 0x10; // seconds BCD 10
// first_sector at +8 = 0x000004D2 = 1234
pgc[co + 8..co + 12].copy_from_slice(&1234u32.to_be_bytes());
// last_sector at +20 = 0x0000162E = 5678
pgc[co + 20..co + 24].copy_from_slice(&5678u32.to_be_bytes());
let title = parse_pgc(&pgc, 0, 3).unwrap();
assert_eq!(title.cells.len(), 1);
assert_eq!(title.cells[0].first_sector, 1234);
assert_eq!(title.cells[0].last_sector, 5678);
// PGC time was 0 → recomputed from cell time = 10s.
assert!(
(title.duration_secs - 10.0).abs() < 0.01,
"got {}",
title.duration_secs
);
}
/// parse_pgc requires `pgc_offset + 0xEA <= data.len()` (needs the cell
/// playback offset at 0xE8). A PGC shorter than 0xEA → IfoParse error,
/// not panic.
@@ -1336,16 +1282,6 @@ mod tests {
);
}
/// VMG magic check: parse_video_attr et al. aside, the top-level VMG
/// must start with "DVDVIDEO-VMG". We exercise the constant directly to
/// guard against an accidental edit to the 12-byte magic.
#[test]
fn vmg_vts_magic_constants() {
assert_eq!(VMG_MAGIC, b"DVDVIDEO-VMG");
assert_eq!(VTS_MAGIC, b"DVDVIDEO-VTS");
assert_eq!(SECTOR_SIZE, 2048);
}
/// parse_pgc with cell_playback_offset == 0 must produce NO cells (the
/// `cell_playback_offset > 0 && num_cells > 0` guard). Even with
/// nr_cells set, a zero offset means the table is absent.
-53
View File
@@ -269,32 +269,6 @@ mod tests {
);
}
/// Boundary: an op that finishes well within the deadline returns
/// Ok even when a (live, never-cancelled) halt token is supplied.
/// The halt-poll path must not spuriously convert a completed op
/// into Halted/Timeout. Grounds the `Ok(v) => return Ok(v)` arm of
/// the recv_timeout match (line 134) with a non-None halt.
#[test]
fn live_halt_token_does_not_interfere_with_fast_op() {
let halt = Halt::new(); // never cancelled
let r = bounded_syscall(Some(&halt), Duration::from_secs(5), || 123u64);
assert!(matches!(r, Ok(123)));
assert!(!halt.is_cancelled());
}
/// The op's return value is propagated byte-for-byte, not just a
/// success flag. A non-Copy heap type proves the worker's
/// `tx.send(op())` moves the real value across the rendezvous
/// channel (line 125) to the receiver (line 134).
#[test]
fn returns_owned_value_unchanged() {
let r = bounded_syscall(None, Duration::from_secs(2), || vec![9u8, 8, 7, 6]);
match r {
Ok(v) => assert_eq!(v, vec![9u8, 8, 7, 6]),
other => panic!("expected Ok(vec), got {other:?}"),
}
}
/// Timeout boundary: with a tiny deadline and an op that sleeps
/// much longer, the helper must return Timeout and must do so
/// roughly at the deadline — NOT wait for the op to finish (that
@@ -318,31 +292,4 @@ mod tests {
"timeout did not return near deadline: {elapsed:?} (op should be leaked, not awaited)"
);
}
/// A worker that returns a non-Copy value AND completes within the
/// deadline must hand the value back; the rendezvous channel has
/// capacity 0, so the worker's send blocks until the receiver is
/// ready — exercising the happy-path handshake rather than the
/// buffered-send path. Mutation: changing `sync_channel::<R>(0)` to
/// a buffered channel would still pass; changing the recv arm to
/// drop the value would fail here.
#[test]
fn zero_capacity_rendezvous_delivers_string() {
let r = bounded_syscall(None, Duration::from_secs(2), || String::from("rendezvous"));
assert!(matches!(r.as_deref(), Ok("rendezvous")));
}
/// Halt that fires AFTER the op has already completed must still
/// yield Ok — there is no race that turns a delivered result into
/// Halted. The op completes instantly; we cancel the halt
/// afterwards and confirm the earlier call returned Ok. This pins
/// the precedence: a value already in the channel wins over a
/// subsequent halt.
#[test]
fn op_completion_wins_over_later_halt() {
let halt = Halt::new();
let r = bounded_syscall(Some(&halt), Duration::from_secs(2), || 55u32);
halt.cancel();
assert!(matches!(r, Ok(55)));
}
}
-19
View File
@@ -423,25 +423,6 @@ mod tests {
});
}
/// Backpressure / recycle exhaustion does not deadlock: a source
/// larger than the whole in-flight pool (FORWARD_DEPTH +
/// RECYCLE_DEPTH chunks) must still drain fully when the consumer
/// recycles. 10 chunks of 256 bytes = 2560 bytes; pool holds far
/// fewer. Proves the producer parks on recycle_rx and resumes as
/// the consumer returns buffers (lines 106-117). Mutation: dropping
/// the recycle seed loop (lines 90-92) would deadlock on the first
/// recv and within() times out.
#[test]
fn large_source_drains_with_recycling() {
within(10, || {
let src: Vec<u8> = (0..2560u32).map(|i| (i % 251) as u8).collect();
let pf = BytePrefetcher::new(Cursor::new(src.clone()), 256, None).expect("spawn");
let (got, err) = drain_to_vec(pf);
assert!(err.is_none());
assert_eq!(got, src);
});
}
/// Exact-multiple boundary: when the source length is an exact
/// multiple of chunk_bytes, the final non-empty chunk is followed
/// by an `Ok(0)` EOF read, NOT a spurious empty Ok batch. 12 bytes
-35
View File
@@ -416,20 +416,6 @@ mod tests {
);
}
/// Reading entirely beyond EOF (seek lands past the end) must also
/// error rather than silently return zeros. Grounding: read_exact
/// over an empty remainder is UnexpectedEof.
#[test]
fn read_wholly_beyond_eof_errors() {
let dir = tempdir().unwrap();
let path = dir.path().join("beyond.iso");
make_iso(&path, 4);
let mut src = FileSectorSource::open(&path).unwrap();
let mut buf = vec![0u8; SECTOR_SIZE];
let r = src.read_sectors(10, 1, &mut buf, false);
assert!(r.is_err(), "read starting past EOF must error");
}
/// On a successful full read the returned count MUST equal
/// `count * 2048` exactly — the declared byte count. Grounding:
/// `Ok(bytes)` where `bytes = count * SECTOR_SIZE`.
@@ -496,27 +482,6 @@ mod tests {
assert_eq!(io.kind(), std::io::ErrorKind::NotFound);
}
/// Repeated reads of the SAME sector must return identical bytes —
/// the per-read seek makes each call independent of prior position,
/// and the DONTNEED/prefetch hooks are advisory only (no data
/// effect). Grounding: `seek(SeekFrom::Start(offset))` before every
/// read.
#[test]
fn repeated_same_sector_is_stable() {
let dir = tempdir().unwrap();
let path = dir.path().join("stable.iso");
make_iso(&path, 8);
let mut src = FileSectorSource::open(&path).unwrap();
let mut a = vec![0u8; SECTOR_SIZE];
let mut b = vec![0u8; SECTOR_SIZE];
src.read_sectors(5, 1, &mut a, false).unwrap();
// Read a different sector in between to move the file cursor.
src.read_sectors(0, 1, &mut b, false).unwrap();
src.read_sectors(5, 1, &mut b, false).unwrap();
assert_eq!(a, b, "same-LBA reads must be position-independent");
assert!(a.iter().all(|x| *x == (5u8)));
}
/// A DONTNEED drop crossing the chunk threshold must not corrupt or
/// short subsequent reads — the eviction is a pure page-cache hint.
/// We read past the DEFAULT 32 MiB drop chunk (16384 sectors) so the
-54
View File
@@ -926,22 +926,6 @@ mod tests {
}
}
/// FIFO ordering: items must be delivered to `apply` in send order.
/// crossbeam's `bounded` channel is FIFO; this pins that the
/// pipeline does not reorder. Mutation: if the consumer loop reused
/// a stale item or sorted, the equality fails.
#[test]
fn items_delivered_in_fifo_order() {
let pipe =
Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, OrderSink { seen: Vec::new() }).expect("spawn");
let input: Vec<u64> = (0..50).map(|i| i * 7 + 1).collect();
for &i in &input {
pipe.send(i).expect("send");
}
let seen = pipe.finish().expect("finish");
assert_eq!(seen, input, "pipeline reordered or dropped items");
}
/// Zero items sent: closing the pipeline immediately must still
/// call `close()` exactly once and return its Output. The consumer
/// loop's `while let Ok = rx.recv()` exits on the dropped tx with
@@ -1117,23 +1101,6 @@ mod tests {
assert!(!halt.is_cancelled(), "halt must not have been the cause");
}
/// `send_with_halt` happy path: when there is room in the channel
/// it must deliver the item (Ok) and the consumer must process it.
/// Pins the `Ok(()) => return Ok(())` arm (line 390). Mutation:
/// inverting that arm to Err would drop the item and the sum would
/// be wrong.
#[test]
fn send_with_halt_delivers_when_room_available() {
let pipe = Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, SumSink { total: 0 }).expect("spawn");
let halt = crate::halt::Halt::new();
for i in 1..=5u64 {
pipe.send_with_halt(i, &halt, Duration::from_secs(5))
.expect("send_with_halt should deliver when room is available");
}
let total = pipe.finish().expect("finish");
assert_eq!(total, 15, "1+2+3+4+5");
}
/// `send_with_halt` with a pre-cancelled halt must return the item
/// immediately without attempting to enqueue. Pins the pre-check at
/// line 365 (`if halt.is_cancelled()`). Mutation: removing that
@@ -1154,27 +1121,6 @@ mod tests {
assert_eq!(total, 0, "item was enqueued despite pre-cancelled halt");
}
/// `finish_with_halt` must propagate a consumer panic as
/// `PipelineConsumerPanicked` — same as `finish`. The consumer
/// panics on the first apply; finish_with_halt sees `is_finished()`
/// true and joins, mapping the panic payload (lines 454-458).
#[test]
fn finish_with_halt_propagates_consumer_panic() {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let pipe = Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, PanickingSink).expect("spawn");
let _ = pipe.send(1);
for i in 0..5u64 {
let _ = pipe.send(i);
}
let res = pipe.finish_with_halt(None);
std::panic::set_hook(prev);
assert!(
matches!(res, Err(Error::PipelineConsumerPanicked)),
"expected PipelineConsumerPanicked, got {res:?}"
);
}
/// `finish_with_halt(None)` with a wedged consumer and NO halt
/// token must NOT return early — it must keep polling until the
/// JOIN_TIMEOUT_SECS deadline (it cannot observe a halt that was
-13
View File
@@ -230,17 +230,4 @@ mod tests {
);
assert_eq!(&bytes[10..14], b"TAIL");
}
/// `write` (single call) returns the BufWriter's accepted count.
/// For a buffer under the 4 MiB capacity this is the full length
/// (lines 108-110). Mutation: a wrong count return would break
/// callers relying on `Write::write`'s contract.
#[test]
fn write_returns_full_count_under_capacity() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("count.bin");
let mut s = LocalFileSink::create(&p).unwrap();
let n = s.write(&[1u8; 1000]).unwrap();
assert_eq!(n, 1000);
}
}
-34
View File
@@ -216,24 +216,6 @@ mod tests {
assert_eq!(bytes.load(Ordering::SeqCst), 3);
}
/// Default `finish()` dispatched through a `dyn SequentialSink`
/// trait object must still reach the default `flush` (vtable path).
/// This guards that there is no accidental override that turns the
/// default into a no-op via dyn dispatch.
#[test]
fn default_finish_flushes_through_dyn() {
let flushed = Arc::new(AtomicBool::new(false));
let bytes = Arc::new(AtomicUsize::new(0));
let sink = FlushTracker {
flushed: flushed.clone(),
bytes: bytes.clone(),
};
let mut boxed: Box<dyn SequentialSink> = Box::new(sink);
boxed.write_all(b"xy").unwrap();
boxed.finish().unwrap();
assert!(flushed.load(Ordering::SeqCst));
}
/// `open_for_mkv` with `None` size hint must still produce a working
/// random-access sink (the `match size_hint { None => ... }` arm,
/// lines 103-106). Round-trip a seek-back patch through it to prove
@@ -252,20 +234,4 @@ mod tests {
drop(sink);
assert_eq!(std::fs::read(&p).unwrap(), b"AAAACCCC");
}
/// finish() through a `dyn RandomAccessSink` (the supertrait of
/// SequentialSink) for a LocalFileSink must also flush+fsync. The
/// existing regression test boxes as `dyn SequentialSink`; this
/// pins the `dyn RandomAccessSink` vtable path too, since
/// `open_for_mkv` returns exactly that boxed type.
#[test]
fn finish_through_random_access_dyn_persists() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("ra-finish.bin");
let mut sink: Box<dyn RandomAccessSink> = open_for_mkv(&p, None).unwrap();
sink.write_all(b"durable").unwrap();
sink.finish().unwrap();
// Visible to a separate reader before drop.
assert_eq!(&std::fs::read(&p).unwrap()[..], b"durable");
}
}
-21
View File
@@ -328,27 +328,6 @@ mod tests {
assert_eq!(received, b"unflushed-tail");
}
/// `SocketSink::write` must report the exact byte count it accepted
/// into the BufWriter (forwarded from `BufWriter::write`, lines
/// 78-80). For a buffer smaller than the 1 MiB capacity this equals
/// the full length. Mutation: returning a wrong/clamped count would
/// break `Write::write_all`'s loop downstream; we pin the count
/// here directly.
#[test]
fn write_reports_accepted_count() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let _accept = thread::spawn(move || {
let _ = listener.accept();
});
let mut sink = SocketSink::connect(addr, None).unwrap();
let n = sink.write(&[7u8; 100]).unwrap();
assert_eq!(
n, 100,
"buffered write under capacity must accept all bytes"
);
}
/// UDP `write` must emit ONE datagram per call carrying exactly the
/// bytes passed — no buffering, no coalescing (doc lines 100-108).
/// Two writes of different lengths must arrive as two separate
-24
View File
@@ -522,30 +522,6 @@ mod tests {
assert_eq!(&bytes, b"hello");
}
/// `flush` must not be a durability barrier nor reorder bytes, but
/// it also must not lose buffered data. We interleave write_all and
/// flush and confirm exact byte order survives to disk. (Distinct
/// from the existing `flush_is_observed_in_order` which uses 3
/// words; this exercises many small flushes to stress the
/// passthrough flush path at line 199-201.) Mutation: if `flush`
/// dropped pending bytes the reassembly fails.
#[test]
fn many_interleaved_flushes_preserve_order() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("many-flush.bin");
let mut w = WritebackFile::create(&p).unwrap();
let mut expected = Vec::new();
for i in 0u8..32 {
let chunk = [i; 4];
w.write_all(&chunk).unwrap();
expected.extend_from_slice(&chunk);
w.flush().unwrap();
}
w.sync_all().unwrap();
drop(w);
assert_eq!(read_back(&p), expected);
}
/// `sync_all` is idempotent: calling it twice (and then Drop, which
/// also finalizes) must not corrupt data or panic. Doc lines
/// 256-262: `finalize` is idempotent so explicit sync_all then drop
-77
View File
@@ -410,48 +410,6 @@ mod tests {
assert_eq!(val, "/new/path", "value must be trimmed");
}
/// parse_url with no explicit port defaults to 80.
/// Spec: HTTP default port is 80 (RFC 7230 §2.7.1).
/// Mutation: defaulting to 443 instead makes plain-HTTP URLs go to the wrong port.
#[test]
fn parse_url_no_port_defaults_to_80() {
let (_, port, _) = parse_url("http://example.com/path").unwrap();
assert_eq!(port, 80, "default HTTP port must be 80 (RFC 7230 §2.7.1)");
}
/// parse_url with explicit port parses it correctly.
/// Mutation: ignoring the port component and defaulting to 80 changes the port.
#[test]
fn parse_url_explicit_port_is_parsed() {
let (host, port, path) = parse_url("http://mirror.example:9000/key.zip").unwrap();
assert_eq!(host, "mirror.example");
assert_eq!(port, 9000);
assert_eq!(path, "/key.zip");
}
/// parse_url with no path component yields "/" as the path.
/// RFC 7230 §5.3.1: origin-form must start with "/"; empty → root.
/// Mutation: returning "" as the path makes the HTTP request malformed.
#[test]
fn parse_url_no_path_yields_root() {
let (_, _, path) = parse_url("http://example.com").unwrap();
assert_eq!(
path, "/",
"missing path must default to '/' (RFC 7230 §5.3.1)"
);
}
/// parse_url rejects a URL whose scheme is not http.
/// Mutation: accepting ftp:// silently leads to a TCP connection receiving
/// binary FTP data instead of HTTP.
#[test]
fn parse_url_rejects_ftp_scheme() {
assert!(matches!(
parse_url("ftp://ftp.example.com/file"),
Err(Error::KeydbUnsupportedScheme { .. })
));
}
/// save() rejects data that is not a valid keydb (no recognisable entries).
/// Spec: entries are lines starting with "0x", "| DK", "| PK", or "| HC".
/// Mutation: dropping the entries==0 check lets an empty file be saved.
@@ -589,32 +547,6 @@ mod tests {
assert!(result.is_ok(), "exactly MAX_KEYDB_BYTES must be accepted");
}
/// parse_url path round-trips: the extracted path is the same string that
/// was in the URL.
/// Mutation: dropping the leading '/' from the path breaks the HTTP request.
#[test]
fn parse_url_path_includes_leading_slash() {
let (_, _, path) = parse_url("http://example.com/a/b/c.zip").unwrap();
assert!(
path.starts_with('/'),
"path must start with '/', got `{path}`"
);
assert_eq!(path, "/a/b/c.zip");
}
/// resolve_redirect with an absolute http URL parses it fresh
/// (ignores the current host/port entirely).
/// Mutation: keeping the current host instead of parsing the new one
/// points the next request at the wrong server.
#[test]
fn resolve_redirect_absolute_http_ignores_current_host() {
let (h, p, path) =
resolve_redirect("http://new.host:8080/k.zip", "old.host", 9000).unwrap();
assert_eq!(h, "new.host");
assert_eq!(p, 8080);
assert_eq!(path, "/k.zip");
}
/// parse_status returns 0 for an empty status line (not a panic).
/// Mutation: calling unwrap() instead of unwrap_or(0) panics on empty input.
#[test]
@@ -622,13 +554,4 @@ mod tests {
assert_eq!(parse_status(""), 0);
assert_eq!(parse_status("\r\n"), 0);
}
/// parse_status handles HTTP/1.0 and HTTP/1.1 both.
/// Mutation: only matching "HTTP/1.0 " misses HTTP/1.1 responses.
#[test]
fn parse_status_handles_http_versions() {
assert_eq!(parse_status("HTTP/1.0 404 Not Found"), 404);
assert_eq!(parse_status("HTTP/1.1 200 OK"), 200);
assert_eq!(parse_status("HTTP/1.1 302 Found\r\nLocation: /new"), 302);
}
}
-186
View File
@@ -101,103 +101,6 @@ mod tests {
// ── DiscInputs structural tests ────────────────────────────────────────────
/// DiscInputs can be constructed with all-zero volume_id ([0u8;16]) to
/// represent "no authenticated handshake ran".
/// Spec: doc says "[0u8; 16] when no authenticated handshake ran".
/// Mutation: using Option<[u8;16]> would require callers to handle None explicitly.
#[test]
fn disc_inputs_zero_volume_id_represents_no_handshake() {
let inputs = DiscInputs {
disc_hash: "0x1234".to_string(),
volume_id: [0u8; 16],
mkb: Vec::new(),
unit_key_ro: Vec::new(),
samples: Vec::new(),
volume_label: None,
};
assert_eq!(
inputs.volume_id, [0u8; 16],
"all-zero volume_id must be valid (represents no handshake)"
);
}
/// DiscInputs disc_hash is a string in "0x"-prefixed hex format.
/// Spec: doc says "SHA-1 of Unit_Key_RO.inf, 0x-prefixed hex."
/// Mutation: storing the hash without the "0x" prefix would silently change
/// the keydb lookup key format.
#[test]
fn disc_inputs_disc_hash_is_0x_prefixed() {
let hash = "0xabcdef0123456789abcdef0123456789abcdef01".to_string();
let inputs = DiscInputs {
disc_hash: hash.clone(),
volume_id: [0u8; 16],
mkb: Vec::new(),
unit_key_ro: Vec::new(),
samples: Vec::new(),
volume_label: None,
};
assert!(
inputs.disc_hash.starts_with("0x"),
"disc_hash must be 0x-prefixed per spec"
);
assert_eq!(
inputs.disc_hash.len(),
42,
"SHA-1 in 0x-prefixed hex: 2 ('0x') + 40 (20 bytes hex) = 42 chars"
);
}
/// DiscInputs samples is intentionally empty by default (filled by caller).
/// Spec: doc says "Populated by the application — libfreemkv::Disc::inputs
/// leaves it empty for the caller to fill."
/// Mutation: auto-filling samples in Disc::inputs would force all callers
/// to read content data even for local keydb lookups.
#[test]
fn disc_inputs_samples_defaults_to_empty() {
let inputs = DiscInputs {
disc_hash: "0x0000000000000000000000000000000000000000".to_string(),
volume_id: [0u8; 16],
mkb: Vec::new(),
unit_key_ro: Vec::new(),
samples: Vec::new(),
volume_label: None,
};
assert!(
inputs.samples.is_empty(),
"samples must start empty — populated by the application, not Disc::inputs"
);
}
/// DiscInputs volume_label is Option<String>: None means not captured.
/// Spec: doc says "None when not captured."
/// Mutation: using an empty string instead of None would conflate "not captured"
/// with "the disc has an empty label" — a semantic difference.
#[test]
fn disc_inputs_volume_label_none_vs_some() {
let no_label = DiscInputs {
disc_hash: "0x0000000000000000000000000000000000000000".to_string(),
volume_id: [0u8; 16],
mkb: Vec::new(),
unit_key_ro: Vec::new(),
samples: Vec::new(),
volume_label: None,
};
assert!(
no_label.volume_label.is_none(),
"not-captured label must be None"
);
let with_label = DiscInputs {
disc_hash: "0x0000000000000000000000000000000000000000".to_string(),
volume_id: [0u8; 16],
mkb: Vec::new(),
unit_key_ro: Vec::new(),
samples: Vec::new(),
volume_label: Some("WICKED_FOR_GOOD".to_string()),
};
assert_eq!(with_label.volume_label.as_deref(), Some("WICKED_FOR_GOOD"));
}
// ── KeySource default-method behaviour ────────────────────────────────────
/// KeySource::needs_samples() defaults to false.
@@ -231,93 +134,4 @@ mod tests {
let s = MinimalSource;
assert!(!s.errored(), "errored must default to false");
}
/// A source that returns None and has errored()==true can be distinguished
/// from a source that simply has no key.
/// Spec: doc says "After exhaustion the caller must consult errored()".
/// Mutation: errored() always returning false hides network/parse failures.
#[test]
fn errored_source_is_distinguishable_from_empty_source() {
struct FailedSource;
impl KeySource for FailedSource {
fn next_key(&mut self, _inputs: &DiscInputs) -> Option<Key> {
None
}
fn errored(&self) -> bool {
true
}
}
struct EmptySource;
impl KeySource for EmptySource {
fn next_key(&mut self, _inputs: &DiscInputs) -> Option<Key> {
None
}
// errored() defaults to false
}
let inputs = DiscInputs {
disc_hash: String::new(),
volume_id: [0u8; 16],
mkb: vec![],
unit_key_ro: vec![],
samples: vec![],
volume_label: None,
};
let mut failed = FailedSource;
let mut empty = EmptySource;
// Both return None (exhausted).
assert!(failed.next_key(&inputs).is_none());
assert!(empty.next_key(&inputs).is_none());
// But only FailedSource reports an error.
assert!(failed.errored(), "FailedSource must report errored=true");
assert!(!empty.errored(), "EmptySource must report errored=false");
}
/// DiscInputs mkb field stores raw MKB bytes and can be empty.
/// Mutation: using Option<Vec<u8>> for mkb forces callers to handle Option.
#[test]
fn disc_inputs_mkb_can_be_empty_or_populated() {
let empty_mkb = DiscInputs {
disc_hash: String::new(),
volume_id: [0u8; 16],
mkb: Vec::new(),
unit_key_ro: Vec::new(),
samples: Vec::new(),
volume_label: None,
};
assert!(empty_mkb.mkb.is_empty());
let populated_mkb = DiscInputs {
disc_hash: String::new(),
volume_id: [0u8; 16],
mkb: vec![0x01, 0x02, 0x03],
unit_key_ro: vec![0xFF],
samples: Vec::new(),
volume_label: None,
};
assert_eq!(populated_mkb.mkb, vec![0x01, 0x02, 0x03]);
assert_eq!(populated_mkb.unit_key_ro, vec![0xFF]);
}
/// A source that overrides needs_samples() to true is handled correctly.
/// Mutation: ignoring the needs_samples() return means online sources
/// never get the ciphertext samples they need for validation.
#[test]
fn needs_samples_can_be_overridden_to_true() {
struct SamplesNeededSource;
impl KeySource for SamplesNeededSource {
fn next_key(&mut self, _inputs: &DiscInputs) -> Option<Key> {
None
}
fn needs_samples(&self) -> bool {
true
}
}
let s = SamplesNeededSource;
assert!(
s.needs_samples(),
"an online source that validates against ciphertext must return needs_samples=true"
);
}
}
-167
View File
@@ -485,18 +485,6 @@ mod tests {
assert_eq!(title, "Primary Title");
}
/// Spec reference: BDA disc-library metadata schema — `<di:title>` is a
/// secondary carrier used when `<di:name>` is absent.
/// Mutation: insert a `<di:name>` element → test goes red (di:name wins).
#[test]
fn di_title_used_when_no_di_name() {
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:title>Fallback Title</di:title>
</discInfo>"#;
let (title, _, _) = parse_bdmt_xml(xml).unwrap();
assert_eq!(title, "Fallback Title");
}
/// Spec reference: BDA disc-library metadata §3.3.2 — `<di:tableOfContents>`
/// with nested `<di:titleName>` is a vendor-specific variant.
/// Mutation: rename `titleName` → `movieName` → test goes red (None).
@@ -513,20 +501,6 @@ mod tests {
assert_eq!(title, "Winner");
}
/// Spec reference: BDA disc-library metadata §3.3.2 — titleName inside
/// tableOfContents is the last-resort title fallback.
/// Mutation: rename `titleName` to `movieTitle` → test goes red (None returned).
#[test]
fn table_of_contents_title_name_is_last_resort() {
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:tableOfContents>
<di:titleName>TOC Title</di:titleName>
</di:tableOfContents>
</discInfo>"#;
let (title, _, _) = parse_bdmt_xml(xml).unwrap();
assert_eq!(title, "TOC Title");
}
/// Spec reference: BDA §3.3.2 — an empty `<di:name>` element must be
/// treated as absent, falling through to the next candidate.
/// Mutation: change `<di:name></di:name>` to `<di:name>X</di:name>` → red.
@@ -540,85 +514,6 @@ mod tests {
assert_eq!(title, "Non-Empty Title");
}
/// Spec reference: BDA §3.3.5 — MAX_BDMT_BYTES must be exactly 1 MiB
/// so a crafted entry with declared size 1,048,576 passes while
/// 1,048,577 is rejected.
/// Mutation: change MAX_BDMT_BYTES from 1_048_576 to e.g. 512*1024 → boundary test red.
#[test]
fn max_bdmt_bytes_boundary_exact_1mib() {
// Spec: MAX_BDMT_BYTES = 1 MiB = 1_048_576.
// Exactly at the limit: accepted.
assert!(bdmt_size_acceptable(1_048_576));
// One byte over: rejected.
assert!(!bdmt_size_acceptable(1_048_577));
}
/// Mutation: remove the `n > total` rejection check → test goes red
/// (Disc 5 of 2 would no longer be None).
#[test]
fn disc_set_rejects_disc_number_greater_than_total() {
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>X</di:name>
<di:discNumber>5</di:discNumber>
<di:numSets>3</di:numSets>
</discInfo>"#;
assert_eq!(parse_bdmt_xml(xml).unwrap().2, None);
}
/// Mutation: change `n < 1` check to `n < 0` → zero numerator accepted.
#[test]
fn disc_set_rejects_zero_numerator() {
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>X</di:name>
<di:discNumber>0</di:discNumber>
<di:numSets>5</di:numSets>
</discInfo>"#;
assert_eq!(parse_bdmt_xml(xml).unwrap().2, None);
}
/// Mutation: change `total < 1` to `total < 0` → zero denominator accepted.
#[test]
fn disc_set_rejects_zero_denominator() {
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>X</di:name>
<di:discNumber>1</di:discNumber>
<di:numSets>0</di:numSets>
</discInfo>"#;
assert_eq!(parse_bdmt_xml(xml).unwrap().2, None);
}
/// `<di:numberOfSets>` is an alternate spelling for `<di:numSets>`.
/// Spec reference: BDA vendor variation observed in the wild.
/// Mutation: rename `numberOfSets` to `setCount` → disc_number is None.
#[test]
fn number_of_sets_alternate_tag_accepted() {
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>Box Film</di:name>
<di:discNumber>4</di:discNumber>
<di:numberOfSets>8</di:numberOfSets>
</discInfo>"#;
let (_, _, set) = parse_bdmt_xml(xml).unwrap();
assert_eq!(set, Some((4, 8)));
}
/// Mutation: remove the `looks_like_xml` filter → XML-fragment descriptions
/// pass through as the description string.
#[test]
fn description_containing_inner_tag_is_rejected() {
// The description element starts with a `<` after trimming — the
// `looks_like_xml` filter must drop it.
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>Film</di:name>
<di:description><inner>garbage</inner></di:description>
</discInfo>"#;
let (_, description, _) = parse_bdmt_xml(xml).unwrap();
assert!(
description.is_none(),
"XML-fragment description must be dropped, got {:?}",
description
);
}
/// Mutation: remove the `!s.is_empty()` filter → empty descriptions
/// come through as Some("").
#[test]
@@ -631,29 +526,6 @@ mod tests {
assert_eq!(description, None);
}
/// Mutation: remove the `len != 3` guard in `lang_code_from_filename`
/// → 2-char or 4-char codes would be accepted.
#[test]
fn lang_code_rejects_two_char_code() {
assert_eq!(lang_code_from_filename("bdmt_en.xml"), None);
}
/// Mutation: remove the `is_ascii_alphabetic` guard → numeric codes
/// (e.g. `en3`) would be accepted.
#[test]
fn lang_code_rejects_non_alphabetic_code() {
assert_eq!(lang_code_from_filename("bdmt_en3.xml"), None);
assert_eq!(lang_code_from_filename("bdmt_e_g.xml"), None);
}
/// Mutation: change `strip_prefix("bdmt_")` to `strip_prefix("bmt_")` →
/// bdmt_ prefix check broken.
#[test]
fn lang_code_rejects_wrong_prefix() {
assert_eq!(lang_code_from_filename("bmt_eng.xml"), None);
assert_eq!(lang_code_from_filename("meta_eng.xml"), None);
}
/// Disc N of N (e.g. 3 of 3) is valid — not an off-by-one error.
/// Mutation: change `n > total` to `n >= total` → last disc of set is None.
#[test]
@@ -680,31 +552,6 @@ mod tests {
assert_eq!(set, None);
}
/// A title with embedded XML entities: we do NOT decode entities.
/// Spec: our xml helpers do not handle entity decoding; the raw text
/// is passed through. This documents the limitation explicitly.
/// Mutation: add entity decoding → this test goes red (value changes).
#[test]
fn title_with_entities_passes_through_raw() {
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
<di:name>Arthur &amp; Max</di:name>
</discInfo>"#;
let (title, _, _) = parse_bdmt_xml(xml).unwrap();
// We don't decode &amp; — passes through as literal text between tags.
assert!(!title.is_empty(), "title must not be empty");
}
/// `is_bdmt_filename` is just a thin wrapper — verify the delegation.
/// Mutation: break is_bdmt_filename to always return true → sibling
/// files that aren't bdmt XML would be picked up.
#[test]
fn is_bdmt_filename_delegates_correctly() {
assert!(is_bdmt_filename("bdmt_eng.xml"));
assert!(is_bdmt_filename("BDMT_DEU.XML"));
assert!(!is_bdmt_filename("other.xml"));
assert!(!is_bdmt_filename("bdmt_engl.xml"));
}
/// Whitespace-only title element must be treated as empty (trimmed → "").
/// Spec: xml::text trims; an all-whitespace element produces "" after trim,
/// which the title-extraction logic should skip.
@@ -719,18 +566,4 @@ mod tests {
let (title, _, _) = parse_bdmt_xml(xml).unwrap();
assert_eq!(title, "Real Title");
}
/// A zero-byte size entry should be accepted (legitimate empty-but-present files).
/// Mutation: change `size <= MAX_BDMT_BYTES` to `size < MAX_BDMT_BYTES` → zero fails.
#[test]
fn zero_size_entry_is_acceptable() {
assert!(bdmt_size_acceptable(0));
}
/// MAX value of u64 must definitely be rejected.
/// Mutation: add a `MIN_SIZE` check that always passes → u64::MAX accepted.
#[test]
fn u64_max_size_rejected() {
assert!(!bdmt_size_acceptable(u64::MAX));
}
}
-55
View File
@@ -345,61 +345,6 @@ mod tests {
assert_eq!((co, mo, m, d), (0, 0, 0, 0));
}
/// Spec: (false, false) branch — both coding_types absent, equal language → Match.
/// The spec comment says "compare language fields; Divergent if they differ, else Match".
/// Mutation: return Divergent for any (false, false) case → this test goes red.
#[test]
fn class_both_coding_absent_equal_none_lang_is_match() {
let r = ClpiVsMplsRow {
pid: 0x1100,
clpi_coding_type: None,
clpi_language: None,
mpls_coding_type: None,
mpls_language: None,
};
// Both languages are None == None → Match.
assert_eq!(r.class(), ClpiVsMplsClass::Match);
}
/// Spec: all four classes form an exhaustive disjoint cover.
/// This test verifies the discriminant logic using boundary coding_type values.
/// Mutation: swap the ClpiOnly/MplsOnly branches → wrong classification.
#[test]
fn class_boundary_coding_types_all_four_classes_reachable() {
let clpi_only = ClpiVsMplsRow {
pid: 1,
clpi_coding_type: Some(1),
clpi_language: None,
mpls_coding_type: None,
mpls_language: None,
};
let mpls_only = ClpiVsMplsRow {
pid: 2,
clpi_coding_type: None,
clpi_language: None,
mpls_coding_type: Some(1),
mpls_language: None,
};
let match_ = ClpiVsMplsRow {
pid: 3,
clpi_coding_type: Some(0x83),
clpi_language: Some("eng".into()),
mpls_coding_type: Some(0x83),
mpls_language: Some("eng".into()),
};
let divergent = ClpiVsMplsRow {
pid: 4,
clpi_coding_type: Some(0x83),
clpi_language: Some("eng".into()),
mpls_coding_type: Some(0x83),
mpls_language: Some("fra".into()),
};
assert_eq!(clpi_only.class(), ClpiVsMplsClass::ClpiOnly);
assert_eq!(mpls_only.class(), ClpiVsMplsClass::MplsOnly);
assert_eq!(match_.class(), ClpiVsMplsClass::Match);
assert_eq!(divergent.class(), ClpiVsMplsClass::Divergent);
}
/// Spec: class_counts tuple order is (clpi_only, mpls_only, matches, divergent).
/// Verifies each counter increments the RIGHT slot.
/// Mutation: swap any two counters → wrong slot increments.
-13
View File
@@ -1730,19 +1730,6 @@ mod apply_tests {
// ── codec_hint_consistent hardening ───────────────────────────────────────
/// Spec: a hint naming only "TrueHD" is consistent with a TrueHD stream;
/// inconsistent with AC-3, AC-3+, DTS, etc.
/// Mutation: make all hints consistent with every codec → the unshuffle logic stops working.
#[test]
fn codec_hint_consistent_truehd_families() {
assert!(codec_hint_consistent("TrueHD 7.1", &Codec::TrueHd));
assert!(codec_hint_consistent("Dolby TrueHD", &Codec::TrueHd));
assert!(!codec_hint_consistent("TrueHD 7.1", &Codec::Ac3));
assert!(!codec_hint_consistent("TrueHD 7.1", &Codec::Ac3Plus));
assert!(!codec_hint_consistent("TrueHD 7.1", &Codec::Dts));
assert!(!codec_hint_consistent("TrueHD 7.1", &Codec::Lpcm));
}
/// Spec: "Dolby Digital" (AC-3) hint is consistent ONLY with AC-3 streams;
/// NOT with DD+ or TrueHD.
/// Mutation: accept "Dolby Digital" as consistent with AC-3+ → DD+ mislabeled.
-143
View File
@@ -548,33 +548,6 @@ mod tests {
assert_eq!(codec("dts"), "dts");
}
/// Spec: COMPOUND_LANGS must be ordered longest-first so that
/// "Brazilian Portuguese" is matched before bare "Portuguese".
/// Mutation: put "portuguese" before "brazilian portuguese" in the table →
/// Brazilian Portuguese returns variant="", losing the regional info.
#[test]
fn compound_lang_longest_match_wins() {
let r = lang("Brazilian Portuguese 5.1 Dolby").unwrap();
assert_eq!(r.code, "por");
assert_eq!(r.variant, "Brazilian");
let r = lang("Castilian Spanish").unwrap();
assert_eq!(r.code, "spa");
assert_eq!(r.variant, "Castilian");
let r = lang("Latin American Spanish").unwrap();
assert_eq!(r.code, "spa");
assert_eq!(r.variant, "Latin American");
}
/// Spec: bare language name lookup uses word-boundary matching.
/// Mutation: use `.contains()` instead of `has_word()` → "engineering" matches "english".
#[test]
fn lang_no_false_positive_substring() {
assert_eq!(lang("Audio Engineering"), None);
assert_eq!(lang("Francispeople"), None);
}
/// Spec: all 36 bare-lang entries must resolve correctly.
/// Mutation: swap two entries in BARE_LANGS → wrong code returned.
#[test]
@@ -605,122 +578,6 @@ mod tests {
}
}
/// Spec: `purpose()` recognizes "commentary" (word-boundary).
/// Mutation: use `contains("comment")` → "commenter" wrongly matches.
#[test]
fn purpose_commentary_word_boundary() {
assert_eq!(purpose("English Commentary"), LabelPurpose::Commentary);
assert_eq!(purpose("Commenter Track"), LabelPurpose::Normal);
assert_eq!(purpose("recommentary"), LabelPurpose::Normal);
}
/// Spec: "Director's Commentary" is a recognized phrase.
/// Mutation: require exact "commentary" without apostrophe prefix → fails.
#[test]
fn purpose_directors_commentary_recognized() {
assert_eq!(purpose("Director's Commentary"), LabelPurpose::Commentary);
}
/// Spec: `purpose()` recognizes "audio description" compound phrase.
/// Mutation: remove the compound `audio description` check → Descriptive broken.
#[test]
fn purpose_audio_description_compound() {
assert_eq!(purpose("Audio Description"), LabelPurpose::Descriptive);
assert_eq!(
purpose("English Audio Description"),
LabelPurpose::Descriptive
);
}
/// Spec: "descriptive service" maps to Descriptive via compound check.
/// Mutation: remove "descriptive service" compound → Normal returned.
#[test]
fn purpose_descriptive_service_compound() {
assert_eq!(
purpose("English Descriptive Service"),
LabelPurpose::Descriptive
);
}
/// Spec: "music only" maps to Score via compound check.
/// Mutation: remove "music only" compound → Normal returned.
#[test]
fn purpose_music_only_maps_to_score() {
assert_eq!(purpose("Music Only"), LabelPurpose::Score);
assert_eq!(purpose("English Music Only Track"), LabelPurpose::Score);
}
/// Spec: "score" (bare word) maps to Score.
/// Mutation: remove `has_word(&lower, "score")` check → Normal returned.
#[test]
fn purpose_score_bare_word() {
assert_eq!(purpose("Isolated Score"), LabelPurpose::Score);
assert_eq!(purpose("Score Track"), LabelPurpose::Score);
}
/// Spec: "ime" maps to Ime (alternate music track).
/// Mutation: remove `has_word(&lower, "ime")` check → Normal returned.
#[test]
fn purpose_ime_recognized() {
assert_eq!(purpose("IME"), LabelPurpose::Ime);
assert_eq!(purpose("English ime track"), LabelPurpose::Ime);
}
/// Spec: "ime" inside "time" or "anime" must NOT match.
/// Mutation: use `contains("ime")` → "anime", "time" falsely match.
#[test]
fn purpose_ime_no_substring_match() {
assert_eq!(purpose("Showtime Audio"), LabelPurpose::Normal);
assert_eq!(purpose("Anime Commentary"), LabelPurpose::Commentary);
}
/// Spec: `qualifier()` prioritizes SDH over Forced when both present.
/// Mutation: reverse the SDH check order → Forced returned when both present.
#[test]
fn qualifier_sdh_priority_over_forced() {
assert_eq!(qualifier("English Forced SDH"), LabelQualifier::Sdh);
assert_eq!(qualifier("SDH Forced"), LabelQualifier::Sdh);
}
/// Spec: "captions" maps to Sdh (closed-caption subtitles for deaf).
/// Mutation: remove `has_word(&lower, "captions")` → "captions" returns None.
#[test]
fn qualifier_captions_maps_to_sdh() {
assert_eq!(qualifier("English Captions"), LabelQualifier::Sdh);
assert_eq!(qualifier("Closed Captions"), LabelQualifier::Sdh);
}
/// Spec: "forced narrative" → Forced qualifier.
/// Mutation: remove "forced" check → None returned.
#[test]
fn qualifier_forced_narrative() {
assert_eq!(qualifier("Forced Narrative"), LabelQualifier::Forced);
assert_eq!(
qualifier("English Forced Subtitles"),
LabelQualifier::Forced
);
}
/// Spec: "rnib" → DescriptiveService qualifier.
/// Mutation: remove `has_word(&lower, "rnib")` → None returned.
#[test]
fn qualifier_rnib_maps_to_descriptive_service() {
assert_eq!(
qualifier("English RNIB"),
LabelQualifier::DescriptiveService
);
}
/// Spec: "descriptive service" compound → DescriptiveService.
/// Mutation: remove compound check → None returned.
#[test]
fn qualifier_descriptive_service_compound() {
assert_eq!(
qualifier("English Descriptive Service"),
LabelQualifier::DescriptiveService
);
}
/// Word boundary: "sdh" inside "lambdash" must not match.
/// Mutation: use `contains("sdh")` → "lambdash" falsely triggers SDH.
#[test]
-120
View File
@@ -1018,32 +1018,6 @@ mod tests {
assert!(parse(&data).is_err());
}
/// Spec: version field is bytes [4..8], copied verbatim. A "0300"
/// (UHD) playlist must report version "0300", not "0200".
#[test]
fn version_field_reflects_bytes_4_to_8() {
let mut data = build_mpls(&[(b"00001", 1, 0, 9000000)], (0, 0, 0, 0, 0, 0, 0, 0), &[]);
data[4..8].copy_from_slice(b"0300");
let pl = parse(&data).expect("should parse");
assert_eq!(pl.version, "0300");
}
/// Spec: num_play_items is a u16 at pl[6..8]. The loop must produce
/// exactly that many items when the buffer holds them. Tests that the
/// count is read from the right offset (not e.g. pl[4..6]).
#[test]
fn num_play_items_read_from_offset_6() {
// build_mpls writes num_play_items at pl[6..8]; supply 2 items.
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
let data = build_mpls(
&[(b"00001", 1, 0, 4500000), (b"00002", 1, 4500000, 9000000)],
(1, 0, 0, 0, 0, 0, 0, 0),
&[video],
);
let pl = parse(&data).expect("should parse");
assert_eq!(pl.play_items.len(), 2);
}
/// Spec: connection_condition is the LOW nibble of PlayItem byte[9]
/// (high nibble is reserved/flags). A byte 0xF5 must yield 5, not 0xF5.
#[test]
@@ -1064,40 +1038,6 @@ mod tests {
assert_eq!(pl.play_items[0].connection_condition, 0x05);
}
/// Spec: in_time/out_time are big-endian u32 at PlayItem [12..16] and
/// [16..20]. Verify byte order is BE (not LE).
#[test]
fn in_out_time_big_endian() {
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
let data = build_mpls(
&[(b"00001", 1, 0x01020304, 0x05060708)],
(1, 0, 0, 0, 0, 0, 0, 0),
&[video],
);
let pl = parse(&data).expect("should parse");
assert_eq!(pl.play_items[0].in_time, 0x01020304);
assert_eq!(pl.play_items[0].out_time, 0x05060708);
}
/// Spec: streams come ONLY from the first PlayItem's STN table (doc'd
/// in parse()). A second item carrying STN counts must NOT contribute
/// streams. build_mpls only writes STN on idx 0, so we verify the
/// `item_idx == 0` guard by confirming a 2-item playlist with streams
/// on item 0 reports exactly item-0's streams.
#[test]
fn streams_only_from_first_play_item() {
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
let audio = build_stream_entry_audio(0x1100, 0x83, 6, 1, b"eng");
let data = build_mpls(
&[(b"00001", 1, 0, 4500000), (b"00002", 1, 4500000, 9000000)],
(1, 1, 0, 0, 0, 0, 0, 0),
&[video, audio],
);
let pl = parse(&data).expect("should parse");
assert_eq!(pl.play_items.len(), 2);
assert_eq!(pl.streams.len(), 2); // only from item 0
}
/// stream_entry() PID location for type 0x02 (stream in a SubPath
/// SubClip): subpath_id(1)+subclip_id(1) precede the PID, so PID is at
/// +4 within the entry. A parser that read +2 (type-1 layout) would
@@ -1455,66 +1395,6 @@ mod tests {
assert_eq!(pl.play_items[0].in_time, 90000);
}
/// clip_id is the 5 ASCII bytes at PlayItem [0..5]. Verify exact decode
/// (e.g. "01234"), not a truncated/padded version.
#[test]
fn clip_id_five_bytes() {
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
let data = build_mpls(
&[(b"01234", 1, 0, 9000000)],
(1, 0, 0, 0, 0, 0, 0, 0),
&[video],
);
let pl = parse(&data).expect("should parse");
assert_eq!(pl.play_items[0].clip_id, "01234");
}
/// STN counts are read at STN_OFFSET+4..+12 in PlayItem order:
/// video, audio, pg, ig, sec_audio, sec_video, pip_pg, dv. Verify that
/// supplying 2 video + 2 audio + 1 PG retains all 5 in that order with
/// correct types — catches an off-by-one in the count-byte offsets.
#[test]
fn stn_counts_ordering_video_audio_pg() {
let v0 = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
let v1 = build_stream_entry_video(0x1012, 0x1B, 6, 1, None);
let a0 = build_stream_entry_audio(0x1100, 0x83, 6, 1, b"eng");
let a1 = build_stream_entry_audio(0x1101, 0x81, 3, 1, b"fra");
let pg = build_stream_entry_pg(0x1200, 0x90, b"spa");
let data = build_mpls(
&[(b"00001", 1, 0, 9000000)],
(2, 2, 1, 0, 0, 0, 0, 0),
&[v0, v1, a0, a1, pg],
);
let pl = parse(&data).expect("should parse");
assert_eq!(pl.streams.len(), 5);
assert_eq!(pl.streams[0].stream_type, 1);
assert_eq!(pl.streams[1].stream_type, 1);
assert_eq!(pl.streams[1].pid, 0x1012);
assert_eq!(pl.streams[2].stream_type, 2);
assert_eq!(pl.streams[3].stream_type, 2);
assert_eq!(pl.streams[3].pid, 0x1101);
assert_eq!(pl.streams[3].language, "fra");
assert_eq!(pl.streams[4].stream_type, 3);
assert_eq!(pl.streams[4].language, "spa");
}
/// Audio sample/channel nibbles: sa[1] high nibble = audio_format,
/// low nibble = audio_rate (BD spec audio format/sample_rate packing).
/// 0xC5 → format 12 (7.1), rate 5 (192kHz).
#[test]
fn audio_format_rate_nibble_split() {
let audio = build_stream_entry_audio(0x1100, 0x86, 12, 5, b"eng");
let data = build_mpls(
&[(b"00001", 1, 0, 9000000)],
(0, 1, 0, 0, 0, 0, 0, 0),
&[audio],
);
let pl = parse(&data).expect("should parse");
assert_eq!(pl.streams[0].audio_format, 12);
assert_eq!(pl.streams[0].audio_rate, 5);
assert_eq!(pl.streams[0].language, "eng");
}
/// data.len() exactly 40 with valid magic but playlist_start past the
/// header: parse() must hit the `playlist_start + 10 > data.len()`
/// guard. A 40-byte buffer with playlist_start=40 has no PlayList body.
-21
View File
@@ -631,27 +631,6 @@ mod tests {
// --- YCbCr → RGB green channel + neutral chroma ---
#[test]
fn ycbcr_green_channel_formula() {
// G = Y - 0.344*(Cb-128) - 0.714*(Cr-128). For pure-ish green choose
// Y=145, Cb=54, Cr=34: G should be high, R and B low. (Full-range BT.601
// per the module's deliberate convention.)
let [r, g, b] = ycbcr_to_rgb(&[0x00, 145, 54, 34]);
assert!(g > 200, "G high for green, got {g}");
assert!(r < 80, "R low for green, got {r}");
assert!(b < 80, "B low for green, got {b}");
}
#[test]
fn ycbcr_neutral_chroma_is_grey() {
// Cb=Cr=128 (neutral) → R=G=B=Y for any Y. (Confirms the chroma terms
// vanish at 128.)
for y in [0u8, 64, 128, 200, 255] {
let [r, g, b] = ycbcr_to_rgb(&[0x00, y, 128, 128]);
assert_eq!([r, g, b], [y, y, y], "neutral chroma → grey at Y={y}");
}
}
#[test]
fn ycbcr_blue_channel_clamps_high() {
// B = Y + 1.772*(Cb-128). Y=128, Cb=255 → 128 + 1.772*127 ≈ 353 → clamp 255.
-11
View File
@@ -1518,17 +1518,6 @@ mod tests {
// --- IRAP keyframe boundary values ---
#[test]
fn irap_lower_boundary_type_16_is_keyframe() {
// BLA_W_LP = 16, the inclusive lower boundary of NAL_BLA_W_LP..=23.
let mut parser = HevcParser::new();
let mut data = vec![0x00, 0x00, 0x01];
data.extend_from_slice(&hevc_nal_header(16));
data.extend_from_slice(&[0x10, 0x20]);
let f = parser.parse(&make_pes(data, Some(0)));
assert!(f[0].keyframe);
}
#[test]
fn type_15_just_below_irap_not_keyframe() {
// Type 15 (RASL_R) is one below the IRAP range and must NOT be a keyframe.
-11
View File
@@ -225,17 +225,6 @@ mod tests {
assert_eq!(f[0].data, vec![0xAB], "5 BD bytes → 1 PCM byte");
}
#[test]
fn bd_exactly_four_bytes_dropped() {
// Exactly 4 bytes = header only: `len <= offset` (4 <= 4) → dropped.
let mut parser = LpcmParser::new();
assert!(
parser
.parse(&make_pes(vec![0x00, 0x01, 0x00, 0x91], Some(0)))
.is_empty()
);
}
#[test]
fn bd_three_bytes_dropped() {
// Fewer than the 4-byte header → dropped, no panic / no underflow slice.
-14
View File
@@ -803,20 +803,6 @@ mod tests {
assert_eq!(f2[0].pts_ns, 0, "no PTS/DTS → 0");
}
#[test]
fn frame_data_is_whole_pes_not_just_picture() {
// The emitted frame data is the ENTIRE PES payload (pes.data.clone()),
// not just the picture NAL — MPEG-2 ES is muxed as-is. Confirm a seq
// header + picture PES emits the whole buffer.
let mut parser = Mpeg2Parser::new();
let mut data = make_seq_header(720, 480, 3, 4);
data.extend_from_slice(&make_picture_header(PICTURE_TYPE_I));
data.extend_from_slice(&[0x12, 0x34]);
let f = parser.parse(&make_pes(data.clone(), Some(0)));
assert_eq!(f.len(), 1);
assert_eq!(f[0].data, data, "frame data = whole PES payload");
}
#[test]
fn parser_resolution_method() {
let mut parser = Mpeg2Parser::new();
-11
View File
@@ -433,17 +433,6 @@ mod tests {
// --- duration computation and clamping ---
#[test]
fn duration_is_clear_minus_display() {
// BlockDuration = clear_pts - display_pts (in ns). display @ 90000 (1s),
// clear @ 450000 (5s) → duration 4s.
let mut parser = PgsParser::new();
let _ = parser.parse(&make_pes(pcs_bytes(1), Some(90000)));
let f = parser.parse(&make_pes(pcs_bytes(0), Some(450000)));
assert_eq!(f[0].pts_ns, 1_000_000_000);
assert_eq!(f[0].duration_ns, Some(4_000_000_000));
}
#[test]
fn duration_clamps_to_zero_when_clear_precedes_display() {
// A clear PTS earlier than the display PTS (corrupt/out-of-order stream)
-9
View File
@@ -163,15 +163,6 @@ mod tests {
// --- skip_start_code: boundary / form selection ---
#[test]
fn skip_4byte_preferred_over_3byte_when_extra_zero_present() {
// `00 00 00 01`: the function must recognise the 4-byte form (return
// pos+4), not stop at a phantom 3-byte interpretation. data[pos+2]==0x00
// and data[pos+3]==0x01 select the 4-byte branch.
let data = [0x00, 0x00, 0x00, 0x01, 0x42];
assert_eq!(skip_start_code(&data, 0), Some(4));
}
#[test]
fn skip_start_code_at_nonzero_pos() {
// skip must honour pos: a 3-byte code at offset 2 returns 2+3 = 5.
-6
View File
@@ -526,12 +526,6 @@ mod tests {
assert_eq!(truehd_channels(1 << 20), None);
}
#[test]
fn truehd_channels_71_layout_low5_bits() {
// Standard 7.1: 8ch bits 0-4 = L/R(2)+C(1)+LFE(1)+Ls/Rs(2)+Lb/Rb(2) = 8.
assert_eq!(truehd_channels(0x1F), Some(8));
}
// --- truehd_channels_from_stream: major-sync variant bit + scan ---
#[test]
-41
View File
@@ -588,47 +588,6 @@ mod tests {
assert_eq!(parse_vc1_resolution(&sh), Some((8192, 8192)));
}
#[test]
fn resolution_field_is_12_bits_no_higher() {
// Asserting the field width: a width one step above the max (8194 →
// coded_w 4096) overflows the 12-bit MAX_CODED_WIDTH field (4096 & 0xFFF
// = 0), so it cannot encode 8194 — it wraps to (0+1)*2 = 2. This proves
// the 12-bit masking in the parser, i.e. it never reads a 13th bit.
let sh = make_ap_seq_header(8194, 720);
assert_eq!(
parse_vc1_resolution(&sh),
Some((2, 720)),
"coded_w field is masked to 12 bits → 4096 wraps to 0 → width 2"
);
}
#[test]
fn resolution_deescapes_emulation_prevention() {
// VC-1 Annex-B EBDU payload may carry an emulation-prevention 0x03 after
// a 00 00 run. The resolution parser must de-escape before bit
// extraction; an EP byte in the first few payload bytes would otherwise
// shift every later bit and corrupt the dimensions. Build a header whose
// de-escaped payload encodes 1280x720, then splice 00 00 03 into the raw
// payload and confirm it still decodes 1280x720.
let base = make_ap_seq_header(1280, 720);
// base = [00 00 01 0F][5 payload bytes]. Insert a benign EP run that
// de-escapes away: find a spot where two zeros precede our inserted 0x03.
// Construct payload manually: prepend 00 00 03 then the real 5 bytes; the
// de-escaper drops the 0x03, leaving 00 00 + the 5 bytes → but that
// shifts the fields. Instead, the real coverage: the de-escaper collects
// 5 bytes skipping EP. Put the EP at the very front so after stripping we
// still recover the 5 meaningful bytes... that changes leading bits.
// Simpler grounded check: a payload with a trailing EP byte (after the 5
// needed bytes) must not change the result, since only 5 are collected.
let mut sh = base.clone();
sh.extend_from_slice(&[0x00, 0x00, 0x03, 0xFF]); // trailing EP run
assert_eq!(
parse_vc1_resolution(&sh),
Some((1280, 720)),
"trailing EP bytes beyond the 5 collected must not affect parsing"
);
}
// --- codec_private BITMAPINFOHEADER field layout ---
#[test]
-8
View File
@@ -551,14 +551,6 @@ mod tests {
assert!(!starts_with_start_code(&[]));
}
#[test]
fn three_byte_start_code_only_buffer_passes_through() {
// A buffer that is exactly a 3-byte start code prefix is passed through
// (the probe wins before length parsing).
let raw = [0x00, 0x00, 0x01, 0x40, 0x01];
assert_eq!(length_prefixed_to_annex_b(&raw), raw);
}
// --- HevcMux: params-once + error semantics ---
#[test]
+73 -20
View File
@@ -270,6 +270,26 @@ fn monotonic_ts(prev: Option<i64>, pts_ms: i64) -> i64 {
}
}
/// Per-track block timestamp. The strictly-monotonic nudge is applied to
/// AUDIO/SUBTITLE tracks only; VIDEO (track 0) is returned UNCHANGED.
///
/// With B-frames, a video frame's presentation PTS is legitimately
/// non-monotonic in decode/storage order (a B-frame sits between its anchors,
/// below the frame stored just before it). Forcing it strictly-increasing
/// clobbers those PTS to prev+1ms — a `copy` remux preserves the (wrong) value,
/// but a decoder derives DTS from the HEVC POC and finds them colliding
/// ("non monotonically increasing dts", thousands per title). Matroska
/// SimpleBlock permits non-monotonic block timestamps (signed block-relative
/// offsets), so video keeps its true PES PTS; only no-reorder tracks (audio,
/// subtitles), where a same-millisecond collision IS a real defect, get nudged.
fn block_ts(track_idx: usize, prev: Option<i64>, pts_ms: i64) -> i64 {
if track_idx == 0 {
pts_ms
} else {
monotonic_ts(prev, pts_ms)
}
}
/// Encode a Matroska track number as an EBML VINT into a stack buffer,
/// returning the buffer and the used length. Track numbers are small (1-based,
/// a handful of tracks), so 1 byte covers `< 0x80` and 2 bytes covers the rest;
@@ -550,12 +570,19 @@ impl<W: Write + Seek> MkvMuxer<W> {
// kept keyframe are clamped to t=0 rather than corrupting the timeline.
let pts_ms = (raw_ms - base).max(0);
// Enforce strictly-monotonic per-track block timestamps. Some audio PES
// PTS truncate to the same millisecond as the previous frame (or, rarely,
// tick back 1ms), which surfaces as "non-monotonic DTS" and is rejected
// by ffmpeg/strict players. Nudge to prev+1ms — sub-frame, inaudible,
// and A/V sync is unaffected at millisecond granularity.
let pts_ms = monotonic_ts(self.last_pts_ms.get(&track_idx).copied(), pts_ms);
// Strictly-monotonic block timestamps — AUDIO/SUBTITLE ONLY. Some audio
// PES PTS truncate to the same millisecond as the previous frame (or
// tick back 1ms); nudge those to prev+1ms (sub-frame, inaudible).
//
// VIDEO (track 0) is EXEMPT: with B-frames, presentation PTS is
// legitimately non-monotonic in decode/storage order (a B-frame's PTS
// sits between its anchors, below the frame stored before it). Forcing
// it strictly-increasing clobbers those PTS to prev+1ms, which a `copy`
// remux preserves but a decoder rejects — it derives DTS from the HEVC
// POC and finds them colliding ("non monotonically increasing dts").
// Matroska SimpleBlock permits non-monotonic block timestamps (negative
// block-relative offsets), so leave the true PES PTS intact for video.
let pts_ms = block_ts(track_idx, self.last_pts_ms.get(&track_idx).copied(), pts_ms);
let needs_new_cluster = !self.cluster_open
|| (is_video_key && (pts_ms - self.cluster_ts_ms) >= CLUSTER_DURATION_MS);
@@ -974,6 +1001,46 @@ mod tests {
assert_eq!(out, [1000, 1001, 1002, 1003, 1032, 1033, 1064]);
}
#[test]
fn block_ts_exempts_video_from_monotonic_nudge() {
// VIDEO (track 0) keeps its true PTS even when non-monotonic in storage
// order — a B-frame whose presentation PTS sits below the frame stored
// before it must NOT be nudged to prev+1ms (that clobbering is what
// produced the "non monotonically increasing dts" flood on decode).
assert_eq!(
block_ts(0, Some(1040), 1000),
1000,
"video B-frame PTS preserved"
);
assert_eq!(
block_ts(0, Some(1000), 1000),
1000,
"video dup-ms PTS preserved"
);
// A realistic decode-order GOP (I, then B-frames dipping below it):
// every value passes through untouched for video.
let gop = [1000i64, 960, 920, 1080, 1040];
let mut prev = None;
let out: Vec<i64> = gop
.iter()
.map(|&p| {
let t = block_ts(0, prev, p);
prev = Some(t);
t
})
.collect();
assert_eq!(out, gop, "video timestamps must be left exactly as-is");
// AUDIO/SUBTITLE (track != 0) still get the strictly-monotonic nudge —
// a same-ms collision there is a real defect.
assert_eq!(block_ts(1, Some(1000), 1000), 1001, "audio dup-ms nudged");
assert_eq!(
block_ts(2, Some(1001), 1000),
1002,
"subtitle back-tick nudged"
);
}
#[test]
fn mkv_multiple_tracks() {
let buf = Cursor::new(Vec::new());
@@ -2036,20 +2103,6 @@ mod tests {
// element ID bytes.
// ============================================================
#[test]
fn seekhead_seek_id_values_match_target_element_ids() {
let tracks = [make_video_track(), make_audio_track()];
let (data, _) = mux_to_bytes(&tracks, &[], &frames_for(10.0, 1.0));
let entries = parse_seekhead(&data);
// The decoded SeekID for each entry must equal a real Matroska element
// ID (Info, Tracks, Cues). parse_seekhead reads SeekID as a uint; the
// value is the big-endian element ID.
let ids: Vec<u32> = entries.iter().map(|(id, _)| *id).collect();
assert!(ids.contains(&ebml::INFO));
assert!(ids.contains(&ebml::TRACKS));
assert!(ids.contains(&ebml::CUES));
}
// ============================================================
// dolby_vision_config (dvcC / DOVIDecoderConfigurationRecord) bit
// packing. Byte 2: profile(7 bits) << 1 | level high bit. Byte 3:
-8
View File
@@ -1210,14 +1210,6 @@ mod tests {
// 0x1100 + (tnum-2) formula).
// ============================================================
#[test]
fn ts_pid_for_track_mid_range_formula() {
// tnum 10 → 0x1100 + 8 = 0x1108.
assert_eq!(ts_pid_for_track(10).unwrap(), 0x1108);
// tnum 0x100 → 0x1100 + 0xFE = 0x11FE.
assert_eq!(ts_pid_for_track(0x100).unwrap(), 0x11FE);
}
// ============================================================
// CLUSTER_TIMESTAMP overflow guard — a value above i64::MAX would cast
// to a large negative i64 and poison every block PTS in the cluster.
+2 -12
View File
@@ -152,8 +152,8 @@ mod tests {
fn path_str_returns_address_for_network() {
// network:// path_str is the host:port address verbatim.
assert_eq!(
parse_url("network://192.168.1.1:9000").path_str(),
"192.168.1.1:9000"
parse_url("network://203.0.113.5:9000").path_str(),
"203.0.113.5:9000"
);
}
@@ -231,16 +231,6 @@ mod tests {
);
}
#[test]
fn write_seek_blanket_impl_covers_cursor() {
// WriteSeek is the MKV sink bound (Write + Seek). The blanket impl
// must opt in any T: Write+Seek; Cursor<Vec<u8>> is the canonical
// in-memory seekable sink. Compile-time proof via a generic fn.
fn assert_writeseek<T: super::super::WriteSeek>(_: &T) {}
let cur = std::io::Cursor::new(Vec::<u8>::new());
assert_writeseek(&cur);
}
#[test]
fn first_matching_scheme_wins_no_double_prefix_confusion() {
// A path component that itself looks like another scheme must be
-26
View File
@@ -87,30 +87,4 @@ mod tests {
// size, track index, or post-finish state.
sink.write(&frame).unwrap();
}
/// info() must return the title the sink was constructed with, unchanged
/// — the Stream trait contract requires info() be stable and reflect the
/// supplied metadata (the muxer reads stream layout from it).
#[test]
fn info_reflects_constructed_title() {
let mut title = DiscTitle::empty();
title.playlist = "BenchTitle".into();
title.playlist_id = 7;
let sink = NullStream::new(&title);
assert_eq!(sink.info().playlist, "BenchTitle");
assert_eq!(sink.info().playlist_id, 7);
}
/// The write-only read() guard must hold on EVERY call, not just the
/// first — a caller that retries read() after the initial error must
/// keep getting StreamWriteOnly, never a stale Ok(None).
#[test]
fn read_stays_write_only_across_repeated_calls() {
let title = DiscTitle::empty();
let mut sink = NullStream::new(&title);
for _ in 0..3 {
let err = Stream::read(&mut sink).expect_err("read on a sink must always error");
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
}
}
}
-23
View File
@@ -889,21 +889,6 @@ mod tests {
assert_eq!(parse_pts(&encode_pts(max, 0x20)), max);
}
#[test]
fn parse_pts_ignores_marker_bits_in_value() {
// The marker bits (LSB of bytes 0,2,4) are NOT part of the 33-bit
// value. Two encodings differing only in marker bits decode equal.
let v = 0x1_2345_6789u64 & ((1 << 33) - 1);
let a = encode_pts(v, 0x20);
let mut b = a;
// markers are already 1; the value bits must dominate regardless.
b[0] |= 0x01;
b[2] |= 0x01;
b[4] |= 0x01;
assert_eq!(parse_pts(&a), v);
assert_eq!(parse_pts(&b), v);
}
// ── pack header (0xBA) framing ────────────────────────────────────────
#[test]
@@ -1151,14 +1136,6 @@ mod tests {
);
}
#[test]
fn empty_feed_then_flush_is_empty() {
// No input at all → nothing to emit, no panic.
let mut demuxer = PsDemuxer::new();
assert!(demuxer.feed(&[]).is_empty());
assert!(demuxer.flush().is_empty());
}
#[test]
fn pes_header_data_length_skips_pts_when_flag_unset() {
// If pts_dts_flags == 0 the 5 "PTS" bytes after the fixed header are
-26
View File
@@ -700,13 +700,6 @@ mod tests {
);
}
/// output() to null:// must succeed (it's the canonical write sink).
#[test]
fn output_null_succeeds() {
let t = DiscTitle::empty();
assert!(output("null://", &t).is_ok());
}
/// output() to an unknown scheme must surface StreamUrlInvalid
/// (E9002 → InvalidInput).
#[test]
@@ -964,23 +957,4 @@ mod tests {
);
assert!(res.is_err(), "zero batch_sectors must be rejected");
}
/// info() on the assembled pipeline returns the title it was built with —
/// the consumer reads stream layout from here before muxing.
#[test]
fn build_iso_pipeline_info_returns_title() {
let mut title = aac_audio_title(0x1100);
title.playlist = "PipelineTitle".into();
let stream = build_iso_pipeline(
MemSource { data: Vec::new() },
title,
DecryptKeys::None,
8192,
ContentFormat::BdTs,
None,
None,
)
.unwrap();
assert_eq!(stream.info().playlist, "PipelineTitle");
}
}
-7
View File
@@ -231,13 +231,6 @@ mod tests {
assert_eq!(s.codec_private(99), None);
}
/// info() on the write side reflects the supplied title.
#[test]
fn output_info_reflects_title() {
let s = StdioStream::output(&title_with_codec_privates());
assert_eq!(s.info().playlist, "StdioTitle");
}
/// A fresh input stream defaults to an empty title until a header is
/// parsed — info() must not invent stream metadata.
#[test]
-23
View File
@@ -885,27 +885,4 @@ mod tests {
assert_eq!(got.len(), big.len(), "no bytes lost in the PES split");
assert_eq!(got, big, "split audio reassembles byte-for-byte");
}
#[test]
fn af_plus_payload_always_fills_184() {
// Invariant from write_pes_chain: af_bytes + payload_len == 184 on
// every packet (so the 192-byte frame is exact). Verify for a video
// keyframe (which forces an RAI adaptation field on packet 1).
let mut sink: Vec<u8> = Vec::new();
{
let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID]);
let idr = fake_hevc_nal(19, 400);
mux.write_frame(0, 0, true, &idr).unwrap();
mux.finish().unwrap();
}
let packets = parse_bd_ts(&sink);
for p in packets.iter().filter(|p| p.pid == VIDEO_PID) {
let af_total = p.af.as_ref().map(|a| a.len() + 1).unwrap_or(0); // +1 length byte
assert_eq!(
af_total + p.payload.len(),
184,
"AF area + payload must fill the 184-byte TS body"
);
}
}
}
-69
View File
@@ -493,55 +493,6 @@ mod tests {
);
}
/// serialize rejects data larger than MAX_FRAME_SIZE.
/// Spec: doc says "A frame larger than this is rejected on write rather than written
/// and then hard-erroring mid-stream on read."
/// Mutation: removing the size check serializes an unreadable frame.
#[test]
fn serialize_rejects_data_exceeding_max_frame_size() {
// We can't actually allocate 256 MiB in a test; instead we construct a
// PesFrame whose data len is exactly MAX_FRAME_SIZE+1 by building a
// custom case. We test the boundary via the const.
assert!(
MAX_FRAME_SIZE == 256 * 1024 * 1024,
"MAX_FRAME_SIZE constant changed — update this test"
);
// Verify the error path via the const: MAX_FRAME_SIZE+1 won't fit.
// We can't allocate 256 MiB + 1 in CI, so we test the length check
// indirectly: a frame at exactly MAX_FRAME_SIZE must succeed on
// serialize (the len itself fits in u32). We can also do a small
// trick: check that the error kind is correct for a simulated large size.
// The simplest safe test: confirm MAX_FRAME_SIZE fits in a u32.
assert!(
MAX_FRAME_SIZE <= u32::MAX as usize,
"MAX_FRAME_SIZE must fit in u32 for wire length field"
);
}
/// deserialize round-trips a negative pts (i64 can be negative).
/// Spec: pts is a signed i64 nanosecond timestamp; negative values are valid
/// (e.g. pts before stream start). Wire format is little-endian i64.
/// Mutation: using u64 for pts interpretation makes negative values wrap.
#[test]
fn deserialize_round_trips_negative_pts() {
let frame = PesFrame {
track: 1,
pts: -12345678_i64,
keyframe: false,
data: vec![0xDE, 0xAD],
duration_ns: None,
};
let mut buf = Vec::new();
frame.serialize(&mut buf).unwrap();
let mut cursor = std::io::Cursor::new(buf);
let got = PesFrame::deserialize(&mut cursor).unwrap().unwrap();
assert_eq!(
got.pts, -12345678_i64,
"negative pts must survive round-trip"
);
assert_eq!(got.data, vec![0xDE, 0xAD]);
}
/// deserialize round-trips pts=0 and pts=i64::MAX correctly.
/// Mutation: off-by-one in byte indices [1..9] shifts the pts value.
#[test]
@@ -666,24 +617,4 @@ mod tests {
cs.write(&f2).unwrap();
assert_eq!(cs.bytes_written(), 5, "must accumulate 3+2=5 bytes");
}
/// CountingStream.finish() delegates to the inner stream (no panic).
/// Mutation: not calling inner.finish() silently drops any buffered data.
#[test]
fn counting_stream_finish_delegates_to_inner() {
let mut cs = CountingStream::new(Box::new(MockStream::new(Vec::new())));
// Must not panic.
cs.finish().unwrap();
}
/// CountingStream.info() and codec_private() delegate to inner.
/// Mutation: returning a default title instead of inner.info() drops disc metadata.
#[test]
fn counting_stream_delegates_info_and_codec_private() {
let cs = CountingStream::new(Box::new(MockStream::new(Vec::new())));
// info() must return the inner stream's title without panicking.
let _ = cs.info();
// codec_private defaults to None for MockStream.
assert!(cs.codec_private(0).is_none());
}
}
-34
View File
@@ -386,17 +386,6 @@ mod tests {
// ── New comprehensive tests ────────────────────────────────────────────────
/// decode_hex rejects odd-length hex strings.
/// Spec: hex encoding uses pairs of hex digits; odd length is malformed.
/// Mutation: padding an odd-length string instead of erroring silently
/// misinterprets the last nibble.
#[test]
fn decode_hex_rejects_odd_length() {
assert!(decode_hex("abc").is_err(), "odd length must be rejected");
assert!(decode_hex("a").is_err());
assert!(decode_hex("abcde").is_err());
}
/// decode_hex accepts empty string → empty Vec.
/// Mutation: returning an error on empty input breaks empty-field handling.
#[test]
@@ -422,15 +411,6 @@ mod tests {
);
}
/// decode_hex rejects non-hex ASCII characters.
/// Mutation: treating 'g' or 'z' as 0 silently corrupts key material.
#[test]
fn decode_hex_rejects_non_hex_ascii() {
assert!(decode_hex("gg").is_err(), "'g' is not a hex digit");
assert!(decode_hex("0z").is_err(), "'z' is not a hex digit");
assert!(decode_hex("0 ").is_err(), "space is not a hex digit");
}
/// parse_hex4 rejects an 8-hex-char string (4 bytes) correctly.
/// Spec: the signature field is exactly 4 bytes = 8 hex chars.
/// Mutation: accepting 6 hex chars (3 bytes) would pass a wrong-length signature.
@@ -462,20 +442,6 @@ mod tests {
assert_eq!(Platform::Renesas.name(), "Renesas");
}
/// Platform names do not contain English prose — they are identifiers.
/// Mutation: adding " (unsupported)" to the Renesas name would break key lookup.
#[test]
fn platform_name_has_no_whitespace_only_words() {
for p in [Platform::Mt1959A, Platform::Mt1959B, Platform::Renesas] {
let name = p.name();
assert!(!name.is_empty(), "platform name must not be empty");
// Each whitespace-separated token must be non-empty (no trailing spaces).
for token in name.split_whitespace() {
assert!(!token.is_empty());
}
}
}
/// find_by_drive_id: exact match (including firmware_date) wins over loose match.
/// Spec: two-pass — first an exact match including firmware_date, then looser.
/// Build two synthetic ProfilesFile entries that differ only by firmware_date,
-44
View File
@@ -810,22 +810,6 @@ mod parse_sense_tests {
assert_eq!(d.ascq, 0x05, "n=14 reaches ASCQ at offset 13");
}
#[test]
fn sb_len_wr_clamped_to_slice_len() {
// Doc: n = min(sb_len_wr, sense.len()). A caller claiming 200
// bytes written into a 14-byte slice must not read out of bounds;
// the effective n is the slice length.
let mut s = [0u8; 14];
s[0] = 0x70;
s[2] = 0x03;
s[12] = 0x11;
s[13] = 0x05;
let d = parse_sense(&s, 200);
assert_eq!(d.sense_key, 3);
assert_eq!(d.asc, 0x11);
assert_eq!(d.ascq, 0x05);
}
#[test]
fn n_exactly_three_decodes_key_only() {
// n==3 is the minimum that passes the n<3 early-return. For fixed
@@ -900,34 +884,6 @@ mod scsi_sense_predicate_tests {
}
}
#[test]
fn hardware_error_is_not_marginal() {
// HARDWARE ERROR (4) is explicitly non-recoverable per doc.
assert!(!s(SENSE_KEY_HARDWARE_ERROR).is_marginal());
assert!(s(SENSE_KEY_HARDWARE_ERROR).is_hardware_error());
}
#[test]
fn data_protect_not_marginal() {
// DATA PROTECT (7) = AACS/region/write-protect; retry won't help.
assert!(!s(SENSE_KEY_DATA_PROTECT).is_marginal());
assert!(s(SENSE_KEY_DATA_PROTECT).is_data_protect());
}
#[test]
fn illegal_request_not_marginal() {
// ILLEGAL REQUEST (5) = bad CDB; not marginal.
assert!(!s(SENSE_KEY_ILLEGAL_REQUEST).is_marginal());
assert!(s(SENSE_KEY_ILLEGAL_REQUEST).is_illegal_request());
}
#[test]
fn unit_attention_not_marginal() {
// UNIT ATTENTION (6) = state change; caller rescans, not retries.
assert!(!s(SENSE_KEY_UNIT_ATTENTION).is_marginal());
assert!(s(SENSE_KEY_UNIT_ATTENTION).is_unit_attention());
}
#[test]
fn each_specific_predicate_is_exclusive() {
// Each is_* predicate matches exactly its one key and no other.
+148 -43
View File
@@ -21,11 +21,19 @@
use crate::error::{Error, Result};
use crate::sector::SectorSource;
/// Upper bound on a single metadata file read (`read_file`). BD-ROM
/// metadata files (.mpls/.clpi/.inf/.bdmv) are a few KiB to tens of MiB;
/// 64 MiB is a generous ceiling. Caps the allocation so a crafted ICB
/// info_length / extent length cannot force a huge zeroed reservation
/// before any data is read.
/// Upper bound on a single UNBOUNDED metadata file read (`read_file`).
/// BD-ROM metadata files (.mpls/.clpi/.bdmv/.inf) are a few KiB to a few
/// MiB; 64 MiB is a generous ceiling that bounds the allocation a crafted
/// ICB info_length / extent length can force.
///
/// The one legitimately huge file — the AACS `MKB_RO.inf`, allocated to a
/// fixed ~128 MiB and zero-padded — is NOT read through the unbounded path:
/// `read_aacs_inputs_from_reader` reads a bounded prefix via
/// `read_file_prefix` and trims to the real record length, so it never
/// trips this cap (and never reads 100+ MiB of padding). A 0.31.0
/// regression added this cap and read the MKB unbounded, so the cap
/// rejected it → `read_aacs_inputs` failed → autorip reported "could not
/// read this disc's key files" and never contacted the keyserver.
const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024;
/// Upper bound on a single directory's on-disc data. Real BD-ROM
@@ -135,6 +143,35 @@ impl UdfFs {
/// Read a file by path, returning its raw bytes.
/// Reads all data extents sector by sector from disc — no buffering.
pub fn read_file(&self, reader: &mut dyn SectorSource, path: &str) -> Result<Vec<u8>> {
self.read_file_limited(reader, path, None)
}
/// Read at most `max_bytes` of a file (rounded up to a whole sector),
/// stopping early rather than reading the whole file.
///
/// Used to read only the real, record-length portion of the AACS
/// `MKB_RO.inf` — allocated to a fixed ~128 MiB and zero-padded — instead
/// of reading 100+ MiB of padding (and tripping `MAX_FILE_BYTES`). The
/// caller trims the returned prefix to the MKB record length.
pub fn read_file_prefix(
&self,
reader: &mut dyn SectorSource,
path: &str,
max_bytes: usize,
) -> Result<Vec<u8>> {
self.read_file_limited(reader, path, Some(max_bytes))
}
/// Shared implementation of [`read_file`] / [`read_file_prefix`]. When
/// `max_bytes` is `Some`, reads at most that many bytes and the whole-file
/// `MAX_FILE_BYTES` anti-DoS cap on the declared size / extent lengths is
/// not applied (the read is already bounded by `max_bytes`).
fn read_file_limited(
&self,
reader: &mut dyn SectorSource,
path: &str,
max_bytes: Option<usize>,
) -> Result<Vec<u8>> {
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
let mut current = &self.root;
@@ -166,16 +203,34 @@ impl UdfFs {
path: path.to_string(),
})?;
// Read ALL the file's data extents. Multi-extent files (fragmented or
// split across dual layers) would otherwise be silently truncated to
// the first extent, since the buffer is sized to entry.size and
// truncate() can't grow it.
// `max_bytes == None` => read the whole file; `Some(n)` => read at most
// n bytes (rounded up to a sector) and skip the anti-DoS caps below.
let limit = max_bytes.unwrap_or(usize::MAX);
// Tiny files (notably the AACS `*.inf` key files) may store their data
// embedded inline in the ICB (AD type 3), with no out-of-line extents.
// Honor that before the extent path, which would otherwise misparse the
// embedded bytes as allocation descriptors and (since 0.31.0) hard-error
// on the resulting bogus extent length.
if let Some(mut inline) = self.read_inline_data(reader, entry.meta_lba)? {
let want = (entry.size as usize).min(limit);
if inline.len() > want {
inline.truncate(want);
}
return Ok(inline);
}
// Read the file's data extents. Multi-extent files (fragmented or split
// across dual layers) would otherwise be silently truncated to the
// first extent, since the buffer is sized to entry.size and truncate()
// can't grow it.
let extents = self.read_icb_extents(reader, entry.meta_lba)?;
// Reject an oversized declared total before allocating: entry.size is
// a raw u64 off the ICB, so a crafted file could otherwise force a
// multi-hundred-MiB / GiB allocation across its extents.
if entry.size > MAX_FILE_BYTES {
// Reject an oversized declared total before allocating: entry.size is a
// raw u64 off the ICB, so a crafted file could otherwise force a
// multi-hundred-MiB / GiB allocation. Only for the UNBOUNDED path — a
// bounded read is limited by `limit` regardless of the declared size.
if max_bytes.is_none() && entry.size > MAX_FILE_BYTES {
return Err(Error::DiscRead {
sector: self.partition_start as u64,
status: None,
@@ -183,33 +238,30 @@ impl UdfFs {
});
}
// Read ALL the file's data extents. File DATA is in the physical
// partition (partition_start + lba), NOT the metadata partition: ICBs
// are in metadata, data is in physical.
let mut data = Vec::with_capacity(entry.size as usize);
// Read the file DATA from the physical partition (partition_start +
// lba), NOT the metadata partition: ICBs are in metadata, data is in
// physical. Pre-allocate to the smaller of declared size and the
// requested prefix (capped so a bogus entry.size can't reserve GiB).
let cap_hint = (entry.size as usize)
.min(limit)
.min(MAX_FILE_BYTES as usize);
let mut data = Vec::with_capacity(cap_hint);
let mut sector = [0u8; 2048];
for (data_lba, data_len) in extents {
// Cumulative guard: entry.size and each per-extent data_len are
// capped individually above, but a crafted ICB can chain many
// small extents (read_icb_extents follows type-3 chains up to
// MAX_AD_BLOCKS) whose running total grows `data` into GiB. Reject
// once the accumulated bytes would exceed MAX_FILE_BYTES.
if data.len() as u64 + data_len as u64 > MAX_FILE_BYTES {
return Err(Error::DiscRead {
sector: self.partition_start as u64,
status: None,
sense: None,
});
}
// data_len is the disc-controlled 30-bit extent length; reject an
// oversized extent before reading so a crafted ICB can't grow the
// buffer past MAX_FILE_BYTES.
if data_len as u64 > MAX_FILE_BYTES {
return Err(Error::DiscRead {
sector: self.partition_start as u64,
status: None,
sense: None,
});
'extents: for (data_lba, data_len) in extents {
if max_bytes.is_none() {
// Anti-DoS guards for the unbounded path: a crafted ICB can
// chain many extents whose running total grows `data` into GiB,
// or declare a single oversized extent. (Skipped when bounded —
// `limit` already caps the read.)
if data.len() as u64 + data_len as u64 > MAX_FILE_BYTES
|| data_len as u64 > MAX_FILE_BYTES
{
return Err(Error::DiscRead {
sector: self.partition_start as u64,
status: None,
sense: None,
});
}
}
let abs_start = self
.partition_start
@@ -221,6 +273,9 @@ impl UdfFs {
})?;
let sector_count = (data_len as u64).div_ceil(2048) as u32;
for i in 0..sector_count {
if data.len() >= limit {
break 'extents;
}
let abs = abs_start.checked_add(i).ok_or(Error::DiscRead {
sector: abs_start as u64,
status: None,
@@ -231,10 +286,12 @@ impl UdfFs {
}
}
// Trim to the real file size; if extents under-covered the file (e.g.
// sparse), leave what we have rather than over-reporting.
if data.len() > entry.size as usize {
data.truncate(entry.size as usize);
// Trim to the real file size, or to the requested prefix — whichever is
// smaller. If extents under-covered the file (e.g. sparse), leave what
// we have rather than over-reporting.
let trim_to = (entry.size as usize).min(limit);
if data.len() > trim_to {
data.truncate(trim_to);
}
Ok(data)
}
@@ -382,6 +439,54 @@ impl UdfFs {
})
}
/// If this ICB stores its file data INLINE (embedded — ICB Tag flags low
/// 3 bits == 3) rather than via out-of-line extents, return the embedded
/// bytes. Tiny files such as the AACS `*.inf` key files are routinely
/// embedded directly in the ICB; `read_icb_extents` finds no real extents
/// for them (it would misparse the embedded payload as allocation
/// descriptors), so `read_file` must read the inline payload here. Returns
/// `Ok(None)` for the normal extent-backed case.
///
/// (Regression guard: 0.31.0 added a per-extent `MAX_FILE_BYTES` cap that
/// turned the misparsed-embedded case into a hard error, which surfaced as
/// autorip "could not read this disc's key files" on discs whose AACS
/// `.inf` files are ICB-embedded — the keyserver was then never called.)
fn read_inline_data(
&self,
reader: &mut dyn SectorSource,
meta_lba: u32,
) -> Result<Option<Vec<u8>>> {
let icb_abs = self.meta_to_abs(meta_lba)?;
let mut icb = [0u8; 2048];
read_sector(reader, icb_abs, &mut icb)?;
let tag = u16::from_le_bytes([icb[0], icb[1]]);
let (ad_offset, l_ad) = match tag {
// Extended File Entry (266) / standard File Entry (261): the
// allocation-descriptors field (which, for embedded files, holds
// the data itself) begins after the extended attributes.
266 => {
let l_ea = u32::from_le_bytes([icb[208], icb[209], icb[210], icb[211]]) as usize;
let l_ad = u32::from_le_bytes([icb[212], icb[213], icb[214], icb[215]]) as usize;
(216 + l_ea, l_ad)
}
261 => {
let l_ea = u32::from_le_bytes([icb[168], icb[169], icb[170], icb[171]]) as usize;
let l_ad = u32::from_le_bytes([icb[172], icb[173], icb[174], icb[175]]) as usize;
(176 + l_ea, l_ad)
}
_ => return Ok(None),
};
// ICB Tag flags: u16 at absolute offset 34, low 3 bits select the AD
// type. 3 == data embedded inline in the ICB.
let icb_flags = u16::from_le_bytes([icb[34], icb[35]]);
if (icb_flags & 0x07) != 3 {
return Ok(None);
}
let start = ad_offset.min(icb.len());
let end = ad_offset.saturating_add(l_ad).min(icb.len());
Ok(Some(icb[start..end].to_vec()))
}
/// Read ALL allocation extents for a file from its ICB.
/// Returns Vec of (partition_relative_lba, byte_length) pairs.
/// Handles files with many extents (e.g. 88 GB m2ts files have ~90 extents)