pub(crate) fn extract_scsi_context(e: &Error) -> (u8, Option<crate::scsi::ScsiSense>) {
match e {
Error::ScsiError { status, sense, .. } => (*status, *sense),
Error::DiscRead { status, sense, .. } => (status.unwrap_or(0), *sense),
_ => (0, None),
}
}
pub mod capture;
#[cfg(target_os = "linux")]
pub(crate) mod linux;
#[cfg(target_os = "macos")]
pub(crate) mod macos;
#[cfg(windows)]
pub(crate) mod windows;
use crate::error::{Error, Result};
use crate::event::Event;
use crate::identity::DriveId;
use crate::scsi::ScsiTransport;
use crate::sector::SectorSource;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DriveStatus {
TrayOpen,
NoDisc,
DiscPresent,
NotReady,
Unknown,
}
const SCSI_TEST_UNIT_READY: u8 = 0x00;
const SCSI_START_STOP_UNIT: u8 = 0x1B;
const SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL: u8 = 0x1E;
const SCSI_GET_EVENT_STATUS: u8 = 0x4A;
const SCSI_MODE_SENSE: u8 = 0x5A;
const SCSI_REPORT_KEY: u8 = 0xA4;
pub struct Drive {
scsi: Box<dyn ScsiTransport>,
unlocker_name: Option<String>,
init_ran: bool,
matched_name_cache: std::sync::OnceLock<String>,
pub drive_id: DriveId,
device_path: String,
halt: Arc<AtomicBool>,
event_fn: Option<Box<dyn Fn(Event) + Send>>,
#[cfg(target_os = "linux")]
block_dev_fd: Option<std::os::unix::io::RawFd>,
}
impl Drive {
pub fn open(device: &Path) -> Result<Self> {
let t0 = std::time::Instant::now();
tracing::info!(target: "freemkv::drive", phase = "open", device = %device.display(), "begin");
let mut transport = crate::scsi::open(device)?;
let drive_id = DriveId::from_drive(transport.as_mut())?;
tracing::info!(
target: "freemkv::drive",
phase = "open",
device = %device.display(),
vendor = %drive_id.vendor_id.trim(),
product = %drive_id.product_id.trim(),
elapsed_ms = t0.elapsed().as_millis() as u64,
"end"
);
#[cfg(target_os = "linux")]
let block_dev_fd = open_block_device_for_sg(device);
Ok(Drive {
scsi: transport,
unlocker_name: None,
init_ran: false,
matched_name_cache: std::sync::OnceLock::new(),
drive_id,
device_path: device.to_string_lossy().to_string(),
halt: Arc::new(AtomicBool::new(false)),
event_fn: None,
#[cfg(target_os = "linux")]
block_dev_fd,
})
}
#[cfg(test)]
fn from_transport_for_test(scsi: Box<dyn ScsiTransport>) -> Self {
Drive {
scsi,
unlocker_name: None,
init_ran: false,
matched_name_cache: std::sync::OnceLock::new(),
drive_id: DriveId {
vendor_id: String::new(),
product_id: String::new(),
product_revision: String::new(),
vendor_specific: String::new(),
firmware_date: String::new(),
serial_number: String::new(),
raw_inquiry: Vec::new(),
raw_gc_010c: Vec::new(),
},
device_path: "test".to_string(),
halt: Arc::new(AtomicBool::new(false)),
event_fn: None,
#[cfg(target_os = "linux")]
block_dev_fd: None,
}
}
pub fn halt_flag(&self) -> Arc<AtomicBool> {
self.halt.clone()
}
pub fn halt(&self) {
self.halt.store(true, Ordering::Relaxed);
}
pub fn clear_halt(&self) {
self.halt.store(false, Ordering::Relaxed);
}
pub fn on_event(&mut self, f: impl Fn(Event) + Send + 'static) {
self.event_fn = Some(Box::new(f));
}
fn is_halted(&self) -> bool {
self.halt.load(Ordering::Relaxed)
}
fn checked_exec(
&mut self,
cdb: &[u8],
dir: crate::scsi::DataDirection,
buf: &mut [u8],
timeout_ms: u32,
) -> Result<crate::scsi::ScsiResult> {
if self.is_halted() {
return Err(Error::Halted);
}
let r = self.scsi.as_mut().execute(cdb, dir, buf, timeout_ms)?;
if self.is_halted() {
return Err(Error::Halted);
}
Ok(r)
}
pub fn close(self) {
}
fn cleanup(&mut self) {
self.unlock_tray();
}
pub fn has_profile(&self) -> bool {
crate::unlock::matching_name(&self.drive_id).is_some()
}
pub fn scsi_mut(&mut self) -> &mut dyn ScsiTransport {
self.scsi.as_mut()
}
pub fn wait_ready(&mut self) -> Result<()> {
let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00];
let t0 = std::time::Instant::now();
tracing::info!(target: "freemkv::drive", phase = "wait_ready", "begin");
let mut hb = crate::progress::Heartbeat::new("wait_ready");
for attempt in 0..60u64 {
hb.tick(attempt, 60);
let mut buf = [0u8; 0];
if self
.scsi
.as_mut()
.execute(&tur, crate::scsi::DataDirection::None, &mut buf, 5_000)
.is_ok()
{
tracing::info!(
target: "freemkv::drive",
phase = "wait_ready",
attempts = attempt + 1,
elapsed_ms = t0.elapsed().as_millis() as u64,
"end"
);
return Ok(());
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
tracing::warn!(
target: "freemkv::drive",
phase = "wait_ready",
elapsed_ms = t0.elapsed().as_millis() as u64,
"device never became ready"
);
Err(Error::DeviceNotReady {
path: self.device_path.clone(),
})
}
pub fn drive_status(&mut self) -> DriveStatus {
let cdb = [
SCSI_GET_EVENT_STATUS,
0x01,
0x00,
0x00,
0x10,
0x00,
0x00,
0x00,
0x08,
0x00,
];
let mut buf = [0u8; 8];
match self.scsi.as_mut().execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
) {
Ok(r) if r.bytes_transferred >= 6 => {
let media_status = buf[5];
match media_status & 0x03 {
0x00 => DriveStatus::NoDisc, 0x01 => DriveStatus::TrayOpen, 0x02 => DriveStatus::DiscPresent, 0x03 => DriveStatus::TrayOpen,
_ => DriveStatus::Unknown,
}
}
_ => {
let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00];
let mut empty = [0u8; 0];
match self.scsi.as_mut().execute(
&tur,
crate::scsi::DataDirection::None,
&mut empty,
5_000,
) {
Ok(_) => DriveStatus::DiscPresent,
Err(ref e)
if e.scsi_sense()
.is_some_and(|s| s.is_not_ready() || s.is_unit_attention()) =>
{
DriveStatus::NotReady
}
_ => DriveStatus::Unknown,
}
}
}
}
pub fn platform_name(&self) -> &str {
if let Some(ref n) = self.unlocker_name {
return n;
}
self.matched_name_cache.get_or_init(|| {
crate::unlock::matching_name(&self.drive_id).unwrap_or_else(|| "Unknown".to_string())
})
}
pub fn device_path(&self) -> &str {
&self.device_path
}
fn current_profile(&mut self) -> Option<u16> {
let cdb = [
crate::scsi::SCSI_GET_CONFIGURATION,
0x00, 0x00,
0x00, 0x00,
0x00,
0x00,
0x00,
0x08, 0x00,
];
let mut buf = [0u8; 8];
let r = self
.scsi
.as_mut()
.execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
)
.ok()?;
if r.bytes_transferred >= 8 {
Some(((buf[6] as u16) << 8) | buf[7] as u16)
} else {
None
}
}
fn disc_is_dvd(&mut self) -> bool {
matches!(self.current_profile(), Some(p) if (0x0010..=0x001F).contains(&p))
}
pub fn init(&mut self) -> Result<()> {
let t0 = std::time::Instant::now();
tracing::info!(target: "freemkv::drive", phase = "init", "begin");
if self.disc_is_dvd() {
tracing::info!(target: "freemkv::drive", phase = "init", dvd = true, elapsed_ms = t0.elapsed().as_millis() as u64, "end (stock-mode DVD, no unlock)");
self.init_ran = true;
return Ok(());
}
let r = crate::unlock::route_unlock(self.scsi.as_mut(), &self.drive_id);
self.init_ran = true;
let r = match r {
Ok(Some(name)) => {
self.unlocker_name = Some(name);
if let Err(e) =
crate::unlock::unlocker_set_max_read_speed(self.scsi.as_mut(), &self.drive_id)
{
tracing::warn!(
target: "freemkv::drive",
phase = "init",
error = ?e,
"unlocker set_max_read_speed failed; continuing at current speed"
);
}
Ok(())
}
Ok(None) => Ok(()),
Err(e) => Err(e),
};
tracing::info!(
target: "freemkv::drive",
phase = "init",
ok = r.is_ok(),
unlocker = self.unlocker_name.as_deref().unwrap_or("none"),
elapsed_ms = t0.elapsed().as_millis() as u64,
"end"
);
r
}
pub fn probe_disc(&mut self) -> Result<()> {
let t0 = std::time::Instant::now();
tracing::info!(target: "freemkv::drive", phase = "probe_disc", "begin");
if self.disc_is_dvd() {
tracing::info!(target: "freemkv::drive", phase = "probe_disc", dvd = true, elapsed_ms = t0.elapsed().as_millis() as u64, "end (stock-mode DVD, no calibration)");
return Ok(());
}
tracing::info!(
target: "freemkv::drive",
phase = "probe_disc",
elapsed_ms = t0.elapsed().as_millis() as u64,
"end (calibration handled by unlocker at init)"
);
Ok(())
}
pub fn get_config_feature(&mut self, feature_code: u16) -> Option<Vec<u8>> {
let cdb = [
crate::scsi::SCSI_GET_CONFIGURATION,
0x02,
(feature_code >> 8) as u8,
feature_code as u8,
0x00,
0x00,
0x00,
0x01,
0x00,
0x00,
];
let mut buf = vec![0u8; 256];
let r = self
.scsi
.as_mut()
.execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
)
.ok()?;
let end = r.bytes_transferred.min(buf.len());
if end > 8 {
Some(buf[8..end].to_vec())
} else {
None
}
}
pub fn report_key_rpc_state(&mut self) -> Option<Vec<u8>> {
let cdb = [
SCSI_REPORT_KEY,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x08,
0x08,
0x00,
];
let mut buf = vec![0u8; 8];
let r = self
.scsi
.as_mut()
.execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
)
.ok()?;
let end = r.bytes_transferred.min(buf.len());
if end > 0 {
Some(buf[..end].to_vec())
} else {
None
}
}
pub fn mode_sense_page(&mut self, page: u8) -> Option<Vec<u8>> {
let cdb = [
SCSI_MODE_SENSE,
0x00,
page,
0x00,
0x00,
0x00,
0x00,
0x00,
0xFC,
0x00,
];
let mut buf = vec![0u8; 252];
let r = self
.scsi
.as_mut()
.execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
)
.ok()?;
let end = r.bytes_transferred.min(buf.len());
if end > 0 {
Some(buf[..end].to_vec())
} else {
None
}
}
pub fn read_buffer(&mut self, mode: u8, buffer_id: u8, length: u16) -> Option<Vec<u8>> {
let cdb = crate::scsi::build_read_buffer(mode, buffer_id, 0, length as u32);
let mut buf = vec![0u8; length as usize];
let r = self
.scsi
.as_mut()
.execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
)
.ok()?;
let end = r.bytes_transferred.min(buf.len());
if end > 0 {
Some(buf[..end].to_vec())
} else {
None
}
}
pub fn is_ready(&self) -> bool {
self.init_ran && self.unlocker_name.is_some()
}
pub fn is_unlocked(&self) -> bool {
crate::unlock::matching_name(&self.drive_id).is_some()
}
pub fn read(&mut self, lba: u32, count: u16, buf: &mut [u8], recovery: bool) -> Result<usize> {
let timeout_ms = if recovery {
crate::scsi::READ_RECOVERY_TIMEOUT_MS
} else {
crate::scsi::READ_TIMEOUT_MS
};
tracing::trace!(
target: "freemkv::drive",
lba,
count,
recovery,
timeout_ms,
"Drive::read enter"
);
let max_sectors = (self.scsi.max_transfer_bytes() / 2048).max(1) as u32;
if count as u32 <= max_sectors {
return self.read_one(lba, count, buf, timeout_ms, recovery);
}
let mut done: u32 = 0;
let mut total: usize = 0;
let count = count as u32;
while done < count {
let chunk = (count - done).min(max_sectors);
let cur_lba = lba + done;
let byte_off = done as usize * 2048;
let byte_len = chunk as usize * 2048;
let slice = &mut buf[byte_off..byte_off + byte_len];
let n = self.read_one(cur_lba, chunk as u16, slice, timeout_ms, recovery)?;
total += n;
done += chunk;
}
Ok(total)
}
fn read_one(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
timeout_ms: u32,
#[cfg_attr(not(target_os = "linux"), allow(unused_variables))] recovery: bool,
) -> Result<usize> {
let cdb = [
crate::scsi::SCSI_READ_10,
0x00,
(lba >> 24) as u8,
(lba >> 16) as u8,
(lba >> 8) as u8,
lba as u8,
0x00,
(count >> 8) as u8,
count as u8,
0x00,
];
match self.checked_exec(
&cdb,
crate::scsi::DataDirection::FromDevice,
buf,
timeout_ms,
) {
Ok(result) if result.bytes_transferred == count as usize * 2048 => {
Ok(result.bytes_transferred)
}
Ok(_) => Err(Error::DiscRead {
sector: lba as u64,
status: None,
sense: None,
}),
Err(Error::Halted) => Err(Error::Halted),
Err(e) => {
let (status, sense) = extract_scsi_context(&e);
tracing::warn!(
target: "freemkv::drive",
lba,
count,
inner_error = %e,
scsi_status = status,
"Drive::read checked_exec failed"
);
#[cfg(target_os = "linux")]
if recovery {
if let Some(fd) = self.block_dev_fd {
let len = count as usize * 2048;
if buf.len() >= len {
let offset = lba as i64 * 2048;
let _ = unsafe {
libc::posix_fadvise(
fd,
offset,
len as i64,
libc::POSIX_FADV_DONTNEED,
)
};
let n = unsafe {
libc::pread(fd, buf.as_mut_ptr() as *mut libc::c_void, len, offset)
};
if n == len as isize {
tracing::info!(
target: "freemkv::drive",
lba,
count,
bytes = len,
"Drive::read recovered via /dev/sr0 pread fallback"
);
return Ok(len);
}
tracing::debug!(
target: "freemkv::drive",
lba,
count,
pread_ret = n as i64,
errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
"/dev/sr0 pread fallback also failed"
);
}
}
}
Err(Error::DiscRead {
sector: lba as u64,
status: Some(status),
sense,
})
}
}
}
pub fn read_capacity(&mut self) -> Result<u32> {
let cdb = [
crate::scsi::SCSI_READ_CAPACITY,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
];
let mut buf = [0u8; 8];
let result = self.scsi.as_mut().execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
)?;
decode_read_capacity(&buf, result.bytes_transferred)
}
pub fn set_speed(&mut self, speed_kbs: u16) {
let cdb = crate::scsi::build_set_cd_speed(speed_kbs);
let mut dummy = [0u8; 0];
let _ = self.scsi_execute(&cdb, crate::scsi::DataDirection::None, &mut dummy, 5_000);
}
pub fn lock_tray(&mut self) {
let prevent = [
SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL,
0x00,
0x00,
0x00,
0x01,
0x00,
];
let mut buf = [0u8; 0];
let _ =
self.scsi
.as_mut()
.execute(&prevent, crate::scsi::DataDirection::None, &mut buf, 5_000);
}
pub fn unlock_tray(&mut self) {
let allow = [
SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL,
0x00,
0x00,
0x00,
0x00,
0x00,
];
let mut buf = [0u8; 0];
let _ =
self.scsi
.as_mut()
.execute(&allow, crate::scsi::DataDirection::None, &mut buf, 5_000);
}
pub fn eject(&mut self) -> Result<()> {
self.unlock_tray();
let eject_cdb = [SCSI_START_STOP_UNIT, 0, 0, 0, 0x02, 0];
let mut buf = [0u8; 0];
self.scsi.as_mut().execute(
&eject_cdb,
crate::scsi::DataDirection::None,
&mut buf,
30_000,
)?;
Ok(())
}
pub fn scsi_execute(
&mut self,
cdb: &[u8],
direction: crate::scsi::DataDirection,
buf: &mut [u8],
timeout_ms: u32,
) -> Result<crate::scsi::ScsiResult> {
self.scsi.as_mut().execute(cdb, direction, buf, timeout_ms)
}
}
impl Drop for Drive {
fn drop(&mut self) {
self.cleanup();
#[cfg(target_os = "linux")]
if let Some(fd) = self.block_dev_fd.take() {
unsafe { libc::close(fd) };
}
}
}
#[cfg(target_os = "linux")]
fn open_block_device_for_sg(sg_path: &Path) -> Option<std::os::unix::io::RawFd> {
let basename = sg_path.file_name()?.to_str()?;
if !basename.starts_with("sg") {
return None;
}
let sysfs_dir = format!("/sys/class/scsi_generic/{}/device/block", basename);
let entries = std::fs::read_dir(&sysfs_dir).ok()?;
let block_name = entries
.flatten()
.find_map(|e| e.file_name().into_string().ok())?;
let block_path = format!("/dev/{}", block_name);
let mut bytes = block_path.as_bytes().to_vec();
bytes.push(0);
let fd = unsafe {
libc::open(
bytes.as_ptr() as *const libc::c_char,
libc::O_RDONLY | libc::O_CLOEXEC,
)
};
if fd < 0 {
tracing::debug!(
target: "freemkv::drive",
sg = basename,
block_path,
errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
"Failed to open block device for fallback; sr0 fallback disabled"
);
None
} else {
tracing::info!(
target: "freemkv::drive",
sg = basename,
block_path,
fd,
"Opened /dev/sr* as recovery fallback for failed SCSI reads"
);
Some(fd)
}
}
impl SectorSource for Drive {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
) -> Result<usize> {
self.read(lba, count, buf, recovery)
}
fn set_speed(&mut self, kbs: u16) {
Drive::set_speed(self, kbs);
}
}
pub fn find_drive() -> Option<Drive> {
select_drive_with_media(
discover_drives()
.into_iter()
.filter_map(|(path, _)| Drive::open(std::path::Path::new(&path)).ok()),
)
}
fn select_drive_with_media(drives: impl Iterator<Item = Drive>) -> Option<Drive> {
let mut fallback: Option<Drive> = None;
for mut drive in drives {
if drive.drive_status() == DriveStatus::DiscPresent {
return Some(drive);
}
if fallback.is_none() {
fallback = Some(drive);
}
}
fallback
}
fn decode_read_capacity(buf: &[u8; 8], bytes_transferred: usize) -> Result<u32> {
if bytes_transferred < 4 {
return Err(Error::DiscCapacityMalformed);
}
let last_lba = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
last_lba.checked_add(1).ok_or(Error::DiscCapacityOverflow)
}
#[cfg(test)]
fn sleep_until_halted(halt: &AtomicBool, total: std::time::Duration) -> Result<()> {
const SLICE: std::time::Duration = std::time::Duration::from_millis(100);
let deadline = std::time::Instant::now() + total;
loop {
if halt.load(Ordering::Relaxed) {
return Err(Error::Halted);
}
let now = std::time::Instant::now();
if now >= deadline {
return Ok(());
}
let remaining = deadline - now;
std::thread::sleep(remaining.min(SLICE));
}
}
fn discover_drives() -> Vec<(String, DriveId)> {
#[cfg(target_os = "linux")]
{
linux::find_drives()
}
#[cfg(target_os = "macos")]
{
macos::find_drives()
}
#[cfg(windows)]
{
windows::find_drives()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeviceResolution {
Direct,
SrToSg,
SrNoSgMatch,
}
#[allow(dead_code)]
pub(crate) fn resolve_device(path: &str) -> Result<(String, DeviceResolution)> {
#[cfg(target_os = "linux")]
{
linux::resolve_device(path)
}
#[cfg(target_os = "macos")]
{
macos::resolve_device(path)
}
#[cfg(windows)]
{
windows::resolve_device(path)
}
}
#[cfg(test)]
mod halt_tests {
use super::*;
use std::time::{Duration, Instant};
#[test]
fn sleep_until_halted_completes_when_not_halted() {
let flag = AtomicBool::new(false);
let t0 = Instant::now();
let r = sleep_until_halted(&flag, Duration::from_millis(150));
assert!(r.is_ok());
assert!(t0.elapsed() >= Duration::from_millis(140));
}
#[test]
fn sleep_until_halted_returns_immediately_if_preflagged() {
let flag = AtomicBool::new(true);
let t0 = Instant::now();
let r = sleep_until_halted(&flag, Duration::from_secs(10));
assert!(matches!(r, Err(Error::Halted)));
assert!(t0.elapsed() < Duration::from_millis(200));
}
#[test]
fn sleep_until_halted_wakes_mid_sleep() {
let flag = Arc::new(AtomicBool::new(false));
let f2 = flag.clone();
let t0 = Instant::now();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(150));
f2.store(true, Ordering::Relaxed);
});
let r = sleep_until_halted(&flag, Duration::from_secs(10));
assert!(matches!(r, Err(Error::Halted)));
let waited = t0.elapsed();
assert!(waited < Duration::from_millis(350), "waited {waited:?}");
assert!(waited >= Duration::from_millis(140), "waited {waited:?}");
}
#[test]
fn sleep_until_halted_zero_duration_is_noop_when_not_halted() {
let flag = AtomicBool::new(false);
let r = sleep_until_halted(&flag, Duration::ZERO);
assert!(r.is_ok());
}
#[test]
fn read_capacity_short_transfer_is_rejected() {
let buf = [0u8; 8];
assert!(matches!(
decode_read_capacity(&buf, 0),
Err(Error::DiscCapacityMalformed)
));
assert!(matches!(
decode_read_capacity(&buf, 3),
Err(Error::DiscCapacityMalformed)
));
}
#[test]
fn read_capacity_full_transfer_decodes_last_lba_plus_one() {
let buf = [0x00, 0x01, 0x23, 0x44, 0, 0, 0, 0];
assert_eq!(decode_read_capacity(&buf, 8).unwrap(), 0x0001_2345);
}
#[test]
fn read_capacity_overflow_is_rejected() {
let buf = [0xFF, 0xFF, 0xFF, 0xFF, 0, 0, 0, 0];
assert!(matches!(
decode_read_capacity(&buf, 8),
Err(Error::DiscCapacityOverflow)
));
}
}
#[cfg(test)]
mod command_tests {
use super::*;
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
struct FixedTransport {
payload: Vec<u8>,
}
impl ScsiTransport for FixedTransport {
fn execute(
&mut self,
_cdb: &[u8],
_direction: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
let n = self.payload.len().min(data.len());
data[..n].copy_from_slice(&self.payload[..n]);
Ok(ScsiResult {
status: 0,
bytes_transferred: n,
sense: [0u8; 32],
})
}
}
fn drive_with(payload: Vec<u8>) -> Drive {
Drive::from_transport_for_test(Box::new(FixedTransport { payload }))
}
#[test]
fn read_capacity_normal_adds_one() {
let mut d = drive_with(vec![0x00, 0x00, 0x00, 0x63, 0x00, 0x00, 0x08, 0x00]);
assert_eq!(d.read_capacity().unwrap(), 100);
}
#[test]
fn read_capacity_sentinel_does_not_overflow() {
let mut d = drive_with(vec![0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x08, 0x00]);
assert!(matches!(
d.read_capacity(),
Err(Error::DiscCapacityOverflow)
));
}
#[test]
fn disc_is_dvd_matches_only_dvd_profile_family() {
let probe = |profile: u16| {
let mut hdr = vec![0u8; 8];
hdr[6] = (profile >> 8) as u8;
hdr[7] = profile as u8;
drive_with(hdr).disc_is_dvd()
};
assert!(probe(0x0010), "DVD-ROM");
assert!(probe(0x0011), "DVD-R");
assert!(probe(0x001B), "DVD+R DL");
assert!(!probe(0x0040), "BD-ROM (UHD) must NOT be classed as DVD");
assert!(!probe(0x0041), "BD-R");
assert!(!probe(0x0008), "CD-ROM");
assert!(!probe(0x0000), "no/unknown profile");
assert!(
!drive_with(vec![0u8; 4]).disc_is_dvd(),
"short GET CONFIGURATION must default to not-DVD (unlock still runs)"
);
}
#[test]
fn drive_status_tray_open_and_media_present_is_not_ready_to_rip() {
let mut buf = vec![0u8; 8];
buf[5] = 0x03;
let mut d = drive_with(buf);
assert_eq!(d.drive_status(), DriveStatus::TrayOpen);
}
#[test]
fn drive_status_disc_present_maps_correctly() {
let mut buf = vec![0u8; 8];
buf[5] = 0x02; let mut d = drive_with(buf);
assert_eq!(d.drive_status(), DriveStatus::DiscPresent);
}
use std::sync::{Arc, Mutex};
struct RecordingTransport {
last_cdb: Arc<Mutex<Vec<u8>>>,
last_timeout: Arc<Mutex<u32>>,
outcome: TransportOutcome,
}
enum TransportOutcome {
Ok(usize),
Scsi(u8, Option<crate::scsi::ScsiSense>),
}
impl ScsiTransport for RecordingTransport {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
_data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult> {
*self.last_cdb.lock().unwrap() = cdb.to_vec();
*self.last_timeout.lock().unwrap() = timeout_ms;
match self.outcome {
TransportOutcome::Ok(n) => Ok(ScsiResult {
status: 0,
bytes_transferred: n,
sense: [0u8; 32],
}),
TransportOutcome::Scsi(status, sense) => Err(Error::ScsiError {
opcode: cdb[0],
status,
sense,
}),
}
}
}
fn recording(outcome: TransportOutcome) -> (Drive, Arc<Mutex<Vec<u8>>>, Arc<Mutex<u32>>) {
let cdb = Arc::new(Mutex::new(Vec::new()));
let to = Arc::new(Mutex::new(0u32));
let t = RecordingTransport {
last_cdb: cdb.clone(),
last_timeout: to.clone(),
outcome,
};
(Drive::from_transport_for_test(Box::new(t)), cdb, to)
}
#[test]
fn read_builds_read10_cdb_with_be_lba_and_count() {
let (mut d, cdb, _to) = recording(TransportOutcome::Ok(4096));
let mut buf = vec![0u8; 4096];
let n = d.read(0x00AB_CDEF, 2, &mut buf, false).unwrap();
assert_eq!(n, 4096, "returns transport bytes_transferred");
let c = cdb.lock().unwrap();
assert_eq!(c[0], crate::scsi::SCSI_READ_10);
assert_eq!(c[1], 0x00, "Drive::read path sets no FUA");
assert_eq!(&c[2..6], &[0x00, 0xAB, 0xCD, 0xEF], "LBA big-endian");
assert_eq!(&c[7..9], &[0x00, 0x02], "transfer length big-endian");
}
#[test]
fn read_recovery_flag_selects_60s_timeout() {
let (mut d, _cdb, to) = recording(TransportOutcome::Ok(2048));
let mut buf = vec![0u8; 2048];
d.read(0, 1, &mut buf, true).unwrap();
assert_eq!(*to.lock().unwrap(), crate::scsi::READ_RECOVERY_TIMEOUT_MS);
let (mut d2, _c2, to2) = recording(TransportOutcome::Ok(2048));
d2.read(0, 1, &mut buf, false).unwrap();
assert_eq!(*to2.lock().unwrap(), crate::scsi::READ_TIMEOUT_MS);
}
#[test]
fn read_maps_scsi_error_to_discread_preserving_status_and_sense() {
let sense = crate::scsi::ScsiSense {
sense_key: 3,
asc: 0x11,
ascq: 0x05,
};
let (mut d, _cdb, _to) = recording(TransportOutcome::Scsi(0x02, Some(sense)));
let mut buf = vec![0u8; 2048];
let err = d.read(0x1234, 1, &mut buf, false).unwrap_err();
match err {
Error::DiscRead {
sector,
status,
sense: s,
} => {
assert_eq!(sector, 0x1234, "sector must be the requested LBA");
assert_eq!(status, Some(0x02));
assert_eq!(s, Some(sense), "sense triple preserved");
}
other => panic!("expected DiscRead, got {other:?}"),
}
}
#[test]
fn read_transport_failure_status_preserved_for_marginal_routing() {
let (mut d, _cdb, _to) = recording(TransportOutcome::Scsi(
crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
None,
));
let mut buf = vec![0u8; 2048];
let err = d.read(7, 1, &mut buf, false).unwrap_err();
assert!(err.is_scsi_transport_failure());
assert!(err.scsi_sense().is_none());
}
#[test]
fn read_returns_halted_before_dispatch_without_touching_transport() {
let (mut d, cdb, _to) = recording(TransportOutcome::Ok(2048));
d.halt();
let mut buf = vec![0u8; 2048];
let err = d.read(0, 1, &mut buf, false).unwrap_err();
assert!(matches!(err, Error::Halted));
assert!(
cdb.lock().unwrap().is_empty(),
"transport execute must not run when pre-halted"
);
}
#[test]
fn clear_halt_reenables_reads() {
let (mut d, _cdb, _to) = recording(TransportOutcome::Ok(2048));
d.halt();
d.clear_halt();
let mut buf = vec![0u8; 2048];
assert!(d.read(0, 1, &mut buf, false).is_ok());
}
#[test]
fn read_does_not_truncate_reported_bytes() {
let (mut d, _cdb, _to) = recording(TransportOutcome::Ok(65536));
let mut buf = vec![0u8; 65536];
assert_eq!(d.read(0, 32, &mut buf, false).unwrap(), 65536);
}
struct ChunkingTransport {
max_bytes: usize,
reads: Arc<Mutex<Vec<(u32, u16)>>>,
fail_on: Option<usize>,
seen: usize,
}
impl ScsiTransport for ChunkingTransport {
fn max_transfer_bytes(&self) -> usize {
self.max_bytes
}
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
if cdb.first() != Some(&crate::scsi::SCSI_READ_10) || cdb.len() < 10 {
return Ok(ScsiResult {
status: 0,
bytes_transferred: data.len(),
sense: [0u8; 32],
});
}
let lba = u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]);
let count = u16::from_be_bytes([cdb[7], cdb[8]]);
self.reads.lock().unwrap().push((lba, count));
let idx = self.seen;
self.seen += 1;
if self.fail_on == Some(idx) {
return Err(Error::ScsiError {
opcode: cdb[0],
status: 0x02,
sense: Some(crate::scsi::ScsiSense {
sense_key: 3,
asc: 0x11,
ascq: 0x05,
}),
});
}
Ok(ScsiResult {
status: 0,
bytes_transferred: data.len(),
sense: [0u8; 32],
})
}
}
fn chunking(max_bytes: usize, fail_on: Option<usize>) -> (Drive, Arc<Mutex<Vec<(u32, u16)>>>) {
let reads = Arc::new(Mutex::new(Vec::new()));
let t = ChunkingTransport {
max_bytes,
reads: reads.clone(),
fail_on,
seen: 0,
};
(Drive::from_transport_for_test(Box::new(t)), reads)
}
#[test]
fn read_chunks_large_request_to_max_transfer() {
let (mut d, reads) = chunking(4 * 2048, None);
let mut buf = vec![0u8; 10 * 2048];
let n = d.read(0, 10, &mut buf, false).unwrap();
assert_eq!(n, 10 * 2048, "returns total bytes across all chunks");
let r = reads.lock().unwrap();
assert_eq!(
*r,
vec![(0, 4), (4, 4), (8, 2)],
"must chunk into 4+4+2 sectors at advancing LBAs"
);
}
#[test]
fn read_chunk_failure_reports_failing_chunk_lba() {
let (mut d, reads) = chunking(4 * 2048, Some(1));
let mut buf = vec![0u8; 10 * 2048];
let err = d.read(0, 10, &mut buf, false).unwrap_err();
match err {
Error::DiscRead { sector, status, .. } => {
assert_eq!(sector, 4, "failing chunk's LBA, not the request base");
assert_eq!(status, Some(0x02));
}
other => panic!("expected DiscRead, got {other:?}"),
}
let r = reads.lock().unwrap();
assert_eq!(*r, vec![(0, 4), (4, 4)], "stops at the failing chunk");
}
#[test]
fn read_small_request_is_single_unchunked_read() {
let (mut d, reads) = chunking(4 * 2048, None);
let mut buf = vec![0u8; 3 * 2048];
assert_eq!(d.read(0, 3, &mut buf, false).unwrap(), 3 * 2048);
assert_eq!(*reads.lock().unwrap(), vec![(0, 3)], "single CDB, no split");
}
fn drive_with_media_byte(media_status: u8) -> Drive {
let mut buf = vec![0u8; 8];
buf[5] = media_status;
drive_with(buf)
}
#[test]
fn select_drive_prefers_drive_with_media() {
let drives = vec![drive_with_media_byte(0x00), drive_with_media_byte(0x02)];
let picked = select_drive_with_media(drives.into_iter()).expect("a drive");
let mut picked = picked;
assert_eq!(
picked.drive_status(),
DriveStatus::DiscPresent,
"must pick the drive reporting DiscPresent, not the empty first drive"
);
}
#[test]
fn select_drive_falls_back_to_first_when_none_have_media() {
let drives = vec![drive_with_media_byte(0x01), drive_with_media_byte(0x00)];
let mut picked = select_drive_with_media(drives.into_iter()).expect("a fallback drive");
assert_eq!(
picked.drive_status(),
DriveStatus::TrayOpen,
"fallback must be the first drive yielded"
);
}
#[test]
fn select_drive_none_when_no_drives() {
let empty: Vec<Drive> = Vec::new();
assert!(select_drive_with_media(empty.into_iter()).is_none());
}
#[test]
fn drive_status_no_disc_maps_correctly() {
let mut buf = vec![0u8; 8];
buf[5] = 0x00;
let mut d = drive_with(buf);
assert_eq!(d.drive_status(), DriveStatus::NoDisc);
}
#[test]
fn drive_status_tray_open_maps_correctly() {
let mut buf = vec![0u8; 8];
buf[5] = 0x01;
let mut d = drive_with(buf);
assert_eq!(d.drive_status(), DriveStatus::TrayOpen);
}
#[test]
fn drive_status_high_bits_in_media_status_ignored() {
let mut buf = vec![0u8; 8];
buf[5] = 0xFE;
let mut d = drive_with(buf);
assert_eq!(d.drive_status(), DriveStatus::DiscPresent);
}
#[test]
fn drive_status_short_transfer_falls_back_to_tur() {
let mut d = drive_with(vec![0u8; 4]);
assert_eq!(d.drive_status(), DriveStatus::DiscPresent);
}
struct AlwaysErr {
err: fn() -> Error,
}
impl ScsiTransport for AlwaysErr {
fn execute(
&mut self,
_cdb: &[u8],
_dir: DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
Err((self.err)())
}
}
#[test]
fn drive_status_tur_not_ready_sense_maps_not_ready() {
let mut d = Drive::from_transport_for_test(Box::new(AlwaysErr {
err: || Error::ScsiError {
opcode: 0,
status: 0x02,
sense: Some(crate::scsi::ScsiSense {
sense_key: 2, asc: 0x04,
ascq: 0x01,
}),
},
}));
assert_eq!(d.drive_status(), DriveStatus::NotReady);
}
#[test]
fn drive_status_tur_unit_attention_maps_not_ready() {
let mut d = Drive::from_transport_for_test(Box::new(AlwaysErr {
err: || Error::ScsiError {
opcode: 0,
status: 0x02,
sense: Some(crate::scsi::ScsiSense {
sense_key: 6, asc: 0x28,
ascq: 0x00,
}),
},
}));
assert_eq!(d.drive_status(), DriveStatus::NotReady);
}
#[test]
fn drive_status_tur_other_error_maps_unknown() {
let mut d = Drive::from_transport_for_test(Box::new(AlwaysErr {
err: || Error::ScsiError {
opcode: 0,
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
},
}));
assert_eq!(d.drive_status(), DriveStatus::Unknown);
}
#[test]
fn get_config_feature_strips_8_byte_header() {
let mut payload = vec![0u8; 8];
payload.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]);
let mut d = drive_with(payload);
assert_eq!(
d.get_config_feature(0x010D),
Some(vec![0xDE, 0xAD, 0xBE, 0xEF])
);
}
#[test]
fn get_config_feature_at_exactly_8_bytes_returns_none() {
let mut d = drive_with(vec![0u8; 8]);
assert_eq!(d.get_config_feature(0x0000), None);
}
#[test]
fn report_key_rpc_state_returns_transferred_prefix() {
let mut d = drive_with(vec![1, 2, 3, 4, 5, 6, 7, 8]);
assert_eq!(d.report_key_rpc_state(), Some(vec![1, 2, 3, 4, 5, 6, 7, 8]));
}
#[test]
fn report_key_rpc_state_zero_transfer_returns_none() {
let mut d = drive_with(vec![]);
assert_eq!(d.report_key_rpc_state(), None);
}
#[test]
fn mode_sense_zero_transfer_returns_none() {
let mut d = drive_with(vec![]);
assert_eq!(d.mode_sense_page(0x2A), None);
}
#[test]
fn read_buffer_returns_prefix_and_clamps() {
let mut d = drive_with(vec![9, 9, 9, 9]);
assert_eq!(d.read_buffer(0x02, 0xF1, 16), Some(vec![9, 9, 9, 9]));
}
#[test]
fn read_buffer_zero_transfer_returns_none() {
let mut d = drive_with(vec![]);
assert_eq!(d.read_buffer(0x02, 0xF1, 16), None);
}
#[test]
fn init_without_unlocker_is_ok_oem_fallback() {
let mut d = drive_with(vec![]);
assert!(
d.init().is_ok(),
"no-match init must succeed (OEM fallback)"
);
assert!(
!d.is_ready(),
"no unlocker ran → not in unlocked-ready state"
);
}
#[test]
fn probe_disc_without_unlocker_is_ok_noop() {
let mut d = drive_with(vec![]);
assert!(d.probe_disc().is_ok());
}
#[test]
fn read_capacity_exactly_4_bytes_decodes() {
let buf = [0x00, 0x00, 0x00, 0x05, 0, 0, 0, 0];
assert_eq!(decode_read_capacity(&buf, 4).unwrap(), 6);
}
#[test]
fn read_capacity_zero_last_lba_is_one_sector() {
let buf = [0, 0, 0, 0, 0, 0, 0, 0];
assert_eq!(decode_read_capacity(&buf, 8).unwrap(), 1);
}
}