CSS keyless decrypt (Stevenson), AACS 1.0/2.0/2.1, MPEG-2 DVD, multi-OS SCSI, multipass recovery, mux highway, audit hardening
1573 lines
56 KiB
Rust
1573 lines
56 KiB
Rust
//! Drive session — open, identify, and read from optical drives.
|
||
//!
|
||
//! A `Drive` is opened from a device path, identifies itself via INQUIRY,
|
||
//! optionally unlocks/initializes via a platform driver, and reads sectors.
|
||
//! `probe_disc()` primes the firmware's per-region speed table.
|
||
|
||
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;
|
||
|
||
// Per-platform discovery helpers (the `pub(crate)` `find_drives` /
|
||
// equivalents). Crate-public so `scsi/{linux,macos,windows}.rs` can
|
||
// reuse the existing enumeration logic when shaping `DriveInfo`.
|
||
#[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::platform::PlatformDriver;
|
||
use crate::platform::mt1959::Mt1959;
|
||
use crate::profile::{self, DriveProfile};
|
||
use crate::scsi::ScsiTransport;
|
||
use crate::sector::SectorSource;
|
||
use std::path::Path;
|
||
use std::sync::Arc;
|
||
use std::sync::atomic::{AtomicBool, Ordering};
|
||
|
||
/// Physical state of the drive tray and disc.
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub enum DriveStatus {
|
||
/// Tray is open
|
||
TrayOpen,
|
||
/// Tray closed, no disc
|
||
NoDisc,
|
||
/// Tray closed, disc present and ready
|
||
DiscPresent,
|
||
/// Drive is loading or spinning up
|
||
NotReady,
|
||
/// Could not determine status
|
||
Unknown,
|
||
}
|
||
|
||
// SCSI opcodes used in drive control
|
||
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;
|
||
|
||
/// Optical disc drive session -- open, identify, unlock, and read.
|
||
pub struct Drive {
|
||
scsi: Box<dyn ScsiTransport>,
|
||
driver: Option<Box<dyn PlatformDriver>>,
|
||
pub profile: Option<DriveProfile>,
|
||
pub platform: Option<profile::Platform>,
|
||
pub drive_id: DriveId,
|
||
device_path: String,
|
||
/// Halt flag — when set, Drive::read() bails at the next check point.
|
||
halt: Arc<AtomicBool>,
|
||
/// Event handler — fires for read errors and library-level state changes.
|
||
event_fn: Option<Box<dyn Fn(Event) + Send>>,
|
||
/// Linux only: raw fd for the corresponding block device (`/dev/sr*`)
|
||
/// used as a recovery fallback when SCSI READ via `/dev/sg*` returns
|
||
/// an error. The kernel `sr_mod` driver auto-retries failed reads
|
||
/// (~5× per command) — historically the reason `dd if=/dev/sr0`
|
||
/// recovers ~50% of bad sectors that single-shot `SG_IO` READ
|
||
/// misses on the same drive. `None` when the block device couldn't
|
||
/// be resolved or opened (no fallback in that case; SCSI read
|
||
/// errors propagate as before).
|
||
#[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 profiles = profile::load_bundled()?;
|
||
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"
|
||
);
|
||
|
||
let m = profile::find_by_drive_id(&profiles, &drive_id);
|
||
let (driver, platform, profile) = match m {
|
||
Some(m) => (
|
||
create_driver(m.platform, &m.profile).ok(),
|
||
Some(m.platform),
|
||
Some(m.profile),
|
||
),
|
||
None => (None, None, None),
|
||
};
|
||
|
||
#[cfg(target_os = "linux")]
|
||
let block_dev_fd = open_block_device_for_sg(device);
|
||
|
||
Ok(Drive {
|
||
scsi: transport,
|
||
driver,
|
||
platform,
|
||
profile,
|
||
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,
|
||
})
|
||
}
|
||
|
||
/// Test-only constructor: build a `Drive` over an arbitrary
|
||
/// [`ScsiTransport`] (no profile, no platform driver, no block-device
|
||
/// fallback) so command-builder/response-parser logic can be exercised
|
||
/// against a scripted mock transport.
|
||
#[cfg(test)]
|
||
fn from_transport_for_test(scsi: Box<dyn ScsiTransport>) -> Self {
|
||
Drive {
|
||
scsi,
|
||
driver: None,
|
||
profile: None,
|
||
platform: None,
|
||
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,
|
||
}
|
||
}
|
||
|
||
/// Get a clone of the halt flag. Set to true to interrupt Drive::read().
|
||
pub fn halt_flag(&self) -> Arc<AtomicBool> {
|
||
self.halt.clone()
|
||
}
|
||
|
||
/// Halt the drive — Drive::read() will bail at the next check point.
|
||
pub fn halt(&self) {
|
||
self.halt.store(true, Ordering::Relaxed);
|
||
}
|
||
|
||
/// Clear the halt flag for the next operation.
|
||
pub fn clear_halt(&self) {
|
||
self.halt.store(false, Ordering::Relaxed);
|
||
}
|
||
|
||
/// Set an event handler for read recovery events.
|
||
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)
|
||
}
|
||
|
||
/// Halt-aware SCSI execute. Returns `Err(Halted)` if the flag is set
|
||
/// before the command dispatches or by the time it completes. The only
|
||
/// path to talk to the drive in the recovery hot loop; keeps Drive::read
|
||
/// free of explicit halt checks.
|
||
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)
|
||
}
|
||
|
||
/// Close the drive cleanly. Unlocks the tray and closes the fd.
|
||
/// Also runs automatically on Drop as a safety net.
|
||
pub fn close(self) {
|
||
// cleanup() runs here via Drop
|
||
}
|
||
|
||
/// Shared cleanup — called by Drop (and thus by close).
|
||
fn cleanup(&mut self) {
|
||
self.unlock_tray();
|
||
}
|
||
|
||
/// Whether this drive has a known profile (unlock parameters available).
|
||
pub fn has_profile(&self) -> bool {
|
||
self.profile.is_some()
|
||
}
|
||
|
||
/// Borrow the matched drive profile, if any. Used by callers that
|
||
/// need to issue per-drive OEM CDB templates (e.g. the OEM VID
|
||
/// retrieval path in `disc::encrypt`).
|
||
pub fn drive_profile(&self) -> Option<&DriveProfile> {
|
||
self.profile.as_ref()
|
||
}
|
||
|
||
/// Access the SCSI transport for direct commands (used by CSS/AACS auth).
|
||
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");
|
||
|
||
// The poll can take up to 30s (60 × 500ms). Heartbeat it so a slow
|
||
// spin-up is visible as steady beats rather than a silent stall.
|
||
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(),
|
||
})
|
||
}
|
||
|
||
/// Query the physical state of the drive — disc present, tray open, etc.
|
||
/// Uses GET EVENT STATUS NOTIFICATION which works regardless of firmware state.
|
||
pub fn drive_status(&mut self) -> DriveStatus {
|
||
// GET EVENT STATUS NOTIFICATION: polled, media event class (0x10)
|
||
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];
|
||
// Bits 1-0: door/tray state
|
||
// Bit 1: media present, Bit 0: tray open
|
||
match media_status & 0x03 {
|
||
0x00 => DriveStatus::NoDisc, // tray closed, no disc
|
||
0x01 => DriveStatus::TrayOpen, // tray open, no media
|
||
0x02 => DriveStatus::DiscPresent, // tray closed, disc present
|
||
// 0x03 = tray-open bit AND media-present bit both set:
|
||
// a contradictory/transient state. Don't report it as
|
||
// ready — autorip must not start a rip on a drive that
|
||
// is still settling. Treat as tray-open.
|
||
0x03 => DriveStatus::TrayOpen,
|
||
_ => DriveStatus::Unknown,
|
||
}
|
||
}
|
||
_ => {
|
||
// Fallback: try TUR
|
||
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 {
|
||
match self.platform {
|
||
Some(ref p) => p.name(),
|
||
None => "Unknown",
|
||
}
|
||
}
|
||
|
||
pub fn device_path(&self) -> &str {
|
||
&self.device_path
|
||
}
|
||
|
||
/// Current mounted-disc profile from the GET CONFIGURATION header
|
||
/// (Current Profile, bytes 6-7). DVD family is `0x0010..=0x001F`, BD
|
||
/// family `0x0040..=0x0043`. This is a stock MMC command — it works
|
||
/// before (and without) any firmware unlock. `None` if unreadable.
|
||
fn current_profile(&mut self) -> Option<u16> {
|
||
let cdb = [
|
||
crate::scsi::SCSI_GET_CONFIGURATION,
|
||
0x00, // RT=0: header carries the Current Profile
|
||
0x00,
|
||
0x00, // starting feature 0
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0x00,
|
||
0x08, // allocation length = 8 (header only)
|
||
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
|
||
}
|
||
}
|
||
|
||
/// True when the mounted disc is a DVD (profile family `0x0010..=0x001F`).
|
||
fn disc_is_dvd(&mut self) -> bool {
|
||
matches!(self.current_profile(), Some(p) if (0x0010..=0x001F).contains(&p))
|
||
}
|
||
|
||
/// Initialize drive — unlock + firmware upload.
|
||
/// Optional. Adds features: removes riplock, enables UHD reads, speed control.
|
||
///
|
||
/// The LibreDrive/OEM firmware unlock is required for BD/UHD (AACS) reads,
|
||
/// but it puts the drive in an extended-access state where stock CSS
|
||
/// authentication no longer works — so a CSS-protected DVD can't be read.
|
||
/// For a DVD we therefore SKIP the unlock and run the drive in its normal
|
||
/// stock mode; the DVD path then issues standard CSS commands, which a stock
|
||
/// drive honors. BD/UHD and any non-DVD/unknown media keep today's behavior.
|
||
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)");
|
||
return Ok(());
|
||
}
|
||
let r = match self.driver {
|
||
Some(ref mut d) => d.init(self.scsi.as_mut()),
|
||
None => Err(Error::UnsupportedDrive {
|
||
vendor_id: self.drive_id.vendor_id.trim().to_string(),
|
||
product_id: self.drive_id.product_id.trim().to_string(),
|
||
product_revision: self.drive_id.product_revision.trim().to_string(),
|
||
}),
|
||
};
|
||
tracing::info!(
|
||
target: "freemkv::drive",
|
||
phase = "init",
|
||
ok = r.is_ok(),
|
||
elapsed_ms = t0.elapsed().as_millis() as u64,
|
||
"end"
|
||
);
|
||
r
|
||
}
|
||
|
||
/// Probe disc surface so the drive firmware learns optimal read speeds
|
||
/// per region. After this the host reads at max speed and the drive
|
||
/// manages zones internally.
|
||
pub fn probe_disc(&mut self) -> Result<()> {
|
||
let t0 = std::time::Instant::now();
|
||
tracing::info!(target: "freemkv::drive", phase = "probe_disc", "begin");
|
||
// A DVD runs in stock mode (see `init`); skip the OEM/firmware-path
|
||
// disc calibration, which only applies to the unlocked BD/UHD drive.
|
||
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(());
|
||
}
|
||
let r = match self.driver {
|
||
Some(ref mut d) => d.probe_disc(self.scsi.as_mut()),
|
||
None => Err(Error::UnsupportedDrive {
|
||
vendor_id: self.drive_id.vendor_id.trim().to_string(),
|
||
product_id: self.drive_id.product_id.trim().to_string(),
|
||
product_revision: self.drive_id.product_revision.trim().to_string(),
|
||
}),
|
||
};
|
||
tracing::info!(
|
||
target: "freemkv::drive",
|
||
phase = "probe_disc",
|
||
ok = r.is_ok(),
|
||
elapsed_ms = t0.elapsed().as_millis() as u64,
|
||
"end"
|
||
);
|
||
r
|
||
}
|
||
|
||
/// Query a specific GET CONFIGURATION feature by code.
|
||
/// Returns the feature data (without the 8-byte header), or None if not available.
|
||
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()?;
|
||
// Clamp the transport-reported count to the buffer length: a
|
||
// misbehaving driver/bridge could report more bytes than the
|
||
// buffer holds, which would panic the slice.
|
||
let end = r.bytes_transferred.min(buf.len());
|
||
if end > 8 {
|
||
Some(buf[8..end].to_vec())
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// Read REPORT KEY RPC state (region playback control).
|
||
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
|
||
}
|
||
}
|
||
|
||
/// Read MODE SENSE page data.
|
||
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
|
||
}
|
||
}
|
||
|
||
/// Read vendor-specific READ BUFFER data.
|
||
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 {
|
||
match self.driver {
|
||
Some(ref d) => d.is_ready(),
|
||
None => false,
|
||
}
|
||
}
|
||
|
||
/// True if the drive is currently in the extended-access state.
|
||
///
|
||
/// Detected by the platform driver during `init()` from the unlock
|
||
/// response's mode markers. When true:
|
||
/// - SCSI READ_10 returns plaintext sectors (no AACS bus
|
||
/// encryption applied)
|
||
/// - VID retrieval works via the per-drive OEM CDB in
|
||
/// [`DriveProfile`] without the cert-based AACS handshake
|
||
/// - Disc-side Host Revocation List enforcement is effectively
|
||
/// bypassed by the alternate data path
|
||
///
|
||
/// AACS layer code branches on this: if true, issue the OEM
|
||
/// `read_vid_cdb` to retrieve VID directly; if false, fall back
|
||
/// to the cert-based mutual-auth handshake.
|
||
pub fn is_unlocked(&self) -> bool {
|
||
match self.driver {
|
||
Some(ref d) => d.is_unlocked(),
|
||
None => false,
|
||
}
|
||
}
|
||
|
||
/// Read sectors from the disc. Single-shot — no inline retries, no
|
||
/// SCSI reset.
|
||
///
|
||
/// `recovery=true` uses [`crate::scsi::READ_RECOVERY_TIMEOUT_MS`] (60 s,
|
||
/// matches sg_dd) for the `Disc::patch` pass; `recovery=false` uses
|
||
/// [`crate::scsi::READ_TIMEOUT_MS`] (10 s) for `Disc::copy`'s fast
|
||
/// skip-forward sweep. Both budgets are generous enough that the drive
|
||
/// can finish ECC recovery on a marginal sector — pre-0.13.21 this was
|
||
/// 1.5 s on the fast path which forced the kernel mid-layer to time
|
||
/// out and escalate while we waited anyway. On any failure returns
|
||
/// `Err(DiscRead)` immediately; orchestration (`Disc::patch` multi-pass,
|
||
/// `DiscStream` adaptive batch halving) handles retry policy.
|
||
///
|
||
/// Inline retry phases (5× gentle + reset+reopen + 5× more) were
|
||
/// removed in 0.13.6: on some USB-SATA bridges the inline reset wedged
|
||
/// drive firmware without ever recovering a sector. The remaining
|
||
/// recovery layers (Disc::patch multi-pass, DiscStream batch halving)
|
||
/// do not touch the wedge-prone reset path.
|
||
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::debug!(
|
||
target: "freemkv::drive",
|
||
lba,
|
||
count,
|
||
recovery,
|
||
timeout_ms,
|
||
"Drive::read enter"
|
||
);
|
||
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)
|
||
}
|
||
// A READ(10) that completes with GOOD status but a residual
|
||
// underrun (bytes_transferred < requested) is a SHORT transfer:
|
||
// the tail of `buf` still holds stale bytes from a prior read.
|
||
// Committing those as recovered/Good is silent data corruption, so
|
||
// treat a short transfer as a failed read — the caller marks the
|
||
// range NonTrimmed and retries (a loud miss, never a silent commit).
|
||
// The sector/file path enforces the same invariant in
|
||
// sector/prefetched.rs; this is the live-drive counterpart.
|
||
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"
|
||
);
|
||
|
||
// /dev/sr0 pread fallback (Linux only). The kernel
|
||
// sr_mod driver auto-retries failed reads (~5× per
|
||
// command). Empirically (BU40N + a UHD disc,
|
||
// 2026-05-08) dd via /dev/sr0 recovers ~50% of bad
|
||
// sectors that a single-shot SG_IO READ misses.
|
||
#[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;
|
||
// Drop kernel cache for this region so we get
|
||
// a fresh device read, not stale page-cache
|
||
// data from a prior successful neighbour read.
|
||
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,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Read the disc capacity in sectors (2048 bytes each).
|
||
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);
|
||
}
|
||
|
||
/// Lock the tray so the disc cannot be ejected during a rip.
|
||
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);
|
||
}
|
||
|
||
/// Unlock the tray so the user can manually eject the disc.
|
||
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);
|
||
}
|
||
|
||
/// Eject the disc tray. Unlocks first, then ejects.
|
||
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();
|
||
// SgIoTransport::drop() runs next, calling libc::close(fd)
|
||
#[cfg(target_os = "linux")]
|
||
if let Some(fd) = self.block_dev_fd.take() {
|
||
unsafe { libc::close(fd) };
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Resolve a `/dev/sg*` path to the corresponding `/dev/sr*` block
|
||
/// device by walking sysfs, then open it for read (no `O_DIRECT` —
|
||
/// `posix_fadvise(POSIX_FADV_DONTNEED)` flushes the cache before each
|
||
/// pread, which avoids buffer-alignment requirements while still
|
||
/// forcing fresh device reads).
|
||
///
|
||
/// Returns `None` on any error (sysfs not present, no matching block
|
||
/// device, open failed). Callers treat that as "no fallback available"
|
||
/// and propagate the original SCSI READ error.
|
||
#[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);
|
||
}
|
||
}
|
||
|
||
/// Find the first optical drive on this system and open it.
|
||
///
|
||
/// For just listing drives without opening (e.g. UI sidebar), use
|
||
/// `scsi::list_drives()` — that returns `DriveInfo` (path + identity)
|
||
/// without the cost of running every drive's profile + identity probe.
|
||
pub fn find_drive() -> Option<Drive> {
|
||
discover_drives()
|
||
.into_iter()
|
||
.find_map(|(path, _)| Drive::open(std::path::Path::new(&path)).ok())
|
||
}
|
||
|
||
/// Decode a READ CAPACITY (10) response into a sector count.
|
||
///
|
||
/// A short transfer (`bytes_transferred < 4`, which would leave the high
|
||
/// bytes zero-initialised and decode to a bogus 1-sector disc) is rejected
|
||
/// as [`Error::DiscCapacityMalformed`]. The `0xFFFF_FFFF` "capacity exceeds
|
||
/// 32-bit" sentinel, whose `last_lba + 1` overflows `u32`, is reported as the
|
||
/// distinct [`Error::DiscCapacityOverflow`] so callers can tell an unusable
|
||
/// response apart from an over-large disc.
|
||
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)
|
||
}
|
||
|
||
/// Halt-aware sleep primitive — wakes within ~100 ms of `halt` flipping
|
||
/// to true. Kept for the unit tests that cover the slicing behaviour;
|
||
/// production code paths no longer sleep on the recovery hot path
|
||
/// (recovery loop removed in 0.13.6).
|
||
#[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));
|
||
}
|
||
}
|
||
|
||
/// Internal: discover drive paths + IDs without opening full Drive objects.
|
||
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()
|
||
}
|
||
}
|
||
|
||
/// Structured outcome of [`resolve_device`] — a machine-readable signal
|
||
/// (no English prose) the application layer can render however it likes.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum DeviceResolution {
|
||
/// Path resolved directly to a SCSI-generic device; no substitution.
|
||
Direct,
|
||
/// A `/dev/sr*` block path was substituted with the matching
|
||
/// `/dev/sg*` SCSI-generic device for raw access (Linux only).
|
||
SrToSg,
|
||
/// A `/dev/sr*` block path was given but no matching `/dev/sg*`
|
||
/// device could be found; the original path is returned (Linux only).
|
||
SrNoSgMatch,
|
||
}
|
||
|
||
/// Resolve a device path to its raw SCSI device. Returns the resolved
|
||
/// path plus a structured [`DeviceResolution`] signal describing whether
|
||
/// any substitution happened; the application layer maps that to UX text.
|
||
#[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)
|
||
}
|
||
}
|
||
|
||
fn create_driver(
|
||
platform: profile::Platform,
|
||
profile: &DriveProfile,
|
||
) -> Result<Box<dyn PlatformDriver>> {
|
||
match platform {
|
||
profile::Platform::Mt1959A => Ok(Box::new(Mt1959::new(profile.clone(), false))),
|
||
profile::Platform::Mt1959B => Ok(Box::new(Mt1959::new(profile.clone(), true))),
|
||
profile::Platform::Renesas => Err(Error::PlatformNotImplemented {
|
||
platform: "renesas".to_string(),
|
||
}),
|
||
}
|
||
}
|
||
|
||
#[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)));
|
||
// Must wake within one slice (100 ms) — the whole point of the
|
||
// primitive is that a 30 s sleep doesn't block Stop.
|
||
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();
|
||
// Flag flipped at ~150 ms; we wake within one 100 ms slice → <300 ms.
|
||
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() {
|
||
// bytes_transferred < 4 must NOT decode to capacity=1 from
|
||
// zero-init bytes.
|
||
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() {
|
||
// last_lba = 0x00012344 -> capacity 0x00012345.
|
||
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() {
|
||
// last_lba = u32::MAX (the "capacity exceeds 32-bit" sentinel) -> +1
|
||
// overflows; reported as the distinct DiscCapacityOverflow, not the
|
||
// short-transfer DiscCapacityMalformed.
|
||
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};
|
||
|
||
/// Mock transport: returns a fixed data payload (copied into the
|
||
/// caller's buffer, truncated to fit) on every `execute()`.
|
||
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() {
|
||
// last_lba = 0x0000_0063 (99) → capacity 100 sectors.
|
||
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() {
|
||
// last_lba = 0xFFFF_FFFF is the "capacity exceeds 32-bit" sentinel;
|
||
// +1 would overflow. Must surface DiscCapacityOverflow, not panic
|
||
// (debug) or wrap to 0 (release).
|
||
let mut d = drive_with(vec![0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x08, 0x00]);
|
||
assert!(matches!(
|
||
d.read_capacity(),
|
||
Err(Error::DiscCapacityOverflow)
|
||
));
|
||
}
|
||
|
||
/// `disc_is_dvd()` must match the DVD profile family (0x0010..=0x001F)
|
||
/// and ONLY that family. A false positive on a BD/UHD profile (0x0040+)
|
||
/// would skip the LibreDrive firmware unlock that UHD reads require; a
|
||
/// false negative on a DVD would re-introduce the CSS read failure. The
|
||
/// Current Profile is bytes 6-7 of the GET CONFIGURATION header.
|
||
/// Mutation: widening the range to `..=0x0040` makes the BD-ROM assert
|
||
/// fire; a failed/short GET CONFIGURATION must default to NOT-DVD so the
|
||
/// unlock still runs.
|
||
#[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()
|
||
};
|
||
// DVD family → DVD (skip firmware unlock, run stock for CSS).
|
||
assert!(probe(0x0010), "DVD-ROM");
|
||
assert!(probe(0x0011), "DVD-R");
|
||
assert!(probe(0x001B), "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");
|
||
assert!(!probe(0x0008), "CD-ROM");
|
||
assert!(!probe(0x0000), "no/unknown profile");
|
||
// Short / failed GET CONFIGURATION → no Current Profile → NOT DVD,
|
||
// so the firmware unlock still runs (safe default).
|
||
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() {
|
||
// GET EVENT STATUS reply: byte 5 (media_status) low bits = 0b11
|
||
// (tray-open AND media-present, contradictory). Must NOT report
|
||
// DiscPresent. Buffer is 8 bytes; bytes_transferred >= 6.
|
||
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; // media present, tray closed
|
||
let mut d = drive_with(buf);
|
||
assert_eq!(d.drive_status(), DriveStatus::DiscPresent);
|
||
}
|
||
|
||
// ── Mocks for Drive::read single-shot semantics + CDB encoding ──
|
||
|
||
use std::sync::{Arc, Mutex};
|
||
|
||
/// Records the CDB of every execute() and returns a programmable
|
||
/// outcome. Lets a test assert both the bytes sent to the drive and
|
||
/// how the driver translates the transport result.
|
||
struct RecordingTransport {
|
||
last_cdb: Arc<Mutex<Vec<u8>>>,
|
||
last_timeout: Arc<Mutex<u32>>,
|
||
outcome: TransportOutcome,
|
||
}
|
||
enum TransportOutcome {
|
||
/// Report this many bytes transferred (data left as-is).
|
||
Ok(usize),
|
||
/// Fail with a ScsiError carrying this status + optional sense.
|
||
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() {
|
||
// Drive::read issues READ(10) (0x28). LBA bytes 2..5 big-endian,
|
||
// transfer length bytes 7..8 big-endian (MMC-6). No FUA on this
|
||
// path (byte 1 == 0). Distinct nibbles catch a swapped shift.
|
||
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() {
|
||
// recovery=true must use READ_RECOVERY_TIMEOUT_MS (60 s); false
|
||
// uses READ_TIMEOUT_MS (10 s). Doc: patch pass vs copy sweep.
|
||
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() {
|
||
// On a non-Halted failure, Drive::read returns Error::DiscRead
|
||
// with sector=lba and the transport's status+sense carried
|
||
// through (extract_scsi_context). A 03/11/05 MEDIUM ERROR.
|
||
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() {
|
||
// Status 0xFF (TRANSPORT_FAILURE) with no sense must surface in
|
||
// DiscRead.status so is_scsi_transport_failure() routes it.
|
||
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() {
|
||
// When the halt flag is set, checked_exec returns Halted BEFORE
|
||
// execute(); the error must be Halted (not DiscRead), so the
|
||
// recovery loop distinguishes user-stop from a read failure.
|
||
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() {
|
||
// halt() then clear_halt() must allow reads again — the flag is
|
||
// not sticky.
|
||
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() {
|
||
// Single-shot contract: Drive::read returns exactly what the
|
||
// transport reported, never a smaller count silently. Transport
|
||
// says a full 32-sector batch (65536 bytes) succeeded.
|
||
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);
|
||
}
|
||
|
||
// ── drive_status branch coverage (GET EVENT STATUS byte 5) ──────
|
||
|
||
#[test]
|
||
fn drive_status_no_disc_maps_correctly() {
|
||
// media_status low bits 0b00 = tray closed, no disc.
|
||
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() {
|
||
// media_status low bits 0b01 = tray open, no media.
|
||
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() {
|
||
// Only the low 2 bits of byte 5 are the door/media state; upper
|
||
// bits (NEA, etc.) must be masked. 0xFE has low bits 0b10 =
|
||
// DiscPresent.
|
||
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() {
|
||
// bytes_transferred < 6 means the GET EVENT reply is unusable;
|
||
// the code falls back to a TUR. FixedTransport always returns
|
||
// Ok, so the TUR "succeeds" → DiscPresent. (Buffer length 8 but
|
||
// payload only 4 bytes → bytes_transferred = 4.)
|
||
let mut d = drive_with(vec![0u8; 4]);
|
||
assert_eq!(d.drive_status(), DriveStatus::DiscPresent);
|
||
}
|
||
|
||
/// Transport that fails every command with a programmable error —
|
||
/// drives the TUR-fallback NotReady/Unknown branches of drive_status.
|
||
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() {
|
||
// GET EVENT fails, fallback TUR fails with NOT READY sense →
|
||
// DriveStatus::NotReady (drive spinning up). Doc: drive_status
|
||
// fallback branch.
|
||
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, // NOT READY
|
||
asc: 0x04,
|
||
ascq: 0x01,
|
||
}),
|
||
},
|
||
}));
|
||
assert_eq!(d.drive_status(), DriveStatus::NotReady);
|
||
}
|
||
|
||
#[test]
|
||
fn drive_status_tur_unit_attention_maps_not_ready() {
|
||
// UNIT ATTENTION (media changed) on the fallback TUR also maps to
|
||
// NotReady per the is_unit_attention() arm.
|
||
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, // UNIT ATTENTION
|
||
asc: 0x28,
|
||
ascq: 0x00,
|
||
}),
|
||
},
|
||
}));
|
||
assert_eq!(d.drive_status(), DriveStatus::NotReady);
|
||
}
|
||
|
||
#[test]
|
||
fn drive_status_tur_other_error_maps_unknown() {
|
||
// A fallback TUR failure that is neither NOT READY nor UNIT
|
||
// ATTENTION (e.g. transport failure, no sense) → 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);
|
||
}
|
||
|
||
// ── get_config_feature: header-strip threshold + clamp ──────────
|
||
|
||
#[test]
|
||
fn get_config_feature_strips_8_byte_header() {
|
||
// GET CONFIGURATION reply has an 8-byte Feature Header (MMC-6
|
||
// §5.2.2). get_config_feature returns buf[8..end]. Provide a
|
||
// 12-byte reply → returns the 4 payload bytes.
|
||
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() {
|
||
// end == 8 means header only, no descriptor → None (the `end > 8`
|
||
// guard). Boundary against an off-by-one that would return an
|
||
// empty Vec instead of None.
|
||
let mut d = drive_with(vec![0u8; 8]);
|
||
assert_eq!(d.get_config_feature(0x0000), None);
|
||
}
|
||
|
||
// ── report_key / mode_sense / read_buffer empty-vs-some ─────────
|
||
|
||
#[test]
|
||
fn report_key_rpc_state_returns_transferred_prefix() {
|
||
// Returns buf[..end] where end = bytes_transferred. An 8-byte
|
||
// reply yields all 8 bytes.
|
||
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() {
|
||
// end == 0 → None (the `end > 0` guard), never Some(empty).
|
||
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() {
|
||
// read_buffer allocates `length` bytes; FixedTransport returns
|
||
// min(payload, length). Request 16 with a 4-byte payload → 4 bytes.
|
||
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);
|
||
}
|
||
|
||
// ── No-driver paths: init/probe surface UnsupportedDrive ────────
|
||
|
||
#[test]
|
||
fn init_without_driver_is_unsupported_drive() {
|
||
// from_transport_for_test has no platform driver; init() must
|
||
// return UnsupportedDrive, not panic or silently succeed.
|
||
let mut d = drive_with(vec![]);
|
||
assert!(matches!(d.init(), Err(Error::UnsupportedDrive { .. })));
|
||
}
|
||
|
||
#[test]
|
||
fn probe_disc_without_driver_is_unsupported_drive() {
|
||
let mut d = drive_with(vec![]);
|
||
assert!(matches!(
|
||
d.probe_disc(),
|
||
Err(Error::UnsupportedDrive { .. })
|
||
));
|
||
}
|
||
|
||
// ── decode_read_capacity additional boundaries ──────────────────
|
||
|
||
#[test]
|
||
fn read_capacity_exactly_4_bytes_decodes() {
|
||
// bytes_transferred == 4 is the minimum that decodes (the guard
|
||
// is `< 4`). last_lba in bytes 0..4 big-endian.
|
||
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() {
|
||
// last_lba 0 → capacity 1 (a single-sector medium), distinct from
|
||
// the malformed/short-transfer rejection.
|
||
let buf = [0, 0, 0, 0, 0, 0, 0, 0];
|
||
assert_eq!(decode_read_capacity(&buf, 8).unwrap(), 1);
|
||
}
|
||
}
|