libfreemkv 0.31.4: prune 144 vacuous tests (keep spec-grounded subset)
This commit is contained in:
@@ -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]
|
||||
|
||||
-33
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(§or, &plain9).is_none());
|
||||
// 10 bytes: passes the guard and runs to completion without panic.
|
||||
let _ = recover_title_key(§or, &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(§or, &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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 & Max</di:name>
|
||||
</discInfo>"#;
|
||||
let (title, _, _) = parse_bdmt_xml(xml).unwrap();
|
||||
// We don't decode & — 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -2036,20 +2036,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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user