Skip to main content

kvas_engine/
assets.rs

1//! Asset access. Everything reads through `Assets`, so the editor (loose files) and a shipped
2//! game (single .pak) share the exact same loading code.
3
4use std::borrow::Cow;
5use std::collections::HashMap;
6use std::io;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use std::time::SystemTime;
10
11const MAGIC: &[u8; 8] = b"KVASPAK1";
12/// Multi-volume archives: a small index file plus data volumes of bounded size.
13const MAGIC2: &[u8; 8] = b"KVASPAK2";
14/// Default upper size of one data volume (a single file larger than this gets a volume of its own).
15pub const DEFAULT_VOLUME_BYTES: u64 = 256 * 1024 * 1024;
16
17/// Validated in-memory archive shared by cheap [`Assets::Pak`] clones.
18/// Construct through [`Assets::open_pak`] or [`Assets::from_pak_bytes`].
19pub struct PakData {
20    source: PakSource,
21    /// Path -> (volume, offset, length).
22    index: HashMap<String, (usize, usize, usize)>,
23}
24
25enum PakSource {
26    /// A single-file archive held in memory (`KVASPAK1`, or built with `from_pak_bytes`).
27    Memory(Vec<u8>),
28    /// Data volumes read on demand from disk (`KVASPAK2`), so huge games do not need huge RAM.
29    Volumes(Vec<std::sync::Mutex<std::fs::File>>),
30}
31
32/// Unified asset source with root-relative, forward-slash paths.
33///
34/// Paths are normalized lexically and cannot escape the root. Directory reads
35/// also reject symlinks that resolve outside the canonical root. Hidden entries
36/// and symlinked directories are omitted from listing/packing.
37#[derive(Clone)]
38pub enum Assets {
39    /// Files beneath a project assets directory; read on demand.
40    Dir(PathBuf),
41    /// Validated archive held in shared memory; no filesystem reads after opening.
42    Pak(Arc<PakData>),
43}
44
45fn not_found(path: &str) -> io::Error {
46    io::Error::new(io::ErrorKind::NotFound, format!("asset not found: {path}"))
47}
48
49impl Assets {
50    /// Reads and validates a `KVASPAK1` archive.
51    /// Reports `InvalidData` for malformed tables, duplicate/non-canonical paths,
52    /// offsets into the table, or data ranges outside the file.
53    pub fn open_pak(path: &Path) -> io::Result<Assets> {
54        let bytes = std::fs::read(path)?;
55        if bytes.len() >= 8 && &bytes[..8] == MAGIC2 {
56            return Self::open_volumes(path, &bytes);
57        }
58        Self::from_pak_bytes(bytes)
59    }
60
61    /// Opens a `KVASPAK2` index and its data volumes (next to the index file).
62    fn open_volumes(path: &Path, bytes: &[u8]) -> io::Result<Assets> {
63        let bad = |what: &str| io::Error::new(io::ErrorKind::InvalidData, format!("corrupt pak index: {what}"));
64        let mut pos = 8usize;
65        let mut take = |n: usize| -> io::Result<&[u8]> {
66            let slice = bytes.get(pos..pos.checked_add(n).ok_or_else(|| bad("overflow"))?).ok_or_else(|| bad("truncated"))?;
67            pos += n;
68            Ok(slice)
69        };
70        let u32_of = |b: &[u8]| u32::from_le_bytes(b.try_into().unwrap());
71        let u64_of = |b: &[u8]| u64::from_le_bytes(b.try_into().unwrap());
72        let volume_count = u32_of(take(4)?) as usize;
73        let dir = path.parent().unwrap_or(Path::new("."));
74        let mut files = Vec::new();
75        let mut sizes = Vec::new();
76        for _ in 0..volume_count {
77            let name_len = u32_of(take(4)?) as usize;
78            let name = String::from_utf8(take(name_len)?.to_vec()).map_err(|_| bad("volume name"))?;
79            if name.contains(['/', '\\']) || name.is_empty() {
80                return Err(bad("volume name"));
81            }
82            let size = u64_of(take(8)?);
83            let file = std::fs::File::open(dir.join(&name)).map_err(|e| io::Error::new(e.kind(), format!("{name}: {e}")))?;
84            if file.metadata()?.len() != size {
85                return Err(bad(&format!("{name} has the wrong size (an incomplete copy?)")));
86            }
87            files.push(std::sync::Mutex::new(file));
88            sizes.push(size);
89        }
90        let count = u32_of(take(4)?) as usize;
91        let mut index = HashMap::new();
92        for _ in 0..count {
93            let name_len = u32_of(take(4)?) as usize;
94            let name = String::from_utf8(take(name_len)?.to_vec()).map_err(|_| bad("name"))?;
95            if normalize_path(&name).map_err(|_| bad("path"))? != name {
96                return Err(bad("path"));
97            }
98            let volume = u32_of(take(4)?) as usize;
99            let offset = u64_of(take(8)?);
100            let len = u64_of(take(8)?);
101            if volume >= sizes.len() || offset.checked_add(len).is_none_or(|end| end > sizes[volume]) {
102                return Err(bad("entry outside its volume"));
103            }
104            if index.insert(name, (volume, offset as usize, len as usize)).is_some() {
105                return Err(bad("duplicate entry"));
106            }
107        }
108        Ok(Assets::Pak(Arc::new(PakData { source: PakSource::Volumes(files), index })))
109    }
110
111    /// Opens an owned archive buffer without an intermediate disk file.
112    /// Uses the same validation as [`Self::open_pak`].
113    pub fn from_pak_bytes(bytes: Vec<u8>) -> io::Result<Assets> {
114        let bad = || io::Error::new(io::ErrorKind::InvalidData, "corrupt pak");
115        if bytes.len() < 12 || &bytes[..8] != MAGIC {
116            return Err(bad());
117        }
118        let u32_at = |p: usize| bytes.get(p..p + 4).map(|b| u32::from_le_bytes(b.try_into().unwrap()) as usize);
119        let u64_at = |p: usize| bytes.get(p..p.checked_add(8)?).and_then(|b| usize::try_from(u64::from_le_bytes(b.try_into().ok()?)).ok());
120        let count = u32_at(8).ok_or_else(bad)?;
121        if count > (bytes.len() - 12) / 20 {
122            return Err(bad());
123        }
124        let mut pos = 12;
125        let mut index = HashMap::new();
126        for _ in 0..count {
127            let name_len = u32_at(pos).ok_or_else(bad)?;
128            let start = pos.checked_add(4).ok_or_else(bad)?;
129            let end = start.checked_add(name_len).ok_or_else(bad)?;
130            let name = bytes.get(start..end).ok_or_else(bad)?;
131            let name = String::from_utf8(name.to_vec()).map_err(|_| bad())?;
132            if normalize_path(&name).map_err(|_| bad())? != name {
133                return Err(bad());
134            }
135            pos = end;
136            let offset = u64_at(pos).ok_or_else(bad)?;
137            let len = u64_at(pos + 8).ok_or_else(bad)?;
138            pos += 16;
139            if offset.checked_add(len).is_none_or(|end| end > bytes.len()) {
140                return Err(bad());
141            }
142            if index.insert(name, (0usize, offset, len)).is_some() {
143                return Err(bad());
144            }
145        }
146        if index.values().any(|&(_, offset, _)| offset < pos) {
147            return Err(bad());
148        }
149        Ok(Assets::Pak(Arc::new(PakData { source: PakSource::Memory(bytes), index })))
150    }
151
152    /// Reads a root-relative asset into an owned byte buffer.
153    /// Errors preserve I/O kinds (`NotFound`, `InvalidInput`, etc.).
154    pub fn read(&self, path: &str) -> io::Result<Vec<u8>> {
155        self.read_bytes(path).map(Cow::into_owned)
156    }
157
158    /// Reads an asset, borrowing archive bytes without copying when packed.
159    /// Directory assets return an owned buffer. The borrow keeps the source alive.
160    pub fn read_bytes(&self, path: &str) -> io::Result<Cow<'_, [u8]>> {
161        let path = normalize_path(path)?;
162        match self {
163            Assets::Dir(root) => std::fs::read(resolve_file(root, &path)?).map(Cow::Owned),
164            Assets::Pak(p) => {
165                let &(volume, off, len) = p.index.get(&path).ok_or_else(|| not_found(&path))?;
166                match &p.source {
167                    PakSource::Memory(bytes) => Ok(Cow::Borrowed(&bytes[off..off + len])),
168                    PakSource::Volumes(files) => {
169                        use std::io::{Read, Seek, SeekFrom};
170                        let mut file = files[volume].lock().map_err(|_| io::Error::other("pak volume lock poisoned"))?;
171                        file.seek(SeekFrom::Start(off as u64))?;
172                        let mut data = vec![0u8; len];
173                        file.read_exact(&mut data)?;
174                        Ok(Cow::Owned(data))
175                    }
176                }
177            }
178        }
179    }
180
181    /// Reads UTF-8 text, returning `InvalidData` for invalid encoding.
182    pub fn read_string(&self, path: &str) -> io::Result<String> {
183        String::from_utf8(self.read(path)?).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
184    }
185
186    /// Last modification time, used for hot reload. Always `None` inside a pak.
187    pub fn modified(&self, path: &str) -> Option<SystemTime> {
188        let path = normalize_path(path).ok()?;
189        match self {
190            Assets::Dir(root) => std::fs::metadata(resolve_file(root, &path).ok()?).and_then(|m| m.modified()).ok(),
191            Assets::Pak(_) => None,
192        }
193    }
194
195    /// All asset paths (forward slashes, relative to the root), sorted.
196    /// I/O failures return an empty list; use [`Self::try_list`] when packing/exporting.
197    pub fn list(&self) -> Vec<String> {
198        self.try_list().unwrap_or_default()
199    }
200
201    /// Lists assets while preserving directory traversal errors.
202    /// Hidden entries and symlinks are skipped consistently with packing.
203    pub fn try_list(&self) -> io::Result<Vec<String>> {
204        let mut out: Vec<String> = match self {
205            Assets::Dir(root) => {
206                let mut v = Vec::new();
207                walk(root, root, &mut v)?;
208                v
209            }
210            Assets::Pak(p) => p.index.keys().cloned().collect(),
211        };
212        out.sort();
213        Ok(out)
214    }
215
216    /// Whether an asset is a readable file. Invalid paths and I/O errors yield `false`.
217    pub fn exists(&self, path: &str) -> bool {
218        self.byte_len(path).is_ok()
219    }
220
221    /// Asset size in bytes without reading its contents.
222    pub fn byte_len(&self, path: &str) -> io::Result<u64> {
223        let path = normalize_path(path)?;
224        match self {
225            Assets::Dir(root) => {
226                let metadata = std::fs::metadata(resolve_file(root, &path)?)?;
227                if !metadata.is_file() {
228                    return Err(not_found(&path));
229                }
230                Ok(metadata.len())
231            }
232            Assets::Pak(p) => p.index.get(&path).map(|&(_, _, len)| len as u64).ok_or_else(|| not_found(&path)),
233        }
234    }
235
236    /// Sorted paths below a directory prefix (not a partial filename).
237    /// Empty prefix lists everything; invalid prefixes return an empty list.
238    pub fn list_prefix(&self, prefix: &str) -> Vec<String> {
239        if prefix.is_empty() {
240            return self.list();
241        }
242        let Ok(prefix) = normalize_path(prefix) else { return Vec::new() };
243        let prefix = format!("{prefix}/");
244        self.list().into_iter().filter(|p| p.starts_with(&prefix)).collect()
245    }
246}
247
248/// Normalizes separators and `.`/`..` segments in an asset-relative path.
249/// Rejects empty/root paths, absolute paths, drive prefixes, NUL and traversal
250/// above the root. The result uses forward slashes on every platform.
251pub fn normalize_path(path: &str) -> io::Result<String> {
252    let bad = || io::Error::new(io::ErrorKind::InvalidInput, "expected an asset-relative path within the root");
253    let path = path.replace('\\', "/");
254    if path.starts_with('/') || path.contains(':') || path.contains('\0') {
255        return Err(bad());
256    }
257    let mut parts = Vec::new();
258    for part in path.split('/') {
259        match part {
260            "" | "." => {}
261            ".." => {
262                if parts.pop().is_none() {
263                    return Err(bad());
264                }
265            }
266            _ => parts.push(part),
267        }
268    }
269    if parts.is_empty() {
270        return Err(bad());
271    }
272    Ok(parts.join("/"))
273}
274
275fn resolve_file(root: &Path, path: &str) -> io::Result<PathBuf> {
276    let root = root.canonicalize()?;
277    let file = root.join(path).canonicalize()?;
278    if !file.starts_with(&root) {
279        return Err(io::Error::new(io::ErrorKind::PermissionDenied, "asset resolves outside root"));
280    }
281    Ok(file)
282}
283
284fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) -> io::Result<()> {
285    for entry in std::fs::read_dir(dir)? {
286        let entry = entry?;
287        let path = entry.path();
288        let kind = entry.file_type()?;
289        if kind.is_symlink() {
290            continue;
291        }
292        if entry.file_name().to_string_lossy().starts_with('.') {
293            continue;
294        }
295        if kind.is_dir() {
296            walk(root, &path, out)?;
297        } else if kind.is_file() {
298            if let Ok(rel) = path.strip_prefix(root) {
299                out.push(rel.to_string_lossy().replace('\\', "/"));
300            }
301        }
302    }
303    Ok(())
304}
305
306/// Packs every file under `src`, plus `extra` in-memory files, into one archive.
307/// Returns the number of files.
308/// Extra files override loose files with the same normalized name; repeated
309/// extra names use the last value. Entries are sorted for reproducible builds.
310/// The output archive itself is excluded when located inside `src`.
311pub fn pack(src: &Path, out: &Path, extra: &[(&str, Vec<u8>)]) -> io::Result<usize> {
312    pack_with(src, out, extra, DEFAULT_VOLUME_BYTES).map(|(count, _)| count)
313}
314
315/// Data volumes of the archive being written (`<stem>.NNN.pkd` beside it) are never packed again.
316fn is_own_volume(file: &Path, output: &Path) -> bool {
317    let (Some(dir), Some(stem)) = (output.parent(), output.file_stem()) else { return false };
318    let name = file.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
319    file.parent() == Some(dir) && name.starts_with(&format!("{}.", stem.to_string_lossy())) && name.ends_with(".pkd")
320}
321
322/// Like [`pack`], with an explicit maximum volume size. Returns `(files, volumes)`.
323///
324/// The archive is an index file (`out`, e.g. `game.pak`) plus data volumes next to it
325/// (`game.000.pkd`, `game.001.pkd`...). Files are spread over the volumes largest-first so each
326/// volume ends up close to the limit and the volumes are of similar size; a file larger than the
327/// limit gets a volume of its own. Data is streamed to disk file by file, so packing a multi-gigabyte
328/// project does not need that much memory.
329pub fn pack_with(src: &Path, out: &Path, extra: &[(&str, Vec<u8>)], max_volume: u64) -> io::Result<(usize, usize)> {
330    use std::io::Write;
331    let src = src.canonicalize()?;
332    let assets = Assets::Dir(src.clone());
333    let output_path = match out.canonicalize() {
334        Ok(path) => path,
335        Err(_) => {
336            let absolute = std::path::absolute(out)?;
337            let parent =
338                absolute.parent().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "output has no parent"))?.canonicalize()?;
339            parent.join(absolute.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "output has no filename"))?)
340        }
341    };
342    let mut files: std::collections::BTreeMap<String, Option<Vec<u8>>> =
343        assets.try_list()?.into_iter().filter(|f| src.join(f) != output_path && !is_own_volume(&src.join(f), &output_path)).map(|f| (f, None)).collect();
344    for (name, data) in extra {
345        files.insert(normalize_path(name)?, Some(data.clone()));
346    }
347    if files.len() > u32::MAX as usize {
348        return Err(io::Error::new(io::ErrorKind::InvalidInput, "too many archive entries"));
349    }
350    // Sizes without reading the contents.
351    let mut sized: Vec<(String, u64)> = Vec::with_capacity(files.len());
352    for (name, inline) in &files {
353        let size = match inline {
354            Some(d) => d.len() as u64,
355            None => assets.byte_len(name)?,
356        };
357        sized.push((name.clone(), size));
358    }
359    // First-fit decreasing: biggest files first, each into the first volume with room.
360    let max_volume = max_volume.max(1024 * 1024);
361    let mut order: Vec<&(String, u64)> = sized.iter().collect();
362    order.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
363    let mut volumes: Vec<(u64, Vec<&str>)> = Vec::new();
364    for (name, size) in order {
365        match volumes.iter_mut().find(|(used, _)| *used + *size <= max_volume) {
366            Some((used, list)) => {
367                *used += *size;
368                list.push(name);
369            }
370            None => volumes.push((*size, vec![name])),
371        }
372    }
373    if volumes.is_empty() {
374        volumes.push((0, Vec::new()));
375    }
376    // Remove volumes of an earlier build, then write the new ones.
377    let dir = output_path.parent().unwrap_or(Path::new(".")).to_path_buf();
378    let stem = output_path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "game".into());
379    if let Ok(rd) = std::fs::read_dir(&dir) {
380        for e in rd.flatten() {
381            let n = e.file_name().to_string_lossy().to_string();
382            if n.starts_with(&format!("{stem}.")) && n.ends_with(".pkd") {
383                let _ = std::fs::remove_file(e.path());
384            }
385        }
386    }
387    let mut entries: Vec<(String, usize, u64, u64)> = Vec::new();
388    let mut volume_info: Vec<(String, u64)> = Vec::new();
389    for (index, (_, names)) in volumes.iter_mut().enumerate() {
390        names.sort_unstable();
391        let volume_name = format!("{stem}.{index:03}.pkd");
392        let mut writer = io::BufWriter::new(std::fs::File::create(dir.join(&volume_name))?);
393        let mut offset = 0u64;
394        for name in names.iter() {
395            let data = match &files[*name] {
396                Some(d) => d.clone(),
397                None => assets.read(name)?,
398            };
399            writer.write_all(&data)?;
400            entries.push((name.to_string(), index, offset, data.len() as u64));
401            offset += data.len() as u64;
402        }
403        writer.flush()?;
404        volume_info.push((volume_name, offset));
405    }
406    entries.sort_by(|a, b| a.0.cmp(&b.0));
407    let mut index = Vec::new();
408    index.extend(MAGIC2);
409    index.extend((volume_info.len() as u32).to_le_bytes());
410    for (name, size) in &volume_info {
411        index.extend((name.len() as u32).to_le_bytes());
412        index.extend(name.as_bytes());
413        index.extend(size.to_le_bytes());
414    }
415    index.extend((entries.len() as u32).to_le_bytes());
416    for (name, volume, offset, len) in &entries {
417        index.extend((name.len() as u32).to_le_bytes());
418        index.extend(name.as_bytes());
419        index.extend((*volume as u32).to_le_bytes());
420        index.extend(offset.to_le_bytes());
421        index.extend(len.to_le_bytes());
422    }
423    crate::file_io::atomic_write(out, &index)?;
424    Ok((entries.len(), volume_info.len()))
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    #[test]
432    fn pack_roundtrip() {
433        let dir = std::env::temp_dir().join(format!("kvas_pak_test_{}", std::process::id()));
434        std::fs::create_dir_all(dir.join("sub")).unwrap();
435        std::fs::write(dir.join("a.txt"), b"hello").unwrap();
436        std::fs::write(dir.join("sub/b.bin"), [1u8, 2, 3]).unwrap();
437        let pak = dir.join("out.pak");
438        let n = pack(&dir, &pak, &[("project.json", b"{}".to_vec())]).unwrap();
439        assert_eq!(n, 3);
440        let a = Assets::open_pak(&pak).unwrap();
441        assert_eq!(a.read("a.txt").unwrap(), b"hello");
442        assert_eq!(a.read("project.json").unwrap(), b"{}");
443        assert_eq!(a.read("sub/b.bin").unwrap(), vec![1, 2, 3]);
444        assert!(a.read("missing").is_err());
445        std::fs::remove_dir_all(dir).ok();
446    }
447
448    #[test]
449    fn big_projects_are_split_into_balanced_volumes() {
450        let dir = std::env::temp_dir().join(format!("kvas_pak_split_{}", std::process::id()));
451        std::fs::create_dir_all(dir.join("assets")).unwrap();
452        // 6 files of 700 KiB and a 2.5 MiB one: with a 2 MiB limit they cannot share one volume.
453        for i in 0..6 {
454            std::fs::write(dir.join(format!("assets/f{i}.bin")), vec![i as u8; 700 * 1024]).unwrap();
455        }
456        std::fs::write(dir.join("assets/big.bin"), vec![9u8; 2560 * 1024]).unwrap();
457        let out = dir.join("out/game.pak");
458        std::fs::create_dir_all(dir.join("out")).unwrap();
459        let (files, volumes) = pack_with(&dir.join("assets"), &out, &[("project.json", b"{}".to_vec())], 2 * 1024 * 1024).unwrap();
460        assert_eq!(files, 8);
461        assert!(volumes >= 3, "got {volumes} volumes");
462        let pak = Assets::open_pak(&out).unwrap();
463        assert_eq!(pak.read("big.bin").unwrap().len(), 2560 * 1024);
464        assert_eq!(pak.read("f3.bin").unwrap(), vec![3u8; 700 * 1024]);
465        assert_eq!(pak.read("project.json").unwrap(), b"{}");
466        assert_eq!(pak.list().len(), 8);
467        // Rebuilding replaces the old volumes instead of leaving stale ones behind.
468        pack_with(&dir.join("assets"), &out, &[], 64 * 1024 * 1024).unwrap();
469        let count = std::fs::read_dir(dir.join("out")).unwrap().flatten().filter(|e| e.file_name().to_string_lossy().ends_with(".pkd")).count();
470        assert_eq!(count, 1);
471        std::fs::remove_dir_all(dir).ok();
472    }
473}