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}