1use 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";
12const MAGIC2: &[u8; 8] = b"KVASPAK2";
14pub const DEFAULT_VOLUME_BYTES: u64 = 256 * 1024 * 1024;
16
17pub struct PakData {
20 source: PakSource,
21 index: HashMap<String, (usize, usize, usize)>,
23}
24
25enum PakSource {
26 Memory(Vec<u8>),
28 Volumes(Vec<std::sync::Mutex<std::fs::File>>),
30}
31
32#[derive(Clone)]
38pub enum Assets {
39 Dir(PathBuf),
41 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 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 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 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 pub fn read(&self, path: &str) -> io::Result<Vec<u8>> {
155 self.read_bytes(path).map(Cow::into_owned)
156 }
157
158 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 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 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 pub fn list(&self) -> Vec<String> {
198 self.try_list().unwrap_or_default()
199 }
200
201 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 pub fn exists(&self, path: &str) -> bool {
218 self.byte_len(path).is_ok()
219 }
220
221 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 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
248pub 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
306pub 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
315fn 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
322pub 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 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 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 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 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 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}