use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
use crate::fs::Storage;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileOp {
Write {
path: PathBuf,
bytes: Vec<u8>,
},
Rename {
from: PathBuf,
to: PathBuf,
},
Remove {
path: PathBuf,
},
}
impl FileOp {
pub fn path(&self) -> &Path {
match self {
FileOp::Write { path, .. } => path,
FileOp::Rename { to, .. } => to,
FileOp::Remove { path } => path,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ChangeSet {
ops: Vec<FileOp>,
}
impl ChangeSet {
pub fn new() -> Self {
Self::default()
}
pub fn write(&mut self, path: impl Into<PathBuf>, contents: impl Into<Vec<u8>>) -> &mut Self {
self.ops.push(FileOp::Write {
path: path.into(),
bytes: contents.into(),
});
self
}
pub fn rename(&mut self, from: impl Into<PathBuf>, to: impl Into<PathBuf>) -> &mut Self {
self.ops.push(FileOp::Rename {
from: from.into(),
to: to.into(),
});
self
}
pub fn remove(&mut self, path: impl Into<PathBuf>) -> &mut Self {
self.ops.push(FileOp::Remove { path: path.into() });
self
}
pub fn ops(&self) -> &[FileOp] {
&self.ops
}
pub fn staged(&self, path: &Path) -> Option<&[u8]> {
self.ops.iter().rev().find_map(|op| match op {
FileOp::Write { path: p, bytes } if p == path => Some(bytes.as_slice()),
_ => None,
})
}
pub fn renamed_to(&self, path: &Path) -> Option<PathBuf> {
let mut current = path.to_path_buf();
let mut moved = false;
for op in &self.ops {
if let FileOp::Rename { from, to } = op
&& *from == current
{
current = to.clone();
moved = true;
}
}
moved.then_some(current)
}
pub fn is_empty(&self) -> bool {
self.ops.is_empty()
}
pub fn len(&self) -> usize {
self.ops.len()
}
pub fn extend(&mut self, other: ChangeSet) -> &mut Self {
self.ops.extend(other.ops);
self
}
pub async fn apply<FS: Storage>(&self, fs: &FS, root: &Path) -> Result<()> {
if self.ops.is_empty() {
return Ok(());
}
for op in &self.ops {
match op {
FileOp::Write { path, .. } | FileOp::Remove { path } => {
guard_in_root(path)?;
}
FileOp::Rename { from, to } => {
guard_in_root(from)?;
guard_in_root(to)?;
}
}
}
let journal = root.join(crate::journal::JOURNAL_NAME);
if fs.try_exists(&journal).await? {
return Err(Error::StaleJournal(journal));
}
fs.write_atomic(&journal, &crate::journal::encode(&self.ops)?)
.await?;
let mut undo: Vec<Undo> = Vec::new();
for op in &self.ops {
let Err(cause) = exec(fs, root, op, &mut undo).await else {
continue;
};
return Err(match unwind(fs, undo).await {
Ok(()) => match fs.remove_file(&journal).await {
Ok(()) => cause,
Err(cleanup) => Error::Torn {
cause: cause.to_string(),
rollback: cleanup.to_string(),
},
},
Err(rollback) => Error::Torn {
cause: cause.to_string(),
rollback: rollback.to_string(),
},
});
}
fs.remove_file(&journal).await?;
Ok(())
}
}
enum Undo {
Restore { path: PathBuf, bytes: Vec<u8> },
Delete { path: PathBuf },
Rename { from: PathBuf, to: PathBuf },
}
async fn exec<FS: Storage>(fs: &FS, root: &Path, op: &FileOp, undo: &mut Vec<Undo>) -> Result<()> {
match op {
FileOp::Write { path, bytes } => {
let full = root.join(path);
match fs.read(&full).await {
Ok(old) => undo.push(Undo::Restore {
path: full.clone(),
bytes: old,
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
undo.push(Undo::Delete { path: full.clone() });
}
Err(e) => return Err(e.into()),
}
ensure_parent(fs, &full).await?;
fs.write_atomic(&full, bytes).await?;
}
FileOp::Rename { from, to } => {
let (from_full, to_full) = (root.join(from), root.join(to));
ensure_parent(fs, &to_full).await?;
fs.rename(&from_full, &to_full).await?;
undo.push(Undo::Rename {
from: to_full,
to: from_full,
});
}
FileOp::Remove { path } => {
let full = root.join(path);
let old = fs.read(&full).await?;
fs.remove_file(&full).await?;
undo.push(Undo::Restore {
path: full,
bytes: old,
});
}
}
Ok(())
}
async fn unwind<FS: Storage>(fs: &FS, undo: Vec<Undo>) -> Result<()> {
let mut first_error = None;
for step in undo.into_iter().rev() {
let result = match step {
Undo::Restore { path, bytes } => fs.write(&path, &bytes).await,
Undo::Delete { path } => match fs.remove_file(&path).await {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
other => other,
},
Undo::Rename { from, to } => fs.rename(&from, &to).await,
};
if let Err(e) = result
&& first_error.is_none()
{
first_error = Some(e);
}
}
match first_error {
Some(e) => Err(e.into()),
None => Ok(()),
}
}
fn guard_in_root(path: &Path) -> Result<()> {
if crate::link::escapes_root(path) {
return Err(Error::Escape(path.to_path_buf()));
}
Ok(())
}
async fn ensure_parent<FS: Storage>(fs: &FS, full: &Path) -> Result<()> {
if let Some(dir) = full.parent() {
fs.create_dir_all(dir).await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::exec::block_on;
use crate::fs::{FailAtWrite, FsEvent, RecordingFs, StdFs};
use crate::journal::JOURNAL_NAME;
fn tmp(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("prov-change-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn read(root: &Path, rel: &str) -> Option<String> {
std::fs::read_to_string(root.join(rel)).ok()
}
#[test]
fn applies_every_op_in_order() {
let root = tmp("apply");
std::fs::write(root.join("parent.md"), "old parent").unwrap();
let mut cs = ChangeSet::new();
cs.write("child.md", "child");
cs.write("parent.md", "new parent");
block_on(cs.apply(&StdFs, &root)).unwrap();
assert_eq!(read(&root, "child.md").as_deref(), Some("child"));
assert_eq!(read(&root, "parent.md").as_deref(), Some("new parent"));
}
#[test]
fn creates_missing_parent_directories() {
let root = tmp("mkdir");
let mut cs = ChangeSet::new();
cs.write("deep/nested/child.md", "hi");
block_on(cs.apply(&StdFs, &root)).unwrap();
assert_eq!(read(&root, "deep/nested/child.md").as_deref(), Some("hi"));
}
#[test]
fn a_failed_write_restores_the_files_already_written() {
let root = tmp("rollback-write");
std::fs::write(root.join("parent.md"), "old parent").unwrap();
std::fs::write(root.join("child.md"), "old child").unwrap();
let mut cs = ChangeSet::new();
cs.write("child.md", "new child");
cs.write("parent.md", "new parent");
cs.write("third.md", "third");
let err = block_on(cs.apply(&FailAtWrite::nth(2), &root)).unwrap_err();
assert!(err.to_string().contains("disk full"), "{err}");
assert_eq!(read(&root, "child.md").as_deref(), Some("old child"));
assert_eq!(read(&root, "parent.md").as_deref(), Some("old parent"));
}
#[test]
fn a_failed_write_deletes_files_the_set_had_created() {
let root = tmp("rollback-create");
let mut cs = ChangeSet::new();
cs.write("fresh.md", "fresh");
cs.write("doomed.md", "doomed");
let err = block_on(cs.apply(&FailAtWrite::nth(1), &root)).unwrap_err();
assert!(err.to_string().contains("disk full"), "{err}");
assert_eq!(read(&root, "fresh.md"), None);
}
#[test]
fn a_clean_rollback_reports_the_cause_not_a_tear() {
let root = tmp("clean-rollback");
std::fs::write(root.join("existing.md"), "before").unwrap();
let mut cs = ChangeSet::new();
cs.write("existing.md", "after");
cs.write("brand-new.md", "never lands");
let err = block_on(cs.apply(&FailAtWrite::nth(1), &root)).unwrap_err();
assert!(
matches!(err, Error::Io(_)),
"a clean rollback should surface the cause itself, got: {err:?}"
);
assert_eq!(read(&root, "existing.md").as_deref(), Some("before"));
assert_eq!(read(&root, "brand-new.md"), None);
}
#[test]
fn a_failed_write_after_a_rename_moves_the_file_back() {
let root = tmp("rollback-rename");
std::fs::write(root.join("a.md"), "original").unwrap();
let mut cs = ChangeSet::new();
cs.rename("a.md", "sub/a.md");
cs.write("sub/a.md", "rewritten");
cs.write("parent.md", "never gets here");
let err = block_on(cs.apply(&FailAtWrite::nth(1), &root)).unwrap_err();
assert!(err.to_string().contains("disk full"), "{err}");
assert_eq!(read(&root, "a.md").as_deref(), Some("original"));
assert_eq!(read(&root, "sub/a.md"), None);
}
#[test]
fn a_failed_write_restores_a_removed_file() {
let root = tmp("rollback-remove");
std::fs::write(root.join("gone.md"), "precious").unwrap();
let mut cs = ChangeSet::new();
cs.remove("gone.md");
cs.write("parent.md", "boom");
let err = block_on(cs.apply(&FailAtWrite::nth(0), &root)).unwrap_err();
assert!(err.to_string().contains("disk full"), "{err}");
assert_eq!(read(&root, "gone.md").as_deref(), Some("precious"));
}
#[test]
fn every_document_write_lands_atomically_and_leaves_no_temp_files() {
let root = tmp("apply-atomic");
std::fs::write(root.join("parent.md"), "old parent").unwrap();
let fs = RecordingFs::local();
let mut cs = ChangeSet::new();
cs.write("child.md", "child");
cs.write("parent.md", "new parent");
block_on(cs.apply(&fs, &root)).unwrap();
assert_eq!(read(&root, "child.md").as_deref(), Some("child"));
assert_eq!(read(&root, "parent.md").as_deref(), Some("new parent"));
for event in fs.events() {
if let FsEvent::Write(p) = event {
let name = p.file_name().unwrap().to_string_lossy();
assert!(
name.contains("prov-tmp"),
"wrote a document non-atomically: {name}"
);
}
}
let leftovers: Vec<_> = std::fs::read_dir(&root)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.filter(|n| n.contains("prov-tmp"))
.collect();
assert!(
leftovers.is_empty(),
"staging files survived apply: {leftovers:?}"
);
}
#[test]
fn apply_journals_before_touching_documents_and_clears_it_after() {
let root = tmp("journal-order");
std::fs::write(root.join("parent.md"), "old parent").unwrap();
let fs = RecordingFs::local();
let mut cs = ChangeSet::new();
cs.write("child.md", "child");
cs.write("parent.md", "new parent");
block_on(cs.apply(&fs, &root)).unwrap();
let events = fs.events();
let journal = root.join(JOURNAL_NAME);
let journal_committed = events
.iter()
.position(|e| matches!(e, FsEvent::Rename(_, to) if *to == journal))
.expect("journal must be committed");
let first_doc_write = events
.iter()
.position(|e| matches!(e, FsEvent::Write(p) if !crate::journal::is_journal_path(p)))
.expect("a document must be written");
assert!(
journal_committed < first_doc_write,
"the journal must be durable before any document is touched"
);
assert_eq!(events.last(), Some(&FsEvent::Remove(journal.clone())));
assert!(!journal.exists());
}
#[test]
fn a_caught_error_reverts_and_leaves_no_journal_behind() {
let root = tmp("journal-abort");
std::fs::write(root.join("existing.md"), "before").unwrap();
let mut cs = ChangeSet::new();
cs.write("existing.md", "after");
cs.write("brand-new.md", "never lands");
let err = block_on(cs.apply(&FailAtWrite::nth(1), &root)).unwrap_err();
assert!(err.to_string().contains("disk full"), "{err}");
assert_eq!(read(&root, "existing.md").as_deref(), Some("before"));
assert_eq!(read(&root, "brand-new.md"), None);
assert!(
!root.join(JOURNAL_NAME).exists(),
"a cleanly-reverted change must not leave a journal to roll forward"
);
}
#[test]
fn a_crash_mid_apply_is_recovered_forward_from_the_journal() {
let root = tmp("journal-crash");
std::fs::write(root.join("parent.md"), "old parent").unwrap();
let mut cs = ChangeSet::new();
cs.write("child.md", "child");
cs.write("parent.md", "new parent");
std::fs::write(
root.join(JOURNAL_NAME),
crate::journal::encode(cs.ops()).unwrap(),
)
.unwrap();
std::fs::write(root.join("child.md"), "child").unwrap();
let outcome = block_on(crate::journal::recover(&StdFs, &root)).unwrap();
assert_eq!(outcome, crate::journal::Recovered::Applied(2));
assert_eq!(read(&root, "child.md").as_deref(), Some("child"));
assert_eq!(read(&root, "parent.md").as_deref(), Some("new parent"));
assert!(!root.join(JOURNAL_NAME).exists());
}
#[test]
fn apply_refuses_a_path_that_escapes_the_root() {
let root = tmp("escape-write");
let mut cs = ChangeSet::new();
cs.write("../escape.md", "should never land");
let err = block_on(cs.apply(&StdFs, &root)).unwrap_err();
assert!(
matches!(err, Error::Escape(_)),
"expected Escape, got {err:?}"
);
assert!(!root.parent().unwrap().join("escape.md").exists());
assert!(!root.join(JOURNAL_NAME).exists());
}
#[test]
fn apply_refuses_an_absolute_path() {
let root = tmp("escape-abs");
let mut cs = ChangeSet::new();
cs.write("/tmp/prov-abs-escape-should-not-exist.md", "nope");
let err = block_on(cs.apply(&StdFs, &root)).unwrap_err();
assert!(
matches!(err, Error::Escape(_)),
"expected Escape, got {err:?}"
);
}
#[test]
fn apply_refuses_to_clobber_a_stale_journal() {
let root = tmp("stale-journal");
std::fs::write(root.join("doc.md"), "before").unwrap();
let prior = vec![FileOp::Write {
path: "other.md".into(),
bytes: b"prior".to_vec(),
}];
std::fs::write(
root.join(JOURNAL_NAME),
crate::journal::encode(&prior).unwrap(),
)
.unwrap();
let mut cs = ChangeSet::new();
cs.write("doc.md", "after");
let err = block_on(cs.apply(&StdFs, &root)).unwrap_err();
assert!(
matches!(err, Error::StaleJournal(_)),
"expected StaleJournal, got {err:?}"
);
assert_eq!(read(&root, "doc.md").as_deref(), Some("before"));
assert!(root.join(JOURNAL_NAME).exists());
}
#[test]
fn apply_proceeds_once_the_stale_journal_is_recovered() {
let root = tmp("stale-journal-cleared");
std::fs::write(root.join("doc.md"), "before").unwrap();
let prior = vec![FileOp::Write {
path: "other.md".into(),
bytes: b"prior".to_vec(),
}];
std::fs::write(
root.join(JOURNAL_NAME),
crate::journal::encode(&prior).unwrap(),
)
.unwrap();
block_on(crate::journal::recover(&StdFs, &root)).unwrap();
let mut cs = ChangeSet::new();
cs.write("doc.md", "after");
block_on(cs.apply(&StdFs, &root)).unwrap();
assert_eq!(read(&root, "doc.md").as_deref(), Some("after"));
assert_eq!(read(&root, "other.md").as_deref(), Some("prior"));
}
#[test]
fn staged_ops_are_readable_without_applying() {
let root = tmp("dry-run");
let mut cs = ChangeSet::new();
cs.write("child.md", "child");
cs.remove("old.md");
assert_eq!(cs.len(), 2);
assert_eq!(
cs.ops().iter().map(FileOp::path).collect::<Vec<_>>(),
[Path::new("child.md"), Path::new("old.md")]
);
assert_eq!(read(&root, "child.md"), None);
}
}