Skip to main content

zipatch_rs/chunk/
mod.rs

1pub(crate) mod adir;
2pub(crate) mod afsp;
3pub(crate) mod aply;
4pub(crate) mod ddir;
5pub(crate) mod fhdr;
6pub(crate) mod sqpk;
7pub(crate) mod util;
8
9pub use adir::AddDirectory;
10pub use afsp::ApplyFreeSpace;
11pub use aply::{ApplyOption, ApplyOptionKind};
12pub use ddir::DeleteDirectory;
13pub use fhdr::{FileHeader, FileHeaderV2, FileHeaderV3};
14pub use sqpk::{SqpackFile, SqpkCommand};
15// Re-export SqpkCommand sub-types so callers can match on them
16pub use sqpk::{
17    IndexCommand, SqpkAddData, SqpkCompressedBlock, SqpkDeleteData, SqpkExpandData, SqpkFile,
18    SqpkFileOperation, SqpkHeader, SqpkHeaderTarget, SqpkIndex, SqpkPatchInfo, SqpkTargetInfo,
19    TargetFileKind, TargetHeaderKind,
20};
21
22use crate::reader::ReadExt;
23use crate::{Result, ZiPatchError};
24use tracing::trace;
25
26const MAGIC: [u8; 12] = [
27    0x91, 0x5A, 0x49, 0x50, 0x41, 0x54, 0x43, 0x48, 0x0D, 0x0A, 0x1A, 0x0A,
28];
29
30const MAX_CHUNK_SIZE: usize = 512 * 1024 * 1024;
31
32/// One top-level chunk parsed from a `ZiPatch` stream.
33///
34/// Each variant corresponds to a 4-byte wire tag; see the [`ZiPatchReader`]
35/// iterator for the stream contract.
36#[non_exhaustive]
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum Chunk {
39    /// `FHDR` — patch file header (version + per-version metadata).
40    FileHeader(FileHeader),
41    /// `APLY` — sets an apply-time option flag on the [`crate::ApplyContext`].
42    ApplyOption(ApplyOption),
43    /// `APFS` — `ApplyFreeSpace` book-keeping; ignored at apply time.
44    ApplyFreeSpace(ApplyFreeSpace),
45    /// `ADIR` — create a directory under the game install root.
46    AddDirectory(AddDirectory),
47    /// `DELD` — remove a directory under the game install root.
48    DeleteDirectory(DeleteDirectory),
49    /// `SQPK` — wrapper around a [`SqpkCommand`] sub-command.
50    Sqpk(SqpkCommand),
51    /// Not yielded by [`ZiPatchReader`]; signals clean termination and is consumed internally.
52    EndOfFile,
53}
54
55pub(crate) fn parse_chunk<R: std::io::Read>(r: &mut R, verify_checksums: bool) -> Result<Chunk> {
56    let size = match r.read_u32_be() {
57        Ok(s) => s as usize,
58        Err(ZiPatchError::Io(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
59            return Err(ZiPatchError::TruncatedPatch);
60        }
61        Err(e) => return Err(e),
62    };
63    if size > MAX_CHUNK_SIZE {
64        return Err(ZiPatchError::OversizedChunk(size));
65    }
66    // buf layout: [tag: 4] [body: size] [crc32: 4]
67    let buf = r.read_exact_vec(size + 8)?;
68
69    let tag: [u8; 4] = buf[..4].try_into().unwrap();
70
71    let actual_crc = crc32fast::hash(&buf[..size + 4]);
72    let expected_crc = u32::from_be_bytes(buf[size + 4..].try_into().unwrap());
73    if verify_checksums && actual_crc != expected_crc {
74        return Err(ZiPatchError::ChecksumMismatch {
75            tag,
76            expected: expected_crc,
77            actual: actual_crc,
78        });
79    }
80
81    let body = &buf[4..size + 4];
82
83    trace!(tag = %String::from_utf8_lossy(&tag), "chunk");
84
85    match &tag {
86        b"EOF_" => Ok(Chunk::EndOfFile),
87        b"FHDR" => Ok(Chunk::FileHeader(fhdr::parse(body)?)),
88        b"APLY" => Ok(Chunk::ApplyOption(aply::parse(body)?)),
89        b"APFS" => Ok(Chunk::ApplyFreeSpace(afsp::parse(body)?)),
90        b"ADIR" => Ok(Chunk::AddDirectory(adir::parse(body)?)),
91        b"DELD" => Ok(Chunk::DeleteDirectory(ddir::parse(body)?)),
92        b"SQPK" => Ok(Chunk::Sqpk(sqpk::parse_sqpk(body)?)),
93        _ => Err(ZiPatchError::UnknownChunkTag(tag)),
94    }
95}
96
97/// Iterator over the [`Chunk`]s in a `ZiPatch` stream.
98///
99/// Construct with [`ZiPatchReader::new`] from any [`std::io::Read`] source or
100/// with [`ZiPatchReader::from_path`] for a file on disk. The reader validates
101/// the 12-byte magic header up-front. Iteration stops at the first error or at
102/// the `EOF_` terminator (which is consumed internally, not yielded).
103#[derive(Debug)]
104pub struct ZiPatchReader<R> {
105    inner: R,
106    done: bool,
107    verify_checksums: bool,
108    eof_seen: bool,
109}
110
111impl<R: std::io::Read> ZiPatchReader<R> {
112    /// Wrap a reader and validate the leading 12-byte `ZiPatch` magic.
113    ///
114    /// Returns [`ZiPatchError::InvalidMagic`] if the prefix does not match.
115    pub fn new(mut reader: R) -> Result<Self> {
116        let magic = reader.read_exact_vec(12)?;
117        if magic.as_slice() != MAGIC {
118            return Err(ZiPatchError::InvalidMagic);
119        }
120        Ok(Self {
121            inner: reader,
122            done: false,
123            verify_checksums: true,
124            eof_seen: false,
125        })
126    }
127
128    /// Enable per-chunk CRC32 verification (the default).
129    #[must_use]
130    pub fn verify_checksums(mut self) -> Self {
131        self.verify_checksums = true;
132        self
133    }
134
135    /// Disable per-chunk CRC32 verification.
136    ///
137    /// Useful when the source has already been verified out-of-band.
138    #[must_use]
139    pub fn skip_checksum_verification(mut self) -> Self {
140        self.verify_checksums = false;
141        self
142    }
143
144    /// Returns `true` if iteration ended at the `EOF_` chunk (no truncation).
145    pub fn is_complete(&self) -> bool {
146        self.eof_seen
147    }
148}
149
150impl ZiPatchReader<std::io::BufReader<std::fs::File>> {
151    /// Convenience constructor: open `path` and wrap it in a buffered reader.
152    pub fn from_path(path: impl AsRef<std::path::Path>) -> crate::Result<Self> {
153        let file = std::fs::File::open(path)?;
154        Self::new(std::io::BufReader::new(file))
155    }
156}
157
158impl<R: std::io::Read> Iterator for ZiPatchReader<R> {
159    type Item = Result<Chunk>;
160
161    fn next(&mut self) -> Option<Self::Item> {
162        if self.done {
163            return None;
164        }
165        match parse_chunk(&mut self.inner, self.verify_checksums) {
166            Ok(Chunk::EndOfFile) => {
167                self.done = true;
168                self.eof_seen = true;
169                None
170            }
171            Ok(chunk) => Some(Ok(chunk)),
172            Err(e) => {
173                self.done = true;
174                Some(Err(e))
175            }
176        }
177    }
178}
179
180impl<R: std::io::Read> std::iter::FusedIterator for ZiPatchReader<R> {}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use std::io::Cursor;
186
187    // Build a well-formed chunk: 4-byte BE size | 4-byte tag | body | 4-byte BE CRC32.
188    fn build_chunk(tag: &[u8; 4], body: &[u8]) -> Vec<u8> {
189        let size = body.len() as u32;
190        let mut buf = Vec::with_capacity(8 + body.len() + 4);
191        buf.extend_from_slice(&size.to_be_bytes());
192        buf.extend_from_slice(tag);
193        buf.extend_from_slice(body);
194        let crc = {
195            let mut crc_input = Vec::with_capacity(4 + body.len());
196            crc_input.extend_from_slice(tag);
197            crc_input.extend_from_slice(body);
198            crc32fast::hash(&crc_input)
199        };
200        buf.extend_from_slice(&crc.to_be_bytes());
201        buf
202    }
203
204    #[test]
205    fn truncated_at_chunk_boundary_maps_to_truncated_patch() {
206        // FHDR chunk: 4-byte version + 4-byte tag + 8-byte sizes — body is 24 bytes.
207        // We just need any well-formed chunk that isn't EOF_.
208        // Use ADIR with a minimal body: 4-byte path_len = 0 path "" — body is whatever
209        // adir::parse accepts. Simplest: a custom tag isn't viable since unknown tags error.
210        // Use APLY which has a u32 option_id and (typically) a u32 value — but to keep it
211        // independent of inner parser quirks, we'll just verify TruncatedPatch is returned
212        // when the reader is positioned exactly at a chunk boundary with no more bytes.
213        let mut patch = Vec::new();
214        patch.extend_from_slice(&MAGIC);
215        // No chunks at all — first parse_chunk hits EOF on the 4-byte size read.
216        let mut reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
217        let first = reader.next().expect("iterator must yield once");
218        match first {
219            Err(ZiPatchError::TruncatedPatch) => {}
220            other => panic!("expected TruncatedPatch, got {other:?}"),
221        }
222        assert!(!reader.is_complete());
223    }
224
225    #[test]
226    fn truncated_after_one_chunk_maps_to_truncated_patch() {
227        // Magic + one ADIR chunk (well-formed) + nothing else.
228        // The first ADIR yields successfully, the next call must yield TruncatedPatch.
229        // ADIR body is just a length-prefixed name (u32 BE name_len + name bytes).
230        let mut adir_body = Vec::new();
231        adir_body.extend_from_slice(&4u32.to_be_bytes()); // name_len
232        adir_body.extend_from_slice(b"test"); // name
233        let chunk = build_chunk(b"ADIR", &adir_body);
234
235        let mut patch = Vec::new();
236        patch.extend_from_slice(&MAGIC);
237        patch.extend_from_slice(&chunk);
238
239        let mut reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
240        let first = reader.next().expect("first chunk");
241        assert!(first.is_ok(), "first chunk should parse: {first:?}");
242        let second = reader.next().expect("iterator yields TruncatedPatch");
243        match second {
244            Err(ZiPatchError::TruncatedPatch) => {}
245            other => panic!("expected TruncatedPatch, got {other:?}"),
246        }
247        assert!(!reader.is_complete());
248    }
249
250    #[test]
251    fn oversized_chunk_size_rejected() {
252        // Craft a stream where the first 4 bytes read by parse_chunk encode u32::MAX.
253        let bytes = [0xFFu8, 0xFF, 0xFF, 0xFF];
254        let mut cur = Cursor::new(&bytes[..]);
255        let Err(ZiPatchError::OversizedChunk(size)) = parse_chunk(&mut cur, false) else {
256            panic!("expected OversizedChunk for oversized chunk")
257        };
258        assert!(
259            size > MAX_CHUNK_SIZE,
260            "expected size > MAX_CHUNK_SIZE, got {size}"
261        );
262    }
263
264    #[test]
265    fn from_path_opens_and_parses_patch_file() {
266        // Write a minimal valid patch (MAGIC + EOF_) to a temp file, then use from_path.
267        let mut bytes = Vec::new();
268        bytes.extend_from_slice(&MAGIC);
269        bytes.extend_from_slice(&build_chunk(b"EOF_", &[]));
270
271        let tmp = tempfile::tempdir().unwrap();
272        let file_path = tmp.path().join("test.patch");
273        std::fs::write(&file_path, &bytes).unwrap();
274
275        let mut reader = ZiPatchReader::from_path(&file_path).expect("from_path opens patch file");
276        assert!(reader.next().is_none(), "EOF_ should terminate iteration");
277        assert!(reader.is_complete());
278    }
279
280    #[test]
281    fn from_path_returns_io_error_on_missing_file() {
282        let tmp = tempfile::tempdir().unwrap();
283        let file_path = tmp.path().join("nonexistent.patch");
284        assert!(matches!(
285            ZiPatchReader::from_path(&file_path),
286            Err(ZiPatchError::Io(_))
287        ));
288    }
289}