#![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, Checkpoint, CheckpointPolicy, CheckpointSink, ChunkEvent,
InFlightAddFile, IndexedCheckpoint, NoopCheckpointSink, NoopObserver, SequentialCheckpoint,
};
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>;
fn run_apply_loop<R: std::io::Read>(
reader: &mut chunk::ZiPatchReader<R>,
ctx: &mut apply::ApplyContext,
start_index: u64,
) -> Result<u64> {
use apply::Apply;
use std::ops::ControlFlow;
let mut index = start_index;
while let Some(chunk) = reader.next() {
let chunk = chunk?;
ctx.current_chunk_index = index;
ctx.current_chunk_bytes_read = reader.bytes_read();
chunk.apply(ctx)?;
let bytes_read = reader.bytes_read();
let tag = reader
.last_tag()
.expect("last_tag is set whenever next() yielded Some(Ok(_))");
let next_chunk_index = index + 1;
let checkpoint = apply::Checkpoint::Sequential(apply::SequentialCheckpoint {
schema_version: apply::SequentialCheckpoint::CURRENT_SCHEMA_VERSION,
next_chunk_index,
bytes_read,
patch_name: ctx.patch_name.clone(),
patch_size: ctx.patch_size,
in_flight: None,
});
tracing::debug!(
next_chunk_index,
bytes_read,
in_flight = false,
"apply_to: checkpoint recorded"
);
ctx.record_checkpoint(&checkpoint)?;
let event = apply::ChunkEvent {
index: index as usize,
kind: tag,
bytes_read,
};
if let ControlFlow::Break(()) = ctx.observer.on_chunk_applied(event) {
return Err(ZiPatchError::Cancelled);
}
index += 1;
}
Ok(index - start_index)
}
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();
ctx.patch_name = self.patch_name().map(str::to_owned);
ctx.patch_size = None;
let result = run_apply_loop(&mut self, ctx, 0);
let flush_result = ctx.flush();
let (final_result, chunks_applied) = match (result, flush_result) {
(Ok(n), Ok(())) => (Ok(()), n),
(Ok(_), Err(e)) => (Err(ZiPatchError::Io(e)), 0),
(Err(e), _) => (Err(e), 0),
};
if final_result.is_ok() {
tracing::info!(
chunks = chunks_applied,
bytes_read = self.bytes_read(),
resumed_from_chunk = tracing::field::Empty,
elapsed_ms = started.elapsed().as_millis() as u64,
"apply_to: patch applied"
);
}
final_result
}
}
impl<R: std::io::Read + std::io::Seek> chunk::ZiPatchReader<R> {
pub fn resume_apply_to(
mut self,
ctx: &mut apply::ApplyContext,
from: Option<&apply::SequentialCheckpoint>,
) -> Result<apply::SequentialCheckpoint> {
let span = tracing::info_span!("resume_apply_to");
let _enter = span.enter();
let started = std::time::Instant::now();
if let Some(cp) = from {
if cp.schema_version != apply::SequentialCheckpoint::CURRENT_SCHEMA_VERSION {
return Err(ZiPatchError::SchemaVersionMismatch {
kind: "sequential-checkpoint",
found: cp.schema_version,
expected: apply::SequentialCheckpoint::CURRENT_SCHEMA_VERSION,
});
}
}
let reader_name = self.patch_name().map(str::to_owned);
let total_size = stream_total_size(&mut self)?;
ctx.patch_name.clone_from(&reader_name);
ctx.patch_size = Some(total_size);
let effective_from = from.and_then(|cp| {
let name_match = cp.patch_name == reader_name;
let size_match = match cp.patch_size {
Some(sz) => sz == total_size,
None => true,
};
if name_match && size_match {
Some(cp)
} else {
tracing::warn!(
expected_patch_name = ?reader_name,
expected_patch_size = total_size,
checkpoint_patch_name = ?cp.patch_name,
checkpoint_patch_size = ?cp.patch_size,
"resume_apply_to: stale checkpoint, restarting from chunk 0"
);
None
}
});
let resumed_from_chunk = effective_from.map(|cp| cp.next_chunk_index);
let skipped_bytes_at_start = effective_from.map_or(0, |cp| cp.bytes_read);
let has_in_flight = effective_from
.and_then(|cp| cp.in_flight.as_ref())
.is_some();
if let Some(cp) = effective_from {
tracing::info!(
patch_name = ?reader_name,
skipped_chunks = cp.next_chunk_index,
skipped_bytes = cp.bytes_read,
has_in_flight,
"resume_apply_to: resuming patch"
);
fast_forward(&mut self, cp.next_chunk_index, cp.bytes_read)?;
}
let start_index = effective_from.map_or(0, |cp| cp.next_chunk_index);
let in_flight = effective_from.and_then(|cp| cp.in_flight.clone());
let result: Result<u64> = (|| {
if let Some(in_flight) = in_flight {
resume_in_flight_chunk(&mut self, ctx, start_index, &in_flight)?;
run_apply_loop(&mut self, ctx, start_index + 1).map(|n| n + 1)
} else {
run_apply_loop(&mut self, ctx, start_index)
}
})();
let flush_result = ctx.flush();
let (final_result, chunks_applied) = match (result, flush_result) {
(Ok(n), Ok(())) => (Ok(()), n),
(Ok(_), Err(e)) => (Err(ZiPatchError::Io(e)), 0),
(Err(e), _) => (Err(e), 0),
};
match final_result {
Ok(()) => {
let bytes_read = self.bytes_read();
if let Some(from_chunk) = resumed_from_chunk {
tracing::info!(
chunks = chunks_applied,
bytes_read,
resumed_from_chunk = from_chunk,
skipped_bytes = skipped_bytes_at_start,
elapsed_ms = started.elapsed().as_millis() as u64,
"resume_apply_to: patch applied"
);
} else {
tracing::info!(
chunks = chunks_applied,
bytes_read,
resumed_from_chunk = tracing::field::Empty,
elapsed_ms = started.elapsed().as_millis() as u64,
"resume_apply_to: patch applied"
);
}
Ok(apply::SequentialCheckpoint {
schema_version: apply::SequentialCheckpoint::CURRENT_SCHEMA_VERSION,
next_chunk_index: start_index + chunks_applied,
bytes_read,
patch_name: reader_name,
patch_size: Some(total_size),
in_flight: None,
})
}
Err(e) => Err(e),
}
}
}
fn stream_total_size<R: std::io::Read + std::io::Seek>(
reader: &mut chunk::ZiPatchReader<R>,
) -> Result<u64> {
use std::io::Seek;
let inner = reader.inner_mut();
let current = inner.stream_position()?;
let end = inner.seek(std::io::SeekFrom::End(0))?;
inner.seek(std::io::SeekFrom::Start(current))?;
Ok(end)
}
fn fast_forward<R: std::io::Read>(
reader: &mut chunk::ZiPatchReader<R>,
target_chunks: u64,
expected_bytes_read: u64,
) -> Result<()> {
let mut consumed: u64 = 0;
while consumed < target_chunks {
match reader.next() {
Some(Ok(_)) => consumed += 1,
Some(Err(e)) => return Err(e),
None => {
return Err(ZiPatchError::TruncatedPatch);
}
}
}
if reader.bytes_read() != expected_bytes_read {
tracing::warn!(
actual_bytes_read = reader.bytes_read(),
expected_bytes_read,
target_chunks,
"resume_apply_to: bytes_read drift during fast-forward"
);
}
tracing::debug!(
skipped_chunks = target_chunks,
bytes_read = reader.bytes_read(),
"resume_apply_to: fast-forward complete"
);
Ok(())
}
fn resume_in_flight_chunk<R: std::io::Read>(
reader: &mut chunk::ZiPatchReader<R>,
ctx: &mut apply::ApplyContext,
chunk_index: u64,
in_flight: &apply::InFlightAddFile,
) -> Result<()> {
use apply::Apply;
use std::ops::ControlFlow;
let chunk = match reader.next() {
Some(Ok(c)) => c,
Some(Err(e)) => return Err(e),
None => return Err(ZiPatchError::TruncatedPatch),
};
ctx.current_chunk_index = chunk_index;
ctx.current_chunk_bytes_read = reader.bytes_read();
let (start_block, start_bytes) = match resolve_in_flight_resume(&chunk, ctx, in_flight) {
InFlightResume::Resume {
start_block,
start_bytes,
} => (start_block, start_bytes),
InFlightResume::Restart => (0, 0),
};
match &chunk {
chunk::Chunk::Sqpk(chunk::SqpkCommand::File(file))
if matches!(
file.operation,
crate::chunk::sqpk::SqpkFileOperation::AddFile
) =>
{
apply::sqpk::apply_file_add_from(file, ctx, start_block, start_bytes)?;
}
_ => chunk.apply(ctx)?,
}
let bytes_read = reader.bytes_read();
let tag = reader
.last_tag()
.expect("last_tag is set whenever next() yielded Some(Ok(_))");
let next_chunk_index = chunk_index + 1;
let checkpoint = apply::Checkpoint::Sequential(apply::SequentialCheckpoint {
schema_version: apply::SequentialCheckpoint::CURRENT_SCHEMA_VERSION,
next_chunk_index,
bytes_read,
patch_name: ctx.patch_name.clone(),
patch_size: ctx.patch_size,
in_flight: None,
});
ctx.record_checkpoint(&checkpoint)?;
let event = apply::ChunkEvent {
index: chunk_index as usize,
kind: tag,
bytes_read,
};
if let ControlFlow::Break(()) = ctx.observer.on_chunk_applied(event) {
return Err(ZiPatchError::Cancelled);
}
Ok(())
}
enum InFlightResume {
Resume {
start_block: usize,
start_bytes: u64,
},
Restart,
}
fn resolve_in_flight_resume(
chunk: &chunk::Chunk,
ctx: &apply::ApplyContext,
in_flight: &apply::InFlightAddFile,
) -> InFlightResume {
let chunk::Chunk::Sqpk(chunk::SqpkCommand::File(file)) = chunk else {
tracing::warn!(
"resume_apply_to: in-flight chunk is not an SqpkFile; discarding in-flight state"
);
return InFlightResume::Restart;
};
if !matches!(
file.operation,
crate::chunk::sqpk::SqpkFileOperation::AddFile
) {
tracing::warn!(
"resume_apply_to: in-flight chunk is not an AddFile; discarding in-flight state"
);
return InFlightResume::Restart;
}
let expected_path = apply::path::generic_path(ctx, &file.path);
if expected_path != in_flight.target_path {
tracing::warn!(
chunk_path = %expected_path.display(),
in_flight_path = %in_flight.target_path.display(),
"resume_apply_to: in-flight target path does not match chunk; discarding"
);
return InFlightResume::Restart;
}
let Ok(chunk_offset) = u64::try_from(file.file_offset) else {
tracing::warn!(
file_offset = file.file_offset,
"resume_apply_to: negative file_offset on in-flight chunk; discarding"
);
return InFlightResume::Restart;
};
if chunk_offset != in_flight.file_offset {
tracing::warn!(
chunk_offset,
in_flight_offset = in_flight.file_offset,
"resume_apply_to: in-flight file_offset does not match chunk; discarding"
);
return InFlightResume::Restart;
}
if in_flight.block_idx as usize > file.blocks.len() {
tracing::warn!(
block_idx = in_flight.block_idx,
block_count = file.blocks.len(),
"resume_apply_to: in-flight block_idx out of range; discarding"
);
return InFlightResume::Restart;
}
if chunk_offset == 0 && in_flight.bytes_into_target > 0 {
let on_disk_len = std::fs::metadata(&in_flight.target_path).map_or(0, |m| m.len());
if on_disk_len < in_flight.bytes_into_target {
tracing::warn!(
target = %in_flight.target_path.display(),
on_disk_len,
bytes_into_target = in_flight.bytes_into_target,
"resume_apply_to: target file truncated or missing since checkpoint; restarting AddFile"
);
return InFlightResume::Restart;
}
}
InFlightResume::Resume {
start_block: in_flight.block_idx as usize,
start_bytes: in_flight.bytes_into_target,
}
}
#[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"
);
}
}