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;
fn sqpk_patch_info_body() -> Vec<u8> {
let mut cmd_body = Vec::new();
cmd_body.push(0u8); cmd_body.push(0u8); cmd_body.push(0u8); cmd_body.extend_from_slice(&0u64.to_be_bytes());
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'); 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_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'); 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);
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
}
#[test]
fn build_plan_ignores_patch_info_chunk() {
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());
}
#[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:?}"
);
}
#[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(_)));
}
#[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");
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:?}"
);
}
}
}
#[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() {
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"
);
}