use std::io::Cursor;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use zipatch_rs::test_utils::{MAGIC, make_chunk};
use zipatch_rs::{ApplyConfig, ApplyError, ApplyObserver, CancelToken, ZiPatchReader};
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)
}
fn three_adir_patch() -> Vec<u8> {
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"b"));
patch.extend_from_slice(&make_adir(b"c"));
patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
patch
}
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 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 ctx = ApplyConfig::new(tmp.path()).with_observer(CancelAfter {
calls: 0,
cancel_after: 2,
});
let reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
let err = ctx.apply_patch(reader).unwrap_err();
assert!(
matches!(err, ApplyError::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 ctx = ApplyConfig::new(tmp.path()).with_observer(CancelAfter {
calls: 0,
cancel_after: 2, });
let reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
ctx.apply_patch(reader).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 ctx = ApplyConfig::new(tmp.path()).with_observer(CancelAfter {
calls: 0,
cancel_after: 0, });
let reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
let err = ctx.apply_patch(reader).unwrap_err();
assert!(
matches!(err, ApplyError::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 cancel_token_unset_lets_apply_complete_normally() {
let token = CancelToken::new();
let tmp = tempfile::tempdir().unwrap();
let ctx = ApplyConfig::new(tmp.path()).with_cancel_token(token);
let reader = ZiPatchReader::new(Cursor::new(three_adir_patch())).unwrap();
ctx.apply_patch(reader)
.expect("never-cancelled token must not abort");
for name in ["a", "b", "c"] {
assert!(
tmp.path().join(name).is_dir(),
"{name}/ must exist when the token is never flipped"
);
}
}
#[test]
fn cancel_token_pre_cancelled_aborts_before_any_chunk_applies() {
let token = CancelToken::new();
token.cancel();
let tmp = tempfile::tempdir().unwrap();
let ctx = ApplyConfig::new(tmp.path()).with_cancel_token(token);
let reader = ZiPatchReader::new(Cursor::new(three_adir_patch())).unwrap();
let err = ctx.apply_patch(reader).unwrap_err();
assert!(
matches!(err, ApplyError::Cancelled),
"pre-cancelled token must abort with Cancelled, got {err:?}"
);
assert!(
tmp.path().join("a").is_dir(),
"a/ ran before the post-chunk poll"
);
assert!(
!tmp.path().join("b").exists(),
"b/ must not have run after Cancelled"
);
assert!(
!tmp.path().join("c").exists(),
"c/ must not have run after Cancelled"
);
}
#[test]
fn cancel_token_flipped_mid_sqpk_block_loop_aborts() {
struct FlipAfter {
count: Arc<AtomicUsize>,
token: CancelToken,
after: usize,
}
impl ApplyObserver for FlipAfter {
fn should_cancel(&mut self) -> bool {
let n = self.count.fetch_add(1, Ordering::Relaxed);
if n + 1 == self.after {
self.token.cancel();
}
false
}
}
let chunk = make_sqpk_addfile_chunk("created/tok.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 token = CancelToken::new();
let count = Arc::new(AtomicUsize::new(0));
let tmp = tempfile::tempdir().unwrap();
let ctx = ApplyConfig::new(tmp.path())
.with_cancel_token(token.clone())
.with_observer(FlipAfter {
count: count.clone(),
token,
after: 2,
});
let reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
let err = ctx.apply_patch(reader).unwrap_err();
assert!(
matches!(err, ApplyError::Cancelled),
"token-driven mid-block cancel must return Cancelled, got {err:?}"
);
let target = tmp.path().join("created").join("tok.dat");
assert_eq!(
std::fs::metadata(&target).unwrap().len(),
16,
"exactly two of three blocks must have written before token cancel"
);
}
#[test]
fn cancel_token_propagates_between_clones() {
let a = CancelToken::new();
let b = a.clone();
assert!(!a.is_cancelled() && !b.is_cancelled());
b.cancel();
assert!(
a.is_cancelled(),
"cancel on clone must surface on the original"
);
}