mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Merge branch 'audit-lib-G3' into audit-fix-lib
This commit is contained in:
+22
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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"];
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user