Skip to main content

gasm_host/
assets.rs

1//! Read-only guest assets: in-memory buffers, or files opened (not read) at start.
2//!
3//! - **File-backed** (`--asset`, `--rom`, `--asset-dir`): `size` comes from file
4//!   metadata and reads are positioned (`pread` on Unix, `seek_read` on
5//!   Windows) straight into guest memory. Nothing is loaded up front, so a
6//!   200 MB asset costs no RAM and no start-up time. A file that shrinks or
7//!   disappears while running just yields fewer bytes (possibly 0); reads never
8//!   panic.
9//! - **Folders** (`--asset-dir [prefix=]dir`): every regular file under `dir`,
10//!   named by its `/`-separated relative path as stored on disk. Symlinks are
11//!   skipped (so nothing outside `dir` is reachable) and so are hidden entries
12//!   (any path component starting with `.`, e.g. `.DS_Store`, `._foo`). The set
13//!   of names is fixed at start-up.
14//! - **Lookup:** an exact name always wins (explicit `--asset` entries override
15//!   folder entries of the same name). Otherwise folder entries also match
16//!   **case-insensitively** (ASCII folding): CD file systems are upper case,
17//!   games ask in mixed case. If several folder entries differ only in case, a
18//!   warning is logged at start-up and the first in sorted order wins.
19//! - Sizes are 32-bit in the ABI: files over 2 GiB - 1 are refused.
20
21use std::collections::{BTreeMap, HashMap};
22use std::fs::File;
23use std::path::{Path, PathBuf};
24
25/// Largest asset the ABI can address (`asset_size -> i32`).
26pub const MAX_ASSET: u64 = i32::MAX as u64;
27
28enum Source {
29    Memory(Vec<u8>),
30    File { file: File, path: PathBuf },
31}
32
33struct Entry {
34    source: Source,
35    from_dir: bool,
36}
37
38#[derive(Default)]
39pub struct Assets {
40    exact: HashMap<String, Entry>,
41    /// ASCII-lowercased name -> folder entry names, sorted
42    folded: BTreeMap<String, Vec<String>>,
43}
44
45impl From<HashMap<String, Vec<u8>>> for Assets {
46    fn from(map: HashMap<String, Vec<u8>>) -> Assets {
47        let mut a = Assets::default();
48        for (name, bytes) in map {
49            a.insert_memory(&name, bytes);
50        }
51        a
52    }
53}
54
55fn open_checked(path: &Path) -> Result<(File, u64), String> {
56    let file = File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
57    let len = file.metadata().map_err(|e| format!("{}: {e}", path.display()))?.len();
58    if len > MAX_ASSET {
59        return Err(format!(
60            "{}: {len} bytes is larger than the 2 GiB the gasm ABI can address (asset sizes are 32-bit)",
61            path.display()
62        ));
63    }
64    Ok((file, len))
65}
66
67#[cfg(unix)]
68fn pread(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result<usize> {
69    std::os::unix::fs::FileExt::read_at(file, buf, offset)
70}
71
72#[cfg(windows)]
73fn pread(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result<usize> {
74    std::os::windows::fs::FileExt::seek_read(file, buf, offset)
75}
76
77impl Assets {
78    pub fn new() -> Assets {
79        Assets::default()
80    }
81
82    pub fn len(&self) -> usize {
83        self.exact.len()
84    }
85
86    pub fn is_empty(&self) -> bool {
87        self.exact.is_empty()
88    }
89
90    /// Asset from memory (overrides any entry with the same name).
91    pub fn insert_memory(&mut self, name: &str, bytes: Vec<u8>) {
92        self.exact.insert(name.to_owned(), Entry { source: Source::Memory(bytes), from_dir: false });
93    }
94
95    /// Asset backed by a file, opened now and read on demand (overrides any
96    /// entry with the same name).
97    pub fn insert_file(&mut self, name: &str, path: &Path) -> Result<(), String> {
98        let (file, _) = open_checked(path)?;
99        self.exact.insert(name.to_owned(), Entry { source: Source::File { file, path: path.to_owned() }, from_dir: false });
100        Ok(())
101    }
102
103    /// Expose every regular file under `dir` (recursively), named
104    /// `[prefix/]relative/path`. Existing names are kept (explicit assets win).
105    /// Returns the number of files added. Call [`Assets::finish`] afterwards.
106    pub fn add_dir(&mut self, prefix: Option<&str>, dir: &Path) -> Result<usize, String> {
107        let meta = std::fs::metadata(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
108        if !meta.is_dir() {
109            return Err(format!("{}: not a directory", dir.display()));
110        }
111        let mut files = Vec::new();
112        walk(dir, &mut Vec::new(), &mut files)?;
113        let mut added = 0;
114        for (segments, path) in files {
115            let rel = segments.join("/");
116            let name = match prefix {
117                Some(p) if !p.is_empty() => format!("{}/{rel}", p.trim_end_matches('/')),
118                _ => rel,
119            };
120            if self.exact.contains_key(&name) {
121                continue;
122            }
123            match open_checked(&path) {
124                Ok((file, _)) => {
125                    self.exact.insert(name, Entry { source: Source::File { file, path }, from_dir: true });
126                    added += 1;
127                }
128                Err(e) => eprintln!("[gasm] assets: skipping {e}"),
129            }
130        }
131        Ok(added)
132    }
133
134    /// Build the case-insensitive index for folder entries; logs collisions.
135    pub fn finish(&mut self) {
136        self.folded.clear();
137        for (name, entry) in &self.exact {
138            if entry.from_dir {
139                self.folded.entry(name.to_ascii_lowercase()).or_default().push(name.clone());
140            }
141        }
142        for names in self.folded.values_mut() {
143            names.sort();
144            if names.len() > 1 {
145                eprintln!(
146                    "[gasm] assets: {} differ only in case; case-insensitive lookups use {:?}",
147                    names.join(", "),
148                    names[0]
149                );
150            }
151        }
152    }
153
154    fn resolve(&self, name: &str) -> Option<&Entry> {
155        if let Some(e) = self.exact.get(name) {
156            return Some(e);
157        }
158        let canonical = self.folded.get(&name.to_ascii_lowercase())?.first()?;
159        self.exact.get(canonical)
160    }
161
162    /// Current size in bytes, or `None` if there is no such asset (or its file vanished).
163    pub fn size(&self, name: &str) -> Option<u64> {
164        match &self.resolve(name)?.source {
165            Source::Memory(b) => Some(b.len() as u64),
166            Source::File { file, .. } => file.metadata().ok().map(|m| m.len().min(MAX_ASSET)),
167        }
168    }
169
170    /// Copy bytes starting at `offset` into `dst`. Returns bytes copied (0 at or
171    /// past the end), or `None` if there is no such asset. Short reads (a file
172    /// that shrank) return what was available; I/O errors end the read early.
173    pub fn read_at(&self, name: &str, offset: u64, dst: &mut [u8]) -> Option<usize> {
174        match &self.resolve(name)?.source {
175            Source::Memory(b) => {
176                let start = (offset as usize).min(b.len());
177                let n = (b.len() - start).min(dst.len());
178                dst[..n].copy_from_slice(&b[start..start + n]);
179                Some(n)
180            }
181            Source::File { file, path } => {
182                let mut done = 0;
183                while done < dst.len() {
184                    match pread(file, &mut dst[done..], offset + done as u64) {
185                        Ok(0) => break,
186                        Ok(n) => done += n,
187                        Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
188                        Err(e) => {
189                            eprintln!("[gasm] assets: {}: {e}", path.display());
190                            break;
191                        }
192                    }
193                }
194                Some(done)
195            }
196        }
197    }
198
199    /// All asset names, sorted.
200    pub fn names(&self) -> Vec<String> {
201        let mut v: Vec<String> = self.exact.keys().cloned().collect();
202        v.sort();
203        v
204    }
205}
206
207/// Recursive, sorted walk collecting regular files; skips symlinks and hidden
208/// entries. Names must be valid UTF-8 (others are skipped with a warning).
209fn walk(dir: &Path, prefix: &mut Vec<String>, out: &mut Vec<(Vec<String>, PathBuf)>) -> Result<(), String> {
210    let mut entries: Vec<_> = std::fs::read_dir(dir)
211        .map_err(|e| format!("{}: {e}", dir.display()))?
212        .filter_map(Result::ok)
213        .collect();
214    entries.sort_by_key(|e| e.file_name());
215    for entry in entries {
216        let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
217            eprintln!("[gasm] assets: skipping non-UTF-8 name {:?}", entry.path());
218            continue;
219        };
220        if name.starts_with('.') {
221            continue;
222        }
223        let Ok(ft) = entry.file_type() else { continue }; // does not follow symlinks
224        if ft.is_symlink() {
225            continue;
226        }
227        prefix.push(name);
228        if ft.is_dir() {
229            walk(&entry.path(), prefix, out)?;
230        } else if ft.is_file() {
231            out.push((prefix.clone(), entry.path()));
232        }
233        prefix.pop();
234    }
235    Ok(())
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    fn tree() -> PathBuf {
243        let d = std::env::temp_dir().join(format!("gasm-assets-test-{}", std::process::id()));
244        let _ = std::fs::remove_dir_all(&d);
245        std::fs::create_dir_all(d.join("ART")).unwrap();
246        std::fs::create_dir_all(d.join("WORLDS/1PLAYER")).unwrap();
247        std::fs::write(d.join("ART/ART.CAR"), b"car").unwrap();
248        std::fs::write(d.join("WORLDS/1PLAYER/MAP.RFM"), b"map-data").unwrap();
249        std::fs::write(d.join(".DS_Store"), b"x").unwrap();
250        std::fs::write(d.join("dup.txt"), b"lower").unwrap();
251        std::fs::write(d.join("DUP.TXT"), b"upper").unwrap();
252        #[cfg(unix)]
253        std::os::unix::fs::symlink("/etc/hosts", d.join("escape")).unwrap();
254        d
255    }
256
257    #[test]
258    fn folders_case_and_precedence() {
259        let d = tree();
260        let mut a = Assets::new();
261        a.insert_memory("ART/ART.CAR", b"explicit".to_vec());
262        a.add_dir(None, &d).unwrap();
263        a.add_dir(Some("cd"), &d).unwrap();
264        a.finish();
265        let read = |a: &Assets, n: &str| {
266            let mut buf = vec![0u8; 64];
267            a.read_at(n, 0, &mut buf).map(|k| String::from_utf8_lossy(&buf[..k]).into_owned())
268        };
269        assert_eq!(read(&a, "ART/ART.CAR").as_deref(), Some("explicit")); // explicit wins
270        assert_eq!(read(&a, "cd/art/art.car").as_deref(), Some("car")); // case-insensitive
271        assert_eq!(read(&a, "Worlds/1player/map.rfm").as_deref(), Some("map-data"));
272        // Case collisions only exist on case-sensitive file systems (not default macOS/Windows).
273        let case_sensitive = std::fs::read_dir(&d).unwrap().filter_map(Result::ok)
274            .filter(|e| e.file_name().to_string_lossy().eq_ignore_ascii_case("dup.txt")).count() == 2;
275        if case_sensitive {
276            assert_eq!(read(&a, "dup.txt").as_deref(), Some("lower")); // exact beats folding
277            assert_eq!(read(&a, "Dup.Txt").as_deref(), Some("upper")); // first in sorted order ("DUP.TXT" < "dup.txt")
278        }
279        assert!(a.size(".DS_Store").is_none()); // hidden skipped
280        assert!(a.size("escape").is_none()); // symlink skipped
281        assert_eq!(a.size("worlds/1player/MAP.RFM"), Some(8));
282        let mut buf = [0u8; 4];
283        assert_eq!(a.read_at("WORLDS/1PLAYER/MAP.RFM", 4, &mut buf), Some(4));
284        assert_eq!(&buf, b"data");
285        assert_eq!(a.read_at("WORLDS/1PLAYER/MAP.RFM", 100, &mut buf), Some(0)); // past the end
286        assert!(a.read_at("missing", 0, &mut buf).is_none());
287        let _ = std::fs::remove_dir_all(&d);
288    }
289
290    #[test]
291    fn shrinking_file_never_panics() {
292        let p = std::env::temp_dir().join(format!("gasm-shrink-{}", std::process::id()));
293        std::fs::write(&p, vec![7u8; 1000]).unwrap();
294        let mut a = Assets::new();
295        a.insert_file("f", &p).unwrap();
296        std::fs::write(&p, vec![7u8; 10]).unwrap(); // truncate while "running"
297        let mut buf = vec![0u8; 100];
298        assert_eq!(a.size("f"), Some(10));
299        assert_eq!(a.read_at("f", 0, &mut buf), Some(10));
300        assert_eq!(a.read_at("f", 500, &mut buf), Some(0));
301        std::fs::remove_file(&p).unwrap(); // vanishes
302        let _ = a.read_at("f", 0, &mut buf); // must not panic (Unix keeps the inode)
303        let _ = std::fs::remove_file(&p);
304    }
305}