Skip to main content

gasm_host/
assets.rs

1//! Read-only guest assets: in-memory buffers, or files read on demand.
2//!
3//! - **File-backed** (`--asset`, `--rom`, `--asset-dir`): the 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. Explicit files are opened at start; folder entries are opened on
9//!   first read and kept in a small cache, so a CD-sized tree doesn't hit the
10//!   open-file limit.
11//! - **Folders** (`--asset-dir [prefix=]dir`): every regular file under `dir`,
12//!   named by its `/`-separated relative path as stored on disk. Symlinks are
13//!   skipped (so nothing outside `dir` is reachable; a file replaced by a
14//!   symlink later is refused too) and so are hidden entries (any path
15//!   component starting with `.`, e.g. `.DS_Store`, `._foo`). The set of names
16//!   is fixed at start-up.
17//! - **Lookup:** an exact name always wins (explicit `--asset` entries override
18//!   folder entries of the same name). Otherwise folder entries also match
19//!   **case-insensitively** (ASCII folding): CD file systems are upper case,
20//!   games ask in mixed case. If several folder entries differ only in case, a
21//!   warning is logged at start-up and the first in sorted order wins.
22//! - Sizes are 64-bit (`asset_size64`, `asset_read_at64`); `asset_size`
23//!   reports assets of 2 GiB and more as -2.
24
25use std::cell::RefCell;
26use std::collections::{BTreeMap, HashMap, VecDeque};
27use std::fs::File;
28use std::path::{Path, PathBuf};
29use std::sync::Arc;
30
31/// Folder entries kept open at once (least recently used are closed).
32const OPEN_FILES: usize = 64;
33
34enum Source {
35    Memory(Vec<u8>),
36    /// explicit file: opened at start (the path the user named)
37    File { file: File, path: PathBuf },
38    /// folder entry: opened on first read
39    Lazy { path: PathBuf, len: u64 },
40}
41
42struct Entry {
43    source: Source,
44    from_dir: bool,
45}
46
47#[derive(Default)]
48pub struct Assets {
49    exact: HashMap<String, Entry>,
50    /// ASCII-lowercased name -> folder entry names, sorted
51    folded: BTreeMap<String, Vec<String>>,
52    /// sorted names, built by `finish`
53    sorted: Vec<String>,
54    /// open folder entries, most recently used last
55    open: RefCell<VecDeque<(PathBuf, Arc<File>)>>,
56}
57
58impl From<HashMap<String, Vec<u8>>> for Assets {
59    fn from(map: HashMap<String, Vec<u8>>) -> Assets {
60        let mut a = Assets::default();
61        for (name, bytes) in map {
62            a.insert_memory(&name, bytes);
63        }
64        a.finish();
65        a
66    }
67}
68
69#[cfg(unix)]
70fn pread(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result<usize> {
71    std::os::unix::fs::FileExt::read_at(file, buf, offset)
72}
73
74#[cfg(windows)]
75fn pread(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result<usize> {
76    std::os::windows::fs::FileExt::seek_read(file, buf, offset)
77}
78
79/// Fill `dst` from `offset`; stops early at the end of the file or on an error.
80fn read_file(file: &File, path: &Path, offset: u64, dst: &mut [u8]) -> usize {
81    let mut done = 0;
82    while done < dst.len() {
83        match pread(file, &mut dst[done..], offset + done as u64) {
84            Ok(0) => break,
85            Ok(n) => done += n,
86            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
87            Err(e) => {
88                eprintln!("[gasm] assets: {}: {e}", path.display());
89                break;
90            }
91        }
92    }
93    done
94}
95
96/// A resolved asset: its size and reads, without looking the name up again.
97pub struct Asset<'a> {
98    assets: &'a Assets,
99    source: &'a Source,
100}
101
102impl Asset<'_> {
103    /// Size in bytes (for folder entries: as found at start-up).
104    pub fn size(&self) -> u64 {
105        match self.source {
106            Source::Memory(b) => b.len() as u64,
107            Source::File { file, .. } => file.metadata().map_or(0, |m| m.len()),
108            Source::Lazy { len, .. } => *len,
109        }
110    }
111
112    /// Copy bytes starting at `offset` into `dst`. Returns bytes copied (0 at or
113    /// past the end). Short reads (a file that shrank) return what was
114    /// available; I/O errors end the read early.
115    pub fn read_at(&self, offset: u64, dst: &mut [u8]) -> usize {
116        match self.source {
117            Source::Memory(b) => {
118                let start = offset.min(b.len() as u64) as usize;
119                let n = (b.len() - start).min(dst.len());
120                dst[..n].copy_from_slice(&b[start..start + n]);
121                n
122            }
123            Source::File { file, path } => read_file(file, path, offset, dst),
124            Source::Lazy { path, .. } => match self.assets.open_lazy(path) {
125                Some(file) => read_file(&file, path, offset, dst),
126                None => 0,
127            },
128        }
129    }
130}
131
132impl Assets {
133    pub fn new() -> Assets {
134        Assets::default()
135    }
136
137    pub fn len(&self) -> usize {
138        self.exact.len()
139    }
140
141    pub fn is_empty(&self) -> bool {
142        self.exact.is_empty()
143    }
144
145    /// Asset from memory (overrides any entry with the same name).
146    pub fn insert_memory(&mut self, name: &str, bytes: Vec<u8>) {
147        self.exact.insert(name.to_owned(), Entry { source: Source::Memory(bytes), from_dir: false });
148    }
149
150    /// Asset backed by a file, opened now and read on demand (overrides any
151    /// entry with the same name).
152    pub fn insert_file(&mut self, name: &str, path: &Path) -> Result<(), String> {
153        let file = File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
154        if !file.metadata().map_err(|e| format!("{}: {e}", path.display()))?.is_file() {
155            return Err(format!("{}: not a regular file", path.display()));
156        }
157        self.exact.insert(name.to_owned(), Entry { source: Source::File { file, path: path.to_owned() }, from_dir: false });
158        Ok(())
159    }
160
161    /// Expose every regular file under `dir` (recursively), named
162    /// `[prefix/]relative/path`. Existing names are kept (explicit assets win).
163    /// Returns the number of files added. Call [`Assets::finish`] afterwards.
164    pub fn add_dir(&mut self, prefix: Option<&str>, dir: &Path) -> Result<usize, String> {
165        let meta = std::fs::metadata(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
166        if !meta.is_dir() {
167            return Err(format!("{}: not a directory", dir.display()));
168        }
169        let mut files = Vec::new();
170        walk(dir, &mut Vec::new(), &mut files)?;
171        let mut added = 0;
172        for (segments, path, len) in files {
173            let rel = segments.join("/");
174            let name = match prefix {
175                Some(p) if !p.is_empty() => format!("{}/{rel}", p.trim_end_matches('/')),
176                _ => rel,
177            };
178            if self.exact.contains_key(&name) {
179                continue;
180            }
181            self.exact.insert(name, Entry { source: Source::Lazy { path, len }, from_dir: true });
182            added += 1;
183        }
184        Ok(added)
185    }
186
187    /// Build the case-insensitive index and the sorted name list; logs collisions.
188    pub fn finish(&mut self) {
189        self.folded.clear();
190        for (name, entry) in &self.exact {
191            if entry.from_dir {
192                self.folded.entry(name.to_ascii_lowercase()).or_default().push(name.clone());
193            }
194        }
195        for names in self.folded.values_mut() {
196            names.sort();
197            if names.len() > 1 {
198                eprintln!(
199                    "[gasm] assets: {} differ only in case; case-insensitive lookups use {:?}",
200                    names.join(", "),
201                    names[0]
202                );
203            }
204        }
205        self.sorted = self.exact.keys().cloned().collect();
206        self.sorted.sort();
207    }
208
209    /// Look an asset up (exact name, then case-insensitively among folder entries).
210    pub fn get(&self, name: &str) -> Option<Asset<'_>> {
211        let entry = match self.exact.get(name) {
212            Some(e) => e,
213            None => self.exact.get(self.folded.get(&name.to_ascii_lowercase())?.first()?)?,
214        };
215        Some(Asset { assets: self, source: &entry.source })
216    }
217
218    /// Current size in bytes, or `None` if there is no such asset.
219    pub fn size(&self, name: &str) -> Option<u64> {
220        self.get(name).map(|a| a.size())
221    }
222
223    /// Copy bytes starting at `offset` into `dst` (see [`Asset::read_at`]), or
224    /// `None` if there is no such asset.
225    pub fn read_at(&self, name: &str, offset: u64, dst: &mut [u8]) -> Option<usize> {
226        self.get(name).map(|a| a.read_at(offset, dst))
227    }
228
229    /// All asset names, sorted (by UTF-8 bytes).
230    pub fn names(&self) -> &[String] {
231        &self.sorted
232    }
233
234    /// Open a folder entry (cached). Refuses anything that is no longer a regular
235    /// file (e.g. replaced by a symlink since start-up).
236    fn open_lazy(&self, path: &Path) -> Option<Arc<File>> {
237        let mut open = self.open.borrow_mut();
238        if let Some(i) = open.iter().position(|(p, _)| p == path) {
239            let hit = open.remove(i)?;
240            let file = hit.1.clone();
241            open.push_back(hit);
242            return Some(file);
243        }
244        let ok = std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_file());
245        let file = match ok.then(|| File::open(path)) {
246            Some(Ok(f)) => Arc::new(f),
247            Some(Err(e)) => {
248                eprintln!("[gasm] assets: {}: {e}", path.display());
249                return None;
250            }
251            None => {
252                eprintln!("[gasm] assets: {}: no longer a regular file", path.display());
253                return None;
254            }
255        };
256        if open.len() >= OPEN_FILES {
257            open.pop_front();
258        }
259        open.push_back((path.to_owned(), file.clone()));
260        Some(file)
261    }
262}
263
264/// Recursive, sorted walk collecting regular files (with their sizes); skips
265/// symlinks and hidden entries. Names must be valid UTF-8 (others are skipped
266/// with a warning).
267fn walk(dir: &Path, prefix: &mut Vec<String>, out: &mut Vec<(Vec<String>, PathBuf, u64)>) -> Result<(), String> {
268    let mut entries: Vec<_> = std::fs::read_dir(dir)
269        .map_err(|e| format!("{}: {e}", dir.display()))?
270        .filter_map(Result::ok)
271        .collect();
272    entries.sort_by_key(|e| e.file_name());
273    for entry in entries {
274        let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
275            eprintln!("[gasm] assets: skipping non-UTF-8 name {:?}", entry.path());
276            continue;
277        };
278        if name.starts_with('.') {
279            continue;
280        }
281        let Ok(ft) = entry.file_type() else { continue }; // does not follow symlinks
282        if ft.is_symlink() {
283            continue;
284        }
285        prefix.push(name);
286        if ft.is_dir() {
287            walk(&entry.path(), prefix, out)?;
288        } else if ft.is_file() {
289            match entry.metadata() {
290                Ok(m) => out.push((prefix.clone(), entry.path(), m.len())),
291                Err(e) => eprintln!("[gasm] assets: skipping {}: {e}", entry.path().display()),
292            }
293        }
294        prefix.pop();
295    }
296    Ok(())
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    fn tree() -> PathBuf {
304        let d = std::env::temp_dir().join(format!("gasm-assets-test-{}", std::process::id()));
305        let _ = std::fs::remove_dir_all(&d);
306        std::fs::create_dir_all(d.join("ART")).unwrap();
307        std::fs::create_dir_all(d.join("WORLDS/1PLAYER")).unwrap();
308        std::fs::write(d.join("ART/ART.CAR"), b"car").unwrap();
309        std::fs::write(d.join("WORLDS/1PLAYER/MAP.RFM"), b"map-data").unwrap();
310        std::fs::write(d.join(".DS_Store"), b"x").unwrap();
311        std::fs::write(d.join("dup.txt"), b"lower").unwrap();
312        std::fs::write(d.join("DUP.TXT"), b"upper").unwrap();
313        #[cfg(unix)]
314        std::os::unix::fs::symlink("/etc/hosts", d.join("escape")).unwrap();
315        d
316    }
317
318    #[test]
319    fn folders_case_and_precedence() {
320        let d = tree();
321        let mut a = Assets::new();
322        a.insert_memory("ART/ART.CAR", b"explicit".to_vec());
323        a.add_dir(None, &d).unwrap();
324        a.add_dir(Some("cd"), &d).unwrap();
325        a.finish();
326        let read = |a: &Assets, n: &str| {
327            let mut buf = vec![0u8; 64];
328            a.read_at(n, 0, &mut buf).map(|k| String::from_utf8_lossy(&buf[..k]).into_owned())
329        };
330        assert_eq!(read(&a, "ART/ART.CAR").as_deref(), Some("explicit")); // explicit wins
331        assert_eq!(read(&a, "cd/art/art.car").as_deref(), Some("car")); // case-insensitive
332        assert_eq!(read(&a, "Worlds/1player/map.rfm").as_deref(), Some("map-data"));
333        // Case collisions only exist on case-sensitive file systems (not default macOS/Windows).
334        let case_sensitive = std::fs::read_dir(&d).unwrap().filter_map(Result::ok)
335            .filter(|e| e.file_name().to_string_lossy().eq_ignore_ascii_case("dup.txt")).count() == 2;
336        if case_sensitive {
337            assert_eq!(read(&a, "dup.txt").as_deref(), Some("lower")); // exact beats folding
338            assert_eq!(read(&a, "Dup.Txt").as_deref(), Some("upper")); // first in sorted order ("DUP.TXT" < "dup.txt")
339        }
340        assert!(a.size(".DS_Store").is_none()); // hidden skipped
341        assert!(a.size("escape").is_none()); // symlink skipped
342        assert_eq!(a.size("worlds/1player/MAP.RFM"), Some(8));
343        let mut buf = [0u8; 4];
344        assert_eq!(a.read_at("WORLDS/1PLAYER/MAP.RFM", 4, &mut buf), Some(4));
345        assert_eq!(&buf, b"data");
346        assert_eq!(a.read_at("WORLDS/1PLAYER/MAP.RFM", 100, &mut buf), Some(0)); // past the end
347        assert!(a.read_at("missing", 0, &mut buf).is_none());
348        assert!(a.names().windows(2).all(|w| w[0] < w[1]));
349        let _ = std::fs::remove_dir_all(&d);
350    }
351
352    #[test]
353    fn many_folder_files_stay_under_the_open_file_limit() {
354        let d = std::env::temp_dir().join(format!("gasm-assets-many-{}", std::process::id()));
355        let _ = std::fs::remove_dir_all(&d);
356        std::fs::create_dir_all(&d).unwrap();
357        for i in 0..(OPEN_FILES * 3) {
358            std::fs::write(d.join(format!("F{i:04}")), format!("{i}")).unwrap();
359        }
360        let mut a = Assets::new();
361        a.add_dir(None, &d).unwrap();
362        a.finish();
363        let mut buf = [0u8; 8];
364        for round in 0..2 {
365            for i in 0..(OPEN_FILES * 3) {
366                let n = a.read_at(&format!("f{i:04}"), 0, &mut buf).unwrap();
367                assert_eq!(&buf[..n], format!("{i}").as_bytes(), "round {round}");
368            }
369        }
370        assert!(a.open.borrow().len() <= OPEN_FILES);
371        let _ = std::fs::remove_dir_all(&d);
372    }
373
374    #[test]
375    fn shrinking_file_never_panics() {
376        let p = std::env::temp_dir().join(format!("gasm-shrink-{}", std::process::id()));
377        std::fs::write(&p, vec![7u8; 1000]).unwrap();
378        let mut a = Assets::new();
379        a.insert_file("f", &p).unwrap();
380        a.finish();
381        std::fs::write(&p, vec![7u8; 10]).unwrap(); // truncate while "running"
382        let mut buf = vec![0u8; 100];
383        assert_eq!(a.size("f"), Some(10));
384        assert_eq!(a.read_at("f", 0, &mut buf), Some(10));
385        assert_eq!(a.read_at("f", 500, &mut buf), Some(0));
386        std::fs::remove_file(&p).unwrap(); // vanishes
387        let _ = a.read_at("f", 0, &mut buf); // must not panic (Unix keeps the inode)
388        let _ = std::fs::remove_file(&p);
389    }
390}