Merge branch 'audit-lib-G3' into audit-fix-lib

This commit is contained in:
Matthew Jackson
2026-10-01 15:12:45 -07:00
18 changed files with 515 additions and 42 deletions
+22
View File
@@ -159,4 +159,26 @@ mod tests {
}
);
}
// Each switch follows its own variable; no other test reads either one.
#[test]
fn diag_from_env_reads_each_switch_from_its_own_variable() {
let set = |k: &str, on: bool| unsafe {
if on {
std::env::set_var(k, "1")
} else {
std::env::remove_var(k)
}
};
set("FREEMKV_SKIP_PARSE", true);
set("FREEMKV_PROFILE", false);
let d = Diag::from_env();
assert!(d.skip_parse && !d.profile);
set("FREEMKV_SKIP_PARSE", false);
set("FREEMKV_PROFILE", true);
let d = Diag::from_env();
assert!(!d.skip_parse && d.profile);
set("FREEMKV_PROFILE", false);
assert_eq!(Diag::from_env(), Diag::default());
}
}
+8
View File
@@ -774,6 +774,9 @@ mod tests {
assert_eq!(tv_system_str(FrameRate::F25), "PAL");
assert_eq!(fps_str(FrameRate::F29_97), "29.97");
assert_eq!(tv_system_str(FrameRate::F29_97), "NTSC");
assert_eq!(tv_system_str(FrameRate::F50), "PAL");
assert_eq!(tv_system_str(FrameRate::F23_976), "NTSC");
assert_eq!(tv_system_str(FrameRate::F59_94), "NTSC");
}
#[test]
@@ -893,5 +896,10 @@ mod tests {
assert!(row.contains("block_mode=2"), "{row}");
assert!(row.contains("block_type=1"), "{row}");
assert!(row.contains("DROP(leading-secondary-block-piece)"), "{row}");
// The same secondary piece past the leading run is kept, as feature body.
let row = dvd_cell_row(3, &sec, false);
assert!(row.contains("keep(feature-body)"), "{row}");
assert!(!row.contains("keep(plain-feature)"), "{row}");
}
}
+3
View File
@@ -117,6 +117,9 @@ mod tests {
assert!(allowed_after_cancel(&EJECT, none, FinishEject));
assert!(!allowed_after_cancel(&EJECT, locked, Plain));
assert!(!allowed_after_cancel(&START, none, FinishEject), "LoEj=0");
// LoEj=1 with Start=1 loads the tray: not an eject.
let load = [0x1B, 0, 0, 0, 0x03, 0];
assert!(!allowed_after_cancel(&load, none, FinishEject), "Start=1");
// Row 3: 0x3F iff that AGID's bit is set.
assert!(allowed_after_cancel(&release(2), agid2, Plain));
assert!(!allowed_after_cancel(&release(1), agid2, Plain));
+42 -6
View File
@@ -492,7 +492,7 @@ impl Drive {
buf: &mut [u8],
timeout_ms: u32,
) -> Result<crate::scsi::ScsiResult> {
crate::halt::diag::assert_may_block("Drive::exec", Duration::MAX);
crate::halt::diag::assert_may_block("Drive::exec_uncancellable", Duration::MAX);
self.dispatch(cdb, dir, buf, timeout_ms)
}
@@ -507,7 +507,7 @@ impl Drive {
timeout_ms: u32,
ctx: CleanupCtx,
) -> Result<crate::scsi::ScsiResult> {
crate::halt::diag::assert_may_block("Drive::exec", Duration::MAX);
crate::halt::diag::assert_may_block("Drive::exec_cleanup", Duration::MAX);
if self.is_halted() && !allow::allowed_after_cancel(cdb, self.ledger(), ctx) {
return Err(Error::Halted);
}
@@ -849,9 +849,10 @@ impl Drive {
}
}
/// True when the mounted disc is a DVD (profile family `0x0010..=0x001F`).
/// True when the mounted disc is a DVD (profile family `0x0010..=0x001F`,
/// plus DVD+RW DL `0x002A` and DVD+R DL `0x002B`).
pub(crate) fn disc_is_dvd(&mut self) -> bool {
matches!(self.current_profile(), Some(p) if (0x0010..=0x001F).contains(&p))
matches!(self.current_profile(), Some(p) if (0x0010..=0x001F).contains(&p) || p == 0x002A || p == 0x002B)
}
/// Initialize drive — drive-prep unlock + init.
@@ -2282,7 +2283,8 @@ mod command_tests {
));
}
// disc_is_dvd() must match ONLY the DVD profile family (0x0010..=0x001F).
// disc_is_dvd() must match ONLY the DVD profile family (0x0010..=0x001F plus
// the DVD+ DL profiles 0x002A / 0x002B).
#[test]
fn disc_is_dvd_matches_only_dvd_profile_family() {
let probe = |profile: u16| {
@@ -2294,7 +2296,9 @@ mod command_tests {
// DVD family → DVD (skip drive unlock, run stock for CSS).
assert!(probe(0x0010), "DVD-ROM");
assert!(probe(0x0011), "DVD-R");
assert!(probe(0x001B), "DVD+R DL");
assert!(probe(0x001B), "DVD+R");
assert!(probe(0x002A), "DVD+RW DL");
assert!(probe(0x002B), "DVD+R DL");
// BD/UHD family → NOT DVD (must keep today's unlock path).
assert!(!probe(0x0040), "BD-ROM (UHD) must NOT be classed as DVD");
assert!(!probe(0x0041), "BD-R");
@@ -3876,6 +3880,38 @@ mod command_tests {
);
}
// A Stop that lands DURING the spin-up settle (the second pause) must wake it too.
#[test]
fn spin_cycle_wakes_from_its_spin_up_settle_when_stopped() {
let RecordingHarness {
drive: mut d,
cdb,
timeouts: _to,
} = recording(TransportOutcome::Ok(0));
let flag = d.halt_flag();
let stopper = std::thread::spawn(move || {
// Wait for the START (spin-up) CDB, then stop inside the settle pause.
while cdb.lock().unwrap().get(4) != Some(&0x01) {
std::thread::sleep(std::time::Duration::from_millis(20));
}
std::thread::sleep(std::time::Duration::from_millis(200));
flag.store(true, Ordering::Relaxed);
});
let t0 = std::time::Instant::now();
let r = d.spin_cycle();
let took = t0.elapsed();
stopper.join().expect("stopper thread");
assert!(
matches!(r, Err(Error::Halted)),
"a Stop during the spin-up settle must surface as Halted: {r:?}"
);
assert!(
took < std::time::Duration::from_secs(SPIN_DOWN_IDLE_SECS + 3),
"the settle must be halt-aware, not a blind {SPIN_UP_SETTLE_SECS}s \
thread::sleep; took {took:?}"
);
}
// A READ(10) with GOOD status but a residual underrun must be refused AND logged.
#[test]
fn read_logs_a_good_status_short_transfer() {
+13
View File
@@ -122,6 +122,7 @@ fn every_drive_cdb_goes_through_exec() {
assert!(d.mode_sense_page(0x2A).is_none());
assert!(d.read_buffer(2, 0xF1, 16).is_none());
assert!(d.report_key_rpc_state().is_none());
assert!(!d.disc_is_dvd());
assert!(!d.enable_recovered_error_reporting());
assert!(matches!(d.eject(), Err(Error::Halted)));
assert!(matches!(
@@ -198,6 +199,18 @@ fn read_postcheck_discards_after_cancel() {
);
}
/// LD3: every READ-class opcode is checked again on completion, not only READ(10).
#[test]
fn read_class_covers_every_read_opcode() {
for op in [crate::scsi::SCSI_READ_10, 0xA8, 0x88, 0xBE, 0xB9] {
assert!(is_read_class(&[op, 0, 0]), "{op:#04x}");
}
for op in [SCSI_TEST_UNIT_READY, 0x1E, 0x46, 0x25] {
assert!(!is_read_class(&[op, 0, 0]), "{op:#04x}");
}
assert!(!is_read_class(&[]));
}
/// LD4 (Drive half): `exec_cleanup` admits the ALLOW for a tray this Drive locked
/// and refuses anything else after a cancel.
#[test]
+14 -1
View File
@@ -42,7 +42,8 @@ pub struct DriveId {
/// Drive serial number — GET CONFIGURATION Feature 0108h
pub serial_number: String,
/// Raw 96-byte INQUIRY response for additional parsing if needed.
/// Raw INQUIRY response for additional parsing if needed: what the drive
/// sent, 36 to 96 bytes.
pub raw_inquiry: Vec<u8>,
/// Raw GET CONFIGURATION Feature 010Ch response bytes.
@@ -663,6 +664,18 @@ mod tests {
assert_eq!(id.raw_gc_010c.len(), 28, "header + 20-byte descriptor");
}
// MMC-6 §5.3.10: the date is CCYYMMDDHHMI, 12 characters; longer vendor
// payload in the descriptor is not part of it.
#[test]
fn from_drive_firmware_date_is_capped_at_12_characters() {
let mut t = GcReplyTransport {
firmware: gc_reply(0x010C, b"201604250000EXTRA"),
serial: gc_reply(0x0108, b"ABCD1234"),
};
let id = DriveId::from_drive(&mut t).unwrap();
assert_eq!(id.firmware_date, "201604250000");
}
// A drive lacking the feature answers RT=10b with only the 8-byte header.
#[test]
fn from_drive_absent_or_foreign_gc_feature_yields_empty_fields() {
+33 -8
View File
@@ -145,9 +145,9 @@ impl SgIoTransport {
fd
}
// Map errno from a failed open(): permission-denied -> DevicePermission,
// else DeviceNotFound. Path carried in the error; no English text (app
// layer localizes).
// Map errno from a failed open(): EACCES -> DevicePermission, ENOENT/ENXIO/
// ENODEV -> DeviceNotFound, else (EMFILE, EIO, EBUSY) IoError, so the real
// cause is not reported as an unplug. No English text (app layer localizes).
fn open_error<T>(device: &Path) -> Result<T> {
Err(Self::map_open_error(
&std::io::Error::last_os_error(),
@@ -160,10 +160,19 @@ impl SgIoTransport {
Error::DevicePermission {
path: device.display().to_string(),
}
} else {
} else if err.kind() == std::io::ErrorKind::NotFound
|| matches!(err.raw_os_error(), Some(libc::ENXIO | libc::ENODEV))
{
Error::DeviceNotFound {
path: device.display().to_string(),
}
} else {
Error::IoError {
source: match err.raw_os_error() {
Some(code) => std::io::Error::from_raw_os_error(code),
None => std::io::Error::new(err.kind(), err.to_string()),
},
}
}
}
@@ -208,7 +217,7 @@ impl SgIoTransport {
return Err(Error::ScsiError {
opcode: cdb[0],
status: hdr.status,
sense: Some(super::parse_sense(&sense, hdr.sb_len_wr)),
sense: super::sense_for_status(hdr.status, &sense, hdr.sb_len_wr),
});
}
Ok(())
@@ -301,11 +310,11 @@ impl Drop for SgIoTransport {
}
impl ScsiTransport for SgIoTransport {
// Execute via one synchronous SG_IO ioctl; errors map to IoError/ScsiError.
fn last_sense_progress(&self) -> Option<u16> {
self.last_progress
}
// Execute via one synchronous SG_IO ioctl; errors map to IoError/ScsiError.
fn execute(
&mut self,
cdb: &[u8],
@@ -666,8 +675,6 @@ pub(super) fn disc_presence(path: &Path) -> Result<super::DiscPresence> {
r
}
// After a transport failure the fd is reopened in the background and the next
// execute() adopts it. Needs a real device: FREEMKV_TEST_SG_DEVICE (default sg2).
/// Open a block device read-only (no O_DIRECT). Negative on failure.
pub(crate) fn open_block_ro(path: &str) -> i32 {
let mut bytes = path.as_bytes().to_vec();
@@ -701,6 +708,8 @@ mod recovery_device_tests {
use super::*;
use std::time::Duration;
// After a transport failure the fd is reopened in the background and the next
// execute() adopts it. Needs a real device: FREEMKV_TEST_SG_DEVICE (default sg2).
#[test]
#[ignore]
fn timeout_does_not_kill_transport() {
@@ -741,6 +750,22 @@ mod recovery_device_tests {
}
// ── CDB guard wiring ───────────────────────────────────────────────────────
#[cfg(test)]
mod open_error_tests {
use super::*;
#[test]
fn open_errno_maps_to_its_real_cause() {
let p = Path::new("/dev/sg9");
let map = |code| SgIoTransport::map_open_error(&std::io::Error::from_raw_os_error(code), p);
assert!(matches!(map(libc::EACCES), Error::DevicePermission { .. }));
assert!(matches!(map(libc::ENOENT), Error::DeviceNotFound { .. }));
assert!(matches!(map(libc::ENODEV), Error::DeviceNotFound { .. }));
assert!(matches!(map(libc::EMFILE), Error::IoError { .. }));
assert!(matches!(map(libc::EBUSY), Error::IoError { .. }));
}
}
#[cfg(test)]
mod raw_command_cdb_guard_tests {
use super::*;
+25 -3
View File
@@ -15,9 +15,9 @@ use std::sync::atomic::{AtomicBool, Ordering};
const K_SENSE_DATA_SIZE: usize = 32;
/// Max CDB length the SCSI commands this library issues ever use; also
/// the clamp Linux applies. Used to bound the `cdb_len` passed to the
/// shim so a pathological >255-byte slice can't wrap a `u8`.
/// Max CDB length the SCSI commands this library issues ever use. An
/// over-length CDB is rejected (never clamped) before the `cdb_len` reaches
/// the shim, so a pathological >255-byte slice can't wrap a `u8`.
const K_MAX_CDB_SIZE: usize = 16;
// The C shim uses a single global IOKit handle, so only one MacScsiTransport may exist at a
@@ -493,6 +493,28 @@ mod tests {
}
}
/// `execute` itself rejects an over-length CDB; the fake device would accept
/// it if the guard were bypassed and the length truncated.
#[test]
fn execute_rejects_an_oversized_cdb_before_the_shim() {
let _globals = FakeDeviceGuard(shim_globals());
assert!(
!OPEN.swap(true, Ordering::Acquire),
"OPEN held outside SHIM_GLOBALS"
);
assert_eq!(unsafe { shim_selftest_install_fake_device() }, 0);
let mut transport = MacScsiTransport {
last_progress: None,
};
for len in [K_MAX_CDB_SIZE + 1, 256 + 6] {
let r = transport.execute(&vec![0u8; len], DataDirection::None, &mut [], 1_000);
assert!(
matches!(r, Err(Error::InvalidCdbLength { .. })),
"{len}-byte CDB: {r:?}"
);
}
}
/// A CDB exactly at the limit must not trigger the guard, and its length
/// reaches the shim verbatim.
#[test]
+5
View File
@@ -1192,6 +1192,11 @@ mod transport_helper_tests {
let v = get_config_010c(&mut t).unwrap();
assert_eq!(v.len(), 28, "header + full 010Ch descriptor");
assert_eq!(&v[12..24], b"202101311259", "date through the minute");
// MMC-6 §6.6: RT=10b (one feature), Starting Feature 010Ch, Allocation Length 28.
assert_eq!(
t.0,
[SCSI_GET_CONFIGURATION, 0x02, 0x01, 0x0C, 0, 0, 0, 0, 28, 0]
);
}
}
+2 -2
View File
@@ -254,8 +254,8 @@ pub(super) fn list_drives() -> Vec<super::DriveInfo> {
drives
}
/// TEST UNIT READY probe on Windows. No in-library recovery — see the
/// Linux `disc_presence` doc block for the rationale.
/// TEST UNIT READY probe on Windows. No in-library recovery: the answer is
/// classified by [`super::tur_disc_presence`].
pub(super) fn disc_presence(path: &Path) -> Result<super::DiscPresence> {
let mut transport = SptiTransport::open(path)?;
let cdb = [crate::scsi::SCSI_TEST_UNIT_READY, 0, 0, 0, 0, 0];
+128 -9
View File
@@ -330,12 +330,18 @@ impl BusGate {
Some(h) => head_byte0(h),
None => None,
};
// An unreadable or unmapped head is cached as encrypted: never re-read.
// An unreadable head is a guess, not a measurement: it is not
// remembered, so a later read that has the head decides for real.
// An unmapped head can never be measured, so that guess is stable.
let e = byte0.is_none_or(|b| b & CPI_MASK != 0);
if self.cpi.len() >= CPI_CACHE_MAX {
self.cpi.clear();
if byte0.is_some() || head.is_none() {
if self.cpi.len() >= CPI_CACHE_MAX {
self.cpi.clear();
}
self.cpi.insert(key, e);
} else {
return e;
}
self.cpi.insert(key, e);
e
}
};
@@ -658,6 +664,39 @@ mod tests {
assert_eq!(s.inner().fua, [true, false]);
}
// The wrapper is transparent to the inner source's capabilities and speed control.
#[test]
fn bus_removal_forwards_random_access_and_set_speed() {
struct Seq {
speed: u16,
}
impl SectorSource for Seq {
fn capacity_sectors(&self) -> u32 {
0
}
fn read_sectors(&mut self, _: u32, _: u16, _: &mut [u8], _: bool) -> Result<usize> {
Ok(0)
}
fn random_access(&self) -> bool {
false
}
fn set_speed(&mut self, kbs: u16) {
self.speed = kbs;
}
}
let mut s = BusRemovalSectorSource::new(Seq { speed: 0 }, BusStage::Passthrough);
assert!(!s.random_access());
s.set_speed(4500);
assert_eq!(s.inner().speed, 4500);
}
// An extent running past the 32-bit LBA space is clipped at it, never wrapped.
#[test]
fn bus_map_extent_past_the_lba_space_is_clipped() {
let map = BusMap::from_files(vec![vec![(0xFFFF_FFF0, 100)]]);
assert_eq!(map.covered_ranges(), vec![(0xFFFF_FFF0, 16)]);
}
// A partly overlapping second file keeps its file offset across the clip.
#[test]
fn bus_map_partial_overlap_clip_keeps_the_file_offset() {
@@ -987,10 +1026,10 @@ mod tests {
assert_eq!(buf, clear, "unknown head CPI must de-bus");
}
// A unit head that fails to read is fetched once, fast-path (recovery=false),
// and cached as encrypted: neighbours never re-read it.
// A unit head that fails to read is fetched fast-path (recovery=false) and the
// unit guessed encrypted; the guess is not remembered, so each read retries.
#[test]
fn host_key_failed_head_fetch_is_cached_as_encrypted() {
fn host_key_failed_head_fetch_guesses_encrypted_without_caching() {
struct BadHead {
bytes: Vec<u8>,
reads: Vec<(u32, bool)>,
@@ -1036,11 +1075,91 @@ mod tests {
assert_eq!(got, clear);
assert_eq!(
s.inner().reads,
vec![(301, true), (300, false), (302, true)],
"one fast head fetch, then cached"
vec![(301, true), (300, false), (302, true), (300, false)],
"the unreadable head is retried, never cached"
);
}
// A head that was unreadable once and is clear when later read: the guess made
// meanwhile must not decide the whole unit.
#[test]
fn host_key_head_guess_is_replaced_by_the_measured_head() {
let rdk = [0x62u8; 16];
let mut clear = clear_content(3); // LBAs 300..303
for l in 0..3 {
clear[l * SECTOR_BYTES] &= !0xC0;
}
let mut wire = clear.clone();
encrypt_bus(&mut wire, &rdk);
let map = Arc::new(BusMap::from_files(vec![vec![(300, 3)]]));
let mut g = BusGate::new(map);
// First pass: sector 301 alone, head 300 unreadable -> guessed encrypted.
let mut b = sector(&wire, 300, 301).to_vec();
g.debus(&mut b, &rdk, 301, &mut |_| None);
// Second pass: the whole unit with its real (clear) head.
let mut all = clear.clone();
g.debus(&mut all, &rdk, 300, &mut |_| {
unreachable!("head is in the buffer")
});
assert_eq!(all, clear, "a clear unit must pass through untouched");
}
// Any copy-permission bit set means encrypted (CPI 01/10/11), not just the top bit.
#[test]
fn host_key_any_cpi_bit_marks_the_unit_encrypted() {
let rdk = [0x63u8; 16];
for (head, encrypted) in [(0x00u8, false), (0x40, true), (0x80, true), (0xC0, true)] {
let mut clear = clear_content(3);
clear[0] = (clear[0] & !0xC0) | head;
let mut wire = clear.clone();
if encrypted {
encrypt_bus(&mut wire, &rdk);
}
let mut g = BusGate::new(Arc::new(BusMap::from_files(vec![vec![(300, 3)]])));
g.debus(&mut wire, &rdk, 300, &mut |_| None);
assert_eq!(wire, clear, "head byte0 {head:#x}");
}
}
// The decision is kept per (file, unit): interleaved reads of two files' mid-unit
// sectors fetch each head once, and unit 0 of one file does not decide the other's.
#[test]
fn host_key_decisions_are_keyed_by_file_and_unit() {
let rdk = [0x64u8; 16];
let mut clear = clear_content(6); // file 0 = 300..303 (CPI set), file 1 = 303..306 (clear)
clear[3 * SECTOR_BYTES] &= !0xC0;
let mut wire = clear.clone();
encrypt_bus(&mut wire[..3 * SECTOR_BYTES], &rdk);
let map = Arc::new(BusMap::from_files(vec![vec![(300, 3)], vec![(303, 3)]]));
let mut g = BusGate::new(map);
let mut heads = Vec::new();
for lba in [301u32, 304, 302, 305] {
let mut b = sector(&wire, 300, lba).to_vec();
g.debus(&mut b, &rdk, lba, &mut |h| {
heads.push(h);
Some(sector(&clear, 300, h)[0])
});
assert_eq!(b, sector(&clear, 300, lba), "lba {lba}");
}
assert_eq!(heads, vec![300, 303], "each head fetched once");
}
// The per-unit cache is bounded: it is cleared rather than growing with the disc.
#[test]
fn host_key_cpi_cache_stays_bounded() {
let units = CPI_CACHE_MAX as u32 + 8;
let map = Arc::new(BusMap::from_files(vec![vec![(0, units * 3)]]));
let mut g = BusGate::new(map);
let rdk = [0x65u8; 16];
let mut buf = clear_content(1);
buf[0] &= !0xC0;
for u in 0..units {
g.debus(&mut buf, &rdk, u * 3 + 1, &mut |_| Some(0));
assert!(g.cpi.len() <= CPI_CACHE_MAX);
}
assert!(g.cpi.len() < units as usize);
}
// Unrecorded extents keep file offsets: [500,1) + hole(1) + [700,4) puts
// 700 in unit 0 (head 500) and 701 at the head of unit 1.
#[test]
+26 -2
View File
@@ -291,8 +291,6 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
}
// Decrypt `buf` with the active keys, gated by content ranges when set.
// Shared by the first read and post-fetch retry so both agree on which
// units are content and the unit-key try order.
fn decrypt_buf(
buf: &mut [u8],
keys: &mut DecryptKeys,
@@ -1396,6 +1394,32 @@ mod tests {
assert_eq!(dec.blanked_units(), 1);
}
/// A blanked unit reaches the run: its loss counter and one `UnitBlanked` at the read's
/// LBA; a read with no damage reports nothing.
#[test]
fn blanked_units_are_reported_to_the_run() {
let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
let sink = seen.clone();
let ctx =
crate::ctx::Ctx::default().with_events(Arc::new(move |e: &crate::event::Event<'_>| {
if let crate::event::Event::UnitBlanked { lba, units } = e {
sink.lock().unwrap().push((*lba, *units));
}
}));
let mut dec = damaged_file_source(4, &[1, 2]);
dec.observe(&ctx);
read_units(&mut dec, 0, 1).expect("intact unit");
assert!(seen.lock().unwrap().is_empty(), "no damage, no event");
assert_eq!(ctx.stats.snapshot().units_blanked, 0);
read_units(&mut dec, 1, 2).expect("a damage cluster is blanked");
assert_eq!(
*seen.lock().unwrap(),
vec![(u64::from(FILE_LBA) + 3, 2)],
"one event: the read's LBA and the unit count"
);
assert_eq!(ctx.stats.snapshot().units_blanked, 2);
}
/// A cluster of damaged units is blanked and counted however it is read: alone, first,
/// with no intact unit around it. KS-2 [BD] §3.10.1: "Each MPEG source packet consists of
/// the TP_extra_header (4 bytes) and an MPEG Transport packet".
+46 -2
View File
@@ -36,7 +36,8 @@ pub trait SectorSource: Send {
///
/// # Panics
///
/// Implementations may panic if `buf` is undersized.
/// Implementations may panic if `buf` is undersized; `FileSectorSource`
/// returns `DiscRead` instead.
fn read_sectors(
&mut self,
lba: u32,
@@ -44,7 +45,6 @@ pub trait SectorSource: Send {
buf: &mut [u8],
recovery: bool,
) -> Result<usize>;
// `FileSectorSource` checks this and returns `DiscRead` instead of panicking.
/// Like [`read_sectors`], but with an explicit Force Unit Access request.
///
@@ -267,6 +267,50 @@ mod tests {
s.set_unit_base(base);
}
// FUA and random-access answers must cross the forwarding impls, or Pass-N recovery
// reads lose Force Unit Access and a prefetcher claims random access.
struct FuaSpy(Arc<Mutex<Vec<bool>>>);
impl SectorSource for FuaSpy {
fn capacity_sectors(&self) -> u32 {
0
}
fn read_sectors(&mut self, _: u32, _: u16, _: &mut [u8], _: bool) -> Result<usize> {
self.0.lock().unwrap().push(false);
Ok(0)
}
fn read_sectors_fua(
&mut self,
_: u32,
_: u16,
_: &mut [u8],
_: bool,
fua: bool,
) -> Result<usize> {
self.0.lock().unwrap().push(fua);
Ok(0)
}
fn random_access(&self) -> bool {
false
}
}
fn fua_through<S: SectorSource>(mut s: S) -> bool {
s.read_sectors_fua(0, 1, &mut [0u8; 2048], false, true)
.unwrap();
s.random_access()
}
#[test]
fn forwarding_impls_keep_fua_and_random_access() {
let seen = Arc::new(Mutex::new(Vec::new()));
let boxed: Box<dyn SectorSource> = Box::new(FuaSpy(seen.clone()));
assert!(!fua_through(boxed));
let mut spy = FuaSpy(seen.clone());
let by_ref: &mut dyn SectorSource = &mut spy;
assert!(!fua_through(by_ref));
assert_eq!(*seen.lock().unwrap(), vec![true, true]);
}
// Leaf sources with no bus stage: image bytes on file are already bus-clear.
const UNMAPPED_LEAVES: &[&str] = &["FileSectorSource", "DirImage"];
+81 -4
View File
@@ -177,10 +177,7 @@ impl PrefetchedSectorSource {
crate::sector::read_stage::ExtentWalk::new(extents, policy, unit_align, ctx)?;
let loss = walk.loss();
let total_sectors = walk.total_sectors();
let batch_sectors = match policy {
crate::sector::read_stage::ReadPolicy::Image { batch }
| crate::sector::read_stage::ReadPolicy::Live { batch, .. } => batch,
};
let batch_sectors = policy.batch();
let unmapped = reader.unmapped_stream_files().to_vec();
let (tx, rx) = bounded::<Batch>(PREFETCH_CHANNEL_DEPTH);
let (recycle_tx, recycle_rx) = bounded::<Vec<u8>>(PREFETCH_CHANNEL_DEPTH + 1);
@@ -1349,6 +1346,86 @@ mod tests {
});
}
// After the producer reports an error, the closed channel that follows is a dead source,
// never a clean end of stream.
#[test]
fn a_producer_error_latches_the_source_as_terminated() {
let _serial = serial();
with_watchdog(Duration::from_secs(10), || {
let extents = vec![Extent {
start_lba: 0,
sector_count: 3,
}];
let mut pf = PrefetchedSectorSource::new(ErrorSource, extents, 3, &Ctx::default())
.expect("spawn");
let mut buf = vec![0u8; 3 * 2048];
pf.read_sectors(0, 3, &mut buf, false)
.expect_err("the reader's error");
let again = pf.read_sectors(0, 3, &mut buf, false);
assert!(
matches!(&again, Err(e) if e.is_source_terminated()),
"second read after a producer error: {again:?}"
);
});
}
// A reader that panics is an error to the consumer, not a dropped channel read as EOF.
#[test]
fn a_panicking_reader_is_an_error_not_eof() {
struct Panics;
impl SectorSource for Panics {
fn read_sectors(&mut self, _: u32, _: u16, _: &mut [u8], _: bool) -> Result<usize> {
panic!("reader panic (expected by the test)");
}
fn capacity_sectors(&self) -> u32 {
3
}
}
let _serial = serial();
with_watchdog(Duration::from_secs(10), || {
let extents = vec![Extent {
start_lba: 0,
sector_count: 3,
}];
let mut pf =
PrefetchedSectorSource::new(Panics, extents, 3, &Ctx::default()).expect("spawn");
let mut buf = vec![0u8; 3 * 2048];
let r = pf.read_sectors(0, 3, &mut buf, false);
assert!(
matches!(r, Err(crate::error::Error::DemuxThreadPanicked)),
"{r:?}"
);
});
}
// A byte view ends at the file's real length: the zero-padded tail of the last sector
// is cut, and nothing past it is read.
#[test]
fn a_byte_view_is_clipped_to_the_real_length() {
for len in [3 * 2048 + 700u64, 4 * 2048, 1000] {
let extents = vec![Extent {
start_lba: 0,
sector_count: 8,
}];
let policy = crate::sector::read_stage::ReadPolicy::Image { batch: 2 };
let view = ByteView {
prefix: Vec::new(),
len,
};
let (mut pf, _loss) = PrefetchedSectorSource::file_bytes(
PatternSource { capacity: 16 },
extents,
policy,
&Ctx::default(),
view,
)
.expect("spawn");
let (got, last) = drain_direct(&mut pf, 2, 16);
assert!(matches!(last, Ok(0)), "{last:?}");
assert_eq!(got.len() as u64, len, "len {len}");
}
}
// An inner source that answers a mid-extent read with `Ok(0)` has quit early: the producer
// must say so, not drop `tx` (which reads as clean EOF and lets `fill_extents` fabricate
// zeros for the rest).
+1 -1
View File
@@ -20,7 +20,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
// Ramp back to the preferred batch size after this many clean sectors (100
// MiB = 51,200 sectors) — long enough that noisy zones can't trigger a
// premature probe, short enough an isolated failure doesn't lock size 1.
const PROBE_THRESHOLD_SECTORS: u32 = 100 * 1024 * 1024 / 2048;
const PROBE_THRESHOLD_SECTORS: u32 = (100 * 1024 * 1024 / SECTOR) as u32;
const SECTOR: usize = crate::consts::SECTOR_BYTES;
+29
View File
@@ -448,6 +448,35 @@ mod tests {
);
}
// A stream shorter than one unit is BD-TS only if every source packet is synced at byte 4.
#[test]
fn a_short_stream_is_bd_ts_only_when_every_packet_syncs() {
let unit = ts_unit(false);
let short = unit[..3 * BD_SOURCE_PACKET_BYTES].to_vec();
assert_eq!(classify(&short), Kind::BdTs);
let mut one_bad = short.clone();
one_bad[BD_SOURCE_PACKET_BYTES + 4] = 0x00;
assert_eq!(classify(&one_bad), Kind::Opaque, "one packet off sync");
let mut sync_at_0 = short;
for p in sync_at_0.chunks_mut(BD_SOURCE_PACKET_BYTES) {
p[0] = 0x47;
p[4] = 0x00;
}
assert_eq!(classify(&sync_at_0), Kind::Opaque, "sync belongs at byte 4");
}
// KS-5: a decrypter that leaves CPI set on units it has already decrypted still streams:
// clear TS is never a missing-key refusal, wherever its CPI flag says otherwise.
#[test]
fn clear_ts_with_a_cpi_flag_is_not_refused() {
let bytes: Vec<u8> = (0..4).flat_map(|_| ts_unit(true)).collect();
let mut stage = Stage::lazy(Cursor::new(bytes.clone()), false);
let mut out = Vec::new();
stage.read_to_end(&mut out).expect("clear TS is not E7022");
assert_eq!(out, bytes);
assert_eq!(stage.kind(), Some(Kind::BdTs));
}
// An Opaque stream is handed through byte-identical, even if a later sector looks like a
// scrambled pack (a VOB muxed into an MKV attachment stays untouched).
#[test]
+2 -4
View File
@@ -276,8 +276,7 @@ impl DiscSession {
return Err(e);
}
};
self.disc = Some(disc);
Ok(self.disc.as_ref().expect("disc just stored"))
Ok(&*self.disc.insert(disc))
}
/// [`Self::scan`] under the session's op token (stop design §2.2, §6 ST-L3): the
@@ -306,8 +305,7 @@ impl DiscSession {
return Err(e);
}
};
self.disc = Some(disc);
Ok(self.disc.as_ref().expect("disc just stored"))
Ok(&*self.disc.insert(disc))
}
/// End the session on the handle it holds (stop design §2.5), never re-opening the
+35
View File
@@ -353,6 +353,41 @@ mod tests {
assert!(fu::scsi::ScsiSense::from_buf(&sense).is_illegal_request());
}
/// A `DiscRead` carrying a real status + sense is a drive answer like any CHECK CONDITION:
/// it must not collapse to a dead bus.
#[test]
fn disc_read_preserves_status_and_sense() {
let err = adapt(|| crate::error::Error::DiscRead {
sector: 7,
status: Some(0x02),
sense: Some(crate::scsi::ScsiSense {
sense_key: 0x05,
asc: 0x24,
ascq: 0x00,
}),
});
assert_eq!(err.status, 0x02);
let sense = err.sense.expect("sense preserved");
assert!(fu::scsi::ScsiSense::from_buf(&sense).is_illegal_request());
}
/// Each host-cert field lands in its own slot: v1 and v2 material never swap.
#[test]
fn host_certs_map_field_for_field() {
let cert = crate::aacs::types::HostCert {
private_key: [1; 20],
certificate: vec![2; 92],
private_key_v2: Some([3; 32]),
certificate_v2: Some(vec![4; 8]),
};
let mapped = map_host_certs(std::slice::from_ref(&cert));
assert_eq!(mapped.len(), 1);
assert_eq!(mapped[0].private_key, [1; 20]);
assert_eq!(mapped[0].certificate, vec![2; 92]);
assert_eq!(mapped[0].private_key_v2, Some([3; 32]));
assert_eq!(mapped[0].certificate_v2, Some(vec![4; 8]));
}
/// A drive-tagged transport fault (status 0xFF) crosses unchanged.
#[test]
fn scsi_transport_fault_maps_unchanged() {