zipatch-rs 1.5.0

Parser for FFXIV ZiPatch patch files
Documentation
//! Integration tests for `src/index/builder.rs` — the [`PlanBuilder`] surface,
//! `consume_*` traversal paths, multi-patch chain construction, and the
//! filesystem-op categorization (`MakeDirTree`, `DeleteFile`, etc.).

mod common;

use std::io::Cursor;

use common::{adir_body, sqpk_add_data_body, sqpk_target_info_body, wrap_patch};
use zipatch_rs::ZiPatchReader;
use zipatch_rs::index::{FilesystemOp, build_plan, build_plan_chain};
use zipatch_rs::test_utils::make_chunk;

// ---- File-local helpers (SQPK 'X' PatchInfo + 'F' MakeDirTree / DeleteFile) ----

fn sqpk_patch_info_body() -> Vec<u8> {
    // SqpkCommand::PatchInfo — the parser's SQPK 'X' sub-command.
    // cmd_body layout (from src/chunk/sqpk/index.rs): status(1) version(1) pad(1) install_size(8).
    let mut cmd_body = Vec::new();
    cmd_body.push(0u8); // status
    cmd_body.push(0u8); // version
    cmd_body.push(0u8); // alignment
    cmd_body.extend_from_slice(&0u64.to_be_bytes()); // install_size

    let mut body = Vec::new();
    let inner_size = (5 + cmd_body.len()) as i32;
    body.extend_from_slice(&inner_size.to_be_bytes());
    body.push(b'X');
    body.extend_from_slice(&cmd_body);
    body
}

fn sqpk_makedir_body(path: &str) -> Vec<u8> {
    let mut path_bytes = path.as_bytes().to_vec();
    path_bytes.push(0);

    let mut cmd = Vec::new();
    cmd.push(b'M'); // MakeDirTree
    cmd.extend_from_slice(&[0u8; 2]);
    cmd.extend_from_slice(&0u64.to_be_bytes()); // file_offset
    cmd.extend_from_slice(&0u64.to_be_bytes()); // file_size
    cmd.extend_from_slice(&(path_bytes.len() as u32).to_be_bytes());
    cmd.extend_from_slice(&0u16.to_be_bytes()); // expansion_id
    cmd.extend_from_slice(&[0u8; 2]);
    cmd.extend_from_slice(&path_bytes);

    let mut body = Vec::new();
    let inner_size = (5 + cmd.len()) as i32;
    body.extend_from_slice(&inner_size.to_be_bytes());
    body.push(b'F');
    body.extend_from_slice(&cmd);
    body
}

fn sqpk_deletefile_body(path: &str) -> Vec<u8> {
    let mut path_bytes = path.as_bytes().to_vec();
    path_bytes.push(0);

    let mut cmd = Vec::new();
    cmd.push(b'D'); // DeleteFile
    cmd.extend_from_slice(&[0u8; 2]);
    cmd.extend_from_slice(&0u64.to_be_bytes());
    cmd.extend_from_slice(&0u64.to_be_bytes());
    cmd.extend_from_slice(&(path_bytes.len() as u32).to_be_bytes());
    cmd.extend_from_slice(&0u16.to_be_bytes());
    cmd.extend_from_slice(&[0u8; 2]);
    cmd.extend_from_slice(&path_bytes);

    let mut body = Vec::new();
    let inner_size = (5 + cmd.len()) as i32;
    body.extend_from_slice(&inner_size.to_be_bytes());
    body.push(b'F');
    body.extend_from_slice(&cmd);
    body
}

fn sqpk_removeall_body(expansion_id: u16) -> Vec<u8> {
    let mut cmd = Vec::new();
    cmd.push(b'R');
    cmd.extend_from_slice(&[0u8; 2]);
    cmd.extend_from_slice(&0u64.to_be_bytes());
    cmd.extend_from_slice(&0u64.to_be_bytes());
    cmd.extend_from_slice(&0u32.to_be_bytes());
    cmd.extend_from_slice(&expansion_id.to_be_bytes());
    cmd.extend_from_slice(&[0u8; 2]);

    let mut body = Vec::new();
    let inner_size = (5 + cmd.len()) as i32;
    body.extend_from_slice(&inner_size.to_be_bytes());
    body.push(b'F');
    body.extend_from_slice(&cmd);
    body
}

fn sqpk_addfile_body_single_block(path: &str, file_offset: i64, payload: &[u8]) -> Vec<u8> {
    let mut path_bytes = path.as_bytes().to_vec();
    path_bytes.push(0);

    let mut cmd = Vec::new();
    cmd.push(b'A');
    cmd.extend_from_slice(&[0u8; 2]);
    cmd.extend_from_slice(&(file_offset as u64).to_be_bytes());
    cmd.extend_from_slice(&(payload.len() as u64).to_be_bytes());
    cmd.extend_from_slice(&(path_bytes.len() as u32).to_be_bytes());
    cmd.extend_from_slice(&0u16.to_be_bytes());
    cmd.extend_from_slice(&[0u8; 2]);
    cmd.extend_from_slice(&path_bytes);

    // One uncompressed block.
    let header_size: i32 = 16;
    let compressed_size: i32 = 0x7d00;
    let decompressed_size = payload.len() as i32;
    let data_len = decompressed_size as u32;
    let block_len = ((data_len + 143) & !127) as usize;
    let pad = block_len - 16 - payload.len();

    cmd.extend_from_slice(&header_size.to_le_bytes());
    cmd.extend_from_slice(&0u32.to_le_bytes());
    cmd.extend_from_slice(&compressed_size.to_le_bytes());
    cmd.extend_from_slice(&decompressed_size.to_le_bytes());
    cmd.extend_from_slice(payload);
    cmd.extend_from_slice(&vec![0u8; pad]);

    let mut body = Vec::new();
    let inner_size = (5 + cmd.len()) as i32;
    body.extend_from_slice(&inner_size.to_be_bytes());
    body.push(b'F');
    body.extend_from_slice(&cmd);
    body
}

// ---- consume_sqpk metadata-only branches ----

#[test]
fn build_plan_ignores_patch_info_chunk() {
    // A plan built from a patch that contains only a PatchInfo (SQPK 'X')
    // chunk must produce an empty plan without error.
    let patch = wrap_patch(vec![make_chunk(b"SQPK", &sqpk_patch_info_body())]);

    let reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
    let plan = build_plan(reader, "patchinfo-only").expect("PatchInfo-only patch must build");
    assert!(plan.targets.is_empty());
    assert!(plan.fs_ops.is_empty());
}

// ---- consume_file fs_ops categorization ----

#[test]
fn build_plan_records_makedirtree_op() {
    let patch = wrap_patch(vec![make_chunk(
        b"SQPK",
        &sqpk_makedir_body("sqpack/ex2/0c0000"),
    )]);

    let reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
    let plan = build_plan(reader, "makedirtree").expect("MakeDirTree patch must build");

    assert!(
        plan.fs_ops
            .iter()
            .any(|op| matches!(op, FilesystemOp::MakeDirTree(p) if p == "sqpack/ex2/0c0000")),
        "MakeDirTree op must appear in fs_ops: {plan:?}"
    );
}

// ---- consume_file path-traversal rejection on MakeDirTree / DeleteFile ----
// `reject_unsafe_relative_path` runs at the top of `consume_file` regardless
// of operation kind; the pre-polish test only exercised AddFile. Pin the
// rejection for the other operations so a future refactor that moves the
// check inside a per-operation branch can't silently regress them.

#[test]
fn consume_file_rejects_path_traversal_on_makedirtree() {
    let patch = wrap_patch(vec![make_chunk(b"SQPK", &sqpk_makedir_body("../../etc"))]);
    let reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
    let err = build_plan(reader, "evil-mkdirtree")
        .expect_err("MakeDirTree with `..` components must reject");
    assert!(matches!(err, zipatch_rs::ZiPatchError::UnsafeTargetPath(_)));
}

#[test]
fn consume_file_rejects_path_traversal_on_deletefile() {
    let patch = wrap_patch(vec![make_chunk(
        b"SQPK",
        &sqpk_deletefile_body("../../etc/passwd"),
    )]);
    let reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
    let err = build_plan(reader, "evil-deletefile")
        .expect_err("DeleteFile with `..` components must reject");
    assert!(matches!(err, zipatch_rs::ZiPatchError::UnsafeTargetPath(_)));
}

// ---- target_falls_under: Generic-path expansion match ----
// `target_falls_under`'s `Generic(path)` branch handles RemoveAll against
// non-SqPack targets (e.g. movie files). The pre-polish suite only used
// `SqpackDat` numeric-id targets in RemoveAll tests, so the
// `sqpack_prefix`/`movie_prefix` startswith check on the Generic branch had no
// regression test. This test creates a Generic target via AddFile under
// `movie/ffxiv/`, then issues a chained RemoveAll(0) and asserts the target
// drops out of the plan entirely.

#[test]
fn removeall_drops_generic_movie_target_under_expansion() {
    let p1_chunks = vec![
        make_chunk(b"SQPK", &sqpk_target_info_body(0)),
        make_chunk(b"ADIR", &adir_body("movie")),
        make_chunk(b"ADIR", &adir_body("movie/ffxiv")),
        make_chunk(
            b"SQPK",
            &sqpk_addfile_body_single_block("movie/ffxiv/opening.bk2", 0, &[0xAAu8; 64]),
        ),
    ];
    let p2_chunks = vec![
        make_chunk(b"SQPK", &sqpk_target_info_body(0)),
        make_chunk(b"SQPK", &sqpk_removeall_body(0)),
    ];

    let patch1 = wrap_patch(p1_chunks);
    let patch2 = wrap_patch(p2_chunks);

    let plan = build_plan_chain([
        (
            "p1",
            ZiPatchReader::new(Cursor::new(patch1.as_slice())).unwrap(),
        ),
        (
            "p2",
            ZiPatchReader::new(Cursor::new(patch2.as_slice())).unwrap(),
        ),
    ])
    .expect("chain plan must build");

    // No surviving target should mention movie/ffxiv/opening.bk2.
    for target in &plan.targets {
        if let zipatch_rs::index::TargetPath::Generic(p) = &target.path {
            assert!(
                !p.starts_with("movie/ffxiv/"),
                "Generic target under movie/ffxiv/ must drop on RemoveAll(0), got {p:?}"
            );
        }
    }
}

// ---- PlanBuilder construction ----

#[test]
fn plan_builder_default_produces_same_result_as_new() {
    use zipatch_rs::PlanBuilder;

    let plan_via_new = PlanBuilder::new().finish();
    let plan_via_default = PlanBuilder::default().finish();

    assert_eq!(plan_via_new, plan_via_default, "Default and new must match");
    assert!(plan_via_default.targets.is_empty());
    assert!(plan_via_default.fs_ops.is_empty());
    assert!(plan_via_default.patches.is_empty());
}

#[test]
fn build_plan_chain_equals_sequential_add_patch() {
    // Build the same two-patch chain via build_plan_chain and via sequential
    // PlanBuilder::add_patch calls. The resulting plans must be identical.
    let p1_data = vec![0x55u8; 128];
    let p1_chunks = vec![
        make_chunk(b"ADIR", &adir_body("sqpack")),
        make_chunk(b"ADIR", &adir_body("sqpack/ffxiv")),
        make_chunk(b"SQPK", &sqpk_add_data_body(0, 0, 0, 0, &p1_data, 0)),
    ];
    let patch1 = wrap_patch(p1_chunks);

    let p2_data = vec![0x77u8; 128];
    let p2_chunks = vec![make_chunk(
        b"SQPK",
        &sqpk_add_data_body(0, 0, 0, 1, &p2_data, 0),
    )];
    let patch2 = wrap_patch(p2_chunks);

    let plan_chain = build_plan_chain([
        (
            "p1",
            ZiPatchReader::new(Cursor::new(patch1.as_slice())).unwrap(),
        ),
        (
            "p2",
            ZiPatchReader::new(Cursor::new(patch2.as_slice())).unwrap(),
        ),
    ])
    .unwrap();

    let mut builder = zipatch_rs::PlanBuilder::new();
    builder
        .add_patch(
            "p1",
            ZiPatchReader::new(Cursor::new(patch1.as_slice())).unwrap(),
        )
        .unwrap();
    builder
        .add_patch(
            "p2",
            ZiPatchReader::new(Cursor::new(patch2.as_slice())).unwrap(),
        )
        .unwrap();
    let plan_sequential = builder.finish();

    assert_eq!(
        plan_chain, plan_sequential,
        "build_plan_chain must produce the same Plan as sequential add_patch calls"
    );
}