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; selects what the command does to
8/// the game install tree.
9///
10/// Encoded as a single ASCII byte in the wire format:
11/// `b'A'` → `AddFile`, `b'R'` → `RemoveAll`, `b'D'` → `DeleteFile`,
12/// `b'M'` → `MakeDirTree`. Any other byte is rejected with
13/// [`ZiPatchError::UnknownFileOperation`].
14///
15/// See `SqpkFile.cs` in the `XIVLauncher` reference implementation.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum SqpkFileOperation {
18    /// `A` — write the inline compressed-block payload into a file under the
19    /// game install root, creating it (or overwriting it) as needed.
20    ///
21    /// Parent directories are created automatically. If `file_offset` is zero,
22    /// the target file is truncated to zero before writing (full replacement);
23    /// if `file_offset` is non-zero, only the covered range is overwritten.
24    AddFile,
25    /// `R` — delete all files in the expansion folder (`sqpack/<expansion>/`
26    /// and `movie/<expansion>/`) that are not on the keep-list.
27    ///
28    /// Kept unconditionally: `.var` files and `00000.bk2`–`00003.bk2`.
29    /// Files `00004.bk2` and beyond are deleted. `expansion_id` selects
30    /// the target expansion folder.
31    RemoveAll,
32    /// `D` — delete a single file at the path given by `SqpkFile::path`.
33    DeleteFile,
34    /// `M` — create the directory tree at `SqpkFile::path` (equivalent to
35    /// `std::fs::create_dir_all`). Idempotent.
36    MakeDirTree,
37}
38
39/// One block of a [`SqpkFile`] `AddFile` payload, which may be DEFLATE-compressed
40/// or stored raw.
41///
42/// `SqpkFile` payloads are split into a sequence of these blocks. Each block
43/// begins with a 16-byte little-endian header that describes the compressed
44/// and decompressed sizes, followed by the data bytes padded to a 128-byte
45/// boundary.
46///
47/// ## Compression sentinel
48///
49/// The `compressed_size` field in the wire header uses the value `0x7d00`
50/// (decimal **32000**) as a sentinel meaning "this block is not compressed".
51/// Any other value means the data bytes are a raw DEFLATE stream
52/// (no zlib wrapper, no gzip header — just RFC 1951 raw deflate).
53///
54/// ## Wire format of one block (all little-endian)
55///
56/// ```text
57/// ┌─────────────────────────────────────────────────────────────────────┐
58/// │ header_size     : i32 LE   always 16 in practice                   │  bytes 0–3
59/// │ <pad>           : u32 LE   always zero                              │  bytes 4–7
60/// │ compressed_size : i32 LE   byte count of DEFLATE data               │  bytes 8–11
61/// │                             OR 0x7d00 (32000) if uncompressed       │
62/// │ decompressed_size : i32 LE  byte count of decompressed output       │  bytes 12–15
63/// │ data            : [u8]     compressed or raw bytes                  │  bytes 16–…
64/// │ <alignment>     : [u8]     zero-padding to 128-byte boundary        │
65/// └─────────────────────────────────────────────────────────────────────┘
66/// ```
67///
68/// ## 128-byte alignment formula
69///
70/// The total byte count to read for a block's data + alignment is:
71///
72/// ```text
73/// block_len = (data_len + 143) & !127
74/// ```
75///
76/// where `data_len` is `compressed_size` if compressed, or `decompressed_size`
77/// if uncompressed. The constant 143 is `128 - 1 + 16` (subtract the 16-byte
78/// header that is not included in `data_len`, then round up to the next
79/// 128-byte boundary). The number of data bytes actually read is
80/// `block_len - header_size`; the alignment padding is consumed but discarded.
81///
82/// ## `pub(crate)` visibility
83///
84/// `SqpkCompressedBlock` is `pub` so that it appears in rustdoc and can be
85/// named in `SqpkFile::blocks`, but it can only be constructed via
86/// [`new`](SqpkCompressedBlock::new) (for tests) or by parsing a [`SqpkFile`].
87///
88/// See `SqpkFile.cs` / `ZiPatchConfig.cs` in the `XIVLauncher` reference implementation.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct SqpkCompressedBlock {
91    // true  → data holds raw DEFLATE bytes (compressed_size != 0x7d00)
92    // false → data holds the exact decompressed bytes (compressed_size == 0x7d00)
93    is_compressed: bool,
94    // Expected output size in bytes; used to pre-allocate the decompression buffer.
95    decompressed_size: usize,
96    // Compressed blocks: the raw DEFLATE stream, trimmed to compressed_size bytes
97    //   (alignment padding is consumed by read() but not stored here).
98    // Uncompressed blocks: the exact payload bytes, already stripped of padding.
99    data: Vec<u8>,
100}
101
102impl SqpkCompressedBlock {
103    /// Construct a block directly from its component parts.
104    ///
105    /// This constructor exists primarily for unit tests. Production code
106    /// creates blocks by parsing a [`SqpkFile`] from a patch byte stream.
107    ///
108    /// - `is_compressed`: `true` if `data` is a raw DEFLATE stream.
109    /// - `decompressed_size`: the expected number of bytes after decompression;
110    ///   used to pre-allocate the output buffer in
111    ///   [`decompress`](SqpkCompressedBlock::decompress).
112    /// - `data`: raw compressed bytes or exact uncompressed bytes, depending
113    ///   on `is_compressed`.
114    #[must_use]
115    pub fn new(is_compressed: bool, decompressed_size: usize, data: Vec<u8>) -> Self {
116        Self {
117            is_compressed,
118            decompressed_size,
119            data,
120        }
121    }
122
123    // Parse one block from the reader, consuming header + data + alignment padding.
124    //
125    // Reads the 16-byte little-endian block header, determines whether the block
126    // is compressed (compressed_size != 0x7d00), computes the 128-byte-aligned
127    // total length via (data_len + 143) & !127, then reads exactly that many
128    // bytes minus the header size — leaving the reader positioned at the start
129    // of the next block.
130    fn read<R: Read>(r: &mut R) -> Result<Self> {
131        // 16-byte block header, all fields little-endian:
132        //   i32 header_size  (always 16)
133        //   u32 pad          (always 0)
134        //   i32 compressed_size   (0x7d00 = uncompressed sentinel)
135        //   i32 decompressed_size
136        let header_size_raw = r.read_i32_le()?;
137        r.skip(4)?; // pad — always zero, no semantic content
138        let compressed_size = r.read_i32_le()?;
139        let decompressed_size_raw = r.read_i32_le()?;
140
141        if header_size_raw < 0 {
142            return Err(ZiPatchError::InvalidField {
143                context: "negative header_size in block",
144            });
145        }
146        if decompressed_size_raw < 0 {
147            return Err(ZiPatchError::InvalidField {
148                context: "negative decompressed_size in block",
149            });
150        }
151        // 0x7d00 (32000) is the sentinel for "store raw, not compressed".
152        // Any other value is the byte count of the DEFLATE stream.
153        let is_compressed = compressed_size != 0x7d00;
154        if is_compressed && compressed_size < 0 {
155            return Err(ZiPatchError::InvalidField {
156                context: "negative compressed_size in block",
157            });
158        }
159
160        let header_size = header_size_raw as usize;
161        let decompressed_size = decompressed_size_raw as usize;
162        // data_len is the logical size used for alignment: for compressed blocks
163        // it is the compressed byte count; for uncompressed it is the raw byte count.
164        let data_len = if is_compressed {
165            compressed_size
166        } else {
167            decompressed_size_raw
168        };
169        // Round data_len up to the next 128-byte boundary, accounting for the
170        // 16-byte header that precedes the data in the stream.
171        // Formula: (data_len + 128 - 1 + (header_size=16)) & !127
172        //        = (data_len + 143) & !127
173        let block_len = ((data_len as u32 + 143) & !127u32) as usize;
174        let data = if is_compressed {
175            // Read block_len - header_size bytes: the DEFLATE payload plus any
176            // alignment padding. For compressed blocks we store everything
177            // (padding included) because DeflateDecoder stops at the end of the
178            // DEFLATE stream before reading into the padding.
179            r.read_exact_vec(block_len - header_size)?
180        } else {
181            // Uncompressed: read exactly decompressed_size bytes of payload,
182            // then skip any alignment padding so the reader is positioned at the
183            // start of the next block.
184            let d = r.read_exact_vec(decompressed_size)?;
185            r.skip((block_len - header_size - decompressed_size) as u64)?;
186            d
187        };
188        Ok(SqpkCompressedBlock {
189            is_compressed,
190            decompressed_size,
191            data,
192        })
193    }
194
195    /// Stream the block's decompressed bytes into `w`.
196    ///
197    /// For uncompressed blocks, `w.write_all(&self.data)` is called directly.
198    /// For compressed blocks, the data is piped through [`DeflateDecoder`] (raw
199    /// DEFLATE, RFC 1951 — no zlib or gzip wrapper) before being written.
200    ///
201    /// This is the primary write path used by the apply layer: each block in a
202    /// [`SqpkFile`] `AddFile` operation is streamed into the target file handle
203    /// in sequence.
204    ///
205    /// # Errors
206    ///
207    /// - [`ZiPatchError::Decompress`] — the DEFLATE stream is malformed or
208    ///   truncated.
209    /// - [`ZiPatchError::Io`] — `w.write_all` failed.
210    pub fn decompress_into(&self, w: &mut impl Write) -> Result<()> {
211        if self.is_compressed {
212            std::io::copy(&mut DeflateDecoder::new(self.data.as_slice()), w)
213                .map_err(ZiPatchError::Decompress)?;
214        } else {
215            w.write_all(&self.data)?;
216        }
217        Ok(())
218    }
219
220    /// Return the block's decompressed bytes as a [`Cow`].
221    ///
222    /// Uncompressed blocks return `Cow::Borrowed(&self.data)` — a zero-copy
223    /// borrow into the block's existing buffer. Compressed blocks decompress
224    /// into a newly allocated `Vec` and return `Cow::Owned`.
225    ///
226    /// Use [`decompress_into`](SqpkCompressedBlock::decompress_into) instead
227    /// when writing to a file handle, to avoid the intermediate allocation.
228    ///
229    /// # Errors
230    ///
231    /// - [`ZiPatchError::Decompress`] — the DEFLATE stream is malformed or
232    ///   truncated (compressed blocks only).
233    pub fn decompress(&self) -> crate::Result<Cow<'_, [u8]>> {
234        if self.is_compressed {
235            let mut out = Vec::with_capacity(self.decompressed_size);
236            self.decompress_into(&mut out)?;
237            Ok(Cow::Owned(out))
238        } else {
239            Ok(Cow::Borrowed(&self.data))
240        }
241    }
242}
243
244/// SQPK `F` command body: a file-level operation on the game install tree.
245///
246/// Unlike the block-oriented commands (`A`, `D`, `E`) that target `SqPack`
247/// archive internals, `F` operates on whole files in the install directory.
248/// The operation to perform is selected by [`operation`](SqpkFile::operation).
249///
250/// ## Wire format
251///
252/// ```text
253/// ┌──────────────────────────────────────────────────────────────────────────┐
254/// │ operation    : u8      b'A', b'R', b'D', or b'M'                        │  byte 0
255/// │ <padding>    : [u8; 2] (always zero)                                     │  bytes 1–2
256/// │ file_offset  : u64 BE  destination byte offset within the target file    │  bytes 3–10
257/// │ file_size    : u64 BE  declared size of the target file after operation  │  bytes 11–18
258/// │ path_len     : u32 BE  byte length of the path field (including NUL)     │  bytes 19–22
259/// │ expansion_id : u16 BE  expansion folder selector for `RemoveAll`         │  bytes 23–24
260/// │ <padding>    : [u8; 2] (always zero)                                     │  bytes 25–26
261/// │ path         : [u8; path_len]  NUL-terminated UTF-8 path                │  bytes 27–…
262/// │ [blocks]     : SqpkCompressedBlock…  (only for `AddFile`)                │
263/// └──────────────────────────────────────────────────────────────────────────┘
264/// ```
265///
266/// `file_offset` and `file_size` are stored as big-endian `u64` in the wire
267/// format but cast to `i64` after parsing (negative values in `file_offset`
268/// cause [`ZiPatchError::NegativeFileOffset`] at apply time).
269///
270/// The NUL terminator in `path` is stripped during parsing; [`path`](SqpkFile::path)
271/// always contains a clean UTF-8 string.
272///
273/// For `AddFile` operations the remaining bytes in the command body after the
274/// path form a sequence of [`SqpkCompressedBlock`]s (see that type's
275/// documentation for the block wire format). For all other operations the block
276/// list is empty.
277///
278/// ## Reference
279///
280/// See `SqpkFile.cs` in the `XIVLauncher` reference implementation.
281///
282/// # Errors
283///
284/// Parsing returns a [`crate::ZiPatchError`] if:
285/// - The operation byte is not `b'A'`, `b'R'`, `b'D'`, or `b'M'`
286///   → [`ZiPatchError::UnknownFileOperation`].
287/// - The path bytes are not valid UTF-8 → [`ZiPatchError::Utf8Error`].
288/// - A block header contains a negative `header_size` or `decompressed_size`,
289///   or a negative non-sentinel `compressed_size`
290///   → [`ZiPatchError::InvalidField`].
291/// - The body is too short → [`ZiPatchError::Io`].
292#[derive(Debug, Clone, PartialEq, Eq)]
293pub struct SqpkFile {
294    /// The file operation to perform.
295    pub operation: SqpkFileOperation,
296    /// Destination byte offset within the target file.
297    ///
298    /// For `AddFile`: if zero, the target file is truncated to zero before
299    /// writing (complete replacement); if positive, writing begins at this
300    /// byte offset in the existing file. Negative values (cast from the raw
301    /// `u64`) are rejected at apply time with [`ZiPatchError::NegativeFileOffset`].
302    ///
303    /// Unused by `RemoveAll`, `DeleteFile`, and `MakeDirTree`.
304    pub file_offset: i64,
305    /// Declared total size of the target file after the operation, in bytes.
306    ///
307    /// Informational; the apply layer does not use this to pre-allocate or
308    /// truncate the file (truncation is controlled by `file_offset == 0`).
309    pub file_size: i64,
310    /// Expansion folder selector used by `RemoveAll`.
311    ///
312    /// `0` → `ffxiv` (base game), `n > 0` → `ex<n>`. Corresponds to the
313    /// high byte of `sub_id` in block-oriented commands.
314    pub expansion_id: u16,
315    /// Relative path to the target file or directory under the game install root.
316    ///
317    /// NUL terminator is stripped during parsing. For `AddFile` / `DeleteFile`
318    /// this is joined with the install root via `generic_path`. For `MakeDirTree`
319    /// it is the directory tree to create.
320    pub path: String,
321    /// Byte offset of each block's data payload — measured from the start of
322    /// the SQPK command body slice — after skipping the block's 16-byte header.
323    ///
324    /// `block_source_offsets[i]` corresponds to `blocks[i]`. Adding the chunk's
325    /// absolute position in the patch file to this offset gives the patch-file
326    /// byte offset where the block's data begins, enabling `IndexedZiPatch`
327    /// random-access reads that do not need to decompress the full stream.
328    ///
329    /// Empty for all operations other than `AddFile`.
330    pub block_source_offsets: Vec<u64>,
331    /// Inline compressed-or-raw block payloads that make up the file content.
332    ///
333    /// Only populated for `AddFile`; empty for `RemoveAll`, `DeleteFile`, and
334    /// `MakeDirTree`. Each block is decompressed in sequence into the target
335    /// file by the apply layer. See [`SqpkCompressedBlock`] for the block wire
336    /// format and DEFLATE discrimination logic.
337    pub blocks: Vec<SqpkCompressedBlock>,
338}
339
340// Parse a SQPK 'F' command body into a SqpkFile.
341//
342// Reads the fixed-size header fields (operation, offsets, sizes, path),
343// then — for AddFile only — iterates over the remaining bytes in `body`,
344// parsing SqpkCompressedBlock entries until the cursor reaches the end.
345// The block source offsets are recorded as the cursor position + 16 (to
346// skip the block's own 16-byte header) before each SqpkCompressedBlock::read
347// call.
348pub(crate) fn parse(body: &[u8]) -> Result<SqpkFile> {
349    let mut c = Cursor::new(body);
350
351    let operation = match c.read_u8()? {
352        b'A' => SqpkFileOperation::AddFile,
353        b'R' => SqpkFileOperation::RemoveAll,
354        b'D' => SqpkFileOperation::DeleteFile,
355        b'M' => SqpkFileOperation::MakeDirTree,
356        b => {
357            return Err(ZiPatchError::UnknownFileOperation(b));
358        }
359    };
360    c.skip(2)?; // alignment
361
362    let file_offset = c.read_u64_be()? as i64;
363    let file_size = c.read_u64_be()? as i64;
364    let path_len = c.read_u32_be()?;
365    let expansion_id = c.read_u16_be()?;
366    c.skip(2)?; // padding
367
368    let path_bytes = c.read_exact_vec(path_len as usize)?;
369    let path = String::from_utf8(path_bytes)
370        .map(|s| s.trim_end_matches('\0').to_owned())
371        .map_err(ZiPatchError::Utf8Error)?;
372
373    let (blocks, block_source_offsets) = if matches!(operation, SqpkFileOperation::AddFile) {
374        let mut blocks = Vec::new();
375        let mut offsets = Vec::new();
376        while (c.position() as usize) < body.len() {
377            // Record offset of the data payload (after the fixed 16-byte block header).
378            offsets.push(c.position() + 16);
379            blocks.push(SqpkCompressedBlock::read(&mut c)?);
380        }
381        (blocks, offsets)
382    } else {
383        (Vec::new(), Vec::new())
384    };
385
386    Ok(SqpkFile {
387        operation,
388        file_offset,
389        file_size,
390        expansion_id,
391        path,
392        block_source_offsets,
393        blocks,
394    })
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    fn make_header(
402        op: u8,
403        file_offset: i64,
404        file_size: i64,
405        path: &[u8],
406        expansion_id: u16,
407    ) -> Vec<u8> {
408        let mut body = Vec::new();
409        body.push(op);
410        body.extend_from_slice(&[0u8; 2]); // alignment
411        body.extend_from_slice(&(file_offset as u64).to_be_bytes());
412        body.extend_from_slice(&(file_size as u64).to_be_bytes());
413        body.extend_from_slice(&(path.len() as u32).to_be_bytes());
414        body.extend_from_slice(&expansion_id.to_be_bytes());
415        body.extend_from_slice(&[0u8; 2]); // padding
416        body.extend_from_slice(path);
417        body
418    }
419
420    #[test]
421    fn parses_add_file_no_blocks() {
422        let body = make_header(b'A', 0, 512, b"test\0", 1);
423        let cmd = parse(&body).unwrap();
424        assert!(matches!(cmd.operation, SqpkFileOperation::AddFile));
425        assert_eq!(cmd.file_offset, 0);
426        assert_eq!(cmd.file_size, 512);
427        assert_eq!(cmd.expansion_id, 1);
428        assert_eq!(cmd.path, "test");
429        assert!(cmd.blocks.is_empty());
430        assert!(cmd.block_source_offsets.is_empty());
431    }
432
433    #[test]
434    fn parses_add_file_uncompressed_block() {
435        // block_len = ((8 + 143) & !127) = 128; read 8 data bytes + skip 104 padding
436        let mut body = make_header(b'A', 0, 0, b"\0", 0);
437        // header bytes: 1+2+8+8+4+2+2+1 = 28 — block starts at offset 28
438        body.extend_from_slice(&16i32.to_le_bytes()); // header_size
439        body.extend_from_slice(&0u32.to_le_bytes()); // pad
440        body.extend_from_slice(&0x7d00i32.to_le_bytes()); // compressed_size = uncompressed sentinel
441        body.extend_from_slice(&8i32.to_le_bytes()); // decompressed_size
442        body.extend_from_slice(&[0xABu8; 8]); // data
443        body.extend_from_slice(&[0u8; 104]); // alignment padding
444
445        let cmd = parse(&body).unwrap();
446        assert_eq!(cmd.blocks.len(), 1);
447        let block = &cmd.blocks[0];
448        assert!(!block.is_compressed);
449        assert_eq!(block.decompressed_size, 8);
450        assert_eq!(block.data.len(), 8);
451        assert!(block.data.iter().all(|&b| b == 0xAB));
452        assert_eq!(block.decompress().unwrap(), vec![0xABu8; 8]);
453        assert_eq!(cmd.block_source_offsets, vec![44u64]); // 28 (header) + 16 (block header)
454    }
455
456    #[test]
457    fn parses_remove_all_operation() {
458        let body = make_header(b'R', 0, 0, b"\0", 0);
459        let cmd = parse(&body).unwrap();
460        assert!(matches!(cmd.operation, SqpkFileOperation::RemoveAll));
461        assert!(cmd.blocks.is_empty());
462        assert!(cmd.block_source_offsets.is_empty());
463    }
464
465    #[test]
466    fn parses_delete_file_operation() {
467        let body = make_header(b'D', 0, 0, b"sqpack/foo.dat\0", 0);
468        let cmd = parse(&body).unwrap();
469        assert!(matches!(cmd.operation, SqpkFileOperation::DeleteFile));
470        assert_eq!(cmd.path, "sqpack/foo.dat");
471    }
472
473    #[test]
474    fn parses_make_dir_tree_operation() {
475        let body = make_header(b'M', 0, 0, b"sqpack/ex1\0", 0);
476        let cmd = parse(&body).unwrap();
477        assert!(matches!(cmd.operation, SqpkFileOperation::MakeDirTree));
478        assert_eq!(cmd.path, "sqpack/ex1");
479    }
480
481    #[test]
482    fn rejects_unknown_operation() {
483        let body = make_header(b'Z', 0, 0, b"\0", 0);
484        assert!(parse(&body).is_err());
485    }
486
487    fn block_with_sizes(header_size: i32, compressed_size: i32, decompressed_size: i32) -> Vec<u8> {
488        let mut body = make_header(b'A', 0, 0, b"\0", 0);
489        body.extend_from_slice(&header_size.to_le_bytes());
490        body.extend_from_slice(&0u32.to_le_bytes()); // pad
491        body.extend_from_slice(&compressed_size.to_le_bytes());
492        body.extend_from_slice(&decompressed_size.to_le_bytes());
493        body
494    }
495
496    #[test]
497    fn rejects_negative_header_size() {
498        let body = block_with_sizes(-1, 0x7d00, 0);
499        let Err(ZiPatchError::InvalidField { context }) = parse(&body) else {
500            panic!("expected InvalidField for negative header_size");
501        };
502        assert!(
503            context.contains("header_size"),
504            "unexpected context: {context}"
505        );
506    }
507
508    #[test]
509    fn rejects_negative_decompressed_size() {
510        let body = block_with_sizes(16, 0x7d00, -1);
511        let Err(ZiPatchError::InvalidField { context }) = parse(&body) else {
512            panic!("expected InvalidField for negative decompressed_size");
513        };
514        assert!(
515            context.contains("decompressed_size"),
516            "unexpected context: {context}"
517        );
518    }
519
520    #[test]
521    fn rejects_negative_compressed_size() {
522        // is_compressed = (compressed_size != 0x7d00) — pass -1 (not 0x7d00).
523        let body = block_with_sizes(16, -1, 8);
524        let Err(ZiPatchError::InvalidField { context }) = parse(&body) else {
525            panic!("expected InvalidField for negative compressed_size");
526        };
527        assert!(
528            context.contains("compressed_size"),
529            "unexpected context: {context}"
530        );
531    }
532
533    #[test]
534    fn rejects_invalid_utf8_in_path() {
535        // 0xFF is not valid UTF-8 — Utf8Error path on `String::from_utf8`.
536        let body = make_header(b'D', 0, 0, &[0xFFu8], 0);
537        assert!(matches!(parse(&body), Err(ZiPatchError::Utf8Error(_))));
538    }
539
540    #[test]
541    fn decompress_into_uncompressed_writes_data_verbatim() {
542        // Uncompressed branch: w.write_all(&self.data).
543        let block = SqpkCompressedBlock::new(false, 5, b"hello".to_vec());
544        let mut out = Vec::new();
545        block.decompress_into(&mut out).unwrap();
546        assert_eq!(out, b"hello");
547    }
548
549    #[test]
550    fn decompress_returns_borrowed_for_uncompressed() {
551        // Cow::Borrowed branch — no allocation, points at the block's data.
552        let block = SqpkCompressedBlock::new(false, 4, b"data".to_vec());
553        let cow = block.decompress().unwrap();
554        assert!(matches!(cow, Cow::Borrowed(_)));
555        assert_eq!(&*cow, b"data");
556    }
557
558    #[test]
559    fn decompress_into_compressed_propagates_decompress_error() {
560        // Garbage DEFLATE payload — the `.map_err(ZiPatchError::Decompress)?` arm.
561        let block = SqpkCompressedBlock::new(true, 16, vec![0xFFu8; 16]);
562        let mut out = Vec::new();
563        assert!(matches!(
564            block.decompress_into(&mut out),
565            Err(ZiPatchError::Decompress(_))
566        ));
567        // And via the `decompress()` wrapper — the `?` error arm at line 106.
568        assert!(matches!(
569            block.decompress(),
570            Err(ZiPatchError::Decompress(_))
571        ));
572    }
573
574    #[test]
575    fn parses_compressed_block() {
576        use flate2::Compression;
577        use flate2::write::DeflateEncoder;
578        use std::io::Write;
579
580        let raw: &[u8] = b"hello compressed world";
581        let mut enc = DeflateEncoder::new(Vec::new(), Compression::default());
582        enc.write_all(raw).unwrap();
583        let compressed = enc.finish().unwrap();
584
585        let header_size: i32 = 16;
586        let compressed_size = compressed.len() as i32;
587        let decompressed_size = raw.len() as i32;
588        let block_len = ((compressed_size as u32 + 143) & !127) as usize;
589        let trailing_pad = block_len - header_size as usize - compressed.len();
590
591        // header bytes: 1+2+8+8+4+2+2+1 = 28 — block starts at offset 28
592        let mut body = make_header(b'A', 0, 0, b"\0", 0);
593        body.extend_from_slice(&header_size.to_le_bytes());
594        body.extend_from_slice(&0u32.to_le_bytes()); // pad
595        body.extend_from_slice(&compressed_size.to_le_bytes());
596        body.extend_from_slice(&decompressed_size.to_le_bytes());
597        body.extend_from_slice(&compressed);
598        body.extend_from_slice(&vec![0u8; trailing_pad]);
599
600        let cmd = parse(&body).unwrap();
601        assert_eq!(cmd.blocks.len(), 1);
602        let block = &cmd.blocks[0];
603        assert!(block.is_compressed);
604        assert_eq!(block.decompressed_size, raw.len());
605        assert_eq!(block.decompress().unwrap(), raw);
606        assert_eq!(cmd.block_source_offsets, vec![44u64]); // 28 (header) + 16 (block header)
607    }
608}