pub mod checkpoint;
pub(crate) mod observer;
pub(crate) mod path;
pub(crate) mod sqpk;
pub use checkpoint::{
Checkpoint, CheckpointPolicy, CheckpointSink, InFlightAddFile, IndexedCheckpoint,
NoopCheckpointSink, SequentialCheckpoint,
};
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, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use tracing::{trace, warn};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ApplyMode {
Write,
DryRun,
}
#[derive(Debug, Default)]
pub(crate) struct NullWriter {
position: u64,
len: u64,
}
impl Write for NullWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let Some(new_pos) = self.position.checked_add(buf.len() as u64) else {
return Err(std::io::Error::from(std::io::ErrorKind::WriteZero));
};
self.position = new_pos;
if self.position > self.len {
self.len = self.position;
}
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl Seek for NullWriter {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
let new_pos: i128 = match pos {
SeekFrom::Start(n) => i128::from(n),
SeekFrom::Current(d) => i128::from(self.position) + i128::from(d),
SeekFrom::End(d) => i128::from(self.len) + i128::from(d),
};
if new_pos < 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"negative seek position",
));
}
self.position = new_pos as u64;
Ok(self.position)
}
}
#[derive(Debug)]
pub(crate) enum CachedWriter {
File(BufWriter<File>),
Null(NullWriter),
}
impl Write for CachedWriter {
#[inline]
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
match self {
Self::File(w) => w.write(buf),
Self::Null(w) => w.write(buf),
}
}
#[inline]
fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> {
match self {
Self::File(w) => w.write_all(buf),
Self::Null(w) => w.write_all(buf),
}
}
#[inline]
fn flush(&mut self) -> std::io::Result<()> {
match self {
Self::File(w) => w.flush(),
Self::Null(w) => w.flush(),
}
}
}
impl Seek for CachedWriter {
#[inline]
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
match self {
Self::File(w) => w.seek(pos),
Self::Null(w) => w.seek(pos),
}
}
#[inline]
fn stream_position(&mut self) -> std::io::Result<u64> {
match self {
Self::File(w) => w.stream_position(),
Self::Null(w) => w.stream_position(),
}
}
}
impl CachedWriter {
pub(crate) fn truncate_to_zero(&mut self) -> std::io::Result<()> {
match self {
Self::File(w) => {
w.flush()?;
w.get_mut().set_len(0)
}
Self::Null(_) => Ok(()),
}
}
fn sync_all_inner(&mut self) -> std::io::Result<()> {
match self {
Self::File(w) => {
w.flush()?;
w.get_ref().sync_all()
}
Self::Null(_) => Ok(()),
}
}
}
pub(crate) fn validate_checkpoint_policy(policy: CheckpointPolicy) {
assert!(
!matches!(policy, CheckpointPolicy::FsyncEveryN(0)),
"CheckpointPolicy::FsyncEveryN(0) is invalid; use CheckpointPolicy::Fsync \
for an every-record fsync cadence"
);
}
#[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) mode: ApplyMode,
pub(crate) file_cache: HashMap<PathBuf, CachedWriter>,
pub(crate) dirs_created: HashSet<PathBuf>,
pub(crate) path_cache: HashMap<PathCacheKey, PathBuf>,
pub(crate) decompressor: flate2::Decompress,
pub(crate) observer: Box<dyn ApplyObserver>,
pub(crate) checkpoint_sink: Box<dyn CheckpointSink>,
pub(crate) checkpoints_since_fsync: u32,
#[cfg(any(test, feature = "test-utils"))]
pub test_flush_count: usize,
#[cfg(any(test, feature = "test-utils"))]
pub test_sync_count: usize,
pub(crate) current_chunk_index: u64,
pub(crate) current_chunk_bytes_read: u64,
pub(crate) patch_name: Option<String>,
pub(crate) patch_size: Option<u64>,
}
impl std::fmt::Debug for ApplyContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut s = f.debug_struct("ApplyContext");
s.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("mode", &self.mode)
.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>")
.field("checkpoint_sink", &"<dyn CheckpointSink>")
.field("checkpoints_since_fsync", &self.checkpoints_since_fsync)
.field("current_chunk_index", &self.current_chunk_index)
.field("current_chunk_bytes_read", &self.current_chunk_bytes_read)
.field("patch_name", &self.patch_name)
.field("patch_size", &self.patch_size);
#[cfg(any(test, feature = "test-utils"))]
s.field("test_flush_count", &self.test_flush_count)
.field("test_sync_count", &self.test_sync_count);
s.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,
mode: ApplyMode::Write,
file_cache: HashMap::new(),
dirs_created: HashSet::new(),
path_cache: HashMap::new(),
decompressor: flate2::Decompress::new(false),
observer: Box::new(NoopObserver),
checkpoint_sink: Box::new(NoopCheckpointSink),
checkpoints_since_fsync: 0,
#[cfg(any(test, feature = "test-utils"))]
test_flush_count: 0,
#[cfg(any(test, feature = "test-utils"))]
test_sync_count: 0,
current_chunk_index: 0,
current_chunk_bytes_read: 0,
patch_name: None,
patch_size: None,
}
}
#[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 mode(&self) -> ApplyMode {
self.mode
}
#[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 = 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
}
#[must_use]
pub fn with_checkpoint_sink(mut self, sink: impl CheckpointSink + 'static) -> Self {
validate_checkpoint_policy(sink.policy());
self.checkpoint_sink = Box::new(sink);
self
}
pub fn sync_all(&mut self) -> std::io::Result<()> {
#[cfg(any(test, feature = "test-utils"))]
{
self.test_sync_count += 1;
}
let mut first_err: Option<std::io::Error> = None;
for writer in self.file_cache.values_mut() {
if let Err(e) = writer.sync_all_inner() {
first_err.get_or_insert(e);
}
}
match first_err {
Some(e) => Err(e),
None => Ok(()),
}
}
pub(crate) fn record_checkpoint(&mut self, checkpoint: &Checkpoint) -> Result<()> {
self.checkpoint_sink.record(checkpoint)?;
match self.checkpoint_sink.policy() {
CheckpointPolicy::Flush => {
self.flush()?;
}
CheckpointPolicy::Fsync => {
self.sync_all()?;
self.checkpoints_since_fsync = 0;
}
CheckpointPolicy::FsyncEveryN(n) => {
debug_assert!(n >= 1, "FsyncEveryN(0) must be rejected at install time");
self.checkpoints_since_fsync = self.checkpoints_since_fsync.saturating_add(1);
if self.checkpoints_since_fsync >= n {
self.sync_all()?;
self.checkpoints_since_fsync = 0;
} else {
self.flush()?;
}
}
}
Ok(())
}
pub(crate) fn record_checkpoint_mid_block(&mut self, checkpoint: &Checkpoint) -> Result<()> {
self.checkpoint_sink.record(checkpoint)?;
Ok(())
}
pub(crate) fn open_cached(&mut self, path: &Path) -> std::io::Result<&mut CachedWriter> {
if self.file_cache.contains_key(path) {
return Ok(self
.file_cache
.get_mut(path)
.expect("contains_key returned true above"));
}
if self.file_cache.len() >= MAX_CACHED_FDS {
self.drain_and_flush()?;
}
let writer = match self.mode {
ApplyMode::Write => {
let file = OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(path)?;
CachedWriter::File(BufWriter::with_capacity(WRITE_BUFFER_CAPACITY, file))
}
ApplyMode::DryRun => CachedWriter::Null(NullWriter::default()),
};
Ok(self.file_cache.entry(path.to_path_buf()).or_insert(writer))
}
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(());
}
if matches!(self.mode, ApplyMode::Write) {
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<()> {
#[cfg(any(test, feature = "test-utils"))]
{
self.test_flush_count += 1;
}
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<()> {
trace!(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<()> {
if matches!(ctx.mode, ApplyMode::DryRun) {
trace!(name = %self.name, "delete directory: dry-run, suppressed");
return Ok(());
}
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).unwrap();
ctx.open_cached(&b).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).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).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).unwrap().write_all(b"AA").unwrap();
ctx.open_cached(&b).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(), CachedWriter::File(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 null_writer_write_advances_virtual_position() {
use std::io::Write;
let mut w = NullWriter::default();
let n = w.write(b"hello").unwrap();
assert_eq!(n, 5);
assert_eq!(w.stream_position().unwrap(), 5);
w.write_all(b"world").unwrap();
assert_eq!(w.stream_position().unwrap(), 10);
}
#[test]
fn null_writer_seek_from_start_sets_position() {
let mut w = NullWriter::default();
w.write_all(&[0u8; 64]).unwrap();
let pos = w.seek(SeekFrom::Start(20)).unwrap();
assert_eq!(pos, 20);
assert_eq!(w.stream_position().unwrap(), 20);
}
#[test]
fn null_writer_seek_from_current_advances_position() {
let mut w = NullWriter::default();
w.write_all(&[0u8; 32]).unwrap();
w.seek(SeekFrom::Start(10)).unwrap();
let pos = w.seek(SeekFrom::Current(5)).unwrap();
assert_eq!(pos, 15);
}
#[test]
fn null_writer_seek_from_end_resolves_relative_to_len() {
let mut w = NullWriter::default();
w.write_all(&[0u8; 100]).unwrap();
let pos = w.seek(SeekFrom::End(-10)).unwrap();
assert_eq!(pos, 90);
}
#[test]
fn null_writer_seek_past_end_moves_position_beyond_len() {
let mut w = NullWriter::default();
w.write_all(&[0u8; 16]).unwrap();
let pos = w.seek(SeekFrom::Start(1000)).unwrap();
assert_eq!(pos, 1000);
assert_eq!(w.stream_position().unwrap(), 1000);
}
#[test]
fn null_writer_seek_before_origin_returns_invalid_input() {
let mut w = NullWriter::default();
w.write_all(&[0u8; 10]).unwrap();
w.seek(SeekFrom::Start(5)).unwrap();
let err = w.seek(SeekFrom::Current(-100)).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
}
#[test]
fn null_writer_write_at_saturation_returns_write_zero() {
let mut w = NullWriter::default();
w.seek(SeekFrom::Start(u64::MAX)).unwrap();
let err = w.write(b"x").unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::WriteZero);
}
#[test]
fn null_writer_seek_to_u64_max_then_stream_position() {
let mut w = NullWriter::default();
let pos = w.seek(SeekFrom::Start(u64::MAX)).unwrap();
assert_eq!(pos, u64::MAX);
assert_eq!(w.stream_position().unwrap(), u64::MAX);
}
#[test]
fn null_writer_flush_is_always_ok() {
use std::io::Write;
let mut w = NullWriter::default();
w.write_all(b"data").unwrap();
w.flush().unwrap();
assert_eq!(w.stream_position().unwrap(), 4);
}
#[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(), CachedWriter::File(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"
);
}
}