zipatch-rs 1.0.0

Parser for FFXIV ZiPatch patch files
Documentation
pub(crate) mod path;
pub(crate) mod sqpk;

use crate::Platform;
use crate::Result;
use crate::chunk::Chunk;
use crate::chunk::adir::AddDirectory;
use crate::chunk::aply::{ApplyOption, ApplyOptionKind};
use crate::chunk::ddir::DeleteDirectory;
use std::collections::HashMap;
use std::fs::{File, OpenOptions};
use std::path::{Path, PathBuf};
use tracing::{debug, trace, warn};

const MAX_CACHED_FDS: usize = 256;

/// Apply-time state: install root, target platform, flag toggles, and the
/// internal file-handle cache used by SQPK writers.
///
/// Construct with [`ApplyContext::new`] and customise with the `with_*`
/// builder methods. `SqpkTargetInfo` chunks may overwrite the platform field
/// at apply time; `ApplyOption` chunks may overwrite the ignore-flags.
pub struct ApplyContext {
    pub(crate) game_path: PathBuf,
    /// The target platform. Defaults to `Win32`. Note: `SqpkTargetInfo` chunks
    /// in the patch stream will override this value when applied.
    pub(crate) platform: Platform,
    pub(crate) ignore_missing: bool,
    pub(crate) ignore_old_mismatch: bool,
    // Capped at MAX_CACHED_FDS entries; cleared wholesale when full to bound open FD count.
    file_cache: HashMap<PathBuf, File>,
}

impl ApplyContext {
    /// Create a context targeting the given game install directory.
    ///
    /// Defaults: platform is [`Platform::Win32`], both ignore-flags are off.
    pub fn new(game_path: impl Into<PathBuf>) -> Self {
        Self {
            game_path: game_path.into(),
            platform: Platform::Win32,
            ignore_missing: false,
            ignore_old_mismatch: false,
            file_cache: HashMap::new(),
        }
    }

    /// Returns the game installation directory.
    #[must_use]
    pub fn game_path(&self) -> &std::path::Path {
        &self.game_path
    }

    /// Returns the current target platform.
    #[must_use]
    pub fn platform(&self) -> Platform {
        self.platform
    }

    /// Returns whether missing files are silently ignored during apply.
    #[must_use]
    pub fn ignore_missing(&self) -> bool {
        self.ignore_missing
    }

    /// Returns whether old-data mismatches are silently ignored during apply.
    #[must_use]
    pub fn ignore_old_mismatch(&self) -> bool {
        self.ignore_old_mismatch
    }

    /// Sets the target platform. Defaults to [`Platform::Win32`].
    /// Note: `SqpkTargetInfo` chunks in the patch stream will override this at apply time.
    #[must_use]
    pub fn with_platform(mut self, platform: Platform) -> Self {
        self.platform = platform;
        self
    }

    /// Silently ignore missing files instead of returning an error during apply.
    #[must_use]
    pub fn with_ignore_missing(mut self, v: bool) -> Self {
        self.ignore_missing = v;
        self
    }

    /// Silently ignore old-data mismatches instead of returning an error during apply.
    #[must_use]
    pub fn with_ignore_old_mismatch(mut self, v: bool) -> Self {
        self.ignore_old_mismatch = v;
        self
    }

    pub(crate) fn open_cached(&mut self, path: PathBuf) -> std::io::Result<&mut File> {
        use std::collections::hash_map::Entry;
        // Crude eviction: clear all when full to bound open FD count.
        if self.file_cache.len() >= MAX_CACHED_FDS && !self.file_cache.contains_key(&path) {
            self.file_cache.clear();
        }
        match self.file_cache.entry(path) {
            Entry::Occupied(e) => Ok(e.into_mut()),
            Entry::Vacant(e) => {
                let file = OpenOptions::new()
                    .write(true)
                    .create(true)
                    .truncate(false)
                    .open(e.key())?;
                Ok(e.insert(file))
            }
        }
    }

    pub(crate) fn evict_cached(&mut self, path: &Path) {
        self.file_cache.remove(path);
    }

    pub(crate) fn clear_file_cache(&mut self) {
        self.file_cache.clear();
    }
}

/// Applies a parsed chunk to the filesystem via an [`ApplyContext`].
///
/// Implementors should be idempotent against transient errors only when the
/// chunk semantics permit it; in general callers must consume chunks in stream
/// order.
pub trait Apply {
    /// Apply this chunk to `ctx`.
    fn apply(&self, ctx: &mut ApplyContext) -> Result<()>;
}

impl Apply for Chunk {
    fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
        match self {
            Chunk::FileHeader(_) | Chunk::ApplyFreeSpace(_) | Chunk::EndOfFile => Ok(()),
            Chunk::Sqpk(c) => c.apply(ctx),
            Chunk::ApplyOption(c) => c.apply(ctx),
            Chunk::AddDirectory(c) => c.apply(ctx),
            Chunk::DeleteDirectory(c) => c.apply(ctx),
        }
    }
}

impl Apply for ApplyOption {
    fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
        debug!(kind = ?self.kind, value = self.value, "apply option");
        match self.kind {
            ApplyOptionKind::IgnoreMissing => ctx.ignore_missing = self.value,
            ApplyOptionKind::IgnoreOldMismatch => ctx.ignore_old_mismatch = self.value,
        }
        Ok(())
    }
}

impl Apply for AddDirectory {
    fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
        trace!(name = %self.name, "create directory");
        std::fs::create_dir_all(ctx.game_path.join(&self.name))?;
        Ok(())
    }
}

impl Apply for DeleteDirectory {
    fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
        match std::fs::remove_dir(ctx.game_path.join(&self.name)) {
            Ok(()) => {
                trace!(name = %self.name, "delete directory");
                Ok(())
            }
            Err(e) if e.kind() == std::io::ErrorKind::NotFound && ctx.ignore_missing => {
                warn!(name = %self.name, "delete directory: not found, ignored");
                Ok(())
            }
            Err(e) => Err(e.into()),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn cache_eviction_clears_when_full() {
        let tmp = tempfile::tempdir().unwrap();
        let mut ctx = ApplyContext::new(tmp.path());

        for i in 0..MAX_CACHED_FDS {
            ctx.open_cached(tmp.path().join(format!("{i}.dat")))
                .unwrap();
        }
        assert_eq!(ctx.file_cache.len(), MAX_CACHED_FDS);

        ctx.open_cached(tmp.path().join("new.dat")).unwrap();
        assert_eq!(ctx.file_cache.len(), 1);
    }

    #[test]
    fn game_path_returns_install_root() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = ApplyContext::new(tmp.path());
        assert_eq!(ctx.game_path(), tmp.path());
    }

    #[test]
    fn with_platform_overrides_default() {
        let ctx = ApplyContext::new("/irrelevant").with_platform(Platform::Ps4);
        assert_eq!(ctx.platform(), Platform::Ps4);
    }

    #[test]
    fn with_ignore_old_mismatch_toggles_flag() {
        let ctx = ApplyContext::new("/irrelevant").with_ignore_old_mismatch(true);
        assert!(ctx.ignore_old_mismatch());
        let ctx = ctx.with_ignore_old_mismatch(false);
        assert!(!ctx.ignore_old_mismatch());
    }
}