zipatch-rs 1.6.0

Parser for FFXIV ZiPatch patch files
Documentation
//! `CancelToken` + SQPK-block mid-loop cancellation — moved from `src/lib.rs`
//! inline tests during the API-overhaul pass. Exercises both the observer-
//! driven cancel path inside SQPK `F` block loops and the standalone
//! `CancelToken` cancellation channel.

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};

/// One uncompressed block carrying 8 bytes of payload, framed as a
/// `SqpkCompressedBlock` would appear inside an `SqpkFile` `AddFile` body.
///
/// Block layout: 16-byte header + 8 data bytes + 104 alignment-pad bytes
/// (rounded up to the 128-byte boundary via `(8 + 143) & !127 = 128`).
fn make_sqpk_file_block(byte: u8) -> Vec<u8> {
    let mut out = Vec::new();
    out.extend_from_slice(&16i32.to_le_bytes()); // header_size
    out.extend_from_slice(&0u32.to_le_bytes()); // pad
    out.extend_from_slice(&0x7d00i32.to_le_bytes()); // compressed_size = uncompressed sentinel
    out.extend_from_slice(&8i32.to_le_bytes()); // decompressed_size
    out.extend_from_slice(&[byte; 8]); // data
    out.extend_from_slice(&[0u8; 104]); // 128-byte alignment padding
    out
}

/// Build an SQPK `F`(`AddFile`) chunk that targets `path` and contains
/// `block_count` uncompressed blocks of 8 bytes each.
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); // NUL terminator
        p
    };

    let mut cmd_body = Vec::new();
    cmd_body.push(b'A'); // operation = AddFile
    cmd_body.extend_from_slice(&[0u8; 2]); // alignment
    cmd_body.extend_from_slice(&0u64.to_be_bytes()); // file_offset = 0
    cmd_body.extend_from_slice(&0u64.to_be_bytes()); // file_size
    cmd_body.extend_from_slice(&(path_bytes.len() as u32).to_be_bytes());
    cmd_body.extend_from_slice(&0u16.to_be_bytes()); // expansion_id
    cmd_body.extend_from_slice(&[0u8; 2]); // padding
    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
}

/// Observer that returns `should_cancel() == true` after `cancel_after` calls.
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
    }
}

// --- SQPK F-block mid-loop cancellation via observer ---

#[test]
fn sqpk_file_cancellation_mid_block_loop_returns_aborted() {
    // Three blocks of 8 bytes each. Observer cancels after 2 should_cancel
    // polls, so at most 2 blocks are written before abort.
    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:?}"
    );

    // File exists (create=true opened it) but the third block must not have
    // been written.  With `cancel_after = 2`, `should_cancel` returns true
    // on the third poll (the one that gates block 3), so exactly the first
    // two 8-byte blocks (= 16 bytes) reach disk.  Pin this exactly so an
    // off-by-one in where `should_cancel` is polled inside the block loop
    // would surface as a failing test rather than passing by inequality.
    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() {
    // A single-block AddFile provides no between-block cancellation
    // opportunity. An observer that cancels only on the second call to
    // should_cancel must NOT abort — the loop executes exactly one block
    // and then the chunk completes normally. The chunk-boundary event fires
    // next, and a Continue there lets apply_to succeed.
    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, // never reaches 2nd call within a single block
    });

    // should succeed: only 1 should_cancel call (call 0 < 2 = cancel_after)
    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() {
    // Observer cancels immediately (cancel_after = 0).  The first
    // should_cancel poll inside the block loop fires before any block data
    // is written, so the file must be empty (truncated by set_len(0) but
    // no block data written).
    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, // cancel on very first check
    });

    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"
    );
}

// --- CancelToken ---

#[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:?}"
    );
    // First chunk applies before the post-chunk poll fires; b/ and c/ must not.
    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() {
    // Three blocks of 8 bytes; the token is wired to a counter observer
    // that flips it after the second block. The third block must not run.
    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();
            }
            // Always return false from the observer itself; the token
            // is the cancellation channel under test.
            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");
    // Two blocks ran (8 bytes each), then the token flipped, then the
    // pre-block poll on iteration 3 caught it.
    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"
    );
}