From 9e3d0f13832fb64bc92ff9e7e3df646ad91223b2 Mon Sep 17 00:00:00 2001 From: MattJackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:59:35 -0700 Subject: [PATCH] v0.13.44: macOS raw CDB transport via IOKit exclusive access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS SCSI transport rewritten from hybrid MMC+pread to single-path raw CDB dispatch through SCSITaskDeviceInterface. All CDBs (INQUIRY, READ, REPORT KEY, etc.) now go through ExecuteTaskSync — 1:1 with the Linux SG_IO backend. Key changes: - New macos_shim.c: diskutil unmount → find IOBDServices → ObtainExclusiveAccess → raw CDB dispatch. Eliminates Rust-side IOKit COM vtable complexity. - build.rs compiles macos_shim.c via cc into static lib - macos.rs simplified to three FFI calls (open/close/execute) - disc/mod.rs: graduated batch restore after errors, skip-ahead through bad zones, configurable error pause --- build.rs | 29 ++ src/disc/mod.rs | 116 +++-- src/scsi/macos.rs | 586 +++++-------------------- src/scsi/macos_shim.c | 142 ++++++ src/scsi/mod.rs | 2 +- tests/integration_progress_and_halt.rs | 25 +- 6 files changed, 373 insertions(+), 527 deletions(-) create mode 100644 src/scsi/macos_shim.c diff --git a/build.rs b/build.rs index bb7df6b..6c2d4cf 100644 --- a/build.rs +++ b/build.rs @@ -3,5 +3,34 @@ fn main() { if target == "macos" { println!("cargo:rustc-link-lib=framework=IOKit"); println!("cargo:rustc-link-lib=framework=CoreFoundation"); + + let out_dir = std::env::var("OUT_DIR").unwrap(); + let obj = format!("{out_dir}/macos_shim.o"); + let lib = format!("{out_dir}/libmacos_scsi.a"); + + std::process::Command::new("cc") + .args([ + "-c", + "src/scsi/macos_shim.c", + "-o", + &obj, + "-framework", + "IOKit", + "-framework", + "CoreFoundation", + "-Wall", + "-O2", + ]) + .status() + .expect("failed to compile macos_shim.c"); + + std::process::Command::new("ar") + .args(["rcs", &lib, &obj]) + .status() + .expect("failed to create static lib"); + + println!("cargo:rustc-link-search=native={out_dir}"); + println!("cargo:rustc-link-lib=static=macos_scsi"); + println!("cargo:rerun-if-changed=src/scsi/macos_shim.c"); } } diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 44227f0..f499ae5 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -1281,6 +1281,10 @@ impl Disc { let mut buf = vec![0u8; batch as usize * 2048]; let mut bytes_done = 0u64; let mut halt_requested = false; + let mut current_batch = batch; + let mut consecutive_ok_since_error: u64 = 0; + let mut consecutive_errors: u64 = 0; + let mut skip_power: u32 = 0; let copy_t0 = std::time::Instant::now(); let mut iter_count: u64 = 0; let mut read_ok_count: u64 = 0; @@ -1333,25 +1337,11 @@ impl Disc { } } - let block_bytes = (region_end - pos).min(batch as u64 * 2048); + let block_bytes = (region_end - pos).min(current_batch as u64 * 2048); let block_lba = (pos / 2048) as u32; let block_count = (block_bytes / 2048) as u16; let recovery = !opts.skip_on_error; - if read_ok_count + read_err_count < 3 { - tracing::info!( - target: "freemkv::disc", - block_lba, - block_count, - block_bytes, - recovery, - skip_on_error = opts.skip_on_error, - batch, - total_bytes, - "Disc::copy first reads" - ); - } - let read_result = reader.read_sectors( block_lba, block_count, @@ -1361,6 +1351,30 @@ impl Disc { if read_result.is_ok() { read_ok_count += 1; + consecutive_ok_since_error += 1; + consecutive_errors = 0; + skip_power = 0; + + if current_batch < batch + && consecutive_ok_since_error >= COPY_BATCH_RESTORE_STREAK + { + let next_batch = (current_batch * 2).min(batch); + tracing::info!( + target: "freemkv::disc", + phase = "batch_restore", + prev_batch = current_batch, + batch = next_batch, + lba = block_lba, + streak = consecutive_ok_since_error, + "graduated batch restore" + ); + current_batch = next_batch; + if (current_batch as usize * 2048) > buf.len() { + buf.resize(current_batch as usize * 2048, 0); + } + consecutive_ok_since_error = 0; + } + if opts.decrypt { crate::decrypt::decrypt_sectors( &mut buf[..block_bytes as usize], @@ -1389,6 +1403,19 @@ impl Disc { } else { let err = read_result.err().unwrap(); read_err_count += 1; + consecutive_ok_since_error = 0; + consecutive_errors += 1; + + if current_batch > 1 { + current_batch = 1; + tracing::warn!( + target: "freemkv::disc", + phase = "batch_reduce", + lba = block_lba, + prev_batch = block_count, + "dropping to single-sector reads after error" + ); + } if err.is_scsi_transport_failure() { tracing::warn!( @@ -1407,12 +1434,12 @@ impl Disc { return Err(err); } - // ECC block failed — zero-fill, mark NonTrimmed, advance. tracing::warn!( target: "freemkv::disc", phase = "skip_ecc_block", lba = block_lba, sectors = block_count, + consecutive_errors, error = %err, "ECC block failed; marking NonTrimmed" ); @@ -1424,6 +1451,44 @@ impl Disc { map.record(pos, block_bytes, mapfile::SectorStatus::NonTrimmed) .map_err(|e| Error::IoError { source: e })?; bytes_done = bytes_done.saturating_add(block_bytes); + + let pause_ms = opts.error_pause_ms.unwrap_or(COPY_ERROR_PAUSE_MS); + if pause_ms > 0 { + std::thread::sleep(std::time::Duration::from_millis(pause_ms)); + } + + if consecutive_errors >= COPY_SKIP_THRESHOLD { + let skip_sectors = + (COPY_SKIP_BASE_SECTORS << skip_power).min(COPY_SKIP_MAX_SECTORS); + let available = region_end.saturating_sub(pos + block_bytes); + let skip_bytes = (skip_sectors as u64 * 2048).min(available); + if skip_bytes > 0 { + let skip_lba = ((pos + block_bytes) / 2048) as u32; + tracing::warn!( + target: "freemkv::disc", + phase = "skip_ahead", + from_lba = skip_lba, + skip_sectors, + skip_power, + consecutive_errors, + "skipping ahead through bad zone" + ); + let skip_zero = vec![0u8; skip_bytes as usize]; + file.seek(SeekFrom::Start(pos + block_bytes)) + .map_err(|e| Error::IoError { source: e })?; + file.write_all(&skip_zero) + .map_err(|e| Error::IoError { source: e })?; + map.record( + pos + block_bytes, + skip_bytes, + mapfile::SectorStatus::NonTrimmed, + ) + .map_err(|e| Error::IoError { source: e })?; + bytes_done = bytes_done.saturating_add(skip_bytes); + pos += skip_bytes; + skip_power = skip_power.saturating_add(1); + } + } } pos += block_bytes; @@ -1490,23 +1555,12 @@ impl Disc { #[derive(Default)] pub struct CopyOptions<'a> { pub decrypt: bool, - /// Resume from existing mapfile + ISO if present. Without this, any - /// existing mapfile is wiped and the ISO recreated. pub resume: bool, - /// Override the default block size in sectors. Callers should resolve - /// this with `detect_max_batch_sectors(device_path)` for live drives. - /// When `None`, falls back to 32 sectors (64 KB BD ECC block) in - /// `skip_on_error` mode or `DEFAULT_BATCH_SECTORS=60` otherwise. pub batch_sectors: Option, - /// Zero-fill bad blocks in the ISO, mark them NonTrimmed in the mapfile, - /// and continue. Failed ECC blocks are left for `Disc::patch` to recover. pub skip_on_error: bool, - /// Per-iteration progress reporter. v0.13.16 architecture: the library - /// emits a single `PassProgress` shape via the `Progress` trait; - /// consumers compute their own derived percentages / ETAs from it. No - /// more positional `(bytes_good, pos, total)` callbacks. pub progress: Option<&'a dyn crate::progress::Progress>, pub halt: Option>, + pub error_pause_ms: Option, } /// Result of `Disc::copy`. `complete=true` means every byte reached a terminal @@ -1812,6 +1866,12 @@ const MAX_BATCH_SECTORS: u16 = 510; const DEFAULT_BATCH_SECTORS: u16 = 60; const MIN_BATCH_SECTORS: u16 = 3; +const COPY_ERROR_PAUSE_MS: u64 = 2000; +const COPY_SKIP_THRESHOLD: u64 = 8; +const COPY_SKIP_BASE_SECTORS: u32 = 32; +const COPY_SKIP_MAX_SECTORS: u32 = 8192; +const COPY_BATCH_RESTORE_STREAK: u64 = 200; + /// Coarse damage tier for a finished or in-progress rip. Maps the /// observable signals (bad sector count + lost wallclock playback time) /// onto a small discrete classification so UIs can render a colored badge diff --git a/src/scsi/macos.rs b/src/scsi/macos.rs index a786360..5de8760 100644 --- a/src/scsi/macos.rs +++ b/src/scsi/macos.rs @@ -1,183 +1,41 @@ -//! macOS SCSI transport via IOKit SCSITaskDeviceInterface. +//! macOS SCSI transport: IOKit SCSITaskDeviceInterface with exclusive access. //! -//! Sends SCSI commands to optical drives through IOKit's SCSI Architecture -//! Model family. Accepts BSD device paths like `/dev/disk2` or `/dev/rdisk2`. +//! Single dispatch path: **all** CDBs (INQUIRY, READ, REPORT KEY, etc.) go +//! through `SCSITaskDeviceInterface::ExecuteTaskSync` — 1:1 with the Linux +//! SG_IO backend. The C shim (`macos_shim.c`) handles: //! -//! Requires exclusive access to the device — unmount the disc first: -//! `diskutil unmountDisk /dev/disk2` +//! 1. `diskutil unmountDisk force` so the kernel block-storage driver releases +//! 2. Find `IOBDServices` directly via `IOServiceMatching` (not IOMedia walk) +//! 3. Create `MMCDeviceInterface` → `SCSITaskDeviceInterface` +//! 4. `ObtainExclusiveAccess` +//! 5. Raw CDB dispatch via `CreateSCSITask` + `ExecuteTaskSync` use super::{DataDirection, ScsiResult, ScsiTransport}; use crate::error::{Error, Result}; use std::path::Path; -// ── IOKit / CoreFoundation type aliases ───────────────────────────────────── - -type CFMutableDictionaryRef = *mut std::ffi::c_void; -type IOObject = u32; -type IOReturn = i32; -type MachPort = u32; - -/// Opaque COM interface pointer — `*mut *mut VTable` (double-indirect). -/// IOKit plugins use COM-style vtables: the pointer points to a pointer -/// to the function table. -type ComRef = *mut *mut std::ffi::c_void; - -const K_IO_RETURN_SUCCESS: IOReturn = 0; - -// SCSI data transfer directions (SCSITaskLib.h) -const K_SCSI_DATA_TRANSFER_NO_DATA: u8 = 0; -const K_SCSI_DATA_TRANSFER_FROM_TARGET: u8 = 1; -const K_SCSI_DATA_TRANSFER_TO_TARGET: u8 = 2; - -// SCSI task status values -const K_SCSI_TASK_STATUS_GOOD: u8 = 0x00; - -const K_MAX_CDB_SIZE: usize = 16; const K_SENSE_DATA_SIZE: usize = 32; -// ── IOKit plugin UUIDs ────────────────────────────────────────────────────── -// From IOKit/scsi/SCSITaskLib.h - -/// kIOMMCDeviceUserClientTypeID — plugin type for MMC (optical) devices. -const K_IO_MMC_DEVICE_USER_CLIENT_TYPE_ID: [u8; 16] = [ - 0x97, 0xAB, 0xCF, 0x5C, 0x45, 0x71, 0x11, 0xD6, 0xB6, 0xA0, 0x00, 0x30, 0x65, 0xA4, 0x7A, 0xEE, -]; - -/// kIOCFPlugInInterfaceID — base IOCFPlugin interface. -const K_IO_CFPLUGIN_INTERFACE_ID: [u8; 16] = [ - 0xC2, 0x44, 0xE8, 0x58, 0x10, 0x9C, 0x11, 0xD4, 0x91, 0xD4, 0x00, 0x50, 0xE4, 0xC6, 0x42, 0x6F, -]; - -/// kIOSCSITaskDeviceInterfaceID — the interface we QueryInterface for. -const K_IO_SCSI_TASK_DEVICE_INTERFACE_ID: [u8; 16] = [ - 0x61, 0x3E, 0x48, 0xB0, 0x30, 0x01, 0x11, 0xD6, 0xA4, 0xC0, 0x00, 0x0A, 0x27, 0x05, 0x28, 0x61, -]; - -// ── Scatter/gather element ────────────────────────────────────────────────── - -#[repr(C)] -struct SCSITaskSGElement { - address: u64, - length: u64, -} - -// ── External IOKit / CoreFoundation functions ─────────────────────────────── - -// Rust 2024: FFI blocks declaring extern fns must be `unsafe extern`. unsafe extern "C" { - fn IOMasterPort(bootstrap: u32, master: *mut MachPort) -> IOReturn; - fn IOBSDNameMatching( - master: MachPort, - options: u32, - bsd_name: *const u8, - ) -> CFMutableDictionaryRef; - fn IOServiceGetMatchingService(master: MachPort, matching: CFMutableDictionaryRef) -> IOObject; - fn IOObjectRelease(object: IOObject) -> IOReturn; - fn IORegistryEntryGetParentEntry( - entry: IOObject, - plane: *const u8, - parent: *mut IOObject, - ) -> IOReturn; - fn IOObjectConformsTo(object: IOObject, class_name: *const u8) -> u8; - fn IOCreatePlugInInterfaceForService( - service: IOObject, - plugin_type: *const [u8; 16], - interface_type: *const [u8; 16], - the_interface: *mut ComRef, - the_score: *mut i32, - ) -> IOReturn; + fn shim_open_exclusive(bsd_name: *const u8) -> i32; + fn shim_close(); + fn shim_execute( + cdb: *const u8, + cdb_len: u8, + buf: *mut u8, + buf_len: u32, + data_in: i32, + sense_out: *mut u8, + sense_len: u32, + task_status_out: *mut u8, + transfer_count: *mut u64, + ) -> i32; } -// ── COM vtable helpers ────────────────────────────────────────────────────── -// -// IOKit plugin interfaces use COM-style vtables. A ComRef is **vtable — -// dereferencing once gives the vtable pointer, then index into it for -// individual function pointers. -// -// All vtable indices verified against Apple open source: -// IOSCSIArchitectureModelFamily/UserClientLib/SCSITaskLib.h - -/// Read a function pointer from a COM vtable at the given index. -/// -/// # Safety -/// `iface` must be a valid COM interface pointer (*mut *mut c_void), and -/// `index` must be a valid vtable slot for the target type `T`. -unsafe fn vtable_fn(iface: ComRef, index: usize) -> T { - // Rust 2024: `unsafe fn` bodies are no longer implicitly unsafe. - // Each unsafe op needs its own `unsafe { }` block. - unsafe { - let vtable = *iface as *const *const std::ffi::c_void; - let fn_ptr = *vtable.add(index); - std::mem::transmute_copy(&fn_ptr) - } -} - -/// Call Release (vtable index 3) on any COM interface. -fn com_release(iface: ComRef) { - type Fn = unsafe extern "C" fn(ComRef) -> u32; - unsafe { - let f: Fn = vtable_fn(iface, 3); - f(iface); - } -} - -// ── SCSITaskDeviceInterface vtable ────────────────────────────────────────── -// -// Index Method -// 0 _reserved -// 1 QueryInterface -// 2 AddRef -// 3 Release -// 4 IsExclusiveAccessAvailable -// 5 AddCallbackDispatcherToRunLoop -// 6 RemoveCallbackDispatcherFromRunLoop -// 7 ObtainExclusiveAccess -// 8 ReleaseExclusiveAccess -// 9 CreateSCSITask - -const VTIDX_OBTAIN_EXCLUSIVE: usize = 7; -const VTIDX_RELEASE_EXCLUSIVE: usize = 8; -const VTIDX_CREATE_TASK: usize = 9; - -// ── SCSITaskInterface vtable ──────────────────────────────────────────────── -// -// Index Method -// 0 _reserved -// 1 QueryInterface -// 2 AddRef -// 3 Release -// 4 IsTaskActive -// 5 SetTaskAttribute -// 6 GetTaskAttribute -// 7 GetTaskState -// 8 SetCommandDescriptorBlock -// 9 GetCommandDescriptorBlockSize -// 10 GetCommandDescriptorBlock -// 11 SetScatterGatherEntries -// 12 SetTimeoutDuration -// 13 GetTimeoutDuration -// 14 SetTaskCompletionCallback -// 15 ExecuteTaskSync -// 16 ExecuteTaskAsync -// 17 AbortTask -// 18 GetSCSIServiceResponse -// 19 GetTaskStatus -// 20 GetRealizedDataTransferCount -// 21 GetAutoSenseData - -const VTIDX_SET_CDB: usize = 8; -const VTIDX_SET_SG: usize = 11; -const VTIDX_SET_TIMEOUT: usize = 12; -const VTIDX_EXECUTE_SYNC: usize = 15; - -// ── Transport implementation ──────────────────────────────────────────────── - pub struct MacScsiTransport { - device_iface: ComRef, - exclusive: bool, + _bsd_name: String, } -// IOKit COM interface pointers are Mach port references — safe to send between threads. unsafe impl Send for MacScsiTransport {} impl MacScsiTransport { @@ -186,7 +44,6 @@ impl MacScsiTransport { path: device.display().to_string(), })?; - // Strip /dev/ prefix to get BSD name (e.g. "disk2") let bsd_name = if let Some(rest) = dev_str.strip_prefix("/dev/r") { rest } else if let Some(rest) = dev_str.strip_prefix("/dev/") { @@ -195,88 +52,87 @@ impl MacScsiTransport { dev_str }; - let device_iface = Self::acquire_device_iface(bsd_name)?; + let mut bsd_c = bsd_name.as_bytes().to_vec(); + bsd_c.push(0); + + let rc = unsafe { shim_open_exclusive(bsd_c.as_ptr()) }; + if rc != 0 { + return Err(Error::DeviceNotFound { + path: bsd_name.to_string(), + }); + } Ok(MacScsiTransport { - device_iface, - exclusive: true, + _bsd_name: bsd_name.to_string(), }) } - - /// Resolve the BSD name → IOKit SCSITaskDeviceInterface with exclusive - /// access. Returns the COM ref the caller must release. - fn acquire_device_iface(bsd_name: &str) -> Result { - let service = find_scsi_service(bsd_name)?; - - // Create IOKit plugin for the MMC device - let mut plugin: ComRef = std::ptr::null_mut(); - let mut score: i32 = 0; - let kr = unsafe { - IOCreatePlugInInterfaceForService( - service, - &K_IO_MMC_DEVICE_USER_CLIENT_TYPE_ID, - &K_IO_CFPLUGIN_INTERFACE_ID, - &mut plugin, - &mut score, - ) - }; - unsafe { IOObjectRelease(service) }; - - if kr != K_IO_RETURN_SUCCESS || plugin.is_null() { - return Err(Error::IoKitPluginFailed { - path: bsd_name.to_string(), - kr: kr as u32, - }); - } - - // QueryInterface for SCSITaskDeviceInterface - let mut device_iface: ComRef = std::ptr::null_mut(); - let hr = unsafe { - type QiFn = unsafe extern "C" fn(ComRef, *const [u8; 16], *mut ComRef) -> i32; - let qi: QiFn = vtable_fn(plugin, 1); - qi( - plugin, - &K_IO_SCSI_TASK_DEVICE_INTERFACE_ID, - &mut device_iface, - ) - }; - com_release(plugin); - - if hr != 0 || device_iface.is_null() { - return Err(Error::ScsiInterfaceUnavailable { - path: bsd_name.to_string(), - }); - } - - // Obtain exclusive access - let kr = unsafe { - type Fn = unsafe extern "C" fn(ComRef) -> IOReturn; - let f: Fn = vtable_fn(device_iface, VTIDX_OBTAIN_EXCLUSIVE); - f(device_iface) - }; - if kr != K_IO_RETURN_SUCCESS { - com_release(device_iface); - return Err(Error::DeviceLocked { - path: bsd_name.to_string(), - kr: kr as u32, - }); - } - - Ok(device_iface) - } - - // `reset()` removed in 0.13.6 — see scsi/mod.rs for rationale. - // `try_recover()` removed in 0.13.20 — userspace handle-recovery on - // task failure was the same anti-pattern stripped from Linux SG_IO - // (see internal architecture audit, 2026-04-26). - // Errors bubble up; caller decides whether to reopen the Drive. } -/// Enumerate optical drives on macOS. Mirrors `drive::macos::find_drives` -/// (which iterates `/dev/disk0..15` + INQUIRY + filters peripheral -/// type 5). Same logic, exposed through the new `DriveInfo` shape so -/// callers — `list_drives()` in `scsi::mod` — never reach into -/// `crate::drive::macos`. +impl Drop for MacScsiTransport { + fn drop(&mut self) { + unsafe { shim_close() }; + } +} + +impl ScsiTransport for MacScsiTransport { + fn execute( + &mut self, + cdb: &[u8], + direction: DataDirection, + data: &mut [u8], + _timeout_ms: u32, + ) -> Result { + let data_in = match direction { + DataDirection::FromDevice => 1, + DataDirection::ToDevice => 0, + DataDirection::None => 0, + }; + + let mut sense = [0u8; K_SENSE_DATA_SIZE]; + let mut task_status: u8 = 0xFF; + let mut transfer_count: u64 = 0; + + let kr = unsafe { + shim_execute( + cdb.as_ptr(), + cdb.len() as u8, + data.as_mut_ptr(), + data.len() as u32, + data_in, + sense.as_mut_ptr(), + K_SENSE_DATA_SIZE as u32, + &mut task_status, + &mut transfer_count, + ) + }; + + if kr != 0 { + return Err(Error::ScsiError { + opcode: cdb[0], + status: super::SCSI_STATUS_TRANSPORT_FAILURE, + sense: None, + }); + } + + if task_status != 0 { + let parsed = super::parse_sense(&sense, K_SENSE_DATA_SIZE as u8); + return Err(Error::ScsiError { + opcode: cdb[0], + status: task_status, + sense: Some(parsed), + }); + } + + Ok(ScsiResult { + status: 0, + bytes_transferred: transfer_count as usize, + sense, + }) + } +} + +// ── Drive enumeration ──────────────────────────────────────────────────── + pub(super) fn list_drives() -> Vec { let mut out = Vec::new(); for i in 0..K_DEV_DISK_MAX { @@ -292,7 +148,6 @@ pub(super) fn list_drives() -> Vec { Ok(r) => r, Err(_) => continue, }; - // SCSI peripheral type field is the lower 5 bits of byte 0. if inquiry.raw.is_empty() || (inquiry.raw[K_INQUIRY_TYPE_BYTE] & K_INQUIRY_TYPE_MASK) != K_SCSI_TYPE_OPTICAL { @@ -308,24 +163,11 @@ pub(super) fn list_drives() -> Vec { out } -/// Maximum BSD disk index probed during enumeration. macOS assigns -/// `/dev/diskN` sequentially per attached storage device; 16 covers -/// any realistic homelab. const K_DEV_DISK_MAX: u8 = 16; - -/// SCSI INQUIRY response: peripheral device type lives in byte 0, -/// lower 5 bits. const K_INQUIRY_TYPE_BYTE: usize = 0; const K_INQUIRY_TYPE_MASK: u8 = 0x1F; - -/// SCSI peripheral type 5 = "CD-ROM device" (covers DVD, BD-ROM, BD-RE). const K_SCSI_TYPE_OPTICAL: u8 = 0x05; -/// TEST UNIT READY probe on macOS. Any non-"not ready" error bubbles up -/// to the caller — in-library wedge recovery was rolled back in 0.13.4 -/// after USB-layer resets failed to recover the LG BU40N on Linux; the -/// macOS impl mirrors that choice for symmetry. See the Linux -/// `drive_has_disc` in `scsi/linux.rs` for the full rationale. pub(super) fn drive_has_disc(path: &Path) -> Result { let mut transport = MacScsiTransport::open(path)?; let cdb = [crate::scsi::SCSI_TEST_UNIT_READY, 0, 0, 0, 0, 0]; @@ -341,235 +183,3 @@ pub(super) fn drive_has_disc(path: &Path) -> Result { Err(e) => Err(e), } } - -impl Drop for MacScsiTransport { - fn drop(&mut self) { - if self.device_iface.is_null() { - return; - } - if self.exclusive { - unsafe { - type Fn = unsafe extern "C" fn(ComRef) -> IOReturn; - let f: Fn = vtable_fn(self.device_iface, VTIDX_RELEASE_EXCLUSIVE); - f(self.device_iface); - } - } - com_release(self.device_iface); - } -} - -impl ScsiTransport for MacScsiTransport { - fn execute( - &mut self, - cdb: &[u8], - direction: DataDirection, - data: &mut [u8], - timeout_ms: u32, - ) -> Result { - // Create a SCSI task - let task: ComRef = unsafe { - type Fn = unsafe extern "C" fn(ComRef) -> ComRef; - let f: Fn = vtable_fn(self.device_iface, VTIDX_CREATE_TASK); - f(self.device_iface) - }; - if task.is_null() { - return Err(Error::ScsiError { - opcode: cdb[0], - status: super::SCSI_STATUS_TRANSPORT_FAILURE, - sense: None, - }); - } - - // Set CDB - let mut cdb_padded = [0u8; K_MAX_CDB_SIZE]; - let cdb_len = cdb.len().min(K_MAX_CDB_SIZE); - cdb_padded[..cdb_len].copy_from_slice(&cdb[..cdb_len]); - unsafe { - type Fn = unsafe extern "C" fn(ComRef, *const u8, u8) -> IOReturn; - let f: Fn = vtable_fn(task, VTIDX_SET_CDB); - f(task, cdb_padded.as_ptr(), cdb_len as u8); - } - - // Set scatter/gather and transfer direction - let iokit_dir = match direction { - DataDirection::None => K_SCSI_DATA_TRANSFER_NO_DATA, - DataDirection::FromDevice => K_SCSI_DATA_TRANSFER_FROM_TARGET, - DataDirection::ToDevice => K_SCSI_DATA_TRANSFER_TO_TARGET, - }; - - if direction != DataDirection::None && !data.is_empty() { - let sg = SCSITaskSGElement { - address: data.as_mut_ptr() as u64, - length: data.len() as u64, - }; - unsafe { - type Fn = - unsafe extern "C" fn(ComRef, *const SCSITaskSGElement, u8, u64, u8) -> IOReturn; - let f: Fn = vtable_fn(task, VTIDX_SET_SG); - f(task, &sg, 1, data.len() as u64, iokit_dir); - } - } else { - unsafe { - type Fn = - unsafe extern "C" fn(ComRef, *const SCSITaskSGElement, u8, u64, u8) -> IOReturn; - let f: Fn = vtable_fn(task, VTIDX_SET_SG); - f(task, std::ptr::null(), 0, 0, K_SCSI_DATA_TRANSFER_NO_DATA); - } - } - - // Set timeout (IOKit SCSITask takes milliseconds) - unsafe { - type Fn = unsafe extern "C" fn(ComRef, u32); - let f: Fn = vtable_fn(task, VTIDX_SET_TIMEOUT); - f(task, timeout_ms); - } - - // Execute synchronously - let mut sense = [0u8; K_SENSE_DATA_SIZE]; - let mut task_status: u32 = 0; - let mut realized_count: u64 = 0; - - let kr = unsafe { - type Fn = unsafe extern "C" fn(ComRef, *mut u8, *mut u32, *mut u64) -> IOReturn; - let f: Fn = vtable_fn(task, VTIDX_EXECUTE_SYNC); - f( - task, - sense.as_mut_ptr(), - &mut task_status, - &mut realized_count, - ) - }; - - com_release(task); - - if kr != K_IO_RETURN_SUCCESS { - // Task-level failure (timeout / IOKit error). Bubble it up; - // the kernel mid-layer has already done what it can. - return Err(Error::ScsiError { - opcode: cdb[0], - status: super::SCSI_STATUS_TRANSPORT_FAILURE, - sense: None, - }); - } - - if task_status != K_SCSI_TASK_STATUS_GOOD as u32 { - // IOKit doesn't surface a "bytes written into sense buffer" - // count the way SG_IO does — pass the buffer's full length - // and let parse_sense inspect byte 0's response code to pick - // descriptor-vs-fixed format. - // - // 0.13.23: carry the full SPC-4 sense triple in - // `Error::ScsiError::sense` so callers can route on - // `ScsiSense::is_medium_error()` etc. - let parsed = super::parse_sense(&sense, sense.len() as u8); - return Err(Error::ScsiError { - opcode: cdb[0], - status: task_status as u8, - sense: Some(parsed), - }); - } - - Ok(ScsiResult { - status: task_status as u8, - bytes_transferred: realized_count as usize, - sense, - }) - } -} - -// ── IOKit service discovery ───────────────────────────────────────────────── - -/// BSD name → IOKit service for the SCSI device. -/// -/// Walk: IOMedia (BSD name match) → parent chain → SCSIPeripheralDeviceNub. -/// -/// All failure paths surface as `Error::DeviceNotFound { path: bsd_name }` — -/// the four internal stages (IOMasterPort / IOBSDNameMatching / IOMedia -/// lookup / walk_to_authoring_device) collapse into one observable error -/// because none of them are user-actionable individually. Pre-0.13 each -/// stage stuffed an English description into `path:` ("…IOMasterPort -/// failed", "…SCSITaskDeviceInterface not available", etc.) which broke -/// the library's "no English text" rule. -fn find_scsi_service(bsd_name: &str) -> Result { - let not_found = || Error::DeviceNotFound { - path: bsd_name.to_string(), - }; - - let mut master: MachPort = 0; - let kr = unsafe { IOMasterPort(0, &mut master) }; - if kr != K_IO_RETURN_SUCCESS { - return Err(not_found()); - } - - // IOBSDNameMatching creates a dictionary matching { "BSD Name" = bsd_name } - let mut bsd_c = bsd_name.as_bytes().to_vec(); - bsd_c.push(0); - let matching = unsafe { IOBSDNameMatching(master, 0, bsd_c.as_ptr()) }; - if matching.is_null() { - return Err(not_found()); - } - - // Find the single IOMedia service (consumes the matching dict) - let media = unsafe { IOServiceGetMatchingService(master, matching) }; - if media == 0 { - return Err(not_found()); - } - - // Walk up the IOService plane to find the authoring device. - // The chain is typically: - // IOMedia → IOPartitionScheme → IOMedia → IOBlockStorageDriver - // → IOSCSIPeripheralDeviceNub (this is what we want) - // - // We walk up until we find a service that IOCreatePlugInInterfaceForService - // accepts with kIOMMCDeviceUserClientTypeID, or until we hit the root. - let service = walk_to_authoring_device(media); - unsafe { IOObjectRelease(media) }; - - service.ok_or_else(not_found) -} - -/// Walk up the IOService plane from an IOMedia to the SCSI authoring device. -fn walk_to_authoring_device(start: IOObject) -> Option { - let mut current = start; - // Retain start so we can release uniformly in the loop - // (IORegistryEntryGetParentEntry retains the parent for us) - - // Target class names for authoring devices - let target_classes: &[&[u8]] = &[ - b"IOSCSIPeripheralDeviceNub\0", - b"IOBDBlockStorageDevice\0", - b"IODVDBlockStorageDevice\0", - b"IOCDBlockStorageDevice\0", - b"IOBlockStorageDevice\0", - ]; - - // Walk up to 10 levels (more than enough) - for _ in 0..10 { - let mut parent: IOObject = 0; - let kr = unsafe { - IORegistryEntryGetParentEntry(current, c"IOService".as_ptr() as *const u8, &mut parent) - }; - - if current != start { - unsafe { IOObjectRelease(current) }; - } - - if kr != K_IO_RETURN_SUCCESS || parent == 0 { - return None; - } - - // Check if this parent matches any of our target classes - for class in target_classes { - if unsafe { IOObjectConformsTo(parent, class.as_ptr()) } != 0 { - return Some(parent); - } - } - - current = parent; - } - - if current != start { - unsafe { IOObjectRelease(current) }; - } - None -} diff --git a/src/scsi/macos_shim.c b/src/scsi/macos_shim.c new file mode 100644 index 0000000..485e663 --- /dev/null +++ b/src/scsi/macos_shim.c @@ -0,0 +1,142 @@ +#include +#include +#include +#include +#include +#include + +typedef struct { + IOCFPlugInInterface **plugin; + MMCDeviceInterface **mmc; + SCSITaskDeviceInterface **scsi; + int exclusive; +} ShimHandle; + +static ShimHandle g_handle = {NULL, NULL, NULL, 0}; + +int shim_open_exclusive(const char *bsd_name) { + kern_return_t kr; + HRESULT hr; + SInt32 score = 0; + + if (g_handle.exclusive && g_handle.scsi) { + return 0; + } + + char cmd[128]; + snprintf(cmd, sizeof(cmd), "diskutil unmountDisk force %s 2>/dev/null", bsd_name); + system(cmd); + usleep(500000); + + mach_port_t mp; + IOMainPort(0, &mp); + + CFMutableDictionaryRef matching = IOServiceMatching("IOBDServices"); + io_service_t svc = IOServiceGetMatchingService(mp, matching); + if (!svc) return -1; + + kr = IOCreatePlugInInterfaceForService(svc, + kIOMMCDeviceUserClientTypeID, kIOCFPlugInInterfaceID, + &g_handle.plugin, &score); + IOObjectRelease(svc); + + if (kr != KERN_SUCCESS || !g_handle.plugin) return -2; + + hr = (*g_handle.plugin)->QueryInterface(g_handle.plugin, + CFUUIDGetUUIDBytes(kIOMMCDeviceInterfaceID), (LPVOID *)&g_handle.mmc); + if (hr != S_OK || !g_handle.mmc) { + IODestroyPlugInInterface(g_handle.plugin); + g_handle.plugin = NULL; + return -3; + } + + g_handle.scsi = (*g_handle.mmc)->GetSCSITaskDeviceInterface(g_handle.mmc); + if (!g_handle.scsi) { + (*g_handle.mmc)->Release(g_handle.mmc); + IODestroyPlugInInterface(g_handle.plugin); + g_handle.mmc = NULL; + g_handle.plugin = NULL; + return -4; + } + + kr = (*g_handle.scsi)->ObtainExclusiveAccess(g_handle.scsi); + if (kr != kIOReturnSuccess) { + (*g_handle.scsi)->Release(g_handle.scsi); + (*g_handle.mmc)->Release(g_handle.mmc); + IODestroyPlugInInterface(g_handle.plugin); + g_handle.scsi = NULL; + g_handle.mmc = NULL; + g_handle.plugin = NULL; + return -5; + } + + g_handle.exclusive = 1; + return 0; +} + +void shim_close(void) { + if (g_handle.exclusive && g_handle.scsi) { + (*g_handle.scsi)->ReleaseExclusiveAccess(g_handle.scsi); + } + if (g_handle.scsi) { + (*g_handle.scsi)->Release(g_handle.scsi); + g_handle.scsi = NULL; + } + if (g_handle.mmc) { + (*g_handle.mmc)->Release(g_handle.mmc); + g_handle.mmc = NULL; + } + if (g_handle.plugin) { + IODestroyPlugInInterface(g_handle.plugin); + g_handle.plugin = NULL; + } + g_handle.exclusive = 0; +} + +int shim_execute(const unsigned char *cdb, unsigned char cdb_len, + void *buf, unsigned int buf_len, int data_in, + unsigned char *sense_out, unsigned int sense_len, + unsigned char *task_status_out, unsigned long long *transfer_count) { + if (!g_handle.scsi) return -1; + + SCSITaskInterface **task = (*g_handle.scsi)->CreateSCSITask(g_handle.scsi); + if (!task) return -2; + + SCSICommandDescriptorBlock cdb_buf; + memset(&cdb_buf, 0, sizeof(cdb_buf)); + memcpy(&cdb_buf, cdb, cdb_len); + + (*task)->SetCommandDescriptorBlock(task, cdb_buf, cdb_len); + + if (buf_len > 0 && buf) { + SCSITaskSGElement sg; + sg.address = (UInt64)(uintptr_t)buf; + sg.length = buf_len; + (*task)->SetScatterGatherEntries(task, &sg, 1, buf_len, + data_in ? kSCSIDataTransfer_FromTargetToInitiator + : kSCSIDataTransfer_FromInitiatorToTarget); + } else { + (*task)->SetScatterGatherEntries(task, NULL, 0, 0, + kSCSIDataTransfer_NoDataTransfer); + } + + (*task)->SetTimeoutDuration(task, 30000); + + SCSI_Sense_Data sense; + memset(&sense, 0, sizeof(sense)); + SCSITaskStatus status = 0xFF; + UInt64 count = 0; + + IOReturn kr = (*task)->ExecuteTaskSync(task, &sense, &status, &count); + + if (sense_out && sense_len > 0) { + size_t copy = sense_len < sizeof(sense) ? sense_len : sizeof(sense); + memcpy(sense_out, &sense, copy); + } + if (task_status_out) *task_status_out = (unsigned char)status; + if (transfer_count) *transfer_count = count; + + (*task)->Release(task); + + return (int)kr; +} diff --git a/src/scsi/mod.rs b/src/scsi/mod.rs index d2250c1..3199d42 100644 --- a/src/scsi/mod.rs +++ b/src/scsi/mod.rs @@ -2,7 +2,7 @@ //! //! Platform backends are in separate files: //! - `linux.rs` — SG_IO ioctl -//! - `macos.rs` — IOKit SCSITaskDeviceInterface +//! - `macos.rs` — IOKit SCSITaskDeviceInterface (exclusive access) //! - `windows.rs` — SPTI (SCSI Pass-Through Interface) #[cfg(target_os = "linux")] diff --git a/tests/integration_progress_and_halt.rs b/tests/integration_progress_and_halt.rs index 7309a0d..d02d931 100644 --- a/tests/integration_progress_and_halt.rs +++ b/tests/integration_progress_and_halt.rs @@ -439,7 +439,7 @@ fn test_disc_copy_completes_full_disc_with_failing_reader() { let opts = CopyOptions { decrypt: false, skip_on_error: true, - + error_pause_ms: Some(0), ..Default::default() }; @@ -454,9 +454,7 @@ fn test_disc_copy_completes_full_disc_with_failing_reader() { let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path)); // Hard bound — even at 0 ms per read, 1024 sectors with skip-forward - // should complete in well under a second on any host. If this test runs - // for minutes, something has regressed (e.g. stall guard reintroduced - // with infinite-loop semantics, or Pass 1 is hanging on each read). + // should complete in well under a second on any host. assert!( elapsed < Duration::from_secs(5), "Pass 1 took {elapsed:?} on a 2 MB synthetic disc — expected < 5 s" @@ -521,7 +519,7 @@ fn test_disc_copy_halts_promptly_on_failing_reader() { let opts = CopyOptions { decrypt: false, skip_on_error: true, - + error_pause_ms: Some(0), halt: Some(halt), ..Default::default() }; @@ -609,6 +607,7 @@ fn test_disc_copy_marks_failed_ecc_blocks_as_nontrimmed() { let opts = CopyOptions { decrypt: false, skip_on_error: true, + error_pause_ms: Some(0), ..Default::default() }; @@ -619,14 +618,20 @@ fn test_disc_copy_marks_failed_ecc_blocks_as_nontrimmed() { let _ = std::fs::remove_file(&iso_path); let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path)); - assert_eq!( - result.bytes_good, 0, - "Pass 1 should have 0 bytes_good when all batch reads fail. Got {} of {}", - result.bytes_good, total_bytes + // With graduated batch restore, Pass 1 drops to batch=1 after the + // first batch=32 failure, reads individually (count=1 succeeds for + // BlockSizeFailingReader), and recovers those sectors. After 200 OK + // at batch=1, it tries batch=2 which fails again. Net result: most + // sectors are recovered (Finished), only the batch>1 failures produce + // NonTrimmed blocks. + assert!( + result.bytes_good > 0, + "Pass 1 should recover batch=1-readable sectors. Got bytes_good={}", + result.bytes_good ); assert!( result.bytes_pending > 0, - "all sectors should be NonTrimmed pending Pass 2" + "batch>1 failures should produce NonTrimmed pending Pass 2" ); assert!( !result.complete,