use crate::Result;
use crate::ZiPatchError;
use crate::apply::observer::{ApplyObserver, ChunkEvent};
use crate::apply::path::{dat_path, generic_path, index_path};
use crate::apply::sqpk::{keep_in_remove_all, write_empty_block, write_zeros};
use crate::apply::{ApplyContext, ApplyMode};
use crate::index::plan::{
FilesystemOp, PartSource, PatchSourceKind, Plan, Region, Target, TargetPath,
};
use crate::index::source::PatchSource;
use crate::index::verify::RepairManifest;
use crate::{Platform, apply::path::expansion_folder_id};
use flate2::{FlushDecompress, Status};
use std::fs;
use std::io::{Seek, SeekFrom, Write};
use std::ops::ControlFlow;
use std::path::PathBuf;
use tracing::{debug, debug_span, info, info_span, trace, warn};
pub struct IndexApplier<S: PatchSource> {
source: S,
game_path: PathBuf,
platform_override: Option<Platform>,
mode: ApplyMode,
observer: Option<Box<dyn ApplyObserver>>,
checkpoint_sink: Option<Box<dyn crate::apply::CheckpointSink>>,
}
impl<S: PatchSource> IndexApplier<S> {
pub fn new(source: S, game_path: impl Into<PathBuf>) -> Self {
Self {
source,
game_path: game_path.into(),
platform_override: None,
mode: ApplyMode::Write,
observer: None,
checkpoint_sink: None,
}
}
#[must_use]
pub fn with_mode(mut self, mode: ApplyMode) -> Self {
self.mode = mode;
self
}
#[must_use]
pub fn with_platform(mut self, platform: Platform) -> Self {
self.platform_override = Some(platform);
self
}
#[must_use]
pub fn with_observer(mut self, observer: impl ApplyObserver + 'static) -> Self {
self.observer = Some(Box::new(observer));
self
}
#[must_use]
pub fn with_checkpoint_sink(
mut self,
sink: impl crate::apply::CheckpointSink + 'static,
) -> Self {
crate::apply::validate_checkpoint_policy(sink.policy());
self.checkpoint_sink = Some(Box::new(sink));
self
}
pub fn execute(self, plan: &Plan) -> Result<()> {
self.resume_execute(plan, None).map(|_| ())
}
#[allow(clippy::too_many_lines)]
pub fn resume_execute(
self,
plan: &Plan,
from: Option<&crate::apply::IndexedCheckpoint>,
) -> Result<crate::apply::IndexedCheckpoint> {
if let Some(cp) = from {
if cp.schema_version != crate::apply::IndexedCheckpoint::CURRENT_SCHEMA_VERSION {
return Err(ZiPatchError::SchemaVersionMismatch {
kind: "indexed-checkpoint",
found: cp.schema_version,
expected: crate::apply::IndexedCheckpoint::CURRENT_SCHEMA_VERSION,
});
}
}
let plan_crc32 = plan.crc32();
let region_count: usize = plan.targets.iter().map(|t| t.regions.len()).sum();
let span = info_span!(
"resume_execute",
plan_crc32,
targets = plan.targets.len(),
regions = region_count,
fs_ops = plan.fs_ops.len(),
);
let _enter = span.enter();
let started = std::time::Instant::now();
let effective_from = from.and_then(|cp| {
if cp.plan_crc32 == plan_crc32 {
Some(cp)
} else {
warn!(
expected_plan_crc32 = plan_crc32,
checkpoint_plan_crc32 = cp.plan_crc32,
"resume_execute: stale checkpoint, restarting from scratch"
);
None
}
});
let skip_until_target = effective_from.map_or(0, |cp| cp.next_target_idx);
let skip_until_region = effective_from.map_or(0, |cp| cp.next_region_idx);
let bytes_written_in = effective_from.map_or(0, |cp| cp.bytes_written);
let fs_ops_already_done = effective_from.is_some_and(|cp| cp.fs_ops_done);
let resumed_from = effective_from.map(|cp| (cp.next_target_idx, cp.next_region_idx));
let IndexApplier {
mut source,
game_path,
platform_override,
mode,
observer,
checkpoint_sink,
} = self;
let platform = platform_override.unwrap_or(plan.platform);
let mut ctx = ApplyContext::new(game_path)
.with_platform(platform)
.with_mode(mode);
if let Some(obs) = observer {
ctx.observer = obs;
}
if let Some(sink) = checkpoint_sink {
ctx.checkpoint_sink = sink;
}
if let Some((t, r)) = resumed_from {
info!(
plan_crc32,
skipped_targets = t,
skipped_regions = r,
fs_ops_skipped = fs_ops_already_done,
"resume_execute: resuming indexed apply"
);
}
let mut bytes_written: u64 = bytes_written_in;
let result: Result<()> = (|| {
if fs_ops_already_done {
debug!("resume_execute: fast-forwarded past fs_ops");
} else {
apply_fs_ops(&mut ctx, &plan.fs_ops)?;
}
bytes_written = apply_targets(
&mut ctx,
&mut source,
&plan.targets,
plan_crc32,
true,
skip_until_target,
skip_until_region,
bytes_written_in,
)?;
Ok(())
})();
let final_result = match ctx.flush() {
Err(e) if result.is_ok() => Err(ZiPatchError::Io(e)),
_ => result,
};
match final_result {
Ok(()) => {
info!(
bytes_written,
targets = plan.targets.len(),
resumed_from = ?resumed_from,
elapsed_ms = started.elapsed().as_millis() as u64,
"apply_plan: indexed apply complete"
);
Ok(crate::apply::IndexedCheckpoint::new(
plan_crc32,
true,
plan.targets.len() as u64,
0,
bytes_written,
))
}
Err(e) => Err(e),
}
}
pub fn execute_with_manifest(self, plan: &Plan, manifest: &RepairManifest) -> Result<()> {
let plan_crc32 = plan.crc32();
let total_regions = manifest.total_missing_regions();
let span = info_span!(
"apply_plan",
mode = "manifest",
targets = manifest.missing_regions.len(),
regions = total_regions,
);
let _enter = span.enter();
let started = std::time::Instant::now();
let IndexApplier {
mut source,
game_path,
platform_override,
mode,
observer,
checkpoint_sink,
} = self;
let platform = platform_override.unwrap_or(plan.platform);
let mut ctx = ApplyContext::new(game_path)
.with_platform(platform)
.with_mode(mode);
if let Some(obs) = observer {
ctx.observer = obs;
}
if let Some(sink) = checkpoint_sink {
ctx.checkpoint_sink = sink;
}
let mut bytes_written: u64 = 0;
let result: Result<()> = (|| {
bytes_written = apply_manifest_regions(
&mut ctx,
&mut source,
plan,
manifest,
plan_crc32,
true,
0,
0,
0,
)?;
Ok(())
})();
let final_result = match ctx.flush() {
Err(e) if result.is_ok() => Err(ZiPatchError::Io(e)),
_ => result,
};
if final_result.is_ok() {
info!(
bytes_written,
targets = manifest.missing_regions.len(),
regions = total_regions,
elapsed_ms = started.elapsed().as_millis() as u64,
"apply_plan: manifest replay complete"
);
}
final_result
}
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
fn apply_manifest_regions<S: PatchSource>(
ctx: &mut ApplyContext,
source: &mut S,
plan: &Plan,
manifest: &RepairManifest,
plan_crc32: u32,
fs_ops_done: bool,
skip_until_target: u64,
skip_until_region: u64,
bytes_written_in: u64,
) -> Result<u64> {
let mut scratch: Vec<u8> = Vec::new();
let mut decompress_scratch: Vec<u8> = Vec::new();
let mut bytes_written: u64 = bytes_written_in;
let mut stale_targets: usize = 0;
let mut stale_regions: usize = 0;
for (&target_idx, region_idxs) in &manifest.missing_regions {
if (target_idx as u64) < skip_until_target {
continue;
}
if ctx.observer.should_cancel() {
debug!("apply_plan: cancelled at target boundary (manifest)");
return Err(ZiPatchError::Cancelled);
}
emit_indexed_checkpoint(
ctx,
plan_crc32,
fs_ops_done,
target_idx as u64,
0,
bytes_written,
)?;
let Some(target) = plan.targets.get(target_idx) else {
stale_targets += 1;
warn!(
target_idx,
"apply_plan: manifest target_idx out of range; skipping (stale manifest)"
);
continue;
};
let span = debug_span!(
"apply_target",
target_idx,
path = %target_path_display(&target.path),
regions = region_idxs.len(),
);
let _t_enter = span.enter();
let path = resolve_target_path(ctx, &target.path)?;
if let Some(parent) = path.parent() {
ctx.ensure_dir_all(parent)?;
}
let sorted: std::borrow::Cow<'_, [usize]> = if region_idxs.is_sorted() {
std::borrow::Cow::Borrowed(region_idxs)
} else {
let mut v = region_idxs.clone();
v.sort_unstable();
std::borrow::Cow::Owned(v)
};
let mut last_end: Option<u64> = None;
let skip_in_this_target = if (target_idx as u64) == skip_until_target {
skip_until_region as usize
} else {
0
};
for (i, region_idx) in sorted.iter().enumerate() {
if i < skip_in_this_target {
last_end = None;
continue;
}
if i % 64 == 0 {
if ctx.observer.should_cancel() {
debug!(target_idx, "apply_plan: cancelled mid-target (manifest)");
return Err(ZiPatchError::Cancelled);
}
if i > 0 {
emit_indexed_checkpoint(
ctx,
plan_crc32,
fs_ops_done,
target_idx as u64,
i as u64,
bytes_written,
)?;
}
}
let Some(region) = target.regions.get(*region_idx) else {
stale_regions += 1;
warn!(
target_idx,
region_idx = *region_idx,
"apply_plan: manifest region_idx out of range; skipping (stale manifest)"
);
last_end = None;
continue;
};
last_end = apply_region(
ctx,
source,
&path,
region,
&mut scratch,
&mut decompress_scratch,
last_end,
)?;
bytes_written += u64::from(region.length);
}
debug!(
target_idx,
regions = region_idxs.len(),
bytes_written,
"apply_target: regions applied"
);
let event = ChunkEvent {
index: target_idx,
kind: *b"IRGN",
bytes_read: bytes_written,
};
if let ControlFlow::Break(()) = ctx.observer.on_chunk_applied(event) {
return Err(ZiPatchError::Cancelled);
}
}
if stale_targets > 0 || stale_regions > 0 {
warn!(
stale_targets,
stale_regions, "apply_plan: manifest had stale entries"
);
}
Ok(bytes_written)
}
fn apply_fs_ops(ctx: &mut ApplyContext, ops: &[FilesystemOp]) -> Result<()> {
if ops.is_empty() {
return Ok(());
}
debug!(count = ops.len(), "apply_plan: applying fs_ops");
for op in ops {
match op {
FilesystemOp::EnsureDir(rel) => {
let path = ctx.game_path().join(rel);
trace!(path = %path.display(), "fs_op: ensure dir");
ctx.ensure_dir_all(&path)?;
}
FilesystemOp::MakeDirTree(rel) => {
let path = ctx.game_path().join(rel);
trace!(path = %path.display(), "fs_op: make dir tree");
ctx.ensure_dir_all(&path)?;
}
FilesystemOp::DeleteDir(rel) => {
let path = ctx.game_path().join(rel);
if matches!(ctx.mode(), ApplyMode::DryRun) {
trace!(path = %path.display(), "fs_op: delete dir: dry-run, suppressed");
} else {
fs::remove_dir(&path)?;
ctx.invalidate_dirs_created();
trace!(path = %path.display(), "fs_op: delete dir");
}
}
FilesystemOp::DeleteFile(rel) => {
let path = ctx.game_path().join(rel);
ctx.evict_cached(&path)?;
if matches!(ctx.mode(), ApplyMode::DryRun) {
trace!(path = %path.display(), "fs_op: delete file: dry-run, suppressed");
continue;
}
match fs::remove_file(&path) {
Ok(()) => trace!(path = %path.display(), "fs_op: delete file"),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
trace!(path = %path.display(), "fs_op: delete file missing, ignored");
}
Err(e) => return Err(e.into()),
}
}
FilesystemOp::RemoveAllInExpansion(expansion_id) => {
ctx.clear_file_cache()?;
let folder = expansion_folder_id(*expansion_id);
debug!(folder = %folder, "fs_op: remove all in expansion");
for top in &["sqpack", "movie"] {
let dir = ctx.game_path().join(top).join(&folder);
if !dir.exists() {
continue;
}
for entry in fs::read_dir(&dir)? {
let path = entry?.path();
if path.is_file()
&& !keep_in_remove_all(&path)
&& matches!(ctx.mode(), ApplyMode::Write)
{
fs::remove_file(&path)?;
}
}
}
}
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn apply_targets<S: PatchSource>(
ctx: &mut ApplyContext,
source: &mut S,
targets: &[Target],
plan_crc32: u32,
fs_ops_done: bool,
skip_until_target: u64,
skip_until_region: u64,
bytes_written_in: u64,
) -> Result<u64> {
let mut scratch: Vec<u8> = Vec::new();
let mut decompress_scratch: Vec<u8> = Vec::new();
let mut bytes_written: u64 = bytes_written_in;
for (idx, target) in targets.iter().enumerate() {
if (idx as u64) < skip_until_target {
continue;
}
if ctx.observer.should_cancel() {
debug!("apply_plan: cancelled at target boundary");
return Err(ZiPatchError::Cancelled);
}
emit_indexed_checkpoint(ctx, plan_crc32, fs_ops_done, idx as u64, 0, bytes_written)?;
let span = debug_span!(
"apply_target",
target_idx = idx,
path = %target_path_display(&target.path),
regions = target.regions.len(),
);
let _t_enter = span.enter();
let path = resolve_target_path(ctx, &target.path)?;
if let Some(parent) = path.parent() {
ctx.ensure_dir_all(parent)?;
}
let target_start_bytes = bytes_written;
let mut last_end: Option<u64> = None;
let skip_in_this_target = if (idx as u64) == skip_until_target {
skip_until_region as usize
} else {
0
};
for (i, region) in target.regions.iter().enumerate() {
if i < skip_in_this_target {
last_end = None;
continue;
}
if i % 64 == 0 {
if ctx.observer.should_cancel() {
debug!(target_idx = idx, "apply_plan: cancelled mid-target");
return Err(ZiPatchError::Cancelled);
}
if i > 0 {
emit_indexed_checkpoint(
ctx,
plan_crc32,
fs_ops_done,
idx as u64,
i as u64,
bytes_written,
)?;
}
}
last_end = apply_region(
ctx,
source,
&path,
region,
&mut scratch,
&mut decompress_scratch,
last_end,
)?;
bytes_written += u64::from(region.length);
}
debug!(
target_idx = idx,
regions = target.regions.len(),
bytes_written_target = bytes_written - target_start_bytes,
"apply_target: regions applied"
);
let event = ChunkEvent {
index: idx,
kind: *b"IRGN",
bytes_read: bytes_written,
};
if let ControlFlow::Break(()) = ctx.observer.on_chunk_applied(event) {
return Err(ZiPatchError::Cancelled);
}
}
Ok(bytes_written)
}
fn target_path_display(tp: &TargetPath) -> String {
match tp {
TargetPath::SqpackDat {
main_id,
sub_id,
file_id,
} => format!("sqpack:dat({main_id:x}/{sub_id:x}/{file_id})"),
TargetPath::SqpackIndex {
main_id,
sub_id,
file_id,
} => format!("sqpack:index({main_id:x}/{sub_id:x}/{file_id})"),
TargetPath::Generic(p) => p.clone(),
}
}
fn resolve_target_path(ctx: &mut ApplyContext, tp: &TargetPath) -> Result<PathBuf> {
match *tp {
TargetPath::SqpackDat {
main_id,
sub_id,
file_id,
} => dat_path(ctx, main_id, sub_id, file_id),
TargetPath::SqpackIndex {
main_id,
sub_id,
file_id,
} => index_path(ctx, main_id, sub_id, file_id),
TargetPath::Generic(ref rel) => Ok(generic_path(ctx, rel)),
}
}
fn apply_region<S: PatchSource>(
ctx: &mut ApplyContext,
source: &mut S,
path: &std::path::Path,
region: &Region,
scratch: &mut Vec<u8>,
decompress_scratch: &mut Vec<u8>,
last_end: Option<u64>,
) -> Result<Option<u64>> {
let seek_needed = last_end != Some(region.target_offset);
match ®ion.source {
PartSource::Patch {
patch_idx,
offset,
kind,
decoded_skip,
} => match *kind {
PatchSourceKind::Raw { len } => {
let len_us = len as usize;
ensure_scratch(scratch, len_us);
source.read(*patch_idx, *offset, &mut scratch[..len_us])?;
let writer = ctx.open_cached(path)?;
if seek_needed {
writer.seek(SeekFrom::Start(region.target_offset))?;
}
writer.write_all(&scratch[..len_us])?;
}
PatchSourceKind::Deflated {
compressed_len,
decompressed_len,
} => {
let comp_us = compressed_len as usize;
ensure_scratch(scratch, comp_us);
source.read(*patch_idx, *offset, &mut scratch[..comp_us])?;
ctx.open_cached(path)?;
let decompressor = &mut ctx.decompressor;
let writer = ctx
.file_cache
.get_mut(path)
.expect("open_cached above inserted this path");
if seek_needed {
writer.seek(SeekFrom::Start(region.target_offset))?;
}
decompress_into_sliced(
decompressor,
&scratch[..comp_us],
u64::from(*decoded_skip),
u64::from(region.length),
decompressed_len,
decompress_scratch,
writer,
)?;
}
},
PartSource::Zeros => {
let writer = ctx.open_cached(path)?;
if seek_needed {
writer.seek(SeekFrom::Start(region.target_offset))?;
}
write_zeros(writer, u64::from(region.length))?;
}
PartSource::EmptyBlock { units } => {
let writer = ctx.open_cached(path)?;
write_empty_block(writer, region.target_offset, *units)?;
return Ok(None);
}
PartSource::Unavailable => {
return Err(ZiPatchError::IndexSourceUnavailable {
target_offset: region.target_offset,
length: region.length,
});
}
}
Ok(Some(region.target_offset + u64::from(region.length)))
}
fn emit_indexed_checkpoint(
ctx: &mut ApplyContext,
plan_crc32: u32,
fs_ops_done: bool,
next_target_idx: u64,
next_region_idx: u64,
bytes_written: u64,
) -> Result<()> {
let checkpoint = crate::apply::Checkpoint::Indexed(crate::apply::IndexedCheckpoint {
schema_version: crate::apply::IndexedCheckpoint::CURRENT_SCHEMA_VERSION,
plan_crc32,
fs_ops_done,
next_target_idx,
next_region_idx,
bytes_written,
});
debug!(
next_target_idx,
next_region_idx, bytes_written, "apply_plan: checkpoint recorded"
);
ctx.record_checkpoint(&checkpoint)
}
fn ensure_scratch(scratch: &mut Vec<u8>, needed: usize) {
if scratch.len() < needed {
scratch.resize(needed, 0);
}
}
pub(crate) fn decompress_full(
decompressor: &mut flate2::Decompress,
input: &[u8],
expected_out: u32,
scratch: &mut Vec<u8>,
) -> Result<usize> {
decompressor.reset(false);
let needed = expected_out as usize;
ensure_scratch(scratch, needed);
let mut remaining = input;
let mut produced_total: usize = 0;
loop {
let before_in = decompressor.total_in();
let before_out = decompressor.total_out();
let status = decompressor
.decompress(
remaining,
&mut scratch[produced_total..needed],
FlushDecompress::Finish,
)
.map_err(|e| {
ZiPatchError::Decompress(std::io::Error::new(std::io::ErrorKind::InvalidData, e))
})?;
let consumed = (decompressor.total_in() - before_in) as usize;
let produced = (decompressor.total_out() - before_out) as usize;
produced_total += produced;
remaining = &remaining[consumed..];
match status {
Status::StreamEnd => return Ok(produced_total),
Status::Ok | Status::BufError => {
if consumed == 0 && produced == 0 {
return Err(ZiPatchError::Decompress(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"DEFLATE stream made no forward progress",
)));
}
}
}
}
}
fn decompress_into_sliced(
decompressor: &mut flate2::Decompress,
input: &[u8],
skip: u64,
take: u64,
expected_out: u32,
scratch: &mut Vec<u8>,
w: &mut impl Write,
) -> Result<()> {
let produced = decompress_full(decompressor, input, expected_out, scratch)?;
let needed = expected_out as usize;
let skip_us = usize::try_from(skip).map_err(|_| {
ZiPatchError::Decompress(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("decoded_skip {skip} exceeds addressable range"),
))
})?;
let take_us = usize::try_from(take).map_err(|_| {
ZiPatchError::Decompress(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("region length {take} exceeds addressable range"),
))
})?;
let end = skip_us.checked_add(take_us).ok_or_else(|| {
ZiPatchError::Decompress(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"decoded_skip + region.length overflows usize",
))
})?;
if end > needed {
return Err(ZiPatchError::Decompress(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"deflated region slice [{skip_us}..{end}] exceeds decompressed length {needed}"
),
)));
}
let clamped_end = end.min(produced);
let clamped_start = skip_us.min(clamped_end);
w.write_all(&scratch[clamped_start..clamped_end])?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::index::plan::{PartExpected, Region, Target, TargetPath};
use crate::index::source::MemoryPatchSource;
use flate2::Compression;
use flate2::write::DeflateEncoder;
fn dat_target(regions: Vec<Region>) -> Target {
Target {
path: TargetPath::SqpackDat {
main_id: 0,
sub_id: 0,
file_id: 0,
},
final_size: regions
.last()
.map_or(0, |r| r.target_offset + u64::from(r.length)),
regions,
}
}
fn plan_with(targets: Vec<Target>, fs_ops: Vec<FilesystemOp>) -> Plan {
Plan {
schema_version: Plan::CURRENT_SCHEMA_VERSION,
platform: Platform::Win32,
patches: vec![crate::index::PatchRef {
name: "synthetic".into(),
patch_type: None,
}],
targets,
fs_ops,
}
}
#[test]
fn raw_region_writes_bytes_at_target_offset() {
let payload = b"hello-raw-bytes!";
let mut buf = vec![0u8; 1024];
buf[100..100 + payload.len()].copy_from_slice(payload);
let src = MemoryPatchSource::new(buf);
let regions = vec![Region {
target_offset: 50,
length: payload.len() as u32,
source: PartSource::Patch {
patch_idx: 0,
offset: 100,
kind: PatchSourceKind::Raw {
len: payload.len() as u32,
},
decoded_skip: 0,
},
expected: PartExpected::SizeOnly,
}];
let plan = plan_with(vec![dat_target(regions)], vec![]);
let tmp = tempfile::tempdir().unwrap();
IndexApplier::new(src, tmp.path())
.execute(&plan)
.expect("apply must succeed");
let target = tmp
.path()
.join("sqpack")
.join("ffxiv")
.join("000000.win32.dat0");
let content = std::fs::read(&target).unwrap();
assert_eq!(&content[50..50 + payload.len()], payload);
assert!(content[..50].iter().all(|&b| b == 0));
}
#[test]
fn deflated_region_decompresses_and_writes() {
let raw: &[u8] = b"the quick brown fox jumps over the lazy dog";
let mut enc = DeflateEncoder::new(Vec::new(), Compression::default());
enc.write_all(raw).unwrap();
let compressed = enc.finish().unwrap();
let mut src_buf = vec![0u8; 4096];
src_buf[200..200 + compressed.len()].copy_from_slice(&compressed);
let regions = vec![Region {
target_offset: 0,
length: raw.len() as u32,
source: PartSource::Patch {
patch_idx: 0,
offset: 200,
kind: PatchSourceKind::Deflated {
compressed_len: compressed.len() as u32,
decompressed_len: raw.len() as u32,
},
decoded_skip: 0,
},
expected: PartExpected::SizeOnly,
}];
let plan = plan_with(vec![dat_target(regions)], vec![]);
let tmp = tempfile::tempdir().unwrap();
IndexApplier::new(MemoryPatchSource::new(src_buf), tmp.path())
.execute(&plan)
.expect("apply must succeed");
let target = tmp
.path()
.join("sqpack")
.join("ffxiv")
.join("000000.win32.dat0");
let content = std::fs::read(&target).unwrap();
assert_eq!(&content[..raw.len()], raw);
}
#[test]
fn zeros_region_writes_zeros() {
let regions = vec![Region {
target_offset: 0,
length: 64,
source: PartSource::Zeros,
expected: PartExpected::Zeros,
}];
let plan = plan_with(vec![dat_target(regions)], vec![]);
let tmp = tempfile::tempdir().unwrap();
IndexApplier::new(MemoryPatchSource::new(Vec::new()), tmp.path())
.execute(&plan)
.unwrap();
let target = tmp
.path()
.join("sqpack")
.join("ffxiv")
.join("000000.win32.dat0");
let content = std::fs::read(&target).unwrap();
assert_eq!(content.len(), 64);
assert!(content.iter().all(|&b| b == 0));
}
#[test]
fn empty_block_region_matches_write_empty_block_output() {
let regions = vec![Region {
target_offset: 0,
length: 128,
source: PartSource::EmptyBlock { units: 1 },
expected: PartExpected::EmptyBlock { units: 1 },
}];
let plan = plan_with(vec![dat_target(regions)], vec![]);
let tmp = tempfile::tempdir().unwrap();
IndexApplier::new(MemoryPatchSource::new(Vec::new()), tmp.path())
.execute(&plan)
.unwrap();
let target = tmp
.path()
.join("sqpack")
.join("ffxiv")
.join("000000.win32.dat0");
let content = std::fs::read(&target).unwrap();
assert_eq!(content.len(), 128);
let mut cur = std::io::Cursor::new(Vec::<u8>::new());
write_empty_block(&mut cur, 0, 1).unwrap();
assert_eq!(content, cur.into_inner());
}
#[test]
fn unavailable_region_surfaces_specific_error() {
let regions = vec![Region {
target_offset: 0,
length: 16,
source: PartSource::Unavailable,
expected: PartExpected::SizeOnly,
}];
let plan = plan_with(vec![dat_target(regions)], vec![]);
let tmp = tempfile::tempdir().unwrap();
let err = IndexApplier::new(MemoryPatchSource::new(Vec::new()), tmp.path())
.execute(&plan)
.expect_err("Unavailable must abort");
match err {
ZiPatchError::IndexSourceUnavailable {
target_offset,
length,
} => {
assert_eq!(target_offset, 0);
assert_eq!(length, 16);
}
other => panic!("expected IndexSourceUnavailable, got {other:?}"),
}
}
#[test]
fn decompress_into_sliced_does_not_leak_stale_scratch_bytes() {
let stale: Vec<u8> = (0..128u8).collect();
let short_payload: &[u8] = b"short";
let mut enc = DeflateEncoder::new(Vec::new(), Compression::default());
enc.write_all(short_payload).unwrap();
let compressed = enc.finish().unwrap();
let mut scratch = stale.clone();
let mut decompressor = flate2::Decompress::new(false);
let declared_decompressed: u32 = 64;
let mut out = Vec::new();
decompress_into_sliced(
&mut decompressor,
&compressed,
0,
u64::from(declared_decompressed),
declared_decompressed,
&mut scratch,
&mut out,
)
.expect("short stream must still succeed (lenient on undershoot)");
assert_eq!(
out, short_payload,
"output must only contain bytes the decoder actually produced; \
any extra bytes are stale leftovers from prior scratch contents"
);
}
#[test]
fn deflated_short_stream_after_prior_region_does_not_corrupt_output() {
let first_payload: Vec<u8> = (0..96).map(|i| 0x80u8 | (i as u8)).collect();
let second_payload: &[u8] = b"abcd";
let compress = |raw: &[u8]| {
let mut enc = DeflateEncoder::new(Vec::new(), Compression::default());
enc.write_all(raw).unwrap();
enc.finish().unwrap()
};
let first_compressed = compress(&first_payload);
let second_compressed = compress(second_payload);
let mut src_buf = Vec::new();
let first_offset = src_buf.len() as u64;
src_buf.extend_from_slice(&first_compressed);
let second_offset = src_buf.len() as u64;
src_buf.extend_from_slice(&second_compressed);
let declared_second_len: u32 = first_payload.len() as u32;
let regions = vec![
Region {
target_offset: 0,
length: first_payload.len() as u32,
source: PartSource::Patch {
patch_idx: 0,
offset: first_offset,
kind: PatchSourceKind::Deflated {
compressed_len: first_compressed.len() as u32,
decompressed_len: first_payload.len() as u32,
},
decoded_skip: 0,
},
expected: PartExpected::SizeOnly,
},
Region {
target_offset: u64::from(first_payload.len() as u32),
length: declared_second_len,
source: PartSource::Patch {
patch_idx: 0,
offset: second_offset,
kind: PatchSourceKind::Deflated {
compressed_len: second_compressed.len() as u32,
decompressed_len: declared_second_len,
},
decoded_skip: 0,
},
expected: PartExpected::SizeOnly,
},
];
let plan = plan_with(vec![dat_target(regions)], vec![]);
let tmp = tempfile::tempdir().unwrap();
IndexApplier::new(MemoryPatchSource::new(src_buf), tmp.path())
.execute(&plan)
.expect("apply must succeed");
let target = tmp
.path()
.join("sqpack")
.join("ffxiv")
.join("000000.win32.dat0");
let content = std::fs::read(&target).unwrap();
assert_eq!(
&content[..first_payload.len()],
first_payload.as_slice(),
"region 1 must round-trip unchanged"
);
let r2_start = first_payload.len();
assert_eq!(
&content[r2_start..r2_start + second_payload.len()],
second_payload,
"region 2's actually-produced bytes must round-trip"
);
assert_eq!(
content.len(),
r2_start + second_payload.len(),
"file must contain only what the decoder produced"
);
let leak_pos = r2_start + second_payload.len();
let leaked_tail = &first_payload[second_payload.len()..];
assert!(
content.len() < leak_pos + leaked_tail.len()
|| &content[leak_pos..leak_pos + leaked_tail.len()] != leaked_tail,
"indexed apply must not leak stale scratch bytes from a prior region"
);
}
#[test]
fn decompress_full_truncated_stream_surfaces_no_progress() {
let raw: Vec<u8> = (0..256u32).map(|i| (i & 0xFF) as u8).collect();
let mut enc = DeflateEncoder::new(Vec::new(), Compression::default());
enc.write_all(&raw).unwrap();
let full = enc.finish().unwrap();
assert!(full.len() > 4, "stream must have at least a few bytes");
let truncated = &full[..full.len() / 2];
let mut decompressor = flate2::Decompress::new(false);
let mut scratch: Vec<u8> = Vec::new();
let err = decompress_full(&mut decompressor, truncated, raw.len() as u32, &mut scratch)
.expect_err("truncated stream must surface an error");
match err {
ZiPatchError::Decompress(inner) => {
let msg = inner.to_string();
assert!(
msg.contains("no forward progress") || !msg.is_empty(),
"expected a meaningful Decompress error message, got {msg:?}"
);
}
other => panic!("expected ZiPatchError::Decompress, got {other:?}"),
}
}
#[test]
fn delete_file_fs_op_removes_existing_file() {
let tmp = tempfile::tempdir().unwrap();
let target_rel = "victim.bin";
let target_abs = tmp.path().join(target_rel);
std::fs::write(&target_abs, b"to be removed").unwrap();
assert!(target_abs.is_file());
let plan = plan_with(vec![], vec![FilesystemOp::DeleteFile(target_rel.into())]);
IndexApplier::new(MemoryPatchSource::new(Vec::new()), tmp.path())
.execute(&plan)
.unwrap();
assert!(
!target_abs.exists(),
"DeleteFile fs_op must remove the file"
);
}
}