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};
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;
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Chunk {
FileHeader(FileHeader),
ApplyOption(ApplyOption),
ApplyFreeSpace(ApplyFreeSpace),
AddDirectory(AddDirectory),
DeleteDirectory(DeleteDirectory),
Sqpk(SqpkCommand),
EndOfFile,
}
pub(crate) struct ParsedChunk {
pub(crate) chunk: Chunk,
pub(crate) tag: [u8; 4],
pub(crate) consumed: u64,
}
pub(crate) fn parse_chunk<R: std::io::Read>(
r: &mut R,
verify_checksums: bool,
) -> Result<ParsedChunk> {
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));
}
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");
let consumed = (size as u64) + 12;
let chunk = match &tag {
b"EOF_" => Chunk::EndOfFile,
b"FHDR" => Chunk::FileHeader(fhdr::parse(body)?),
b"APLY" => Chunk::ApplyOption(aply::parse(body)?),
b"APFS" => Chunk::ApplyFreeSpace(afsp::parse(body)?),
b"ADIR" => Chunk::AddDirectory(adir::parse(body)?),
b"DELD" => Chunk::DeleteDirectory(ddir::parse(body)?),
b"SQPK" => Chunk::Sqpk(sqpk::parse_sqpk(body)?),
_ => return Err(ZiPatchError::UnknownChunkTag(tag)),
};
Ok(ParsedChunk {
chunk,
tag,
consumed,
})
}
#[derive(Debug)]
pub struct ZiPatchReader<R> {
inner: R,
done: bool,
verify_checksums: bool,
eof_seen: bool,
bytes_read: u64,
last_tag: Option<[u8; 4]>,
}
impl<R: std::io::Read> ZiPatchReader<R> {
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,
bytes_read: 12,
last_tag: None,
})
}
#[must_use]
pub fn verify_checksums(mut self) -> Self {
self.verify_checksums = true;
self
}
#[must_use]
pub fn skip_checksum_verification(mut self) -> Self {
self.verify_checksums = false;
self
}
pub fn is_complete(&self) -> bool {
self.eof_seen
}
#[must_use]
pub fn bytes_read(&self) -> u64 {
self.bytes_read
}
#[must_use]
pub fn last_tag(&self) -> Option<[u8; 4]> {
self.last_tag
}
}
impl ZiPatchReader<std::io::BufReader<std::fs::File>> {
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(ParsedChunk {
chunk: Chunk::EndOfFile,
tag,
consumed,
}) => {
self.bytes_read += consumed;
self.last_tag = Some(tag);
self.done = true;
self.eof_seen = true;
None
}
Ok(ParsedChunk {
chunk,
tag,
consumed,
}) => {
self.bytes_read += consumed;
self.last_tag = Some(tag);
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 crate::test_utils::make_chunk;
use std::io::Cursor;
#[test]
fn truncated_at_chunk_boundary_yields_truncated_patch() {
let mut patch = Vec::new();
patch.extend_from_slice(&MAGIC);
let mut reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
match reader
.next()
.expect("iterator must yield an error, not None")
{
Err(ZiPatchError::TruncatedPatch) => {}
other => panic!("expected TruncatedPatch, got {other:?}"),
}
assert!(!reader.is_complete(), "stream is not clean-ended");
}
#[test]
fn non_eof_io_error_on_body_len_read_propagates_as_io() {
struct BrokenReader;
impl std::io::Read for BrokenReader {
fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
Err(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"simulated broken pipe",
))
}
}
let result = parse_chunk(&mut BrokenReader, false);
match result {
Err(ZiPatchError::Io(e)) => {
assert_eq!(
e.kind(),
std::io::ErrorKind::BrokenPipe,
"non-EOF I/O error must propagate unchanged, got kind {:?}",
e.kind()
);
}
Err(other) => panic!("expected ZiPatchError::Io(BrokenPipe), got {other:?}"),
Ok(_) => panic!("expected an error, got Ok"),
}
}
#[test]
fn truncated_after_one_chunk_yields_truncated_patch() {
let mut adir_body = Vec::new();
adir_body.extend_from_slice(&4u32.to_be_bytes());
adir_body.extend_from_slice(b"test");
let chunk = make_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 must be present");
assert!(
first.is_ok(),
"first ADIR chunk should parse cleanly: {first:?}"
);
match reader.next().expect("second call must yield an error") {
Err(ZiPatchError::TruncatedPatch) => {}
other => panic!("expected TruncatedPatch on truncated stream, got {other:?}"),
}
assert!(
!reader.is_complete(),
"is_complete must be false after truncation"
);
}
#[test]
fn checksum_mismatch_returns_checksum_mismatch_error() {
let mut adir_body = Vec::new();
adir_body.extend_from_slice(&4u32.to_be_bytes());
adir_body.extend_from_slice(b"test");
let mut chunk = make_chunk(b"ADIR", &adir_body);
let last = chunk.len() - 1;
chunk[last] ^= 0xFF;
let mut cur = Cursor::new(chunk);
let result = parse_chunk(&mut cur, true);
assert!(
matches!(result, Err(ZiPatchError::ChecksumMismatch { .. })),
"corrupted CRC must yield ChecksumMismatch"
);
}
#[test]
fn unknown_chunk_tag_returns_unknown_chunk_tag_error() {
let chunk = make_chunk(b"ZZZZ", &[]);
let mut cur = Cursor::new(chunk);
match parse_chunk(&mut cur, false) {
Err(ZiPatchError::UnknownChunkTag(tag)) => {
assert_eq!(tag, *b"ZZZZ", "tag bytes must be preserved in error");
}
Err(other) => panic!("expected UnknownChunkTag, got {other:?}"),
Ok(_) => panic!("expected UnknownChunkTag, got Ok"),
}
}
#[test]
fn oversized_chunk_body_len_returns_oversized_chunk_error() {
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 u32::MAX body_len")
};
assert!(
size > MAX_CHUNK_SIZE,
"reported size {size} must exceed MAX_CHUNK_SIZE {MAX_CHUNK_SIZE}"
);
}
#[test]
fn bytes_read_starts_at_12_before_first_chunk() {
let mut patch = Vec::new();
patch.extend_from_slice(&MAGIC);
patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
let reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
assert_eq!(
reader.bytes_read(),
12,
"bytes_read must be 12 (magic only) before iteration starts"
);
}
#[test]
fn last_tag_is_none_before_first_chunk() {
let mut patch = Vec::new();
patch.extend_from_slice(&MAGIC);
patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
let reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
assert_eq!(
reader.last_tag(),
None,
"last_tag must be None before any chunk is read"
);
}
#[test]
fn bytes_read_and_last_tag_track_each_chunk_frame() {
let mut adir_body = Vec::new();
adir_body.extend_from_slice(&1u32.to_be_bytes());
adir_body.extend_from_slice(b"a");
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 mut reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
assert_eq!(reader.bytes_read(), 12, "pre-read: magic only");
assert_eq!(reader.last_tag(), None, "pre-read: no tag yet");
let chunk = reader.next().unwrap().unwrap();
assert!(
matches!(chunk, Chunk::AddDirectory(_)),
"first chunk must be ADIR"
);
assert_eq!(
reader.bytes_read(),
12 + 17,
"after ADIR: magic + ADIR frame"
);
assert_eq!(
reader.last_tag(),
Some(*b"ADIR"),
"last_tag must be ADIR after first next()"
);
assert!(reader.next().is_none(), "EOF_ must terminate iteration");
assert_eq!(
reader.bytes_read(),
12 + 17 + 12,
"after EOF_: magic + ADIR + EOF_ frames"
);
assert_eq!(
reader.last_tag(),
Some(*b"EOF_"),
"last_tag must be EOF_ after stream ends"
);
assert!(reader.is_complete(), "is_complete must be true after EOF_");
}
#[test]
fn bytes_read_is_monotonically_non_decreasing() {
let make_adir = |name: &[u8]| -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(&(name.len() as u32).to_be_bytes());
body.extend_from_slice(name);
make_chunk(b"ADIR", &body)
};
let mut patch = Vec::new();
patch.extend_from_slice(&MAGIC);
patch.extend_from_slice(&make_adir(b"a"));
patch.extend_from_slice(&make_adir(b"bb"));
patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
let mut reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
let mut prev = reader.bytes_read();
while let Some(result) = reader.next() {
result.unwrap();
let current = reader.bytes_read();
assert!(
current >= prev,
"bytes_read must be monotonically non-decreasing: {prev} -> {current}"
);
assert!(
current > prev,
"non-empty ADIR frame must strictly advance bytes_read: \
{prev} -> {current}"
);
prev = current;
}
assert!(
reader.bytes_read() > prev,
"consuming EOF_ must advance bytes_read by its 12-byte frame: \
{prev} -> {}",
reader.bytes_read()
);
}
#[test]
fn from_path_opens_minimal_patch_and_reaches_eof() {
let mut bytes = Vec::new();
bytes.extend_from_slice(&MAGIC);
bytes.extend_from_slice(&make_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 must open valid patch");
assert!(
reader.next().is_none(),
"EOF_ must terminate iteration immediately"
);
assert!(reader.is_complete(), "is_complete must be true after EOF_");
}
#[test]
fn from_path_returns_io_error_when_file_is_missing() {
let tmp = tempfile::tempdir().unwrap();
let file_path = tmp.path().join("nonexistent.patch");
assert!(
matches!(
ZiPatchReader::from_path(&file_path),
Err(ZiPatchError::Io(_))
),
"from_path on a missing file must return ZiPatchError::Io"
);
}
#[test]
fn iterator_is_fused_after_error() {
let mut patch = Vec::new();
patch.extend_from_slice(&MAGIC);
patch.extend_from_slice(&make_chunk(b"ZZZZ", &[]));
let mut reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
let first = reader.next();
assert!(
matches!(first, Some(Err(ZiPatchError::UnknownChunkTag(_)))),
"first call must yield the error: {first:?}"
);
assert!(
reader.next().is_none(),
"fused: must return None after error"
);
assert!(reader.next().is_none(), "fused: still None on third call");
}
#[test]
fn is_complete_false_until_eof_seen() {
let mut adir_body = Vec::new();
adir_body.extend_from_slice(&1u32.to_be_bytes());
adir_body.extend_from_slice(b"x");
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 mut reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
assert!(
!reader.is_complete(),
"not complete before reading anything"
);
reader.next().unwrap().unwrap(); assert!(
!reader.is_complete(),
"not complete after ADIR, before EOF_"
);
assert!(reader.next().is_none(), "EOF_ consumed");
assert!(reader.is_complete(), "complete after EOF_ consumed");
}
}