diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 094e802..9fd1f60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,9 +26,16 @@ jobs: - name: Check all targets run: | - cargo check --workspace --all-targets --no-default-features - cargo check --workspace --all-targets --no-default-features --features ctaphid - cargo check --workspace --all-targets --no-default-features --features ccid + # usb-device 0.2 bus + cargo check --workspace --all-targets --no-default-features --features usb-device-0-2 + cargo check --workspace --all-targets --no-default-features --features usb-device-0-2,ctaphid + cargo check --workspace --all-targets --no-default-features --features usb-device-0-2,ccid + # usb-device 0.3 bus + cargo check --workspace --all-targets --no-default-features --features usb-device-0-3 + cargo check --workspace --all-targets --no-default-features --features usb-device-0-3,ctaphid + cargo check --workspace --all-targets --no-default-features --features usb-device-0-3,ccid + # storage pulls in usb-device 0.3 on its own + cargo check --workspace --all-targets --no-default-features --features storage cargo check --workspace --all-targets cargo check --workspace --all-targets --all-features diff --git a/Cargo.lock b/Cargo.lock index 40afbf8..d1c09bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -624,6 +624,16 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + [[package]] name = "heapless" version = "0.9.1" @@ -927,6 +937,26 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_enum" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a015b430d3c108a207fd776d2e2196aaf8b1cf8cf93253e3a097ff3085076a1" +dependencies = [ + "num_enum_derive", +] + +[[package]] +name = "num_enum_derive" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96667db765a921f7b295ffee8b60472b686a51d4f21c2ee4ffdb94c7013b65a6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -999,6 +1029,12 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + [[package]] name = "postcard" version = "0.7.3" @@ -1522,6 +1558,7 @@ dependencies = [ name = "trussed-usbip" version = "0.1.0-rc.1" dependencies = [ + "aes", "apdu-dispatch", "clap", "clap-num", @@ -1537,10 +1574,14 @@ dependencies = [ "rand_core", "trussed", "trussed-core", - "usb-device", + "usb-device 0.2.9", + "usb-device 0.3.2", "usbd-ccid", "usbd-ctaphid", - "usbip-device", + "usbd-storage", + "usbip-device 0.1.5", + "usbip-device 0.2.0", + "xts-mode", ] [[package]] @@ -1577,24 +1618,33 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f6cc3adc849b5292b4075fc0d5fdcf2f24866e88e336dd27a8943090a520508" +[[package]] +name = "usb-device" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98816b1accafbb09085168b90f27e93d790b4bfa19d883466b5e53315b5f06a6" +dependencies = [ + "heapless 0.8.0", + "portable-atomic", +] + [[package]] name = "usbd-ccid" version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acbc8d41fe1b6dc92347f5988a44252fc9e2511f7cb312fa23e9eee186bfdd3" +source = "git+https://github.com/trussed-dev/usbd-ccid?rev=9912b35c5d7ad0253a60219c1555084eab3844d7#9912b35c5d7ad0253a60219c1555084eab3844d7" dependencies = [ "delog", "embedded-time", "heapless 0.9.1", "interchange", - "usb-device", + "usb-device 0.2.9", + "usb-device 0.3.2", ] [[package]] name = "usbd-ctaphid" version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c527a4840bcb38299924c7a6b0c31f95a89172bdfb4577c515315ce9b097f15a" +source = "git+https://github.com/trussed-dev/usbd-ctaphid?rev=91ecf98c88b1a61276ebf3e77e65009bba3c4e17#91ecf98c88b1a61276ebf3e77e65009bba3c4e17" dependencies = [ "ctaphid-dispatch", "delog", @@ -1604,7 +1654,18 @@ dependencies = [ "ref-swap", "serde", "trussed-core", - "usb-device", + "usb-device 0.2.9", + "usb-device 0.3.2", +] + +[[package]] +name = "usbd-storage" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9596ded13538d5407d0cef1c2879d6e5d9038d6ba601aa9e90a7d08a21ba5215" +dependencies = [ + "num_enum", + "usb-device 0.3.2", ] [[package]] @@ -1615,7 +1676,17 @@ checksum = "2e7a0b57d68d666cc85d8bfe8a32fb4196e8eb89b611658a0624af6428e7a2fd" dependencies = [ "bitflags 1.3.2", "log", - "usb-device", + "usb-device 0.2.9", +] + +[[package]] +name = "usbip-device" +version = "0.2.0" +source = "git+https://github.com/daringer/usbip-device?rev=ad359ae257e4bd931d9424732132cf085e0fac02#ad359ae257e4bd931d9424732132cf085e0fac02" +dependencies = [ + "bitflags 1.3.2", + "log", + "usb-device 0.3.2", ] [[package]] @@ -1821,6 +1892,16 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "xts-mode" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cbddb7545ca0b9ffa7bdc653e8743303e1712687a6918ced25f2cdbed42520" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "zerocopy" version = "0.8.27" diff --git a/Cargo.toml b/Cargo.toml index 80e8e02..c6dc304 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,16 +14,24 @@ log = { version = "0.4.14", default-features = false } rand_chacha = { version = "0.3", default-features = false } rand_core = { version = "0.6", features = ["getrandom"] } trussed = { version = "=0.2.0-rc.1", default-features = false, features = ["log-all", "virt"] } -usb-device = { version = "0.2.7", default-features = false } -usbip-device = "0.1.5" +# Selects the usb-device version; see the `usb_device` alias in src/lib.rs. +usb-device-0-2 = { package = "usb-device", version = "0.2.7", default-features = false, optional = true } +usb-device-0-3 = { package = "usb-device", version = "0.3.2", default-features = false, optional = true } +usbip-device-0-1 = { package = "usbip-device", version = "0.1.5", optional = true } +usbip-device-0-2 = { package = "usbip-device", git = "https://github.com/daringer/usbip-device", rev = "ad359ae257e4bd931d9424732132cf085e0fac02", optional = true } # ctaphid ctaphid-dispatch = { version = "0.4", features = ["log-all"], optional = true } -usbd-ctaphid = { version = "0.4", features = ["log-all"], optional = true } +usbd-ctaphid = { version = "0.4", features = ["log-all"], optional = true, default-features = false } # ccid apdu-dispatch = { version = "0.4", optional = true } -usbd-ccid = { version = "0.4", features = ["log-all"], optional = true } +usbd-ccid = { version = "0.4", features = ["log-all"], optional = true, default-features = false } + +# storage +usbd-storage = { version = "3.0.0", features = ["bbb", "scsi"], optional = true } +aes = { version = "0.8", optional = true } +xts-mode = { version = "0.5", optional = true } [dev-dependencies] clap = { version = "3.0.0", features = ["derive"] } @@ -36,9 +44,29 @@ trussed = "=0.2.0-rc.1" trussed-core = "0.2" [features] -default = ["ctaphid", "ccid"] +default = ["ctaphid", "ccid", "usb-device-0-2"] ctaphid = ["ctaphid-dispatch", "usbd-ctaphid"] ccid = ["apdu-dispatch", "usbd-ccid"] +# usbd-storage requires usb-device 0.3, so enabling storage selects it. +storage = ["dep:usbd-storage", "dep:aes", "dep:xts-mode", "usb-device-0-3"] + +# The class drivers must resolve to the same usb-device version as the bus. +"usb-device-0-2" = [ + "dep:usb-device-0-2", + "dep:usbip-device-0-1", + "usbd-ctaphid?/usb-device-0-2", + "usbd-ccid?/usb-device-0-2", +] +"usb-device-0-3" = [ + "dep:usb-device-0-3", + "dep:usbip-device-0-2", + "usbd-ctaphid?/usb-device-0-3", + "usbd-ccid?/usb-device-0-3", +] [package.metadata.docs.rs] all-features = true + +[patch.crates-io] +usbd-ccid = { git = "https://github.com/trussed-dev/usbd-ccid", rev = "9912b35c5d7ad0253a60219c1555084eab3844d7" } +usbd-ctaphid = { git = "https://github.com/trussed-dev/usbd-ctaphid", rev = "91ecf98c88b1a61276ebf3e77e65009bba3c4e17" } diff --git a/src/ccid.rs b/src/ccid.rs index bb26cec..938cef0 100644 --- a/src/ccid.rs +++ b/src/ccid.rs @@ -1,8 +1,8 @@ use std::time::{Duration, Instant}; +use crate::usb_device::bus::{UsbBus, UsbBusAllocator}; use apdu_dispatch::dispatch::ApduDispatch; use apdu_dispatch::interchanges::Data; -use usb_device::bus::{UsbBus, UsbBusAllocator}; use usbd_ccid::{Ccid, Status}; use super::Timeout; diff --git a/src/ctaphid.rs b/src/ctaphid.rs index aca475a..cefa4c8 100644 --- a/src/ctaphid.rs +++ b/src/ctaphid.rs @@ -3,8 +3,8 @@ use std::{ time::{Duration, Instant}, }; +use crate::usb_device::bus::{UsbBus, UsbBusAllocator}; use ctaphid_dispatch::Dispatch; -use usb_device::bus::{UsbBus, UsbBusAllocator}; use usbd_ctaphid::{types::Status, CtapHid}; use super::{Timeout, IS_WAITING}; diff --git a/src/lib.rs b/src/lib.rs index 74d9db2..591e283 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,20 @@ #![cfg_attr(docsrs, feature(doc_cfg))] +// The selected `usb-device` version and matching bus; 0.3 wins if both are enabled. +#[cfg(all(feature = "usb-device-0-2", not(feature = "usb-device-0-3")))] +pub use {usb_device_0_2 as usb_device, usbip_device_0_1 as usbip_device}; +#[cfg(feature = "usb-device-0-3")] +pub use {usb_device_0_3 as usb_device, usbip_device_0_2 as usbip_device}; + +#[cfg(not(any(feature = "usb-device-0-2", feature = "usb-device-0-3")))] +compile_error!("No usb-device version chosen! Enable `usb-device-0-2` or `usb-device-0-3`."); + #[cfg(feature = "ccid")] mod ccid; #[cfg(feature = "ctaphid")] mod ctaphid; +#[cfg(feature = "storage")] +pub mod storage; use std::{ marker::PhantomData, @@ -15,6 +26,11 @@ use std::{ time::{Duration, Instant}, }; +use crate::usb_device::{ + bus::{UsbBus, UsbBusAllocator}, + device::{UsbDevice, UsbDeviceBuilder, UsbVidPid}, +}; +use crate::usbip_device::UsbIpBus; use littlefs2_core::DynFilesystem; use rand_chacha::ChaCha8Rng; use rand_core::SeedableRng as _; @@ -27,11 +43,6 @@ use trussed::{ virt::UserInterface, ClientImplementation, }; -use usb_device::{ - bus::{UsbBus, UsbBusAllocator}, - device::{UsbDevice, UsbDeviceBuilder, UsbVidPid}, -}; -use usbip_device::UsbIpBus; static IS_WAITING: AtomicBool = AtomicBool::new(false); @@ -49,6 +60,14 @@ pub struct Options { pub serial_number: Option, pub vid: u16, pub pid: u16, + /// File for the block device, memory if `None`. + #[cfg(feature = "storage")] + pub block_device: Option, + /// AES-128 XTS key (32 bytes) for on-the-fly storage encryption; plaintext + /// if `None`. Must be supplied consistently across runs against the same + /// image. + #[cfg(feature = "storage")] + pub block_device_key: Option<[u8; 32]>, } impl Options { @@ -170,6 +189,18 @@ where #[cfg(feature = "ccid")] let (mut ccid, mut apdu_dispatch) = ccid::setup(&bus_allocator, &contact, &contactless); + #[cfg(feature = "storage")] + let mut scsi = storage::setup(&bus_allocator); + #[cfg(feature = "storage")] + let (mut block_device, mut storage_state) = ( + storage::BlockDevice::open( + self.options.block_device.as_deref(), + self.options.block_device_key, + ) + .expect("failed to open block device"), + storage::State::default(), + ); + let mut usb_device = build_device(&bus_allocator, &self.options); let mut service = Service::with_dispatch(platform, self.dispatch); let mut endpoints = Vec::new(); @@ -189,12 +220,40 @@ where loop { thread::sleep(Duration::from_millis(5)); - usb_device.poll(&mut [ + let polled = usb_device.poll(&mut [ #[cfg(feature = "ctaphid")] &mut ctaphid, #[cfg(feature = "ccid")] &mut ccid, + #[cfg(feature = "storage")] + &mut scsi, ]); + let _ = polled; + + #[cfg(feature = "storage")] + if polled { + // A bus reset abandons any transfer that was in flight. + if usb_device.state() == crate::usb_device::device::UsbDeviceState::Default + { + storage_state.reset(); + } + // Polled twice: usbd-storage ends a from-host data phase + // as soon as the last packet is off the endpoint, without + // checking that we have drained its buffer. + // Without this the final block of every write is dropped. + // @TODO: this might be a usbd-storage bug ? + for _ in 0..2 { + if let Err(err) = scsi.poll_command(|command| { + storage::process_command( + command, + &mut block_device, + &mut storage_state, + ) + }) { + log::warn!("storage: {err:?}"); + } + } + } #[cfg(feature = "ctaphid")] ctaphid::keepalive(&mut ctaphid, &mut timeout_ctaphid, _epoch); @@ -265,6 +324,7 @@ impl trussed::platform::Syscall for Syscall { } } +#[cfg(not(feature = "usb-device-0-3"))] fn build_device<'a, B: UsbBus>( bus_allocator: &'a UsbBusAllocator, options: &'a Options, @@ -282,6 +342,36 @@ fn build_device<'a, B: UsbBus>( usb_builder.device_class(0x03).device_sub_class(0).build() } +/// usb-device 0.3 replaced the string setters with a single `strings()` call. +#[cfg(feature = "usb-device-0-3")] +fn build_device<'a, B: UsbBus>( + bus_allocator: &'a UsbBusAllocator, + options: &'a Options, +) -> UsbDevice<'a, B> { + use crate::usb_device::prelude::{LangID, StringDescriptors}; + + let mut strings = StringDescriptors::new(LangID::EN); + if let Some(manufacturer) = &options.manufacturer { + strings = strings.manufacturer(manufacturer); + } + if let Some(product) = &options.product { + strings = strings.product(product); + } + if let Some(serial_number) = &options.serial_number { + strings = strings.serial_number(serial_number); + } + + UsbDeviceBuilder::new(bus_allocator, options.vid_pid()) + .strings(&[strings]) + .expect("failed to set USB string descriptors") + // usbip-device enumerates as high speed, which requires EP0 to be 64. + .max_packet_size_0(64) + .expect("invalid max packet size for EP0") + .device_class(0x03) + .device_sub_class(0) + .build() +} + #[derive(Default)] pub struct Timeout(Option); diff --git a/src/storage.rs b/src/storage.rs new file mode 100644 index 0000000..fb6c807 --- /dev/null +++ b/src/storage.rs @@ -0,0 +1,430 @@ +//! USB mass storage, exposed as a SCSI block device over Bulk Only Transport. + +use std::{ + fs::OpenOptions, + io::{self, Read, Seek, SeekFrom, Write}, + path::Path, +}; + +use aes::{ + cipher::{generic_array::GenericArray, KeyInit}, + Aes128, +}; +use xts_mode::{get_tweak_default, Xts128}; + +use crate::usb_device::bus::{UsbBus, UsbBusAllocator}; +use usbd_storage::{ + subclass::{ + scsi::{Scsi, ScsiCommand}, + Command, + }, + transport::{ + bbb::{BulkOnly, BulkOnlyError}, + TransportError, + }, +}; + +/// Bytes per logical block. 512 is mostly assumed +pub const BLOCK_SIZE: u32 = 512; +/// Number of blocks +pub const BLOCKS: u32 = 16384; + +/// usbip-device always enumerates as high speed, where bulk endpoints must be +/// 512 bytes. The kernel rejects the configuration outright otherwise. +const USB_PACKET_SIZE: u16 = 512; +const MAX_LUN: u8 = 0; + +/// Transport IO buffer. +const IO_BUFFER_SIZE: usize = BLOCK_SIZE as usize; + +const VENDOR_ID: &[u8; 8] = b"Nitrokey"; +const PRODUCT_ID: &[u8; 16] = b"USBIP Storage "; +const PRODUCT_REVISION: &[u8; 4] = b"1.00"; + +/// SCSI sense codes for the failure paths we actually report. +const SENSE_ILLEGAL_REQUEST: u8 = 0x05; +const ASC_INVALID_COMMAND: u8 = 0x20; +const ASC_LBA_OUT_OF_RANGE: u8 = 0x21; + +pub type StorageClass<'bus, B> = Scsi>>; + +/// The device's backing store. +enum Backing { + File(std::fs::File), + Memory(Vec), +} + +/// A fixed-size block device, optionally encrypted on the fly. +pub struct BlockDevice { + backing: Backing, + len: usize, + /// AES-128 XTS, keyed when the device is encrypted. + cipher: Option>, +} + +impl BlockDevice { + /// Opens `path` as file image + /// + /// If `key` != `None`, all data is transparently encrypted with AES-128 XTS. + pub fn open(path: Option<&Path>, key: Option<[u8; 32]>) -> io::Result { + let len = (BLOCK_SIZE * BLOCKS) as usize; + + let backing = match path { + Some(path) => { + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path)?; + // Fixes the size on first use; an existing image keeps its data. + file.set_len(len as u64)?; + log::info!("storage image: {}", path.display()); + Backing::File(file) + } + None => { + log::info!("storage in memory"); + Backing::Memory(vec![0; len]) + } + }; + + let cipher = key.map(|key| { + log::info!("storage encryption: AES-128 XTS"); + let data_key = Aes128::new(GenericArray::from_slice(&key[..16])); + let tweak_key = Aes128::new(GenericArray::from_slice(&key[16..])); + Xts128::new(data_key, tweak_key) + }); + + Ok(Self { + backing, + len, + cipher, + }) + } + + /// Reads `buf.len()` bytes at `offset`, decrypt if `self.cipher != None`. + /// `offset` needs not be sector-aligned: read surrounding sectors, if needed + fn read_at(&mut self, offset: usize, buf: &mut [u8]) -> io::Result<()> { + let Some(cipher) = self.cipher.take() else { + return self.read_raw_at(offset, buf); + }; + + let first = offset / BLOCK_SIZE as usize; + let start = first * BLOCK_SIZE as usize; + let end = (offset + buf.len()).div_ceil(BLOCK_SIZE as usize) * BLOCK_SIZE as usize; + + let mut staging = vec![0u8; end - start]; + let result = self.read_raw_at(start, &mut staging); + if result.is_ok() { + // Each sector is decrypted under its own tweak: sector index + cipher.decrypt_area( + &mut staging, + BLOCK_SIZE as usize, + first as u128, + get_tweak_default, + ); + let head = offset - start; + buf.copy_from_slice(&staging[head..head + buf.len()]); + } + self.cipher = Some(cipher); + result + } + + /// Write `buf` at `offset`, encrypt if `self.cipher != None` + fn write_at(&mut self, offset: usize, buf: &[u8]) -> io::Result<()> { + let Some(cipher) = self.cipher.take() else { + return self.write_raw_at(offset, buf); + }; + + let first = offset / BLOCK_SIZE as usize; + let start = first * BLOCK_SIZE as usize; + let end = (offset + buf.len()).div_ceil(BLOCK_SIZE as usize) * BLOCK_SIZE as usize; + + // Restore cipher + self.cipher = Some(cipher); + + let mut staging = vec![0u8; end - start]; + if start != offset || end != offset + buf.len() { + self.read_at(start, &mut staging)?; + } + let head = offset - start; + staging[head..head + buf.len()].copy_from_slice(buf); + + let cipher = self.cipher.take().expect("cipher restored above"); + // Each sector is encrypted under its own tweak: sector index + cipher.encrypt_area( + &mut staging, + BLOCK_SIZE as usize, + first as u128, + get_tweak_default, + ); + let result = self.write_raw_at(start, &staging); + self.cipher = Some(cipher); + result + } + + /// Reads raw backing bytes at `offset`, without decryption. + fn read_raw_at(&mut self, offset: usize, buf: &mut [u8]) -> io::Result<()> { + match &mut self.backing { + Backing::File(file) => { + file.seek(SeekFrom::Start(offset as u64))?; + file.read_exact(buf) + } + Backing::Memory(mem) => { + buf.copy_from_slice(&mem[offset..offset + buf.len()]); + Ok(()) + } + } + } + + /// Writes raw bytes to the backing at `offset`, without encryption. + fn write_raw_at(&mut self, offset: usize, buf: &[u8]) -> io::Result<()> { + match &mut self.backing { + Backing::File(file) => { + file.seek(SeekFrom::Start(offset as u64))?; + file.write_all(buf)?; + file.flush() + } + Backing::Memory(mem) => { + mem[offset..offset + buf.len()].copy_from_slice(buf); + Ok(()) + } + } + } + + /// True if `[offset, offset + len)` lies within the device. + fn in_bounds(&self, offset: usize, len: usize) -> bool { + offset.checked_add(len).is_some_and(|end| end <= self.len) + } +} + +/// Per-transfer state. A single SCSI read or write is spread across several +/// `poll_command` calls, so the progress within it has to be carried over. +pub struct State { + /// Bytes transferred so far within the current command. + offset: usize, + /// Scratch for the block currently being assembled during a write. + block: Vec, + sense_key: Option, + sense_key_code: Option, + sense_qualifier: Option, +} + +impl Default for State { + fn default() -> Self { + Self { + offset: 0, + block: vec![0; BLOCK_SIZE as usize], + sense_key: None, + sense_key_code: None, + sense_qualifier: None, + } + } +} + +impl State { + pub fn reset(&mut self) { + self.offset = 0; + self.sense_key = None; + self.sense_key_code = None; + self.sense_qualifier = None; + } + + fn fail_with(&mut self, sense_key: u8, asc: u8) { + self.sense_key = Some(sense_key); + self.sense_key_code = Some(asc); + self.sense_qualifier = Some(0x00); + } +} + +pub fn setup(bus_allocator: &UsbBusAllocator) -> StorageClass<'_, B> { + Scsi::new( + bus_allocator, + USB_PACKET_SIZE, + MAX_LUN, + vec![0; IO_BUFFER_SIZE], + ) + .expect("failed to allocate USB mass storage class") +} + +/// Handles one SCSI command against `device`. +pub fn process_command( + mut command: Command>, + device: &mut BlockDevice, + state: &mut State, +) -> Result<(), TransportError> { + log::debug!("storage: {:?}", command.kind); + + // The device exposes a single logical unit (MAX_LUN == 0). + // A host can probe more LUNs, make sure to answer "no device on this LU" for any != 0 + if command.lun != 0 { + match command.kind { + ScsiCommand::Inquiry { .. } => { + let mut data = [0u8; 36]; + data[0] = 0x7F; // PQ 0b011, device type 0x1F: not connected + data[4] = 0x20; // remaining length + command.try_write_data_all(&data)?; + command.pass(data.len() as u32); + } + _ => { + state.fail_with(SENSE_ILLEGAL_REQUEST, ASC_LBA_OUT_OF_RANGE); + command.fail(0); + } + } + return Ok(()); + } + + match command.kind { + ScsiCommand::TestUnitReady => { + command.pass(0); + } + ScsiCommand::Inquiry { .. } => { + let mut data = [0u8; 36]; + data[0] = 0x00; // direct access block device + data[1] = 0x80; // removable + data[2] = 0x04; // SPC-2 + data[3] = 0x02; // response data format + data[4] = 0x20; // 36 bytes total + data[8..16].copy_from_slice(VENDOR_ID); + data[16..32].copy_from_slice(PRODUCT_ID); + data[32..36].copy_from_slice(PRODUCT_REVISION); + command.try_write_data_all(&data)?; + command.pass(data.len() as u32); + } + ScsiCommand::RequestSense { .. } => { + let mut data = [0u8; 18]; + data[0] = 0x70; // current errors + data[2] = state.sense_key.unwrap_or(0); + data[12] = state.sense_key_code.unwrap_or(0); + data[13] = state.sense_qualifier.unwrap_or(0); + command.try_write_data_all(&data)?; + state.reset(); + command.pass(data.len() as u32); + } + ScsiCommand::ReadCapacity10 => { + let mut data = [0u8; 8]; + // Last addressable block, not the count. + data[0..4].copy_from_slice(&u32::to_be_bytes(BLOCKS - 1)); + data[4..8].copy_from_slice(&u32::to_be_bytes(BLOCK_SIZE)); + command.try_write_data_all(&data)?; + command.pass(data.len() as u32); + } + ScsiCommand::ReadCapacity16 { .. } => { + let mut data = [0u8; 16]; + data[0..8].copy_from_slice(&u64::to_be_bytes((BLOCKS - 1) as u64)); + data[8..12].copy_from_slice(&u32::to_be_bytes(BLOCK_SIZE)); + command.try_write_data_all(&data)?; + command.pass(data.len() as u32); + } + ScsiCommand::ReadFormatCapacities { .. } => { + let mut data = [0u8; 12]; + data[3] = 0x08; // capacity list length + data[4..8].copy_from_slice(&u32::to_be_bytes(BLOCKS)); + data[8] = 0x02; // formatted media + data[9..12].copy_from_slice(&u32::to_be_bytes(BLOCK_SIZE)[1..]); + command.try_write_data_all(&data)?; + command.pass(data.len() as u32); + } + ScsiCommand::Read { lba, len } => { + let total = len as usize * BLOCK_SIZE as usize; + + if state.offset == total { + command.pass(state.offset as u32); + state.offset = 0; + return Ok(()); + } + + // Stage the whole remaining transfer, not one block: the host + // issues a single bulk IN for all of it, and a partial answer + // completes that transfer short. + let start = lba as usize * BLOCK_SIZE as usize + state.offset; + let chunk = total - state.offset; + + if !device.in_bounds(start, chunk) { + log::warn!("storage: read past end of device at lba {lba}"); + state.fail_with(SENSE_ILLEGAL_REQUEST, ASC_LBA_OUT_OF_RANGE); + command.fail(0); + state.offset = 0; + return Ok(()); + } + + let mut buf = vec![0u8; chunk]; + device + .read_at(start, &mut buf) + .expect("block device read failed"); + + let count = command.write_data(&buf)?; + state.offset += count; + } + ScsiCommand::Write { lba, len } => { + let total = len as usize * BLOCK_SIZE as usize; + + if state.offset == total { + command.pass(state.offset as u32); + state.offset = 0; + return Ok(()); + } + + let base = lba as usize * BLOCK_SIZE as usize; + if !device.in_bounds(base, total) { + log::warn!("storage: write past end of device at lba {lba}"); + state.fail_with(SENSE_ILLEGAL_REQUEST, ASC_LBA_OUT_OF_RANGE); + command.fail(0); + state.offset = 0; + return Ok(()); + } + + // Read at most up to the end of the block currently being filled, + // flushing each block as it completes. Reading past a block + // boundary in one call would desynchronise the transport, which + // then rejects further reads with InvalidState. + loop { + // Stop once the transfer is complete. + if state.offset == total { + break; + } + + let start = base + state.offset; + let block_offset = start % BLOCK_SIZE as usize; + + let count = command.read_data(&mut state.block[block_offset..])?; + state.offset += count; + + if count > 0 && state.offset % BLOCK_SIZE as usize == 0 { + device + .write_at(start - block_offset, &state.block) + .expect("block device write failed"); + } else { + break; + } + } + + if state.offset == total { + command.pass(state.offset as u32); + state.offset = 0; + } + } + ScsiCommand::ModeSense6 { .. } => { + let data = [ + 0x03, // number of bytes that follow + 0x00, // SBC media type + 0x00, // not write-protected, no cache control + 0x00, // no block descriptors + ]; + command.try_write_data_all(&data)?; + command.pass(data.len() as u32); + } + ScsiCommand::ModeSense10 { .. } => { + let data = [0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; + command.try_write_data_all(&data)?; + command.pass(data.len() as u32); + } + ref unknown => { + log::warn!("storage: unhandled SCSI command: {unknown:?}"); + state.fail_with(SENSE_ILLEGAL_REQUEST, ASC_INVALID_COMMAND); + command.fail(0); + } + } + + Ok(()) +}