zipatch-rs 1.0.2

Parser for FFXIV ZiPatch patch files
Documentation
//! Parser and applier for FFXIV `ZiPatch` (`.patch`) binary files.
//!
//! `zipatch-rs` decodes the binary patch format that Square Enix ships for
//! Final Fantasy XIV and writes the decoded changes to a local game installation.
//! The library never touches the network — it operates entirely on byte streams
//! you supply.
//!
//! # Architecture
//!
//! The crate is split into three layers that share types but are otherwise
//! independent:
//!
//! ## Layer 1 — I/O primitives (`reader`)
//!
//! `reader::ReadExt` is a crate-internal extension trait that adds typed
//! big- and little-endian reads on top of [`std::io::Read`]. It is not part
//! of the public API; the parsing layer uses it exclusively.
//!
//! ## Layer 2 — Parsing ([`chunk`])
//!
//! [`ZiPatchReader`] is an [`Iterator`] over [`Chunk`] values. Construct it
//! from any [`std::io::Read`] source (a [`std::fs::File`], a
//! [`std::io::Cursor<Vec<u8>>`], a network stream, …). It validates the
//! 12-byte file magic on construction, then yields one [`Chunk`] per
//! [`Iterator::next`] call until it sees the `EOF_` terminator or hits an
//! error.
//!
//! Nothing in the parsing layer allocates file handles, stats paths, or
//! performs I/O against the install tree. Parse-only users can consume
//! [`ZiPatchReader`] without ever importing [`apply`].
//!
//! ## Layer 3 — Applying ([`apply`])
//!
//! The [`Apply`] trait bridges parsing and application: every [`Chunk`]
//! variant implements it, and each implementation writes the patch change to
//! disk via an [`ApplyContext`]. [`ApplyContext`] holds the install root, the
//! target [`Platform`], behavioural flags, and an internal file-handle cache
//! that avoids re-opening the same `.dat` file for every chunk.
//!
//! # Quick start
//!
//! The most common usage: open a patch file, build a context, apply every
//! chunk in stream order.
//!
//! ```no_run
//! use std::fs::File;
//! use zipatch_rs::{ApplyContext, ZiPatchReader};
//!
//! let patch_file = File::open("H2017.07.11.0000.0000a.patch").unwrap();
//! let mut ctx = ApplyContext::new("/opt/ffxiv/game");
//!
//! ZiPatchReader::new(patch_file)
//!     .unwrap()
//!     .apply_to(&mut ctx)
//!     .unwrap();
//! ```
//!
//! # Inspecting a patch without applying it
//!
//! Iterate the reader directly to inspect chunks without touching the
//! filesystem:
//!
//! ```no_run
//! use zipatch_rs::{Chunk, ZiPatchReader};
//! use std::fs::File;
//!
//! let reader = ZiPatchReader::new(File::open("patch.patch").unwrap()).unwrap();
//! for chunk in reader {
//!     match chunk.unwrap() {
//!         Chunk::FileHeader(h) => println!("patch version: {:?}", h),
//!         Chunk::AddDirectory(d) => println!("mkdir {}", d.name),
//!         Chunk::Sqpk(cmd) => println!("sqpk: {cmd:?}"),
//!         _ => {}
//!     }
//! }
//! ```
//!
//! # In-memory doctest
//!
//! The following example builds a minimal well-formed patch in memory — magic
//! header, one `ADIR` chunk (which creates a directory), and an `EOF_`
//! terminator — then applies it to a temporary directory. This mirrors the
//! technique used in the crate's own unit tests.
//!
//! ```rust
//! use std::io::Cursor;
//! use zipatch_rs::{ApplyContext, Chunk, ZiPatchReader};
//!
//! // ZiPatch file magic: \x91ZIPATCH\r\n\x1a\n
//! const MAGIC: [u8; 12] = [
//!     0x91, 0x5A, 0x49, 0x50, 0x41, 0x54, 0x43, 0x48,
//!     0x0D, 0x0A, 0x1A, 0x0A,
//! ];
//!
//! /// Wrap `tag + body` into a length-prefixed, CRC32-verified chunk frame.
//! fn make_chunk(tag: [u8; 4], body: &[u8]) -> Vec<u8> {
//!     // CRC is computed over tag ++ body (NOT including the leading body_len).
//!     let mut crc_input = Vec::new();
//!     crc_input.extend_from_slice(&tag);
//!     crc_input.extend_from_slice(body);
//!     let crc = crc32fast::hash(&crc_input);
//!
//!     let mut out = Vec::new();
//!     out.extend_from_slice(&(body.len() as u32).to_be_bytes()); // body_len: u32 BE
//!     out.extend_from_slice(&tag);                               // tag: 4 bytes
//!     out.extend_from_slice(body);                               // body: body_len bytes
//!     out.extend_from_slice(&crc.to_be_bytes());                 // crc32: u32 BE
//!     out
//! }
//!
//! // ADIR body: big-endian u32 name length followed by the name bytes.
//! 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
//!
//! // Assemble the full patch stream.
//! 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_", &[]));
//!
//! // Apply to a temporary directory.
//! let tmp = tempfile::tempdir().unwrap();
//! let mut ctx = ApplyContext::new(tmp.path());
//! ZiPatchReader::new(Cursor::new(patch))
//!     .unwrap()
//!     .apply_to(&mut ctx)
//!     .unwrap();
//!
//! assert!(tmp.path().join("created").is_dir());
//! ```
//!
//! # Error handling
//!
//! Every fallible operation returns [`Result<T>`], which is an alias for
//! `std::result::Result<T, `[`ZiPatchError`]`>`. Parse errors and apply
//! errors share the same type so callers need only one error arm.
//!
//! # Tracing
//!
//! The library emits structured [`tracing`] events at `trace!`, `debug!`, and
//! `warn!` levels. No subscriber is configured here — configure output in your
//! application binary (or in `gaveloc`'s launcher binary).
//!
//! [`tracing`]: https://docs.rs/tracing

#![deny(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`.
    ///
    /// This is the primary high-level entry point for applying a patch. It
    /// drives the [`ZiPatchReader`] iterator to completion, calling
    /// [`Apply::apply`] on each yielded [`Chunk`] in stream order.
    ///
    /// Chunks **must** be applied in order — the `ZiPatch` format is a
    /// sequential log and later chunks may depend on filesystem state produced
    /// by earlier ones (e.g. a directory created by an `ADIR` chunk that a
    /// subsequent `SQPK AddFile` writes into).
    ///
    /// # Errors
    ///
    /// Stops at the first parse or apply error and returns it immediately.
    /// Any filesystem changes already applied by earlier chunks are **not**
    /// rolled back — the format does not provide transactional semantics.
    ///
    /// Possible error variants:
    /// - [`ZiPatchError::Io`] — underlying I/O failure (read or write).
    /// - [`ZiPatchError::InvalidMagic`] — caught at construction, not here.
    /// - [`ZiPatchError::UnknownChunkTag`] — an unrecognised 4-byte tag was
    ///   encountered.
    /// - [`ZiPatchError::ChecksumMismatch`] — a chunk's CRC32 did not match.
    /// - [`ZiPatchError::TruncatedPatch`] — the stream ended before `EOF_`.
    /// - [`ZiPatchError::NegativeFileOffset`] — a `SqpkFile` chunk carried a
    ///   negative offset.
    /// - [`ZiPatchError::Decompress`] — a compressed block could not be
    ///   inflated.
    ///
    /// # Example
    ///
    /// ```no_run
    /// use std::fs::File;
    /// use zipatch_rs::{ApplyContext, ZiPatchReader};
    ///
    /// let mut ctx = ApplyContext::new("/opt/ffxiv/game");
    /// ZiPatchReader::new(File::open("update.patch").unwrap())
    ///     .unwrap()
    ///     .apply_to(&mut ctx)
    ///     .unwrap();
    /// ```
    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.
///
/// FFXIV's `SqPack` archive files live in platform-specific subdirectories
/// under the game install root. For example, a data file for the Windows
/// client lives at `sqpack/ffxiv/000000.win32.dat0`, while the PS4 equivalent
/// is `sqpack/ffxiv/000000.ps4.dat0`. The [`Platform`] value stored in an
/// [`ApplyContext`] selects which suffix is used when resolving chunk targets
/// to filesystem paths.
///
/// # Default
///
/// An [`ApplyContext`] defaults to [`Platform::Win32`]. Override this at
/// construction time with [`ApplyContext::with_platform`].
///
/// # Runtime override via `SqpkTargetInfo`
///
/// In practice, real FFXIV patch files begin with an `SQPK T` chunk
/// ([`chunk::SqpkTargetInfo`]) that declares the target platform. When
/// [`Apply::apply`] is called on that chunk (see `src/apply/sqpk.rs`,
/// `apply_target_info`), it overwrites [`ApplyContext::platform`] with the
/// decoded [`Platform`] value. This means the default is only relevant for
/// synthetic patches or when you know the target in advance and want to assert
/// it before the stream starts.
///
/// # Forward compatibility
///
/// The enum is `#[non_exhaustive]`. The [`Platform::Unknown`] variant
/// preserves unrecognised platform IDs so that newer patch files do not fail
/// parsing when a new platform is introduced. Path resolution falls back to
/// the `win32` layout for unknown variants.
///
/// # Display
///
/// Implements [`std::fmt::Display`]: `"Win32"`, `"PS3"`, `"PS4"`, or
/// `"Unknown(N)"` where `N` is the raw platform ID.
///
/// # Example
///
/// ```rust
/// use zipatch_rs::{ApplyContext, Platform};
///
/// let ctx = ApplyContext::new("/opt/ffxiv/game")
///     .with_platform(Platform::Win32);
///
/// assert_eq!(ctx.platform(), Platform::Win32);
/// assert_eq!(format!("{}", Platform::Unknown(99)), "Unknown(99)");
/// ```
///
/// [`chunk::SqpkTargetInfo`]: crate::chunk::SqpkTargetInfo
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Platform {
    /// Windows / PC client (`win32` path suffix).
    ///
    /// This is the platform used by all current PC releases of FFXIV and is
    /// the default for [`ApplyContext`].
    Win32,
    /// `PlayStation` 3 client (`ps3` path suffix).
    ///
    /// PS3 support was discontinued after FFXIV: A Realm Reborn. Patches
    /// targeting this platform are no longer issued by Square Enix, but the
    /// variant is retained for completeness.
    Ps3,
    /// `PlayStation` 4 client (`ps4` path suffix).
    ///
    /// Active platform alongside Windows. PS4 patches share the same chunk
    /// structure as Windows patches but target different file paths.
    Ps4,
    /// Unrecognised platform ID preserved from a `SqpkTargetInfo` chunk.
    ///
    /// When `apply_target_info` in `src/apply/sqpk.rs` encounters a
    /// `platform_id` it does not recognise, it stores the raw `u16` value
    /// here and emits a `warn!` tracing event. Path resolution then falls
    /// back to the `win32` layout so that the apply operation can proceed
    /// rather than hard-failing on an unknown platform.
    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());
    }
}