rustpak/lib.rs
1//! Rustpak - A library for reading and writing `GoldSrc` .pak archive files
2//!
3//! This library provides functionality to work with .pak files used by
4//! Quake, Half-Life, and other `GoldSrc` engine games. The .pak format
5//! is a simple archive format containing a header, a file table, and file data.
6//!
7//! # Basic Usage
8//!
9//! ```no_run
10//! use rustpak::Pak;
11//!
12//! // Load an existing pak file
13//! let pak = Pak::from_file("data.pak".to_string()).unwrap();
14//!
15//! // List all files
16//! for file in &pak.files {
17//! println!("{} - {} bytes", file.name, file.size);
18//! }
19//!
20//! // Save modifications
21//! pak.save("data_modified.pak".to_string()).unwrap();
22//! ```
23
24use std::{
25 borrow::Borrow,
26 error::Error,
27 fs::{self, File},
28 io::{self, Read, Seek, SeekFrom},
29 path,
30};
31
32use byteorder::{ByteOrder, LittleEndian, WriteBytesExt};
33
34/// Header structure for .pak archive files
35///
36/// The header is always 12 bytes and contains magic identifier,
37/// offset to the file table, and size of the file table.
38#[derive(Debug)]
39pub struct PakHeader {
40 /// Should be "PACK" (not null-terminated).
41 pub id: String,
42 /// Index to the beginning of the file table.
43 pub offset: u32,
44 /// Size of the file table.
45 pub size: u32,
46}
47
48impl Default for PakHeader {
49 fn default() -> Self {
50 Self::new()
51 }
52}
53
54impl PakHeader {
55 /// Creates a new `PakHeader` with default values
56 ///
57 /// Returns a header with "PACK" magic and zeroed offset/size.
58 #[must_use]
59 pub fn new() -> PakHeader {
60 PakHeader {
61 id: "PACK".to_string(),
62 offset: 0,
63 size: 0,
64 }
65 }
66
67 /// Parses a `PakHeader` from a byte slice
68 ///
69 /// # Arguments
70 ///
71 /// * `buf` - A byte slice containing at least 12 bytes of header data
72 ///
73 /// # Errors
74 ///
75 /// Returns an error if the buffer is too short or contains invalid UTF-8 for the magic bytes
76 #[must_use = "the result should be used, as it may contain parsing errors"]
77 pub fn from_u8(buf: &[u8]) -> Result<PakHeader, Box<dyn Error>> {
78 Ok(PakHeader {
79 id: String::from_utf8(buf[0..4].to_vec())?,
80 offset: LittleEndian::read_u32(&buf[4..8]),
81 size: LittleEndian::read_u32(&buf[8..12]),
82 })
83 }
84
85 /// Writes the header to a writer
86 ///
87 /// # Arguments
88 ///
89 /// * `writer` - Any type implementing `std::io::Write`
90 ///
91 /// # Errors
92 ///
93 /// Returns an error if writing to the writer fails
94 pub fn write_to<W: io::Write>(&self, mut writer: W) -> Result<(), Box<dyn Error>> {
95 writer.write_all(self.id.as_bytes())?;
96 writer.write_u32::<LittleEndian>(self.offset)?;
97 writer.write_u32::<LittleEndian>(self.size)?;
98 Ok(())
99 }
100}
101
102/// Represents a single file entry within a .pak archive
103///
104/// Each file entry consists of metadata (name, offset, size) and the
105/// actual file data. Entries in the file table are always 64 bytes,
106/// with the name padded to 56 bytes with null terminators.
107#[derive(Debug)]
108pub struct PakFileEntry {
109 /// 56 byte null-terminated string including path.
110 /// Example: "maps/e1m1.bsp"
111 pub name: String,
112 /// The offset from the beginning of the pak file to this file's contents
113 pub offset: u32,
114 /// The size of this file in bytes
115 pub size: u32,
116 /// The raw file data
117 data: Vec<u8>,
118}
119
120impl PakFileEntry {
121 /// Parses a `PakFileEntry` from header buffer and full file data
122 ///
123 /// # Arguments
124 ///
125 /// * `header_buf` - 64 bytes containing the file entry metadata
126 /// * `file_buf` - Full .pak file data to extract file contents from
127 ///
128 /// # Errors
129 ///
130 /// Returns an error if buffer sizes are insufficient or data is out of bounds
131 #[must_use = "the result should be used, as it may contain parsing errors"]
132 pub fn from_u8(header_buf: &[u8], file_buf: &[u8]) -> Result<PakFileEntry, Box<dyn Error>> {
133 let namebuf = header_buf[0..56].to_vec();
134
135 let nul_range_end = namebuf
136 .iter()
137 .position(|&c| c == b'\0')
138 .unwrap_or(namebuf.len()); // default to length if no `\0` present
139
140 let offset = LittleEndian::read_u32(&header_buf[56..60]);
141 let size = LittleEndian::read_u32(&header_buf[60..64]);
142
143 Ok(PakFileEntry {
144 name: String::from_utf8(header_buf[0..nul_range_end].to_vec())?
145 .trim()
146 .to_string(),
147 offset,
148 size,
149 data: (file_buf[offset as usize..(offset + size) as usize]).to_vec(),
150 })
151 }
152
153 /// Extracts this file's contents to disk
154 ///
155 /// # Arguments
156 ///
157 /// * `path` - Destination path for the extracted file
158 /// * `with_full_path` - If true, creates directory structure from path;
159 /// if false, only uses filename
160 ///
161 /// # Errors
162 ///
163 /// Returns an error if file operations fail
164 ///
165 /// # Panics
166 ///
167 /// Panics if the path has no parent directory or if the filename cannot be converted to UTF-8
168 pub fn save_to(&self, path: &str, with_full_path: bool) -> Result<String, std::io::Error> {
169 let data: &Vec<u8> = self.data.borrow();
170 let mut path = path::Path::new(&path);
171
172 if with_full_path {
173 if let Some(parent) = path.parent() {
174 fs::create_dir_all(parent)?;
175 } else {
176 return Err(std::io::Error::new(
177 std::io::ErrorKind::InvalidInput,
178 "Path has no parent directory",
179 ));
180 }
181 } else if let Some(file_name) = path.file_name() {
182 if let Some(file_str) = file_name.to_str() {
183 path = path::Path::new(file_str);
184 } else {
185 return Err(std::io::Error::new(
186 std::io::ErrorKind::InvalidInput,
187 "Filename contains invalid UTF-8 characters",
188 ));
189 }
190 } else {
191 return Err(std::io::Error::new(
192 std::io::ErrorKind::InvalidInput,
193 "Path has no filename",
194 ));
195 }
196
197 std::fs::write(path, data)?;
198 Ok(path
199 .to_str()
200 .ok_or_else(|| {
201 std::io::Error::new(
202 std::io::ErrorKind::InvalidInput,
203 "Path contains invalid UTF-8 characters",
204 )
205 })?
206 .to_string())
207 }
208
209 /// Returns a reference to the file's raw data
210 #[must_use]
211 pub fn get_data(&self) -> &Vec<u8> {
212 &self.data
213 }
214
215 /// Creates a new `PakFileEntry` with the given parameters
216 ///
217 /// # Arguments
218 ///
219 /// * `name` - The file name/path (will be stored in file table)
220 /// * `offset` - Byte offset within the .pak file where data will be stored
221 /// * `data` - The actual file contents
222 ///
223 /// # Panics
224 ///
225 /// Panics if the data size exceeds `u32::MAX`
226 #[must_use]
227 pub fn new(name: String, offset: u32, data: &[u8]) -> PakFileEntry {
228 PakFileEntry {
229 name,
230 offset,
231 size: u32::try_from(data.len()).expect("file size exceeds u32::MAX"),
232 data: data.to_vec(),
233 }
234 }
235
236 /// Writes the file entry metadata (64 bytes) to a writer
237 ///
238 /// The name is padded with null bytes to 56 bytes, followed by
239 /// offset (4 bytes) and size (4 bytes).
240 ///
241 /// # Arguments
242 ///
243 /// * `writer` - Any type implementing `std::io::Write`
244 ///
245 /// # Errors
246 ///
247 /// Returns an error if writing fails
248 pub fn write_to<W: io::Write>(&self, mut writer: W) -> Result<(), Box<dyn Error>> {
249 let mut buf = self.name.as_bytes().to_vec();
250
251 while buf.len() < 56 {
252 buf.push(0_u8);
253 }
254 writer.write_all(buf.as_slice())?;
255 writer.write_u32::<LittleEndian>(self.offset)?;
256 writer.write_u32::<LittleEndian>(self.size)?;
257
258 Ok(())
259 }
260}
261
262/// Represents a complete .pak archive file
263///
264/// Contains the archive header and collection of file entries.
265/// Provides methods for reading, writing, and manipulating archives.
266#[derive(Debug)]
267pub struct Pak {
268 /// Path to the .pak file on disk (if loaded from file)
269 pub pak_path: String,
270 /// Archive header containing PACK magic, offset, and size
271 pub header: PakHeader,
272 /// All files contained in the archive
273 pub files: Vec<PakFileEntry>,
274}
275
276impl Default for Pak {
277 fn default() -> Self {
278 Self::new()
279 }
280}
281
282impl Pak {
283 /// Creates a new empty Pak archive
284 ///
285 /// Returns a Pak with empty path, default header, and no files.
286 #[must_use]
287 pub fn new() -> Pak {
288 Pak {
289 pak_path: String::new(),
290 header: PakHeader::new(),
291 files: Vec::new(),
292 }
293 }
294
295 /// Loads a .pak archive from disk
296 ///
297 /// # Arguments
298 ///
299 /// * `path` - Path to the .pak file to load
300 ///
301 /// # Errors
302 ///
303 /// Returns an error if the file doesn't exist, can't be read,
304 /// or has an invalid format
305 pub fn from_file(path: String) -> Result<Pak, Box<dyn Error>> {
306 let bytes = std::fs::read(&path)?;
307 let pakheader = PakHeader::from_u8(&bytes)?;
308 let num_files = pakheader.size / 64;
309
310 let file_table_offset = pakheader.offset;
311 let mut my_offset: u32 = 0;
312 let mut pakfiles: Vec<PakFileEntry> = Vec::new();
313
314 for _i in 0..num_files {
315 let file_entry = PakFileEntry::from_u8(
316 &bytes[(file_table_offset + my_offset) as usize
317 ..(file_table_offset + my_offset + 64) as usize],
318 &bytes,
319 )?;
320 pakfiles.push(file_entry);
321
322 my_offset += 64;
323 }
324
325 Ok(Pak {
326 pak_path: path,
327 header: pakheader,
328 files: pakfiles,
329 })
330 }
331
332 /// Adds a file entry to the archive
333 ///
334 /// # Arguments
335 ///
336 /// * `file` - The `PakFileEntry` to add
337 ///
338 /// # Errors
339 ///
340 /// Returns an error if a file with the same name already exists
341 pub fn add_file(&mut self, file: PakFileEntry) -> Result<&mut Pak, Box<dyn Error>> {
342 if self.files.iter().any(|f| f.name.eq(&file.name)) {
343 Err(Box::new(PakFileError {
344 msg: "File already exists".to_string(),
345 }))
346 } else {
347 self.files.push(file);
348 Ok(self)
349 }
350 }
351
352 /// Removes a file from the archive by name
353 ///
354 /// # Arguments
355 ///
356 /// * `filename` - Name of the file to remove
357 ///
358 /// # Errors
359 ///
360 /// Returns an error if the file is not found
361 pub fn remove_file(&mut self, filename: &str) -> Result<(), Box<dyn Error>> {
362 if let Some(p) = self.files.iter().position(|p| p.name.eq(&filename)) {
363 self.files.remove(p);
364 Ok(())
365 } else {
366 Err(Box::new(PakFileError {
367 msg: "file entry not found".to_string(),
368 }))
369 }
370 }
371
372 /// Saves the archive to a file
373 ///
374 /// Writes the archive in standard .pak format:
375 /// - 12-byte header
376 /// - File table entries (64 bytes each)
377 /// - File data at specified offsets
378 ///
379 /// # Arguments
380 ///
381 /// * `filename` - Path where the .pak file should be saved
382 ///
383 /// # Errors
384 ///
385 /// Returns an error if file creation or writing fails
386 ///
387 /// # Panics
388 ///
389 /// Panics if the directory size exceeds `u32::MAX`
390 pub fn save(&self, filename: String) -> Result<(), Box<dyn Error>> {
391 let mut hdr = PakHeader::new();
392 hdr.offset = 12;
393 hdr.size = u32::try_from(self.files.len() * 64).expect("directory size exceeds u32::MAX");
394
395 let mut f = File::create(filename)?;
396 hdr.write_to(&f)?;
397
398 for file in &self.files {
399 file.write_to(&f)?;
400 }
401
402 for file in &self.files {
403 f.seek(SeekFrom::Start(u64::from(file.offset)))?;
404 io::Write::write(&mut f, file.data.as_slice())?;
405 }
406
407 Ok(())
408 }
409
410 /// Appends a file from disk to the archive
411 ///
412 /// Reads a file from disk and adds it to the archive with the
413 /// path specified in `pakfilepath`.
414 ///
415 /// # Arguments
416 ///
417 /// * `infilepath` - Path to the file on disk to read
418 /// * `pakfilepath` - Path/name to store within the .pak archive
419 ///
420 /// # Errors
421 ///
422 /// Returns an error if the input file doesn't exist or can't be read,
423 /// or if a file with that name already exists in the archive
424 ///
425 /// # Panics
426 ///
427 /// Panics if opening the pak file fails, reading file metadata fails,
428 /// reading file data fails, or if adding the file to the archive fails
429 pub fn append_file(
430 &mut self,
431 infilepath: String,
432 pakfilepath: &str,
433 ) -> Result<(), Box<dyn Error>> {
434 fn get_last_offset(path: String) -> Result<u32, Box<dyn Error>> {
435 let f = File::open(path)?;
436 let metadata = f.metadata()?;
437 let len = metadata.len();
438 u32::try_from(len).map_err(|_| {
439 Box::new(std::io::Error::new(
440 std::io::ErrorKind::InvalidData,
441 "file size exceeds u32::MAX",
442 )) as Box<dyn Error>
443 })
444 }
445
446 fn get_file_data(path: String) -> Result<Vec<u8>, Box<dyn Error>> {
447 let mut f = File::open(path)?;
448 let mut vec: Vec<u8> = Vec::new();
449 f.read_to_end(&mut vec)?;
450 Ok(vec)
451 }
452
453 let newfilepath = path::Path::new(&infilepath);
454 if !newfilepath.exists() {
455 return Err(Box::new(PakFileError {
456 msg: "File does not exist!".to_string(),
457 }));
458 }
459
460 let last_offset = get_last_offset(self.pak_path.clone())?;
461 let data = get_file_data(infilepath)?;
462
463 let fe = PakFileEntry::new(pakfilepath.to_string(), last_offset, &data);
464 self.add_file(fe)?;
465 Ok(())
466 }
467}
468
469impl std::fmt::Display for Pak {
470 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
471 write!(
472 f,
473 "<Pak structure from file {} with {} files>",
474 self.pak_path,
475 self.files.len()
476 )
477 }
478}
479
480/// Error type for .pak file operations
481///
482/// Used to report errors during file loading, saving, and manipulation.
483#[derive(Debug, Clone)]
484pub struct PakFileError {
485 /// Error message describing what went wrong
486 pub msg: String,
487}
488
489impl std::fmt::Display for PakFileError {
490 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
491 write!(f, "{}", self.msg)
492 }
493}
494
495impl Error for PakFileError {}