zipatch-rs 1.0.0

Parser for FFXIV ZiPatch patch files
Documentation
use crate::Platform;
use crate::apply::path::{dat_path, expansion_folder_id, generic_path, index_path};
use crate::apply::{Apply, ApplyContext};
use crate::chunk::sqpk::SqpkCommand;
use crate::chunk::sqpk::add_data::SqpkAddData;
use crate::chunk::sqpk::delete_data::SqpkDeleteData;
use crate::chunk::sqpk::expand_data::SqpkExpandData;
use crate::chunk::sqpk::file::{SqpkFile, SqpkFileOperation};
use crate::chunk::sqpk::header::{SqpkHeader, SqpkHeaderTarget, TargetHeaderKind};
use crate::chunk::sqpk::target_info::SqpkTargetInfo;
use crate::{Result, ZiPatchError};
use std::fs;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::Path;
use tracing::{debug, trace, warn};

fn write_zeros(w: &mut impl Write, len: u64) -> std::io::Result<()> {
    std::io::copy(&mut std::io::repeat(0).take(len), w)?;
    Ok(())
}

// Zeroes block_number<<7 bytes at offset, then writes the 5-field empty block header.
fn write_empty_block(
    f: &mut (impl Write + Seek),
    offset: u64,
    block_number: u32,
) -> std::io::Result<()> {
    if block_number == 0 {
        return Err(std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            "block_number must be non-zero",
        ));
    }
    f.seek(SeekFrom::Start(offset))?;
    write_zeros(f, (block_number as u64) << 7)?;
    f.seek(SeekFrom::Start(offset))?;
    f.write_all(&128u32.to_le_bytes())?;
    f.write_all(&0u32.to_le_bytes())?;
    f.write_all(&0u32.to_le_bytes())?;
    f.write_all(&block_number.wrapping_sub(1).to_le_bytes())?;
    f.write_all(&0u32.to_le_bytes())?;
    Ok(())
}

fn keep_in_remove_all(path: &Path) -> bool {
    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
        return false;
    };
    #[allow(clippy::case_sensitive_file_extension_comparisons)]
    let is_var = name.ends_with(".var");
    is_var || matches!(name, "00000.bk2" | "00001.bk2" | "00002.bk2" | "00003.bk2")
}

impl Apply for SqpkCommand {
    fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
        match self {
            SqpkCommand::TargetInfo(c) => apply_target_info(c, ctx),
            SqpkCommand::Index(_) | SqpkCommand::PatchInfo(_) => Ok(()),
            SqpkCommand::AddData(c) => apply_add_data(c, ctx),
            SqpkCommand::DeleteData(c) => apply_delete_data(c, ctx),
            SqpkCommand::ExpandData(c) => apply_expand_data(c, ctx),
            SqpkCommand::Header(c) => apply_header(c, ctx),
            SqpkCommand::File(c) => apply_file(c, ctx),
        }
    }
}

#[allow(clippy::unnecessary_wraps)] // sibling dispatch arms all return Result<()>
fn apply_target_info(cmd: &SqpkTargetInfo, ctx: &mut ApplyContext) -> Result<()> {
    ctx.platform = match cmd.platform_id {
        0 => Platform::Win32,
        1 => Platform::Ps3,
        2 => Platform::Ps4,
        id => {
            warn!(
                platform_id = id,
                "unknown platform_id in TargetInfo; stored as Unknown"
            );
            Platform::Unknown(id)
        }
    };
    debug!(platform = ?ctx.platform, "target info");
    Ok(())
}

fn apply_add_data(cmd: &SqpkAddData, ctx: &mut ApplyContext) -> Result<()> {
    let tf = &cmd.target_file;
    let path = dat_path(ctx, tf.main_id, tf.sub_id, tf.file_id);
    trace!(path = %path.display(), offset = cmd.block_offset, delete_zeros = cmd.block_delete_number, "add data");
    let file = ctx.open_cached(path)?;
    file.seek(SeekFrom::Start(cmd.block_offset))?;
    file.write_all(&cmd.data)?;
    write_zeros(file, cmd.block_delete_number)?;
    Ok(())
}

fn apply_delete_data(cmd: &SqpkDeleteData, ctx: &mut ApplyContext) -> Result<()> {
    let tf = &cmd.target_file;
    let path = dat_path(ctx, tf.main_id, tf.sub_id, tf.file_id);
    trace!(path = %path.display(), offset = cmd.block_offset, block_count = cmd.block_count, "delete data");
    let file = ctx.open_cached(path)?;
    write_empty_block(file, cmd.block_offset, cmd.block_count)?;
    Ok(())
}

fn apply_expand_data(cmd: &SqpkExpandData, ctx: &mut ApplyContext) -> Result<()> {
    let tf = &cmd.target_file;
    let path = dat_path(ctx, tf.main_id, tf.sub_id, tf.file_id);
    trace!(path = %path.display(), offset = cmd.block_offset, block_count = cmd.block_count, "expand data");
    let file = ctx.open_cached(path)?;
    write_empty_block(file, cmd.block_offset, cmd.block_count)?;
    Ok(())
}

fn apply_header(cmd: &SqpkHeader, ctx: &mut ApplyContext) -> Result<()> {
    let path = match &cmd.target {
        SqpkHeaderTarget::Dat(f) => dat_path(ctx, f.main_id, f.sub_id, f.file_id),
        SqpkHeaderTarget::Index(f) => index_path(ctx, f.main_id, f.sub_id, f.file_id),
    };
    let offset: u64 = match cmd.header_kind {
        TargetHeaderKind::Version => 0,
        _ => 1024,
    };
    trace!(path = %path.display(), offset, kind = ?cmd.header_kind, "apply header");
    let file = ctx.open_cached(path)?;
    file.seek(SeekFrom::Start(offset))?;
    file.write_all(&cmd.header_data)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Cursor;
    use std::path::Path;

    // --- write_empty_block header structure ---
    // Tests the private helper directly; cannot be tested through public API.

    #[test]
    fn write_empty_block_header_structure() {
        let mut cur = Cursor::new(Vec::<u8>::new());
        write_empty_block(&mut cur, 0, 2).unwrap();
        let buf = cur.into_inner();

        assert_eq!(buf.len(), 256); // block_number << 7 = 256 bytes zeroed
        assert!(buf[20..].iter().all(|&b| b == 0));

        assert_eq!(&buf[0..4], &128u32.to_le_bytes()); // block size marker
        assert_eq!(&buf[4..8], &0u32.to_le_bytes());
        assert_eq!(&buf[8..12], &0u32.to_le_bytes());
        assert_eq!(&buf[12..16], &1u32.to_le_bytes()); // block_number.wrapping_sub(1) = 1
        assert_eq!(&buf[16..20], &0u32.to_le_bytes());
    }

    #[test]
    fn write_empty_block_rejects_zero_block_number() {
        let mut cur = Cursor::new(Vec::<u8>::new());
        let err = write_empty_block(&mut cur, 0, 0).expect_err("must reject block_number=0");
        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
    }

    // --- keep_in_remove_all filter ---
    // Tests the private helper directly.

    #[test]
    fn keep_in_remove_all_var_kept() {
        assert!(keep_in_remove_all(Path::new("path/to/something.var")));
    }

    #[test]
    fn keep_in_remove_all_bk2_kept() {
        assert!(keep_in_remove_all(Path::new("00000.bk2")));
        assert!(keep_in_remove_all(Path::new("00001.bk2")));
        assert!(keep_in_remove_all(Path::new("00002.bk2")));
        assert!(keep_in_remove_all(Path::new("00003.bk2")));
    }

    #[test]
    fn keep_in_remove_all_bk2_04_deleted() {
        assert!(!keep_in_remove_all(Path::new("00004.bk2")));
    }

    #[test]
    fn keep_in_remove_all_dat_deleted() {
        assert!(!keep_in_remove_all(Path::new("040100.win32.dat0")));
        assert!(!keep_in_remove_all(Path::new("040100.win32.index")));
    }

    #[test]
    fn keep_in_remove_all_prefixed_bk2_not_kept() {
        assert!(!keep_in_remove_all(Path::new("prefix00000.bk2")));
    }
}

fn apply_file(cmd: &SqpkFile, ctx: &mut ApplyContext) -> Result<()> {
    match cmd.operation {
        SqpkFileOperation::AddFile => {
            let path = generic_path(ctx, &cmd.path);
            trace!(path = %path.display(), file_offset = cmd.file_offset, blocks = cmd.blocks.len(), "add file");
            if let Some(parent) = path.parent() {
                fs::create_dir_all(parent)?;
            }
            let file = ctx.open_cached(path)?;
            if cmd.file_offset == 0 {
                file.set_len(0)?;
            }
            let offset = u64::try_from(cmd.file_offset)
                .map_err(|_| ZiPatchError::NegativeFileOffset(cmd.file_offset))?;
            file.seek(SeekFrom::Start(offset))?;
            for block in &cmd.blocks {
                block.decompress_into(file)?;
            }
            Ok(())
        }
        SqpkFileOperation::RemoveAll => {
            // Flush all cached handles before bulk-deleting files.
            ctx.clear_file_cache();
            let folder = expansion_folder_id(cmd.expansion_id);
            debug!(folder = %folder, "remove all");
            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) {
                        fs::remove_file(&path)?;
                    }
                }
            }
            Ok(())
        }
        SqpkFileOperation::DeleteFile => {
            let path = generic_path(ctx, &cmd.path);
            // Drop the cached handle before the OS delete so the fd is closed first
            // (required on Windows; harmless on Linux).
            ctx.evict_cached(&path);
            match fs::remove_file(&path) {
                Ok(()) => {
                    trace!(path = %path.display(), "delete file");
                    Ok(())
                }
                Err(e) if e.kind() == std::io::ErrorKind::NotFound && ctx.ignore_missing => {
                    warn!(path = %path.display(), "delete file: not found, ignored");
                    Ok(())
                }
                Err(e) => Err(e.into()),
            }
        }
        SqpkFileOperation::MakeDirTree => {
            let path = generic_path(ctx, &cmd.path);
            debug!(path = %path.display(), "make dir tree");
            fs::create_dir_all(path)?;
            Ok(())
        }
    }
}