zipatch-rs 1.2.0

Parser for FFXIV ZiPatch patch files
Documentation
//! Shared fixtures for integration tests under `tests/`.
//!
//! Per Cargo's integration-test conventions, this lives at `tests/common/mod.rs`
//! rather than `tests/common.rs` so Cargo treats it as a regular module rather
//! than a separate test binary. Each consumer file declares `mod common;` and
//! pulls helpers it needs by name.
//!
//! Helpers in this module are duplicated across at least two test files in the
//! pre-polish layout; single-use helpers stay inline with their caller.

#![allow(dead_code)]

use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};

use flate2::Compression;
use flate2::write::DeflateEncoder;
use zipatch_rs::test_utils::{MAGIC, make_chunk};

// ---- SQPK chunk body builders ----

/// SQPK 'A' (`AddData`) body. `block_offset_raw` and `block_delete_raw` are
/// the wire-format pre-shift values; the on-disk file offset is
/// `block_offset_raw * 128`.
pub fn sqpk_add_data_body(
    main_id: u16,
    sub_id: u16,
    file_id: u32,
    block_offset_raw: u32,
    data: &[u8],
    block_delete_raw: u32,
) -> Vec<u8> {
    assert!(data.len() % 128 == 0, "AddData payload must be 128-aligned");
    let data_bytes_raw = (data.len() / 128) as u32;
    let total = 5 + 23 + data.len();
    let mut body = Vec::with_capacity(total);
    body.extend_from_slice(&(total as i32).to_be_bytes());
    body.push(b'A');
    body.extend_from_slice(&[0u8; 3]);
    body.extend_from_slice(&main_id.to_be_bytes());
    body.extend_from_slice(&sub_id.to_be_bytes());
    body.extend_from_slice(&file_id.to_be_bytes());
    body.extend_from_slice(&block_offset_raw.to_be_bytes());
    body.extend_from_slice(&data_bytes_raw.to_be_bytes());
    body.extend_from_slice(&block_delete_raw.to_be_bytes());
    body.extend_from_slice(data);
    body
}

/// SQPK 'E' (`ExpandData`) body — allocates `block_count` empty 128-byte
/// blocks at `block_offset_raw * 128` in the target dat file.
pub fn sqpk_expand_data_body(
    main_id: u16,
    sub_id: u16,
    file_id: u32,
    block_offset_raw: u32,
    block_count: u32,
) -> Vec<u8> {
    let mut cmd = Vec::new();
    cmd.extend_from_slice(&[0u8; 3]);
    cmd.extend_from_slice(&main_id.to_be_bytes());
    cmd.extend_from_slice(&sub_id.to_be_bytes());
    cmd.extend_from_slice(&file_id.to_be_bytes());
    cmd.extend_from_slice(&block_offset_raw.to_be_bytes());
    cmd.extend_from_slice(&block_count.to_be_bytes());
    cmd.extend_from_slice(&[0u8; 4]);

    let mut body = Vec::new();
    let inner_size = (5 + cmd.len()) as i32;
    body.extend_from_slice(&inner_size.to_be_bytes());
    body.push(b'E');
    body.extend_from_slice(&cmd);
    body
}

/// SQPK 'T' (`TargetInfo`) body — pins the apply-time platform.
pub fn sqpk_target_info_body(platform_id: u16) -> Vec<u8> {
    let mut cmd = Vec::new();
    cmd.extend_from_slice(&[0u8; 3]);
    cmd.extend_from_slice(&platform_id.to_be_bytes());
    cmd.extend_from_slice(&0i16.to_be_bytes());
    cmd.extend_from_slice(&0i16.to_be_bytes());
    cmd.extend_from_slice(&0u16.to_be_bytes());
    cmd.extend_from_slice(&0u64.to_le_bytes());
    cmd.extend_from_slice(&0u64.to_le_bytes());

    let mut body = Vec::new();
    let inner_size = (5 + cmd.len()) as i32;
    body.extend_from_slice(&inner_size.to_be_bytes());
    body.push(b'T');
    body.extend_from_slice(&cmd);
    body
}

/// `ADIR` chunk body — wire-format encoding of a single directory name.
/// `DELD` shares the same body shape.
pub fn adir_body(name: &str) -> Vec<u8> {
    let mut b = Vec::new();
    b.extend_from_slice(&(name.len() as u32).to_be_bytes());
    b.extend_from_slice(name.as_bytes());
    b
}

/// One block inside an `SqpkFile` `AddFile` body.
#[derive(Clone)]
pub struct FileBlock {
    pub is_compressed: bool,
    pub decompressed: Vec<u8>,
}

/// SQPK 'F' (`SqpkFile`) `AddFile` body — writes a list of blocks to `path`
/// starting at `file_offset` bytes into the target file.
pub fn sqpk_addfile_body(path: &str, file_offset: i64, blocks: &[FileBlock]) -> Vec<u8> {
    let mut path_bytes = path.as_bytes().to_vec();
    path_bytes.push(0); // NUL terminator

    let mut cmd = Vec::new();
    cmd.push(b'A'); // AddFile
    cmd.extend_from_slice(&[0u8; 2]); // alignment
    cmd.extend_from_slice(&(file_offset as u64).to_be_bytes());
    let total_size: u64 = blocks.iter().map(|b| b.decompressed.len() as u64).sum();
    cmd.extend_from_slice(&total_size.to_be_bytes());
    cmd.extend_from_slice(&(path_bytes.len() as u32).to_be_bytes());
    cmd.extend_from_slice(&0u16.to_be_bytes()); // expansion_id
    cmd.extend_from_slice(&[0u8; 2]); // padding
    cmd.extend_from_slice(&path_bytes);

    for block in blocks {
        if block.is_compressed {
            let mut enc = DeflateEncoder::new(Vec::new(), Compression::default());
            enc.write_all(&block.decompressed).unwrap();
            let compressed = enc.finish().unwrap();
            let header_size: i32 = 16;
            let compressed_size = compressed.len() as i32;
            let decompressed_size = block.decompressed.len() as i32;
            let data_len = compressed_size;
            let block_len = ((data_len as u32 + 143) & !127) as usize;
            let pad = block_len - 16 - compressed.len();

            cmd.extend_from_slice(&header_size.to_le_bytes());
            cmd.extend_from_slice(&0u32.to_le_bytes()); // pad
            cmd.extend_from_slice(&compressed_size.to_le_bytes());
            cmd.extend_from_slice(&decompressed_size.to_le_bytes());
            cmd.extend_from_slice(&compressed);
            cmd.extend_from_slice(&vec![0u8; pad]);
        } else {
            let header_size: i32 = 16;
            let compressed_size: i32 = 0x7d00; // uncompressed sentinel
            let decompressed_size = block.decompressed.len() as i32;
            let data_len = decompressed_size;
            let block_len = ((data_len as u32 + 143) & !127) as usize;
            let pad = block_len - 16 - block.decompressed.len();

            cmd.extend_from_slice(&header_size.to_le_bytes());
            cmd.extend_from_slice(&0u32.to_le_bytes());
            cmd.extend_from_slice(&compressed_size.to_le_bytes());
            cmd.extend_from_slice(&decompressed_size.to_le_bytes());
            cmd.extend_from_slice(&block.decompressed);
            cmd.extend_from_slice(&vec![0u8; pad]);
        }
    }

    let mut body = Vec::new();
    let inner_size = (5 + cmd.len()) as i32;
    body.extend_from_slice(&inner_size.to_be_bytes());
    body.push(b'F');
    body.extend_from_slice(&cmd);
    body
}

/// SQPK 'F' `DeleteFile` body — removes `path` (NUL-terminated) from the
/// install tree.
pub fn sqpk_delete_file_body(path: &str) -> Vec<u8> {
    let mut path_bytes = path.as_bytes().to_vec();
    path_bytes.push(0);

    let mut cmd = Vec::new();
    cmd.push(b'D');
    cmd.extend_from_slice(&[0u8; 2]);
    cmd.extend_from_slice(&0u64.to_be_bytes()); // file_offset
    cmd.extend_from_slice(&0u64.to_be_bytes()); // file_size
    cmd.extend_from_slice(&(path_bytes.len() as u32).to_be_bytes());
    cmd.extend_from_slice(&0u16.to_be_bytes());
    cmd.extend_from_slice(&[0u8; 2]);
    cmd.extend_from_slice(&path_bytes);

    let mut body = Vec::new();
    let inner_size = (5 + cmd.len()) as i32;
    body.extend_from_slice(&inner_size.to_be_bytes());
    body.push(b'F');
    body.extend_from_slice(&cmd);
    body
}

// ---- Patch envelope ----

/// Assemble `chunks` into a complete patch stream: `MAGIC ++ chunks ++ EOF_`.
pub fn wrap_patch(chunks: Vec<Vec<u8>>) -> Vec<u8> {
    let mut patch = Vec::new();
    patch.extend_from_slice(&MAGIC);
    for c in chunks {
        patch.extend_from_slice(&c);
    }
    patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
    patch
}

// ---- Tree comparison ----

/// Walk `root` and collect every regular file into a map of
/// relative-path → `(size, CRC32(content))`. Symlinks and directories are skipped.
pub fn snapshot_tree(root: &Path) -> BTreeMap<PathBuf, (u64, u32)> {
    let mut out = BTreeMap::new();
    if root.exists() {
        visit(root, root, &mut out);
    }
    out
}

fn visit(root: &Path, dir: &Path, out: &mut BTreeMap<PathBuf, (u64, u32)>) {
    for entry in std::fs::read_dir(dir).unwrap() {
        let entry = entry.unwrap();
        let path = entry.path();
        let kind = entry.file_type().unwrap();
        if kind.is_dir() {
            visit(root, &path, out);
        } else if kind.is_file() {
            let bytes = std::fs::read(&path).unwrap();
            let rel = path.strip_prefix(root).unwrap().to_path_buf();
            out.insert(rel, (bytes.len() as u64, crc32fast::hash(&bytes)));
        }
    }
}

/// Assert `a` and `b` contain the same set of files with byte-identical content.
/// Localises mismatches to the first differing byte to keep failure output useful.
pub fn assert_trees_equal(a: &Path, b: &Path) {
    let snap_a = snapshot_tree(a);
    let snap_b = snapshot_tree(b);

    let keys_a: Vec<_> = snap_a.keys().collect();
    let keys_b: Vec<_> = snap_b.keys().collect();
    assert_eq!(
        keys_a, keys_b,
        "directory contents differ:\n  sequential: {keys_a:?}\n  indexed:    {keys_b:?}"
    );

    for (rel, (len_a, crc_a)) in &snap_a {
        let (len_b, crc_b) = &snap_b[rel];
        assert_eq!(len_a, len_b, "file size mismatch for {}", rel.display());
        if crc_a != crc_b {
            let bytes_a = std::fs::read(a.join(rel)).unwrap();
            let bytes_b = std::fs::read(b.join(rel)).unwrap();
            let mismatch = bytes_a.iter().zip(bytes_b.iter()).position(|(x, y)| x != y);
            panic!(
                "content mismatch for {}: crc {crc_a:#010x} vs {crc_b:#010x}, first diff at {:?}",
                rel.display(),
                mismatch
            );
        }
    }
}