zipatch-rs 1.0.0

Parser for FFXIV ZiPatch patch files
Documentation
pub(crate) mod add_data;
pub(crate) mod delete_data;
pub(crate) mod expand_data;
pub(crate) mod file;
pub(crate) mod header;
pub(crate) mod index;
pub(crate) mod target_info;

pub use add_data::SqpkAddData;
pub use delete_data::SqpkDeleteData;
pub use expand_data::SqpkExpandData;
pub use file::{SqpkCompressedBlock, SqpkFile, SqpkFileOperation};
pub use header::{SqpkHeader, SqpkHeaderTarget, TargetFileKind, TargetHeaderKind};
pub use index::{IndexCommand, SqpkIndex, SqpkPatchInfo};
pub use target_info::SqpkTargetInfo;

use crate::reader::ReadExt;
use crate::{Result, ZiPatchError};
use binrw::BinRead;
use std::io::Cursor;

/// Identifier of a `SqPack` file targeted by a SQPK command.
#[derive(BinRead, Debug, Clone, PartialEq, Eq, Hash)]
#[br(big)]
pub struct SqpackFile {
    /// `SqPack` repository / category ID (`main` segment of the filename).
    pub main_id: u16,
    /// `SqPack` sub-category ID; the high byte encodes the expansion folder.
    pub sub_id: u16,
    /// Per-file index used to derive the `.datN`/`.indexN` suffix.
    pub file_id: u32,
}

/// Sub-command of a `SQPK` chunk; the variant is selected by the command byte.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SqpkCommand {
    /// SQPK `A` — write data at a block offset in a `.dat` file.
    AddData(Box<SqpkAddData>),
    /// SQPK `D` — overwrite a block range with empty-block markers.
    DeleteData(SqpkDeleteData),
    /// SQPK `E` — expand a block range with empty-block markers.
    ExpandData(SqpkExpandData),
    /// SQPK `H` — write a 1024-byte header at offset 0 or 1024.
    Header(SqpkHeader),
    /// SQPK `T` — target platform / region metadata.
    TargetInfo(SqpkTargetInfo),
    /// SQPK `F` — add, delete, or otherwise mutate a whole file.
    File(Box<SqpkFile>),
    /// SQPK `I` — index entry add/delete metadata; not applied directly.
    Index(SqpkIndex),
    /// SQPK `X` — patch install info metadata; not applied directly.
    PatchInfo(SqpkPatchInfo),
}

/// Parse a SQPK chunk body into a [`SqpkCommand`] variant.
pub fn parse_sqpk(body: &[u8]) -> Result<SqpkCommand> {
    let mut c = Cursor::new(body);
    let inner_size = c.read_i32_be()? as usize;
    if inner_size != body.len() {
        return Err(ZiPatchError::InvalidField {
            context: "SQPK inner size mismatch",
        });
    }
    let command = c.read_u8()?;
    let cmd_body = &body[5..];

    match command {
        b'T' => Ok(SqpkCommand::TargetInfo(target_info::parse(cmd_body)?)),
        b'I' => Ok(SqpkCommand::Index(index::parse_index(cmd_body)?)),
        b'X' => Ok(SqpkCommand::PatchInfo(index::parse_patch_info(cmd_body)?)),
        b'A' => Ok(SqpkCommand::AddData(Box::new(add_data::parse(cmd_body)?))),
        b'D' => Ok(SqpkCommand::DeleteData(delete_data::parse(cmd_body)?)),
        b'E' => Ok(SqpkCommand::ExpandData(expand_data::parse(cmd_body)?)),
        b'H' => Ok(SqpkCommand::Header(header::parse(cmd_body)?)),
        b'F' => Ok(SqpkCommand::File(Box::new(file::parse(cmd_body)?))),
        _ => Err(ZiPatchError::UnknownSqpkCommand(command)),
    }
}

#[cfg(test)]
mod tests {
    use super::{SqpkCommand, parse_sqpk};

    fn make_sqpk_body(command: u8, cmd_body: &[u8]) -> Vec<u8> {
        let total = 5 + cmd_body.len();
        let mut out = Vec::with_capacity(total);
        out.extend_from_slice(&(total as i32).to_be_bytes());
        out.push(command);
        out.extend_from_slice(cmd_body);
        out
    }

    #[test]
    fn parses_target_info() {
        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(&1234u64.to_le_bytes()); // deleted_data_size
        cmd_body.extend_from_slice(&5678u64.to_le_bytes()); // seek_count

        let body = make_sqpk_body(b'T', &cmd_body);
        match parse_sqpk(&body).unwrap() {
            SqpkCommand::TargetInfo(t) => {
                assert_eq!(t.platform_id, 0);
                assert_eq!(t.region, -1);
                assert!(!t.is_debug);
                assert_eq!(t.deleted_data_size, 1234);
                assert_eq!(t.seek_count, 5678);
            }
            other => panic!("expected SqpkCommand::TargetInfo, got {other:?}"),
        }
    }

    #[test]
    fn rejects_inner_size_mismatch() {
        let mut body = Vec::new();
        body.extend_from_slice(&999i32.to_be_bytes()); // wrong inner_size
        body.push(b'T');
        assert!(parse_sqpk(&body).is_err());
    }

    #[test]
    fn rejects_unknown_command() {
        let body = make_sqpk_body(b'Z', &[]);
        assert!(parse_sqpk(&body).is_err());
    }

    fn index_cmd_body() -> Vec<u8> {
        let mut v = Vec::new();
        v.push(b'A'); // IndexCommand::Add
        v.push(0u8); // is_synonym = false
        v.push(0u8); // alignment
        v.extend_from_slice(&0u16.to_be_bytes()); // main_id
        v.extend_from_slice(&0u16.to_be_bytes()); // sub_id
        v.extend_from_slice(&0u32.to_be_bytes()); // file_id
        v.extend_from_slice(&0u64.to_be_bytes()); // file_hash
        v.extend_from_slice(&0u32.to_be_bytes()); // block_offset
        v.extend_from_slice(&0u32.to_be_bytes()); // block_number
        v
    }

    #[test]
    fn parses_index_command() {
        let body = make_sqpk_body(b'I', &index_cmd_body());
        assert!(matches!(parse_sqpk(&body).unwrap(), SqpkCommand::Index(_)));
    }

    #[test]
    fn parses_patch_info_command() {
        let mut cmd_body = Vec::new();
        cmd_body.push(0u8); // status
        cmd_body.push(0u8); // version
        cmd_body.push(0u8); // alignment
        cmd_body.extend_from_slice(&0u64.to_be_bytes()); // install_size
        let body = make_sqpk_body(b'X', &cmd_body);
        assert!(matches!(
            parse_sqpk(&body).unwrap(),
            SqpkCommand::PatchInfo(_)
        ));
    }

    #[test]
    fn index_command_truncated_body_returns_error() {
        // Empty `I` body — index::parse_index must error, exercising the `?` arm.
        let body = make_sqpk_body(b'I', &[]);
        assert!(parse_sqpk(&body).is_err());
    }

    #[test]
    fn patch_info_command_truncated_body_returns_error() {
        // Empty `X` body — index::parse_patch_info must error, exercising the `?` arm.
        let body = make_sqpk_body(b'X', &[]);
        assert!(parse_sqpk(&body).is_err());
    }

    #[test]
    fn parses_add_data_command() {
        let mut cmd_body = Vec::new();
        cmd_body.extend_from_slice(&[0u8; 3]); // pad
        cmd_body.extend_from_slice(&0u16.to_be_bytes()); // main_id
        cmd_body.extend_from_slice(&0u16.to_be_bytes()); // sub_id
        cmd_body.extend_from_slice(&0u32.to_be_bytes()); // file_id
        cmd_body.extend_from_slice(&0u32.to_be_bytes()); // block_offset_raw
        cmd_body.extend_from_slice(&0u32.to_be_bytes()); // data_bytes_raw = 0 → no data
        cmd_body.extend_from_slice(&0u32.to_be_bytes()); // block_delete_number_raw
        let body = make_sqpk_body(b'A', &cmd_body);
        assert!(matches!(
            parse_sqpk(&body).unwrap(),
            SqpkCommand::AddData(_)
        ));
    }

    #[test]
    fn parses_delete_data_command() {
        let mut cmd_body = Vec::new();
        cmd_body.extend_from_slice(&[0u8; 3]); // pad
        cmd_body.extend_from_slice(&0u16.to_be_bytes()); // main_id
        cmd_body.extend_from_slice(&0u16.to_be_bytes()); // sub_id
        cmd_body.extend_from_slice(&0u32.to_be_bytes()); // file_id
        cmd_body.extend_from_slice(&0u32.to_be_bytes()); // block_offset_raw
        cmd_body.extend_from_slice(&1u32.to_be_bytes()); // block_count
        cmd_body.extend_from_slice(&[0u8; 4]); // reserved
        let body = make_sqpk_body(b'D', &cmd_body);
        assert!(matches!(
            parse_sqpk(&body).unwrap(),
            SqpkCommand::DeleteData(_)
        ));
    }

    #[test]
    fn parses_expand_data_command() {
        let mut cmd_body = Vec::new();
        cmd_body.extend_from_slice(&[0u8; 3]); // pad
        cmd_body.extend_from_slice(&0u16.to_be_bytes()); // main_id
        cmd_body.extend_from_slice(&0u16.to_be_bytes()); // sub_id
        cmd_body.extend_from_slice(&0u32.to_be_bytes()); // file_id
        cmd_body.extend_from_slice(&0u32.to_be_bytes()); // block_offset_raw
        cmd_body.extend_from_slice(&1u32.to_be_bytes()); // block_count
        cmd_body.extend_from_slice(&[0u8; 4]); // reserved
        let body = make_sqpk_body(b'E', &cmd_body);
        assert!(matches!(
            parse_sqpk(&body).unwrap(),
            SqpkCommand::ExpandData(_)
        ));
    }

    #[test]
    fn parses_header_command() {
        let mut cmd_body = Vec::new();
        cmd_body.push(b'D'); // file_kind = Dat
        cmd_body.push(b'V'); // header_kind = Version
        cmd_body.push(0u8); // alignment
        cmd_body.extend_from_slice(&0u16.to_be_bytes()); // main_id
        cmd_body.extend_from_slice(&0u16.to_be_bytes()); // sub_id
        cmd_body.extend_from_slice(&0u32.to_be_bytes()); // file_id
        cmd_body.extend_from_slice(&[0u8; 1024]); // header_data
        let body = make_sqpk_body(b'H', &cmd_body);
        assert!(matches!(parse_sqpk(&body).unwrap(), SqpkCommand::Header(_)));
    }

    #[test]
    fn parses_file_command() {
        let mut cmd_body = Vec::new();
        cmd_body.push(b'A'); // operation = AddFile
        cmd_body.extend_from_slice(&[0u8; 2]); // alignment
        cmd_body.extend_from_slice(&0u64.to_be_bytes()); // file_offset
        cmd_body.extend_from_slice(&0u64.to_be_bytes()); // file_size
        cmd_body.extend_from_slice(&1u32.to_be_bytes()); // path_len = 1
        cmd_body.extend_from_slice(&0u16.to_be_bytes()); // expansion_id
        cmd_body.extend_from_slice(&[0u8; 2]); // padding
        cmd_body.push(b'\0'); // path = ""
        let body = make_sqpk_body(b'F', &cmd_body);
        assert!(matches!(parse_sqpk(&body).unwrap(), SqpkCommand::File(_)));
    }
}