zipatch-rs 1.0.0

Parser for FFXIV ZiPatch patch files
Documentation
use crate::reader::ReadExt;
use crate::{Result, ZiPatchError};
use flate2::read::DeflateDecoder;
use std::borrow::Cow;
use std::io::{Cursor, Read, Write};

/// Operation byte of a SQPK `F` command.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SqpkFileOperation {
    /// `A` — add or overwrite a file with the inline block payload.
    AddFile,
    /// `R` — remove all files in the expansion folder except a keep-list.
    RemoveAll,
    /// `D` — delete a single file.
    DeleteFile,
    /// `M` — create a directory tree.
    MakeDirTree,
}

/// One DEFLATE-or-raw block of a `SqpkFile` payload.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SqpkCompressedBlock {
    is_compressed: bool,
    decompressed_size: usize,
    data: Vec<u8>, // compressed: raw DEFLATE + alignment padding; uncompressed: exact bytes
}

impl SqpkCompressedBlock {
    /// Build a block from its parsed parts (test/external use).
    #[must_use]
    pub fn new(is_compressed: bool, decompressed_size: usize, data: Vec<u8>) -> Self {
        Self {
            is_compressed,
            decompressed_size,
            data,
        }
    }

    fn read<R: Read>(r: &mut R) -> Result<Self> {
        let header_size_raw = r.read_i32_le()?;
        r.skip(4)?; // pad
        let compressed_size = r.read_i32_le()?;
        let decompressed_size_raw = r.read_i32_le()?;

        if header_size_raw < 0 {
            return Err(ZiPatchError::InvalidField {
                context: "negative header_size in block",
            });
        }
        if decompressed_size_raw < 0 {
            return Err(ZiPatchError::InvalidField {
                context: "negative decompressed_size in block",
            });
        }
        let is_compressed = compressed_size != 0x7d00;
        if is_compressed && compressed_size < 0 {
            return Err(ZiPatchError::InvalidField {
                context: "negative compressed_size in block",
            });
        }

        let header_size = header_size_raw as usize;
        let decompressed_size = decompressed_size_raw as usize;
        let data_len = if is_compressed {
            compressed_size
        } else {
            decompressed_size_raw
        };
        let block_len = ((data_len as u32 + 143) & !127u32) as usize;
        let data = if is_compressed {
            r.read_exact_vec(block_len - header_size)?
        } else {
            let d = r.read_exact_vec(decompressed_size)?;
            r.skip((block_len - header_size - decompressed_size) as u64)?;
            d
        };
        Ok(SqpkCompressedBlock {
            is_compressed,
            decompressed_size,
            data,
        })
    }

    /// Stream the block's decompressed bytes into `w`.
    ///
    /// Uncompressed blocks are written verbatim; compressed blocks are run
    /// through a DEFLATE decoder. Returns [`ZiPatchError::Decompress`] on a
    /// decompression error.
    pub fn decompress_into(&self, w: &mut impl Write) -> Result<()> {
        if self.is_compressed {
            std::io::copy(&mut DeflateDecoder::new(self.data.as_slice()), w)
                .map_err(ZiPatchError::Decompress)?;
        } else {
            w.write_all(&self.data)?;
        }
        Ok(())
    }

    /// Return the block's decompressed bytes.
    ///
    /// Uncompressed blocks return a borrow into the existing buffer with no
    /// allocation; compressed blocks allocate and decompress into a new `Vec`.
    pub fn decompress(&self) -> crate::Result<Cow<'_, [u8]>> {
        if self.is_compressed {
            let mut out = Vec::with_capacity(self.decompressed_size);
            self.decompress_into(&mut out)?;
            Ok(Cow::Owned(out))
        } else {
            Ok(Cow::Borrowed(&self.data))
        }
    }
}

/// SQPK `F` command body: file-level operation (add/delete/etc.) on the install tree.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SqpkFile {
    /// Operation type (`A`/`R`/`D`/`M`).
    pub operation: SqpkFileOperation,
    /// Destination offset within the target file (negative is rejected at apply).
    pub file_offset: i64,
    /// Declared total size of the target file after the operation.
    pub file_size: i64,
    /// Expansion ID; selects the `ex<n>` (or `ffxiv`) sub-folder for `RemoveAll`.
    pub expansion_id: u16,
    /// Relative target path under the game install root.
    pub path: String,
    /// Byte offset of each block's data payload (after its 16-byte header)
    /// within the SQPK command body slice. Add the chunk's absolute file
    /// position to get the patch-file offset needed for `IndexedZiPatch`
    /// random-access reads.
    pub block_source_offsets: Vec<u64>,
    /// Inline block payloads for `AddFile`; empty for other operations.
    pub blocks: Vec<SqpkCompressedBlock>,
}

pub(crate) fn parse(body: &[u8]) -> Result<SqpkFile> {
    let mut c = Cursor::new(body);

    let operation = match c.read_u8()? {
        b'A' => SqpkFileOperation::AddFile,
        b'R' => SqpkFileOperation::RemoveAll,
        b'D' => SqpkFileOperation::DeleteFile,
        b'M' => SqpkFileOperation::MakeDirTree,
        b => {
            return Err(ZiPatchError::UnknownFileOperation(b));
        }
    };
    c.skip(2)?; // alignment

    let file_offset = c.read_u64_be()? as i64;
    let file_size = c.read_u64_be()? as i64;
    let path_len = c.read_u32_be()?;
    let expansion_id = c.read_u16_be()?;
    c.skip(2)?; // padding

    let path_bytes = c.read_exact_vec(path_len as usize)?;
    let path = String::from_utf8(path_bytes)
        .map(|s| s.trim_end_matches('\0').to_owned())
        .map_err(ZiPatchError::Utf8Error)?;

    let (blocks, block_source_offsets) = if matches!(operation, SqpkFileOperation::AddFile) {
        let mut blocks = Vec::new();
        let mut offsets = Vec::new();
        while (c.position() as usize) < body.len() {
            // Record offset of the data payload (after the fixed 16-byte block header).
            offsets.push(c.position() + 16);
            blocks.push(SqpkCompressedBlock::read(&mut c)?);
        }
        (blocks, offsets)
    } else {
        (Vec::new(), Vec::new())
    };

    Ok(SqpkFile {
        operation,
        file_offset,
        file_size,
        expansion_id,
        path,
        block_source_offsets,
        blocks,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_header(
        op: u8,
        file_offset: i64,
        file_size: i64,
        path: &[u8],
        expansion_id: u16,
    ) -> Vec<u8> {
        let mut body = Vec::new();
        body.push(op);
        body.extend_from_slice(&[0u8; 2]); // alignment
        body.extend_from_slice(&(file_offset as u64).to_be_bytes());
        body.extend_from_slice(&(file_size as u64).to_be_bytes());
        body.extend_from_slice(&(path.len() as u32).to_be_bytes());
        body.extend_from_slice(&expansion_id.to_be_bytes());
        body.extend_from_slice(&[0u8; 2]); // padding
        body.extend_from_slice(path);
        body
    }

    #[test]
    fn parses_add_file_no_blocks() {
        let body = make_header(b'A', 0, 512, b"test\0", 1);
        let cmd = parse(&body).unwrap();
        assert!(matches!(cmd.operation, SqpkFileOperation::AddFile));
        assert_eq!(cmd.file_offset, 0);
        assert_eq!(cmd.file_size, 512);
        assert_eq!(cmd.expansion_id, 1);
        assert_eq!(cmd.path, "test");
        assert!(cmd.blocks.is_empty());
        assert!(cmd.block_source_offsets.is_empty());
    }

    #[test]
    fn parses_add_file_uncompressed_block() {
        // block_len = ((8 + 143) & !127) = 128; read 8 data bytes + skip 104 padding
        let mut body = make_header(b'A', 0, 0, b"\0", 0);
        // header bytes: 1+2+8+8+4+2+2+1 = 28 — block starts at offset 28
        body.extend_from_slice(&16i32.to_le_bytes()); // header_size
        body.extend_from_slice(&0u32.to_le_bytes()); // pad
        body.extend_from_slice(&0x7d00i32.to_le_bytes()); // compressed_size = uncompressed sentinel
        body.extend_from_slice(&8i32.to_le_bytes()); // decompressed_size
        body.extend_from_slice(&[0xABu8; 8]); // data
        body.extend_from_slice(&[0u8; 104]); // alignment padding

        let cmd = parse(&body).unwrap();
        assert_eq!(cmd.blocks.len(), 1);
        let block = &cmd.blocks[0];
        assert!(!block.is_compressed);
        assert_eq!(block.decompressed_size, 8);
        assert_eq!(block.data.len(), 8);
        assert!(block.data.iter().all(|&b| b == 0xAB));
        assert_eq!(block.decompress().unwrap(), vec![0xABu8; 8]);
        assert_eq!(cmd.block_source_offsets, vec![44u64]); // 28 (header) + 16 (block header)
    }

    #[test]
    fn parses_remove_all_operation() {
        let body = make_header(b'R', 0, 0, b"\0", 0);
        let cmd = parse(&body).unwrap();
        assert!(matches!(cmd.operation, SqpkFileOperation::RemoveAll));
        assert!(cmd.blocks.is_empty());
        assert!(cmd.block_source_offsets.is_empty());
    }

    #[test]
    fn parses_delete_file_operation() {
        let body = make_header(b'D', 0, 0, b"sqpack/foo.dat\0", 0);
        let cmd = parse(&body).unwrap();
        assert!(matches!(cmd.operation, SqpkFileOperation::DeleteFile));
        assert_eq!(cmd.path, "sqpack/foo.dat");
    }

    #[test]
    fn parses_make_dir_tree_operation() {
        let body = make_header(b'M', 0, 0, b"sqpack/ex1\0", 0);
        let cmd = parse(&body).unwrap();
        assert!(matches!(cmd.operation, SqpkFileOperation::MakeDirTree));
        assert_eq!(cmd.path, "sqpack/ex1");
    }

    #[test]
    fn rejects_unknown_operation() {
        let body = make_header(b'Z', 0, 0, b"\0", 0);
        assert!(parse(&body).is_err());
    }

    fn block_with_sizes(header_size: i32, compressed_size: i32, decompressed_size: i32) -> Vec<u8> {
        let mut body = make_header(b'A', 0, 0, b"\0", 0);
        body.extend_from_slice(&header_size.to_le_bytes());
        body.extend_from_slice(&0u32.to_le_bytes()); // pad
        body.extend_from_slice(&compressed_size.to_le_bytes());
        body.extend_from_slice(&decompressed_size.to_le_bytes());
        body
    }

    #[test]
    fn rejects_negative_header_size() {
        let body = block_with_sizes(-1, 0x7d00, 0);
        let Err(ZiPatchError::InvalidField { context }) = parse(&body) else {
            panic!("expected InvalidField for negative header_size");
        };
        assert!(
            context.contains("header_size"),
            "unexpected context: {context}"
        );
    }

    #[test]
    fn rejects_negative_decompressed_size() {
        let body = block_with_sizes(16, 0x7d00, -1);
        let Err(ZiPatchError::InvalidField { context }) = parse(&body) else {
            panic!("expected InvalidField for negative decompressed_size");
        };
        assert!(
            context.contains("decompressed_size"),
            "unexpected context: {context}"
        );
    }

    #[test]
    fn rejects_negative_compressed_size() {
        // is_compressed = (compressed_size != 0x7d00) — pass -1 (not 0x7d00).
        let body = block_with_sizes(16, -1, 8);
        let Err(ZiPatchError::InvalidField { context }) = parse(&body) else {
            panic!("expected InvalidField for negative compressed_size");
        };
        assert!(
            context.contains("compressed_size"),
            "unexpected context: {context}"
        );
    }

    #[test]
    fn rejects_invalid_utf8_in_path() {
        // 0xFF is not valid UTF-8 — Utf8Error path on `String::from_utf8`.
        let body = make_header(b'D', 0, 0, &[0xFFu8], 0);
        assert!(matches!(parse(&body), Err(ZiPatchError::Utf8Error(_))));
    }

    #[test]
    fn decompress_into_uncompressed_writes_data_verbatim() {
        // Uncompressed branch: w.write_all(&self.data).
        let block = SqpkCompressedBlock::new(false, 5, b"hello".to_vec());
        let mut out = Vec::new();
        block.decompress_into(&mut out).unwrap();
        assert_eq!(out, b"hello");
    }

    #[test]
    fn decompress_returns_borrowed_for_uncompressed() {
        // Cow::Borrowed branch — no allocation, points at the block's data.
        let block = SqpkCompressedBlock::new(false, 4, b"data".to_vec());
        let cow = block.decompress().unwrap();
        assert!(matches!(cow, Cow::Borrowed(_)));
        assert_eq!(&*cow, b"data");
    }

    #[test]
    fn decompress_into_compressed_propagates_decompress_error() {
        // Garbage DEFLATE payload — the `.map_err(ZiPatchError::Decompress)?` arm.
        let block = SqpkCompressedBlock::new(true, 16, vec![0xFFu8; 16]);
        let mut out = Vec::new();
        assert!(matches!(
            block.decompress_into(&mut out),
            Err(ZiPatchError::Decompress(_))
        ));
        // And via the `decompress()` wrapper — the `?` error arm at line 106.
        assert!(matches!(
            block.decompress(),
            Err(ZiPatchError::Decompress(_))
        ));
    }

    #[test]
    fn parses_compressed_block() {
        use flate2::Compression;
        use flate2::write::DeflateEncoder;
        use std::io::Write;

        let raw: &[u8] = b"hello compressed world";
        let mut enc = DeflateEncoder::new(Vec::new(), Compression::default());
        enc.write_all(raw).unwrap();
        let compressed = enc.finish().unwrap();

        let header_size: i32 = 16;
        let compressed_size = compressed.len() as i32;
        let decompressed_size = raw.len() as i32;
        let block_len = ((compressed_size as u32 + 143) & !127) as usize;
        let trailing_pad = block_len - header_size as usize - compressed.len();

        // header bytes: 1+2+8+8+4+2+2+1 = 28 — block starts at offset 28
        let mut body = make_header(b'A', 0, 0, b"\0", 0);
        body.extend_from_slice(&header_size.to_le_bytes());
        body.extend_from_slice(&0u32.to_le_bytes()); // pad
        body.extend_from_slice(&compressed_size.to_le_bytes());
        body.extend_from_slice(&decompressed_size.to_le_bytes());
        body.extend_from_slice(&compressed);
        body.extend_from_slice(&vec![0u8; trailing_pad]);

        let cmd = parse(&body).unwrap();
        assert_eq!(cmd.blocks.len(), 1);
        let block = &cmd.blocks[0];
        assert!(block.is_compressed);
        assert_eq!(block.decompressed_size, raw.len());
        assert_eq!(block.decompress().unwrap(), raw);
        assert_eq!(cmd.block_source_offsets, vec![44u64]); // 28 (header) + 16 (block header)
    }
}