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//! - **Replacing assets while the game runs:** the embedder calls
25//!   [`Assets::set`] / [`Assets::set_file`] / [`Assets::remove`] between frames
26//!   (`Game::set_asset`, or `--watch-asset`, which re-opens a file whenever it
27//!   changes: [`AssetWatch`]). Each replacement gives the asset a new version
28//!   (`gasm.asset_version`): 0 for assets given at start, then increasing
29//!   numbers from one counter, so a removed and re-added asset never repeats one.
30
31use std::cell::RefCell;
32use std::collections::{BTreeMap, HashMap, VecDeque};
33use std::fs::File;
34use std::path::{Path, PathBuf};
35use std::sync::Arc;
36
37/// Folder entries kept open at once (least recently used are closed).
38const OPEN_FILES: usize = 64;
39
40enum Source {
41    Memory(Vec<u8>),
42    /// explicit file: opened at start (the path the user named)
43    File { file: File, path: PathBuf },
44    /// folder entry: opened on first read
45    Lazy { path: PathBuf, len: u64 },
46}
47
48struct Entry {
49    source: Source,
50    from_dir: bool,
51    version: u32,
52}
53
54#[derive(Default)]
55pub struct Assets {
56    exact: HashMap<String, Entry>,
57    /// ASCII-lowercased name -> folder entry names, sorted
58    folded: BTreeMap<String, Vec<String>>,
59    /// sorted names, built by `finish`
60    sorted: Vec<String>,
61    /// open folder entries, most recently used last
62    open: RefCell<VecDeque<(PathBuf, Arc<File>)>>,
63    /// the last version handed out by a replacement (assets given at start are 0)
64    last_version: u32,
65}
66
67impl From<HashMap<String, Vec<u8>>> for Assets {
68    fn from(map: HashMap<String, Vec<u8>>) -> Assets {
69        let mut a = Assets::default();
70        for (name, bytes) in map {
71            a.insert_memory(&name, bytes);
72        }
73        a.finish();
74        a
75    }
76}
77
78#[cfg(unix)]
79fn pread(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result<usize> {
80    std::os::unix::fs::FileExt::read_at(file, buf, offset)
81}
82
83#[cfg(windows)]
84fn pread(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result<usize> {
85    std::os::windows::fs::FileExt::seek_read(file, buf, offset)
86}
87
88/// Fill `dst` from `offset`; stops early at the end of the file or on an error.
89fn read_file(file: &File, path: &Path, offset: u64, dst: &mut [u8]) -> usize {
90    let mut done = 0;
91    while done < dst.len() {
92        match pread(file, &mut dst[done..], offset + done as u64) {
93            Ok(0) => break,
94            Ok(n) => done += n,
95            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
96            Err(e) => {
97                eprintln!("[gasm] assets: {}: {e}", path.display());
98                break;
99            }
100        }
101    }
102    done
103}
104
105/// A resolved asset: its size and reads, without looking the name up again.
106pub struct Asset<'a> {
107    assets: &'a Assets,
108    source: &'a Source,
109    version: u32,
110}
111
112impl Asset<'_> {
113    /// 0 if given at start, else a new number each time it was replaced.
114    pub fn version(&self) -> u32 {
115        self.version
116    }
117
118    /// Size in bytes (for folder entries: as found at start-up).
119    pub fn size(&self) -> u64 {
120        match self.source {
121            Source::Memory(b) => b.len() as u64,
122            Source::File { file, .. } => file.metadata().map_or(0, |m| m.len()),
123            Source::Lazy { len, .. } => *len,
124        }
125    }
126
127    /// Copy bytes starting at `offset` into `dst`. Returns bytes copied (0 at or
128    /// past the end). Short reads (a file that shrank) return what was
129    /// available; I/O errors end the read early.
130    pub fn read_at(&self, offset: u64, dst: &mut [u8]) -> usize {
131        match self.source {
132            Source::Memory(b) => {
133                let start = offset.min(b.len() as u64) as usize;
134                let n = (b.len() - start).min(dst.len());
135                dst[..n].copy_from_slice(&b[start..start + n]);
136                n
137            }
138            Source::File { file, path } => read_file(file, path, offset, dst),
139            Source::Lazy { path, .. } => match self.assets.open_lazy(path) {
140                Some(file) => read_file(&file, path, offset, dst),
141                None => 0,
142            },
143        }
144    }
145}
146
147impl Assets {
148    pub fn new() -> Assets {
149        Assets::default()
150    }
151
152    pub fn len(&self) -> usize {
153        self.exact.len()
154    }
155
156    pub fn is_empty(&self) -> bool {
157        self.exact.is_empty()
158    }
159
160    /// Asset from memory (overrides any entry with the same name).
161    pub fn insert_memory(&mut self, name: &str, bytes: Vec<u8>) {
162        self.exact.insert(name.to_owned(), Entry { source: Source::Memory(bytes), from_dir: false, version: 0 });
163    }
164
165    /// Asset backed by a file, opened now and read on demand (overrides any
166    /// entry with the same name).
167    pub fn insert_file(&mut self, name: &str, path: &Path) -> Result<(), String> {
168        let file = File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
169        if !file.metadata().map_err(|e| format!("{}: {e}", path.display()))?.is_file() {
170            return Err(format!("{}: not a regular file", path.display()));
171        }
172        self.exact.insert(name.to_owned(), Entry { source: Source::File { file, path: path.to_owned() }, from_dir: false, version: 0 });
173        Ok(())
174    }
175
176    /// Expose every regular file under `dir` (recursively), named
177    /// `[prefix/]relative/path`. Existing names are kept (explicit assets win).
178    /// Returns the number of files added. Call [`Assets::finish`] afterwards.
179    pub fn add_dir(&mut self, prefix: Option<&str>, dir: &Path) -> Result<usize, String> {
180        let meta = std::fs::metadata(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
181        if !meta.is_dir() {
182            return Err(format!("{}: not a directory", dir.display()));
183        }
184        let mut files = Vec::new();
185        walk(dir, &mut Vec::new(), &mut files)?;
186        let mut added = 0;
187        for (segments, path, len) in files {
188            let rel = segments.join("/");
189            let name = match prefix {
190                Some(p) if !p.is_empty() => format!("{}/{rel}", p.trim_end_matches('/')),
191                _ => rel,
192            };
193            if self.exact.contains_key(&name) {
194                continue;
195            }
196            self.exact.insert(name, Entry { source: Source::Lazy { path, len }, from_dir: true, version: 0 });
197            added += 1;
198        }
199        Ok(added)
200    }
201
202    /// `--mods <dir>`: the folder's resource packs (`*.pck`, `*.zip`, top level, not
203    /// hidden), each read on demand as `mods/<file name>`, sorted by name (the load
204    /// order). Files that can't be read are refused, not fatal: the game gets them as
205    /// asset `mods.refused` (`name<TAB>reason` lines) and they're logged. Returns
206    /// (mounted, refused).
207    pub fn add_mods(&mut self, dir: &Path) -> Result<(Vec<String>, Vec<(String, String)>), String> {
208        let entries = std::fs::read_dir(dir).map_err(|e| format!("--mods {}: {e}", dir.display()))?;
209        let mut names: Vec<(String, PathBuf)> = entries
210            .filter_map(|e| e.ok())
211            .filter_map(|e| Some((e.file_name().into_string().ok()?, e.path())))
212            .filter(|(n, _)| !n.starts_with('.'))
213            .filter(|(n, _)| {
214                let l = n.to_ascii_lowercase();
215                l.ends_with(".pck") || l.ends_with(".zip")
216            })
217            .collect();
218        names.sort();
219        let (mut mounted, mut refused) = (Vec::new(), Vec::new());
220        for (name, path) in names {
221            let ok = std::fs::symlink_metadata(&path)
222                .map_err(|e| e.to_string())
223                .and_then(|m| if m.is_file() { Ok(m.len()) } else { Err("not a regular file".into()) })
224                .and_then(|len| File::open(&path).map(|_| len).map_err(|e| e.to_string()));
225            match ok {
226                Ok(len) => {
227                    self.exact.insert(format!("mods/{name}"), Entry { source: Source::Lazy { path, len }, from_dir: true, version: 0 });
228                    mounted.push(name);
229                }
230                Err(why) => refused.push((name, why)),
231            }
232        }
233        if !refused.is_empty() {
234            let text: String = refused.iter().map(|(n, w)| format!("{n}\t{w}\n")).collect();
235            self.exact.insert("mods.refused".into(), Entry { source: Source::Memory(text.into_bytes()), from_dir: false, version: 0 });
236        }
237        Ok((mounted, refused))
238    }
239
240    /// Refuse a mounted mod (before the game starts): unmounted, and its line added to
241    /// `mods.refused`.
242    pub fn refuse_mod(&mut self, file: &str, why: &str) {
243        self.exact.remove(&format!("mods/{file}"));
244        let mut text = match self.exact.get("mods.refused").map(|e| &e.source) {
245            Some(Source::Memory(b)) => String::from_utf8_lossy(b).into_owned(),
246            _ => String::new(),
247        };
248        text += &format!("{file}\t{why}\n");
249        self.exact.insert("mods.refused".into(), Entry { source: Source::Memory(text.into_bytes()), from_dir: false, version: 0 });
250        self.index();
251    }
252
253    /// Add or replace an asset from memory while the game runs (between frames):
254    /// it gets a new version. Returns that version.
255    pub fn set(&mut self, name: &str, bytes: Vec<u8>) -> u32 {
256        self.replace(name, Source::Memory(bytes))
257    }
258
259    /// [`Assets::set`] with a file read on demand (opened now).
260    pub fn set_file(&mut self, name: &str, path: &Path) -> Result<u32, String> {
261        let file = File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
262        if !file.metadata().map_err(|e| format!("{}: {e}", path.display()))?.is_file() {
263            return Err(format!("{}: not a regular file", path.display()));
264        }
265        Ok(self.replace(name, Source::File { file, path: path.to_owned() }))
266    }
267
268    /// Remove an asset while the game runs (between frames). False if there was none.
269    pub fn remove(&mut self, name: &str) -> bool {
270        let gone = self.exact.remove(name).is_some();
271        if gone {
272            self.index();
273        }
274        gone
275    }
276
277    fn replace(&mut self, name: &str, source: Source) -> u32 {
278        self.last_version += 1;
279        self.exact.insert(name.to_owned(), Entry { source, from_dir: false, version: self.last_version });
280        self.index();
281        self.last_version
282    }
283
284    /// Build the case-insensitive index and the sorted name list; logs collisions.
285    pub fn finish(&mut self) {
286        self.index();
287        for names in self.folded.values() {
288            if names.len() > 1 {
289                eprintln!(
290                    "[gasm] assets: {} differ only in case; case-insensitive lookups use {:?}",
291                    names.join(", "),
292                    names[0]
293                );
294            }
295        }
296    }
297
298    fn index(&mut self) {
299        self.folded.clear();
300        for (name, entry) in &self.exact {
301            if entry.from_dir {
302                self.folded.entry(name.to_ascii_lowercase()).or_default().push(name.clone());
303            }
304        }
305        for names in self.folded.values_mut() {
306            names.sort();
307        }
308        self.sorted = self.exact.keys().cloned().collect();
309        self.sorted.sort();
310    }
311
312    /// Look an asset up (exact name, then case-insensitively among folder entries).
313    pub fn get(&self, name: &str) -> Option<Asset<'_>> {
314        let entry = match self.exact.get(name) {
315            Some(e) => e,
316            None => self.exact.get(self.folded.get(&name.to_ascii_lowercase())?.first()?)?,
317        };
318        Some(Asset { assets: self, source: &entry.source, version: entry.version })
319    }
320
321    /// Current size in bytes, or `None` if there is no such asset.
322    pub fn size(&self, name: &str) -> Option<u64> {
323        self.get(name).map(|a| a.size())
324    }
325
326    /// Copy bytes starting at `offset` into `dst` (see [`Asset::read_at`]), or
327    /// `None` if there is no such asset.
328    pub fn read_at(&self, name: &str, offset: u64, dst: &mut [u8]) -> Option<usize> {
329        self.get(name).map(|a| a.read_at(offset, dst))
330    }
331
332    /// The asset's version (see [`Asset::version`]), or `None` if there is no such asset.
333    pub fn version(&self, name: &str) -> Option<u32> {
334        self.get(name).map(|a| a.version())
335    }
336
337    /// All asset names, sorted (by UTF-8 bytes).
338    pub fn names(&self) -> &[String] {
339        &self.sorted
340    }
341
342    /// Open a folder entry (cached). Refuses anything that is no longer a regular
343    /// file (e.g. replaced by a symlink since start-up).
344    fn open_lazy(&self, path: &Path) -> Option<Arc<File>> {
345        let mut open = self.open.borrow_mut();
346        if let Some(i) = open.iter().position(|(p, _)| p == path) {
347            let hit = open.remove(i)?;
348            let file = hit.1.clone();
349            open.push_back(hit);
350            return Some(file);
351        }
352        let ok = std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_file());
353        let file = match ok.then(|| File::open(path)) {
354            Some(Ok(f)) => Arc::new(f),
355            Some(Err(e)) => {
356                eprintln!("[gasm] assets: {}: {e}", path.display());
357                return None;
358            }
359            None => {
360                eprintln!("[gasm] assets: {}: no longer a regular file", path.display());
361                return None;
362            }
363        };
364        if open.len() >= OPEN_FILES {
365            open.pop_front();
366        }
367        open.push_back((path.to_owned(), file.clone()));
368        Some(file)
369    }
370}
371
372/// `--watch-asset name=path`: the file is re-opened as asset `name` whenever its
373/// size or modification time changes (in place or replaced by a rename), checked
374/// before each frame. A file that disappears keeps the last version until it's back.
375pub struct AssetWatch {
376    pub name: String,
377    pub path: PathBuf,
378    stamp: Option<(std::time::SystemTime, u64)>,
379}
380
381impl AssetWatch {
382    /// Watch `path`; the asset itself is added by [`AssetWatch::poll`] (or already given at start).
383    pub fn new(name: &str, path: &Path) -> AssetWatch {
384        AssetWatch { name: name.to_owned(), path: path.to_owned(), stamp: Self::stamp(path) }
385    }
386
387    fn stamp(path: &Path) -> Option<(std::time::SystemTime, u64)> {
388        let m = std::fs::metadata(path).ok()?;
389        Some((m.modified().ok()?, m.len()))
390    }
391
392    /// Re-open the file if it changed. Returns the new version, if any.
393    pub fn poll(&mut self, assets: &mut Assets) -> Option<u32> {
394        let now = Self::stamp(&self.path)?;
395        if self.stamp == Some(now) && assets.get(&self.name).is_some() {
396            return None;
397        }
398        self.stamp = Some(now);
399        match assets.set_file(&self.name, &self.path) {
400            Ok(v) => Some(v),
401            Err(e) => {
402                eprintln!("[gasm] assets: --watch-asset {e}");
403                None
404            }
405        }
406    }
407}
408
409/// Recursive, sorted walk collecting regular files (with their sizes); skips
410/// symlinks and hidden entries. Names must be valid UTF-8 (others are skipped
411/// with a warning).
412fn walk(dir: &Path, prefix: &mut Vec<String>, out: &mut Vec<(Vec<String>, PathBuf, u64)>) -> Result<(), String> {
413    let mut entries: Vec<_> = std::fs::read_dir(dir)
414        .map_err(|e| format!("{}: {e}", dir.display()))?
415        .filter_map(Result::ok)
416        .collect();
417    entries.sort_by_key(|e| e.file_name());
418    for entry in entries {
419        let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
420            eprintln!("[gasm] assets: skipping non-UTF-8 name {:?}", entry.path());
421            continue;
422        };
423        if name.starts_with('.') {
424            continue;
425        }
426        let Ok(ft) = entry.file_type() else { continue }; // does not follow symlinks
427        if ft.is_symlink() {
428            continue;
429        }
430        prefix.push(name);
431        if ft.is_dir() {
432            walk(&entry.path(), prefix, out)?;
433        } else if ft.is_file() {
434            match entry.metadata() {
435                Ok(m) => out.push((prefix.clone(), entry.path(), m.len())),
436                Err(e) => eprintln!("[gasm] assets: skipping {}: {e}", entry.path().display()),
437            }
438        }
439        prefix.pop();
440    }
441    Ok(())
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn mods_are_the_folders_packs_in_name_order() {
450        let dir = std::env::temp_dir().join(format!("gasm-mods-test-{}", std::process::id()));
451        let _ = std::fs::remove_dir_all(&dir);
452        std::fs::create_dir_all(dir.join("sub")).unwrap();
453        for f in ["b.ZIP", "a.pck", ".hidden.pck", "notes.txt", "sub/c.pck"] {
454            std::fs::write(dir.join(f), f).unwrap();
455        }
456        #[cfg(unix)]
457        {
458            use std::os::unix::fs::PermissionsExt;
459            std::fs::write(dir.join("locked.pck"), "x").unwrap();
460            std::fs::set_permissions(dir.join("locked.pck"), std::fs::Permissions::from_mode(0)).unwrap();
461        }
462        let mut a = Assets::new();
463        let (mounted, refused) = a.add_mods(&dir).unwrap();
464        assert_eq!(mounted, ["a.pck", "b.ZIP"]);
465        assert_eq!(a.size("mods/a.pck"), Some(5));
466        #[cfg(unix)]
467        if refused.len() == 1 {
468            // (as root everything is readable)
469            assert_eq!(refused[0].0, "locked.pck");
470            assert!(a.size("mods.refused").is_some());
471        }
472        let _ = std::fs::remove_dir_all(&dir);
473    }
474
475    fn tree() -> PathBuf {
476        let d = std::env::temp_dir().join(format!("gasm-assets-test-{}", std::process::id()));
477        let _ = std::fs::remove_dir_all(&d);
478        std::fs::create_dir_all(d.join("ART")).unwrap();
479        std::fs::create_dir_all(d.join("WORLDS/1PLAYER")).unwrap();
480        std::fs::write(d.join("ART/ART.CAR"), b"car").unwrap();
481        std::fs::write(d.join("WORLDS/1PLAYER/MAP.RFM"), b"map-data").unwrap();
482        std::fs::write(d.join(".DS_Store"), b"x").unwrap();
483        std::fs::write(d.join("dup.txt"), b"lower").unwrap();
484        std::fs::write(d.join("DUP.TXT"), b"upper").unwrap();
485        #[cfg(unix)]
486        std::os::unix::fs::symlink("/etc/hosts", d.join("escape")).unwrap();
487        d
488    }
489
490    #[test]
491    fn replace_and_watch() {
492        let read = |a: &Assets, n: &str| {
493            let mut buf = vec![0u8; 64];
494            a.read_at(n, 0, &mut buf).map(|k| String::from_utf8_lossy(&buf[..k]).into_owned())
495        };
496        let mut a = Assets::new();
497        a.insert_memory("weather.json", b"{}".to_vec());
498        a.finish();
499        assert_eq!(a.version("weather.json"), Some(0));
500        assert_eq!(a.set("weather.json", b"{\"t\":1}".to_vec()), 1);
501        assert_eq!((a.version("weather.json"), read(&a, "weather.json").as_deref()), (Some(1), Some("{\"t\":1}")));
502        assert_eq!(a.set("tiles/0.png", b"png".to_vec()), 2); // a new name is listed
503        assert_eq!(a.names(), ["tiles/0.png", "weather.json"]);
504        assert!(a.remove("tiles/0.png") && !a.remove("tiles/0.png"));
505        assert_eq!((a.version("tiles/0.png"), a.names().len()), (None, 1));
506        assert_eq!(a.set("tiles/0.png", b"png".to_vec()), 3); // never a version seen before
507
508        let d = std::env::temp_dir().join(format!("gasm-assets-watch-{}", std::process::id()));
509        let _ = std::fs::remove_dir_all(&d);
510        std::fs::create_dir_all(&d).unwrap();
511        let f = d.join("w.json");
512        std::fs::write(&f, b"one").unwrap();
513        let mut w = AssetWatch::new("w.json", &f);
514        assert_eq!(w.poll(&mut a), Some(4)); // not an asset yet: added
515        assert_eq!(w.poll(&mut a), None); // unchanged
516        std::fs::write(d.join("tmp"), b"second").unwrap();
517        std::fs::rename(d.join("tmp"), &f).unwrap(); // atomic replace: a new size
518        assert_eq!(w.poll(&mut a), Some(5));
519        assert_eq!(read(&a, "w.json").as_deref(), Some("second"));
520        std::fs::remove_file(&f).unwrap();
521        assert_eq!(w.poll(&mut a), None); // gone: the last version stays
522        assert_eq!(read(&a, "w.json").as_deref(), Some("second"));
523        let _ = std::fs::remove_dir_all(&d);
524    }
525
526    #[test]
527    fn folders_case_and_precedence() {
528        let d = tree();
529        let mut a = Assets::new();
530        a.insert_memory("ART/ART.CAR", b"explicit".to_vec());
531        a.add_dir(None, &d).unwrap();
532        a.add_dir(Some("cd"), &d).unwrap();
533        a.finish();
534        let read = |a: &Assets, n: &str| {
535            let mut buf = vec![0u8; 64];
536            a.read_at(n, 0, &mut buf).map(|k| String::from_utf8_lossy(&buf[..k]).into_owned())
537        };
538        assert_eq!(read(&a, "ART/ART.CAR").as_deref(), Some("explicit")); // explicit wins
539        assert_eq!(read(&a, "cd/art/art.car").as_deref(), Some("car")); // case-insensitive
540        assert_eq!(read(&a, "Worlds/1player/map.rfm").as_deref(), Some("map-data"));
541        // Case collisions only exist on case-sensitive file systems (not default macOS/Windows).
542        let case_sensitive = std::fs::read_dir(&d).unwrap().filter_map(Result::ok)
543            .filter(|e| e.file_name().to_string_lossy().eq_ignore_ascii_case("dup.txt")).count() == 2;
544        if case_sensitive {
545            assert_eq!(read(&a, "dup.txt").as_deref(), Some("lower")); // exact beats folding
546            assert_eq!(read(&a, "Dup.Txt").as_deref(), Some("upper")); // first in sorted order ("DUP.TXT" < "dup.txt")
547        }
548        assert!(a.size(".DS_Store").is_none()); // hidden skipped
549        assert!(a.size("escape").is_none()); // symlink skipped
550        assert_eq!(a.size("worlds/1player/MAP.RFM"), Some(8));
551        let mut buf = [0u8; 4];
552        assert_eq!(a.read_at("WORLDS/1PLAYER/MAP.RFM", 4, &mut buf), Some(4));
553        assert_eq!(&buf, b"data");
554        assert_eq!(a.read_at("WORLDS/1PLAYER/MAP.RFM", 100, &mut buf), Some(0)); // past the end
555        assert!(a.read_at("missing", 0, &mut buf).is_none());
556        assert!(a.names().windows(2).all(|w| w[0] < w[1]));
557        let _ = std::fs::remove_dir_all(&d);
558    }
559
560    #[test]
561    fn many_folder_files_stay_under_the_open_file_limit() {
562        let d = std::env::temp_dir().join(format!("gasm-assets-many-{}", std::process::id()));
563        let _ = std::fs::remove_dir_all(&d);
564        std::fs::create_dir_all(&d).unwrap();
565        for i in 0..(OPEN_FILES * 3) {
566            std::fs::write(d.join(format!("F{i:04}")), format!("{i}")).unwrap();
567        }
568        let mut a = Assets::new();
569        a.add_dir(None, &d).unwrap();
570        a.finish();
571        let mut buf = [0u8; 8];
572        for round in 0..2 {
573            for i in 0..(OPEN_FILES * 3) {
574                let n = a.read_at(&format!("f{i:04}"), 0, &mut buf).unwrap();
575                assert_eq!(&buf[..n], format!("{i}").as_bytes(), "round {round}");
576            }
577        }
578        assert!(a.open.borrow().len() <= OPEN_FILES);
579        let _ = std::fs::remove_dir_all(&d);
580    }
581
582    #[test]
583    fn shrinking_file_never_panics() {
584        let p = std::env::temp_dir().join(format!("gasm-shrink-{}", std::process::id()));
585        std::fs::write(&p, vec![7u8; 1000]).unwrap();
586        let mut a = Assets::new();
587        a.insert_file("f", &p).unwrap();
588        a.finish();
589        std::fs::write(&p, vec![7u8; 10]).unwrap(); // truncate while "running"
590        let mut buf = vec![0u8; 100];
591        assert_eq!(a.size("f"), Some(10));
592        assert_eq!(a.read_at("f", 0, &mut buf), Some(10));
593        assert_eq!(a.read_at("f", 500, &mut buf), Some(0));
594        std::fs::remove_file(&p).unwrap(); // vanishes
595        let _ = a.read_at("f", 0, &mut buf); // must not panic (Unix keeps the inode)
596        let _ = std::fs::remove_file(&p);
597    }
598}