zipatch-rs 1.0.0

Parser for FFXIV ZiPatch patch files
Documentation
pub(crate) mod adir;
pub(crate) mod afsp;
pub(crate) mod aply;
pub(crate) mod ddir;
pub(crate) mod fhdr;
pub(crate) mod sqpk;
pub(crate) mod util;

pub use adir::AddDirectory;
pub use afsp::ApplyFreeSpace;
pub use aply::{ApplyOption, ApplyOptionKind};
pub use ddir::DeleteDirectory;
pub use fhdr::{FileHeader, FileHeaderV2, FileHeaderV3};
pub use sqpk::{SqpackFile, SqpkCommand};
// Re-export SqpkCommand sub-types so callers can match on them
pub use sqpk::{
    IndexCommand, SqpkAddData, SqpkCompressedBlock, SqpkDeleteData, SqpkExpandData, SqpkFile,
    SqpkFileOperation, SqpkHeader, SqpkHeaderTarget, SqpkIndex, SqpkPatchInfo, SqpkTargetInfo,
    TargetFileKind, TargetHeaderKind,
};

use crate::reader::ReadExt;
use crate::{Result, ZiPatchError};
use tracing::trace;

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

const MAX_CHUNK_SIZE: usize = 512 * 1024 * 1024;

/// One top-level chunk parsed from a `ZiPatch` stream.
///
/// Each variant corresponds to a 4-byte wire tag; see the [`ZiPatchReader`]
/// iterator for the stream contract.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Chunk {
    /// `FHDR` — patch file header (version + per-version metadata).
    FileHeader(FileHeader),
    /// `APLY` — sets an apply-time option flag on the [`crate::ApplyContext`].
    ApplyOption(ApplyOption),
    /// `APFS` — `ApplyFreeSpace` book-keeping; ignored at apply time.
    ApplyFreeSpace(ApplyFreeSpace),
    /// `ADIR` — create a directory under the game install root.
    AddDirectory(AddDirectory),
    /// `DELD` — remove a directory under the game install root.
    DeleteDirectory(DeleteDirectory),
    /// `SQPK` — wrapper around a [`SqpkCommand`] sub-command.
    Sqpk(SqpkCommand),
    /// Not yielded by [`ZiPatchReader`]; signals clean termination and is consumed internally.
    EndOfFile,
}

pub(crate) fn parse_chunk<R: std::io::Read>(r: &mut R, verify_checksums: bool) -> Result<Chunk> {
    let size = match r.read_u32_be() {
        Ok(s) => s as usize,
        Err(ZiPatchError::Io(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
            return Err(ZiPatchError::TruncatedPatch);
        }
        Err(e) => return Err(e),
    };
    if size > MAX_CHUNK_SIZE {
        return Err(ZiPatchError::OversizedChunk(size));
    }
    // buf layout: [tag: 4] [body: size] [crc32: 4]
    let buf = r.read_exact_vec(size + 8)?;

    let tag: [u8; 4] = buf[..4].try_into().unwrap();

    let actual_crc = crc32fast::hash(&buf[..size + 4]);
    let expected_crc = u32::from_be_bytes(buf[size + 4..].try_into().unwrap());
    if verify_checksums && actual_crc != expected_crc {
        return Err(ZiPatchError::ChecksumMismatch {
            tag,
            expected: expected_crc,
            actual: actual_crc,
        });
    }

    let body = &buf[4..size + 4];

    trace!(tag = %String::from_utf8_lossy(&tag), "chunk");

    match &tag {
        b"EOF_" => Ok(Chunk::EndOfFile),
        b"FHDR" => Ok(Chunk::FileHeader(fhdr::parse(body)?)),
        b"APLY" => Ok(Chunk::ApplyOption(aply::parse(body)?)),
        b"APFS" => Ok(Chunk::ApplyFreeSpace(afsp::parse(body)?)),
        b"ADIR" => Ok(Chunk::AddDirectory(adir::parse(body)?)),
        b"DELD" => Ok(Chunk::DeleteDirectory(ddir::parse(body)?)),
        b"SQPK" => Ok(Chunk::Sqpk(sqpk::parse_sqpk(body)?)),
        _ => Err(ZiPatchError::UnknownChunkTag(tag)),
    }
}

/// Iterator over the [`Chunk`]s in a `ZiPatch` stream.
///
/// Construct with [`ZiPatchReader::new`] from any [`std::io::Read`] source or
/// with [`ZiPatchReader::from_path`] for a file on disk. The reader validates
/// the 12-byte magic header up-front. Iteration stops at the first error or at
/// the `EOF_` terminator (which is consumed internally, not yielded).
#[derive(Debug)]
pub struct ZiPatchReader<R> {
    inner: R,
    done: bool,
    verify_checksums: bool,
    eof_seen: bool,
}

impl<R: std::io::Read> ZiPatchReader<R> {
    /// Wrap a reader and validate the leading 12-byte `ZiPatch` magic.
    ///
    /// Returns [`ZiPatchError::InvalidMagic`] if the prefix does not match.
    pub fn new(mut reader: R) -> Result<Self> {
        let magic = reader.read_exact_vec(12)?;
        if magic.as_slice() != MAGIC {
            return Err(ZiPatchError::InvalidMagic);
        }
        Ok(Self {
            inner: reader,
            done: false,
            verify_checksums: true,
            eof_seen: false,
        })
    }

    /// Enable per-chunk CRC32 verification (the default).
    #[must_use]
    pub fn verify_checksums(mut self) -> Self {
        self.verify_checksums = true;
        self
    }

    /// Disable per-chunk CRC32 verification.
    ///
    /// Useful when the source has already been verified out-of-band.
    #[must_use]
    pub fn skip_checksum_verification(mut self) -> Self {
        self.verify_checksums = false;
        self
    }

    /// Returns `true` if iteration ended at the `EOF_` chunk (no truncation).
    pub fn is_complete(&self) -> bool {
        self.eof_seen
    }
}

impl ZiPatchReader<std::io::BufReader<std::fs::File>> {
    /// Convenience constructor: open `path` and wrap it in a buffered reader.
    pub fn from_path(path: impl AsRef<std::path::Path>) -> crate::Result<Self> {
        let file = std::fs::File::open(path)?;
        Self::new(std::io::BufReader::new(file))
    }
}

impl<R: std::io::Read> Iterator for ZiPatchReader<R> {
    type Item = Result<Chunk>;

    fn next(&mut self) -> Option<Self::Item> {
        if self.done {
            return None;
        }
        match parse_chunk(&mut self.inner, self.verify_checksums) {
            Ok(Chunk::EndOfFile) => {
                self.done = true;
                self.eof_seen = true;
                None
            }
            Ok(chunk) => Some(Ok(chunk)),
            Err(e) => {
                self.done = true;
                Some(Err(e))
            }
        }
    }
}

impl<R: std::io::Read> std::iter::FusedIterator for ZiPatchReader<R> {}

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

    // Build a well-formed chunk: 4-byte BE size | 4-byte tag | body | 4-byte BE CRC32.
    fn build_chunk(tag: &[u8; 4], body: &[u8]) -> Vec<u8> {
        let size = body.len() as u32;
        let mut buf = Vec::with_capacity(8 + body.len() + 4);
        buf.extend_from_slice(&size.to_be_bytes());
        buf.extend_from_slice(tag);
        buf.extend_from_slice(body);
        let crc = {
            let mut crc_input = Vec::with_capacity(4 + body.len());
            crc_input.extend_from_slice(tag);
            crc_input.extend_from_slice(body);
            crc32fast::hash(&crc_input)
        };
        buf.extend_from_slice(&crc.to_be_bytes());
        buf
    }

    #[test]
    fn truncated_at_chunk_boundary_maps_to_truncated_patch() {
        // FHDR chunk: 4-byte version + 4-byte tag + 8-byte sizes — body is 24 bytes.
        // We just need any well-formed chunk that isn't EOF_.
        // Use ADIR with a minimal body: 4-byte path_len = 0 path "" — body is whatever
        // adir::parse accepts. Simplest: a custom tag isn't viable since unknown tags error.
        // Use APLY which has a u32 option_id and (typically) a u32 value — but to keep it
        // independent of inner parser quirks, we'll just verify TruncatedPatch is returned
        // when the reader is positioned exactly at a chunk boundary with no more bytes.
        let mut patch = Vec::new();
        patch.extend_from_slice(&MAGIC);
        // No chunks at all — first parse_chunk hits EOF on the 4-byte size read.
        let mut reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
        let first = reader.next().expect("iterator must yield once");
        match first {
            Err(ZiPatchError::TruncatedPatch) => {}
            other => panic!("expected TruncatedPatch, got {other:?}"),
        }
        assert!(!reader.is_complete());
    }

    #[test]
    fn truncated_after_one_chunk_maps_to_truncated_patch() {
        // Magic + one ADIR chunk (well-formed) + nothing else.
        // The first ADIR yields successfully, the next call must yield TruncatedPatch.
        // ADIR body is just a length-prefixed name (u32 BE name_len + name bytes).
        let mut adir_body = Vec::new();
        adir_body.extend_from_slice(&4u32.to_be_bytes()); // name_len
        adir_body.extend_from_slice(b"test"); // name
        let chunk = build_chunk(b"ADIR", &adir_body);

        let mut patch = Vec::new();
        patch.extend_from_slice(&MAGIC);
        patch.extend_from_slice(&chunk);

        let mut reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
        let first = reader.next().expect("first chunk");
        assert!(first.is_ok(), "first chunk should parse: {first:?}");
        let second = reader.next().expect("iterator yields TruncatedPatch");
        match second {
            Err(ZiPatchError::TruncatedPatch) => {}
            other => panic!("expected TruncatedPatch, got {other:?}"),
        }
        assert!(!reader.is_complete());
    }

    #[test]
    fn oversized_chunk_size_rejected() {
        // Craft a stream where the first 4 bytes read by parse_chunk encode u32::MAX.
        let bytes = [0xFFu8, 0xFF, 0xFF, 0xFF];
        let mut cur = Cursor::new(&bytes[..]);
        let Err(ZiPatchError::OversizedChunk(size)) = parse_chunk(&mut cur, false) else {
            panic!("expected OversizedChunk for oversized chunk")
        };
        assert!(
            size > MAX_CHUNK_SIZE,
            "expected size > MAX_CHUNK_SIZE, got {size}"
        );
    }

    #[test]
    fn from_path_opens_and_parses_patch_file() {
        // Write a minimal valid patch (MAGIC + EOF_) to a temp file, then use from_path.
        let mut bytes = Vec::new();
        bytes.extend_from_slice(&MAGIC);
        bytes.extend_from_slice(&build_chunk(b"EOF_", &[]));

        let tmp = tempfile::tempdir().unwrap();
        let file_path = tmp.path().join("test.patch");
        std::fs::write(&file_path, &bytes).unwrap();

        let mut reader = ZiPatchReader::from_path(&file_path).expect("from_path opens patch file");
        assert!(reader.next().is_none(), "EOF_ should terminate iteration");
        assert!(reader.is_complete());
    }

    #[test]
    fn from_path_returns_io_error_on_missing_file() {
        let tmp = tempfile::tempdir().unwrap();
        let file_path = tmp.path().join("nonexistent.patch");
        assert!(matches!(
            ZiPatchReader::from_path(&file_path),
            Err(ZiPatchError::Io(_))
        ));
    }
}