Skip to main content

zipatch_rs/chunk/sqpk/
file.rs

1use crate::reader::{PREALLOC_CAP, ReadExt};
2use crate::{Result, ZiPatchError};
3use flate2::read::DeflateDecoder;
4use flate2::{Decompress, FlushDecompress, Status};
5use std::borrow::Cow;
6use std::io::{Cursor, Read, Write};
7
8/// Operation byte of a SQPK `F` command; selects what the command does to
9/// the game install tree.
10///
11/// Encoded as a single ASCII byte in the wire format:
12/// `b'A'` → `AddFile`, `b'R'` → `RemoveAll`, `b'D'` → `DeleteFile`,
13/// `b'M'` → `MakeDirTree`. Any other byte is rejected with
14/// [`ZiPatchError::UnknownFileOperation`].
15///
16/// See `SqpkFile.cs` in the `XIVLauncher` reference implementation.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum SqpkFileOperation {
19    /// `A` — write the inline compressed-block payload into a file under the
20    /// game install root, creating it (or overwriting it) as needed.
21    ///
22    /// Parent directories are created automatically. If `file_offset` is zero,
23    /// the target file is truncated to zero before writing (full replacement);
24    /// if `file_offset` is non-zero, only the covered range is overwritten.
25    AddFile,
26    /// `R` — delete all files in the expansion folder (`sqpack/<expansion>/`
27    /// and `movie/<expansion>/`) that are not on the keep-list.
28    ///
29    /// Kept unconditionally: `.var` files and `00000.bk2`–`00003.bk2`.
30    /// Files `00004.bk2` and beyond are deleted. `expansion_id` selects
31    /// the target expansion folder.
32    RemoveAll,
33    /// `D` — delete a single file at the path given by `SqpkFile::path`.
34    DeleteFile,
35    /// `M` — create the directory tree at `SqpkFile::path` (equivalent to
36    /// `std::fs::create_dir_all`). Idempotent.
37    MakeDirTree,
38}
39
40/// One block of a [`SqpkFile`] `AddFile` payload, which may be DEFLATE-compressed
41/// or stored raw.
42///
43/// `SqpkFile` payloads are split into a sequence of these blocks. Each block
44/// begins with a 16-byte little-endian header that describes the compressed
45/// and decompressed sizes, followed by the data bytes padded to a 128-byte
46/// boundary.
47///
48/// ## Compression sentinel
49///
50/// The `compressed_size` field in the wire header uses the value `0x7d00`
51/// (decimal **32000**) as a sentinel meaning "this block is not compressed".
52/// Any other value means the data bytes are a raw DEFLATE stream
53/// (no zlib wrapper, no gzip header — just RFC 1951 raw deflate).
54///
55/// ## Wire format of one block (all little-endian)
56///
57/// ```text
58/// ┌─────────────────────────────────────────────────────────────────────┐
59/// │ header_size     : i32 LE   always 16 in practice                   │  bytes 0–3
60/// │ <pad>           : u32 LE   always zero                              │  bytes 4–7
61/// │ compressed_size : i32 LE   byte count of DEFLATE data               │  bytes 8–11
62/// │                             OR 0x7d00 (32000) if uncompressed       │
63/// │ decompressed_size : i32 LE  byte count of decompressed output       │  bytes 12–15
64/// │ data            : [u8]     compressed or raw bytes                  │  bytes 16–…
65/// │ <alignment>     : [u8]     zero-padding to 128-byte boundary        │
66/// └─────────────────────────────────────────────────────────────────────┘
67/// ```
68///
69/// ## 128-byte alignment formula
70///
71/// The total byte count to read for a block's data + alignment is:
72///
73/// ```text
74/// block_len = (data_len + 143) & !127
75/// ```
76///
77/// where `data_len` is `compressed_size` if compressed, or `decompressed_size`
78/// if uncompressed. The constant 143 is `128 - 1 + 16` (subtract the 16-byte
79/// header that is not included in `data_len`, then round up to the next
80/// 128-byte boundary). The number of data bytes actually read is
81/// `block_len - header_size`; the alignment padding is consumed but discarded.
82///
83/// ## `pub(crate)` visibility
84///
85/// `SqpkCompressedBlock` is `pub` so that it appears in rustdoc and can be
86/// named in `SqpkFile::blocks`, but it can only be constructed via
87/// [`new`](SqpkCompressedBlock::new) (for tests) or by parsing a [`SqpkFile`].
88///
89/// See `SqpkFile.cs` / `ZiPatchConfig.cs` in the `XIVLauncher` reference implementation.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct SqpkCompressedBlock {
92    // true  → data holds raw DEFLATE bytes (compressed_size != 0x7d00)
93    // false → data holds the exact decompressed bytes (compressed_size == 0x7d00)
94    is_compressed: bool,
95    // Expected output size in bytes; used to pre-allocate the decompression buffer.
96    decompressed_size: usize,
97    // Compressed blocks: the raw DEFLATE stream, trimmed to compressed_size bytes
98    //   (alignment padding is consumed by read() but not stored here).
99    // Uncompressed blocks: the exact payload bytes, already stripped of padding.
100    data: Vec<u8>,
101}
102
103impl SqpkCompressedBlock {
104    /// Construct a block directly from its component parts.
105    ///
106    /// This constructor exists primarily for unit tests. Production code
107    /// creates blocks by parsing a [`SqpkFile`] from a patch byte stream.
108    ///
109    /// - `is_compressed`: `true` if `data` is a raw DEFLATE stream.
110    /// - `decompressed_size`: the expected number of bytes after decompression;
111    ///   used to pre-allocate the output buffer in
112    ///   [`decompress`](SqpkCompressedBlock::decompress).
113    /// - `data`: raw compressed bytes or exact uncompressed bytes, depending
114    ///   on `is_compressed`.
115    #[must_use]
116    pub fn new(is_compressed: bool, decompressed_size: usize, data: Vec<u8>) -> Self {
117        Self {
118            is_compressed,
119            decompressed_size,
120            data,
121        }
122    }
123
124    // Parse one block from the reader, consuming header + data + alignment padding.
125    //
126    // Reads the 16-byte little-endian block header, determines whether the block
127    // is compressed (compressed_size != 0x7d00), computes the 128-byte-aligned
128    // total length via (data_len + 143) & !127, then reads exactly that many
129    // bytes minus the header size — leaving the reader positioned at the start
130    // of the next block.
131    fn read<R: Read>(r: &mut R) -> Result<Self> {
132        // 16-byte block header, all fields little-endian:
133        //   i32 header_size  (always 16)
134        //   u32 pad          (always 0)
135        //   i32 compressed_size   (0x7d00 = uncompressed sentinel)
136        //   i32 decompressed_size
137        let header_size_raw = r.read_i32_le()?;
138        r.skip(4)?; // pad — always zero, no semantic content
139        let compressed_size = r.read_i32_le()?;
140        let decompressed_size_raw = r.read_i32_le()?;
141
142        if header_size_raw < 0 {
143            return Err(ZiPatchError::InvalidField {
144                context: "negative header_size in block",
145            });
146        }
147        if decompressed_size_raw < 0 {
148            return Err(ZiPatchError::InvalidField {
149                context: "negative decompressed_size in block",
150            });
151        }
152        // 0x7d00 (32000) is the sentinel for "store raw, not compressed".
153        // Any other value is the byte count of the DEFLATE stream.
154        let is_compressed = compressed_size != 0x7d00;
155        if is_compressed && compressed_size < 0 {
156            return Err(ZiPatchError::InvalidField {
157                context: "negative compressed_size in block",
158            });
159        }
160
161        let header_size = header_size_raw as usize;
162        let decompressed_size = decompressed_size_raw as usize;
163        // data_len is the logical size used for alignment: for compressed blocks
164        // it is the compressed byte count; for uncompressed it is the raw byte count.
165        let data_len = if is_compressed {
166            compressed_size
167        } else {
168            decompressed_size_raw
169        };
170        // Round data_len up to the next 128-byte boundary, accounting for the
171        // 16-byte header that precedes the data in the stream.
172        // Formula: (data_len + 128 - 1 + (header_size=16)) & !127
173        //        = (data_len + 143) & !127
174        let block_len = ((data_len as u32 + 143) & !127u32) as usize;
175        // Underflow guard: a malformed header where `header_size` exceeds the
176        // aligned `block_len` would wrap to a huge size in release builds.
177        let data_region = block_len
178            .checked_sub(header_size)
179            .ok_or(ZiPatchError::InvalidField {
180                context: "block_len smaller than header_size",
181            })?;
182        let data = if is_compressed {
183            // Read the DEFLATE payload plus any alignment padding. For compressed
184            // blocks we store everything (padding included) because DeflateDecoder
185            // stops at the end of the DEFLATE stream before reading into padding.
186            r.read_exact_vec(data_region)?
187        } else {
188            // Uncompressed: read exactly decompressed_size bytes of payload,
189            // then skip any alignment padding so the reader is positioned at
190            // the start of the next block.
191            let padding =
192                data_region
193                    .checked_sub(decompressed_size)
194                    .ok_or(ZiPatchError::InvalidField {
195                        context: "block data region smaller than decompressed_size",
196                    })?;
197            let d = r.read_exact_vec(decompressed_size)?;
198            r.skip(padding as u64)?;
199            d
200        };
201        Ok(SqpkCompressedBlock {
202            is_compressed,
203            decompressed_size,
204            data,
205        })
206    }
207
208    /// Stream the block's decompressed bytes into `w`.
209    ///
210    /// For uncompressed blocks, `w.write_all(&self.data)` is called directly.
211    /// For compressed blocks, the data is piped through [`DeflateDecoder`] (raw
212    /// DEFLATE, RFC 1951 — no zlib or gzip wrapper) before being written.
213    ///
214    /// This is the primary write path used by the apply layer: each block in a
215    /// [`SqpkFile`] `AddFile` operation is streamed into the target file handle
216    /// in sequence.
217    ///
218    /// # Errors
219    ///
220    /// - [`ZiPatchError::Decompress`] — the DEFLATE stream is malformed or
221    ///   truncated.
222    /// - [`ZiPatchError::Io`] — `w.write_all` failed.
223    pub fn decompress_into(&self, w: &mut impl Write) -> Result<()> {
224        if self.is_compressed {
225            std::io::copy(&mut DeflateDecoder::new(self.data.as_slice()), w)
226                .map_err(ZiPatchError::Decompress)?;
227        } else {
228            w.write_all(&self.data)?;
229        }
230        Ok(())
231    }
232
233    /// Stream the block's decompressed bytes into `w`, reusing a caller-owned
234    /// [`Decompress`] state across blocks.
235    ///
236    /// Equivalent to [`decompress_into`](SqpkCompressedBlock::decompress_into)
237    /// in behaviour and error semantics, but avoids the per-call ~100 KiB
238    /// zlib-state allocation that [`DeflateDecoder::new`] would otherwise
239    /// pay. The apply layer threads a single `Decompress` through every
240    /// block in a multi-block `SqpkFile::AddFile` chunk; uncompressed blocks
241    /// short-circuit to `write_all` and leave the decompressor untouched.
242    ///
243    /// `decompressor` is reset via [`Decompress::reset(false)`](Decompress::reset)
244    /// at the start of every compressed block, so callers may pass an
245    /// already-used state without manually resetting it.
246    ///
247    /// # Errors
248    ///
249    /// - [`ZiPatchError::Decompress`] — the DEFLATE stream is malformed or
250    ///   the manual feed loop made no forward progress (corrupt or truncated
251    ///   payload).
252    /// - [`ZiPatchError::Io`] — `w.write_all` failed.
253    pub fn decompress_into_with(
254        &self,
255        decompressor: &mut Decompress,
256        w: &mut impl Write,
257    ) -> Result<()> {
258        if !self.is_compressed {
259            w.write_all(&self.data)?;
260            return Ok(());
261        }
262
263        // Raw DEFLATE — match the legacy `DeflateDecoder::new(_)` zlib_header=false.
264        decompressor.reset(false);
265        // 8 KiB output buffer matches `std::io::copy`'s default and is plenty
266        // for the per-iteration output the underlying miniz_oxide / zlib-ng
267        // backends emit. Stays on the stack — no allocation per block.
268        let mut out = [0u8; 8 * 1024];
269        let mut input: &[u8] = &self.data;
270        loop {
271            let before_in = decompressor.total_in();
272            let before_out = decompressor.total_out();
273            let status = decompressor
274                .decompress(input, &mut out, FlushDecompress::None)
275                .map_err(|e| {
276                    ZiPatchError::Decompress(std::io::Error::new(
277                        std::io::ErrorKind::InvalidData,
278                        e,
279                    ))
280                })?;
281            let consumed = (decompressor.total_in() - before_in) as usize;
282            let produced = (decompressor.total_out() - before_out) as usize;
283            if produced > 0 {
284                w.write_all(&out[..produced])?;
285            }
286            input = &input[consumed..];
287            match status {
288                Status::StreamEnd => return Ok(()),
289                Status::Ok | Status::BufError => {
290                    // Forward progress is required. SqPack DEFLATE blocks are
291                    // self-contained — the trailing alignment padding the parser
292                    // intentionally leaves in `self.data` is past the
293                    // end-of-stream marker, so the decoder must signal
294                    // StreamEnd before exhausting the input. A no-progress loop
295                    // means the payload is corrupt or truncated.
296                    if consumed == 0 && produced == 0 {
297                        return Err(ZiPatchError::Decompress(std::io::Error::new(
298                            std::io::ErrorKind::InvalidData,
299                            "DEFLATE stream made no forward progress",
300                        )));
301                    }
302                }
303            }
304        }
305    }
306
307    /// Returns `true` if the block stores a raw DEFLATE stream.
308    ///
309    /// `false` means the block carries already-decompressed bytes (the
310    /// `compressed_size == 0x7d00` sentinel).
311    #[must_use]
312    pub fn is_compressed(&self) -> bool {
313        self.is_compressed
314    }
315
316    /// Returns the block's expected decompressed length in bytes.
317    #[must_use]
318    pub fn decompressed_size(&self) -> usize {
319        self.decompressed_size
320    }
321
322    /// Returns the byte length of the block's stored `data` slab.
323    ///
324    /// For compressed blocks this is the length of the DEFLATE payload as the
325    /// parser stored it (which may include trailing 128-byte alignment padding
326    /// that the decoder ignores past the end-of-stream marker). For
327    /// uncompressed blocks it equals [`decompressed_size`](Self::decompressed_size).
328    #[must_use]
329    pub fn data_len(&self) -> usize {
330        self.data.len()
331    }
332
333    /// Return the block's decompressed bytes as a [`Cow`].
334    ///
335    /// Uncompressed blocks return `Cow::Borrowed(&self.data)` — a zero-copy
336    /// borrow into the block's existing buffer. Compressed blocks decompress
337    /// into a newly allocated `Vec` and return `Cow::Owned`.
338    ///
339    /// Use [`decompress_into`](SqpkCompressedBlock::decompress_into) instead
340    /// when writing to a file handle, to avoid the intermediate allocation.
341    ///
342    /// # Errors
343    ///
344    /// - [`ZiPatchError::Decompress`] — the DEFLATE stream is malformed or
345    ///   truncated (compressed blocks only).
346    pub fn decompress(&self) -> crate::Result<Cow<'_, [u8]>> {
347        if self.is_compressed {
348            // Cap pre-alloc: `decompressed_size` originates from the parsed
349            // block header. See [`crate::reader::PREALLOC_CAP`] for rationale.
350            let mut out = Vec::with_capacity(self.decompressed_size.min(PREALLOC_CAP));
351            self.decompress_into(&mut out)?;
352            Ok(Cow::Owned(out))
353        } else {
354            Ok(Cow::Borrowed(&self.data))
355        }
356    }
357}
358
359/// SQPK `F` command body: a file-level operation on the game install tree.
360///
361/// Unlike the block-oriented commands (`A`, `D`, `E`) that target `SqPack`
362/// archive internals, `F` operates on whole files in the install directory.
363/// The operation to perform is selected by [`operation`](SqpkFile::operation).
364///
365/// ## Wire format
366///
367/// ```text
368/// ┌──────────────────────────────────────────────────────────────────────────┐
369/// │ operation    : u8      b'A', b'R', b'D', or b'M'                        │  byte 0
370/// │ <padding>    : [u8; 2] (always zero)                                     │  bytes 1–2
371/// │ file_offset  : u64 BE  destination byte offset within the target file    │  bytes 3–10
372/// │ file_size    : u64 BE  declared size of the target file after operation  │  bytes 11–18
373/// │ path_len     : u32 BE  byte length of the path field (including NUL)     │  bytes 19–22
374/// │ expansion_id : u16 BE  expansion folder selector for `RemoveAll`         │  bytes 23–24
375/// │ <padding>    : [u8; 2] (always zero)                                     │  bytes 25–26
376/// │ path         : [u8; path_len]  NUL-terminated UTF-8 path                │  bytes 27–…
377/// │ [blocks]     : SqpkCompressedBlock…  (only for `AddFile`)                │
378/// └──────────────────────────────────────────────────────────────────────────┘
379/// ```
380///
381/// `file_offset` and `file_size` are stored as big-endian `u64` in the wire
382/// format but cast to `i64` after parsing (negative values in `file_offset`
383/// cause [`ZiPatchError::NegativeFileOffset`] at apply time).
384///
385/// The NUL terminator in `path` is stripped during parsing; [`path`](SqpkFile::path)
386/// always contains a clean UTF-8 string.
387///
388/// For `AddFile` operations the remaining bytes in the command body after the
389/// path form a sequence of [`SqpkCompressedBlock`]s (see that type's
390/// documentation for the block wire format). For all other operations the block
391/// list is empty.
392///
393/// ## Reference
394///
395/// See `SqpkFile.cs` in the `XIVLauncher` reference implementation.
396///
397/// # Errors
398///
399/// Parsing returns a [`crate::ZiPatchError`] if:
400/// - The operation byte is not `b'A'`, `b'R'`, `b'D'`, or `b'M'`
401///   → [`ZiPatchError::UnknownFileOperation`].
402/// - The path bytes are not valid UTF-8 → [`ZiPatchError::Utf8Error`].
403/// - A block header contains a negative `header_size` or `decompressed_size`,
404///   or a negative non-sentinel `compressed_size`
405///   → [`ZiPatchError::InvalidField`].
406/// - The body is too short → [`ZiPatchError::Io`].
407#[derive(Debug, Clone, PartialEq, Eq)]
408pub struct SqpkFile {
409    /// The file operation to perform.
410    pub operation: SqpkFileOperation,
411    /// Destination byte offset within the target file.
412    ///
413    /// For `AddFile`: if zero, the target file is truncated to zero before
414    /// writing (complete replacement); if positive, writing begins at this
415    /// byte offset in the existing file. Negative values (cast from the raw
416    /// `u64`) are rejected at apply time with [`ZiPatchError::NegativeFileOffset`].
417    ///
418    /// Unused by `RemoveAll`, `DeleteFile`, and `MakeDirTree`.
419    pub file_offset: i64,
420    /// Declared total size of the target file after the operation, in bytes.
421    ///
422    /// Informational; the apply layer does not use this to pre-allocate or
423    /// truncate the file (truncation is controlled by `file_offset == 0`).
424    pub file_size: i64,
425    /// Expansion folder selector used by `RemoveAll`.
426    ///
427    /// `0` → `ffxiv` (base game), `n > 0` → `ex<n>`. Corresponds to the
428    /// high byte of `sub_id` in block-oriented commands.
429    pub expansion_id: u16,
430    /// Relative path to the target file or directory under the game install root.
431    ///
432    /// NUL terminator is stripped during parsing. For `AddFile` / `DeleteFile`
433    /// this is joined with the install root via `generic_path`. For `MakeDirTree`
434    /// it is the directory tree to create.
435    pub path: String,
436    /// Byte offset of each block's data payload — measured from the start of
437    /// the SQPK command body slice — after skipping the block's 16-byte header.
438    ///
439    /// `block_source_offsets[i]` corresponds to `blocks[i]`. Adding the chunk's
440    /// absolute position in the patch file to this offset gives the patch-file
441    /// byte offset where the block's data begins, enabling `IndexedZiPatch`
442    /// random-access reads that do not need to decompress the full stream.
443    ///
444    /// Empty for all operations other than `AddFile`.
445    pub block_source_offsets: Vec<u64>,
446    /// Inline compressed-or-raw block payloads that make up the file content.
447    ///
448    /// Only populated for `AddFile`; empty for `RemoveAll`, `DeleteFile`, and
449    /// `MakeDirTree`. Each block is decompressed in sequence into the target
450    /// file by the apply layer. See [`SqpkCompressedBlock`] for the block wire
451    /// format and DEFLATE discrimination logic.
452    pub blocks: Vec<SqpkCompressedBlock>,
453}
454
455// Parse a SQPK 'F' command body into a SqpkFile.
456//
457// Reads the fixed-size header fields (operation, offsets, sizes, path),
458// then — for AddFile only — iterates over the remaining bytes in `body`,
459// parsing SqpkCompressedBlock entries until the cursor reaches the end.
460// The block source offsets are recorded as the cursor position + 16 (to
461// skip the block's own 16-byte header) before each SqpkCompressedBlock::read
462// call.
463pub(crate) fn parse(body: &[u8]) -> Result<SqpkFile> {
464    let mut c = Cursor::new(body);
465
466    let operation = match c.read_u8()? {
467        b'A' => SqpkFileOperation::AddFile,
468        b'R' => SqpkFileOperation::RemoveAll,
469        b'D' => SqpkFileOperation::DeleteFile,
470        b'M' => SqpkFileOperation::MakeDirTree,
471        b => {
472            return Err(ZiPatchError::UnknownFileOperation(b));
473        }
474    };
475    c.skip(2)?; // alignment
476
477    let file_offset = c.read_u64_be()? as i64;
478    let file_size = c.read_u64_be()? as i64;
479    let path_len = c.read_u32_be()? as usize;
480    let expansion_id = c.read_u16_be()?;
481    c.skip(2)?; // padding
482
483    // Cap path_len against remaining body bytes — without this an attacker
484    // can declare a 4 GiB path and OOM the patcher (issue #30).
485    let remaining = body.len().saturating_sub(c.position() as usize);
486    if path_len > remaining {
487        return Err(ZiPatchError::InvalidField {
488            context: "SqpkFile path_len exceeds remaining body bytes",
489        });
490    }
491    let path_bytes = c.read_exact_vec(path_len)?;
492    let path = String::from_utf8(path_bytes)
493        .map(|s| s.trim_end_matches('\0').to_owned())
494        .map_err(ZiPatchError::Utf8Error)?;
495
496    let (blocks, block_source_offsets) = if matches!(operation, SqpkFileOperation::AddFile) {
497        let mut blocks = Vec::new();
498        let mut offsets = Vec::new();
499        while (c.position() as usize) < body.len() {
500            // Record offset of the data payload (after the fixed 16-byte block header).
501            offsets.push(c.position() + 16);
502            blocks.push(SqpkCompressedBlock::read(&mut c)?);
503        }
504        (blocks, offsets)
505    } else {
506        (Vec::new(), Vec::new())
507    };
508
509    Ok(SqpkFile {
510        operation,
511        file_offset,
512        file_size,
513        expansion_id,
514        path,
515        block_source_offsets,
516        blocks,
517    })
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    fn make_header(
525        op: u8,
526        file_offset: i64,
527        file_size: i64,
528        path: &[u8],
529        expansion_id: u16,
530    ) -> Vec<u8> {
531        let mut body = Vec::new();
532        body.push(op);
533        body.extend_from_slice(&[0u8; 2]); // alignment
534        body.extend_from_slice(&(file_offset as u64).to_be_bytes());
535        body.extend_from_slice(&(file_size as u64).to_be_bytes());
536        body.extend_from_slice(&(path.len() as u32).to_be_bytes());
537        body.extend_from_slice(&expansion_id.to_be_bytes());
538        body.extend_from_slice(&[0u8; 2]); // padding
539        body.extend_from_slice(path);
540        body
541    }
542
543    #[test]
544    fn parses_add_file_no_blocks() {
545        let body = make_header(b'A', 0, 512, b"test\0", 1);
546        let cmd = parse(&body).unwrap();
547        assert!(matches!(cmd.operation, SqpkFileOperation::AddFile));
548        assert_eq!(cmd.file_offset, 0);
549        assert_eq!(cmd.file_size, 512);
550        assert_eq!(cmd.expansion_id, 1);
551        assert_eq!(cmd.path, "test");
552        assert!(cmd.blocks.is_empty());
553        assert!(cmd.block_source_offsets.is_empty());
554    }
555
556    #[test]
557    fn parses_add_file_uncompressed_block() {
558        // block_len = ((8 + 143) & !127) = 128; read 8 data bytes + skip 104 padding
559        let mut body = make_header(b'A', 0, 0, b"\0", 0);
560        // header bytes: 1+2+8+8+4+2+2+1 = 28 — block starts at offset 28
561        body.extend_from_slice(&16i32.to_le_bytes()); // header_size
562        body.extend_from_slice(&0u32.to_le_bytes()); // pad
563        body.extend_from_slice(&0x7d00i32.to_le_bytes()); // compressed_size = uncompressed sentinel
564        body.extend_from_slice(&8i32.to_le_bytes()); // decompressed_size
565        body.extend_from_slice(&[0xABu8; 8]); // data
566        body.extend_from_slice(&[0u8; 104]); // alignment padding
567
568        let cmd = parse(&body).unwrap();
569        assert_eq!(cmd.blocks.len(), 1);
570        let block = &cmd.blocks[0];
571        assert!(!block.is_compressed);
572        assert_eq!(block.decompressed_size, 8);
573        assert_eq!(block.data.len(), 8);
574        assert!(block.data.iter().all(|&b| b == 0xAB));
575        assert_eq!(block.decompress().unwrap(), vec![0xABu8; 8]);
576        assert_eq!(cmd.block_source_offsets, vec![44u64]); // 28 (header) + 16 (block header)
577    }
578
579    #[test]
580    fn parses_remove_all_operation() {
581        let body = make_header(b'R', 0, 0, b"\0", 0);
582        let cmd = parse(&body).unwrap();
583        assert!(matches!(cmd.operation, SqpkFileOperation::RemoveAll));
584        assert!(cmd.blocks.is_empty());
585        assert!(cmd.block_source_offsets.is_empty());
586    }
587
588    #[test]
589    fn parses_delete_file_operation() {
590        let body = make_header(b'D', 0, 0, b"sqpack/foo.dat\0", 0);
591        let cmd = parse(&body).unwrap();
592        assert!(matches!(cmd.operation, SqpkFileOperation::DeleteFile));
593        assert_eq!(cmd.path, "sqpack/foo.dat");
594    }
595
596    #[test]
597    fn parses_make_dir_tree_operation() {
598        let body = make_header(b'M', 0, 0, b"sqpack/ex1\0", 0);
599        let cmd = parse(&body).unwrap();
600        assert!(matches!(cmd.operation, SqpkFileOperation::MakeDirTree));
601        assert_eq!(cmd.path, "sqpack/ex1");
602    }
603
604    #[test]
605    fn rejects_unknown_operation() {
606        let body = make_header(b'Z', 0, 0, b"\0", 0);
607        assert!(parse(&body).is_err());
608    }
609
610    fn block_with_sizes(header_size: i32, compressed_size: i32, decompressed_size: i32) -> Vec<u8> {
611        let mut body = make_header(b'A', 0, 0, b"\0", 0);
612        body.extend_from_slice(&header_size.to_le_bytes());
613        body.extend_from_slice(&0u32.to_le_bytes()); // pad
614        body.extend_from_slice(&compressed_size.to_le_bytes());
615        body.extend_from_slice(&decompressed_size.to_le_bytes());
616        body
617    }
618
619    #[test]
620    fn rejects_negative_header_size() {
621        let body = block_with_sizes(-1, 0x7d00, 0);
622        let Err(ZiPatchError::InvalidField { context }) = parse(&body) else {
623            panic!("expected InvalidField for negative header_size");
624        };
625        assert!(
626            context.contains("header_size"),
627            "unexpected context: {context}"
628        );
629    }
630
631    #[test]
632    fn rejects_negative_decompressed_size() {
633        let body = block_with_sizes(16, 0x7d00, -1);
634        let Err(ZiPatchError::InvalidField { context }) = parse(&body) else {
635            panic!("expected InvalidField for negative decompressed_size");
636        };
637        assert!(
638            context.contains("decompressed_size"),
639            "unexpected context: {context}"
640        );
641    }
642
643    #[test]
644    fn rejects_negative_compressed_size() {
645        // is_compressed = (compressed_size != 0x7d00) — pass -1 (not 0x7d00).
646        let body = block_with_sizes(16, -1, 8);
647        let Err(ZiPatchError::InvalidField { context }) = parse(&body) else {
648            panic!("expected InvalidField for negative compressed_size");
649        };
650        assert!(
651            context.contains("compressed_size"),
652            "unexpected context: {context}"
653        );
654    }
655
656    #[test]
657    fn rejects_invalid_utf8_in_path() {
658        // 0xFF is not valid UTF-8 — Utf8Error path on `String::from_utf8`.
659        let body = make_header(b'D', 0, 0, &[0xFFu8], 0);
660        assert!(matches!(parse(&body), Err(ZiPatchError::Utf8Error(_))));
661    }
662
663    #[test]
664    fn decompress_into_uncompressed_writes_data_verbatim() {
665        // Uncompressed branch: w.write_all(&self.data).
666        let block = SqpkCompressedBlock::new(false, 5, b"hello".to_vec());
667        let mut out = Vec::new();
668        block.decompress_into(&mut out).unwrap();
669        assert_eq!(out, b"hello");
670    }
671
672    #[test]
673    fn decompress_into_with_reuses_decompressor_across_blocks() {
674        // Verifies the contract of `decompress_into_with`: the same
675        // `Decompress` instance can be threaded through multiple consecutive
676        // compressed blocks, with `reset` between calls, and produce identical
677        // output to `decompress_into`. This is the apply-layer hot path.
678        use flate2::Compression;
679        use flate2::write::DeflateEncoder;
680        use std::io::Write;
681
682        let payload_a: &[u8] = b"alpha alpha alpha beta beta gamma";
683        let payload_b: &[u8] = b"the quick brown fox jumps over the lazy dog";
684
685        let compress = |raw: &[u8]| -> SqpkCompressedBlock {
686            let mut enc = DeflateEncoder::new(Vec::new(), Compression::default());
687            enc.write_all(raw).unwrap();
688            SqpkCompressedBlock::new(true, raw.len(), enc.finish().unwrap())
689        };
690        let a = compress(payload_a);
691        let b = compress(payload_b);
692
693        let mut state = Decompress::new(false);
694        let mut out_a = Vec::new();
695        a.decompress_into_with(&mut state, &mut out_a).unwrap();
696        assert_eq!(out_a, payload_a, "first block must round-trip");
697
698        let mut out_b = Vec::new();
699        b.decompress_into_with(&mut state, &mut out_b).unwrap();
700        assert_eq!(out_b, payload_b, "reused state must reset and round-trip");
701    }
702
703    #[test]
704    fn decompress_into_with_uncompressed_skips_decompressor() {
705        // The uncompressed branch must never touch the supplied state — it
706        // delegates to `write_all`. Verify the state's `total_in`/`total_out`
707        // are unchanged after the call.
708        let block = SqpkCompressedBlock::new(false, 5, b"hello".to_vec());
709        let mut state = Decompress::new(false);
710        let before_in = state.total_in();
711        let before_out = state.total_out();
712        let mut out = Vec::new();
713        block.decompress_into_with(&mut state, &mut out).unwrap();
714        assert_eq!(out, b"hello");
715        assert_eq!(state.total_in(), before_in);
716        assert_eq!(state.total_out(), before_out);
717    }
718
719    #[test]
720    fn decompress_into_with_propagates_corrupt_stream_error() {
721        // Garbage DEFLATE payload must surface as ZiPatchError::Decompress
722        // rather than panic or loop forever.
723        let block = SqpkCompressedBlock::new(true, 16, vec![0xFFu8; 16]);
724        let mut state = Decompress::new(false);
725        let mut out = Vec::new();
726        assert!(matches!(
727            block.decompress_into_with(&mut state, &mut out),
728            Err(ZiPatchError::Decompress(_))
729        ));
730    }
731
732    #[test]
733    fn decompress_returns_borrowed_for_uncompressed() {
734        // Cow::Borrowed branch — no allocation, points at the block's data.
735        let block = SqpkCompressedBlock::new(false, 4, b"data".to_vec());
736        let cow = block.decompress().unwrap();
737        assert!(matches!(cow, Cow::Borrowed(_)));
738        assert_eq!(&*cow, b"data");
739    }
740
741    #[test]
742    fn decompress_into_compressed_propagates_decompress_error() {
743        // Garbage DEFLATE payload — the `.map_err(ZiPatchError::Decompress)?` arm.
744        let block = SqpkCompressedBlock::new(true, 16, vec![0xFFu8; 16]);
745        let mut out = Vec::new();
746        assert!(matches!(
747            block.decompress_into(&mut out),
748            Err(ZiPatchError::Decompress(_))
749        ));
750        // And via the `decompress()` wrapper — the `?` error arm at line 106.
751        assert!(matches!(
752            block.decompress(),
753            Err(ZiPatchError::Decompress(_))
754        ));
755    }
756
757    #[test]
758    fn parses_compressed_block() {
759        use flate2::Compression;
760        use flate2::write::DeflateEncoder;
761        use std::io::Write;
762
763        let raw: &[u8] = b"hello compressed world";
764        let mut enc = DeflateEncoder::new(Vec::new(), Compression::default());
765        enc.write_all(raw).unwrap();
766        let compressed = enc.finish().unwrap();
767
768        let header_size: i32 = 16;
769        let compressed_size = compressed.len() as i32;
770        let decompressed_size = raw.len() as i32;
771        let block_len = ((compressed_size as u32 + 143) & !127) as usize;
772        let trailing_pad = block_len - header_size as usize - compressed.len();
773
774        // header bytes: 1+2+8+8+4+2+2+1 = 28 — block starts at offset 28
775        let mut body = make_header(b'A', 0, 0, b"\0", 0);
776        body.extend_from_slice(&header_size.to_le_bytes());
777        body.extend_from_slice(&0u32.to_le_bytes()); // pad
778        body.extend_from_slice(&compressed_size.to_le_bytes());
779        body.extend_from_slice(&decompressed_size.to_le_bytes());
780        body.extend_from_slice(&compressed);
781        body.extend_from_slice(&vec![0u8; trailing_pad]);
782
783        let cmd = parse(&body).unwrap();
784        assert_eq!(cmd.blocks.len(), 1);
785        let block = &cmd.blocks[0];
786        assert!(block.is_compressed);
787        assert_eq!(block.decompressed_size, raw.len());
788        assert_eq!(block.decompress().unwrap(), raw);
789        assert_eq!(cmd.block_source_offsets, vec![44u64]); // 28 (header) + 16 (block header)
790    }
791
792    #[test]
793    fn parse_rejects_oversized_path_len_issue_30() {
794        // Regression for issue #30: a u32 `path_len` from untrusted patch
795        // bytes was fed straight into `Vec::with_capacity`, allowing a
796        // malicious patch to trigger a ~4 GiB allocation and OOM-abort the
797        // process. The parser must now reject such a header with
798        // `InvalidField` before any allocation occurs.
799        //
800        // Original 32-byte fuzz input (from the `parser_sqpk` harness; byte 0
801        // is the harness's sub-command selector, dropped here):
802        //   2c 41 e5 11 00 36 36 36 36 00 00 00 00 00 00 ff
803        //   ff ff ff ff ff ff 00 00 21 00 ac 00 00 00 00 00
804        let body: &[u8] = &[
805            0x41, 0xe5, 0x11, // op=AddFile, alignment
806            0x00, 0x36, 0x36, 0x36, 0x36, 0x00, 0x00, 0x00, // file_offset
807            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, // file_size
808            0xff, 0xff, 0xff, 0xff, // path_len = u32::MAX
809            0xff, 0xff, // expansion_id
810            0x00, 0x00, // padding
811            0x21, 0x00, 0xac, 0x00, // remaining body bytes
812        ];
813        assert_eq!(body.len(), 31, "test input is the post-selector body");
814        let err = parse(body).expect_err("oversized path_len must error");
815        assert!(
816            matches!(
817                err,
818                ZiPatchError::InvalidField { context }
819                    if context.contains("path_len")
820            ),
821            "expected InvalidField on oversized path_len, got: {err:?}"
822        );
823    }
824}