use std::fs::{self, File};
use std::path::{Path, PathBuf};
use crate::{Error, Result};
pub(crate) struct StagedFile {
destination: PathBuf,
scratch: PathBuf,
file: Option<File>,
}
impl StagedFile {
pub(crate) fn create(destination: &Path) -> Result<Self> {
let scratch = scratch_path(destination);
let file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&scratch)
.map_err(Error::Io)?;
if let Ok(existing) = fs::metadata(destination) {
if let Err(e) = file.set_permissions(existing.permissions()) {
log::warn!(
"Failed to carry the mode of '{}' onto the file replacing it: {}",
destination.display(),
e
);
}
}
Ok(Self {
destination: destination.to_path_buf(),
scratch,
file: Some(file),
})
}
pub(crate) fn file(&mut self) -> &mut File {
self.file
.as_mut()
.expect("the file is only taken when committing")
}
pub(crate) fn take_file(&mut self) -> File {
self.file
.take()
.expect("the file is only taken once, by the caller that writes it")
}
pub(crate) fn path(&self) -> &Path {
&self.scratch
}
pub(crate) fn close(&mut self) -> Result<()> {
if let Some(file) = self.file.take() {
file.sync_data().map_err(Error::Io)?;
}
Ok(())
}
pub(crate) fn commit(mut self) -> Result<PathBuf> {
self.close()?;
fs::rename(&self.scratch, &self.destination).map_err(Error::Io)?;
let destination = std::mem::take(&mut self.destination);
self.scratch = PathBuf::new();
Ok(destination)
}
}
impl Drop for StagedFile {
fn drop(&mut self) {
self.file.take();
if self.scratch.as_os_str().is_empty() {
return;
}
if let Err(e) = fs::remove_file(&self.scratch) {
if e.kind() != std::io::ErrorKind::NotFound {
log::warn!(
"Failed to clean up partial file '{}': {}",
self.scratch.display(),
e
);
}
}
}
}
fn scratch_path(destination: &Path) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(0);
let name = destination
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "entry".to_string());
let suffix = format!(
".zesven-{}-{}.part",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed),
);
const MAX_NAME: usize = 255;
let room = MAX_NAME.saturating_sub(suffix.len() + 1);
let mut trimmed = name;
while trimmed.len() > room {
trimmed.pop();
}
let scratch = format!(".{trimmed}{suffix}");
match destination.parent() {
Some(parent) => parent.join(scratch),
None => PathBuf::from(scratch),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_a_committed_file_reaches_its_destination() {
let dir = tempfile::TempDir::new().expect("temp dir");
let destination = dir.path().join("out.bin");
let mut staged = StagedFile::create(&destination).expect("creates");
staged.file().write_all(b"CONTENTS").expect("writes");
staged.commit().expect("commits");
assert_eq!(fs::read(&destination).expect("reads"), b"CONTENTS");
assert_eq!(strays(dir.path()), Vec::<String>::new());
}
#[test]
fn test_an_abandoned_file_leaves_the_destination_untouched() {
let dir = tempfile::TempDir::new().expect("temp dir");
let destination = dir.path().join("out.bin");
fs::write(&destination, b"WHAT WAS THERE BEFORE").expect("writes");
{
let mut staged = StagedFile::create(&destination).expect("creates");
staged
.file()
.write_all(b"HALF OF SOMETHING")
.expect("writes");
}
assert_eq!(
fs::read(&destination).expect("reads"),
b"WHAT WAS THERE BEFORE",
"a failed extraction destroyed the file it was replacing",
);
assert_eq!(strays(dir.path()), Vec::<String>::new());
}
#[test]
fn test_two_destinations_do_not_share_a_scratch_file() {
let dir = tempfile::TempDir::new().expect("temp dir");
let first = dir.path().join("one.bin");
let second = dir.path().join("two.bin");
let mut a = StagedFile::create(&first).expect("creates");
let mut b = StagedFile::create(&second).expect("creates");
assert_ne!(a.path(), b.path());
a.file().write_all(b"FIRST").expect("writes");
b.file().write_all(b"SECOND").expect("writes");
a.commit().expect("commits");
b.commit().expect("commits");
assert_eq!(fs::read(&first).expect("reads"), b"FIRST");
assert_eq!(fs::read(&second).expect("reads"), b"SECOND");
}
fn strays(dir: &Path) -> Vec<String> {
let mut found: Vec<String> = fs::read_dir(dir)
.expect("reads")
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|name| name.contains("part"))
.collect();
found.sort();
found
}
}