zipatch-rs 1.5.0

Parser for FFXIV ZiPatch patch files
Documentation
//! Schema-version guard tests for both sequential and indexed resume entry points.
//!
//! The existing tests in resume_apply.rs and resume_execute.rs only check a
//! version one higher than CURRENT (wrapping_add(1)). These tests additionally
//! cover a version that is lower (wrapping_sub(1)), ensuring the guard fires
//! in both directions.

mod common;

use std::io::Cursor;

use common::{adir_body, wrap_patch};
use zipatch_rs::index::{
    FilesystemOp, PartExpected, PartSource, PatchRef, PatchSourceKind, Region, Target, TargetPath,
};
use zipatch_rs::test_utils::make_chunk;
use zipatch_rs::{
    ApplyContext, IndexApplier, IndexedCheckpoint, MemoryPatchSource, Plan, Platform,
    SequentialCheckpoint, ZiPatchError, ZiPatchReader,
};

fn single_adir_patch() -> Vec<u8> {
    wrap_patch(vec![make_chunk(b"ADIR", &adir_body("created"))])
}

fn trivial_plan() -> (Vec<u8>, Plan) {
    let payload = b"hello";
    let src = payload.to_vec();
    let target = Target::new(
        TargetPath::Generic("out.bin".into()),
        payload.len() as u64,
        vec![Region::new(
            0,
            payload.len() as u32,
            PartSource::Patch {
                patch_idx: 0,
                offset: 0,
                kind: PatchSourceKind::Raw {
                    len: payload.len() as u32,
                },
                decoded_skip: 0,
            },
            PartExpected::SizeOnly,
        )],
    );
    let plan = Plan::new(
        Platform::Win32,
        vec![PatchRef::new("synthetic", None)],
        vec![target],
        vec![] as Vec<FilesystemOp>,
    );
    (src, plan)
}

// -------- Sequential: version higher than current --------

#[test]
fn sequential_schema_version_higher_than_current_is_rejected() {
    // Catches: guard only checking one direction (e.g. found < expected).
    let patch = single_adir_patch();
    let mut bad = SequentialCheckpoint::new(0, 0, Some("x".into()), None, None);
    bad.schema_version = SequentialCheckpoint::CURRENT_SCHEMA_VERSION.wrapping_add(1);

    let tmp = tempfile::tempdir().unwrap();
    let mut ctx = ApplyContext::new(tmp.path());
    let err = ZiPatchReader::new(Cursor::new(patch))
        .unwrap()
        .with_patch_name("x")
        .resume_apply_to(&mut ctx, Some(&bad))
        .expect_err("higher schema_version must surface as SchemaVersionMismatch");

    let ZiPatchError::SchemaVersionMismatch {
        kind,
        found,
        expected,
    } = err
    else {
        panic!("expected SchemaVersionMismatch, got {err:?}");
    };
    assert_eq!(kind, "sequential-checkpoint");
    assert_eq!(
        found,
        SequentialCheckpoint::CURRENT_SCHEMA_VERSION.wrapping_add(1)
    );
    assert_eq!(expected, SequentialCheckpoint::CURRENT_SCHEMA_VERSION);
}

// -------- Sequential: version lower than current --------

#[test]
fn sequential_schema_version_lower_than_current_is_rejected() {
    // Catches: guard only rejecting versions above current (missing < direction).
    let patch = single_adir_patch();
    let mut bad = SequentialCheckpoint::new(0, 0, Some("x".into()), None, None);
    bad.schema_version = SequentialCheckpoint::CURRENT_SCHEMA_VERSION.wrapping_sub(1);

    let tmp = tempfile::tempdir().unwrap();
    let mut ctx = ApplyContext::new(tmp.path());
    let err = ZiPatchReader::new(Cursor::new(patch))
        .unwrap()
        .with_patch_name("x")
        .resume_apply_to(&mut ctx, Some(&bad))
        .expect_err("lower schema_version must surface as SchemaVersionMismatch");

    let ZiPatchError::SchemaVersionMismatch {
        kind,
        found,
        expected,
    } = err
    else {
        panic!("expected SchemaVersionMismatch, got {err:?}");
    };
    assert_eq!(kind, "sequential-checkpoint");
    assert_eq!(
        found,
        SequentialCheckpoint::CURRENT_SCHEMA_VERSION.wrapping_sub(1)
    );
    assert_eq!(expected, SequentialCheckpoint::CURRENT_SCHEMA_VERSION);
}

// -------- Indexed: version higher than current --------

#[test]
fn indexed_schema_version_higher_than_current_is_rejected() {
    // Catches: guard only checking one direction for indexed checkpoints.
    let (src, plan) = trivial_plan();
    let mut bad = IndexedCheckpoint::new(plan.crc32(), false, 0, 0, 0);
    bad.schema_version = IndexedCheckpoint::CURRENT_SCHEMA_VERSION.wrapping_add(1);

    let tmp = tempfile::tempdir().unwrap();
    let err = IndexApplier::new(MemoryPatchSource::new(src), tmp.path())
        .resume_execute(&plan, Some(&bad))
        .expect_err("higher schema_version must surface as SchemaVersionMismatch");

    let ZiPatchError::SchemaVersionMismatch {
        kind,
        found,
        expected,
    } = err
    else {
        panic!("expected SchemaVersionMismatch, got {err:?}");
    };
    assert_eq!(kind, "indexed-checkpoint");
    assert_eq!(
        found,
        IndexedCheckpoint::CURRENT_SCHEMA_VERSION.wrapping_add(1)
    );
    assert_eq!(expected, IndexedCheckpoint::CURRENT_SCHEMA_VERSION);
}

// -------- Indexed: version lower than current --------

#[test]
fn indexed_schema_version_lower_than_current_is_rejected() {
    // Catches: guard only rejecting versions above current for indexed path.
    let (src, plan) = trivial_plan();
    let mut bad = IndexedCheckpoint::new(plan.crc32(), false, 0, 0, 0);
    bad.schema_version = IndexedCheckpoint::CURRENT_SCHEMA_VERSION.wrapping_sub(1);

    let tmp = tempfile::tempdir().unwrap();
    let err = IndexApplier::new(MemoryPatchSource::new(src), tmp.path())
        .resume_execute(&plan, Some(&bad))
        .expect_err("lower schema_version must surface as SchemaVersionMismatch");

    let ZiPatchError::SchemaVersionMismatch {
        kind,
        found,
        expected,
    } = err
    else {
        panic!("expected SchemaVersionMismatch, got {err:?}");
    };
    assert_eq!(kind, "indexed-checkpoint");
    assert_eq!(
        found,
        IndexedCheckpoint::CURRENT_SCHEMA_VERSION.wrapping_sub(1)
    );
    assert_eq!(expected, IndexedCheckpoint::CURRENT_SCHEMA_VERSION);
}

// -------- Sequential: checkpoint with next_chunk_index past end of stream --------

#[test]
fn resume_with_over_advanced_chunk_index_returns_truncated_patch() {
    // Catches: fast-forward silently stopping at EOF instead of returning TruncatedPatch.
    // The documented contract: if next_chunk_index exceeds the number of real chunks,
    // the fast-forward runs out of stream and returns TruncatedPatch.
    //
    // A single-ADIR patch has 1 real chunk. A checkpoint claiming next_chunk_index = 5
    // will exhaust the stream after consuming 1 chunk and return TruncatedPatch.
    let patch = single_adir_patch();

    // Hand-craft a checkpoint claiming 5 chunks done on a 1-chunk patch.
    let too_far = SequentialCheckpoint::new(5, 999, Some("x".into()), None, None);

    let tmp = tempfile::tempdir().unwrap();
    let mut ctx = ApplyContext::new(tmp.path());
    let err = ZiPatchReader::new(Cursor::new(patch))
        .unwrap()
        .with_patch_name("x")
        .resume_apply_to(&mut ctx, Some(&too_far))
        .expect_err("next_chunk_index past stream end must return TruncatedPatch");

    assert!(
        matches!(err, ZiPatchError::TruncatedPatch),
        "expected TruncatedPatch, got {err:?}"
    );
}

// -------- Sequential: single-chunk patch resumes cleanly --------

#[test]
fn resume_single_chunk_patch_with_none_matches_apply_to() {
    // Catches: off-by-one in the chunk-count bookkeeping for minimal patches.
    let patch = single_adir_patch();

    let tmp_clean = tempfile::tempdir().unwrap();
    ZiPatchReader::new(Cursor::new(patch.clone()))
        .unwrap()
        .with_patch_name("single.patch")
        .apply_to(&mut ApplyContext::new(tmp_clean.path()))
        .unwrap();

    let tmp_resume = tempfile::tempdir().unwrap();
    let final_cp = ZiPatchReader::new(Cursor::new(patch))
        .unwrap()
        .with_patch_name("single.patch")
        .resume_apply_to(&mut ApplyContext::new(tmp_resume.path()), None)
        .unwrap();

    assert!(tmp_resume.path().join("created").is_dir());
    assert_eq!(final_cp.patch_name.as_deref(), Some("single.patch"));
    assert_eq!(
        final_cp.next_chunk_index, 1,
        "single ADIR chunk → index = 1"
    );
}