#![deny(missing_docs)]
pub mod apply;
pub mod chunk;
pub mod error;
pub mod index;
pub(crate) mod reader;
#[cfg(any(test, feature = "test-utils"))]
pub mod test_utils;
#[cfg(fuzzing)]
#[doc(hidden)]
pub mod fuzz_internal {
pub use crate::reader::ReadExt;
}
pub use apply::{Apply, ApplyContext, ApplyObserver, ChunkEvent, NoopObserver};
pub use chunk::{Chunk, ZiPatchReader};
pub use error::ZiPatchError;
pub use index::{IndexApplier, Plan, PlanBuilder, Verifier};
#[cfg(any(test, feature = "test-utils"))]
pub use index::MemoryPatchSource;
pub type Result<T> = std::result::Result<T, ZiPatchError>;
impl<R: std::io::Read> chunk::ZiPatchReader<R> {
pub fn apply_to(mut self, ctx: &mut apply::ApplyContext) -> Result<()> {
let span = tracing::info_span!("apply_patch");
let _enter = span.enter();
let started = std::time::Instant::now();
let mut chunks_applied: usize = 0;
let result: Result<()> = (|| {
use apply::Apply;
use std::ops::ControlFlow;
let mut index: usize = 0;
while let Some(chunk) = self.next() {
let chunk = chunk?;
chunk.apply(ctx)?;
let bytes_read = self.bytes_read();
let tag = self
.last_tag()
.expect("last_tag is set whenever next() yielded Some(Ok(_))");
let event = apply::ChunkEvent {
index,
kind: tag,
bytes_read,
};
if let ControlFlow::Break(()) = ctx.observer.on_chunk_applied(event) {
return Err(ZiPatchError::Cancelled);
}
index += 1;
}
chunks_applied = index;
Ok(())
})();
let flush_result = ctx.flush();
let final_result = match flush_result {
Err(e) if result.is_ok() => Err(ZiPatchError::Io(e)),
_ => result,
};
if final_result.is_ok() {
tracing::info!(
chunks = chunks_applied,
bytes_read = self.bytes_read(),
elapsed_ms = started.elapsed().as_millis() as u64,
"apply_to: patch applied"
);
}
final_result
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Platform {
Win32,
Ps3,
Ps4,
Unknown(u16),
}
impl std::fmt::Display for Platform {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Platform::Win32 => f.write_str("Win32"),
Platform::Ps3 => f.write_str("PS3"),
Platform::Ps4 => f.write_str("PS4"),
Platform::Unknown(id) => write!(f, "Unknown({id})"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::{MAGIC, make_chunk};
use std::io::Cursor;
use std::ops::ControlFlow;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
fn make_sqpk_file_block(byte: u8) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&16i32.to_le_bytes()); out.extend_from_slice(&0u32.to_le_bytes()); out.extend_from_slice(&0x7d00i32.to_le_bytes()); out.extend_from_slice(&8i32.to_le_bytes()); out.extend_from_slice(&[byte; 8]); out.extend_from_slice(&[0u8; 104]); out
}
fn make_sqpk_addfile_chunk(path: &str, block_count: usize) -> Vec<u8> {
let path_bytes: Vec<u8> = {
let mut p = path.as_bytes().to_vec();
p.push(0); p
};
let mut cmd_body = Vec::new();
cmd_body.push(b'A'); cmd_body.extend_from_slice(&[0u8; 2]); cmd_body.extend_from_slice(&0u64.to_be_bytes()); cmd_body.extend_from_slice(&0u64.to_be_bytes()); cmd_body.extend_from_slice(&(path_bytes.len() as u32).to_be_bytes());
cmd_body.extend_from_slice(&0u16.to_be_bytes()); cmd_body.extend_from_slice(&[0u8; 2]); cmd_body.extend_from_slice(&path_bytes);
for i in 0..block_count {
cmd_body.extend_from_slice(&make_sqpk_file_block(0xA0 + (i as u8)));
}
let inner_size = 5 + cmd_body.len();
let mut sqpk_body = Vec::new();
sqpk_body.extend_from_slice(&(inner_size as i32).to_be_bytes());
sqpk_body.push(b'F');
sqpk_body.extend_from_slice(&cmd_body);
make_chunk(b"SQPK", &sqpk_body)
}
#[test]
fn platform_display_all_variants() {
assert_eq!(format!("{}", Platform::Win32), "Win32");
assert_eq!(format!("{}", Platform::Ps3), "PS3");
assert_eq!(format!("{}", Platform::Ps4), "PS4");
assert_eq!(format!("{}", Platform::Unknown(42)), "Unknown(42)");
assert_eq!(format!("{}", Platform::Unknown(0)), "Unknown(0)");
}
#[test]
fn apply_to_applies_adir_chunk_to_filesystem() {
let mut adir_body = Vec::new();
adir_body.extend_from_slice(&7u32.to_be_bytes());
adir_body.extend_from_slice(b"created");
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 tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyContext::new(tmp.path());
ZiPatchReader::new(Cursor::new(patch))
.unwrap()
.apply_to(&mut ctx)
.unwrap();
assert!(
tmp.path().join("created").is_dir(),
"ADIR must have created the directory"
);
}
#[test]
fn apply_to_empty_patch_succeeds_without_side_effects() {
let mut patch = Vec::new();
patch.extend_from_slice(&MAGIC);
patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyContext::new(tmp.path());
ZiPatchReader::new(Cursor::new(patch))
.unwrap()
.apply_to(&mut ctx)
.unwrap();
let entries: Vec<_> = std::fs::read_dir(tmp.path()).unwrap().collect();
assert!(
entries.is_empty(),
"empty patch must not create any files/dirs"
);
}
#[test]
fn apply_to_propagates_parse_error_as_unknown_chunk_tag() {
let mut patch = Vec::new();
patch.extend_from_slice(&MAGIC);
patch.extend_from_slice(&make_chunk(b"ZZZZ", &[]));
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyContext::new(tmp.path());
let err = ZiPatchReader::new(Cursor::new(patch))
.unwrap()
.apply_to(&mut ctx)
.unwrap_err();
assert!(
matches!(err, ZiPatchError::UnknownChunkTag(_)),
"expected UnknownChunkTag, got {err:?}"
);
}
#[test]
fn apply_to_propagates_apply_error_from_delete_directory() {
let mut deld_body = Vec::new();
deld_body.extend_from_slice(&14u32.to_be_bytes());
deld_body.extend_from_slice(b"does_not_exist");
let mut patch = Vec::new();
patch.extend_from_slice(&MAGIC);
patch.extend_from_slice(&make_chunk(b"DELD", &deld_body));
patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyContext::new(tmp.path());
let err = ZiPatchReader::new(Cursor::new(patch))
.unwrap()
.apply_to(&mut ctx)
.unwrap_err();
assert!(
matches!(err, ZiPatchError::Io(_)),
"expected ZiPatchError::Io for missing dir without ignore_missing, got {err:?}"
);
}
struct CancelAfter {
calls: usize,
cancel_after: usize,
}
impl ApplyObserver for CancelAfter {
fn should_cancel(&mut self) -> bool {
let now = self.calls;
self.calls += 1;
now >= self.cancel_after
}
}
#[test]
fn observer_fires_for_each_non_eof_chunk_with_correct_fields() {
let log: Arc<std::sync::Mutex<Vec<ChunkEvent>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let log_clone = log.clone();
let mut a = Vec::new();
a.extend_from_slice(&1u32.to_be_bytes());
a.extend_from_slice(b"a");
let mut b = Vec::new();
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(b"b");
let mut patch = Vec::new();
patch.extend_from_slice(&MAGIC);
patch.extend_from_slice(&make_chunk(b"ADIR", &a));
patch.extend_from_slice(&make_chunk(b"ADIR", &b));
patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyContext::new(tmp.path()).with_observer(move |ev| {
log_clone.lock().unwrap().push(ev);
ControlFlow::Continue(())
});
ZiPatchReader::new(Cursor::new(patch))
.unwrap()
.apply_to(&mut ctx)
.unwrap();
let events = log.lock().unwrap();
assert_eq!(
events.len(),
2,
"two non-EOF chunks must fire exactly two events"
);
assert_eq!(events[0].index, 0, "first event index must be 0");
assert_eq!(events[1].index, 1, "second event index must be 1");
assert_eq!(events[0].kind, *b"ADIR");
assert_eq!(events[1].kind, *b"ADIR");
assert_eq!(
events[0].bytes_read,
12 + 17,
"bytes_read after first ADIR must be magic + one 17-byte frame"
);
assert_eq!(
events[1].bytes_read,
12 + 17 + 17,
"bytes_read after second ADIR must be magic + two 17-byte frames"
);
assert!(
events[0].bytes_read < events[1].bytes_read,
"bytes_read must strictly increase between events"
);
}
#[test]
fn observer_break_on_first_chunk_aborts_immediately_leaving_first_applied() {
let mut a = Vec::new();
a.extend_from_slice(&1u32.to_be_bytes());
a.extend_from_slice(b"a");
let mut b_body = Vec::new();
b_body.extend_from_slice(&1u32.to_be_bytes());
b_body.extend_from_slice(b"b");
let mut c = Vec::new();
c.extend_from_slice(&1u32.to_be_bytes());
c.extend_from_slice(b"c");
let mut patch = Vec::new();
patch.extend_from_slice(&MAGIC);
patch.extend_from_slice(&make_chunk(b"ADIR", &a));
patch.extend_from_slice(&make_chunk(b"ADIR", &b_body));
patch.extend_from_slice(&make_chunk(b"ADIR", &c));
patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
let count = Arc::new(AtomicUsize::new(0));
let count_clone = count.clone();
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyContext::new(tmp.path()).with_observer(move |_| {
count_clone.fetch_add(1, Ordering::Relaxed);
ControlFlow::Break(())
});
let err = ZiPatchReader::new(Cursor::new(patch))
.unwrap()
.apply_to(&mut ctx)
.unwrap_err();
assert!(
matches!(err, ZiPatchError::Cancelled),
"observer Break must produce ZiPatchError::Cancelled, got {err:?}"
);
assert_eq!(
count.load(Ordering::Relaxed),
1,
"exactly one on_chunk_applied call fires before the abort takes effect"
);
assert!(
tmp.path().join("a").is_dir(),
"first ADIR must have been applied before Cancelled was returned"
);
assert!(
!tmp.path().join("b").exists(),
"second ADIR must NOT have been applied after Cancelled"
);
assert!(
!tmp.path().join("c").exists(),
"third ADIR must NOT have been applied after Cancelled"
);
}
#[test]
fn observer_break_on_last_chunk_before_eof_leaves_all_earlier_applied() {
let make_adir_chunk = |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_chunk(b"a"));
patch.extend_from_slice(&make_adir_chunk(b"b"));
patch.extend_from_slice(&make_adir_chunk(b"c"));
patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
let call_count = Arc::new(AtomicUsize::new(0));
let cc = call_count.clone();
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyContext::new(tmp.path()).with_observer(move |_| {
let n = cc.fetch_add(1, Ordering::Relaxed) + 1;
if n >= 3 {
ControlFlow::Break(())
} else {
ControlFlow::Continue(())
}
});
let err = ZiPatchReader::new(Cursor::new(patch))
.unwrap()
.apply_to(&mut ctx)
.unwrap_err();
assert!(
matches!(err, ZiPatchError::Cancelled),
"expected Cancelled, got {err:?}"
);
assert!(tmp.path().join("a").is_dir(), "a/ must exist");
assert!(tmp.path().join("b").is_dir(), "b/ must exist");
assert!(
tmp.path().join("c").is_dir(),
"c/ must exist (apply ran before event fired)"
);
}
#[test]
fn sqpk_file_cancellation_mid_block_loop_returns_aborted() {
let chunk = make_sqpk_addfile_chunk("created/test.dat", 3);
let mut patch = Vec::new();
patch.extend_from_slice(&MAGIC);
patch.extend_from_slice(&chunk);
patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyContext::new(tmp.path()).with_observer(CancelAfter {
calls: 0,
cancel_after: 2,
});
let err = ZiPatchReader::new(Cursor::new(patch))
.unwrap()
.apply_to(&mut ctx)
.unwrap_err();
assert!(
matches!(err, ZiPatchError::Cancelled),
"mid-block cancellation must return Cancelled, got {err:?}"
);
let target = tmp.path().join("created").join("test.dat");
assert!(
target.is_file(),
"target file must exist (was created before cancel)"
);
let len = std::fs::metadata(&target).unwrap().len();
assert_eq!(
len, 16,
"partial write: exactly 2 of 3 blocks (= 16 bytes) must have \
been written before cancellation"
);
}
#[test]
fn sqpk_file_single_block_no_mid_loop_cancel_opportunity() {
let chunk = make_sqpk_addfile_chunk("created/single.dat", 1);
let mut patch = Vec::new();
patch.extend_from_slice(&MAGIC);
patch.extend_from_slice(&chunk);
patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyContext::new(tmp.path()).with_observer(CancelAfter {
calls: 0,
cancel_after: 2, });
ZiPatchReader::new(Cursor::new(patch))
.unwrap()
.apply_to(&mut ctx)
.unwrap();
let target = tmp.path().join("created").join("single.dat");
assert!(
target.is_file(),
"single-block AddFile must complete and create the file"
);
assert_eq!(
std::fs::metadata(&target).unwrap().len(),
8,
"single block of 8 bytes must be fully written"
);
}
#[test]
fn sqpk_file_cancel_on_very_first_block_writes_zero_blocks() {
let chunk = make_sqpk_addfile_chunk("created/zero.dat", 3);
let mut patch = Vec::new();
patch.extend_from_slice(&MAGIC);
patch.extend_from_slice(&chunk);
patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyContext::new(tmp.path()).with_observer(CancelAfter {
calls: 0,
cancel_after: 0, });
let err = ZiPatchReader::new(Cursor::new(patch))
.unwrap()
.apply_to(&mut ctx)
.unwrap_err();
assert!(
matches!(err, ZiPatchError::Cancelled),
"immediate cancel must return Cancelled, got {err:?}"
);
let target = tmp.path().join("created").join("zero.dat");
let len = std::fs::metadata(&target).unwrap().len();
assert_eq!(
len, 0,
"cancel before first block: file must be empty, got {len} bytes"
);
}
#[test]
fn closure_observer_composes_ergonomically_with_with_observer() {
let events = Arc::new(std::sync::Mutex::new(Vec::<(usize, [u8; 4])>::new()));
let ev_clone = events.clone();
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"d1"));
patch.extend_from_slice(&make_adir(b"d2"));
patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyContext::new(tmp.path()).with_observer(move |ev: ChunkEvent| {
ev_clone.lock().unwrap().push((ev.index, ev.kind));
ControlFlow::Continue(())
});
ZiPatchReader::new(Cursor::new(patch))
.unwrap()
.apply_to(&mut ctx)
.unwrap();
let recorded = events.lock().unwrap();
assert_eq!(recorded.len(), 2);
assert_eq!(recorded[0], (0, *b"ADIR"));
assert_eq!(recorded[1], (1, *b"ADIR"));
}
#[test]
fn default_no_observer_apply_succeeds_as_before() {
let mut adir_body = Vec::new();
adir_body.extend_from_slice(&7u32.to_be_bytes());
adir_body.extend_from_slice(b"created");
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 tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyContext::new(tmp.path()); ZiPatchReader::new(Cursor::new(patch))
.unwrap()
.apply_to(&mut ctx)
.unwrap();
assert!(
tmp.path().join("created").is_dir(),
"ADIR must be applied when no observer is set"
);
}
}