Skip to main content

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 {}