use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
use crate::fs::Storage;
use crate::journal::Journal;
use crate::path::guard_in_root;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileOp {
Write {
path: PathBuf,
bytes: Vec<u8>,
},
Rename {
from: PathBuf,
to: PathBuf,
},
Remove {
path: PathBuf,
},
CopyFrom {
path: PathBuf,
source: PathBuf,
},
SetExecutable {
path: PathBuf,
executable: bool,
},
SetLink {
path: PathBuf,
target: PathBuf,
},
}
impl FileOp {
pub fn path(&self) -> &Path {
match self {
FileOp::Write { path, .. }
| FileOp::CopyFrom { path, .. }
| FileOp::SetExecutable { path, .. }
| FileOp::SetLink { 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 copy_from(&mut self, path: impl Into<PathBuf>, source: impl Into<PathBuf>) -> &mut Self {
self.ops.push(FileOp::CopyFrom {
path: path.into(),
source: source.into(),
});
self
}
pub fn set_executable(&mut self, path: impl Into<PathBuf>, executable: bool) -> &mut Self {
self.ops.push(FileOp::SetExecutable {
path: path.into(),
executable,
});
self
}
pub fn set_link(&mut self, path: impl Into<PathBuf>, target: impl Into<PathBuf>) -> &mut Self {
self.ops.push(FileOp::SetLink {
path: path.into(),
target: target.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<()> {
Journal::default().apply(self, fs, root).await
}
}
impl Journal {
pub async fn apply<FS: Storage>(
&self,
changes: &ChangeSet,
fs: &FS,
root: &Path,
) -> Result<()> {
if changes.ops.is_empty() {
return Ok(());
}
guard_ops(&changes.ops)?;
let journal = self.path_in(root);
if fs.try_exists(&journal).await? {
return Err(Error::StaleJournal(journal));
}
if changes.ops.len() == 1
&& fs.capabilities().atomic_replace
&& !matches!(changes.ops[0], FileOp::SetLink { .. })
{
let mut touched = BTreeSet::new();
exec(fs, root, &changes.ops[0], None, &mut touched).await?;
return Ok(crate::fs::flush_all_durable(fs, touched, root).await?);
}
if let Some(home) = self.home() {
for made in crate::fs::create_dir_all_traced(fs, home).await? {
fs.sync(&made, crate::fs::Durability::Durable).await?;
}
}
fs.write_atomic(&journal, &crate::journal::encode(&changes.ops)?)
.await?;
let mut undo: Vec<Undo> = Vec::new();
let mut touched = BTreeSet::new();
let mut cause: Option<Error> = None;
for op in &changes.ops {
if let Err(e) = exec(fs, root, op, Some(&mut undo), &mut touched).await {
cause = Some(e);
break;
}
}
if cause.is_none()
&& let Err(e) = crate::fs::flush_all_durable(fs, touched, root).await
{
cause = Some(e.into());
}
if let Some(cause) = cause {
return Err(match unwind_durable(fs, undo, root, &journal).await {
Ok(()) => cause,
Err(rollback) => Error::Torn {
cause: cause.to_string(),
rollback: rollback.to_string(),
},
});
}
match fs.remove_file(&journal).await {
Ok(()) => Ok(()),
Err(e) => Err(Error::Torn {
cause: format!(
"the set applied and was certified durable, but its journal \
could not be retired: {e}"
),
rollback: "the surviving journal will be replayed idempotently and \
cleared by the next recovery"
.to_string(),
}),
}
}
}
async fn unwind_durable<FS: Storage>(
fs: &FS,
undo: Vec<Undo>,
root: &Path,
journal: &Path,
) -> Result<()> {
let mut first_error: Option<std::io::Error> = None;
let mut dirs: BTreeSet<PathBuf> = BTreeSet::new();
for step in undo.into_iter().rev() {
let result = match step {
Undo::Restore { path, bytes } => {
if let Some(dir) = crate::fs::parent_dir(&path) {
dirs.insert(dir.to_path_buf());
}
match fs.write(&path, &bytes).await {
Ok(()) => fs.sync(&path, crate::fs::Durability::Ordered).await,
e => e,
}
}
Undo::Delete { path } => {
if let Some(dir) = crate::fs::parent_dir(&path) {
dirs.insert(dir.to_path_buf());
}
match fs.remove_file(&path).await {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
other => other,
}
}
Undo::Rename { from, to } => {
for side in [&from, &to] {
if let Some(dir) = crate::fs::parent_dir(side) {
dirs.insert(dir.to_path_buf());
}
}
fs.rename(&from, &to).await
}
Undo::SetExecutable { path, executable } => {
match fs.set_executable(&path, executable).await {
Ok(()) => fs.sync(&path, crate::fs::Durability::Ordered).await,
e => e,
}
}
Undo::Relink { path, target } => {
if let Some(dir) = crate::fs::parent_dir(&path) {
dirs.insert(dir.to_path_buf());
}
fs.set_link(&path, &target).await
}
Undo::RestoreOverLink { path, bytes } => {
if let Some(dir) = crate::fs::parent_dir(&path) {
dirs.insert(dir.to_path_buf());
}
let removed = match fs.remove_file(&path).await {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
other => other,
};
match removed {
Ok(()) => match fs.write(&path, &bytes).await {
Ok(()) => fs.sync(&path, crate::fs::Durability::Ordered).await,
e => e,
},
Err(e) => Err(e),
}
}
};
if let Err(e) = result
&& first_error.is_none()
{
first_error = Some(e);
}
}
if let Some(e) = first_error {
return Err(e.into());
}
for dir in dirs {
fs.sync(&dir, crate::fs::Durability::Ordered).await?;
}
let jparent = crate::fs::parent_dir(journal);
if jparent != Some(root) {
fs.sync(root, crate::fs::Durability::Durable).await?;
}
fs.remove_file(journal).await?;
match jparent {
Some(dir) => Ok(fs.sync(dir, crate::fs::Durability::Durable).await?),
None => Ok(()),
}
}
enum Undo {
Restore { path: PathBuf, bytes: Vec<u8> },
Delete { path: PathBuf },
Rename { from: PathBuf, to: PathBuf },
SetExecutable { path: PathBuf, executable: bool },
Relink { path: PathBuf, target: PathBuf },
RestoreOverLink { path: PathBuf, bytes: Vec<u8> },
}
async fn exec<FS: Storage>(
fs: &FS,
root: &Path,
op: &FileOp,
mut undo: Option<&mut Vec<Undo>>,
touched: &mut BTreeSet<PathBuf>,
) -> Result<()> {
match op {
FileOp::Write { path, bytes } => {
let full = root.join(path);
if let Some(undo) = undo {
capture_replaced(fs, &full, undo).await?;
}
ensure_parent(fs, &full, touched).await?;
fs.replace(&full, bytes).await?;
settle_write_debt(fs, &full, touched).await?;
}
FileOp::Rename { from, to } => {
let (from_full, to_full) = (root.join(from), root.join(to));
if let Some(undo) = undo.as_deref_mut() {
capture_replaced(fs, &to_full, undo).await?;
}
ensure_parent(fs, &to_full, touched).await?;
fs.rename(&from_full, &to_full).await?;
for side in [&from_full, &to_full] {
if let Some(dir) = crate::fs::parent_dir(side) {
touched.insert(dir.to_path_buf());
}
}
if let Some(undo) = undo {
undo.push(Undo::Rename {
from: to_full,
to: from_full,
});
}
}
FileOp::Remove { path } => {
let full = root.join(path);
if let Some(dir) = crate::fs::parent_dir(&full) {
touched.insert(dir.to_path_buf());
}
match undo {
Some(undo) => match fs.read_link(&full).await {
Ok(Some(target)) => {
fs.remove_file(&full).await?;
undo.push(Undo::Relink { path: full, target });
}
_ => {
let old = fs.read(&full).await?;
fs.remove_file(&full).await?;
undo.push(Undo::Restore {
path: full,
bytes: old,
});
}
},
None => fs.remove_file(&full).await?,
}
}
FileOp::CopyFrom { path, source } => {
let (full, source_full) = (root.join(path), root.join(source));
let bytes = fs.read(&source_full).await?;
if let Some(undo) = undo {
capture_replaced(fs, &full, undo).await?;
}
ensure_parent(fs, &full, touched).await?;
fs.replace(&full, &bytes).await?;
settle_write_debt(fs, &full, touched).await?;
}
FileOp::SetExecutable { path, executable } => {
let full = root.join(path);
guard_not_link(fs, &full).await?;
if let Some(undo) = undo {
if let Some(was) = fs.executable(&full).await? {
undo.push(Undo::SetExecutable {
path: full.clone(),
executable: was,
});
}
}
fs.set_executable(&full, *executable).await?;
fs.sync(&full, crate::fs::Durability::Ordered).await?;
if let Some(dir) = crate::fs::parent_dir(&full) {
touched.insert(dir.to_path_buf());
}
}
FileOp::SetLink { path, target } => {
let full = root.join(path);
if let Some(undo) = undo {
match fs.read_link(&full).await {
Ok(Some(old_target)) => undo.push(Undo::Relink {
path: full.clone(),
target: old_target,
}),
Ok(None) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
undo.push(Undo::Delete { path: full.clone() });
}
Err(_) => {
let old = fs.read(&full).await?;
undo.push(Undo::RestoreOverLink {
path: full.clone(),
bytes: old,
});
}
}
}
ensure_parent(fs, &full, touched).await?;
fs.set_link(&full, target).await?;
if let Some(dir) = crate::fs::parent_dir(&full) {
touched.insert(dir.to_path_buf());
}
}
}
Ok(())
}
pub(crate) async fn settle_write_debt<FS: Storage>(
fs: &FS,
full: &Path,
touched: &mut BTreeSet<PathBuf>,
) -> Result<()> {
if !fs.capabilities().atomic_replace {
fs.sync(full, crate::fs::Durability::Ordered).await?;
}
if let Some(dir) = crate::fs::parent_dir(full) {
touched.insert(dir.to_path_buf());
}
Ok(())
}
pub(crate) async fn guard_not_link<FS: Storage>(fs: &FS, full: &Path) -> Result<()> {
if let Ok(Some(_)) = fs.read_link(full).await {
return Err(Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"refusing to set the execute bit through the symbolic link at {}",
full.display()
),
)));
}
Ok(())
}
async fn capture_replaced<FS: Storage>(fs: &FS, full: &Path, undo: &mut Vec<Undo>) -> Result<()> {
match fs.read_link(full).await {
Ok(Some(target)) => undo.push(Undo::Relink {
path: full.to_path_buf(),
target,
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
undo.push(Undo::Delete {
path: full.to_path_buf(),
});
}
_ => match fs.read(full).await {
Ok(old) => undo.push(Undo::Restore {
path: full.to_path_buf(),
bytes: old,
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
undo.push(Undo::Delete {
path: full.to_path_buf(),
});
}
Err(e) => return Err(e.into()),
},
}
Ok(())
}
pub(crate) fn guard_ops(ops: &[FileOp]) -> Result<()> {
for op in ops {
match op {
FileOp::Write { path, .. }
| FileOp::Remove { path }
| FileOp::SetExecutable { path, .. }
| FileOp::SetLink { path, .. } => {
guard_in_root(path)?;
}
FileOp::Rename { from, to } => {
guard_in_root(from)?;
guard_in_root(to)?;
}
FileOp::CopyFrom { path, source } => {
guard_in_root(path)?;
guard_in_root(source)?;
}
}
}
Ok(())
}
async fn ensure_parent<FS: Storage>(
fs: &FS,
full: &Path,
touched: &mut BTreeSet<PathBuf>,
) -> Result<()> {
if let Some(dir) = crate::fs::parent_dir(full) {
for made in crate::fs::create_dir_all_traced(fs, dir).await? {
touched.insert(made);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::exec::block_on;
use crate::fs::{ReadStorage, StdFs};
use crate::fs_faults::{FailAtWrite, FsEvent, RecordingFs};
use crate::journal::Journal;
fn tmp(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("fstx-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_copy_lands_the_source_bytes_and_leaves_the_source_alone() {
let root = tmp("copy");
std::fs::create_dir_all(root.join("history/blobs/9f")).unwrap();
std::fs::write(root.join("history/blobs/9f/86d081"), "captured").unwrap();
std::fs::write(root.join("notes.md"), "damaged").unwrap();
let mut cs = ChangeSet::new();
cs.copy_from("notes.md", "history/blobs/9f/86d081");
cs.copy_from("deep/fresh.md", "history/blobs/9f/86d081");
block_on(cs.apply(&StdFs, &root)).unwrap();
assert_eq!(read(&root, "notes.md").as_deref(), Some("captured"));
assert_eq!(read(&root, "deep/fresh.md").as_deref(), Some("captured"));
assert_eq!(
read(&root, "history/blobs/9f/86d081").as_deref(),
Some("captured")
);
}
#[test]
fn a_failed_copy_rolls_back_exactly_as_a_failed_write_does() {
let root = tmp("rollback-copy");
std::fs::create_dir_all(root.join("history/blobs/9f")).unwrap();
std::fs::write(root.join("history/blobs/9f/86d081"), "captured").unwrap();
std::fs::write(root.join("notes.md"), "damaged").unwrap();
let mut cs = ChangeSet::new();
cs.copy_from("notes.md", "history/blobs/9f/86d081");
cs.copy_from("fresh.md", "history/blobs/9f/86d081");
cs.write("doomed.md", "never lands");
let err = block_on(cs.apply(&FailAtWrite::nth(2), &root)).unwrap_err();
assert!(err.to_string().contains("disk full"), "{err}");
assert_eq!(read(&root, "notes.md").as_deref(), Some("damaged"));
assert_eq!(read(&root, "fresh.md"), None);
}
#[test]
fn a_copy_from_a_missing_source_fails_before_the_target_is_touched() {
let root = tmp("copy-missing-source");
std::fs::write(root.join("notes.md"), "damaged").unwrap();
let mut cs = ChangeSet::new();
cs.copy_from("notes.md", "history/blobs/9f/86d081");
assert!(block_on(cs.apply(&StdFs, &root)).is_err());
assert_eq!(read(&root, "notes.md").as_deref(), Some("damaged"));
}
#[test]
fn a_copy_cannot_read_from_outside_the_root() {
let root = tmp("copy-escape");
let mut cs = ChangeSet::new();
cs.copy_from("stolen.md", "../../../etc/passwd");
let err = block_on(cs.apply(&StdFs, &root)).unwrap_err();
assert!(matches!(err, Error::Escape(_)), "{err:?}");
assert_eq!(read(&root, "stolen.md"), None);
}
#[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);
}
#[cfg(unix)]
fn is_executable(root: &Path, rel: &str) -> bool {
use std::os::unix::fs::PermissionsExt as _;
std::fs::metadata(root.join(rel))
.unwrap()
.permissions()
.mode()
& 0o111
!= 0
}
#[cfg(unix)]
#[test]
fn sets_and_clears_the_execute_bit() {
let root = tmp("exec-bit");
std::fs::write(root.join("run.sh"), "#!/bin/sh").unwrap();
std::fs::write(root.join("plain.md"), "notes").unwrap();
let mut cs = ChangeSet::new();
cs.set_executable("run.sh", true);
cs.set_executable("plain.md", false);
block_on(cs.apply(&StdFs, &root)).unwrap();
assert!(is_executable(&root, "run.sh"));
assert!(!is_executable(&root, "plain.md"));
}
#[cfg(unix)]
#[test]
fn a_failed_set_restores_the_execute_bit_it_flipped() {
let root = tmp("rollback-exec");
std::fs::write(root.join("run.sh"), "#!/bin/sh").unwrap();
assert!(!is_executable(&root, "run.sh"));
let mut cs = ChangeSet::new();
cs.set_executable("run.sh", true);
cs.write("doomed.md", "never lands");
let err = block_on(cs.apply(&FailAtWrite::nth(0), &root)).unwrap_err();
assert!(err.to_string().contains("disk full"), "{err}");
assert!(
!is_executable(&root, "run.sh"),
"the bit must roll back to what was, not stay flipped"
);
}
#[cfg(unix)]
#[test]
fn a_bit_already_in_the_requested_state_rolls_back_to_itself() {
use std::os::unix::fs::PermissionsExt as _;
let root = tmp("rollback-exec-noop");
std::fs::write(root.join("run.sh"), "#!/bin/sh").unwrap();
let mut perms = std::fs::metadata(root.join("run.sh"))
.unwrap()
.permissions();
perms.set_mode(perms.mode() | 0o100);
std::fs::set_permissions(root.join("run.sh"), perms).unwrap();
let mut cs = ChangeSet::new();
cs.set_executable("run.sh", true); cs.write("doomed.md", "never lands");
block_on(cs.apply(&FailAtWrite::nth(0), &root)).unwrap_err();
assert!(
is_executable(&root, "run.sh"),
"rolling back a no-op flip must not clear a bit the set never set"
);
}
#[cfg(unix)]
#[test]
fn lands_a_link_and_reads_nothing_through_it() {
let root = tmp("link");
let mut cs = ChangeSet::new();
cs.set_link("here.md", "nowhere/yet.md");
cs.set_link("out.md", "../elsewhere.md");
block_on(cs.apply(&StdFs, &root)).unwrap();
assert_eq!(
std::fs::read_link(root.join("here.md")).unwrap(),
PathBuf::from("nowhere/yet.md")
);
assert_eq!(
std::fs::read_link(root.join("out.md")).unwrap(),
PathBuf::from("../elsewhere.md")
);
}
#[cfg(unix)]
#[test]
fn a_failed_set_restores_the_file_a_link_replaced() {
let root = tmp("rollback-link-over-file");
std::fs::write(root.join("victim.md"), "the original").unwrap();
std::fs::write(root.join("target.md"), "someone else's file").unwrap();
let mut cs = ChangeSet::new();
cs.set_link("victim.md", "target.md");
cs.write("doomed.md", "never lands");
block_on(cs.apply(&FailAtWrite::nth(0), &root)).unwrap_err();
let md = std::fs::symlink_metadata(root.join("victim.md")).unwrap();
assert!(md.file_type().is_file(), "the link must be gone");
assert_eq!(read(&root, "victim.md").as_deref(), Some("the original"));
assert_eq!(
read(&root, "target.md").as_deref(),
Some("someone else's file"),
"nothing may be written through the link during rollback"
);
}
#[cfg(unix)]
#[test]
fn a_failed_set_repoints_a_link_it_had_repointed() {
let root = tmp("rollback-relink");
std::os::unix::fs::symlink("old-target.md", root.join("link.md")).unwrap();
let mut cs = ChangeSet::new();
cs.set_link("link.md", "new-target.md");
cs.write("doomed.md", "never lands");
block_on(cs.apply(&FailAtWrite::nth(0), &root)).unwrap_err();
assert_eq!(
std::fs::read_link(root.join("link.md")).unwrap(),
PathBuf::from("old-target.md")
);
}
#[cfg(unix)]
#[test]
fn a_failed_set_removes_a_link_it_had_created() {
let root = tmp("rollback-link-fresh");
let mut cs = ChangeSet::new();
cs.set_link("fresh.md", "anywhere.md");
cs.write("doomed.md", "never lands");
block_on(cs.apply(&FailAtWrite::nth(0), &root)).unwrap_err();
assert!(
std::fs::symlink_metadata(root.join("fresh.md")).is_err(),
"the created link must be gone"
);
}
#[cfg(unix)]
#[test]
fn a_lone_link_takes_the_journal_rather_than_the_fast_path() {
let root = tmp("lone-link");
let fs = RecordingFs::local();
let mut cs = ChangeSet::new();
cs.set_link("link.md", "target.md");
block_on(cs.apply(&fs, &root)).unwrap();
let journaled = fs
.events()
.iter()
.any(|e| matches!(e, FsEvent::Write(p) if Journal::default().owns_path(p)));
assert!(journaled, "events: {:?}", fs.events());
}
#[test]
fn an_execute_flip_over_a_backend_with_no_bit_applies_as_nothing() {
let fs = crate::fs::InMemoryFs::new();
block_on(fs.write(Path::new("root/doc.md"), b"hi")).unwrap();
let mut cs = ChangeSet::new();
cs.set_executable("doc.md", true);
cs.write("other.md", "lands");
block_on(cs.apply(&fs, Path::new("root"))).unwrap();
assert_eq!(
block_on(fs.read_to_string(Path::new("root/other.md"))).unwrap(),
"lands"
);
}
#[test]
fn a_link_over_a_backend_without_links_unwinds_the_set() {
struct NoLinks(crate::fs::InMemoryFs);
impl crate::fs::ReadStorage for NoLinks {
async fn read(&self, path: &Path) -> std::io::Result<Vec<u8>> {
self.0.read(path).await
}
async fn read_to_string(&self, path: &Path) -> std::io::Result<String> {
self.0.read_to_string(path).await
}
async fn read_dir(&self, path: &Path) -> std::io::Result<Vec<crate::fs::DirEntry>> {
self.0.read_dir(path).await
}
async fn metadata(&self, path: &Path) -> std::io::Result<crate::fs::Metadata> {
self.0.metadata(path).await
}
}
impl Storage for NoLinks {
async fn write(&self, path: &Path, contents: &[u8]) -> std::io::Result<()> {
self.0.write(path, contents).await
}
async fn create_dir_all(&self, path: &Path) -> std::io::Result<()> {
self.0.create_dir_all(path).await
}
async fn remove_file(&self, path: &Path) -> std::io::Result<()> {
self.0.remove_file(path).await
}
async fn remove_dir_all(&self, path: &Path) -> std::io::Result<()> {
self.0.remove_dir_all(path).await
}
async fn rename(&self, from: &Path, to: &Path) -> std::io::Result<()> {
self.0.rename(from, to).await
}
fn capabilities(&self) -> crate::fs::Capabilities {
self.0.capabilities()
}
}
let fs = NoLinks(crate::fs::InMemoryFs::new());
block_on(fs.0.write(Path::new("root/before.md"), b"old")).unwrap();
let mut cs = ChangeSet::new();
cs.write("before.md", "new");
cs.set_link("link.md", "target.md");
let err = block_on(cs.apply(&fs, Path::new("root"))).unwrap_err();
assert!(err.to_string().contains("symbolic links"), "{err}");
assert_eq!(
block_on(fs.0.read_to_string(Path::new("root/before.md"))).unwrap(),
"old",
"the write that preceded the refused link must unwind"
);
}
#[cfg(unix)]
#[test]
fn a_failed_set_restores_a_link_a_write_replaced_as_a_link() {
let root = tmp("rollback-write-over-link");
std::fs::write(root.join("target.md"), "the target").unwrap();
std::os::unix::fs::symlink("target.md", root.join("link.md")).unwrap();
let mut cs = ChangeSet::new();
cs.write("link.md", "replaces the link");
cs.write("doomed.md", "never lands");
block_on(cs.apply(&FailAtWrite::nth(1), &root)).unwrap_err();
let md = std::fs::symlink_metadata(root.join("link.md")).unwrap();
assert!(
md.file_type().is_symlink(),
"the link must come back as a link"
);
assert_eq!(
std::fs::read_link(root.join("link.md")).unwrap(),
PathBuf::from("target.md")
);
assert_eq!(
read(&root, "target.md").as_deref(),
Some("the target"),
"the rollback must not have written through the link"
);
}
#[cfg(unix)]
#[test]
fn a_failed_set_restores_a_link_it_removed_as_a_link() {
let root = tmp("rollback-remove-link");
std::os::unix::fs::symlink("nowhere.md", root.join("dangling.md")).unwrap();
let mut cs = ChangeSet::new();
cs.remove("dangling.md");
cs.write("doomed.md", "never lands");
block_on(cs.apply(&FailAtWrite::nth(0), &root)).unwrap_err();
assert_eq!(
std::fs::read_link(root.join("dangling.md")).unwrap(),
PathBuf::from("nowhere.md"),
"the removed link must come back as the link it was"
);
}
#[cfg(unix)]
#[test]
fn the_execute_bit_is_refused_through_a_link_and_the_set_unwinds() {
use std::os::unix::fs::PermissionsExt as _;
let root = tmp("exec-through-link");
let outside = tmp("exec-through-link-outside");
std::fs::write(outside.join("victim.sh"), "#!/bin/sh").unwrap();
let victim = outside.join("victim.sh");
let mode_before = std::fs::metadata(&victim).unwrap().permissions().mode();
let mut cs = ChangeSet::new();
cs.set_link("l", victim.to_str().unwrap());
cs.set_executable("l", true);
let err = block_on(cs.apply(&StdFs, &root)).unwrap_err();
assert!(err.to_string().contains("symbolic link"), "{err}");
assert_eq!(
std::fs::metadata(&victim).unwrap().permissions().mode(),
mode_before,
"the referent's mode must be untouched"
);
assert!(
std::fs::symlink_metadata(root.join("l")).is_err(),
"the refused set must unwind the link it made"
);
}
#[test]
fn a_set_of_renames_and_removes_is_flushed_before_the_journal_is_dropped() {
let root = tmp("flush-before-drop");
std::fs::write(root.join("a.md"), "a").unwrap();
std::fs::write(root.join("c.md"), "c").unwrap();
let fs = RecordingFs::local();
let mut cs = ChangeSet::new();
cs.rename("a.md", "sub/b.md");
cs.remove("c.md");
block_on(cs.apply(&fs, &root)).unwrap();
let journal = Journal::default().path_in(&root);
let jtmp = crate::fs::temp_sibling(&journal);
assert_eq!(
fs.events(),
vec![
FsEvent::Write(jtmp.clone()),
FsEvent::Sync(jtmp.clone(), crate::fs::Durability::Ordered),
FsEvent::Rename(jtmp, journal.clone()),
FsEvent::Sync(root.clone(), crate::fs::Durability::Durable),
FsEvent::Rename(root.join("a.md"), root.join("sub/b.md")),
FsEvent::Remove(root.join("c.md")),
FsEvent::Sync(root.join("sub"), crate::fs::Durability::Ordered),
FsEvent::Sync(root.clone(), crate::fs::Durability::Durable),
FsEvent::Remove(journal),
]
);
}
#[test]
fn a_lone_rename_flushes_the_entries_it_edited() {
let root = tmp("lone-rename-flush");
std::fs::write(root.join("a.md"), "a").unwrap();
let fs = RecordingFs::local();
let mut cs = ChangeSet::new();
cs.rename("a.md", "b.md");
block_on(cs.apply(&fs, &root)).unwrap();
assert_eq!(
fs.events(),
vec![
FsEvent::Rename(root.join("a.md"), root.join("b.md")),
FsEvent::Sync(root.clone(), crate::fs::Durability::Durable),
]
);
}
#[test]
fn a_lone_write_pays_the_staging_flush_and_one_drain() {
let root = tmp("lone-write-flush");
let fs = RecordingFs::local();
let mut cs = ChangeSet::new();
cs.write("a.md", "a");
block_on(cs.apply(&fs, &root)).unwrap();
let tmp_name = crate::fs::temp_sibling(&root.join("a.md"));
assert_eq!(
fs.events(),
vec![
FsEvent::Write(tmp_name.clone()),
FsEvent::Sync(tmp_name.clone(), crate::fs::Durability::Ordered),
FsEvent::Rename(tmp_name, root.join("a.md")),
FsEvent::Sync(root.clone(), crate::fs::Durability::Durable),
]
);
}
#[cfg(unix)]
#[test]
fn a_lone_exec_flip_flushes_the_inode_it_edited() {
let root = tmp("lone-exec-flush");
std::fs::write(root.join("run.sh"), "#!/bin/sh").unwrap();
let fs = RecordingFs::local();
let mut cs = ChangeSet::new();
cs.set_executable("run.sh", true);
block_on(cs.apply(&fs, &root)).unwrap();
assert_eq!(
fs.events(),
vec![
FsEvent::SetExecutable(root.join("run.sh"), true),
FsEvent::Sync(root.join("run.sh"), crate::fs::Durability::Ordered),
FsEvent::Sync(root.clone(), crate::fs::Durability::Durable),
]
);
}
struct FailingRecorder {
inner: RecordingFs,
writes: std::cell::Cell<usize>,
fail_at: usize,
}
impl crate::fs::ReadStorage for FailingRecorder {
async fn read(&self, path: &Path) -> std::io::Result<Vec<u8>> {
self.inner.read(path).await
}
async fn read_to_string(&self, path: &Path) -> std::io::Result<String> {
self.inner.read_to_string(path).await
}
async fn read_dir(&self, path: &Path) -> std::io::Result<Vec<crate::fs::DirEntry>> {
self.inner.read_dir(path).await
}
async fn metadata(&self, path: &Path) -> std::io::Result<crate::fs::Metadata> {
self.inner.metadata(path).await
}
async fn read_link(&self, path: &Path) -> std::io::Result<Option<PathBuf>> {
self.inner.read_link(path).await
}
}
impl Storage for FailingRecorder {
async fn write(&self, path: &Path, contents: &[u8]) -> std::io::Result<()> {
if !Journal::default().owns_path(path) {
let n = self.writes.get();
self.writes.set(n + 1);
if n == self.fail_at {
return Err(std::io::Error::other("disk full (test)"));
}
}
self.inner.write(path, contents).await
}
async fn create_dir_all(&self, path: &Path) -> std::io::Result<()> {
self.inner.create_dir_all(path).await
}
async fn remove_file(&self, path: &Path) -> std::io::Result<()> {
self.inner.remove_file(path).await
}
async fn remove_dir_all(&self, path: &Path) -> std::io::Result<()> {
self.inner.remove_dir_all(path).await
}
async fn rename(&self, from: &Path, to: &Path) -> std::io::Result<()> {
self.inner.rename(from, to).await
}
fn capabilities(&self) -> crate::fs::Capabilities {
self.inner.capabilities()
}
async fn sync(&self, path: &Path, need: crate::fs::Durability) -> std::io::Result<()> {
self.inner.sync(path, need).await
}
}
#[test]
fn an_abort_flushes_the_restored_state_and_durably_retires_the_journal() {
let root = tmp("abort-durable");
std::fs::write(root.join("existing.md"), "before").unwrap();
let fs = FailingRecorder {
inner: RecordingFs::local(),
writes: std::cell::Cell::new(0),
fail_at: 1,
};
let mut cs = ChangeSet::new();
cs.write("existing.md", "after");
cs.write("doomed.md", "never lands");
let err = block_on(cs.apply(&fs, &root)).unwrap_err();
assert!(matches!(err, Error::Io(_)), "{err:?}");
assert_eq!(read(&root, "existing.md").as_deref(), Some("before"));
let journal = Journal::default().path_in(&root);
let events = fs.inner.events();
let tail = &events[events.len() - 4..];
assert_eq!(
tail,
&[
FsEvent::Sync(root.join("existing.md"), crate::fs::Durability::Ordered),
FsEvent::Sync(root.clone(), crate::fs::Durability::Ordered),
FsEvent::Remove(journal),
FsEvent::Sync(root.clone(), crate::fs::Durability::Durable),
],
"events: {events:?}"
);
}
#[test]
fn many_writes_into_one_directory_cost_one_drain() {
let root = tmp("write-economy");
let fs = RecordingFs::local();
let mut cs = ChangeSet::new();
cs.write("a.md", "a");
cs.write("b.md", "b");
cs.write("c.md", "c");
cs.write("d.md", "d");
block_on(cs.apply(&fs, &root)).unwrap();
let drains = fs
.events()
.iter()
.filter(|e| matches!(e, FsEvent::Sync(_, crate::fs::Durability::Durable)))
.count();
assert_eq!(drains, 2, "events: {:?}", fs.events());
}
#[cfg(unix)]
#[test]
fn a_flipped_bit_survives_its_name_being_renamed_away() {
let root = tmp("flip-then-rename");
std::fs::write(root.join("z.sh"), "#!/bin/sh").unwrap();
let fs = RecordingFs::local();
let mut cs = ChangeSet::new();
cs.set_executable("z.sh", true);
cs.rename("z.sh", "a.sh");
block_on(cs.apply(&fs, &root)).unwrap();
assert!(is_executable(&root, "a.sh"));
let journal = Journal::default().path_in(&root);
let jtmp = crate::fs::temp_sibling(&journal);
assert_eq!(
fs.events(),
vec![
FsEvent::Write(jtmp.clone()),
FsEvent::Sync(jtmp.clone(), crate::fs::Durability::Ordered),
FsEvent::Rename(jtmp, journal.clone()),
FsEvent::Sync(root.clone(), crate::fs::Durability::Durable),
FsEvent::SetExecutable(root.join("z.sh"), true),
FsEvent::Sync(root.join("z.sh"), crate::fs::Durability::Ordered),
FsEvent::Rename(root.join("z.sh"), root.join("a.sh")),
FsEvent::Sync(root.clone(), crate::fs::Durability::Durable),
FsEvent::Remove(journal),
]
);
}
struct FailNthDrain {
inner: RecordingFs,
anchor: PathBuf,
drains: std::cell::Cell<usize>,
fail_at: usize,
}
impl crate::fs::ReadStorage for FailNthDrain {
async fn read(&self, path: &Path) -> std::io::Result<Vec<u8>> {
self.inner.read(path).await
}
async fn read_to_string(&self, path: &Path) -> std::io::Result<String> {
self.inner.read_to_string(path).await
}
async fn read_dir(&self, path: &Path) -> std::io::Result<Vec<crate::fs::DirEntry>> {
self.inner.read_dir(path).await
}
async fn metadata(&self, path: &Path) -> std::io::Result<crate::fs::Metadata> {
self.inner.metadata(path).await
}
async fn read_link(&self, path: &Path) -> std::io::Result<Option<PathBuf>> {
self.inner.read_link(path).await
}
}
impl Storage for FailNthDrain {
async fn write(&self, path: &Path, contents: &[u8]) -> std::io::Result<()> {
self.inner.write(path, contents).await
}
async fn create_dir_all(&self, path: &Path) -> std::io::Result<()> {
self.inner.create_dir_all(path).await
}
async fn remove_file(&self, path: &Path) -> std::io::Result<()> {
self.inner.remove_file(path).await
}
async fn remove_dir_all(&self, path: &Path) -> std::io::Result<()> {
self.inner.remove_dir_all(path).await
}
async fn rename(&self, from: &Path, to: &Path) -> std::io::Result<()> {
self.inner.rename(from, to).await
}
fn capabilities(&self) -> crate::fs::Capabilities {
self.inner.capabilities()
}
async fn sync(&self, path: &Path, need: crate::fs::Durability) -> std::io::Result<()> {
if need == crate::fs::Durability::Durable && path == self.anchor.as_path() {
let n = self.drains.get();
self.drains.set(n + 1);
if n == self.fail_at {
return Err(std::io::Error::other("cannot drain (test)"));
}
}
self.inner.sync(path, need).await
}
}
#[test]
fn an_abort_flushes_the_entry_a_restored_removal_recreates() {
let root = tmp("abort-remove-entry");
std::fs::create_dir_all(root.join("sub")).unwrap();
std::fs::write(root.join("sub/gone.md"), "kept after all").unwrap();
let fs = FailingRecorder {
inner: RecordingFs::local(),
writes: std::cell::Cell::new(0),
fail_at: 0,
};
let mut cs = ChangeSet::new();
cs.remove("sub/gone.md");
cs.write("doomed.md", "never lands");
block_on(cs.apply(&fs, &root)).unwrap_err();
assert_eq!(
read(&root, "sub/gone.md").as_deref(),
Some("kept after all")
);
let events = fs.inner.events();
let entry_flushed = events
.iter()
.position(|e| matches!(e, FsEvent::Sync(p, _) if *p == root.join("sub")));
let journal_retired = events
.iter()
.rposition(|e| matches!(e, FsEvent::Remove(p) if Journal::default().owns_path(p)));
match (entry_flushed, journal_retired) {
(Some(flush), Some(retire)) => assert!(
flush < retire,
"the recreated entry must be flushed before the journal goes; events: {events:?}"
),
_ => panic!("expected a sub flush and a journal retirement; events: {events:?}"),
}
}
#[test]
fn a_failed_certification_rolls_the_set_back() {
let root = tmp("failed-certification");
std::fs::write(root.join("existing.md"), "before").unwrap();
let fs = FailNthDrain {
inner: RecordingFs::local(),
anchor: root.clone(),
drains: std::cell::Cell::new(0),
fail_at: 1,
};
let mut cs = ChangeSet::new();
cs.write("existing.md", "after");
cs.write("fresh.md", "fresh");
let err = block_on(cs.apply(&fs, &root)).unwrap_err();
assert!(matches!(err, Error::Io(_)), "a clean rollback: {err:?}");
assert_eq!(read(&root, "existing.md").as_deref(), Some("before"));
assert_eq!(read(&root, "fresh.md"), None);
assert!(
!Journal::default().path_in(&root).exists(),
"the abort durably retired the journal"
);
}
#[test]
fn a_failed_rename_set_restores_the_file_the_rename_displaced() {
let root = tmp("rollback-rename-victim");
std::fs::write(root.join("a.md"), "the mover").unwrap();
std::fs::write(root.join("b.md"), "the victim").unwrap();
let mut cs = ChangeSet::new();
cs.rename("a.md", "b.md");
cs.write("doomed.md", "never lands");
block_on(cs.apply(&FailAtWrite::nth(0), &root)).unwrap_err();
assert_eq!(read(&root, "a.md").as_deref(), Some("the mover"));
assert_eq!(read(&root, "b.md").as_deref(), Some("the victim"));
}
#[test]
fn a_deep_chain_written_through_a_set_is_flushed_link_by_link() {
let root = tmp("set-chain-flush");
let fs = RecordingFs::local();
let mut cs = ChangeSet::new();
cs.write("deep/nested/a.md", "a");
cs.write("b.md", "b");
block_on(cs.apply(&fs, &root)).unwrap();
for dir in [root.clone(), root.join("deep"), root.join("deep/nested")] {
assert!(
fs.events()
.iter()
.any(|e| matches!(e, FsEvent::Sync(p, _) if *p == dir)),
"{} never flushed; events: {:?}",
dir.display(),
fs.events()
);
}
}
#[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("fstx-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("fstx-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 = Journal::default().path_in(&root);
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 !Journal::default().owns_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_set_of_one_lands_without_a_journal_at_all() {
let root = tmp("journal-single");
std::fs::write(root.join("doc.md"), "old").unwrap();
let fs = RecordingFs::local();
let mut cs = ChangeSet::new();
cs.write("doc.md", "new");
block_on(cs.apply(&fs, &root)).unwrap();
let (target, temp) = (root.join("doc.md"), root.join(".doc.md.fstx-tmp"));
assert_eq!(std::fs::read_to_string(&target).unwrap(), "new");
assert_eq!(
fs.events(),
vec![
FsEvent::Write(temp.clone()),
FsEvent::Sync(temp.clone(), crate::fs::Durability::Ordered),
FsEvent::Rename(temp, target),
FsEvent::Sync(root.clone(), crate::fs::Durability::Durable),
],
"a set of one must cost exactly one atomic write and nothing else"
);
assert!(!Journal::default().path_in(&root).exists());
}
#[test]
fn a_set_of_one_still_refuses_to_run_over_a_stale_journal() {
let root = tmp("journal-single-stale");
std::fs::write(root.join("doc.md"), "old").unwrap();
std::fs::write(
Journal::default().path_in(&root),
"a previous change's intent",
)
.unwrap();
let mut cs = ChangeSet::new();
cs.write("doc.md", "new");
let err = block_on(cs.apply(&StdFs, &root)).unwrap_err();
assert!(matches!(err, Error::StaleJournal(_)), "got {err:?}");
assert_eq!(
std::fs::read_to_string(root.join("doc.md")).unwrap(),
"old",
"the refused write must not have happened"
);
}
#[test]
fn a_set_of_one_does_not_read_the_file_it_is_about_to_replace() {
let root = tmp("journal-single-unreadable");
let target = root.join("doc.md");
std::fs::write(&target, "old").unwrap();
let mut perms = std::fs::metadata(&target).unwrap().permissions();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
perms.set_mode(0o200); }
std::fs::set_permissions(&target, perms).unwrap();
let mut cs = ChangeSet::new();
cs.write("doc.md", "new");
block_on(cs.apply(&StdFs, &root)).expect("a write-only target is still replaceable");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(&target).unwrap().permissions().mode() & 0o777,
0o200,
"replacing the contents must not have changed who may read it"
);
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap();
}
assert_eq!(std::fs::read_to_string(&target).unwrap(), "new");
}
#[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!(
!Journal::default().path_in(&root).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(
Journal::default().path_in(&root),
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!(!Journal::default().path_in(&root).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!(!Journal::default().path_in(&root).exists());
}
#[test]
fn apply_refuses_an_absolute_path() {
let root = tmp("escape-abs");
let mut cs = ChangeSet::new();
cs.write("/tmp/fstx-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(
Journal::default().path_in(&root),
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!(Journal::default().path_in(&root).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(
Journal::default().path_in(&root),
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);
}
}