Skip to main content

parx_rs/
format.rs

1/*
2 * Copyright 2026 PARX Authors
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17/// Magic bytes identifying a PARX file: "PARX" in ASCII.
18pub const MAGIC: [u8; 4] = *b"PARX";
19
20/// Current major version of the PARX format.
21pub const VERSION_MAJOR: u8 = 1;
22
23/// Current minor version of the PARX format.
24pub const VERSION_MINOR: u8 = 0;
25
26/// Size of the PARX header in bytes.
27pub const HEADER_SIZE: usize = 16;
28
29/// Size of the PARX trailer in bytes.
30pub const TRAILER_SIZE: usize = 12;
31
32/// Minimum valid PARX file size (header + trailer, no payload).
33pub const MIN_FILE_SIZE: usize = HEADER_SIZE + TRAILER_SIZE;
34
35/// Bit 1: Footer payload is compressed.
36pub const FLAG_FOOTER_COMPRESSED: u16 = 0x0002;
37
38/// Bits 2-3: Compression algorithm mask (when `FLAG_FOOTER_COMPRESSED` is set).
39pub const FLAG_COMPRESSION_MASK: u16 = 0x000C;
40
41/// Compression algorithm: Zstd (bits 2-3 = 00).
42pub const FLAG_COMPRESSION_ZSTD: u16 = 0x0000;
43
44/// Compression algorithm: LZ4 (bits 2-3 = 01).
45pub const FLAG_COMPRESSION_LZ4: u16 = 0x0004;
46
47/// Compression algorithm: Gzip (bits 2-3 = 10).
48pub const FLAG_COMPRESSION_GZIP: u16 = 0x0008;
49
50/// Compression algorithm used for footer data.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Compression {
53    Zstd,
54    Lz4,
55    Gzip,
56}
57
58impl Compression {
59    /// Get the corresponding flag bits for this compression algorithm.
60    #[inline]
61    pub const fn to_flag_bits(self) -> u16 {
62        match self {
63            Self::Zstd => FLAG_COMPRESSION_ZSTD,
64            Self::Lz4 => FLAG_COMPRESSION_LZ4,
65            Self::Gzip => FLAG_COMPRESSION_GZIP,
66        }
67    }
68
69    #[inline]
70    pub const fn from_flag_bits(bits: u16) -> Option<Self> {
71        match bits & FLAG_COMPRESSION_MASK {
72            FLAG_COMPRESSION_ZSTD => Some(Self::Zstd),
73            FLAG_COMPRESSION_LZ4 => Some(Self::Lz4),
74            FLAG_COMPRESSION_GZIP => Some(Self::Gzip),
75            _ => None,
76        }
77    }
78
79    #[inline]
80    pub const fn name(self) -> &'static str {
81        match self {
82            Self::Zstd => "zstd",
83            Self::Lz4 => "lz4",
84            Self::Gzip => "gzip",
85        }
86    }
87}
88
89impl std::fmt::Display for Compression {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        f.write_str(self.name())
92    }
93}
94
95impl std::str::FromStr for Compression {
96    type Err = String;
97
98    fn from_str(s: &str) -> Result<Self, Self::Err> {
99        match s.to_lowercase().as_str() {
100            "zstd" | "zstandard" => Ok(Self::Zstd),
101            "lz4" => Ok(Self::Lz4),
102            "gzip" | "gz" => Ok(Self::Gzip),
103            _ => Err(format!(
104                "unknown compression: {s}. Valid options: zstd, lz4, gzip"
105            )),
106        }
107    }
108}
109
110/// PARX file header structure (16 bytes total).
111///
112/// Layout:
113/// - bytes 0-3: magic ("PARX")
114/// - byte 4: version_major
115/// - byte 5: version_minor
116/// - bytes 6-7: flags (little-endian)
117/// - bytes 8-15: reserved
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub struct Header {
120    pub magic: [u8; 4],
121    pub version_major: u8,
122    pub version_minor: u8,
123    pub flags: u16,
124}
125
126impl Header {
127    /// Create a new header
128    #[inline]
129    pub const fn new() -> Self {
130        Self {
131            magic: MAGIC,
132            version_major: VERSION_MAJOR,
133            version_minor: VERSION_MINOR,
134            flags: 0,
135        }
136    }
137
138    /// Parse header from bytes.
139    #[inline]
140    pub const fn from_bytes(bytes: &[u8; HEADER_SIZE]) -> Self {
141        let magic = [bytes[0], bytes[1], bytes[2], bytes[3]];
142        let version_major = bytes[4];
143        let version_minor = bytes[5];
144        let flags = u16::from_le_bytes([bytes[6], bytes[7]]);
145
146        Self {
147            magic,
148            version_major,
149            version_minor,
150            flags,
151        }
152    }
153
154    /// Serialize header to bytes.
155    #[inline]
156    pub fn to_bytes(&self) -> [u8; HEADER_SIZE] {
157        let mut bytes = [0u8; HEADER_SIZE];
158        bytes[0..4].copy_from_slice(&self.magic);
159        bytes[4] = self.version_major;
160        bytes[5] = self.version_minor;
161        bytes[6..8].copy_from_slice(&self.flags.to_le_bytes());
162        // bytes 8-15 are reserved (zeros)
163        bytes
164    }
165
166    /// Check if magic matches expected value.
167    #[inline]
168    pub fn is_magic_valid(&self, expected_magic: [u8; 4]) -> bool {
169        self.magic == expected_magic
170    }
171
172    /// Check if version is supported.
173    #[inline]
174    pub const fn is_version_supported(&self) -> bool {
175        self.version_major == VERSION_MAJOR
176    }
177
178    /// Check if the footer is compressed.
179    #[inline]
180    pub const fn is_footer_compressed(&self) -> bool {
181        self.flags & FLAG_FOOTER_COMPRESSED != 0
182    }
183
184    /// Get the compression algorithm, if footer is compressed.
185    #[inline]
186    pub const fn compression_algorithm(&self) -> Option<Compression> {
187        if !self.is_footer_compressed() {
188            return None;
189        }
190        Compression::from_flag_bits(self.flags)
191    }
192
193    /// Set the compression algorithm (also sets the compressed flag).
194    #[inline]
195    pub fn set_compression(&mut self, compression: Compression) {
196        self.flags |= FLAG_FOOTER_COMPRESSED;
197        self.flags &= !FLAG_COMPRESSION_MASK;
198        self.flags |= compression.to_flag_bits();
199    }
200
201    /// Clear compression (footer is uncompressed).
202    #[inline]
203    pub fn clear_compression(&mut self) {
204        self.flags &= !FLAG_FOOTER_COMPRESSED;
205        self.flags &= !FLAG_COMPRESSION_MASK;
206    }
207}
208
209impl Default for Header {
210    fn default() -> Self {
211        Self::new()
212    }
213}
214
215/// PARX file trailer (12 bytes, at EOF).
216///
217/// Layout:
218/// - bytes 0-3: manifest_len (u32 LE)
219/// - bytes 4-7: manifest_crc32c (u32 LE)
220/// - bytes 8-11: magic ("PARX")
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub struct Trailer {
223    pub manifest_len: u32,
224    pub manifest_crc32c: u32,
225    pub magic: [u8; 4],
226}
227
228impl Trailer {
229    /// Create a new trailer.
230    #[inline]
231    pub const fn new(manifest_len: u32, manifest_crc32c: u32, magic: [u8; 4]) -> Self {
232        Self {
233            manifest_len,
234            manifest_crc32c,
235            magic,
236        }
237    }
238
239    /// Parse trailer from bytes.
240    #[inline]
241    pub const fn from_bytes(bytes: &[u8; TRAILER_SIZE]) -> Self {
242        let manifest_len = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
243        let manifest_crc32c = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
244        let magic = [bytes[8], bytes[9], bytes[10], bytes[11]];
245
246        Self {
247            manifest_len,
248            manifest_crc32c,
249            magic,
250        }
251    }
252
253    /// Serialize trailer to bytes.
254    #[inline]
255    pub fn to_bytes(&self) -> [u8; TRAILER_SIZE] {
256        let mut bytes = [0u8; TRAILER_SIZE];
257        bytes[0..4].copy_from_slice(&self.manifest_len.to_le_bytes());
258        bytes[4..8].copy_from_slice(&self.manifest_crc32c.to_le_bytes());
259        bytes[8..12].copy_from_slice(&self.magic);
260        bytes
261    }
262
263    /// Check if magic matches expected value.
264    #[inline]
265    pub fn is_magic_valid(&self, expected_magic: [u8; 4]) -> bool {
266        self.magic == expected_magic
267    }
268}
269
270/// Magic bytes identifying a PARX bundle file: "PRXB" in ASCII.
271pub const BUNDLE_MAGIC: [u8; 4] = *b"PRXB";
272
273/// Size of the PARX bundle header in bytes.
274pub const BUNDLE_HEADER_SIZE: usize = 24;
275
276/// Bundle header (24 bytes).
277///
278/// Layout:
279/// - bytes 0-3: magic ("PRXB")
280/// - byte 4: version_major
281/// - byte 5: version_minor
282/// - bytes 6-7: flags (little-endian)
283/// - bytes 8-15: entry_count (u64 LE)
284/// - bytes 16-23: reserved
285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
286pub struct BundleHeader {
287    pub magic: [u8; 4],
288    pub version_major: u8,
289    pub version_minor: u8,
290    pub flags: u16,
291    pub entry_count: u64,
292}
293
294impl BundleHeader {
295    /// Create a new bundle header.
296    #[inline]
297    pub const fn new(entry_count: u64) -> Self {
298        Self {
299            magic: BUNDLE_MAGIC,
300            version_major: VERSION_MAJOR,
301            version_minor: VERSION_MINOR,
302            flags: 0,
303            entry_count,
304        }
305    }
306
307    /// Parse bundle header from bytes
308    #[inline]
309    pub const fn from_bytes(bytes: &[u8; BUNDLE_HEADER_SIZE]) -> Self {
310        let magic = [bytes[0], bytes[1], bytes[2], bytes[3]];
311        let version_major = bytes[4];
312        let version_minor = bytes[5];
313        let flags = u16::from_le_bytes([bytes[6], bytes[7]]);
314        let entry_count = u64::from_le_bytes([
315            bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
316        ]);
317
318        Self {
319            magic,
320            version_major,
321            version_minor,
322            flags,
323            entry_count,
324        }
325    }
326
327    /// Serialize bundle header to bytes.
328    #[inline]
329    pub fn to_bytes(&self) -> [u8; BUNDLE_HEADER_SIZE] {
330        let mut bytes = [0u8; BUNDLE_HEADER_SIZE];
331        bytes[0..4].copy_from_slice(&self.magic);
332        bytes[4] = self.version_major;
333        bytes[5] = self.version_minor;
334        bytes[6..8].copy_from_slice(&self.flags.to_le_bytes());
335        bytes[8..16].copy_from_slice(&self.entry_count.to_le_bytes());
336        // bytes 16-23 are reserved (zeros)
337        bytes
338    }
339
340    /// Check if magic is valid.
341    #[inline]
342    pub fn is_magic_valid(&self) -> bool {
343        self.magic == BUNDLE_MAGIC
344    }
345
346    /// Check if version is supported.
347    #[inline]
348    pub const fn is_version_supported(&self) -> bool {
349        self.version_major == VERSION_MAJOR
350    }
351}
352
353impl Default for BundleHeader {
354    fn default() -> Self {
355        Self::new(0)
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    #[test]
364    fn test_header_roundtrip() {
365        let header = Header::new();
366        let bytes = header.to_bytes();
367        let parsed = Header::from_bytes(&bytes);
368        assert_eq!(header, parsed);
369    }
370
371    #[test]
372    fn test_trailer_roundtrip() {
373        let trailer = Trailer::new(1234, 0xDEAD_BEEF, MAGIC);
374        let bytes = trailer.to_bytes();
375        let parsed = Trailer::from_bytes(&bytes);
376        assert_eq!(trailer, parsed);
377    }
378
379    #[test]
380    fn test_magic_validation() {
381        let header = Header::new();
382        assert!(header.is_magic_valid(MAGIC));
383
384        let mut bad_header = header;
385        bad_header.magic = *b"NOPE";
386        assert!(!bad_header.is_magic_valid(MAGIC));
387    }
388
389    #[test]
390    fn test_compression_flag() {
391        let mut header = Header::new();
392        assert!(!header.is_footer_compressed());
393        assert!(header.compression_algorithm().is_none());
394
395        header.set_compression(Compression::Zstd);
396        assert!(header.is_footer_compressed());
397        assert_eq!(header.compression_algorithm(), Some(Compression::Zstd));
398
399        header.set_compression(Compression::Lz4);
400        assert!(header.is_footer_compressed());
401        assert_eq!(header.compression_algorithm(), Some(Compression::Lz4));
402
403        header.set_compression(Compression::Gzip);
404        assert!(header.is_footer_compressed());
405        assert_eq!(header.compression_algorithm(), Some(Compression::Gzip));
406
407        header.clear_compression();
408        assert!(!header.is_footer_compressed());
409        assert!(header.compression_algorithm().is_none());
410    }
411
412    #[test]
413    fn test_header_flags_roundtrip() {
414        let mut header = Header::new();
415        header.set_compression(Compression::Lz4);
416
417        let bytes = header.to_bytes();
418        let parsed = Header::from_bytes(&bytes);
419
420        assert_eq!(parsed.flags, header.flags);
421        assert_eq!(parsed.compression_algorithm(), Some(Compression::Lz4));
422    }
423
424    #[test]
425    fn test_bundle_header_roundtrip() {
426        let header = BundleHeader::new(42);
427        let bytes = header.to_bytes();
428        let parsed = BundleHeader::from_bytes(&bytes);
429        assert_eq!(header, parsed);
430        assert_eq!(parsed.entry_count, 42);
431    }
432
433    #[test]
434    fn test_compression_from_str() {
435        assert_eq!("zstd".parse::<Compression>().unwrap(), Compression::Zstd);
436        assert_eq!("ZSTD".parse::<Compression>().unwrap(), Compression::Zstd);
437        assert_eq!("lz4".parse::<Compression>().unwrap(), Compression::Lz4);
438        assert_eq!("gzip".parse::<Compression>().unwrap(), Compression::Gzip);
439        assert_eq!("gz".parse::<Compression>().unwrap(), Compression::Gzip);
440        assert!("invalid".parse::<Compression>().is_err());
441    }
442
443    #[test]
444    fn test_compression_display() {
445        assert_eq!(Compression::Zstd.to_string(), "zstd");
446        assert_eq!(Compression::Lz4.to_string(), "lz4");
447        assert_eq!(Compression::Gzip.to_string(), "gzip");
448    }
449}