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 mut tag = [0u8; 4];
r.read_exact(&mut tag)?;
let mut prefix = [0u8; 5];
let prefix_len = size.min(5);
if prefix_len > 0 {
r.read_exact(&mut prefix[..prefix_len])?;
}
if &tag == b"SQPK" && size >= 5 + SQPK_ADDDATA_HEADER_SIZE && prefix[4] == b'A' {
return parse_sqpk_add_data_fast(r, tag, prefix, size, verify_checksums);
}
let mut body_vec = vec![0u8; size];
body_vec[..prefix_len].copy_from_slice(&prefix[..prefix_len]);
if size > prefix_len {
r.read_exact(&mut body_vec[prefix_len..])?;
}
let mut crc_buf = [0u8; 4];
r.read_exact(&mut crc_buf)?;
let expected_crc = u32::from_be_bytes(crc_buf);
if verify_checksums {
let mut hasher = crc32fast::Hasher::new();
hasher.update(&tag);
hasher.update(&body_vec);
let actual_crc = hasher.finalize();
if actual_crc != expected_crc {
return Err(ZiPatchError::ChecksumMismatch {
tag,
expected: expected_crc,
actual: actual_crc,
});
}
}
trace!(tag = %String::from_utf8_lossy(&tag), "chunk");
let consumed = (size as u64) + 12;
let body = &body_vec[..];
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,
})
}
const SQPK_ADDDATA_HEADER_SIZE: usize = 23;
fn parse_sqpk_add_data_fast<R: std::io::Read>(
r: &mut R,
tag: [u8; 4],
prefix: [u8; 5],
size: usize,
verify_checksums: bool,
) -> Result<ParsedChunk> {
let inner_size = i32::from_be_bytes([prefix[0], prefix[1], prefix[2], prefix[3]]) as usize;
if inner_size != size {
return Err(ZiPatchError::InvalidField {
context: "SQPK inner size mismatch",
});
}
let mut header = [0u8; SQPK_ADDDATA_HEADER_SIZE];
r.read_exact(&mut header)?;
let main_id = u16::from_be_bytes([header[3], header[4]]);
let sub_id = u16::from_be_bytes([header[5], header[6]]);
let file_id = u32::from_be_bytes([header[7], header[8], header[9], header[10]]);
let block_offset_raw = u32::from_be_bytes([header[11], header[12], header[13], header[14]]);
let data_bytes_raw = u32::from_be_bytes([header[15], header[16], header[17], header[18]]);
let block_delete_raw = u32::from_be_bytes([header[19], header[20], header[21], header[22]]);
let block_offset = (block_offset_raw as u64) << 7;
let data_bytes = (data_bytes_raw as u64) << 7;
let block_delete_number = (block_delete_raw as u64) << 7;
let expected_data = size - 5 - SQPK_ADDDATA_HEADER_SIZE;
if data_bytes as usize != expected_data {
return Err(ZiPatchError::InvalidField {
context: "SqpkAddData data_bytes does not match SQPK body length",
});
}
let mut data = vec![0u8; data_bytes as usize];
r.read_exact(&mut data)?;
let mut crc_buf = [0u8; 4];
r.read_exact(&mut crc_buf)?;
let expected_crc = u32::from_be_bytes(crc_buf);
if verify_checksums {
let mut hasher = crc32fast::Hasher::new();
hasher.update(&tag);
hasher.update(&prefix);
hasher.update(&header);
hasher.update(&data);
let actual_crc = hasher.finalize();
if actual_crc != expected_crc {
return Err(ZiPatchError::ChecksumMismatch {
tag,
expected: expected_crc,
actual: actual_crc,
});
}
}
trace!(tag = %String::from_utf8_lossy(&tag), "chunk");
let chunk = Chunk::Sqpk(sqpk::SqpkCommand::AddData(Box::new(sqpk::SqpkAddData {
target_file: sqpk::SqpackFile {
main_id,
sub_id,
file_id,
},
block_offset,
data_bytes,
block_delete_number,
data,
})));
let consumed = (size as u64) + 12;
Ok(ParsedChunk {
chunk,
tag,
consumed,
})
}
#[derive(Debug)]
pub struct ZiPatchReader<R> {
inner: std::io::BufReader<R>,
done: bool,
verify_checksums: bool,
eof_seen: bool,
bytes_read: u64,
last_tag: Option<[u8; 4]>,
current_body_offset: Option<u64>,
patch_name: Option<String>,
}
impl<R: std::io::Read> ZiPatchReader<R> {
pub fn new(reader: R) -> Result<Self> {
let mut reader = std::io::BufReader::new(reader);
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,
current_body_offset: None,
patch_name: None,
})
}
#[must_use]
pub fn with_patch_name(mut self, name: impl Into<String>) -> Self {
self.patch_name = Some(name.into());
self
}
#[must_use]
pub fn patch_name(&self) -> Option<&str> {
self.patch_name.as_deref()
}
pub(crate) fn inner_mut(&mut self) -> &mut std::io::BufReader<R> {
&mut self.inner
}
#[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
}
#[must_use]
pub fn current_chunk_body_offset(&self) -> Option<u64> {
self.current_body_offset
}
}
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;
}
let body_offset = self.bytes_read + 8;
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.current_body_offset = Some(body_offset);
self.done = true;
self.eof_seen = true;
None
}
Ok(ParsedChunk {
chunk,
tag,
consumed,
}) => {
self.bytes_read += consumed;
self.last_tag = Some(tag);
self.current_body_offset = Some(body_offset);
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");
}
}