zipatch-rs 1.0.0

Parser for FFXIV ZiPatch patch files
Documentation
//! Parser and applier for FFXIV `ZiPatch` (`.patch`) binary files.
//!
//! This crate is split into two layers that share types but are otherwise
//! independent:
//!
//! - **Parsing** — [`ZiPatchReader`] is an iterator over [`Chunk`]s read from any
//!   [`std::io::Read`] source. Nothing in the parser touches the filesystem.
//! - **Applying** — the [`Apply`] trait writes a parsed chunk to disk through an
//!   [`ApplyContext`], which holds the game install root, target platform, and
//!   internal file-handle cache.
//!
//! Typical usage opens a patch file, constructs a context, and pipes chunks
//! through [`ZiPatchReader::apply_to`].

#![warn(missing_docs)]

/// Filesystem application of parsed chunks ([`Apply`], [`ApplyContext`]).
pub mod apply;
/// Wire-format chunk types and the [`ZiPatchReader`] iterator.
pub mod chunk;
/// Error type returned by parsing and applying ([`ZiPatchError`]).
pub mod error;
pub(crate) mod reader;

pub use apply::{Apply, ApplyContext};
pub use chunk::{Chunk, ZiPatchReader};
pub use error::ZiPatchError;

/// Crate-wide `Result` alias parameterised over [`ZiPatchError`].
pub type Result<T> = std::result::Result<T, ZiPatchError>;

impl<R: std::io::Read> chunk::ZiPatchReader<R> {
    /// Iterate every chunk in the patch stream and apply each one to `ctx`.
    ///
    /// Stops at the first parse or apply error; otherwise consumes the reader
    /// to completion (including the `EOF_` terminator).
    pub fn apply_to(self, ctx: &mut apply::ApplyContext) -> Result<()> {
        use apply::Apply;
        for chunk in self {
            chunk?.apply(ctx)?;
        }
        Ok(())
    }
}

/// Target platform for `SqPack` file path resolution.
///
/// Set by [`ApplyContext::with_platform`] or overridden when a `SqpkTargetInfo`
/// chunk is applied. `Unknown(id)` preserves an unrecognised platform ID so
/// newer patch files do not fail parsing; path resolution falls back to the
/// `win32` layout for unknown variants.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Platform {
    /// Windows / PC client (`win32` path suffix).
    Win32,
    /// `PlayStation` 3 client (`ps3` path suffix).
    Ps3,
    /// `PlayStation` 4 client (`ps4` path suffix).
    Ps4,
    /// Unrecognised platform ID from a `TargetInfo` chunk; preserved so
    /// newer patch files do not fail parsing on unknown platforms.
    Unknown(u16),
}

impl std::fmt::Display for Platform {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Platform::Win32 => f.write_str("Win32"),
            Platform::Ps3 => f.write_str("PS3"),
            Platform::Ps4 => f.write_str("PS4"),
            Platform::Unknown(id) => write!(f, "Unknown({id})"),
        }
    }
}

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

    const MAGIC: [u8; 12] = [
        0x91, 0x5A, 0x49, 0x50, 0x41, 0x54, 0x43, 0x48, 0x0D, 0x0A, 0x1A, 0x0A,
    ];

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

        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
    }

    #[test]
    fn platform_display_all_variants() {
        assert_eq!(format!("{}", Platform::Win32), "Win32");
        assert_eq!(format!("{}", Platform::Ps3), "PS3");
        assert_eq!(format!("{}", Platform::Ps4), "PS4");
        assert_eq!(format!("{}", Platform::Unknown(42)), "Unknown(42)");
    }

    #[test]
    fn apply_to_runs_every_chunk_to_eof() {
        // Build: MAGIC + ADIR("created") + EOF_
        let mut adir_body = Vec::new();
        adir_body.extend_from_slice(&7u32.to_be_bytes()); // name_len
        adir_body.extend_from_slice(b"created"); // name

        let mut patch = Vec::new();
        patch.extend_from_slice(&MAGIC);
        patch.extend_from_slice(&make_chunk(*b"ADIR", &adir_body));
        patch.extend_from_slice(&make_chunk(*b"EOF_", &[]));

        let tmp = tempfile::tempdir().unwrap();
        let mut ctx = ApplyContext::new(tmp.path());
        let reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
        reader.apply_to(&mut ctx).unwrap();

        assert!(tmp.path().join("created").is_dir());
    }

    #[test]
    fn apply_to_propagates_parse_error() {
        // Build: MAGIC + ZZZZ (unknown tag) — apply_to must surface the parse error.
        let mut patch = Vec::new();
        patch.extend_from_slice(&MAGIC);
        patch.extend_from_slice(&make_chunk(*b"ZZZZ", &[]));

        let tmp = tempfile::tempdir().unwrap();
        let mut ctx = ApplyContext::new(tmp.path());
        let reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
        let err = reader.apply_to(&mut ctx).unwrap_err();
        assert!(matches!(err, ZiPatchError::UnknownChunkTag(_)));
    }

    #[test]
    fn apply_to_propagates_apply_error() {
        // DELD on a missing dir without ignore_missing returns a filesystem error
        // — exercises the `apply(ctx)?` error-propagation path.
        let mut deld_body = Vec::new();
        deld_body.extend_from_slice(&14u32.to_be_bytes()); // name_len
        deld_body.extend_from_slice(b"does_not_exist"); // name

        let mut patch = Vec::new();
        patch.extend_from_slice(&MAGIC);
        patch.extend_from_slice(&make_chunk(*b"DELD", &deld_body));
        patch.extend_from_slice(&make_chunk(*b"EOF_", &[]));

        let tmp = tempfile::tempdir().unwrap();
        let mut ctx = ApplyContext::new(tmp.path());
        let reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
        assert!(reader.apply_to(&mut ctx).is_err());
    }
}