1use std::collections::{BTreeMap, HashMap};
22use std::fs::File;
23use std::path::{Path, PathBuf};
24
25pub 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 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 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 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 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 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 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 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 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
207fn 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 }; 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")); assert_eq!(read(&a, "cd/art/art.car").as_deref(), Some("car")); assert_eq!(read(&a, "Worlds/1player/map.rfm").as_deref(), Some("map-data"));
272 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")); assert_eq!(read(&a, "Dup.Txt").as_deref(), Some("upper")); }
279 assert!(a.size(".DS_Store").is_none()); assert!(a.size("escape").is_none()); 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)); 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(); 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(); let _ = a.read_at("f", 0, &mut buf); let _ = std::fs::remove_file(&p);
304 }
305}