//! Drive identification — match drives to profiles by SCSI response fields. //! //! Field names follow SPC-4 (INQUIRY) and MMC-6 (GET CONFIGURATION) standards. //! No proprietary fingerprints or encrypted lookups — open matching only. //! //! References: //! SPC-4 §6.4.2 — Standard INQUIRY data //! MMC-6 §5.3.10 — Feature 010Ch (Firmware Information) use crate::error::Result; use crate::scsi::{DataDirection, ScsiTransport}; /// Drive identity from standard SCSI commands. /// /// All field names follow the SCSI standards: /// - SPC-4 §6.4.2 for INQUIRY fields /// - MMC-6 §5.3.10 for Firmware Information #[derive(Debug, Clone)] pub struct DriveId { /// T10 VENDOR IDENTIFICATION — INQUIRY bytes [8:16] /// SPC-4 §6.4.2 pub vendor_id: String, /// PRODUCT IDENTIFICATION — INQUIRY bytes [16:32] /// SPC-4 §6.4.2 pub product_id: String, /// PRODUCT REVISION LEVEL — INQUIRY bytes [32:36] /// SPC-4 §6.4.2 pub product_revision: String, /// VENDOR SPECIFIC — INQUIRY bytes [36:43] /// SPC-4 §6.4.2 /// Content varies by vendor: firmware type code (MTK), date (Pioneer), etc. pub vendor_specific: String, /// Firmware Creation Date — GET CONFIGURATION Feature 010Ch /// MMC-6 §5.3.10 /// Format: CCYYMMDDHHMI (12 ASCII characters) pub firmware_date: String, /// Drive serial number — GET CONFIGURATION Feature 0108h pub serial_number: String, /// Raw 96-byte INQUIRY response for additional parsing if needed. pub raw_inquiry: Vec, /// Raw GET CONFIGURATION Feature 010Ch response bytes. pub raw_gc_010c: Vec, } impl DriveId { /// Probe a real drive via SCSI and build its identity. pub fn from_drive(transport: &mut dyn ScsiTransport) -> Result { // INQUIRY — SPC-4 §6.4 let mut inquiry = vec![0u8; 96]; let cdb_inq = [0x12, 0x00, 0x00, 0x00, 0x60, 0x00]; transport.execute(&cdb_inq, DataDirection::FromDevice, &mut inquiry, 5000)?; // GET CONFIGURATION Feature 010Ch — MMC-6 §6.6. // Best-effort: 010Ch (Firmware Information) is an optional feature. // A drive that lacks it may CHECK CONDITION rather than return an // empty descriptor, so a failure here is treated as feature-absent // (empty firmware date + empty raw bytes) instead of aborting the // whole identity probe. let mut gc = vec![0u8; 256]; let cdb_gc = [0x46, 0x02, 0x01, 0x0C, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00]; // `bytes_transferred` is device-reported and untrusted; clamp every // slice end to the actual buffer length before indexing. let (firmware_date, raw_gc_010c) = match transport.execute(&cdb_gc, DataDirection::FromDevice, &mut gc, 5000) { Ok(result) => { let end = result.bytes_transferred.min(gc.len()); let date = if end > 12 { String::from_utf8_lossy(&gc[12..24.min(end)]) .trim() .to_string() } else { String::new() }; (date, gc[..end].to_vec()) } Err(_) => (String::new(), Vec::new()), }; // GET CONFIGURATION Feature 0108h — Serial Number. // Best-effort, like 010Ch above: the serial-number feature is // optional, so a drive that lacks it (CHECK CONDITION) or reports // too few bytes deliberately yields an empty serial rather than // failing the identity probe. let mut gc_serial = vec![0u8; 256]; let cdb_serial = [0x46, 0x02, 0x01, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00]; let serial_number = if let Ok(r) = transport.execute(&cdb_serial, DataDirection::FromDevice, &mut gc_serial, 5000) { if r.bytes_transferred > 12 { // `bytes_transferred` is device-reported and untrusted; clamp // the slice end to the buffer length to avoid an out-of-range // panic on an oversized reported count. let end = r.bytes_transferred.min(gc_serial.len()); String::from_utf8_lossy(&gc_serial[12..end]) .trim() .to_string() } else { String::new() } } else { String::new() }; Ok(DriveId { vendor_id: ascii_field(&inquiry, 8, 16), product_id: ascii_field(&inquiry, 16, 32), product_revision: ascii_field(&inquiry, 32, 36), vendor_specific: ascii_field(&inquiry, 36, 43), firmware_date, serial_number, raw_inquiry: inquiry, raw_gc_010c, }) } /// Build identity from raw INQUIRY bytes and firmware date string. /// Used by tests and when serial isn't available. pub fn from_inquiry(inquiry: &[u8], firmware_date: &str) -> Self { DriveId { vendor_id: ascii_field(inquiry, 8, 16), product_id: ascii_field(inquiry, 16, 32), product_revision: ascii_field(inquiry, 32, 36), vendor_specific: ascii_field(inquiry, 36, 43), firmware_date: firmware_date.to_string(), serial_number: String::new(), raw_inquiry: inquiry.to_vec(), raw_gc_010c: Vec::new(), } } /// Profile match key: "VENDOR|PRODUCT|REVISION|VENDOR_SPECIFIC" /// /// Used to look up this drive in the profile database. /// All fields trimmed for consistent matching. pub fn match_key(&self) -> String { format!( "{}|{}|{}|{}", self.vendor_id.trim(), self.product_id.trim(), self.product_revision.trim(), self.vendor_specific.trim() ) } } impl std::fmt::Display for DriveId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "{} {} {} {}", self.vendor_id.trim(), self.product_id.trim(), self.product_revision.trim(), self.vendor_specific.trim() ) } } /// Extract an ASCII string field from raw SCSI data. fn ascii_field(data: &[u8], start: usize, end: usize) -> String { if data.len() > start { let e = end.min(data.len()); String::from_utf8_lossy(&data[start..e]).to_string() } else { String::new() } } #[cfg(test)] mod tests { use super::*; use crate::scsi::{ScsiResult, ScsiTransport}; /// Transport that returns the requested data length but reports a /// bytes_transferred larger than the caller's buffer — models a drive /// that lies about its transfer count. The old slicing code panicked /// on this; the clamps must keep it from indexing out of range. struct OversizedCountTransport; impl ScsiTransport for OversizedCountTransport { fn execute( &mut self, cdb: &[u8], _dir: DataDirection, buf: &mut [u8], _timeout_ms: u32, ) -> Result { // Fill plausible ASCII so the from_utf8_lossy paths run. for b in buf.iter_mut() { *b = b'A'; } // INQUIRY (0x12): honest count. GET CONFIGURATION (0x46): lie. let bytes_transferred = if cdb.first() == Some(&0x12) { buf.len() } else { buf.len() + 4096 }; Ok(ScsiResult { status: 0, bytes_transferred, sense: [0u8; 32], }) } } #[test] fn from_drive_clamps_oversized_bytes_transferred() { // Must not panic despite the transport reporting a transfer count // far beyond the 256-byte GET CONFIGURATION buffers. let mut t = OversizedCountTransport; let id = DriveId::from_drive(&mut t).expect("from_drive must not error"); // raw_gc_010c is clamped to the 256-byte buffer, never the lie. assert_eq!(id.raw_gc_010c.len(), 256); } #[test] fn test_bu40n_identity() { let mut inquiry = vec![0u8; 96]; inquiry[4] = 0x5B; inquiry[8..16].copy_from_slice(b"HL-DT-ST"); inquiry[16..32].copy_from_slice(b"BD-RE BU40N "); inquiry[32..36].copy_from_slice(b"1.03"); inquiry[36..43].copy_from_slice(b"NM00000"); let id = DriveId::from_inquiry(&inquiry, "211810241934"); assert_eq!(id.vendor_id.trim(), "HL-DT-ST"); assert_eq!(id.product_id.trim(), "BD-RE BU40N"); assert_eq!(id.product_revision.trim(), "1.03"); assert_eq!(id.vendor_specific.trim(), "NM00000"); assert_eq!(id.firmware_date, "211810241934"); assert_eq!(id.match_key(), "HL-DT-ST|BD-RE BU40N|1.03|NM00000"); } #[test] fn test_pioneer_identity() { let mut inquiry = vec![0u8; 96]; inquiry[4] = 0x5B; inquiry[8..16].copy_from_slice(b"PIONEER "); inquiry[16..32].copy_from_slice(b"BD-RW BDR-S09 "); inquiry[32..36].copy_from_slice(b"1.34"); inquiry[36..43].copy_from_slice(b" 16/04/"); let id = DriveId::from_inquiry(&inquiry, "201604250000"); assert_eq!(id.vendor_id.trim(), "PIONEER"); assert_eq!(id.product_id.trim(), "BD-RW BDR-S09"); assert_eq!(id.product_revision.trim(), "1.34"); assert_eq!(id.vendor_specific.trim(), "16/04/"); assert_eq!(id.firmware_date, "201604250000"); } // ── New comprehensive tests ──────────────────────────────────────────────── /// ascii_field with a buffer shorter than `start` returns empty string /// rather than panicking. /// Spec: SPC-4 §6.4.2 — bytes[8:16] are vendor ID; a truncated buffer /// (e.g. a device that reports fewer than 8 bytes) must not panic. /// Mutation: removing the `data.len() > start` guard makes it panic on short inputs. #[test] fn ascii_field_short_buffer_returns_empty() { // Buffer of length 5: start=8 is beyond the end → empty string. let buf = vec![0u8; 5]; let result = ascii_field(&buf, 8, 16); // SPC-4 vendor ID range assert!(result.is_empty(), "short buffer must yield empty string"); } /// ascii_field with a buffer that covers start but not end is clamped. /// Spec: `ascii_field` documents "clamps to data.len()". /// Mutation: using `end` directly without `min(data.len())` panics here. #[test] fn ascii_field_partial_buffer_is_clamped_not_panicked() { // Buffer of length 12: vendor_id range is [8..16], but only [8..12] present. let mut buf = vec![0u8; 12]; buf[8..12].copy_from_slice(b"SONY"); let result = ascii_field(&buf, 8, 16); // Must not panic; the returned string holds what we wrote. assert_eq!(result, "SONY"); } /// from_inquiry extracts the product_id field from INQUIRY bytes [16:32]. /// Spec: SPC-4 §6.4.2 — PRODUCT IDENTIFICATION at offset 16, length 16. /// Mutation: shifting the product_id slice to [8:24] makes this fail. #[test] fn from_inquiry_extracts_product_id_at_offset_16() { let mut inquiry = vec![0u8; 96]; // Leave vendor_id (8..16) as zeros, write product_id at 16..32. inquiry[16..32].copy_from_slice(b"BD-RW BDR-209M"); let id = DriveId::from_inquiry(&inquiry, ""); assert_eq!( id.product_id, "BD-RW BDR-209M", "product_id must come from INQUIRY bytes 16..32 (SPC-4 §6.4.2)" ); } /// from_inquiry extracts product_revision from INQUIRY bytes [32:36]. /// Spec: SPC-4 §6.4.2 — PRODUCT REVISION LEVEL at offset 32, length 4. /// Mutation: reading revision from [36:40] produces the wrong value. #[test] fn from_inquiry_extracts_revision_at_offset_32() { let mut inquiry = vec![0u8; 96]; inquiry[32..36].copy_from_slice(b"1.53"); let id = DriveId::from_inquiry(&inquiry, ""); assert_eq!( id.product_revision, "1.53", "product_revision must come from INQUIRY bytes 32..36 (SPC-4 §6.4.2)" ); } /// from_inquiry extracts vendor_specific from INQUIRY bytes [36:43]. /// Spec: SPC-4 §6.4.2 — VENDOR SPECIFIC at offset 36, length 8. /// Mutation: reading vendor_specific from [32:39] returns the revision instead. #[test] fn from_inquiry_extracts_vendor_specific_at_offset_36() { let mut inquiry = vec![0u8; 96]; inquiry[36..43].copy_from_slice(b"MM01234"); let id = DriveId::from_inquiry(&inquiry, ""); assert_eq!( id.vendor_specific, "MM01234", "vendor_specific must come from INQUIRY bytes 36..43 (SPC-4 §6.4.2)" ); } /// from_inquiry stores the raw inquiry bytes in raw_inquiry unchanged. /// Mutation: copying only a slice of inquiry into raw_inquiry truncates it. #[test] fn from_inquiry_stores_raw_inquiry() { let mut inquiry = vec![0u8; 96]; inquiry[8..16].copy_from_slice(b"TESTDRVR"); let id = DriveId::from_inquiry(&inquiry, ""); assert_eq!( id.raw_inquiry, inquiry, "raw_inquiry must preserve the full 96-byte buffer" ); } /// GET CONFIGURATION failure (transport error) must not abort the /// identity probe — firmware_date is empty, raw_gc_010c is empty. /// Mutation: propagating the GET_CONFIGURATION error with `?` aborts from_drive. #[test] fn from_drive_gc_failure_yields_empty_firmware_date() { struct GcFailTransport; impl ScsiTransport for GcFailTransport { fn execute( &mut self, cdb: &[u8], _dir: DataDirection, buf: &mut [u8], _timeout_ms: u32, ) -> Result { if cdb.first() == Some(&0x12) { // INQUIRY succeeds with a plausible response. buf[8..16].copy_from_slice(b"TESTDRV "); buf[16..32].copy_from_slice(b"FAKE DRIVE MODEL"); buf[32..36].copy_from_slice(b"0001"); buf[36..43].copy_from_slice(b"X000001"); Ok(ScsiResult { status: 0, bytes_transferred: buf.len(), sense: [0u8; 32], }) } else { // GET CONFIGURATION fails. Err(crate::error::Error::ScsiError { opcode: cdb[0], status: crate::scsi::SCSI_STATUS_CHECK_CONDITION, sense: None, }) } } } let mut t = GcFailTransport; let id = DriveId::from_drive(&mut t).expect("from_drive must succeed despite GC failure"); assert!( id.firmware_date.is_empty(), "firmware_date must be empty when GC fails" ); assert!( id.raw_gc_010c.is_empty(), "raw_gc_010c must be empty when GC fails" ); } }