Skip to main content

zipatch_rs/chunk/sqpk/
file.rs

1use crate::reader::ReadExt;
2use crate::{Result, ZiPatchError};
3use flate2::read::DeflateDecoder;
4use std::borrow::Cow;
5use std::io::{Cursor, Read, Write};
6
7/// Operation byte of a SQPK `F` command.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum SqpkFileOperation {
10    /// `A` — add or overwrite a file with the inline block payload.
11    AddFile,
12    /// `R` — remove all files in the expansion folder except a keep-list.
13    RemoveAll,
14    /// `D` — delete a single file.
15    DeleteFile,
16    /// `M` — create a directory tree.
17    MakeDirTree,
18}
19
20/// One DEFLATE-or-raw block of a `SqpkFile` payload.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct SqpkCompressedBlock {
23    is_compressed: bool,
24    decompressed_size: usize,
25    data: Vec<u8>, // compressed: raw DEFLATE + alignment padding; uncompressed: exact bytes
26}
27
28impl SqpkCompressedBlock {
29    /// Build a block from its parsed parts (test/external use).
30    #[must_use]
31    pub fn new(is_compressed: bool, decompressed_size: usize, data: Vec<u8>) -> Self {
32        Self {
33            is_compressed,
34            decompressed_size,
35            data,
36        }
37    }
38
39    fn read<R: Read>(r: &mut R) -> Result<Self> {
40        let header_size_raw = r.read_i32_le()?;
41        r.skip(4)?; // pad
42        let compressed_size = r.read_i32_le()?;
43        let decompressed_size_raw = r.read_i32_le()?;
44
45        if header_size_raw < 0 {
46            return Err(ZiPatchError::InvalidField {
47                context: "negative header_size in block",
48            });
49        }
50        if decompressed_size_raw < 0 {
51            return Err(ZiPatchError::InvalidField {
52                context: "negative decompressed_size in block",
53            });
54        }
55        let is_compressed = compressed_size != 0x7d00;
56        if is_compressed && compressed_size < 0 {
57            return Err(ZiPatchError::InvalidField {
58                context: "negative compressed_size in block",
59            });
60        }
61
62        let header_size = header_size_raw as usize;
63        let decompressed_size = decompressed_size_raw as usize;
64        let data_len = if is_compressed {
65            compressed_size
66        } else {
67            decompressed_size_raw
68        };
69        let block_len = ((data_len as u32 + 143) & !127u32) as usize;
70        let data = if is_compressed {
71            r.read_exact_vec(block_len - header_size)?
72        } else {
73            let d = r.read_exact_vec(decompressed_size)?;
74            r.skip((block_len - header_size - decompressed_size) as u64)?;
75            d
76        };
77        Ok(SqpkCompressedBlock {
78            is_compressed,
79            decompressed_size,
80            data,
81        })
82    }
83
84    /// Stream the block's decompressed bytes into `w`.
85    ///
86    /// Uncompressed blocks are written verbatim; compressed blocks are run
87    /// through a DEFLATE decoder. Returns [`ZiPatchError::Decompress`] on a
88    /// decompression error.
89    pub fn decompress_into(&self, w: &mut impl Write) -> Result<()> {
90        if self.is_compressed {
91            std::io::copy(&mut DeflateDecoder::new(self.data.as_slice()), w)
92                .map_err(ZiPatchError::Decompress)?;
93        } else {
94            w.write_all(&self.data)?;
95        }
96        Ok(())
97    }
98
99    /// Return the block's decompressed bytes.
100    ///
101    /// Uncompressed blocks return a borrow into the existing buffer with no
102    /// allocation; compressed blocks allocate and decompress into a new `Vec`.
103    pub fn decompress(&self) -> crate::Result<Cow<'_, [u8]>> {
104        if self.is_compressed {
105            let mut out = Vec::with_capacity(self.decompressed_size);
106            self.decompress_into(&mut out)?;
107            Ok(Cow::Owned(out))
108        } else {
109            Ok(Cow::Borrowed(&self.data))
110        }
111    }
112}
113
114/// SQPK `F` command body: file-level operation (add/delete/etc.) on the install tree.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct SqpkFile {
117    /// Operation type (`A`/`R`/`D`/`M`).
118    pub operation: SqpkFileOperation,
119    /// Destination offset within the target file (negative is rejected at apply).
120    pub file_offset: i64,
121    /// Declared total size of the target file after the operation.
122    pub file_size: i64,
123    /// Expansion ID; selects the `ex<n>` (or `ffxiv`) sub-folder for `RemoveAll`.
124    pub expansion_id: u16,
125    /// Relative target path under the game install root.
126    pub path: String,
127    /// Byte offset of each block's data payload (after its 16-byte header)
128    /// within the SQPK command body slice. Add the chunk's absolute file
129    /// position to get the patch-file offset needed for `IndexedZiPatch`
130    /// random-access reads.
131    pub block_source_offsets: Vec<u64>,
132    /// Inline block payloads for `AddFile`; empty for other operations.
133    pub blocks: Vec<SqpkCompressedBlock>,
134}
135
136pub(crate) fn parse(body: &[u8]) -> Result<SqpkFile> {
137    let mut c = Cursor::new(body);
138
139    let operation = match c.read_u8()? {
140        b'A' => SqpkFileOperation::AddFile,
141        b'R' => SqpkFileOperation::RemoveAll,
142        b'D' => SqpkFileOperation::DeleteFile,
143        b'M' => SqpkFileOperation::MakeDirTree,
144        b => {
145            return Err(ZiPatchError::UnknownFileOperation(b));
146        }
147    };
148    c.skip(2)?; // alignment
149
150    let file_offset = c.read_u64_be()? as i64;
151    let file_size = c.read_u64_be()? as i64;
152    let path_len = c.read_u32_be()?;
153    let expansion_id = c.read_u16_be()?;
154    c.skip(2)?; // padding
155
156    let path_bytes = c.read_exact_vec(path_len as usize)?;
157    let path = String::from_utf8(path_bytes)
158        .map(|s| s.trim_end_matches('\0').to_owned())
159        .map_err(ZiPatchError::Utf8Error)?;
160
161    let (blocks, block_source_offsets) = if matches!(operation, SqpkFileOperation::AddFile) {
162        let mut blocks = Vec::new();
163        let mut offsets = Vec::new();
164        while (c.position() as usize) < body.len() {
165            // Record offset of the data payload (after the fixed 16-byte block header).
166            offsets.push(c.position() + 16);
167            blocks.push(SqpkCompressedBlock::read(&mut c)?);
168        }
169        (blocks, offsets)
170    } else {
171        (Vec::new(), Vec::new())
172    };
173
174    Ok(SqpkFile {
175        operation,
176        file_offset,
177        file_size,
178        expansion_id,
179        path,
180        block_source_offsets,
181        blocks,
182    })
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    fn make_header(
190        op: u8,
191        file_offset: i64,
192        file_size: i64,
193        path: &[u8],
194        expansion_id: u16,
195    ) -> Vec<u8> {
196        let mut body = Vec::new();
197        body.push(op);
198        body.extend_from_slice(&[0u8; 2]); // alignment
199        body.extend_from_slice(&(file_offset as u64).to_be_bytes());
200        body.extend_from_slice(&(file_size as u64).to_be_bytes());
201        body.extend_from_slice(&(path.len() as u32).to_be_bytes());
202        body.extend_from_slice(&expansion_id.to_be_bytes());
203        body.extend_from_slice(&[0u8; 2]); // padding
204        body.extend_from_slice(path);
205        body
206    }
207
208    #[test]
209    fn parses_add_file_no_blocks() {
210        let body = make_header(b'A', 0, 512, b"test\0", 1);
211        let cmd = parse(&body).unwrap();
212        assert!(matches!(cmd.operation, SqpkFileOperation::AddFile));
213        assert_eq!(cmd.file_offset, 0);
214        assert_eq!(cmd.file_size, 512);
215        assert_eq!(cmd.expansion_id, 1);
216        assert_eq!(cmd.path, "test");
217        assert!(cmd.blocks.is_empty());
218        assert!(cmd.block_source_offsets.is_empty());
219    }
220
221    #[test]
222    fn parses_add_file_uncompressed_block() {
223        // block_len = ((8 + 143) & !127) = 128; read 8 data bytes + skip 104 padding
224        let mut body = make_header(b'A', 0, 0, b"\0", 0);
225        // header bytes: 1+2+8+8+4+2+2+1 = 28 — block starts at offset 28
226        body.extend_from_slice(&16i32.to_le_bytes()); // header_size
227        body.extend_from_slice(&0u32.to_le_bytes()); // pad
228        body.extend_from_slice(&0x7d00i32.to_le_bytes()); // compressed_size = uncompressed sentinel
229        body.extend_from_slice(&8i32.to_le_bytes()); // decompressed_size
230        body.extend_from_slice(&[0xABu8; 8]); // data
231        body.extend_from_slice(&[0u8; 104]); // alignment padding
232
233        let cmd = parse(&body).unwrap();
234        assert_eq!(cmd.blocks.len(), 1);
235        let block = &cmd.blocks[0];
236        assert!(!block.is_compressed);
237        assert_eq!(block.decompressed_size, 8);
238        assert_eq!(block.data.len(), 8);
239        assert!(block.data.iter().all(|&b| b == 0xAB));
240        assert_eq!(block.decompress().unwrap(), vec![0xABu8; 8]);
241        assert_eq!(cmd.block_source_offsets, vec![44u64]); // 28 (header) + 16 (block header)
242    }
243
244    #[test]
245    fn parses_remove_all_operation() {
246        let body = make_header(b'R', 0, 0, b"\0", 0);
247        let cmd = parse(&body).unwrap();
248        assert!(matches!(cmd.operation, SqpkFileOperation::RemoveAll));
249        assert!(cmd.blocks.is_empty());
250        assert!(cmd.block_source_offsets.is_empty());
251    }
252
253    #[test]
254    fn parses_delete_file_operation() {
255        let body = make_header(b'D', 0, 0, b"sqpack/foo.dat\0", 0);
256        let cmd = parse(&body).unwrap();
257        assert!(matches!(cmd.operation, SqpkFileOperation::DeleteFile));
258        assert_eq!(cmd.path, "sqpack/foo.dat");
259    }
260
261    #[test]
262    fn parses_make_dir_tree_operation() {
263        let body = make_header(b'M', 0, 0, b"sqpack/ex1\0", 0);
264        let cmd = parse(&body).unwrap();
265        assert!(matches!(cmd.operation, SqpkFileOperation::MakeDirTree));
266        assert_eq!(cmd.path, "sqpack/ex1");
267    }
268
269    #[test]
270    fn rejects_unknown_operation() {
271        let body = make_header(b'Z', 0, 0, b"\0", 0);
272        assert!(parse(&body).is_err());
273    }
274
275    fn block_with_sizes(header_size: i32, compressed_size: i32, decompressed_size: i32) -> Vec<u8> {
276        let mut body = make_header(b'A', 0, 0, b"\0", 0);
277        body.extend_from_slice(&header_size.to_le_bytes());
278        body.extend_from_slice(&0u32.to_le_bytes()); // pad
279        body.extend_from_slice(&compressed_size.to_le_bytes());
280        body.extend_from_slice(&decompressed_size.to_le_bytes());
281        body
282    }
283
284    #[test]
285    fn rejects_negative_header_size() {
286        let body = block_with_sizes(-1, 0x7d00, 0);
287        let Err(ZiPatchError::InvalidField { context }) = parse(&body) else {
288            panic!("expected InvalidField for negative header_size");
289        };
290        assert!(
291            context.contains("header_size"),
292            "unexpected context: {context}"
293        );
294    }
295
296    #[test]
297    fn rejects_negative_decompressed_size() {
298        let body = block_with_sizes(16, 0x7d00, -1);
299        let Err(ZiPatchError::InvalidField { context }) = parse(&body) else {
300            panic!("expected InvalidField for negative decompressed_size");
301        };
302        assert!(
303            context.contains("decompressed_size"),
304            "unexpected context: {context}"
305        );
306    }
307
308    #[test]
309    fn rejects_negative_compressed_size() {
310        // is_compressed = (compressed_size != 0x7d00) — pass -1 (not 0x7d00).
311        let body = block_with_sizes(16, -1, 8);
312        let Err(ZiPatchError::InvalidField { context }) = parse(&body) else {
313            panic!("expected InvalidField for negative compressed_size");
314        };
315        assert!(
316            context.contains("compressed_size"),
317            "unexpected context: {context}"
318        );
319    }
320
321    #[test]
322    fn rejects_invalid_utf8_in_path() {
323        // 0xFF is not valid UTF-8 — Utf8Error path on `String::from_utf8`.
324        let body = make_header(b'D', 0, 0, &[0xFFu8], 0);
325        assert!(matches!(parse(&body), Err(ZiPatchError::Utf8Error(_))));
326    }
327
328    #[test]
329    fn decompress_into_uncompressed_writes_data_verbatim() {
330        // Uncompressed branch: w.write_all(&self.data).
331        let block = SqpkCompressedBlock::new(false, 5, b"hello".to_vec());
332        let mut out = Vec::new();
333        block.decompress_into(&mut out).unwrap();
334        assert_eq!(out, b"hello");
335    }
336
337    #[test]
338    fn decompress_returns_borrowed_for_uncompressed() {
339        // Cow::Borrowed branch — no allocation, points at the block's data.
340        let block = SqpkCompressedBlock::new(false, 4, b"data".to_vec());
341        let cow = block.decompress().unwrap();
342        assert!(matches!(cow, Cow::Borrowed(_)));
343        assert_eq!(&*cow, b"data");
344    }
345
346    #[test]
347    fn decompress_into_compressed_propagates_decompress_error() {
348        // Garbage DEFLATE payload — the `.map_err(ZiPatchError::Decompress)?` arm.
349        let block = SqpkCompressedBlock::new(true, 16, vec![0xFFu8; 16]);
350        let mut out = Vec::new();
351        assert!(matches!(
352            block.decompress_into(&mut out),
353            Err(ZiPatchError::Decompress(_))
354        ));
355        // And via the `decompress()` wrapper — the `?` error arm at line 106.
356        assert!(matches!(
357            block.decompress(),
358            Err(ZiPatchError::Decompress(_))
359        ));
360    }
361
362    #[test]
363    fn parses_compressed_block() {
364        use flate2::Compression;
365        use flate2::write::DeflateEncoder;
366        use std::io::Write;
367
368        let raw: &[u8] = b"hello compressed world";
369        let mut enc = DeflateEncoder::new(Vec::new(), Compression::default());
370        enc.write_all(raw).unwrap();
371        let compressed = enc.finish().unwrap();
372
373        let header_size: i32 = 16;
374        let compressed_size = compressed.len() as i32;
375        let decompressed_size = raw.len() as i32;
376        let block_len = ((compressed_size as u32 + 143) & !127) as usize;
377        let trailing_pad = block_len - header_size as usize - compressed.len();
378
379        // header bytes: 1+2+8+8+4+2+2+1 = 28 — block starts at offset 28
380        let mut body = make_header(b'A', 0, 0, b"\0", 0);
381        body.extend_from_slice(&header_size.to_le_bytes());
382        body.extend_from_slice(&0u32.to_le_bytes()); // pad
383        body.extend_from_slice(&compressed_size.to_le_bytes());
384        body.extend_from_slice(&decompressed_size.to_le_bytes());
385        body.extend_from_slice(&compressed);
386        body.extend_from_slice(&vec![0u8; trailing_pad]);
387
388        let cmd = parse(&body).unwrap();
389        assert_eq!(cmd.blocks.len(), 1);
390        let block = &cmd.blocks[0];
391        assert!(block.is_compressed);
392        assert_eq!(block.decompressed_size, raw.len());
393        assert_eq!(block.decompress().unwrap(), raw);
394        assert_eq!(cmd.block_source_offsets, vec![44u64]); // 28 (header) + 16 (block header)
395    }
396}