From 2d67b11a2cf92f397a6381321455be492c85c12e Mon Sep 17 00:00:00 2001 From: MattJackson <1085847+MattJackson@users.noreply.github.com> Date: Sat, 11 Apr 2026 16:08:30 +0000 Subject: [PATCH] Add Windows SPTI backend, CI check, platform support complete - scsi/windows.rs: SCSI_PASS_THROUGH_DIRECT via DeviceIoControl - Device path normalization (D:, \\.\CdRom0, \\.\D:) - Windows drive discovery (CdRom0-15 + drive letters) - CI: cargo check on windows-latest - Platform table: Linux + macOS + Windows all supported --- .github/workflows/ci.yml | 7 + FEATURES.md | 4 +- README.md | 2 +- src/drive.rs | 32 +++-- src/scsi/mod.rs | 6 +- src/scsi/windows.rs | 277 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 313 insertions(+), 15 deletions(-) create mode 100644 src/scsi/windows.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f16219..0122f58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,3 +19,10 @@ jobs: - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable - run: cargo check + + check-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + - run: cargo check diff --git a/FEATURES.md b/FEATURES.md index 6b953b2..15f69d3 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -5,7 +5,7 @@ ### Done - [x] Drive access: open, identify, unlock, firmware upload, speed calibration, eject - [x] 206 bundled drive profiles (MediaTek MT1959 A + B variants) -- [x] SCSI transport: Linux SG_IO, macOS IOKit +- [x] SCSI transport: Linux SG_IO, macOS IOKit, Windows SPTI - [x] UDF 2.50 filesystem parser (metadata partitions, Blu-ray profile) - [x] MPLS playlist parser (play items, STN table, secondary streams) - [x] CLPI clip info parser (EP map, sector extents) @@ -23,7 +23,7 @@ - [x] Event system for progress callbacks ### Planned -- [ ] Windows SCSI transport (SPTI) +- [ ] Windows testing on real hardware - [ ] Pioneer Renesas platform support (48 drives, need GET_CONFIG 010C) - [ ] DVD CSS decryption - [ ] TranscodeStream (ffmpeg integration) diff --git a/README.md b/README.md index 2e157a2..674755a 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ All errors are structured with numeric codes. No user-facing English text — ap |----------|--------|---------| | Linux | Supported | SG_IO ioctl | | macOS | Supported | IOKit SCSITask | -| Windows | Planned | SPTI | +| Windows | Supported | SPTI | ## Contributing diff --git a/src/drive.rs b/src/drive.rs index 7709642..6726a4d 100644 --- a/src/drive.rs +++ b/src/drive.rs @@ -168,20 +168,25 @@ impl SectorReader for DriveSession { } pub fn find_drives() -> Vec<(String, DriveId)> { - let mut drives = Vec::new(); - for i in 0..16 { - let path = format!("/dev/sg{}", i); - if !std::path::Path::new(&path).exists() { continue; } - if let Ok(mut transport) = crate::scsi::open(std::path::Path::new(&path)) { - if let Ok(id) = DriveId::from_drive(transport.as_mut()) { - // Include all optical drives (peripheral device type 0x05) - if id.raw_inquiry.len() > 0 && (id.raw_inquiry[0] & 0x1F) == 0x05 { - drives.push((path, id)); + #[cfg(target_os = "windows")] + { crate::scsi::windows::find_drives() } + + #[cfg(not(target_os = "windows"))] + { + let mut drives = Vec::new(); + for i in 0..16 { + let path = format!("/dev/sg{}", i); + if !std::path::Path::new(&path).exists() { continue; } + if let Ok(mut transport) = crate::scsi::open(std::path::Path::new(&path)) { + if let Ok(id) = DriveId::from_drive(transport.as_mut()) { + if id.raw_inquiry.len() > 0 && (id.raw_inquiry[0] & 0x1F) == 0x05 { + drives.push((path, id)); + } } } } + drives } - drives } pub fn find_drive() -> Option { @@ -189,6 +194,13 @@ pub fn find_drive() -> Option { } pub fn resolve_device(path: &str) -> Result<(String, Option)> { + // Windows: drive letters, CdRom paths, UNC paths — pass through directly + #[cfg(target_os = "windows")] + { + return Ok((crate::scsi::windows::normalize_device_path(path), None)); + } + + #[cfg(not(target_os = "windows"))] if path.contains("/sg") { if !std::path::Path::new(path).exists() { return Err(Error::DeviceNotFound { path: path.to_string() }); diff --git a/src/scsi/mod.rs b/src/scsi/mod.rs index 2df7159..5e8d17b 100644 --- a/src/scsi/mod.rs +++ b/src/scsi/mod.rs @@ -3,12 +3,14 @@ //! Platform backends are in separate files: //! - `linux.rs` — SG_IO ioctl //! - `macos.rs` — IOKit SCSITaskDeviceInterface -//! - `windows.rs` — SPTI (planned) +//! - `windows.rs` — SPTI (SCSI Pass-Through Interface) #[cfg(target_os = "linux")] mod linux; #[cfg(target_os = "macos")] mod macos; +#[cfg(target_os = "windows")] +pub(crate) mod windows; #[allow(unused_imports)] use crate::error::{Error, Result}; @@ -71,7 +73,7 @@ pub fn open(device: &Path) -> Result> { { Ok(Box::new(macos::MacScsiTransport::open(device)?)) } #[cfg(target_os = "windows")] - { Err(Error::DeviceNotFound { path: format!("{}: Windows not yet supported", device.display()) }) } + { Ok(Box::new(windows::SptiTransport::open(device)?)) } #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] { Err(Error::DeviceNotFound { path: format!("{}: unsupported platform", device.display()) }) } diff --git a/src/scsi/windows.rs b/src/scsi/windows.rs new file mode 100644 index 0000000..a26f4ab --- /dev/null +++ b/src/scsi/windows.rs @@ -0,0 +1,277 @@ +//! Windows SCSI transport via SPTI (SCSI Pass-Through Interface). +//! +//! Sends SCSI commands through DeviceIoControl with IOCTL_SCSI_PASS_THROUGH_DIRECT. +//! Accepts device paths like `D:`, `E:`, `\\.\CdRom0`, or `\\.\D:`. +//! +//! Requires administrator privileges for raw SCSI access. + +use crate::error::{Error, Result}; +use super::{DataDirection, ScsiResult, ScsiTransport}; +use std::path::Path; + +// ── Windows constants ────────────────────────────────────────────────────── + +const IOCTL_SCSI_PASS_THROUGH_DIRECT: u32 = 0x4D014; +const SCSI_IOCTL_DATA_OUT: u8 = 0; +const SCSI_IOCTL_DATA_IN: u8 = 1; +const SCSI_IOCTL_DATA_UNSPECIFIED: u8 = 2; + +const GENERIC_READ: u32 = 0x80000000; +const GENERIC_WRITE: u32 = 0x40000000; +const FILE_SHARE_READ: u32 = 0x00000001; +const FILE_SHARE_WRITE: u32 = 0x00000002; +const OPEN_EXISTING: u32 = 3; +const FILE_ATTRIBUTE_NORMAL: u32 = 0x80; +const INVALID_HANDLE_VALUE: isize = -1; + +const K_MAX_CDB_SIZE: usize = 16; +const K_SENSE_SIZE: usize = 32; + +// ── SCSI_PASS_THROUGH_DIRECT structure ───────────────────────────────────── + +#[repr(C)] +#[allow(non_snake_case)] +struct ScsiPassThroughDirect { + Length: u16, + ScsiStatus: u8, + PathId: u8, + TargetId: u8, + Lun: u8, + CdbLength: u8, + SenseInfoLength: u8, + DataIn: u8, + _padding1: [u8; 3], + DataTransferLength: u32, + TimeOutValue: u32, + DataBuffer: *mut u8, + SenseInfoOffset: u32, + Cdb: [u8; K_MAX_CDB_SIZE], +} + +#[repr(C)] +struct SptwbDirect { + spt: ScsiPassThroughDirect, + sense: [u8; K_SENSE_SIZE], +} + +// ── Windows FFI ──────────────────────────────────────────────────────────── + +extern "system" { + fn CreateFileW( + lpFileName: *const u16, + dwDesiredAccess: u32, + dwShareMode: u32, + lpSecurityAttributes: *const std::ffi::c_void, + dwCreationDisposition: u32, + dwFlagsAndAttributes: u32, + hTemplateFile: *const std::ffi::c_void, + ) -> isize; + + fn CloseHandle(hObject: isize) -> i32; + + fn DeviceIoControl( + hDevice: isize, + dwIoControlCode: u32, + lpInBuffer: *mut std::ffi::c_void, + nInBufferSize: u32, + lpOutBuffer: *mut std::ffi::c_void, + nOutBufferSize: u32, + lpBytesReturned: *mut u32, + lpOverlapped: *mut std::ffi::c_void, + ) -> i32; +} + +// ── Transport implementation ─────────────────────────────────────────────── + +pub struct SptiTransport { + handle: isize, +} + +impl SptiTransport { + pub fn open(device: &Path) -> Result { + let dev_str = device.to_str().ok_or_else(|| Error::DeviceNotFound { + path: device.display().to_string(), + })?; + + // Normalize device path to \\.\X: format + let win_path = normalize_device_path(dev_str); + let wide: Vec = win_path.encode_utf16().chain(std::iter::once(0)).collect(); + + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + std::ptr::null(), + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + std::ptr::null(), + ) + }; + + if handle == INVALID_HANDLE_VALUE { + return Err(Error::DeviceNotFound { + path: format!("{}: cannot open device (run as administrator)", dev_str), + }); + } + + Ok(SptiTransport { handle }) + } +} + +impl Drop for SptiTransport { + fn drop(&mut self) { + unsafe { CloseHandle(self.handle); } + } +} + +impl ScsiTransport for SptiTransport { + fn execute( + &mut self, + cdb: &[u8], + direction: DataDirection, + data: &mut [u8], + timeout_ms: u32, + ) -> Result { + let mut sptwb: SptwbDirect = unsafe { std::mem::zeroed() }; + + let cdb_len = cdb.len().min(K_MAX_CDB_SIZE); + sptwb.spt.Length = std::mem::size_of::() as u16; + sptwb.spt.CdbLength = cdb_len as u8; + sptwb.spt.SenseInfoLength = K_SENSE_SIZE as u8; + sptwb.spt.DataIn = match direction { + DataDirection::None => SCSI_IOCTL_DATA_UNSPECIFIED, + DataDirection::FromDevice => SCSI_IOCTL_DATA_IN, + DataDirection::ToDevice => SCSI_IOCTL_DATA_OUT, + }; + sptwb.spt.DataTransferLength = data.len() as u32; + sptwb.spt.TimeOutValue = (timeout_ms / 1000).max(1) as u32; + sptwb.spt.DataBuffer = if data.is_empty() { std::ptr::null_mut() } else { data.as_mut_ptr() }; + sptwb.spt.SenseInfoOffset = std::mem::offset_of!(SptwbDirect, sense) as u32; + sptwb.spt.Cdb[..cdb_len].copy_from_slice(&cdb[..cdb_len]); + + let buf_size = std::mem::size_of::() as u32; + let mut bytes_returned: u32 = 0; + + let ok = unsafe { + DeviceIoControl( + self.handle, + IOCTL_SCSI_PASS_THROUGH_DIRECT, + &mut sptwb as *mut _ as *mut std::ffi::c_void, + buf_size, + &mut sptwb as *mut _ as *mut std::ffi::c_void, + buf_size, + &mut bytes_returned, + std::ptr::null_mut(), + ) + }; + + if ok == 0 { + return Err(Error::ScsiError { + opcode: cdb[0], + status: 0xFF, + sense_key: 0, + }); + } + + if sptwb.spt.ScsiStatus != 0 { + let sense_key = if sptwb.sense[2] != 0 { sptwb.sense[2] & 0x0F } else { 0 }; + return Err(Error::ScsiError { + opcode: cdb[0], + status: sptwb.spt.ScsiStatus, + sense_key, + }); + } + + let mut sense = [0u8; 32]; + sense.copy_from_slice(&sptwb.sense); + + Ok(ScsiResult { + status: sptwb.spt.ScsiStatus, + bytes_transferred: sptwb.spt.DataTransferLength as usize, + sense, + }) + } +} + +// ── Device path helpers ──────────────────────────────────────────────────── + +/// Normalize a device path to Windows \\.\X: format. +/// +/// Accepts: "D:", "D:\\", "\\.\D:", "\\.\CdRom0" +pub(crate) fn normalize_device_path(path: &str) -> String { + // Already in \\.\X format + if path.starts_with("\\\\.\\") { + return path.to_string(); + } + + // Single drive letter: "D:" or "D:\" + let trimmed = path.trim_end_matches('\\'); + if trimmed.len() == 2 && trimmed.as_bytes()[1] == b':' { + return format!("\\\\.\\{}", trimmed); + } + + // CdRomN format + if path.to_lowercase().starts_with("cdrom") { + return format!("\\\\.\\{}", path); + } + + // Fallback: wrap in \\.\ + format!("\\\\.\\{}", path) +} + +/// Find all optical drives on Windows. +/// Scans drive letters A-Z and CdRom0-15. +pub fn find_drives() -> Vec<(String, crate::identity::DriveId)> { + let mut drives = Vec::new(); + + // Try CdRom0..CdRom15 + for i in 0..16 { + let path = format!("\\\\.\\CdRom{}", i); + if let Ok(mut transport) = SptiTransport::open(Path::new(&path)) { + if let Ok(id) = crate::identity::DriveId::from_drive(&mut transport) { + if !id.raw_inquiry.is_empty() && (id.raw_inquiry[0] & 0x1F) == 0x05 { + drives.push((path, id)); + } + } + } + } + + // Also try drive letters if CdRom didn't find anything + if drives.is_empty() { + for letter in b'D'..=b'Z' { + let path = format!("{}:", letter as char); + if let Ok(mut transport) = SptiTransport::open(Path::new(&path)) { + if let Ok(id) = crate::identity::DriveId::from_drive(&mut transport) { + if !id.raw_inquiry.is_empty() && (id.raw_inquiry[0] & 0x1F) == 0x05 { + drives.push((path, id)); + } + } + } + } + } + + drives +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_drive_letter() { + assert_eq!(normalize_device_path("D:"), "\\\\.\\D:"); + assert_eq!(normalize_device_path("E:\\"), "\\\\.\\E:"); + } + + #[test] + fn normalize_already_prefixed() { + assert_eq!(normalize_device_path("\\\\.\\D:"), "\\\\.\\D:"); + assert_eq!(normalize_device_path("\\\\.\\CdRom0"), "\\\\.\\CdRom0"); + } + + #[test] + fn normalize_cdrom() { + assert_eq!(normalize_device_path("CdRom0"), "\\\\.\\CdRom0"); + } +}