zipatch-rs 1.0.2

Parser for FFXIV ZiPatch patch files
Documentation
use std::io::Cursor;
use zipatch_rs::chunk::{ApplyFreeSpace, FileHeader, SqpkCommand};
use zipatch_rs::{Chunk, ZiPatchError, ZiPatchReader};

fn make_chunk(tag: &[u8; 4], body: &[u8]) -> Vec<u8> {
    let mut crc_data = Vec::with_capacity(4 + body.len());
    crc_data.extend_from_slice(tag);
    crc_data.extend_from_slice(body);
    let crc = crc32fast::hash(&crc_data);

    let mut out = Vec::with_capacity(4 + 4 + body.len() + 4);
    out.extend_from_slice(&(body.len() as u32).to_be_bytes());
    out.extend_from_slice(tag);
    out.extend_from_slice(body);
    out.extend_from_slice(&crc.to_be_bytes());
    out
}

fn make_patch(chunks: &[Vec<u8>]) -> Vec<u8> {
    let mut out = Vec::new();
    out.extend_from_slice(&[
        0x91, 0x5A, 0x49, 0x50, 0x41, 0x54, 0x43, 0x48, 0x0D, 0x0A, 0x1A, 0x0A,
    ]);
    for chunk in chunks {
        out.extend_from_slice(chunk);
    }
    out
}

#[test]
fn parses_fhdr_v2_then_eof() {
    let mut body = Vec::new();
    body.extend_from_slice(&(2u32 << 16).to_le_bytes());
    body.extend_from_slice(b"D000");
    body.extend_from_slice(&1u32.to_be_bytes());
    body.extend_from_slice(&[0u8; 8]);

    let data = make_patch(&[make_chunk(b"FHDR", &body), make_chunk(b"EOF_", &[])]);
    let mut reader = ZiPatchReader::new(Cursor::new(data)).unwrap();

    let Chunk::FileHeader(FileHeader::V2(h)) = reader.next().unwrap().unwrap() else {
        panic!("expected FileHeader::V2");
    };
    assert_eq!(h.patch_type, *b"D000");
    assert_eq!(h.entry_files, 1);

    assert!(reader.next().is_none());
    assert!(reader.is_complete());
}

#[test]
fn rejects_bad_magic() {
    assert!(ZiPatchReader::new(Cursor::new(b"not a patch file at all")).is_err());
}

#[test]
fn checksum_verification_enabled_accepts_valid_chunk() {
    let mut body = Vec::new();
    body.extend_from_slice(&(2u32 << 16).to_le_bytes());
    body.extend_from_slice(b"D000");
    body.extend_from_slice(&1u32.to_be_bytes());
    body.extend_from_slice(&[0u8; 8]);

    let data = make_patch(&[make_chunk(b"FHDR", &body), make_chunk(b"EOF_", &[])]);
    let mut reader = ZiPatchReader::new(Cursor::new(data))
        .unwrap()
        .verify_checksums();
    assert!(matches!(
        reader.next().unwrap().unwrap(),
        Chunk::FileHeader(_)
    ));
}

#[test]
fn accepts_zeroed_crc_when_unverified() {
    let mut body = Vec::new();
    body.extend_from_slice(&(2u32 << 16).to_le_bytes());
    body.extend_from_slice(b"D000");
    body.extend_from_slice(&1u32.to_be_bytes());
    body.extend_from_slice(&[0u8; 8]);

    let mut chunk = make_chunk(b"FHDR", &body);
    // zero out the trailing CRC bytes
    let len = chunk.len();
    chunk[len - 4..].fill(0);

    let data = make_patch(&[chunk, make_chunk(b"EOF_", &[])]);
    let mut reader = ZiPatchReader::new(Cursor::new(data))
        .unwrap()
        .skip_checksum_verification();
    assert!(matches!(
        reader.next().unwrap().unwrap(),
        Chunk::FileHeader(_)
    ));
}

#[test]
fn rejects_crc_mismatch() {
    let mut body = Vec::new();
    body.extend_from_slice(&(2u32 << 16).to_le_bytes());
    body.extend_from_slice(b"D000");
    body.extend_from_slice(&0u32.to_be_bytes());
    body.extend_from_slice(&[0u8; 8]);

    let mut chunk = make_chunk(b"FHDR", &body);
    let last = chunk.len() - 1;
    chunk[last] ^= 0xFF;

    let data = make_patch(&[chunk, make_chunk(b"EOF_", &[])]);
    let mut reader = ZiPatchReader::new(Cursor::new(data)).unwrap();
    let err = reader.next().unwrap().unwrap_err();
    let ZiPatchError::ChecksumMismatch { tag, .. } = err else {
        panic!("expected ChecksumMismatch, got {err:?}");
    };
    assert_eq!(&tag, b"FHDR");
}

#[test]
fn rejects_unknown_tag() {
    let data = make_patch(&[make_chunk(b"ZZZZ", &[]), make_chunk(b"EOF_", &[])]);
    let mut reader = ZiPatchReader::new(Cursor::new(data)).unwrap();
    assert!(matches!(
        reader.next().unwrap(),
        Err(ZiPatchError::UnknownChunkTag(_))
    ));
}

#[test]
fn iterator_stops_after_error() {
    let mut body = Vec::new();
    body.extend_from_slice(&(2u32 << 16).to_le_bytes());
    body.extend_from_slice(b"D000");
    body.extend_from_slice(&0u32.to_be_bytes());
    body.extend_from_slice(&[0u8; 8]);

    let mut chunk = make_chunk(b"FHDR", &body);
    let last = chunk.len() - 1;
    chunk[last] ^= 0xFF;

    let data = make_patch(&[chunk, make_chunk(b"EOF_", &[])]);
    let mut reader = ZiPatchReader::new(Cursor::new(data)).unwrap();
    assert!(reader.next().unwrap().is_err());
    assert!(reader.next().is_none());
}

#[test]
fn was_complete_true_after_eof() {
    let data = make_patch(&[make_chunk(b"EOF_", &[])]);
    let mut reader = ZiPatchReader::new(Cursor::new(data)).unwrap();
    assert!(reader.next().is_none());
    assert!(reader.is_complete());
    assert!(reader.next().is_none());
}

#[test]
fn parses_aply_chunk() {
    let mut body = Vec::new();
    body.extend_from_slice(&1u32.to_be_bytes()); // kind = IgnoreMissing
    body.extend_from_slice(&[0u8; 4]); // padding
    body.extend_from_slice(&1u32.to_be_bytes()); // value = true

    let data = make_patch(&[make_chunk(b"APLY", &body), make_chunk(b"EOF_", &[])]);
    let mut reader = ZiPatchReader::new(Cursor::new(data)).unwrap();
    assert!(matches!(
        reader.next().unwrap().unwrap(),
        Chunk::ApplyOption(_)
    ));
}

#[test]
fn parses_apfs_chunk() {
    let mut body = Vec::new();
    body.extend_from_slice(&0u64.to_be_bytes()); // unknown_a
    body.extend_from_slice(&0u64.to_be_bytes()); // unknown_b

    let data = make_patch(&[make_chunk(b"APFS", &body), make_chunk(b"EOF_", &[])]);
    let mut reader = ZiPatchReader::new(Cursor::new(data)).unwrap();
    let chunk = reader.next().unwrap().unwrap();
    assert!(matches!(
        chunk,
        Chunk::ApplyFreeSpace(ApplyFreeSpace {
            unknown_a: 0,
            unknown_b: 0
        })
    ));
}

#[test]
fn parses_deld_chunk() {
    let mut body = Vec::new();
    body.extend_from_slice(&4u32.to_be_bytes()); // name_len
    body.extend_from_slice(b"test");

    let data = make_patch(&[make_chunk(b"DELD", &body), make_chunk(b"EOF_", &[])]);
    let mut reader = ZiPatchReader::new(Cursor::new(data)).unwrap();
    assert!(matches!(
        reader.next().unwrap().unwrap(),
        Chunk::DeleteDirectory(_)
    ));
}

#[test]
fn parses_sqpk_chunk() {
    // Minimal TargetInfo SQPK body
    let mut cmd_body = Vec::new();
    cmd_body.extend_from_slice(&[0u8; 3]); // reserved
    cmd_body.extend_from_slice(&0u16.to_be_bytes()); // platform Win32
    cmd_body.extend_from_slice(&(-1i16).to_be_bytes()); // region Global
    cmd_body.extend_from_slice(&0i16.to_be_bytes()); // not debug
    cmd_body.extend_from_slice(&0u16.to_be_bytes()); // version
    cmd_body.extend_from_slice(&0u64.to_le_bytes()); // deleted_data_size
    cmd_body.extend_from_slice(&0u64.to_le_bytes()); // seek_count

    let total_size = (5 + cmd_body.len()) as i32;
    let mut sqpk_body = Vec::new();
    sqpk_body.extend_from_slice(&total_size.to_be_bytes()); // inner_size
    sqpk_body.push(b'T'); // command = TargetInfo
    sqpk_body.extend_from_slice(&cmd_body);

    let data = make_patch(&[make_chunk(b"SQPK", &sqpk_body), make_chunk(b"EOF_", &[])]);
    let mut reader = ZiPatchReader::new(Cursor::new(data)).unwrap();
    assert!(matches!(
        reader.next().unwrap().unwrap(),
        Chunk::Sqpk(SqpkCommand::TargetInfo(_))
    ));
}

#[test]
fn parses_fhdr_v3() {
    let mut body = Vec::new();
    body.extend_from_slice(&(3u32 << 16).to_le_bytes());
    body.extend_from_slice(b"D000");
    body.extend_from_slice(&5u32.to_be_bytes());
    body.extend_from_slice(&7u32.to_be_bytes());
    body.extend_from_slice(&3u32.to_be_bytes());
    body.extend_from_slice(&2u32.to_be_bytes()); // delete_data_size lo
    body.extend_from_slice(&1u32.to_be_bytes()); // delete_data_size hi → 2 | (1<<32)
    body.extend_from_slice(&10u32.to_be_bytes());
    body.extend_from_slice(&0u32.to_be_bytes());
    body.extend_from_slice(&100u32.to_be_bytes());
    body.extend_from_slice(&20u32.to_be_bytes());
    body.extend_from_slice(&5u32.to_be_bytes());
    body.extend_from_slice(&8u32.to_be_bytes());
    body.extend_from_slice(&12u32.to_be_bytes());
    body.extend_from_slice(&30u32.to_be_bytes());
    body.extend_from_slice(&[0u8; 0xB8]);

    let data = make_patch(&[make_chunk(b"FHDR", &body), make_chunk(b"EOF_", &[])]);
    let mut reader = ZiPatchReader::new(Cursor::new(data)).unwrap();

    let Chunk::FileHeader(FileHeader::V3(h)) = reader.next().unwrap().unwrap() else {
        panic!("expected FileHeader::V3");
    };
    assert_eq!(h.patch_type, *b"D000");
    assert_eq!(h.entry_files, 5);
    assert_eq!(h.add_directories, 7);
    assert_eq!(h.delete_directories, 3);
    assert_eq!(h.delete_data_size, 2 | (1u64 << 32));
    assert_eq!(h.commands, 100);
    assert_eq!(h.sqpk_add_commands, 20);
    assert_eq!(h.sqpk_file_commands, 30);
}