Skip to main content

netget/server/usb/msc/
disk.rs

1//! Sector storage behind the virtual USB mass storage device.
2//!
3//! Two backings, and which one is the **default** is the whole point:
4//!
5//! * [`Backing::Memory`] — a `Vec<u8>` that never touches the filesystem. This is the default,
6//!   and it is what an LLM-supplied file map is laid out into
7//!   (`super::fat16::build_volume`). Nothing persists, nothing is created in the working
8//!   directory, and the only data on the wire is data the model supplied.
9//! * [`Backing::Mapped`] — a memory-mapped file, reached only when the caller **names a path**
10//!   in `startup_params.disk_image` or in a `mount_disk` action.
11//!
12//! It used to be file-only, and the default path was `./tmp/netget_msc_disk.img` relative to
13//! the process's working directory. That made the protocol *implement storage*, which the
14//! project rule forbids: the sectors a host read came from a file on disk, not from the model,
15//! and the read/write events were decorative notifications about data nobody had been asked
16//! for. A path the caller names is host state the model refers to — the same shape as `proxy`'s
17//! `ca_export_path` — and it is opt-in now rather than the default.
18
19#[cfg(all(unix, feature = "libusb"))]
20use anyhow::{Context, Result};
21#[cfg(all(unix, feature = "libusb"))]
22use memmap2::MmapMut;
23#[cfg(all(unix, feature = "libusb"))]
24use std::fs::OpenOptions;
25#[cfg(all(unix, feature = "libusb"))]
26use std::path::Path;
27#[cfg(all(unix, feature = "libusb"))]
28use tracing::{debug, info, trace};
29
30/// Where a `DiskImage`'s sectors actually live.
31#[cfg(all(unix, feature = "libusb"))]
32#[derive(Debug)]
33enum Backing {
34    /// In-process bytes. Nothing is written to the filesystem and nothing outlives the server.
35    Memory(Vec<u8>),
36    /// A memory-mapped file the caller named.
37    Mapped(MmapMut),
38}
39
40#[cfg(all(unix, feature = "libusb"))]
41impl Backing {
42    fn as_slice(&self) -> &[u8] {
43        match self {
44            Backing::Memory(bytes) => bytes,
45            Backing::Mapped(mmap) => mmap,
46        }
47    }
48
49    fn as_mut_slice(&mut self) -> &mut [u8] {
50        match self {
51            Backing::Memory(bytes) => bytes,
52            Backing::Mapped(mmap) => mmap,
53        }
54    }
55
56    /// Push writes through to the file, if there is one.
57    fn flush(&self) -> Result<()> {
58        match self {
59            Backing::Memory(_) => Ok(()),
60            Backing::Mapped(mmap) => mmap.flush().context("Failed to flush disk writes"),
61        }
62    }
63}
64
65/// Virtual disk: a flat array of sectors, in memory or memory-mapped from a file.
66#[cfg(all(unix, feature = "libusb"))]
67#[derive(Debug)]
68pub struct DiskImage {
69    backing: Backing,
70    /// Total number of sectors
71    total_sectors: u32,
72    /// Bytes per sector (typically 512)
73    bytes_per_sector: u32,
74}
75
76#[cfg(all(unix, feature = "libusb"))]
77impl DiskImage {
78    /// Open existing disk image or create new one
79    ///
80    /// An image that already exists keeps its own size: `default_size_mb` only decides how
81    /// large a *newly created* image is. Resizing to a fixed default would silently truncate
82    /// or pad a filesystem image the caller supplied, which is exactly the case that matters
83    /// (`startup_params.disk_image` pointing at a prepared FAT volume).
84    ///
85    /// # Arguments
86    /// * `path` - Path to disk image file
87    /// * `default_size_mb` - Size in megabytes, used only when creating a new image
88    ///
89    /// # Returns
90    /// DiskImage instance with memory-mapped file
91    /// A disk whose sectors are held in memory and never written anywhere.
92    ///
93    /// This is how an LLM-supplied file map becomes a device: `super::fat16::build_volume`
94    /// produces the bytes, and this wraps them. `bytes` is rounded up to a whole sector so a
95    /// partial trailing sector cannot make `read_sectors` slice past the end.
96    pub fn in_memory(mut bytes: Vec<u8>) -> Result<Self> {
97        let bytes_per_sector: u32 = 512;
98
99        let rounded = bytes.len().div_ceil(bytes_per_sector as usize) * bytes_per_sector as usize;
100        bytes.resize(rounded, 0);
101
102        let total_sectors = u32::try_from(rounded / bytes_per_sector as usize)
103            .context("in-memory disk is too large to address in 32-bit sectors")?;
104        if total_sectors == 0 {
105            anyhow::bail!("in-memory disk is smaller than one 512-byte sector");
106        }
107
108        debug!("Created in-memory disk ({} sectors)", total_sectors);
109
110        Ok(Self {
111            backing: Backing::Memory(bytes),
112            total_sectors,
113            bytes_per_sector,
114        })
115    }
116
117    pub fn open_or_create(path: &Path, default_size_mb: u32) -> Result<Self> {
118        let bytes_per_sector: u32 = 512;
119
120        // Open or create file
121        let mut options = OpenOptions::new();
122        options
123            .read(true)
124            .write(true)
125            .create(true)
126            // Explicit: this opens an existing disk image or creates a new one.
127            // Truncating would destroy the image's contents, and leaving it
128            // unstated is what clippy's suspicious_open_options flags.
129            .truncate(false);
130        // A symlink planted at the image path must not redirect the mapping outside
131        // `image_dir`; `ImageDir::resolve` refuses one it can see, this refuses the race.
132        #[cfg(unix)]
133        {
134            use std::os::unix::fs::OpenOptionsExt;
135            options.custom_flags(libc::O_NOFOLLOW);
136        }
137        let file = options
138            .open(path)
139            .context("Failed to open/create disk image file")?;
140
141        let current_size = file.metadata()?.len();
142        let size_bytes = if current_size == 0 {
143            // Brand-new (or empty) image: use the requested default size.
144            let size_bytes = (default_size_mb as u64) * 1024 * 1024;
145            file.set_len(size_bytes)
146                .context("Failed to set disk image size")?;
147            info!(
148                "Created disk image {} ({} MB)",
149                path.display(),
150                default_size_mb
151            );
152            size_bytes
153        } else {
154            // Existing image: keep its contents, but round the mapping up to a whole
155            // sector so a partial trailing sector cannot make read_sectors slice past
156            // the end of the mapping.
157            let rounded = current_size.div_ceil(bytes_per_sector as u64) * bytes_per_sector as u64;
158            if rounded != current_size {
159                file.set_len(rounded)
160                    .context("Failed to pad disk image to a sector boundary")?;
161            }
162            debug!(
163                "Opened existing disk image {} ({} bytes)",
164                path.display(),
165                rounded
166            );
167            rounded
168        };
169
170        // `as u32` truncated: a 4 TiB request produced a sector count that wrapped to a small
171        // number, so the device advertised a capacity bearing no relation to the mapping behind
172        // it. SCSI READ(10) addresses 32 bits of sectors and nothing larger is representable,
173        // so refuse rather than round.
174        let total_sectors =
175            u32::try_from(size_bytes / bytes_per_sector as u64).with_context(|| {
176                format!(
177                    "Disk image {} is {} bytes, more than the {} sectors a 32-bit LBA can \
178                     address",
179                    path.display(),
180                    size_bytes,
181                    u32::MAX
182                )
183            })?;
184        if total_sectors == 0 {
185            anyhow::bail!(
186                "Disk image {} is smaller than one 512-byte sector",
187                path.display()
188            );
189        }
190
191        // Memory-map the file
192        let mmap =
193            unsafe { MmapMut::map_mut(&file).context("Failed to memory-map disk image file")? };
194
195        Ok(Self {
196            backing: Backing::Mapped(mmap),
197            total_sectors,
198            bytes_per_sector,
199        })
200    }
201
202    /// Get total number of sectors
203    pub fn total_sectors(&self) -> u32 {
204        self.total_sectors
205    }
206
207    /// Get bytes per sector
208    pub fn bytes_per_sector(&self) -> u32 {
209        self.bytes_per_sector
210    }
211
212    /// Read sectors from disk image
213    ///
214    /// # Arguments
215    /// * `lba` - Logical Block Address (sector number)
216    /// * `count` - Number of sectors to read
217    ///
218    /// # Returns
219    /// Vector containing sector data
220    pub fn read_sectors(&self, lba: u32, count: u32) -> Result<Vec<u8>> {
221        // `lba + count` is host-controlled, so it must not be allowed to wrap: a wrapped sum
222        // would compare small and then slice out of bounds.
223        let end = lba
224            .checked_add(count)
225            .context("Read beyond disk bounds: LBA + count overflows")?;
226        if end > self.total_sectors {
227            anyhow::bail!(
228                "Read beyond disk bounds: LBA {} + {} > {}",
229                lba,
230                count,
231                self.total_sectors
232            );
233        }
234
235        // In `usize`, not `u32`. `lba * 512` overflows 32 bits at 4 GiB, which is well inside
236        // what `mount_disk` accepts -- a debug or test build panicked on the multiply and a
237        // release build wrapped to a small offset and served the wrong sectors. Nothing here
238        // is bounded by 32 bits except the LBA itself, which SCSI READ(10) defines that way.
239        let offset = lba as usize * self.bytes_per_sector as usize;
240        let length = count as usize * self.bytes_per_sector as usize;
241
242        trace!(
243            "Reading {} sectors from LBA {} (offset {}, length {})",
244            count,
245            lba,
246            offset,
247            length
248        );
249
250        Ok(self.backing.as_slice()[offset..offset + length].to_vec())
251    }
252
253    /// Write sectors to disk image
254    ///
255    /// # Arguments
256    /// * `lba` - Logical Block Address (sector number)
257    /// * `data` - Data to write (will be padded to sector boundary if needed)
258    ///
259    /// # Returns
260    /// Number of sectors written
261    pub fn write_sectors(&mut self, lba: u32, data: &[u8]) -> Result<u32> {
262        let count = u32::try_from(data.len().div_ceil(self.bytes_per_sector as usize))
263            .context("Write payload is too large to address in sectors")?;
264
265        let end = lba
266            .checked_add(count)
267            .context("Write beyond disk bounds: LBA + count overflows")?;
268        if end > self.total_sectors {
269            anyhow::bail!(
270                "Write beyond disk bounds: LBA {} + {} > {}",
271                lba,
272                count,
273                self.total_sectors
274            );
275        }
276
277        let offset = lba as usize * self.bytes_per_sector as usize;
278        let length = data.len();
279
280        trace!(
281            "Writing {} bytes ({} sectors) to LBA {} (offset {})",
282            length,
283            count,
284            lba,
285            offset
286        );
287
288        self.backing.as_mut_slice()[offset..offset + length].copy_from_slice(data);
289
290        // Push through to the file, if this disk has one.
291        self.backing.flush()?;
292
293        Ok(count)
294    }
295
296    /// Zero out a range of sectors
297    ///
298    /// # Arguments
299    /// * `lba` - Starting sector
300    /// * `count` - Number of sectors to zero
301    #[allow(dead_code)]
302    pub fn zero_sectors(&mut self, lba: u32, count: u32) -> Result<()> {
303        let end = lba
304            .checked_add(count)
305            .context("Zero beyond disk bounds: LBA + count overflows")?;
306        if end > self.total_sectors {
307            anyhow::bail!(
308                "Zero beyond disk bounds: LBA {} + {} > {}",
309                lba,
310                count,
311                self.total_sectors
312            );
313        }
314
315        let offset = lba as usize * self.bytes_per_sector as usize;
316        let length = count as usize * self.bytes_per_sector as usize;
317
318        debug!("Zeroing {} sectors from LBA {}", count, lba);
319
320        self.backing.as_mut_slice()[offset..offset + length].fill(0);
321        self.backing.flush()?;
322
323        Ok(())
324    }
325}