zipatch_rs/chunk/sqpk/file.rs
1use crate::reader::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 let data = if is_compressed {
176 // Read block_len - header_size bytes: the DEFLATE payload plus any
177 // alignment padding. For compressed blocks we store everything
178 // (padding included) because DeflateDecoder stops at the end of the
179 // DEFLATE stream before reading into the padding.
180 r.read_exact_vec(block_len - header_size)?
181 } else {
182 // Uncompressed: read exactly decompressed_size bytes of payload,
183 // then skip any alignment padding so the reader is positioned at the
184 // start of the next block.
185 let d = r.read_exact_vec(decompressed_size)?;
186 r.skip((block_len - header_size - decompressed_size) as u64)?;
187 d
188 };
189 Ok(SqpkCompressedBlock {
190 is_compressed,
191 decompressed_size,
192 data,
193 })
194 }
195
196 /// Stream the block's decompressed bytes into `w`.
197 ///
198 /// For uncompressed blocks, `w.write_all(&self.data)` is called directly.
199 /// For compressed blocks, the data is piped through [`DeflateDecoder`] (raw
200 /// DEFLATE, RFC 1951 — no zlib or gzip wrapper) before being written.
201 ///
202 /// This is the primary write path used by the apply layer: each block in a
203 /// [`SqpkFile`] `AddFile` operation is streamed into the target file handle
204 /// in sequence.
205 ///
206 /// # Errors
207 ///
208 /// - [`ZiPatchError::Decompress`] — the DEFLATE stream is malformed or
209 /// truncated.
210 /// - [`ZiPatchError::Io`] — `w.write_all` failed.
211 pub fn decompress_into(&self, w: &mut impl Write) -> Result<()> {
212 if self.is_compressed {
213 std::io::copy(&mut DeflateDecoder::new(self.data.as_slice()), w)
214 .map_err(ZiPatchError::Decompress)?;
215 } else {
216 w.write_all(&self.data)?;
217 }
218 Ok(())
219 }
220
221 /// Stream the block's decompressed bytes into `w`, reusing a caller-owned
222 /// [`Decompress`] state across blocks.
223 ///
224 /// Equivalent to [`decompress_into`](SqpkCompressedBlock::decompress_into)
225 /// in behaviour and error semantics, but avoids the per-call ~100 KiB
226 /// zlib-state allocation that [`DeflateDecoder::new`] would otherwise
227 /// pay. The apply layer threads a single `Decompress` through every
228 /// block in a multi-block `SqpkFile::AddFile` chunk; uncompressed blocks
229 /// short-circuit to `write_all` and leave the decompressor untouched.
230 ///
231 /// `decompressor` is reset via [`Decompress::reset(false)`](Decompress::reset)
232 /// at the start of every compressed block, so callers may pass an
233 /// already-used state without manually resetting it.
234 ///
235 /// # Errors
236 ///
237 /// - [`ZiPatchError::Decompress`] — the DEFLATE stream is malformed or
238 /// the manual feed loop made no forward progress (corrupt or truncated
239 /// payload).
240 /// - [`ZiPatchError::Io`] — `w.write_all` failed.
241 pub fn decompress_into_with(
242 &self,
243 decompressor: &mut Decompress,
244 w: &mut impl Write,
245 ) -> Result<()> {
246 if !self.is_compressed {
247 w.write_all(&self.data)?;
248 return Ok(());
249 }
250
251 // Raw DEFLATE — match the legacy `DeflateDecoder::new(_)` zlib_header=false.
252 decompressor.reset(false);
253 // 8 KiB output buffer matches `std::io::copy`'s default and is plenty
254 // for the per-iteration output the underlying miniz_oxide / zlib-ng
255 // backends emit. Stays on the stack — no allocation per block.
256 let mut out = [0u8; 8 * 1024];
257 let mut input: &[u8] = &self.data;
258 loop {
259 let before_in = decompressor.total_in();
260 let before_out = decompressor.total_out();
261 let status = decompressor
262 .decompress(input, &mut out, FlushDecompress::Finish)
263 .map_err(|e| {
264 ZiPatchError::Decompress(std::io::Error::new(
265 std::io::ErrorKind::InvalidData,
266 e,
267 ))
268 })?;
269 let consumed = (decompressor.total_in() - before_in) as usize;
270 let produced = (decompressor.total_out() - before_out) as usize;
271 if produced > 0 {
272 w.write_all(&out[..produced])?;
273 }
274 input = &input[consumed..];
275 match status {
276 Status::StreamEnd => return Ok(()),
277 Status::Ok | Status::BufError => {
278 // Forward progress is required. SqPack DEFLATE blocks are
279 // self-contained — the trailing alignment padding the parser
280 // intentionally leaves in `self.data` is past the
281 // end-of-stream marker, so the decoder must signal
282 // StreamEnd before exhausting the input. A no-progress loop
283 // means the payload is corrupt or truncated.
284 if consumed == 0 && produced == 0 {
285 return Err(ZiPatchError::Decompress(std::io::Error::new(
286 std::io::ErrorKind::InvalidData,
287 "DEFLATE stream made no forward progress",
288 )));
289 }
290 }
291 }
292 }
293 }
294
295 /// Return the block's decompressed bytes as a [`Cow`].
296 ///
297 /// Uncompressed blocks return `Cow::Borrowed(&self.data)` — a zero-copy
298 /// borrow into the block's existing buffer. Compressed blocks decompress
299 /// into a newly allocated `Vec` and return `Cow::Owned`.
300 ///
301 /// Use [`decompress_into`](SqpkCompressedBlock::decompress_into) instead
302 /// when writing to a file handle, to avoid the intermediate allocation.
303 ///
304 /// # Errors
305 ///
306 /// - [`ZiPatchError::Decompress`] — the DEFLATE stream is malformed or
307 /// truncated (compressed blocks only).
308 pub fn decompress(&self) -> crate::Result<Cow<'_, [u8]>> {
309 if self.is_compressed {
310 let mut out = Vec::with_capacity(self.decompressed_size);
311 self.decompress_into(&mut out)?;
312 Ok(Cow::Owned(out))
313 } else {
314 Ok(Cow::Borrowed(&self.data))
315 }
316 }
317}
318
319/// SQPK `F` command body: a file-level operation on the game install tree.
320///
321/// Unlike the block-oriented commands (`A`, `D`, `E`) that target `SqPack`
322/// archive internals, `F` operates on whole files in the install directory.
323/// The operation to perform is selected by [`operation`](SqpkFile::operation).
324///
325/// ## Wire format
326///
327/// ```text
328/// ┌──────────────────────────────────────────────────────────────────────────┐
329/// │ operation : u8 b'A', b'R', b'D', or b'M' │ byte 0
330/// │ <padding> : [u8; 2] (always zero) │ bytes 1–2
331/// │ file_offset : u64 BE destination byte offset within the target file │ bytes 3–10
332/// │ file_size : u64 BE declared size of the target file after operation │ bytes 11–18
333/// │ path_len : u32 BE byte length of the path field (including NUL) │ bytes 19–22
334/// │ expansion_id : u16 BE expansion folder selector for `RemoveAll` │ bytes 23–24
335/// │ <padding> : [u8; 2] (always zero) │ bytes 25–26
336/// │ path : [u8; path_len] NUL-terminated UTF-8 path │ bytes 27–…
337/// │ [blocks] : SqpkCompressedBlock… (only for `AddFile`) │
338/// └──────────────────────────────────────────────────────────────────────────┘
339/// ```
340///
341/// `file_offset` and `file_size` are stored as big-endian `u64` in the wire
342/// format but cast to `i64` after parsing (negative values in `file_offset`
343/// cause [`ZiPatchError::NegativeFileOffset`] at apply time).
344///
345/// The NUL terminator in `path` is stripped during parsing; [`path`](SqpkFile::path)
346/// always contains a clean UTF-8 string.
347///
348/// For `AddFile` operations the remaining bytes in the command body after the
349/// path form a sequence of [`SqpkCompressedBlock`]s (see that type's
350/// documentation for the block wire format). For all other operations the block
351/// list is empty.
352///
353/// ## Reference
354///
355/// See `SqpkFile.cs` in the `XIVLauncher` reference implementation.
356///
357/// # Errors
358///
359/// Parsing returns a [`crate::ZiPatchError`] if:
360/// - The operation byte is not `b'A'`, `b'R'`, `b'D'`, or `b'M'`
361/// → [`ZiPatchError::UnknownFileOperation`].
362/// - The path bytes are not valid UTF-8 → [`ZiPatchError::Utf8Error`].
363/// - A block header contains a negative `header_size` or `decompressed_size`,
364/// or a negative non-sentinel `compressed_size`
365/// → [`ZiPatchError::InvalidField`].
366/// - The body is too short → [`ZiPatchError::Io`].
367#[derive(Debug, Clone, PartialEq, Eq)]
368pub struct SqpkFile {
369 /// The file operation to perform.
370 pub operation: SqpkFileOperation,
371 /// Destination byte offset within the target file.
372 ///
373 /// For `AddFile`: if zero, the target file is truncated to zero before
374 /// writing (complete replacement); if positive, writing begins at this
375 /// byte offset in the existing file. Negative values (cast from the raw
376 /// `u64`) are rejected at apply time with [`ZiPatchError::NegativeFileOffset`].
377 ///
378 /// Unused by `RemoveAll`, `DeleteFile`, and `MakeDirTree`.
379 pub file_offset: i64,
380 /// Declared total size of the target file after the operation, in bytes.
381 ///
382 /// Informational; the apply layer does not use this to pre-allocate or
383 /// truncate the file (truncation is controlled by `file_offset == 0`).
384 pub file_size: i64,
385 /// Expansion folder selector used by `RemoveAll`.
386 ///
387 /// `0` → `ffxiv` (base game), `n > 0` → `ex<n>`. Corresponds to the
388 /// high byte of `sub_id` in block-oriented commands.
389 pub expansion_id: u16,
390 /// Relative path to the target file or directory under the game install root.
391 ///
392 /// NUL terminator is stripped during parsing. For `AddFile` / `DeleteFile`
393 /// this is joined with the install root via `generic_path`. For `MakeDirTree`
394 /// it is the directory tree to create.
395 pub path: String,
396 /// Byte offset of each block's data payload — measured from the start of
397 /// the SQPK command body slice — after skipping the block's 16-byte header.
398 ///
399 /// `block_source_offsets[i]` corresponds to `blocks[i]`. Adding the chunk's
400 /// absolute position in the patch file to this offset gives the patch-file
401 /// byte offset where the block's data begins, enabling `IndexedZiPatch`
402 /// random-access reads that do not need to decompress the full stream.
403 ///
404 /// Empty for all operations other than `AddFile`.
405 pub block_source_offsets: Vec<u64>,
406 /// Inline compressed-or-raw block payloads that make up the file content.
407 ///
408 /// Only populated for `AddFile`; empty for `RemoveAll`, `DeleteFile`, and
409 /// `MakeDirTree`. Each block is decompressed in sequence into the target
410 /// file by the apply layer. See [`SqpkCompressedBlock`] for the block wire
411 /// format and DEFLATE discrimination logic.
412 pub blocks: Vec<SqpkCompressedBlock>,
413}
414
415// Parse a SQPK 'F' command body into a SqpkFile.
416//
417// Reads the fixed-size header fields (operation, offsets, sizes, path),
418// then — for AddFile only — iterates over the remaining bytes in `body`,
419// parsing SqpkCompressedBlock entries until the cursor reaches the end.
420// The block source offsets are recorded as the cursor position + 16 (to
421// skip the block's own 16-byte header) before each SqpkCompressedBlock::read
422// call.
423pub(crate) fn parse(body: &[u8]) -> Result<SqpkFile> {
424 let mut c = Cursor::new(body);
425
426 let operation = match c.read_u8()? {
427 b'A' => SqpkFileOperation::AddFile,
428 b'R' => SqpkFileOperation::RemoveAll,
429 b'D' => SqpkFileOperation::DeleteFile,
430 b'M' => SqpkFileOperation::MakeDirTree,
431 b => {
432 return Err(ZiPatchError::UnknownFileOperation(b));
433 }
434 };
435 c.skip(2)?; // alignment
436
437 let file_offset = c.read_u64_be()? as i64;
438 let file_size = c.read_u64_be()? as i64;
439 let path_len = c.read_u32_be()?;
440 let expansion_id = c.read_u16_be()?;
441 c.skip(2)?; // padding
442
443 let path_bytes = c.read_exact_vec(path_len as usize)?;
444 let path = String::from_utf8(path_bytes)
445 .map(|s| s.trim_end_matches('\0').to_owned())
446 .map_err(ZiPatchError::Utf8Error)?;
447
448 let (blocks, block_source_offsets) = if matches!(operation, SqpkFileOperation::AddFile) {
449 let mut blocks = Vec::new();
450 let mut offsets = Vec::new();
451 while (c.position() as usize) < body.len() {
452 // Record offset of the data payload (after the fixed 16-byte block header).
453 offsets.push(c.position() + 16);
454 blocks.push(SqpkCompressedBlock::read(&mut c)?);
455 }
456 (blocks, offsets)
457 } else {
458 (Vec::new(), Vec::new())
459 };
460
461 Ok(SqpkFile {
462 operation,
463 file_offset,
464 file_size,
465 expansion_id,
466 path,
467 block_source_offsets,
468 blocks,
469 })
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475
476 fn make_header(
477 op: u8,
478 file_offset: i64,
479 file_size: i64,
480 path: &[u8],
481 expansion_id: u16,
482 ) -> Vec<u8> {
483 let mut body = Vec::new();
484 body.push(op);
485 body.extend_from_slice(&[0u8; 2]); // alignment
486 body.extend_from_slice(&(file_offset as u64).to_be_bytes());
487 body.extend_from_slice(&(file_size as u64).to_be_bytes());
488 body.extend_from_slice(&(path.len() as u32).to_be_bytes());
489 body.extend_from_slice(&expansion_id.to_be_bytes());
490 body.extend_from_slice(&[0u8; 2]); // padding
491 body.extend_from_slice(path);
492 body
493 }
494
495 #[test]
496 fn parses_add_file_no_blocks() {
497 let body = make_header(b'A', 0, 512, b"test\0", 1);
498 let cmd = parse(&body).unwrap();
499 assert!(matches!(cmd.operation, SqpkFileOperation::AddFile));
500 assert_eq!(cmd.file_offset, 0);
501 assert_eq!(cmd.file_size, 512);
502 assert_eq!(cmd.expansion_id, 1);
503 assert_eq!(cmd.path, "test");
504 assert!(cmd.blocks.is_empty());
505 assert!(cmd.block_source_offsets.is_empty());
506 }
507
508 #[test]
509 fn parses_add_file_uncompressed_block() {
510 // block_len = ((8 + 143) & !127) = 128; read 8 data bytes + skip 104 padding
511 let mut body = make_header(b'A', 0, 0, b"\0", 0);
512 // header bytes: 1+2+8+8+4+2+2+1 = 28 — block starts at offset 28
513 body.extend_from_slice(&16i32.to_le_bytes()); // header_size
514 body.extend_from_slice(&0u32.to_le_bytes()); // pad
515 body.extend_from_slice(&0x7d00i32.to_le_bytes()); // compressed_size = uncompressed sentinel
516 body.extend_from_slice(&8i32.to_le_bytes()); // decompressed_size
517 body.extend_from_slice(&[0xABu8; 8]); // data
518 body.extend_from_slice(&[0u8; 104]); // alignment padding
519
520 let cmd = parse(&body).unwrap();
521 assert_eq!(cmd.blocks.len(), 1);
522 let block = &cmd.blocks[0];
523 assert!(!block.is_compressed);
524 assert_eq!(block.decompressed_size, 8);
525 assert_eq!(block.data.len(), 8);
526 assert!(block.data.iter().all(|&b| b == 0xAB));
527 assert_eq!(block.decompress().unwrap(), vec![0xABu8; 8]);
528 assert_eq!(cmd.block_source_offsets, vec![44u64]); // 28 (header) + 16 (block header)
529 }
530
531 #[test]
532 fn parses_remove_all_operation() {
533 let body = make_header(b'R', 0, 0, b"\0", 0);
534 let cmd = parse(&body).unwrap();
535 assert!(matches!(cmd.operation, SqpkFileOperation::RemoveAll));
536 assert!(cmd.blocks.is_empty());
537 assert!(cmd.block_source_offsets.is_empty());
538 }
539
540 #[test]
541 fn parses_delete_file_operation() {
542 let body = make_header(b'D', 0, 0, b"sqpack/foo.dat\0", 0);
543 let cmd = parse(&body).unwrap();
544 assert!(matches!(cmd.operation, SqpkFileOperation::DeleteFile));
545 assert_eq!(cmd.path, "sqpack/foo.dat");
546 }
547
548 #[test]
549 fn parses_make_dir_tree_operation() {
550 let body = make_header(b'M', 0, 0, b"sqpack/ex1\0", 0);
551 let cmd = parse(&body).unwrap();
552 assert!(matches!(cmd.operation, SqpkFileOperation::MakeDirTree));
553 assert_eq!(cmd.path, "sqpack/ex1");
554 }
555
556 #[test]
557 fn rejects_unknown_operation() {
558 let body = make_header(b'Z', 0, 0, b"\0", 0);
559 assert!(parse(&body).is_err());
560 }
561
562 fn block_with_sizes(header_size: i32, compressed_size: i32, decompressed_size: i32) -> Vec<u8> {
563 let mut body = make_header(b'A', 0, 0, b"\0", 0);
564 body.extend_from_slice(&header_size.to_le_bytes());
565 body.extend_from_slice(&0u32.to_le_bytes()); // pad
566 body.extend_from_slice(&compressed_size.to_le_bytes());
567 body.extend_from_slice(&decompressed_size.to_le_bytes());
568 body
569 }
570
571 #[test]
572 fn rejects_negative_header_size() {
573 let body = block_with_sizes(-1, 0x7d00, 0);
574 let Err(ZiPatchError::InvalidField { context }) = parse(&body) else {
575 panic!("expected InvalidField for negative header_size");
576 };
577 assert!(
578 context.contains("header_size"),
579 "unexpected context: {context}"
580 );
581 }
582
583 #[test]
584 fn rejects_negative_decompressed_size() {
585 let body = block_with_sizes(16, 0x7d00, -1);
586 let Err(ZiPatchError::InvalidField { context }) = parse(&body) else {
587 panic!("expected InvalidField for negative decompressed_size");
588 };
589 assert!(
590 context.contains("decompressed_size"),
591 "unexpected context: {context}"
592 );
593 }
594
595 #[test]
596 fn rejects_negative_compressed_size() {
597 // is_compressed = (compressed_size != 0x7d00) — pass -1 (not 0x7d00).
598 let body = block_with_sizes(16, -1, 8);
599 let Err(ZiPatchError::InvalidField { context }) = parse(&body) else {
600 panic!("expected InvalidField for negative compressed_size");
601 };
602 assert!(
603 context.contains("compressed_size"),
604 "unexpected context: {context}"
605 );
606 }
607
608 #[test]
609 fn rejects_invalid_utf8_in_path() {
610 // 0xFF is not valid UTF-8 — Utf8Error path on `String::from_utf8`.
611 let body = make_header(b'D', 0, 0, &[0xFFu8], 0);
612 assert!(matches!(parse(&body), Err(ZiPatchError::Utf8Error(_))));
613 }
614
615 #[test]
616 fn decompress_into_uncompressed_writes_data_verbatim() {
617 // Uncompressed branch: w.write_all(&self.data).
618 let block = SqpkCompressedBlock::new(false, 5, b"hello".to_vec());
619 let mut out = Vec::new();
620 block.decompress_into(&mut out).unwrap();
621 assert_eq!(out, b"hello");
622 }
623
624 #[test]
625 fn decompress_into_with_reuses_decompressor_across_blocks() {
626 // Verifies the contract of `decompress_into_with`: the same
627 // `Decompress` instance can be threaded through multiple consecutive
628 // compressed blocks, with `reset` between calls, and produce identical
629 // output to `decompress_into`. This is the apply-layer hot path.
630 use flate2::Compression;
631 use flate2::write::DeflateEncoder;
632 use std::io::Write;
633
634 let payload_a: &[u8] = b"alpha alpha alpha beta beta gamma";
635 let payload_b: &[u8] = b"the quick brown fox jumps over the lazy dog";
636
637 let compress = |raw: &[u8]| -> SqpkCompressedBlock {
638 let mut enc = DeflateEncoder::new(Vec::new(), Compression::default());
639 enc.write_all(raw).unwrap();
640 SqpkCompressedBlock::new(true, raw.len(), enc.finish().unwrap())
641 };
642 let a = compress(payload_a);
643 let b = compress(payload_b);
644
645 let mut state = Decompress::new(false);
646 let mut out_a = Vec::new();
647 a.decompress_into_with(&mut state, &mut out_a).unwrap();
648 assert_eq!(out_a, payload_a, "first block must round-trip");
649
650 let mut out_b = Vec::new();
651 b.decompress_into_with(&mut state, &mut out_b).unwrap();
652 assert_eq!(out_b, payload_b, "reused state must reset and round-trip");
653 }
654
655 #[test]
656 fn decompress_into_with_uncompressed_skips_decompressor() {
657 // The uncompressed branch must never touch the supplied state — it
658 // delegates to `write_all`. Verify the state's `total_in`/`total_out`
659 // are unchanged after the call.
660 let block = SqpkCompressedBlock::new(false, 5, b"hello".to_vec());
661 let mut state = Decompress::new(false);
662 let before_in = state.total_in();
663 let before_out = state.total_out();
664 let mut out = Vec::new();
665 block.decompress_into_with(&mut state, &mut out).unwrap();
666 assert_eq!(out, b"hello");
667 assert_eq!(state.total_in(), before_in);
668 assert_eq!(state.total_out(), before_out);
669 }
670
671 #[test]
672 fn decompress_into_with_propagates_corrupt_stream_error() {
673 // Garbage DEFLATE payload must surface as ZiPatchError::Decompress
674 // rather than panic or loop forever.
675 let block = SqpkCompressedBlock::new(true, 16, vec![0xFFu8; 16]);
676 let mut state = Decompress::new(false);
677 let mut out = Vec::new();
678 assert!(matches!(
679 block.decompress_into_with(&mut state, &mut out),
680 Err(ZiPatchError::Decompress(_))
681 ));
682 }
683
684 #[test]
685 fn decompress_returns_borrowed_for_uncompressed() {
686 // Cow::Borrowed branch — no allocation, points at the block's data.
687 let block = SqpkCompressedBlock::new(false, 4, b"data".to_vec());
688 let cow = block.decompress().unwrap();
689 assert!(matches!(cow, Cow::Borrowed(_)));
690 assert_eq!(&*cow, b"data");
691 }
692
693 #[test]
694 fn decompress_into_compressed_propagates_decompress_error() {
695 // Garbage DEFLATE payload — the `.map_err(ZiPatchError::Decompress)?` arm.
696 let block = SqpkCompressedBlock::new(true, 16, vec![0xFFu8; 16]);
697 let mut out = Vec::new();
698 assert!(matches!(
699 block.decompress_into(&mut out),
700 Err(ZiPatchError::Decompress(_))
701 ));
702 // And via the `decompress()` wrapper — the `?` error arm at line 106.
703 assert!(matches!(
704 block.decompress(),
705 Err(ZiPatchError::Decompress(_))
706 ));
707 }
708
709 #[test]
710 fn parses_compressed_block() {
711 use flate2::Compression;
712 use flate2::write::DeflateEncoder;
713 use std::io::Write;
714
715 let raw: &[u8] = b"hello compressed world";
716 let mut enc = DeflateEncoder::new(Vec::new(), Compression::default());
717 enc.write_all(raw).unwrap();
718 let compressed = enc.finish().unwrap();
719
720 let header_size: i32 = 16;
721 let compressed_size = compressed.len() as i32;
722 let decompressed_size = raw.len() as i32;
723 let block_len = ((compressed_size as u32 + 143) & !127) as usize;
724 let trailing_pad = block_len - header_size as usize - compressed.len();
725
726 // header bytes: 1+2+8+8+4+2+2+1 = 28 — block starts at offset 28
727 let mut body = make_header(b'A', 0, 0, b"\0", 0);
728 body.extend_from_slice(&header_size.to_le_bytes());
729 body.extend_from_slice(&0u32.to_le_bytes()); // pad
730 body.extend_from_slice(&compressed_size.to_le_bytes());
731 body.extend_from_slice(&decompressed_size.to_le_bytes());
732 body.extend_from_slice(&compressed);
733 body.extend_from_slice(&vec![0u8; trailing_pad]);
734
735 let cmd = parse(&body).unwrap();
736 assert_eq!(cmd.blocks.len(), 1);
737 let block = &cmd.blocks[0];
738 assert!(block.is_compressed);
739 assert_eq!(block.decompressed_size, raw.len());
740 assert_eq!(block.decompress().unwrap(), raw);
741 assert_eq!(cmd.block_source_offsets, vec![44u64]); // 28 (header) + 16 (block header)
742 }
743}