use std::fs::{self, OpenOptions};
use std::io::{self, Write};
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use skif::{FileChange, FixPlan};
use crate::error::{CliError, Kind};
enum Job<'a> {
Write {
target: PathBuf,
bytes: &'a [u8],
original: Option<Vec<u8>>,
},
Remove {
target: PathBuf,
original: Vec<u8>,
},
}
enum Undo {
Restore { target: PathBuf, bytes: Vec<u8> },
Remove { target: PathBuf },
}
struct Temp(Option<PathBuf>);
impl Drop for Temp {
fn drop(&mut self) {
if let Some(path) = self.0.take() {
let _ = fs::remove_file(path);
}
}
}
#[derive(Default)]
struct CreatedDirs {
dirs: Vec<PathBuf>,
keep: bool,
}
impl Drop for CreatedDirs {
fn drop(&mut self) {
if !self.keep {
for dir in self.dirs.iter().rev() {
let _ = fs::remove_dir(dir);
}
}
}
}
pub fn write_plan(plan: &FixPlan, root: &Path) -> Result<(), CliError> {
let root = root
.canonicalize()
.map_err(|error| CliError::io("cannot resolve", root, &error))?;
let jobs = plan
.changes
.iter()
.map(|change| prepare(plan, change, &root))
.collect::<Result<Vec<_>, _>>()?;
let mut dirs = CreatedDirs::default();
let mut staged: Vec<(Temp, &Job<'_>)> = Vec::new();
for job in &jobs {
if let Job::Write {
target,
bytes,
original,
} = job
{
let temp = stage(target, bytes, original.is_none(), &mut dirs)
.map_err(|error| CliError::io("cannot write", target, &error))?;
staged.push((temp, job));
}
}
let mut undo: Vec<Undo> = Vec::new();
let committed = commit(&mut staged, &jobs, &mut undo);
if let Err(error) = committed {
rollback(undo);
return Err(error);
}
dirs.keep = true;
for job in &jobs {
sync_parent(match job {
Job::Write { target, .. } | Job::Remove { target, .. } => target,
});
}
Ok(())
}
fn sync_parent(target: &Path) {
if let Some(parent) = target.parent() {
let _ = fs::File::open(parent).and_then(|dir| dir.sync_all());
}
}
fn commit(staged: &mut [(Temp, &Job<'_>)], jobs: &[Job<'_>], undo: &mut Vec<Undo>) -> Result<(), CliError> {
for (temp, job) in staged.iter_mut() {
let Job::Write { target, original, .. } = job else {
continue;
};
let Some(path) = temp.0.take() else {
continue;
};
if let Err(error) = fs::rename(&path, target) {
temp.0 = Some(path);
return Err(CliError::io("cannot write", target, &error));
}
undo.push(match original {
Some(bytes) => Undo::Restore {
target: target.clone(),
bytes: bytes.clone(),
},
None => Undo::Remove { target: target.clone() },
});
}
for job in jobs {
if let Job::Remove { target, original } = job {
match fs::remove_file(target) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(CliError::io("cannot delete", target, &error)),
}
undo.push(Undo::Restore {
target: target.clone(),
bytes: original.clone(),
});
}
}
Ok(())
}
fn rollback(undo: Vec<Undo>) {
for step in undo.into_iter().rev() {
let _ = match step {
Undo::Restore { target, bytes } => fs::write(target, bytes),
Undo::Remove { target } => fs::remove_file(target),
};
}
}
fn prepare<'a>(plan: &FixPlan, change: &'a FileChange, root: &Path) -> Result<Job<'a>, CliError> {
match change {
FileChange::Modify { path, new_contents, .. } => {
let target = target(root, path)?;
let original = fs::read(&target).map_err(|error| CliError::io("cannot read", path, &error))?;
refuse_if_stale(plan, path, &original, "modify")?;
Ok(Job::Write {
target,
bytes: new_contents.as_bytes(),
original: Some(original),
})
}
FileChange::Create { path, contents } => {
let target = target(root, path)?;
if fs::symlink_metadata(&target).is_ok() {
return Err(CliError::new(
Kind::Io,
format!("refusing to create {}: it already exists", path.display()),
));
}
Ok(Job::Write {
target,
bytes: contents.as_bytes(),
original: None,
})
}
FileChange::Delete { path } => {
let target = target(root, path)?;
match fs::read(&target) {
Ok(original) => {
refuse_if_stale(plan, path, &original, "delete")?;
Ok(Job::Remove { target, original })
}
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Job::Remove {
target,
original: Vec::new(),
}),
Err(error) => Err(CliError::io("cannot read", path, &error)),
}
}
_ => Err(CliError::new(
Kind::Io,
"the fix plan holds a change this skif cannot write",
)),
}
}
fn refuse_if_stale(plan: &FixPlan, path: &Path, current: &[u8], action: &str) -> Result<(), CliError> {
if plan
.base_hash(path)
.is_some_and(|base| skif::content_hash(current) != base)
{
return Err(CliError::new(
Kind::Io,
format!(
"refusing to {action} {}: it changed after the fixes were planned",
path.display()
),
));
}
Ok(())
}
fn target(root: &Path, relative: &Path) -> Result<PathBuf, CliError> {
let outside = || {
CliError::new(
Kind::Io,
format!("refusing to write outside the workspace: {}", relative.display()),
)
};
if relative.as_os_str().is_empty()
|| !relative
.components()
.all(|component| matches!(component, Component::Normal(_)))
{
return Err(outside());
}
let target = root.join(relative);
let nearest = target
.ancestors()
.skip(1)
.find(|dir| dir.exists())
.ok_or_else(outside)?
.canonicalize()
.map_err(|error| CliError::io("cannot resolve", relative, &error))?;
if !nearest.starts_with(root) {
return Err(outside());
}
if fs::symlink_metadata(&target).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
return Err(CliError::new(
Kind::Io,
format!("refusing to write through a symlink: {}", relative.display()),
));
}
Ok(target)
}
static COUNTER: AtomicUsize = AtomicUsize::new(0);
fn temp_path(target: &Path) -> PathBuf {
let sequence = COUNTER.fetch_add(1, Ordering::Relaxed);
target.with_file_name(format!(".{}.{sequence}.skif-tmp", std::process::id()))
}
fn create_parents(target: &Path, dirs: &mut CreatedDirs) -> io::Result<()> {
let missing: Vec<&Path> = target
.ancestors()
.skip(1)
.take_while(|dir| !dir.as_os_str().is_empty() && !dir.exists())
.collect();
for dir in missing.into_iter().rev() {
fs::create_dir(dir)?;
dirs.dirs.push(dir.to_path_buf());
}
Ok(())
}
fn stage(target: &Path, bytes: &[u8], create: bool, dirs: &mut CreatedDirs) -> io::Result<Temp> {
if create {
create_parents(target, dirs)?;
}
let path = temp_path(target);
let mut file = OpenOptions::new().write(true).create_new(true).open(&path)?;
let temp = Temp(Some(path));
file.write_all(bytes)?;
if let Ok(metadata) = fs::metadata(target) {
let _ = file.set_permissions(metadata.permissions());
}
file.sync_all()?;
Ok(temp)
}
pub fn write_atomic(target: &Path, bytes: &[u8]) -> io::Result<()> {
let destination = match fs::metadata(target) {
Ok(metadata) if metadata.is_file() => fs::canonicalize(target)?,
Ok(_) => return write_through(target, bytes),
Err(error) if error.kind() == io::ErrorKind::NotFound => target.to_path_buf(),
Err(error) => return Err(error),
};
let mut dirs = CreatedDirs::default();
let mut temp = stage(&destination, bytes, false, &mut dirs)?;
let path = temp.0.take().unwrap_or_default();
fs::rename(&path, &destination).inspect_err(|_| {
let _ = fs::remove_file(&path);
})?;
sync_parent(&destination);
Ok(())
}
fn write_through(target: &Path, bytes: &[u8]) -> io::Result<()> {
let mut file = OpenOptions::new().write(true).open(target)?;
file.write_all(bytes)?;
file.flush()
}
#[cfg(test)]
mod tests {
use super::*;
fn modify(path: &str, contents: &str) -> FileChange {
FileChange::Modify {
path: path.into(),
edits: Vec::new(),
new_contents: contents.to_owned(),
}
}
fn plan(changes: Vec<FileChange>) -> FixPlan {
let mut plan = FixPlan::default();
plan.changes = changes;
plan
}
fn leftovers(dir: &Path) -> Vec<String> {
fs::read_dir(dir)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.filter(|name| name.ends_with(".skif-tmp"))
.collect()
}
#[test]
fn atomic_write_replaces_the_file_and_leaves_no_temporary() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.json"), "old").unwrap();
write_atomic(&dir.path().join("a.json"), b"new").unwrap();
assert_eq!(fs::read_to_string(dir.path().join("a.json")).unwrap(), "new");
assert_eq!(leftovers(dir.path()), [] as [std::string::String; 0]);
}
#[cfg(unix)]
#[test]
fn atomic_write_keeps_a_symlink_and_updates_its_target() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("real.json"), "old").unwrap();
std::os::unix::fs::symlink("real.json", dir.path().join("link.json")).unwrap();
write_atomic(&dir.path().join("link.json"), b"new").unwrap();
assert!(
fs::symlink_metadata(dir.path().join("link.json"))
.unwrap()
.file_type()
.is_symlink()
);
assert_eq!(fs::read_to_string(dir.path().join("real.json")).unwrap(), "new");
assert_eq!(leftovers(dir.path()), [] as [std::string::String; 0]);
}
#[cfg(unix)]
#[test]
fn atomic_write_goes_through_a_device_instead_of_replacing_it() {
write_atomic(Path::new("/dev/null"), b"discarded").unwrap();
assert!(!fs::metadata("/dev/null").unwrap().is_file());
}
#[test]
fn rejects_paths_that_escape_the_root() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
assert!(target(&root, Path::new("../x")).is_err());
assert!(target(&root, Path::new("/abs")).is_err());
assert!(target(&root, Path::new("")).is_err());
assert_eq!(
target(&root, Path::new("a/b.json")).unwrap(),
root.join("a").join("b.json")
);
}
#[test]
fn one_bad_target_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.json"), "old").unwrap();
let bad = plan(vec![modify("a.json", "new"), modify("../escape.json", "x")]);
assert!(write_plan(&bad, dir.path()).is_err());
assert_eq!(fs::read_to_string(dir.path().join("a.json")).unwrap(), "old");
assert_eq!(leftovers(dir.path()), [] as [std::string::String; 0]);
}
#[test]
fn a_failure_while_staging_leaves_the_tree_untouched() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.json"), "old").unwrap();
fs::write(dir.path().join("blocker"), "file").unwrap();
let failing = plan(vec![
modify("a.json", "new"),
FileChange::Create {
path: Path::new("blocker").join("nested").join("b.json"),
contents: "{}".to_owned(),
},
]);
assert!(write_plan(&failing, dir.path()).is_err());
assert_eq!(fs::read_to_string(dir.path().join("a.json")).unwrap(), "old");
assert_eq!(leftovers(dir.path()), [] as [std::string::String; 0]);
}
#[test]
fn a_file_that_changed_after_planning_is_refused() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.json"), "edited since").unwrap();
let stale = plan(vec![modify("a.json", "new")]).with_base_hash("a.json", skif::content_hash(b"as planned"));
let error = write_plan(&stale, dir.path()).unwrap_err();
assert!(
error.to_string().contains("changed after the fixes were planned"),
"{error}"
);
assert_eq!(fs::read_to_string(dir.path().join("a.json")).unwrap(), "edited since");
let fresh = plan(vec![modify("a.json", "new")]).with_base_hash("a.json", skif::content_hash(b"edited since"));
write_plan(&fresh, dir.path()).unwrap();
assert_eq!(fs::read_to_string(dir.path().join("a.json")).unwrap(), "new");
}
#[test]
fn a_deleted_file_that_changed_after_planning_is_refused() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("gone.json"), "edited since").unwrap();
let delete = |hash_of: &[u8]| {
plan(vec![FileChange::Delete {
path: "gone.json".into(),
}])
.with_base_hash("gone.json", skif::content_hash(hash_of))
};
let error = write_plan(&delete(b"as planned"), dir.path()).unwrap_err();
assert!(error.to_string().contains("refusing to delete"), "{error}");
assert!(dir.path().join("gone.json").exists());
write_plan(&delete(b"edited since"), dir.path()).unwrap();
assert!(!dir.path().join("gone.json").exists());
}
#[test]
fn creating_over_an_existing_file_is_refused() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.json"), "old").unwrap();
let create = plan(vec![FileChange::Create {
path: "a.json".into(),
contents: "new".to_owned(),
}]);
assert!(write_plan(&create, dir.path()).is_err());
assert_eq!(fs::read_to_string(dir.path().join("a.json")).unwrap(), "old");
}
#[test]
fn created_files_get_their_directories() {
let dir = tempfile::tempdir().unwrap();
let create = plan(vec![FileChange::Create {
path: Path::new("new").join("deep").join("c.json"),
contents: "{}".to_owned(),
}]);
write_plan(&create, dir.path()).unwrap();
assert_eq!(
fs::read_to_string(dir.path().join("new").join("deep").join("c.json")).unwrap(),
"{}"
);
}
#[cfg(unix)]
#[test]
fn symlinks_out_of_the_root_are_not_followed() {
let outside = tempfile::tempdir().unwrap();
fs::write(outside.path().join("secret.json"), "secret").unwrap();
let dir = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(outside.path().join("secret.json"), dir.path().join("link.json")).unwrap();
std::os::unix::fs::symlink(outside.path(), dir.path().join("linked-dir")).unwrap();
let through_file = plan(vec![modify("link.json", "pwned")]);
assert!(write_plan(&through_file, dir.path()).is_err());
let through_dir = plan(vec![modify("linked-dir/secret.json", "pwned")]);
assert!(write_plan(&through_dir, dir.path()).is_err());
assert_eq!(
fs::read_to_string(outside.path().join("secret.json")).unwrap(),
"secret"
);
}
}