use std::fs::{self, File};
use std::io;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Overwrite {
Forbid,
Replace,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Refused {
Appeared,
ReadOnly,
Directory,
NotAFile,
}
impl Refused {
pub fn of(error: &io::Error) -> Option<Self> {
error.get_ref()?.downcast_ref::<Self>().copied()
}
fn kind(self) -> io::ErrorKind {
match self {
Self::Appeared => io::ErrorKind::AlreadyExists,
Self::ReadOnly => io::ErrorKind::PermissionDenied,
Self::Directory => io::ErrorKind::IsADirectory,
Self::NotAFile => io::ErrorKind::InvalidInput,
}
}
}
impl std::fmt::Display for Refused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Appeared => "a file appeared there during the export and was left as it was",
Self::ReadOnly => "the file is read-only",
Self::Directory => "it is a directory",
Self::NotAFile => "it is not a regular file",
})
}
}
impl std::error::Error for Refused {}
impl From<Refused> for io::Error {
fn from(refused: Refused) -> Self {
io::Error::new(refused.kind(), refused)
}
}
#[derive(Debug)]
pub struct OutputFile {
temp: tempfile::NamedTempFile,
target: PathBuf,
overwrite: Overwrite,
}
impl OutputFile {
pub fn create(path: &Path, overwrite: Overwrite) -> io::Result<Self> {
let target = resolve_link(path)?;
replaceable(&target, overwrite)?;
let dir = match target.parent() {
Some(dir) if !dir.as_os_str().is_empty() => dir,
_ => Path::new("."),
};
let temp = tempfile::Builder::new()
.prefix(".datui-")
.suffix(&temp_suffix(&target))
.tempfile_in(dir)?;
Ok(Self {
temp,
target,
overwrite,
})
}
pub fn file(&mut self) -> &mut File {
self.temp.as_file_mut()
}
pub fn path(&self) -> &Path {
self.temp.path()
}
pub fn commit(self) -> io::Result<()> {
let Self {
temp,
target,
overwrite,
} = self;
temp.as_file().sync_all()?;
match overwrite {
Overwrite::Replace => {
let existing = replaceable(&target, Overwrite::Replace)?;
set_final_permissions(&temp, existing.as_ref())?;
temp.persist(&target).map_err(|e| e.error)?;
Ok(())
}
Overwrite::Forbid => {
set_final_permissions(&temp, None)?;
Self::persist_new(temp, &target)
}
}
}
fn persist_new(temp: tempfile::NamedTempFile, target: &Path) -> io::Result<()> {
match temp.persist_noclobber(target) {
Ok(_) => Ok(()),
Err(e) if e.error.kind() == io::ErrorKind::AlreadyExists => {
Err(Refused::Appeared.into())
}
Err(e) => {
if fs::symlink_metadata(target).is_ok() {
return Err(Refused::Appeared.into());
}
e.file.persist(target).map(drop).map_err(|e| e.error)
}
}
}
}
fn resolve_link(path: &Path) -> io::Result<PathBuf> {
let mut path = path.to_path_buf();
for _ in 0..40 {
match fs::symlink_metadata(&path) {
Ok(meta) if meta.file_type().is_symlink() => {
let link = fs::read_link(&path)?;
path = match path.parent() {
Some(dir) => dir.join(link),
None => link,
};
}
_ => return Ok(path),
}
}
Err(io::Error::other(format!(
"{} is a loop of symbolic links",
path.display()
)))
}
fn replaceable(target: &Path, overwrite: Overwrite) -> io::Result<Option<fs::Permissions>> {
let meta = match fs::metadata(target) {
Ok(meta) => meta,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
if overwrite == Overwrite::Forbid {
return Err(Refused::Appeared.into());
}
if meta.is_dir() {
return Err(Refused::Directory.into());
}
if !meta.is_file() {
return Err(Refused::NotAFile.into());
}
if meta.permissions().readonly() {
return Err(Refused::ReadOnly.into());
}
fs::OpenOptions::new().write(true).open(target)?;
Ok(Some(meta.permissions()))
}
fn temp_suffix(target: &Path) -> String {
let name = target
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
if name.len() <= 200 {
return format!("-{name}");
}
match target.extension() {
Some(ext) if ext.len() <= 32 => format!(".{}", ext.to_string_lossy()),
_ => String::new(),
}
}
#[cfg(unix)]
fn set_final_permissions(
temp: &tempfile::NamedTempFile,
existing: Option<&fs::Permissions>,
) -> io::Result<()> {
use std::os::unix::fs::PermissionsExt;
let permissions = match existing {
Some(permissions) => permissions.clone(),
None => fs::Permissions::from_mode(fresh_mode(temp.path())?),
};
temp.as_file().set_permissions(permissions)
}
#[cfg(not(unix))]
fn set_final_permissions(
_temp: &tempfile::NamedTempFile,
_existing: Option<&fs::Permissions>,
) -> io::Result<()> {
Ok(())
}
#[cfg(unix)]
fn fresh_mode(temp: &Path) -> io::Result<u32> {
use std::os::unix::fs::PermissionsExt;
let dir = match temp.parent() {
Some(dir) if !dir.as_os_str().is_empty() => dir,
_ => Path::new("."),
};
let probe = tempfile::Builder::new()
.prefix(".datui-mode-")
.permissions(fs::Permissions::from_mode(0o666))
.tempfile_in(dir)?;
Ok(probe.as_file().metadata()?.permissions().mode() & 0o777)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn leftovers(dir: &Path, keep: &[&str]) -> Vec<String> {
fs::read_dir(dir)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.filter(|name| !keep.contains(&name.as_str()))
.collect()
}
#[test]
fn a_commit_writes_a_new_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("out.csv");
let mut out = OutputFile::create(&path, Overwrite::Forbid).unwrap();
out.file().write_all(b"a\n1\n").unwrap();
assert!(!path.exists(), "nothing lands before the commit");
out.commit().unwrap();
assert_eq!(fs::read(&path).unwrap(), b"a\n1\n");
assert!(leftovers(dir.path(), &["out.csv"]).is_empty());
}
#[test]
fn an_uncommitted_new_file_leaves_nothing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("out.csv");
let mut out = OutputFile::create(&path, Overwrite::Forbid).unwrap();
out.file().write_all(b"partial").unwrap();
drop(out);
assert!(leftovers(dir.path(), &[]).is_empty());
}
#[test]
fn an_uncommitted_replacement_keeps_the_old_bytes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("out.csv");
fs::write(&path, b"old").unwrap();
let mut out = OutputFile::create(&path, Overwrite::Replace).unwrap();
out.file().write_all(b"new and partial").unwrap();
drop(out);
assert_eq!(fs::read(&path).unwrap(), b"old");
assert!(leftovers(dir.path(), &["out.csv"]).is_empty());
}
#[test]
fn a_panic_while_writing_keeps_the_old_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("out.csv");
fs::write(&path, b"old").unwrap();
let result = std::panic::catch_unwind(|| {
let mut out = OutputFile::create(&path, Overwrite::Replace).unwrap();
out.file().write_all(b"new and partial").unwrap();
panic!("the writer failed");
});
assert!(result.is_err());
assert_eq!(fs::read(&path).unwrap(), b"old");
assert!(leftovers(dir.path(), &["out.csv"]).is_empty());
}
#[test]
fn an_approved_replacement_lands() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("out.csv");
fs::write(&path, b"old").unwrap();
let mut out = OutputFile::create(&path, Overwrite::Replace).unwrap();
out.file().write_all(b"new").unwrap();
out.commit().unwrap();
assert_eq!(fs::read(&path).unwrap(), b"new");
assert!(leftovers(dir.path(), &["out.csv"]).is_empty());
}
#[test]
fn an_existing_file_is_not_replaced_without_approval() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("out.csv");
fs::write(&path, b"old").unwrap();
let err = OutputFile::create(&path, Overwrite::Forbid).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(fs::read(&path).unwrap(), b"old");
assert!(leftovers(dir.path(), &["out.csv"]).is_empty());
}
#[test]
fn a_file_that_appears_before_the_commit_is_left_alone() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("out.csv");
let mut out = OutputFile::create(&path, Overwrite::Forbid).unwrap();
out.file().write_all(b"ours").unwrap();
fs::write(&path, b"theirs").unwrap();
let err = out.commit().unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(fs::read(&path).unwrap(), b"theirs");
assert!(leftovers(dir.path(), &["out.csv"]).is_empty());
}
#[test]
fn an_approved_replacement_of_a_vanished_file_creates_it() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("out.csv");
fs::write(&path, b"old").unwrap();
let mut out = OutputFile::create(&path, Overwrite::Replace).unwrap();
out.file().write_all(b"new").unwrap();
fs::remove_file(&path).unwrap();
out.commit().unwrap();
assert_eq!(fs::read(&path).unwrap(), b"new");
}
#[test]
fn a_read_only_file_is_refused_and_kept() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("out.csv");
fs::write(&path, b"old").unwrap();
let writable = fs::metadata(&path).unwrap().permissions();
let mut read_only = writable.clone();
read_only.set_readonly(true);
fs::set_permissions(&path, read_only).unwrap();
let err = OutputFile::create(&path, Overwrite::Replace).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
assert_eq!(fs::read(&path).unwrap(), b"old");
assert!(fs::metadata(&path).unwrap().permissions().readonly());
assert!(leftovers(dir.path(), &["out.csv"]).is_empty());
fs::set_permissions(&path, writable).unwrap();
}
#[test]
fn a_directory_is_refused() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("out.csv");
fs::create_dir(&path).unwrap();
let err = OutputFile::create(&path, Overwrite::Replace).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::IsADirectory);
assert!(path.is_dir());
}
#[test]
fn a_missing_directory_fails_before_any_work() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("missing").join("out.csv");
let err = OutputFile::create(&path, Overwrite::Forbid).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);
}
#[test]
fn the_temporary_path_keeps_the_extension() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("chart.png");
let out = OutputFile::create(&path, Overwrite::Forbid).unwrap();
assert_eq!(out.path().extension().unwrap(), "png");
assert_eq!(out.path().parent(), Some(dir.path()));
fs::write(out.path(), b"png bytes").unwrap();
out.commit().unwrap();
assert_eq!(fs::read(&path).unwrap(), b"png bytes");
}
#[test]
fn a_long_name_keeps_its_extension() {
let name = format!("{}.parquet", "x".repeat(240));
assert_eq!(temp_suffix(Path::new(&name)), ".parquet");
assert_eq!(temp_suffix(Path::new("out.csv")), "-out.csv");
}
#[cfg(unix)]
mod unix {
use super::*;
use std::os::unix::fs::PermissionsExt;
fn mode(path: &Path) -> u32 {
fs::metadata(path).unwrap().permissions().mode() & 0o7777
}
#[test]
fn a_replacement_keeps_the_old_mode() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("out.csv");
fs::write(&path, b"old").unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
let mut out = OutputFile::create(&path, Overwrite::Replace).unwrap();
out.file().write_all(b"new").unwrap();
out.commit().unwrap();
assert_eq!(mode(&path), 0o640);
}
#[test]
fn a_failed_replacement_keeps_the_old_mode() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("out.csv");
fs::write(&path, b"old").unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o604)).unwrap();
let out = OutputFile::create(&path, Overwrite::Replace).unwrap();
drop(out);
assert_eq!(mode(&path), 0o604);
assert_eq!(fs::read(&path).unwrap(), b"old");
}
#[test]
fn a_file_we_may_not_write_is_refused() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("out.csv");
fs::write(&path, b"old").unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o060)).unwrap();
if fs::OpenOptions::new().write(true).open(&path).is_ok() {
return; }
let err = OutputFile::create(&path, Overwrite::Replace).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
assert_eq!(mode(&path), 0o060);
assert!(leftovers(dir.path(), &["out.csv"]).is_empty());
}
#[test]
fn a_new_file_is_private_until_it_lands() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("out.csv");
let out = OutputFile::create(&path, Overwrite::Forbid).unwrap();
assert_eq!(mode(out.path()), 0o600);
let plain = dir.path().join("plain");
File::create(&plain).unwrap();
out.commit().unwrap();
assert_eq!(mode(&path), mode(&plain));
}
#[test]
fn a_symlink_is_written_through() {
let dir = tempfile::tempdir().unwrap();
let data = dir.path().join("data");
let links = dir.path().join("links");
fs::create_dir(&data).unwrap();
fs::create_dir(&links).unwrap();
let real = data.join("real.csv");
let link = links.join("link.csv");
fs::write(&real, b"old").unwrap();
std::os::unix::fs::symlink("../data/real.csv", &link).unwrap();
let mut out = OutputFile::create(&link, Overwrite::Replace).unwrap();
assert_eq!(
out.path().parent().unwrap().canonicalize().unwrap(),
data.canonicalize().unwrap()
);
out.file().write_all(b"new").unwrap();
out.commit().unwrap();
assert!(leftovers(&data, &["real.csv"]).is_empty());
assert!(leftovers(&links, &["link.csv"]).is_empty());
assert!(
fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink()
);
assert_eq!(fs::read(&real).unwrap(), b"new");
}
}
}