mod os;
pub use os::OsFs;
use std::path::{Path, PathBuf};
use crate::Result;
#[derive(Debug, Clone)]
pub struct FsMetadata {
pub is_file: bool,
pub is_dir: bool,
pub is_symlink: bool,
pub len: u64,
pub mode: u32,
}
#[derive(Debug, Clone)]
pub struct DirEntry {
pub path: PathBuf,
pub name: String,
pub is_dir: bool,
pub is_file: bool,
pub is_symlink: bool,
}
pub trait Fs: Send + Sync {
fn stat(&self, path: &Path) -> Result<FsMetadata>;
fn lstat(&self, path: &Path) -> Result<FsMetadata>;
fn open_read(&self, path: &Path) -> Result<Box<dyn std::io::Read + Send + Sync>>;
fn read_file(&self, path: &Path) -> Result<Vec<u8>>;
fn read_to_string(&self, path: &Path) -> Result<String>;
fn write_file(&self, path: &Path, contents: &[u8]) -> Result<()>;
fn write_file_with_mode(&self, path: &Path, contents: &[u8], mode: u32) -> Result<()> {
self.write_file(path, contents)?;
self.set_permissions(path, mode)
}
fn write_atomic(&self, path: &Path, contents: &[u8]) -> Result<()> {
let tmp = temp_sibling(path);
if let Err(e) = self.write_file(&tmp, contents) {
let _ = self.remove_file(&tmp);
return Err(e);
}
if let Err(e) = self.rename(&tmp, path) {
let _ = self.remove_file(&tmp);
return Err(e);
}
Ok(())
}
fn write_atomic_with_mode(&self, path: &Path, contents: &[u8], mode: u32) -> Result<()> {
let tmp = temp_sibling(path);
if let Err(e) = self.write_file_with_mode(&tmp, contents, mode) {
let _ = self.remove_file(&tmp);
return Err(e);
}
if let Err(e) = self.rename(&tmp, path) {
let _ = self.remove_file(&tmp);
return Err(e);
}
Ok(())
}
fn mkdir_all(&self, path: &Path) -> Result<()>;
fn symlink(&self, original: &Path, link: &Path) -> Result<()>;
fn readlink(&self, path: &Path) -> Result<PathBuf>;
fn remove_file(&self, path: &Path) -> Result<()>;
fn remove_dir_all(&self, path: &Path) -> Result<()>;
fn exists(&self, path: &Path) -> bool;
fn is_symlink(&self, path: &Path) -> bool;
fn is_dir(&self, path: &Path) -> bool;
fn read_dir(&self, path: &Path) -> Result<Vec<DirEntry>>;
fn rename(&self, from: &Path, to: &Path) -> Result<()>;
fn copy_file(&self, from: &Path, to: &Path) -> Result<()>;
fn set_permissions(&self, path: &Path, mode: u32) -> Result<()>;
fn modified(&self, _path: &Path) -> Result<std::time::SystemTime> {
unimplemented!("Fs::modified is only implemented by OsFs")
}
fn set_modified(&self, _path: &Path, _time: std::time::SystemTime) -> Result<()> {
unimplemented!("Fs::set_modified is only implemented by OsFs")
}
}
fn temp_sibling(path: &Path) -> PathBuf {
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let parent = path.parent().unwrap_or(Path::new("."));
let name = path.file_name().unwrap_or_default().to_string_lossy();
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
parent.join(format!(".dodot-{name}.{}-{seq:x}.tmp", std::process::id()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::DodotError;
use std::collections::HashSet;
use std::sync::Barrier;
use tempfile::TempDir;
#[test]
fn temp_sibling_never_repeats_across_threads_and_calls() {
let path = Path::new("/some/dir/file.txt");
let mut seen = HashSet::new();
std::thread::scope(|s| {
let handles: Vec<_> = (0..8)
.map(|_| s.spawn(|| (0..1000).map(|_| temp_sibling(path)).collect::<Vec<_>>()))
.collect();
for h in handles {
for p in h.join().unwrap() {
assert!(seen.insert(p), "temp_sibling returned a duplicate path");
}
}
});
}
#[test]
fn concurrent_writers_to_one_target_leave_one_complete_file_and_no_temps() {
const WRITERS: u8 = 8;
let dir = TempDir::new().unwrap();
let target = dir.path().join("target.bin");
let payloads: Vec<Vec<u8>> = (0..WRITERS).map(|i| vec![i; 64 * 1024]).collect();
let barrier = Barrier::new(WRITERS as usize);
std::thread::scope(|s| {
for payload in &payloads {
s.spawn(|| {
barrier.wait();
OsFs::new().write_atomic(&target, payload).unwrap();
});
}
});
let survivor = std::fs::read(&target).unwrap();
assert!(
payloads.contains(&survivor),
"target must be exactly one writer's full payload"
);
let leftovers: Vec<String> = std::fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.filter(|name| name.ends_with(".tmp"))
.collect();
assert!(
leftovers.is_empty(),
"temp siblings left behind: {leftovers:?}"
);
}
struct FaultFs {
inner: OsFs,
fail_write: bool,
fail_chmod: bool,
}
impl FaultFs {
fn injected(&self, path: &Path) -> DodotError {
DodotError::Fs {
path: path.to_path_buf(),
source: std::io::Error::other("injected fault"),
}
}
}
impl Fs for FaultFs {
fn stat(&self, path: &Path) -> Result<FsMetadata> {
self.inner.stat(path)
}
fn lstat(&self, path: &Path) -> Result<FsMetadata> {
self.inner.lstat(path)
}
fn open_read(&self, path: &Path) -> Result<Box<dyn std::io::Read + Send + Sync>> {
self.inner.open_read(path)
}
fn read_file(&self, path: &Path) -> Result<Vec<u8>> {
self.inner.read_file(path)
}
fn read_to_string(&self, path: &Path) -> Result<String> {
self.inner.read_to_string(path)
}
fn write_file(&self, path: &Path, contents: &[u8]) -> Result<()> {
self.inner.write_file(path, contents)?;
if self.fail_write {
return Err(self.injected(path));
}
Ok(())
}
fn set_permissions(&self, path: &Path, mode: u32) -> Result<()> {
if self.fail_chmod {
return Err(self.injected(path));
}
self.inner.set_permissions(path, mode)
}
fn mkdir_all(&self, path: &Path) -> Result<()> {
self.inner.mkdir_all(path)
}
fn symlink(&self, original: &Path, link: &Path) -> Result<()> {
self.inner.symlink(original, link)
}
fn readlink(&self, path: &Path) -> Result<PathBuf> {
self.inner.readlink(path)
}
fn remove_file(&self, path: &Path) -> Result<()> {
self.inner.remove_file(path)
}
fn remove_dir_all(&self, path: &Path) -> Result<()> {
self.inner.remove_dir_all(path)
}
fn exists(&self, path: &Path) -> bool {
self.inner.exists(path)
}
fn is_symlink(&self, path: &Path) -> bool {
self.inner.is_symlink(path)
}
fn is_dir(&self, path: &Path) -> bool {
self.inner.is_dir(path)
}
fn read_dir(&self, path: &Path) -> Result<Vec<DirEntry>> {
self.inner.read_dir(path)
}
fn rename(&self, from: &Path, to: &Path) -> Result<()> {
self.inner.rename(from, to)
}
fn copy_file(&self, from: &Path, to: &Path) -> Result<()> {
self.inner.copy_file(from, to)
}
}
fn tmp_leftovers(dir: &Path) -> Vec<String> {
std::fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.filter(|name| name.ends_with(".tmp"))
.collect()
}
#[test]
fn write_atomic_removes_the_temp_when_the_write_fails() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("target.txt");
let fs = FaultFs {
inner: OsFs::new(),
fail_write: true,
fail_chmod: false,
};
assert!(fs.write_atomic(&target, b"data").is_err());
assert!(!target.exists(), "target must not appear on a failed write");
let leftovers = tmp_leftovers(dir.path());
assert!(
leftovers.is_empty(),
"temp siblings left behind: {leftovers:?}"
);
}
#[test]
fn write_atomic_with_mode_removes_the_temp_when_the_chmod_fails() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("target.sh");
let fs = FaultFs {
inner: OsFs::new(),
fail_write: false,
fail_chmod: true,
};
assert!(fs
.write_atomic_with_mode(&target, b"#!/bin/sh\n", 0o755)
.is_err());
assert!(!target.exists(), "target must not appear on a failed chmod");
let leftovers = tmp_leftovers(dir.path());
assert!(
leftovers.is_empty(),
"temp siblings left behind: {leftovers:?}"
);
}
}