Skip to main content

zipatch_rs/
lib.rs

1//! Parser and applier for FFXIV `ZiPatch` (`.patch`) binary files.
2//!
3//! This crate is split into two layers that share types but are otherwise
4//! independent:
5//!
6//! - **Parsing** — [`ZiPatchReader`] is an iterator over [`Chunk`]s read from any
7//!   [`std::io::Read`] source. Nothing in the parser touches the filesystem.
8//! - **Applying** — the [`Apply`] trait writes a parsed chunk to disk through an
9//!   [`ApplyContext`], which holds the game install root, target platform, and
10//!   internal file-handle cache.
11//!
12//! Typical usage opens a patch file, constructs a context, and pipes chunks
13//! through [`ZiPatchReader::apply_to`].
14
15#![warn(missing_docs)]
16
17/// Filesystem application of parsed chunks ([`Apply`], [`ApplyContext`]).
18pub mod apply;
19/// Wire-format chunk types and the [`ZiPatchReader`] iterator.
20pub mod chunk;
21/// Error type returned by parsing and applying ([`ZiPatchError`]).
22pub mod error;
23pub(crate) mod reader;
24
25pub use apply::{Apply, ApplyContext};
26pub use chunk::{Chunk, ZiPatchReader};
27pub use error::ZiPatchError;
28
29/// Crate-wide `Result` alias parameterised over [`ZiPatchError`].
30pub type Result<T> = std::result::Result<T, ZiPatchError>;
31
32impl<R: std::io::Read> chunk::ZiPatchReader<R> {
33    /// Iterate every chunk in the patch stream and apply each one to `ctx`.
34    ///
35    /// Stops at the first parse or apply error; otherwise consumes the reader
36    /// to completion (including the `EOF_` terminator).
37    pub fn apply_to(self, ctx: &mut apply::ApplyContext) -> Result<()> {
38        use apply::Apply;
39        for chunk in self {
40            chunk?.apply(ctx)?;
41        }
42        Ok(())
43    }
44}
45
46/// Target platform for `SqPack` file path resolution.
47///
48/// Set by [`ApplyContext::with_platform`] or overridden when a `SqpkTargetInfo`
49/// chunk is applied. `Unknown(id)` preserves an unrecognised platform ID so
50/// newer patch files do not fail parsing; path resolution falls back to the
51/// `win32` layout for unknown variants.
52#[non_exhaustive]
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum Platform {
55    /// Windows / PC client (`win32` path suffix).
56    Win32,
57    /// `PlayStation` 3 client (`ps3` path suffix).
58    Ps3,
59    /// `PlayStation` 4 client (`ps4` path suffix).
60    Ps4,
61    /// Unrecognised platform ID from a `TargetInfo` chunk; preserved so
62    /// newer patch files do not fail parsing on unknown platforms.
63    Unknown(u16),
64}
65
66impl std::fmt::Display for Platform {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        match self {
69            Platform::Win32 => f.write_str("Win32"),
70            Platform::Ps3 => f.write_str("PS3"),
71            Platform::Ps4 => f.write_str("PS4"),
72            Platform::Unknown(id) => write!(f, "Unknown({id})"),
73        }
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use std::io::Cursor;
81
82    const MAGIC: [u8; 12] = [
83        0x91, 0x5A, 0x49, 0x50, 0x41, 0x54, 0x43, 0x48, 0x0D, 0x0A, 0x1A, 0x0A,
84    ];
85
86    fn make_chunk(tag: [u8; 4], body: &[u8]) -> Vec<u8> {
87        let mut crc_input = Vec::with_capacity(4 + body.len());
88        crc_input.extend_from_slice(&tag);
89        crc_input.extend_from_slice(body);
90        let crc = crc32fast::hash(&crc_input);
91
92        let mut out = Vec::with_capacity(4 + 4 + body.len() + 4);
93        out.extend_from_slice(&(body.len() as u32).to_be_bytes());
94        out.extend_from_slice(&tag);
95        out.extend_from_slice(body);
96        out.extend_from_slice(&crc.to_be_bytes());
97        out
98    }
99
100    #[test]
101    fn platform_display_all_variants() {
102        assert_eq!(format!("{}", Platform::Win32), "Win32");
103        assert_eq!(format!("{}", Platform::Ps3), "PS3");
104        assert_eq!(format!("{}", Platform::Ps4), "PS4");
105        assert_eq!(format!("{}", Platform::Unknown(42)), "Unknown(42)");
106    }
107
108    #[test]
109    fn apply_to_runs_every_chunk_to_eof() {
110        // Build: MAGIC + ADIR("created") + EOF_
111        let mut adir_body = Vec::new();
112        adir_body.extend_from_slice(&7u32.to_be_bytes()); // name_len
113        adir_body.extend_from_slice(b"created"); // name
114
115        let mut patch = Vec::new();
116        patch.extend_from_slice(&MAGIC);
117        patch.extend_from_slice(&make_chunk(*b"ADIR", &adir_body));
118        patch.extend_from_slice(&make_chunk(*b"EOF_", &[]));
119
120        let tmp = tempfile::tempdir().unwrap();
121        let mut ctx = ApplyContext::new(tmp.path());
122        let reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
123        reader.apply_to(&mut ctx).unwrap();
124
125        assert!(tmp.path().join("created").is_dir());
126    }
127
128    #[test]
129    fn apply_to_propagates_parse_error() {
130        // Build: MAGIC + ZZZZ (unknown tag) — apply_to must surface the parse error.
131        let mut patch = Vec::new();
132        patch.extend_from_slice(&MAGIC);
133        patch.extend_from_slice(&make_chunk(*b"ZZZZ", &[]));
134
135        let tmp = tempfile::tempdir().unwrap();
136        let mut ctx = ApplyContext::new(tmp.path());
137        let reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
138        let err = reader.apply_to(&mut ctx).unwrap_err();
139        assert!(matches!(err, ZiPatchError::UnknownChunkTag(_)));
140    }
141
142    #[test]
143    fn apply_to_propagates_apply_error() {
144        // DELD on a missing dir without ignore_missing returns a filesystem error
145        // — exercises the `apply(ctx)?` error-propagation path.
146        let mut deld_body = Vec::new();
147        deld_body.extend_from_slice(&14u32.to_be_bytes()); // name_len
148        deld_body.extend_from_slice(b"does_not_exist"); // name
149
150        let mut patch = Vec::new();
151        patch.extend_from_slice(&MAGIC);
152        patch.extend_from_slice(&make_chunk(*b"DELD", &deld_body));
153        patch.extend_from_slice(&make_chunk(*b"EOF_", &[]));
154
155        let tmp = tempfile::tempdir().unwrap();
156        let mut ctx = ApplyContext::new(tmp.path());
157        let reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
158        assert!(reader.apply_to(&mut ctx).is_err());
159    }
160}