libfreemkv 0.31.2: comprehensive spec-grounded test suite (~950 tests)
Test-hardening release, no runtime changes. Adds spec-grounded unit tests across the silent-corruption surfaces — UDF/MPLS/CLPI/IFO parsing, BD/DVD title + extent assembly, AACS/CSS key handling, TS/PS demux + codec parsers, MKV/EBML container output, the mux pipeline, sector prefetch + decrypt decorator, drive/SCSI sense decoding, label extraction, and core I/O. Each test is grounded in the format spec or real on-disc behavior and verified to fail under a targeted source mutation. No behavior changed.
This commit is contained in:
@@ -195,4 +195,377 @@ mod tests {
|
||||
// path itself is exercised by `crate::aacs` unit tests; here
|
||||
// we only assert the decorator wires the existing helper, not
|
||||
// that AES-128 is correct.
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Additional coverage.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Source that fills the FULL requested span with a CSS-scrambled-
|
||||
/// FLAGGED sector pattern (byte 0x14 scramble bits set, non-zero
|
||||
/// data) but reports a SHORTER read (`report_n`). With a CSS key the
|
||||
/// decorator must descramble ONLY `buf[..report_n]`; the bytes
|
||||
/// beyond `report_n` must stay exactly as filled. A whole-`buf`
|
||||
/// decrypt would clear the flagged sector's scramble bits and XOR
|
||||
/// its data region — observable here.
|
||||
struct ShortReportSource {
|
||||
report_n: usize,
|
||||
}
|
||||
impl ShortReportSource {
|
||||
fn fill_one(buf: &mut [u8]) {
|
||||
for (i, b) in buf.iter_mut().enumerate() {
|
||||
*b = (i as u8).wrapping_mul(29).wrapping_add(3);
|
||||
}
|
||||
buf[0x14] = 0x30; // scramble-control bits set → flags == 0x03
|
||||
}
|
||||
}
|
||||
impl SectorSource for ShortReportSource {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
for s in 0..count as usize {
|
||||
Self::fill_one(&mut buf[s * 2048..(s + 1) * 2048]);
|
||||
}
|
||||
Ok(self.report_n)
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the (lba, count, recovery) the decorator forwarded.
|
||||
struct ArgRecorder {
|
||||
calls: Arc<Mutex<Vec<(u32, u16, bool)>>>,
|
||||
}
|
||||
impl SectorSource for ArgRecorder {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
recovery: bool,
|
||||
) -> Result<usize> {
|
||||
self.calls.lock().unwrap().push((lba, count, recovery));
|
||||
let bytes = count as usize * 2048;
|
||||
buf[..bytes].fill(0);
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// A source whose read returns an error — the decorator must
|
||||
/// propagate it and NOT call decrypt afterward (decrypt over an
|
||||
/// unwritten buffer would be at best wasted work, at worst a panic
|
||||
/// for a missing AACS key). Grounding: `read_sectors` uses `?` on
|
||||
/// the inner read before `decrypt_sectors`.
|
||||
struct FailingSource;
|
||||
impl SectorSource for FailingSource {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
_count: u16,
|
||||
_buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
Err(crate::error::Error::IoError {
|
||||
source: std::io::Error::from(std::io::ErrorKind::TimedOut),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The CSS path is a no-op for sectors whose scrambling-control
|
||||
/// bits are clear. Per CSS, the sector's mode-2 subheader byte at
|
||||
/// offset 0x14 carries the copyright/scramble flags; descrambling
|
||||
/// only runs when `(byte[0x14] >> 4) & 0x03 != 0`. With those bits
|
||||
/// clear (byte 0x14 == 0) the descrambler returns immediately, so
|
||||
/// the decorator must hand back the bytes unchanged. Grounding:
|
||||
/// `css::lfsr::descramble_sector` early-return on `flags == 0`.
|
||||
#[test]
|
||||
fn css_unscrambled_sector_passes_through() {
|
||||
struct FixedSector {
|
||||
template: [u8; 2048],
|
||||
}
|
||||
impl SectorSource for FixedSector {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
let bytes = count as usize * 2048;
|
||||
for s in 0..count as usize {
|
||||
buf[s * 2048..(s + 1) * 2048].copy_from_slice(&self.template);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
let mut template = [0u8; 2048];
|
||||
for (i, b) in template.iter_mut().enumerate() {
|
||||
*b = (i as u8).wrapping_mul(13).wrapping_add(7);
|
||||
}
|
||||
// Byte 0x14: clear the scramble-control bits (bits 4-5) so the
|
||||
// descrambler treats the sector as already in the clear.
|
||||
template[0x14] = 0x00;
|
||||
let expected = template;
|
||||
|
||||
let mut wrapped = DecryptingSectorSource::new(
|
||||
FixedSector { template },
|
||||
DecryptKeys::Css {
|
||||
title_key: [0x11, 0x22, 0x33, 0x44, 0x55],
|
||||
},
|
||||
);
|
||||
let mut got = [0u8; 2048];
|
||||
let n = wrapped.read_sectors(0, 1, &mut got, false).unwrap();
|
||||
assert_eq!(n, 2048);
|
||||
assert_eq!(
|
||||
got, expected,
|
||||
"unscrambled CSS sector (flags=0) must pass through untouched"
|
||||
);
|
||||
}
|
||||
|
||||
/// The decorator must decrypt ONLY the `n` bytes the inner source
|
||||
/// reported as read — never the full `buf`. We use a CSS key and a
|
||||
/// sector whose flags ARE set (so descramble would mutate bytes if
|
||||
/// applied), but the inner source reports a short `n` of 0. With
|
||||
/// n=0 the decrypt span is empty, so the whole buffer must come
|
||||
/// back exactly as the inner source filled it. Grounding:
|
||||
/// `decrypt_sectors(&mut buf[..n], ...)`.
|
||||
#[test]
|
||||
fn decrypt_span_bounded_by_reported_n() {
|
||||
// Inner fills a CSS-scrambled-FLAGGED sector but reports n=0, so
|
||||
// the decrypt span is empty and the buffer must come back
|
||||
// byte-identical to what the inner source wrote. A whole-`buf`
|
||||
// decrypt would clear byte 0x14's scramble bits and XOR the data
|
||||
// region — this asserts that does NOT happen for the n=0 span.
|
||||
let mut wrapped = DecryptingSectorSource::new(
|
||||
ShortReportSource { report_n: 0 },
|
||||
DecryptKeys::Css {
|
||||
title_key: [1, 2, 3, 4, 5],
|
||||
},
|
||||
);
|
||||
let mut expected = vec![0u8; 2048];
|
||||
ShortReportSource::fill_one(&mut expected);
|
||||
|
||||
let mut got = vec![0u8; 2048];
|
||||
let n = wrapped.read_sectors(5, 1, &mut got, false).unwrap();
|
||||
assert_eq!(n, 0, "decorator must return the inner source's n");
|
||||
assert_eq!(
|
||||
got, expected,
|
||||
"with n=0 the decrypt span is empty; buffer must be untouched"
|
||||
);
|
||||
// Belt-and-braces: the scramble flag bits must still be set
|
||||
// (a whole-buf descramble would have cleared them).
|
||||
assert_eq!(got[0x14] & 0x30, 0x30, "scramble flags must remain set");
|
||||
}
|
||||
|
||||
/// lba / count / recovery must be forwarded to the inner source
|
||||
/// verbatim. Grounding: `read_sectors` calls
|
||||
/// `self.inner.read_sectors(lba, count, buf, recovery)`.
|
||||
#[test]
|
||||
fn args_forwarded_verbatim() {
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut wrapped = DecryptingSectorSource::new(
|
||||
ArgRecorder {
|
||||
calls: calls.clone(),
|
||||
},
|
||||
DecryptKeys::None,
|
||||
);
|
||||
let mut buf = vec![0u8; 2 * 2048];
|
||||
wrapped.read_sectors(12345, 2, &mut buf, true).unwrap();
|
||||
wrapped.read_sectors(0, 1, &mut buf, false).unwrap();
|
||||
assert_eq!(
|
||||
*calls.lock().unwrap(),
|
||||
vec![(12345, 2, true), (0, 1, false)],
|
||||
"lba/count/recovery must pass through unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
/// A read error from the inner source must propagate unchanged and
|
||||
/// the decrypt step must NOT run after it. Grounding: the `?` on the
|
||||
/// inner read in `read_sectors`.
|
||||
#[test]
|
||||
fn inner_read_error_propagates() {
|
||||
let mut wrapped = DecryptingSectorSource::new(FailingSource, DecryptKeys::None);
|
||||
let mut buf = vec![0u8; 2048];
|
||||
let r = wrapped.read_sectors(0, 1, &mut buf, false);
|
||||
let err = r.expect_err("inner error must propagate");
|
||||
let io: std::io::Error = err.into();
|
||||
assert_eq!(io.kind(), std::io::ErrorKind::TimedOut);
|
||||
}
|
||||
|
||||
/// With AACS keys but an out-of-range `unit_key_idx`, the decrypt
|
||||
/// step must fail (DecryptFailed) rather than silently returning
|
||||
/// still-encrypted bytes. Grounding: `decrypt_sectors`' unit-key
|
||||
/// lookup — `unit_keys.get(idx)` → None → Error::DecryptFailed.
|
||||
#[test]
|
||||
fn aacs_missing_unit_key_errors() {
|
||||
let src = PatternedSource { capacity: 16 };
|
||||
// idx 0 requested, but unit_keys is empty → get(0) == None.
|
||||
let mut wrapped = DecryptingSectorSource::new(
|
||||
src,
|
||||
DecryptKeys::Aacs {
|
||||
unit_keys: Vec::new(),
|
||||
read_data_key: None,
|
||||
},
|
||||
);
|
||||
let mut buf = vec![0u8; 2048];
|
||||
let r = wrapped.read_sectors(0, 1, &mut buf, false);
|
||||
let err = r.expect_err("missing unit key must error, not pass through encrypted");
|
||||
assert_eq!(
|
||||
err.code(),
|
||||
crate::error::Error::DecryptFailed.code(),
|
||||
"must surface DecryptFailed"
|
||||
);
|
||||
}
|
||||
|
||||
/// A source that yields exactly one CLEAR AACS aligned unit (6144
|
||||
/// bytes = 3 sectors) with MPEG-TS sync bytes (0x47) at the BD-TS
|
||||
/// stride (offset 4, then every 192 bytes). `is_aacs_scrambled`
|
||||
/// reports such a unit as NOT scrambled, so the AACS decrypt path
|
||||
/// reaches the per-unit closure and leaves it untouched — letting
|
||||
/// us prove the unit-key LOOKUP (not the cipher) is what fails for
|
||||
/// an out-of-range index.
|
||||
struct ClearUnitSource;
|
||||
impl SectorSource for ClearUnitSource {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
let bytes = count as usize * 2048;
|
||||
buf[..bytes].fill(0);
|
||||
// BD-TS sync byte at offset 4 of every 192-byte packet.
|
||||
let mut off = 4usize;
|
||||
while off < bytes {
|
||||
buf[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// `with_unit_key_idx` selects which unit key the AACS path uses.
|
||||
/// idx=2 against a single populated key is out of range → the
|
||||
/// `unit_keys.get(idx)` lookup returns None → DecryptFailed. idx=0
|
||||
/// is in range → the lookup succeeds, and on a clear (TS-sync
|
||||
/// intact) full unit the cipher is a no-op, so the read returns Ok
|
||||
/// with the bytes unchanged. Grounding: `decrypt_sectors`'
|
||||
/// `unit_keys.get(unit_key_idx)`.
|
||||
#[test]
|
||||
fn with_unit_key_idx_selects_key() {
|
||||
let keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0u32, [0u8; 16])],
|
||||
read_data_key: None,
|
||||
};
|
||||
// 3 sectors = one 6144-byte aligned unit (so partial_len == 0).
|
||||
let mut buf = vec![0u8; 3 * 2048];
|
||||
|
||||
// idx=2 out of range → lookup fails.
|
||||
let mut bad =
|
||||
DecryptingSectorSource::new(ClearUnitSource, keys.clone()).with_unit_key_idx(2);
|
||||
assert!(
|
||||
bad.read_sectors(0, 3, &mut buf, false).is_err(),
|
||||
"out-of-range unit_key_idx must fail the lookup"
|
||||
);
|
||||
|
||||
// idx=0 in range → lookup ok, clear unit left untouched.
|
||||
let mut good = DecryptingSectorSource::new(ClearUnitSource, keys).with_unit_key_idx(0);
|
||||
let mut buf2 = vec![0u8; 3 * 2048];
|
||||
let n = good.read_sectors(0, 3, &mut buf2, false).unwrap();
|
||||
assert_eq!(n, 3 * 2048);
|
||||
// Clear unit: sync byte preserved at offset 4.
|
||||
assert_eq!(
|
||||
buf2[4], 0x47,
|
||||
"clear unit must be left intact under valid idx"
|
||||
);
|
||||
}
|
||||
|
||||
/// `set_keys` must replace the active keys mid-life. We use a
|
||||
/// CSS-SCRAMBLED-flagged sector (byte 0x14 scramble bits set) so the
|
||||
/// effect of the active key is observable: under a CSS key the
|
||||
/// descrambler XORs a keystream into bytes 128..2048 AND clears the
|
||||
/// scramble flags (`sector[0x14] &= 0xCF`); under `None` the bytes
|
||||
/// pass through unchanged. Flipping keys mid-life must change which
|
||||
/// behavior runs. Grounding: `set_keys` + `css::lfsr::descramble_sector`
|
||||
/// (keystream XOR + flag-clear on flags != 0).
|
||||
#[test]
|
||||
fn set_keys_swaps_active_keys() {
|
||||
struct ScrambledSector {
|
||||
template: [u8; 2048],
|
||||
}
|
||||
impl SectorSource for ScrambledSector {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
let bytes = count as usize * 2048;
|
||||
for s in 0..count as usize {
|
||||
buf[s * 2048..(s + 1) * 2048].copy_from_slice(&self.template);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// Build a sector flagged as scrambled (bits 4-5 of byte 0x14
|
||||
// set) with non-zero payload so the keystream XOR is visible.
|
||||
let mut template = [0u8; 2048];
|
||||
for (i, b) in template.iter_mut().enumerate() {
|
||||
*b = (i as u8).wrapping_mul(29).wrapping_add(3);
|
||||
}
|
||||
template[0x14] = 0x30; // scramble bits (4-5) set → flags == 0x03
|
||||
let pristine = template;
|
||||
|
||||
// Start with None → pass-through (no descramble, flags stay set).
|
||||
let mut wrapped =
|
||||
DecryptingSectorSource::new(ScrambledSector { template }, DecryptKeys::None);
|
||||
let mut got = [0u8; 2048];
|
||||
wrapped.read_sectors(0, 1, &mut got, false).unwrap();
|
||||
assert_eq!(
|
||||
got, pristine,
|
||||
"None keys must pass the sector through unchanged"
|
||||
);
|
||||
assert_eq!(
|
||||
got[0x14] & 0x30,
|
||||
0x30,
|
||||
"None must leave the scramble flags set"
|
||||
);
|
||||
|
||||
// Swap to a CSS key: now the descrambler runs and must clear the
|
||||
// scramble flags (and XOR the data region), so the bytes differ.
|
||||
wrapped.set_keys(DecryptKeys::Css {
|
||||
title_key: [0xa1, 0xb2, 0xc3, 0xd4, 0xe5],
|
||||
});
|
||||
let mut got2 = [0u8; 2048];
|
||||
wrapped.read_sectors(0, 1, &mut got2, false).unwrap();
|
||||
assert_eq!(
|
||||
got2[0x14] & 0x30,
|
||||
0x00,
|
||||
"CSS descramble must clear the scramble-control bits"
|
||||
);
|
||||
assert_ne!(
|
||||
&got2[128..2048],
|
||||
&pristine[128..2048],
|
||||
"CSS descramble must alter the encrypted data region"
|
||||
);
|
||||
}
|
||||
|
||||
/// `into_inner` / `inner` / `inner_mut` must hand back the original
|
||||
/// source unchanged. Grounding: the accessor methods.
|
||||
#[test]
|
||||
fn inner_accessors_round_trip() {
|
||||
let src = PatternedSource { capacity: 42 };
|
||||
let mut wrapped = DecryptingSectorSource::new(src, DecryptKeys::None);
|
||||
assert_eq!(wrapped.inner().capacity_sectors(), 42);
|
||||
assert_eq!(wrapped.inner_mut().capacity_sectors(), 42);
|
||||
let recovered = wrapped.into_inner();
|
||||
assert_eq!(recovered.capacity_sectors(), 42);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,3 +123,151 @@ pub use crate::io::file_sector_source::FileSectorSource;
|
||||
pub use decrypting::DecryptingSectorSource;
|
||||
pub use file::FileSectorSink;
|
||||
pub use prefetched::PrefetchedSectorSource;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// A fully-instrumented SectorSource: records every read's
|
||||
/// (lba, count, recovery), reports a known capacity, and records
|
||||
/// set_speed calls. Lets the forwarding-impl tests prove each
|
||||
/// trait method is delegated, not stubbed.
|
||||
struct Spy {
|
||||
capacity: u32,
|
||||
reads: Arc<Mutex<Vec<(u32, u16, bool)>>>,
|
||||
speeds: Arc<Mutex<Vec<u16>>>,
|
||||
}
|
||||
|
||||
impl Spy {
|
||||
fn new(
|
||||
capacity: u32,
|
||||
) -> (
|
||||
Self,
|
||||
Arc<Mutex<Vec<(u32, u16, bool)>>>,
|
||||
Arc<Mutex<Vec<u16>>>,
|
||||
) {
|
||||
let reads = Arc::new(Mutex::new(Vec::new()));
|
||||
let speeds = Arc::new(Mutex::new(Vec::new()));
|
||||
(
|
||||
Self {
|
||||
capacity,
|
||||
reads: reads.clone(),
|
||||
speeds: speeds.clone(),
|
||||
},
|
||||
reads,
|
||||
speeds,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorSource for Spy {
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
self.capacity
|
||||
}
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
recovery: bool,
|
||||
) -> Result<usize> {
|
||||
self.reads.lock().unwrap().push((lba, count, recovery));
|
||||
let bytes = count as usize * 2048;
|
||||
buf[..bytes].fill(0xa5);
|
||||
Ok(bytes)
|
||||
}
|
||||
fn set_speed(&mut self, kbs: u16) {
|
||||
self.speeds.lock().unwrap().push(kbs);
|
||||
}
|
||||
}
|
||||
|
||||
/// The default `capacity_sectors` is 0 (unknown). Grounding: trait
|
||||
/// default body `fn capacity_sectors(&self) -> u32 { 0 }`.
|
||||
#[test]
|
||||
fn default_capacity_is_zero() {
|
||||
struct Minimal;
|
||||
impl SectorSource for Minimal {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
_count: u16,
|
||||
_buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
assert_eq!(Minimal.capacity_sectors(), 0);
|
||||
}
|
||||
|
||||
/// The default `set_speed` is a no-op that must not panic.
|
||||
/// Grounding: trait default body `fn set_speed(&mut self, _kbs) {}`.
|
||||
#[test]
|
||||
fn default_set_speed_is_noop() {
|
||||
struct Minimal;
|
||||
impl SectorSource for Minimal {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
_count: u16,
|
||||
_buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
let mut m = Minimal;
|
||||
m.set_speed(12345); // must not panic
|
||||
}
|
||||
|
||||
/// `Box<dyn SectorSource>` must forward ALL three trait methods to
|
||||
/// the inner source (capacity, read_sectors args + return, speed) —
|
||||
/// the blanket impl exists so boxed sources satisfy generic
|
||||
/// decorator bounds. Grounding: `impl SectorSource for
|
||||
/// Box<dyn SectorSource>` forwarding bodies.
|
||||
#[test]
|
||||
fn boxed_dyn_forwards_all_methods() {
|
||||
let (spy, reads, speeds) = Spy::new(777);
|
||||
let mut boxed: Box<dyn SectorSource> = Box::new(spy);
|
||||
|
||||
assert_eq!(boxed.capacity_sectors(), 777, "capacity must forward");
|
||||
|
||||
let mut buf = vec![0u8; 3 * 2048];
|
||||
let n = boxed.read_sectors(99, 3, &mut buf, true).unwrap();
|
||||
assert_eq!(n, 3 * 2048, "read return must forward");
|
||||
assert!(buf.iter().all(|b| *b == 0xa5), "inner must have filled buf");
|
||||
|
||||
boxed.set_speed(5400);
|
||||
|
||||
assert_eq!(
|
||||
*reads.lock().unwrap(),
|
||||
vec![(99, 3, true)],
|
||||
"read args (lba/count/recovery) must forward unchanged"
|
||||
);
|
||||
assert_eq!(
|
||||
*speeds.lock().unwrap(),
|
||||
vec![5400],
|
||||
"set_speed must forward"
|
||||
);
|
||||
}
|
||||
|
||||
/// `&mut dyn SectorSource` must likewise forward all three methods.
|
||||
/// Grounding: `impl SectorSource for &mut (dyn SectorSource + '_)`.
|
||||
#[test]
|
||||
fn mut_ref_dyn_forwards_all_methods() {
|
||||
let (mut spy, reads, speeds) = Spy::new(123);
|
||||
let r: &mut dyn SectorSource = &mut spy;
|
||||
|
||||
assert_eq!(r.capacity_sectors(), 123);
|
||||
|
||||
let mut buf = vec![0u8; 2 * 2048];
|
||||
let n = r.read_sectors(7, 2, &mut buf, false).unwrap();
|
||||
assert_eq!(n, 2 * 2048);
|
||||
|
||||
r.set_speed(8800);
|
||||
|
||||
assert_eq!(*reads.lock().unwrap(), vec![(7, 2, false)]);
|
||||
assert_eq!(*speeds.lock().unwrap(), vec![8800]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -721,4 +721,493 @@ mod tests {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Additional coverage below.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Records every (lba, count) the producer issued, in order, and
|
||||
/// always satisfies the full request. Lets a test assert the exact
|
||||
/// read schedule (LBA walk, batch sizing, unit trimming).
|
||||
struct RecordingSource {
|
||||
capacity: u32,
|
||||
calls: Arc<Mutex<Vec<(u32, u16)>>>,
|
||||
}
|
||||
impl SectorSource for RecordingSource {
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
self.capacity
|
||||
}
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
self.calls.lock().unwrap().push((lba, count));
|
||||
let bytes = count as usize * 2048;
|
||||
buf[..bytes].fill((lba & 0xff) as u8);
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Always returns a typed I/O error on the first read. Verifies the
|
||||
/// producer forwards the underlying error verbatim through the
|
||||
/// channel instead of swallowing it / treating it as EOF.
|
||||
struct ErrorSource;
|
||||
impl SectorSource for ErrorSource {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
_count: u16,
|
||||
_buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
Err(crate::error::Error::IoError {
|
||||
source: std::io::Error::from(std::io::ErrorKind::PermissionDenied),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a byte count that is NOT a whole number of sectors
|
||||
/// (n % 2048 != 0). The producer must reject this as a split-sector
|
||||
/// short read rather than truncate-and-advance into a partial unit.
|
||||
struct PartialSectorSource;
|
||||
impl SectorSource for PartialSectorSource {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
_count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
// One sector plus 100 bytes — never a multiple of 2048.
|
||||
let n = 2048 + 100;
|
||||
buf[..n].fill(0xab);
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
/// Drains a prefetch source via the direct `read_sectors` API into a
|
||||
/// single contiguous Vec, stopping at the first EOF (Ok(0)) or the
|
||||
/// first error. Returns (bytes, last_result).
|
||||
fn drain_direct(
|
||||
pf: &mut PrefetchedSectorSource,
|
||||
buf_sectors: u16,
|
||||
max_iters: usize,
|
||||
) -> (Vec<u8>, Result<usize>) {
|
||||
let mut buf = vec![0u8; buf_sectors as usize * 2048];
|
||||
let mut out = Vec::new();
|
||||
let mut last: Result<usize> = Ok(0);
|
||||
for _ in 0..max_iters {
|
||||
let r = pf.read_sectors(0, buf_sectors, &mut buf, false);
|
||||
match r {
|
||||
Ok(0) => {
|
||||
last = Ok(0);
|
||||
break;
|
||||
}
|
||||
Ok(n) => {
|
||||
out.extend_from_slice(&buf[..n]);
|
||||
last = Ok(n);
|
||||
}
|
||||
Err(e) => {
|
||||
last = Err(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
(out, last)
|
||||
}
|
||||
|
||||
/// `capacity_sectors` returns the sum of all extents' sector_counts,
|
||||
/// computed once at construction. Grounding: doc comment on
|
||||
/// `total_sectors` — "the sum of each extent's sector_count".
|
||||
#[test]
|
||||
fn capacity_sectors_sums_all_extents() {
|
||||
with_watchdog(Duration::from_secs(10), || {
|
||||
let extents = vec![
|
||||
Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 9,
|
||||
},
|
||||
Extent {
|
||||
start_lba: 100,
|
||||
sector_count: 6,
|
||||
},
|
||||
Extent {
|
||||
start_lba: 500,
|
||||
sector_count: 3,
|
||||
},
|
||||
];
|
||||
let src = PatternSource { capacity: 9999 };
|
||||
let pf = PrefetchedSectorSource::new(src, extents, 3, None).expect("spawn");
|
||||
// 9 + 6 + 3 = 18, independent of inner source capacity.
|
||||
assert_eq!(pf.capacity_sectors(), 18);
|
||||
// Release the producer without draining: peel the channels
|
||||
// and drop them so the producer observes disconnection
|
||||
// (dropping `pf` directly would join while still holding the
|
||||
// channels → deadlock; the production drain path always uses
|
||||
// into_channels).
|
||||
let (rx, recycle_tx, shell) = pf.into_channels();
|
||||
drop(rx);
|
||||
drop(recycle_tx);
|
||||
drop(shell);
|
||||
});
|
||||
}
|
||||
|
||||
/// Total-sector accumulation must clamp at u32::MAX rather than
|
||||
/// panic (debug overflow) or wrap (release) on a hostile extent set
|
||||
/// whose summed sector_count exceeds u32. Grounding: the `new`
|
||||
/// comment — "Accumulate in u64 then clamp ... a naive u32 sum()
|
||||
/// could panic in debug / wrap in release".
|
||||
#[test]
|
||||
fn capacity_sectors_clamps_on_overflow() {
|
||||
with_watchdog(Duration::from_secs(10), || {
|
||||
let extents = vec![
|
||||
Extent {
|
||||
start_lba: 0,
|
||||
sector_count: u32::MAX,
|
||||
},
|
||||
Extent {
|
||||
start_lba: 0,
|
||||
sector_count: u32::MAX,
|
||||
},
|
||||
];
|
||||
// batch=3 so the producer makes forward progress on the
|
||||
// EndlessZeroSource; we only care about the construction-time
|
||||
// capacity computation here, then we drop to join.
|
||||
let pf =
|
||||
PrefetchedSectorSource::new(EndlessZeroSource, extents, 3, None).expect("spawn");
|
||||
assert_eq!(
|
||||
pf.capacity_sectors(),
|
||||
u32::MAX,
|
||||
"summed total must saturate at u32::MAX, not wrap"
|
||||
);
|
||||
// Release the producer via into_channels + drop (a direct
|
||||
// drop of `pf` would join while still holding the channels →
|
||||
// deadlock against the still-running EndlessZeroSource).
|
||||
let (rx, recycle_tx, shell) = pf.into_channels();
|
||||
drop(rx);
|
||||
drop(recycle_tx);
|
||||
drop(shell);
|
||||
});
|
||||
}
|
||||
|
||||
/// The producer must walk extents in list order and start each
|
||||
/// extent at its `start_lba` (plus running offset within the
|
||||
/// extent), never reorder or merge them. Grounding: lifecycle doc
|
||||
/// — "walks the supplied extent list in order" and
|
||||
/// `lba = extent.start_lba.saturating_add(offset)`.
|
||||
#[test]
|
||||
fn producer_walks_extents_in_order_at_correct_lbas() {
|
||||
with_watchdog(Duration::from_secs(10), || {
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let extents = vec![
|
||||
Extent {
|
||||
start_lba: 1000,
|
||||
sector_count: 6, // two 3-sector batches
|
||||
},
|
||||
Extent {
|
||||
start_lba: 50,
|
||||
sector_count: 3, // one batch — lower LBA, MUST stay second
|
||||
},
|
||||
];
|
||||
let src = RecordingSource {
|
||||
capacity: 99999,
|
||||
calls: calls.clone(),
|
||||
};
|
||||
let mut pf = PrefetchedSectorSource::new(src, extents, 3, None).expect("spawn");
|
||||
let (got, last) = drain_direct(&mut pf, 3, 16);
|
||||
assert_eq!(last.unwrap(), 0, "should reach EOF");
|
||||
assert_eq!(got.len(), (6 + 3) * 2048);
|
||||
drop(pf);
|
||||
let recorded = calls.lock().unwrap().clone();
|
||||
// Expect: extent0 at 1000 then 1003 (offset+3), then extent1 at 50.
|
||||
assert_eq!(
|
||||
recorded,
|
||||
vec![(1000, 3), (1003, 3), (50, 3)],
|
||||
"extents must be walked in list order at their start_lba+offset"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// A batch larger than one unit must be trimmed DOWN to a whole
|
||||
/// number of 3-sector units before issuing the read — never a
|
||||
/// sub-unit count that decrypt would leave partially encrypted.
|
||||
/// batch=5 → trimmed to 3 (5 - 5%3). Grounding: the unit-trim block
|
||||
/// `sectors -= sectors % SECTOR_ALIGNMENT`.
|
||||
#[test]
|
||||
fn batch_trimmed_to_whole_units() {
|
||||
with_watchdog(Duration::from_secs(10), || {
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
// 9 sectors total = three 3-sector units.
|
||||
let extents = vec![Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 9,
|
||||
}];
|
||||
let src = RecordingSource {
|
||||
capacity: 9,
|
||||
calls: calls.clone(),
|
||||
};
|
||||
// batch=5: each read must be trimmed to 3 (one unit), so
|
||||
// 9 sectors take three reads of 3, never a 5/4-sector read.
|
||||
let mut pf = PrefetchedSectorSource::new(src, extents, 5, None).expect("spawn");
|
||||
let (got, last) = drain_direct(&mut pf, 5, 16);
|
||||
assert_eq!(last.unwrap(), 0);
|
||||
assert_eq!(got.len(), 9 * 2048);
|
||||
drop(pf);
|
||||
let recorded = calls.lock().unwrap().clone();
|
||||
assert!(
|
||||
recorded.iter().all(|&(_, c)| c % SECTOR_ALIGNMENT == 0),
|
||||
"every issued read must be a whole number of units, got {recorded:?}"
|
||||
);
|
||||
assert!(
|
||||
recorded.iter().all(|&(_, c)| c == 3),
|
||||
"batch=5 must trim to one 3-sector unit per read, got {recorded:?}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// An extent whose sector_count IS a multiple of 3 must deliver
|
||||
/// exactly that many sectors and then cleanly EOF (no error on the
|
||||
/// final aligned batch). Grounding: the trailing-tail guard only
|
||||
/// fires for sub-unit leftovers; a unit-aligned extent forms full
|
||||
/// units on its own (the comment at line ~188).
|
||||
#[test]
|
||||
fn unit_aligned_extent_delivers_all_and_eofs() {
|
||||
with_watchdog(Duration::from_secs(10), || {
|
||||
// 12 sectors = exactly four 3-sector units.
|
||||
let extents = vec![Extent {
|
||||
start_lba: 7,
|
||||
sector_count: 12,
|
||||
}];
|
||||
let src = PatternSource { capacity: 100 };
|
||||
let mut pf = PrefetchedSectorSource::new(src, extents, 6, None).expect("spawn");
|
||||
let (got, last) = drain_direct(&mut pf, 6, 16);
|
||||
assert_eq!(
|
||||
last.unwrap(),
|
||||
0,
|
||||
"unit-aligned extent must EOF cleanly, not error"
|
||||
);
|
||||
assert_eq!(got.len(), 12 * 2048);
|
||||
});
|
||||
}
|
||||
|
||||
/// The underlying reader's error must propagate to the consumer as
|
||||
/// an error (not Ok(0)/EOF), and its ErrorKind must survive the
|
||||
/// round-trip through the channel. Grounding: the producer's
|
||||
/// `Err(e) => tx.send(Err(e.into()))` arm, and `read_sectors`'
|
||||
/// `Ok(Err(e)) => Err(IoError{source:e})`.
|
||||
#[test]
|
||||
fn reader_error_propagates_with_kind() {
|
||||
with_watchdog(Duration::from_secs(10), || {
|
||||
let extents = vec![Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 3,
|
||||
}];
|
||||
let mut pf = PrefetchedSectorSource::new(ErrorSource, extents, 3, None).expect("spawn");
|
||||
let mut buf = vec![0u8; 3 * 2048];
|
||||
let r = pf.read_sectors(0, 3, &mut buf, false);
|
||||
let err = r.expect_err("reader error must surface as Err, not EOF");
|
||||
let io: std::io::Error = err.into();
|
||||
assert_eq!(
|
||||
io.kind(),
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"underlying ErrorKind must survive the channel round-trip"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// A read returning a byte count that is not a whole number of
|
||||
/// sectors (n % 2048 != 0) must be rejected — never truncated and
|
||||
/// advanced, which would split a sector and hand decrypt a partial
|
||||
/// unit. Grounding: the `if n % 2048 != 0 { send Err }` guard.
|
||||
#[test]
|
||||
fn non_sector_multiple_read_rejected() {
|
||||
with_watchdog(Duration::from_secs(10), || {
|
||||
let extents = vec![Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 9,
|
||||
}];
|
||||
let mut pf =
|
||||
PrefetchedSectorSource::new(PartialSectorSource, extents, 3, None).expect("spawn");
|
||||
let mut buf = vec![0u8; 3 * 2048];
|
||||
let r = pf.read_sectors(0, 3, &mut buf, false);
|
||||
let err = r.expect_err("split-sector read must be rejected");
|
||||
let io: std::io::Error = err.into();
|
||||
assert_eq!(
|
||||
io.kind(),
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"split-sector read maps to ExtentNotUnitAligned (InvalidInput)"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// A too-small consumer buffer in the direct `read_sectors` path
|
||||
/// must error (InvalidInput), never silently drop the bytes past
|
||||
/// `buf.len()`. Grounding: the `if filled.len() > buf.len()` guard
|
||||
/// in `read_sectors` ("would silently drop filled[buf.len()..],
|
||||
/// desyncing the stream").
|
||||
#[test]
|
||||
fn direct_read_too_small_buffer_errors() {
|
||||
with_watchdog(Duration::from_secs(10), || {
|
||||
let extents = vec![Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 6,
|
||||
}];
|
||||
let src = PatternSource { capacity: 6 };
|
||||
// batch=3 → producer fills 3 sectors (6144 bytes) per batch.
|
||||
let mut pf = PrefetchedSectorSource::new(src, extents, 3, None).expect("spawn");
|
||||
// Caller buffer holds only 1 sector — far too small.
|
||||
let mut tiny = vec![0u8; 2048];
|
||||
let r = pf.read_sectors(0, 1, &mut tiny, false);
|
||||
let err = r.expect_err("too-small buffer must error, not truncate");
|
||||
let io: std::io::Error = err.into();
|
||||
assert_eq!(io.kind(), std::io::ErrorKind::InvalidInput);
|
||||
drop(pf);
|
||||
});
|
||||
}
|
||||
|
||||
/// The producer delivers exactly the bytes the inner source
|
||||
/// produced, in order, byte-for-byte. PatternSource tags each
|
||||
/// sector with `(lba & 0xff)`, so the assembled stream must match a
|
||||
/// reconstruction from the extent's LBA range. Guards against
|
||||
/// off-by-one/duplicate/reorder in the offset bookkeeping.
|
||||
#[test]
|
||||
fn delivered_bytes_match_source_exactly() {
|
||||
with_watchdog(Duration::from_secs(10), || {
|
||||
let start = 40u32;
|
||||
let count = 9u32; // three units
|
||||
let extents = vec![Extent {
|
||||
start_lba: start,
|
||||
sector_count: count,
|
||||
}];
|
||||
let src = PatternSource { capacity: 1000 };
|
||||
let mut pf = PrefetchedSectorSource::new(src, extents, 3, None).expect("spawn");
|
||||
let (got, last) = drain_direct(&mut pf, 3, 16);
|
||||
assert_eq!(last.unwrap(), 0);
|
||||
assert_eq!(got.len(), (count as usize) * 2048);
|
||||
// Reconstruct expected: sector i carries byte ((start+i)&0xff).
|
||||
for i in 0..count {
|
||||
let tag = ((start + i) & 0xff) as u8;
|
||||
let off = i as usize * 2048;
|
||||
assert!(
|
||||
got[off..off + 2048].iter().all(|b| *b == tag),
|
||||
"sector {i} (lba {}) content mismatch",
|
||||
start + i
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// An empty extent list must EOF immediately (capacity 0, first
|
||||
/// direct read returns Ok(0)) and must not deadlock. Grounding: the
|
||||
/// producer's `while ext_idx < extents.len()` loop body never runs,
|
||||
/// so `tx` drops and the consumer sees RecvError → Ok(0).
|
||||
#[test]
|
||||
fn empty_extents_eof_immediately() {
|
||||
with_watchdog(Duration::from_secs(10), || {
|
||||
let pf =
|
||||
PrefetchedSectorSource::new(EndlessZeroSource, Vec::new(), 3, None).expect("spawn");
|
||||
assert_eq!(pf.capacity_sectors(), 0);
|
||||
let mut pf = pf;
|
||||
let mut buf = vec![0u8; 3 * 2048];
|
||||
let n = pf.read_sectors(0, 3, &mut buf, false).unwrap();
|
||||
assert_eq!(n, 0, "empty extent list must EOF immediately");
|
||||
});
|
||||
}
|
||||
|
||||
/// A zero-length extent in the middle of the list must be skipped
|
||||
/// (remaining == 0 → advance to next extent) without emitting a
|
||||
/// batch and without stalling. Grounding: the `if remaining == 0 {
|
||||
/// ext_idx += 1; continue }` branch.
|
||||
#[test]
|
||||
fn zero_length_extent_is_skipped() {
|
||||
with_watchdog(Duration::from_secs(10), || {
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let extents = vec![
|
||||
Extent {
|
||||
start_lba: 10,
|
||||
sector_count: 3,
|
||||
},
|
||||
Extent {
|
||||
start_lba: 20,
|
||||
sector_count: 0, // empty — must be skipped
|
||||
},
|
||||
Extent {
|
||||
start_lba: 30,
|
||||
sector_count: 3,
|
||||
},
|
||||
];
|
||||
let src = RecordingSource {
|
||||
capacity: 9999,
|
||||
calls: calls.clone(),
|
||||
};
|
||||
let mut pf = PrefetchedSectorSource::new(src, extents, 3, None).expect("spawn");
|
||||
let (got, last) = drain_direct(&mut pf, 3, 16);
|
||||
assert_eq!(last.unwrap(), 0);
|
||||
assert_eq!(got.len(), 6 * 2048, "two non-empty extents = 6 sectors");
|
||||
drop(pf);
|
||||
let recorded = calls.lock().unwrap().clone();
|
||||
// No read should target LBA 20 (the empty extent).
|
||||
assert_eq!(
|
||||
recorded,
|
||||
vec![(10, 3), (30, 3)],
|
||||
"empty extent must produce no read"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// A 4-sector extent (one full unit + a 1-sector tail) must
|
||||
/// deliver the 3-sector unit and then error on the 1-sector
|
||||
/// remainder — exercising the trim-within-batch path
|
||||
/// (`sectors -= sectors % 3` lands on 3, leaving remaining=1) that
|
||||
/// then hits the sub-unit guard on the next iteration. Distinct
|
||||
/// control flow from the 8-sector case. Grounding: trailing-tail
|
||||
/// guard plus the unit-trim block.
|
||||
#[test]
|
||||
fn four_sector_extent_errors_on_one_sector_tail() {
|
||||
with_watchdog(Duration::from_secs(10), || {
|
||||
let extents = vec![Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 4,
|
||||
}];
|
||||
let src = PatternSource { capacity: 100 };
|
||||
// batch=9 (>4) so the first iter requests 4, trims to 3.
|
||||
let mut pf = PrefetchedSectorSource::new(src, extents, 9, None).expect("spawn");
|
||||
let mut buf = vec![0u8; 9 * 2048];
|
||||
let n0 = pf.read_sectors(0, 9, &mut buf, false).unwrap();
|
||||
assert_eq!(n0, 3 * 2048, "first batch must be exactly one unit");
|
||||
let r = pf.read_sectors(0, 9, &mut buf, false);
|
||||
let err = r.expect_err("1-sector tail must error");
|
||||
let io: std::io::Error = err.into();
|
||||
assert_eq!(io.kind(), std::io::ErrorKind::InvalidInput);
|
||||
});
|
||||
}
|
||||
|
||||
/// Many sequential direct reads across MANY extents must all flow
|
||||
/// through the fixed recycle pool without deadlock — a stronger
|
||||
/// version of the pool-depth regression that also crosses extent
|
||||
/// boundaries (offset reset to 0, ext_idx advance). Grounding: the
|
||||
/// recycle-pool comment in `read_sectors`.
|
||||
#[test]
|
||||
fn many_extents_drain_without_deadlock() {
|
||||
with_watchdog(Duration::from_secs(15), || {
|
||||
// 10 extents of 3 sectors each = 30 sectors total, well past
|
||||
// the 3-buffer pool, and 10 extent transitions.
|
||||
let extents: Vec<Extent> = (0..10)
|
||||
.map(|i| Extent {
|
||||
start_lba: i * 1000,
|
||||
sector_count: 3,
|
||||
})
|
||||
.collect();
|
||||
let src = PatternSource { capacity: 999999 };
|
||||
let mut pf = PrefetchedSectorSource::new(src, extents, 3, None).expect("spawn");
|
||||
let (got, last) = drain_direct(&mut pf, 3, 64);
|
||||
assert_eq!(last.unwrap(), 0);
|
||||
assert_eq!(got.len(), 30 * 2048, "all 10 extents must be drained");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user