Skip to main content

netget/server/usb/msc/
fat16.rs

1//! Lay out an LLM-supplied file map as a FAT16 volume, in memory.
2//!
3//! # Why this exists
4//!
5//! `usb-msc` used to violate the project's no-storage rule outright: `DiskImage::open_or_create`
6//! memory-mapped a real file, defaulting to `./tmp/netget_msc_disk.img` in the process's working
7//! directory. The protocol *was* the storage — the model named a path and the filesystem did the
8//! rest — and the `usb_msc_read` / `usb_msc_write` events were decorative, because the sectors
9//! had already been served from the file before the model was told anything.
10//!
11//! This is the shape the rule asks for instead: **the protocol owns BOT/SCSI/geometry, the model
12//! owns the content.** The model answers `usb_msc_attached` with
13//!
14//! ```json
15//! {"type": "serve_files", "files": [{"name": "hello.txt", "content": "world"}]}
16//! ```
17//!
18//! and this module turns that into the bytes a host reads. Nothing is written to disk, nothing
19//! outlives the server, and the only data on the wire is data the model supplied. Structured
20//! fields, not base64: names and text, which a model can produce reliably.
21//!
22//! # Layout
23//!
24//! 512-byte sectors, one sector per cluster:
25//!
26//! | LBA | Contents |
27//! |---|---|
28//! | 0 | Boot sector / BPB |
29//! | 1..1+F | FAT #1 |
30//! | 1+F..1+2F | FAT #2 |
31//! | .. | Root directory (512 entries, 32 sectors) |
32//! | .. | Data region; cluster *n* is `data_start + n - 2` |
33//!
34//! The volume is sized so the **cluster count** lands inside FAT16's 4085..65525 window. That
35//! window is the definition of FAT16 — below it a host is required to read the FAT with 12-bit
36//! entries, above it with 32-bit ones — so a volume that misses it is not "a small FAT16", it is
37//! a volume the host decodes with the wrong entry width.
38
39#[cfg(all(unix, feature = "libusb"))]
40use anyhow::{bail, Result};
41
42/// Bytes per sector. Fixed: `READ CAPACITY(10)` reports it and the BPB repeats it.
43#[cfg(all(unix, feature = "libusb"))]
44pub const BYTES_PER_SECTOR: usize = 512;
45
46#[cfg(all(unix, feature = "libusb"))]
47const SECTORS_PER_CLUSTER: u8 = 1;
48#[cfg(all(unix, feature = "libusb"))]
49const RESERVED_SECTORS: u16 = 1;
50#[cfg(all(unix, feature = "libusb"))]
51const NUM_FATS: u8 = 2;
52#[cfg(all(unix, feature = "libusb"))]
53const ROOT_ENTRIES: u16 = 512;
54#[cfg(all(unix, feature = "libusb"))]
55const FAT_SECTORS: u16 = 32;
56
57/// Default volume size in sectors: 8192 * 512 = 4 MiB, which gives 8095 data clusters.
58#[cfg(all(unix, feature = "libusb"))]
59pub const DEFAULT_TOTAL_SECTORS: u16 = 8192;
60
61/// Root directory occupies 512 entries of 32 bytes.
62#[cfg(all(unix, feature = "libusb"))]
63const ROOT_DIR_SECTORS: u32 = (ROOT_ENTRIES as u32 * 32).div_ceil(BYTES_PER_SECTOR as u32);
64
65#[cfg(all(unix, feature = "libusb"))]
66const ROOT_DIR_LBA: u32 = RESERVED_SECTORS as u32 + (NUM_FATS as u32 * FAT_SECTORS as u32);
67
68#[cfg(all(unix, feature = "libusb"))]
69const DATA_START_LBA: u32 = ROOT_DIR_LBA + ROOT_DIR_SECTORS;
70
71/// One file the model asked for.
72#[cfg(all(unix, feature = "libusb"))]
73#[derive(Debug, Clone)]
74pub struct FileSpec {
75    /// 8.3 name. Case is normalised to upper, as FAT stores it.
76    pub name: String,
77    /// File contents as text. There is no binary form on purpose: a model cannot reliably
78    /// produce or read base64, and the project rule forbids putting raw bytes in an action.
79    pub content: String,
80}
81
82/// Where a cluster's data lives.
83#[cfg(all(unix, feature = "libusb"))]
84pub fn cluster_lba(cluster: u16) -> u32 {
85    DATA_START_LBA + (cluster as u32 - 2) * SECTORS_PER_CLUSTER as u32
86}
87
88/// Build a FAT16 volume containing `files` in the root directory.
89///
90/// `total_sectors` sizes the volume; `volume_label` is the 11-character label a host shows.
91/// Files are allocated consecutive clusters from cluster 2, in the order given.
92#[cfg(all(unix, feature = "libusb"))]
93pub fn build_volume(files: &[FileSpec], total_sectors: u16, volume_label: &str) -> Result<Vec<u8>> {
94    if files.len() > ROOT_ENTRIES as usize {
95        bail!(
96            "{} files do not fit in a {}-entry root directory",
97            files.len(),
98            ROOT_ENTRIES
99        );
100    }
101
102    let total = total_sectors as usize;
103    if (total as u32) <= DATA_START_LBA {
104        bail!(
105            "{} sectors is smaller than the {} sectors of metadata a FAT16 volume needs",
106            total,
107            DATA_START_LBA
108        );
109    }
110
111    let data_clusters = total as u32 - DATA_START_LBA;
112    if !(4085..65525).contains(&data_clusters) {
113        bail!(
114            "{} sectors gives {} data clusters, outside the FAT16 range 4085..65525; a host \
115             would read the FAT with the wrong entry width",
116            total,
117            data_clusters
118        );
119    }
120
121    let mut image = vec![0u8; total * BYTES_PER_SECTOR];
122    write_boot_sector(&mut image, total_sectors, volume_label);
123
124    // FAT entries 0 and 1 are reserved: the media descriptor and the end-of-chain marker.
125    let mut fat = vec![0u16; (FAT_SECTORS as usize * BYTES_PER_SECTOR) / 2];
126    fat[0] = 0xFFF8;
127    fat[1] = 0xFFFF;
128
129    let mut next_cluster: u16 = 2;
130    for (index, file) in files.iter().enumerate() {
131        let name = encode_8_3(&file.name)?;
132        let data = file.content.as_bytes();
133
134        let clusters_needed = if data.is_empty() {
135            0u32
136        } else {
137            (data.len() as u32).div_ceil(BYTES_PER_SECTOR as u32 * SECTORS_PER_CLUSTER as u32)
138        };
139        if clusters_needed as u64 + next_cluster as u64 > data_clusters as u64 + 2 {
140            bail!(
141                "'{}' does not fit in the remaining {} clusters",
142                file.name,
143                data_clusters as u64 + 2 - next_cluster as u64
144            );
145        }
146
147        // A zero-length file has no cluster at all; FAT stores first-cluster 0 for it.
148        let first_cluster = if clusters_needed == 0 {
149            0
150        } else {
151            next_cluster
152        };
153
154        for n in 0..clusters_needed {
155            let cluster = next_cluster;
156            let offset = cluster_lba(cluster) as usize * BYTES_PER_SECTOR;
157            let start = (n as usize) * BYTES_PER_SECTOR * SECTORS_PER_CLUSTER as usize;
158            let end = (start + BYTES_PER_SECTOR * SECTORS_PER_CLUSTER as usize).min(data.len());
159            let chunk = &data[start..end];
160            image
161                .get_mut(offset..offset + chunk.len())
162                .ok_or_else(|| anyhow::anyhow!("'{}' runs past the end of the volume", file.name))?
163                .copy_from_slice(chunk);
164
165            *fat.get_mut(cluster as usize)
166                .ok_or_else(|| anyhow::anyhow!("cluster {} is outside the FAT", cluster))? =
167                if n + 1 == clusters_needed {
168                    0xFFFF // end of chain
169                } else {
170                    cluster + 1
171                };
172            next_cluster += 1;
173        }
174
175        // Root directory entry.
176        let entry_offset = ROOT_DIR_LBA as usize * BYTES_PER_SECTOR + index * 32;
177        let entry = image
178            .get_mut(entry_offset..entry_offset + 32)
179            .ok_or_else(|| anyhow::anyhow!("root directory entry is outside the volume"))?;
180        entry[0..11].copy_from_slice(&name);
181        entry[11] = 0x20; // archive
182        entry[22..24].copy_from_slice(&0u16.to_le_bytes()); // write time 00:00:00
183        entry[24..26].copy_from_slice(&0x5821u16.to_le_bytes()); // write date 2024-01-01
184        entry[26..28].copy_from_slice(&first_cluster.to_le_bytes());
185        entry[28..32].copy_from_slice(&(data.len() as u32).to_le_bytes());
186    }
187
188    // Both FAT copies. A host that reads only FAT #2 (they exist to be redundant) must see the
189    // same chains.
190    let fat_bytes: Vec<u8> = fat.iter().flat_map(|e| e.to_le_bytes()).collect();
191    for copy in 0..NUM_FATS as usize {
192        let offset = (RESERVED_SECTORS as usize + copy * FAT_SECTORS as usize) * BYTES_PER_SECTOR;
193        image
194            .get_mut(offset..offset + fat_bytes.len())
195            .ok_or_else(|| anyhow::anyhow!("FAT does not fit in the volume"))?
196            .copy_from_slice(&fat_bytes);
197    }
198
199    Ok(image)
200}
201
202#[cfg(all(unix, feature = "libusb"))]
203fn write_boot_sector(image: &mut [u8], total_sectors: u16, volume_label: &str) {
204    let bs = &mut image[..BYTES_PER_SECTOR];
205
206    bs[0..3].copy_from_slice(&[0xEB, 0x3C, 0x90]); // jmp short / nop
207    bs[3..11].copy_from_slice(b"MSWIN4.1"); // OEM name
208
209    // BIOS Parameter Block
210    bs[11..13].copy_from_slice(&(BYTES_PER_SECTOR as u16).to_le_bytes());
211    bs[13] = SECTORS_PER_CLUSTER;
212    bs[14..16].copy_from_slice(&RESERVED_SECTORS.to_le_bytes());
213    bs[16] = NUM_FATS;
214    bs[17..19].copy_from_slice(&ROOT_ENTRIES.to_le_bytes());
215    bs[19..21].copy_from_slice(&total_sectors.to_le_bytes());
216    bs[21] = 0xF8; // media descriptor: fixed disk
217    bs[22..24].copy_from_slice(&FAT_SECTORS.to_le_bytes());
218    bs[24..26].copy_from_slice(&32u16.to_le_bytes()); // sectors per track
219    bs[26..28].copy_from_slice(&2u16.to_le_bytes()); // heads
220    bs[28..32].copy_from_slice(&0u32.to_le_bytes()); // hidden sectors
221    bs[32..36].copy_from_slice(&0u32.to_le_bytes()); // total sectors (32-bit), unused
222
223    // Extended BPB
224    bs[36] = 0x80; // drive number
225    bs[37] = 0x00; // reserved
226    bs[38] = 0x29; // extended boot signature
227    bs[39..43].copy_from_slice(&0x1234_5678u32.to_le_bytes()); // volume id, fixed on purpose
228    bs[43..54].copy_from_slice(&encode_label(volume_label));
229    bs[54..62].copy_from_slice(b"FAT16   "); // fs type (advisory)
230
231    bs[510] = 0x55;
232    bs[511] = 0xAA;
233}
234
235/// An 11-byte, space-padded, upper-case volume label.
236#[cfg(all(unix, feature = "libusb"))]
237fn encode_label(label: &str) -> [u8; 11] {
238    let mut out = [b' '; 11];
239    for (slot, byte) in out.iter_mut().zip(
240        label
241            .to_ascii_uppercase()
242            .bytes()
243            .filter(|b| b.is_ascii_graphic() || *b == b' '),
244    ) {
245        *slot = byte;
246    }
247    out
248}
249
250/// Encode a name as an 11-byte 8.3 directory entry name.
251///
252/// Rejects rather than truncates. A model that asks for `configuration.json` and silently gets
253/// `CONFIGUR.JSO` has been lied to, and the file it then tells the user about does not exist.
254#[cfg(all(unix, feature = "libusb"))]
255fn encode_8_3(name: &str) -> Result<[u8; 11]> {
256    let upper = name.trim().to_ascii_uppercase();
257    let (base, ext) = match upper.rsplit_once('.') {
258        Some((b, e)) => (b, e),
259        None => (upper.as_str(), ""),
260    };
261    if base.is_empty() || base.len() > 8 || ext.len() > 3 {
262        bail!(
263            "'{}' is not a valid FAT 8.3 name: at most 8 characters, a dot, and at most 3 more",
264            name
265        );
266    }
267    if !base
268        .bytes()
269        .chain(ext.bytes())
270        .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || b"$%'-_@~`!(){}^#&".contains(&c))
271    {
272        bail!(
273            "'{}' contains characters a FAT 8.3 name cannot hold (letters, digits and \
274             $%'-_@~`!(){{}}^#& only)",
275            name
276        );
277    }
278
279    let mut out = [b' '; 11];
280    out[..base.len()].copy_from_slice(base.as_bytes());
281    out[8..8 + ext.len()].copy_from_slice(ext.as_bytes());
282    Ok(out)
283}