pub(crate) mod observer;
pub(crate) mod path;
pub(crate) mod sqpk;
pub use observer::{ApplyObserver, ChunkEvent, NoopObserver};
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, HashSet};
use std::fs::{File, OpenOptions};
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use tracing::{debug, trace, warn};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum PathKind {
Dat,
Index,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct PathCacheKey {
pub(crate) main_id: u16,
pub(crate) sub_id: u16,
pub(crate) file_id: u32,
pub(crate) kind: PathKind,
}
const MAX_CACHED_FDS: usize = 256;
const WRITE_BUFFER_CAPACITY: usize = 64 * 1024;
pub struct ApplyContext {
pub(crate) game_path: PathBuf,
pub(crate) platform: Platform,
pub(crate) ignore_missing: bool,
pub(crate) ignore_old_mismatch: bool,
pub(crate) file_cache: HashMap<PathBuf, BufWriter<File>>,
pub(crate) dirs_created: HashSet<PathBuf>,
pub(crate) path_cache: HashMap<PathCacheKey, PathBuf>,
pub(crate) decompressor: flate2::Decompress,
pub(crate) observer: Box<dyn ApplyObserver>,
}
impl std::fmt::Debug for ApplyContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ApplyContext")
.field("game_path", &self.game_path)
.field("platform", &self.platform)
.field("ignore_missing", &self.ignore_missing)
.field("ignore_old_mismatch", &self.ignore_old_mismatch)
.field("file_cache_len", &self.file_cache.len())
.field("dirs_created_len", &self.dirs_created.len())
.field("path_cache_len", &self.path_cache.len())
.field("decompressor", &"<flate2::Decompress>")
.field("observer", &"<dyn ApplyObserver>")
.finish()
}
}
impl ApplyContext {
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(),
dirs_created: HashSet::new(),
path_cache: HashMap::new(),
decompressor: flate2::Decompress::new(false),
observer: Box::new(NoopObserver),
}
}
#[must_use]
pub fn game_path(&self) -> &std::path::Path {
&self.game_path
}
#[must_use]
pub fn platform(&self) -> Platform {
self.platform
}
#[must_use]
pub fn ignore_missing(&self) -> bool {
self.ignore_missing
}
#[must_use]
pub fn ignore_old_mismatch(&self) -> bool {
self.ignore_old_mismatch
}
#[must_use]
pub fn with_platform(mut self, platform: Platform) -> Self {
self.platform = platform;
self
}
#[must_use]
pub fn with_ignore_missing(mut self, v: bool) -> Self {
self.ignore_missing = v;
self
}
#[must_use]
pub fn with_ignore_old_mismatch(mut self, v: bool) -> Self {
self.ignore_old_mismatch = v;
self
}
#[must_use]
pub fn with_observer(mut self, observer: impl ApplyObserver + 'static) -> Self {
self.observer = Box::new(observer);
self
}
pub(crate) fn open_cached(&mut self, path: PathBuf) -> std::io::Result<&mut BufWriter<File>> {
use std::collections::hash_map::Entry;
if self.file_cache.len() >= MAX_CACHED_FDS && !self.file_cache.contains_key(&path) {
self.drain_and_flush()?;
}
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(BufWriter::with_capacity(WRITE_BUFFER_CAPACITY, file)))
}
}
}
pub(crate) fn evict_cached(&mut self, path: &Path) -> std::io::Result<()> {
if let Some(mut writer) = self.file_cache.remove(path) {
writer.flush()?;
}
Ok(())
}
pub(crate) fn clear_file_cache(&mut self) -> std::io::Result<()> {
self.drain_and_flush()
}
pub(crate) fn ensure_dir_all(&mut self, path: &Path) -> std::io::Result<()> {
if self.dirs_created.contains(path) {
return Ok(());
}
std::fs::create_dir_all(path)?;
self.dirs_created.insert(path.to_path_buf());
Ok(())
}
pub(crate) fn invalidate_dirs_created(&mut self) {
self.dirs_created.clear();
}
pub(crate) fn invalidate_path_cache(&mut self) {
self.path_cache.clear();
}
fn drain_and_flush(&mut self) -> std::io::Result<()> {
let mut first_err: Option<std::io::Error> = None;
for (_, mut writer) in self.file_cache.drain() {
if let Err(e) = writer.flush() {
first_err.get_or_insert(e);
}
}
match first_err {
Some(e) => Err(e),
None => Ok(()),
}
}
pub fn flush(&mut self) -> std::io::Result<()> {
let mut first_err: Option<std::io::Error> = None;
for writer in self.file_cache.values_mut() {
if let Err(e) = writer.flush() {
first_err.get_or_insert(e);
}
}
match first_err {
Some(e) => Err(e),
None => Ok(()),
}
}
}
pub trait Apply {
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");
let path = ctx.game_path.join(&self.name);
ctx.ensure_dir_all(&path)?;
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");
ctx.invalidate_dirs_created();
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_all_entries_when_at_capacity() {
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,
"cache should be full before triggering eviction"
);
ctx.open_cached(tmp.path().join("new.dat")).unwrap();
assert_eq!(
ctx.file_cache.len(),
1,
"eviction must clear all entries and leave only the new handle"
);
}
#[test]
fn cache_hit_does_not_trigger_eviction_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();
}
ctx.open_cached(tmp.path().join("0.dat")).unwrap();
assert_eq!(
ctx.file_cache.len(),
MAX_CACHED_FDS,
"cache hit on a full cache must not evict anything"
);
}
#[test]
fn evict_cached_removes_only_target_path() {
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyContext::new(tmp.path());
let a = tmp.path().join("a.dat");
let b = tmp.path().join("b.dat");
ctx.open_cached(a.clone()).unwrap();
ctx.open_cached(b.clone()).unwrap();
assert_eq!(ctx.file_cache.len(), 2);
ctx.evict_cached(&a).unwrap();
assert_eq!(
ctx.file_cache.len(),
1,
"evict_cached must remove exactly the targeted path"
);
assert!(
ctx.file_cache.contains_key(&b),
"evict_cached must not remove the other path"
);
}
#[test]
fn evict_cached_is_noop_for_absent_path() {
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyContext::new(tmp.path());
ctx.open_cached(tmp.path().join("a.dat")).unwrap();
ctx.evict_cached(&tmp.path().join("nonexistent.dat"))
.unwrap();
assert_eq!(ctx.file_cache.len(), 1);
}
#[test]
fn clear_file_cache_removes_all_handles() {
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyContext::new(tmp.path());
ctx.open_cached(tmp.path().join("a.dat")).unwrap();
ctx.open_cached(tmp.path().join("b.dat")).unwrap();
assert_eq!(ctx.file_cache.len(), 2);
ctx.clear_file_cache().unwrap();
assert_eq!(
ctx.file_cache.len(),
0,
"clear_file_cache must empty the cache"
);
}
#[test]
fn game_path_returns_install_root_unchanged() {
let tmp = tempfile::tempdir().unwrap();
let ctx = ApplyContext::new(tmp.path());
assert_eq!(
ctx.game_path(),
tmp.path(),
"game_path() must return exactly the path passed to new()"
);
}
#[test]
fn default_platform_is_win32() {
let ctx = ApplyContext::new("/irrelevant");
assert_eq!(
ctx.platform(),
Platform::Win32,
"default platform must be Win32"
);
}
#[test]
fn with_platform_overrides_default() {
let ctx = ApplyContext::new("/irrelevant").with_platform(Platform::Ps4);
assert_eq!(
ctx.platform(),
Platform::Ps4,
"with_platform must override the Win32 default"
);
}
#[test]
fn default_ignore_missing_is_false() {
let ctx = ApplyContext::new("/irrelevant");
assert!(
!ctx.ignore_missing(),
"ignore_missing must default to false"
);
}
#[test]
fn with_ignore_missing_toggles_flag_both_ways() {
let ctx = ApplyContext::new("/irrelevant").with_ignore_missing(true);
assert!(
ctx.ignore_missing(),
"with_ignore_missing(true) must set the flag"
);
let ctx = ctx.with_ignore_missing(false);
assert!(
!ctx.ignore_missing(),
"with_ignore_missing(false) must clear the flag"
);
}
#[test]
fn default_ignore_old_mismatch_is_false() {
let ctx = ApplyContext::new("/irrelevant");
assert!(
!ctx.ignore_old_mismatch(),
"ignore_old_mismatch must default to false"
);
}
#[test]
fn with_ignore_old_mismatch_toggles_flag_both_ways() {
let ctx = ApplyContext::new("/irrelevant").with_ignore_old_mismatch(true);
assert!(
ctx.ignore_old_mismatch(),
"with_ignore_old_mismatch(true) must set the flag"
);
let ctx = ctx.with_ignore_old_mismatch(false);
assert!(
!ctx.ignore_old_mismatch(),
"with_ignore_old_mismatch(false) must clear the flag"
);
}
#[test]
fn buffered_writes_are_invisible_before_flush() {
use std::io::Write;
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyContext::new(tmp.path());
let path = tmp.path().join("buffered.dat");
let writer = ctx.open_cached(path.clone()).unwrap();
writer.write_all(&[0xAB]).unwrap();
assert_eq!(
std::fs::metadata(&path).unwrap().len(),
0,
"buffered write must not reach disk before flush"
);
ctx.flush().unwrap();
assert_eq!(
std::fs::read(&path).unwrap(),
vec![0xAB],
"flush must drain the buffer to disk"
);
}
#[test]
fn flush_keeps_handles_in_cache() {
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyContext::new(tmp.path());
ctx.open_cached(tmp.path().join("a.dat")).unwrap();
ctx.open_cached(tmp.path().join("b.dat")).unwrap();
assert_eq!(ctx.file_cache.len(), 2);
ctx.flush().unwrap();
assert_eq!(
ctx.file_cache.len(),
2,
"flush must not drop cached handles"
);
}
#[test]
fn evict_cached_flushes_pending_writes_to_disk() {
use std::io::Write;
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyContext::new(tmp.path());
let path = tmp.path().join("evict.dat");
let writer = ctx.open_cached(path.clone()).unwrap();
writer.write_all(b"queued").unwrap();
assert_eq!(
std::fs::metadata(&path).unwrap().len(),
0,
"pre-condition: write is buffered, not on disk"
);
ctx.evict_cached(&path).unwrap();
assert_eq!(
std::fs::read(&path).unwrap(),
b"queued",
"evict_cached must flush before closing the handle"
);
assert!(
!ctx.file_cache.contains_key(&path),
"evict_cached must also remove the entry"
);
}
#[test]
fn clear_file_cache_flushes_every_pending_write() {
use std::io::Write;
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyContext::new(tmp.path());
let a = tmp.path().join("a.dat");
let b = tmp.path().join("b.dat");
ctx.open_cached(a.clone())
.unwrap()
.write_all(b"AA")
.unwrap();
ctx.open_cached(b.clone())
.unwrap()
.write_all(b"BB")
.unwrap();
ctx.clear_file_cache().unwrap();
assert_eq!(std::fs::read(&a).unwrap(), b"AA");
assert_eq!(std::fs::read(&b).unwrap(), b"BB");
assert!(ctx.file_cache.is_empty(), "cache must be empty after clear");
}
#[test]
fn apply_context_debug_renders_all_fields() {
let tmp = tempfile::tempdir().unwrap();
let ctx = ApplyContext::new(tmp.path())
.with_platform(Platform::Ps4)
.with_ignore_missing(true);
let rendered = format!("{ctx:?}");
for needle in [
"ApplyContext",
"game_path",
"platform",
"Ps4",
"ignore_missing",
"true",
"ignore_old_mismatch",
"file_cache_len",
"path_cache_len",
"decompressor",
"<flate2::Decompress>",
"observer",
"<dyn ApplyObserver>",
] {
assert!(
rendered.contains(needle),
"Debug output must mention {needle:?}; got: {rendered}"
);
}
}
#[test]
fn delete_directory_success_removes_existing_dir() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("to_remove");
std::fs::create_dir(&target).unwrap();
assert!(target.is_dir(), "pre-condition: directory must exist");
let mut ctx = ApplyContext::new(tmp.path());
DeleteDirectory {
name: "to_remove".into(),
}
.apply(&mut ctx)
.expect("delete on an existing directory must succeed");
assert!(!target.exists(), "directory must be removed");
}
#[test]
fn ensure_dir_all_cache_hit_returns_early_without_syscall() {
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyContext::new(tmp.path());
let path = tmp.path().join("cached_dir");
ctx.ensure_dir_all(&path).unwrap();
assert!(path.is_dir(), "first call must create the directory");
assert_eq!(
ctx.dirs_created.len(),
1,
"path must be cached after first call"
);
let p2 = tmp.path().join("cached_dir");
ctx.ensure_dir_all(&p2).unwrap();
assert_eq!(
ctx.dirs_created.len(),
1,
"cache hit must not re-insert the path (set length must stay 1)"
);
}
#[test]
fn drain_and_flush_error_propagates_first_io_error() {
use std::io::Write;
let dev_full = std::path::PathBuf::from("/dev/full");
if !dev_full.exists() {
return;
}
let file = OpenOptions::new()
.write(true)
.open(&dev_full)
.expect("/dev/full must be openable for writing");
let mut ctx = ApplyContext::new("/irrelevant");
let mut writer = BufWriter::with_capacity(WRITE_BUFFER_CAPACITY, file);
writer.write_all(&[0xAB; 128]).unwrap();
ctx.file_cache.insert(dev_full.clone(), writer);
let result = ctx.clear_file_cache();
assert!(
result.is_err(),
"drain_and_flush must propagate the ENOSPC error from /dev/full"
);
assert!(
ctx.file_cache.is_empty(),
"cache must be drained even when flush fails"
);
}
#[test]
fn flush_error_propagates_first_io_error() {
use std::io::Write;
let dev_full = std::path::PathBuf::from("/dev/full");
if !dev_full.exists() {
return;
}
let file = OpenOptions::new()
.write(true)
.open(&dev_full)
.expect("/dev/full must be openable for writing");
let mut ctx = ApplyContext::new("/irrelevant");
let mut writer = BufWriter::with_capacity(WRITE_BUFFER_CAPACITY, file);
writer.write_all(&[0xCD; 128]).unwrap();
ctx.file_cache.insert(dev_full.clone(), writer);
let result = ctx.flush();
assert!(
result.is_err(),
"flush must propagate the ENOSPC error from /dev/full"
);
assert_eq!(
ctx.file_cache.len(),
1,
"flush must NOT evict handles — only drain_and_flush does that"
);
}
}