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) 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));
}
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)),
}
}
#[derive(Debug)]
pub struct ZiPatchReader<R> {
inner: R,
done: bool,
verify_checksums: bool,
eof_seen: bool,
}
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,
})
}
#[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
}
}
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(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;
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() {
let mut patch = Vec::new();
patch.extend_from_slice(&MAGIC);
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() {
let mut adir_body = Vec::new();
adir_body.extend_from_slice(&4u32.to_be_bytes()); adir_body.extend_from_slice(b"test"); 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() {
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() {
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(_))
));
}
}