use std::borrow::Cow;
use std::path::{Component, Path, PathBuf};
use crate::change::FileOp;
use crate::error::{Error, Result};
use crate::fs::Storage;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Journal {
name: Cow<'static, str>,
home: Option<PathBuf>,
}
impl Journal {
pub const DEFAULT_NAME: &'static str = ".fstx-journal";
pub fn named(name: impl Into<Cow<'static, str>>) -> Result<Self> {
let name = name.into();
let mut components = Path::new(name.as_ref()).components();
let single =
matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none();
if !single {
return Err(Error::InvalidJournalName(name.into_owned()));
}
Ok(Self { name, home: None })
}
pub fn kept_in(self, home: impl Into<PathBuf>) -> Result<Self> {
let home = home.into();
if !home.is_absolute() {
return Err(Error::InvalidJournalHome(home));
}
Ok(Self {
home: Some(home),
..self
})
}
pub fn name(&self) -> &str {
&self.name
}
pub(crate) fn home(&self) -> Option<&Path> {
self.home.as_deref()
}
pub fn path_in(&self, root: &Path) -> PathBuf {
match &self.home {
Some(home) => home.join(self.name.as_ref()),
None => root.join(self.name.as_ref()),
}
}
pub fn owns_path(&self, path: &Path) -> bool {
let name_matches = path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.contains(self.name.as_ref()));
match &self.home {
Some(home) => name_matches && path.parent() == Some(home.as_path()),
None => name_matches,
}
}
}
impl Default for Journal {
fn default() -> Self {
Self {
name: Cow::Borrowed(Self::DEFAULT_NAME),
home: None,
}
}
}
const MAGIC: &[u8; 8] = b"FSTXJRN1";
const LEGACY_MAGIC: &[u8; 8] = b"COLOJRN1";
pub fn encode(ops: &[FileOp]) -> Result<Vec<u8>> {
let mut buf = Vec::with_capacity(64);
buf.extend_from_slice(MAGIC);
buf.extend_from_slice(&(ops.len() as u64).to_le_bytes());
for op in ops {
match op {
FileOp::Write { path, bytes } => {
buf.push(0);
put_path(&mut buf, path)?;
put_bytes(&mut buf, bytes);
}
FileOp::Rename { from, to } => {
buf.push(1);
put_path(&mut buf, from)?;
put_path(&mut buf, to)?;
}
FileOp::Remove { path } => {
buf.push(2);
put_path(&mut buf, path)?;
}
FileOp::CopyFrom { path, source } => {
buf.push(3);
put_path(&mut buf, path)?;
put_path(&mut buf, source)?;
}
FileOp::SetExecutable { path, executable } => {
buf.push(4);
put_path(&mut buf, path)?;
buf.push(u8::from(*executable));
}
FileOp::SetLink { path, target } => {
buf.push(5);
put_path(&mut buf, path)?;
put_path(&mut buf, target)?;
}
}
}
let checksum = fnv1a(&buf);
buf.extend_from_slice(&checksum.to_le_bytes());
Ok(buf)
}
pub fn decode(bytes: &[u8]) -> Result<Vec<FileOp>> {
let corrupt = |what: &str| Error::Corrupt(what.to_string());
let stamp = bytes.get(..MAGIC.len());
if bytes.len() < MAGIC.len() + 8 + 8
|| !matches!(stamp, Some(m) if m == MAGIC || m == LEGACY_MAGIC)
{
return Err(corrupt("not a journal (bad header)"));
}
let body_end = bytes.len() - 8;
let stored = u64::from_le_bytes(bytes[body_end..].try_into().unwrap());
if fnv1a(&bytes[..body_end]) != stored {
return Err(corrupt("checksum mismatch"));
}
let mut cur = Cursor {
bytes: &bytes[..body_end],
at: MAGIC.len(),
};
let count = cur.take_u64()?;
if count > (cur.bytes.len() - cur.at) as u64 {
return Err(corrupt("op count exceeds the journal body"));
}
let mut ops = Vec::with_capacity(count as usize);
for _ in 0..count {
let op = match cur.take_u8()? {
0 => FileOp::Write {
path: cur.take_path()?,
bytes: cur.take_bytes()?.to_vec(),
},
1 => FileOp::Rename {
from: cur.take_path()?,
to: cur.take_path()?,
},
2 => FileOp::Remove {
path: cur.take_path()?,
},
3 => FileOp::CopyFrom {
path: cur.take_path()?,
source: cur.take_path()?,
},
4 => FileOp::SetExecutable {
path: cur.take_path()?,
executable: match cur.take_u8()? {
0 => false,
1 => true,
other => {
return Err(corrupt(&format!("invalid executable flag {other}")));
}
},
},
5 => FileOp::SetLink {
path: cur.take_path()?,
target: cur.take_path()?,
},
other => return Err(corrupt(&format!("unknown op tag {other}"))),
};
ops.push(op);
}
if cur.at != cur.bytes.len() {
return Err(corrupt("trailing bytes after the last op"));
}
Ok(ops)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Recovered {
Nothing,
Applied(usize),
}
impl Journal {
pub async fn recover<FS: Storage>(&self, fs: &FS, root: &Path) -> Result<Recovered> {
let journal = self.path_in(root);
let bytes = match fs.read(&journal).await {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Recovered::Nothing),
Err(e) => return Err(e.into()),
};
let ops = decode(&bytes)?;
crate::change::guard_ops(&ops)?;
let mut touched = std::collections::BTreeSet::new();
for op in &ops {
replay(fs, root, op, &mut touched).await?;
}
crate::fs::flush_all_durable(fs, touched, root).await?;
fs.remove_file(&journal).await?;
Ok(Recovered::Applied(ops.len()))
}
}
pub async fn recover<FS: Storage>(fs: &FS, root: &Path) -> Result<Recovered> {
Journal::default().recover(fs, root).await
}
async fn replay<FS: Storage>(
fs: &FS,
root: &Path,
op: &FileOp,
touched: &mut std::collections::BTreeSet<PathBuf>,
) -> Result<()> {
match op {
FileOp::Write { path, bytes } => {
let full = root.join(path);
ensure_parent(fs, &full, touched).await?;
fs.replace(&full, bytes).await?;
crate::change::settle_write_debt(fs, &full, touched).await?;
}
FileOp::CopyFrom { path, source } => {
let (full, source_full) = (root.join(path), root.join(source));
let bytes = fs.read(&source_full).await.map_err(|e| {
Error::Recovery(format!(
"cannot copy {} from {} — {e}",
full.display(),
source_full.display()
))
})?;
ensure_parent(fs, &full, touched).await?;
fs.replace(&full, &bytes).await?;
crate::change::settle_write_debt(fs, &full, touched).await?;
}
FileOp::Remove { path } => {
let full = root.join(path);
match fs.remove_file(&full).await {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
if let Some(dir) = crate::fs::parent_dir(&full) {
touched.insert(dir.to_path_buf());
}
}
FileOp::SetExecutable { path, executable } => {
let full = root.join(path);
crate::change::guard_not_link(fs, &full).await?;
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);
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());
}
}
FileOp::Rename { from, to } => {
let (from_full, to_full) = (root.join(from), root.join(to));
if fs.try_exists(&from_full).await? {
ensure_parent(fs, &to_full, touched).await?;
fs.rename(&from_full, &to_full).await?;
} else if fs.try_exists(&to_full).await? {
} else {
return Err(Error::Recovery(format!(
"neither {} nor {} exists — cannot complete the rename",
from_full.display(),
to_full.display()
)));
}
for side in [&from_full, &to_full] {
if let Some(dir) = crate::fs::parent_dir(side) {
touched.insert(dir.to_path_buf());
}
}
}
}
Ok(())
}
async fn ensure_parent<FS: Storage>(
fs: &FS,
full: &Path,
touched: &mut std::collections::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(())
}
fn put_bytes(buf: &mut Vec<u8>, bytes: &[u8]) {
buf.extend_from_slice(&(bytes.len() as u64).to_le_bytes());
buf.extend_from_slice(bytes);
}
fn put_path(buf: &mut Vec<u8>, path: &Path) -> Result<()> {
let s = path
.to_str()
.ok_or_else(|| Error::NonUtf8Path(path.to_path_buf()))?;
put_bytes(buf, s.as_bytes());
Ok(())
}
struct Cursor<'a> {
bytes: &'a [u8],
at: usize,
}
impl Cursor<'_> {
fn short() -> Error {
Error::Corrupt("unexpected end of data".into())
}
fn take(&mut self, n: usize) -> Result<&[u8]> {
let end = self.at.checked_add(n).ok_or_else(Self::short)?;
let slice = self.bytes.get(self.at..end).ok_or_else(Self::short)?;
self.at = end;
Ok(slice)
}
fn take_u8(&mut self) -> Result<u8> {
Ok(self.take(1)?[0])
}
fn take_u64(&mut self) -> Result<u64> {
Ok(u64::from_le_bytes(self.take(8)?.try_into().unwrap()))
}
fn take_bytes(&mut self) -> Result<&[u8]> {
let len = self.take_u64()? as usize;
self.take(len)
}
fn take_path(&mut self) -> Result<PathBuf> {
let bytes = self.take_bytes()?;
let s = std::str::from_utf8(bytes).map_err(|_| Error::Corrupt("non-UTF-8 path".into()))?;
Ok(PathBuf::from(s))
}
}
fn fnv1a(data: &[u8]) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325;
for &byte in data {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
#[cfg(test)]
mod tests {
use super::*;
use crate::exec::block_on;
use crate::fs::StdFs;
fn tmp(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("fstx-journal-{name}-{}", std::process::id()));
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 a_change_set_round_trips_through_the_journal() {
let ops = vec![
FileOp::Write {
path: "child.md".into(),
bytes: b"hello".to_vec(),
},
FileOp::Rename {
from: "a.md".into(),
to: "sub/a.md".into(),
},
FileOp::Remove {
path: "gone.md".into(),
},
FileOp::SetExecutable {
path: "run.sh".into(),
executable: true,
},
FileOp::SetLink {
path: "link.md".into(),
target: "../elsewhere.md".into(),
},
];
let bytes = encode(&ops).unwrap();
assert_eq!(decode(&bytes).unwrap(), ops);
}
#[test]
fn an_invalid_executable_flag_is_refused_not_guessed() {
let ops = vec![FileOp::SetExecutable {
path: "run.sh".into(),
executable: true,
}];
let mut bytes = encode(&ops).unwrap();
let flag_at = bytes.len() - 8 - 1;
assert_eq!(bytes[flag_at], 1);
bytes[flag_at] = 7;
let body_end = bytes.len() - 8;
let sum = fnv1a(&bytes[..body_end]);
bytes[body_end..].copy_from_slice(&sum.to_le_bytes());
let err = decode(&bytes).unwrap_err();
assert!(err.to_string().contains("executable flag"), "{err}");
}
#[test]
fn a_copy_journals_a_reference_not_the_payload() {
let payload: Vec<u8> = vec![7; 512 * 1024];
let by_value = encode(&[FileOp::Write {
path: "notes/photo.jpg".into(),
bytes: payload.clone(),
}])
.unwrap();
let by_reference = encode(&[FileOp::CopyFrom {
path: "notes/photo.jpg".into(),
source: "history/blobs/9f/86d081".into(),
}])
.unwrap();
assert!(by_value.len() > payload.len(), "a Write carries its bytes");
assert!(
by_reference.len() < 128,
"a CopyFrom carries two paths: {} bytes",
by_reference.len()
);
assert_eq!(decode(&by_reference).unwrap().len(), 1);
}
#[test]
fn binary_payloads_survive_the_journal_verbatim() {
let payload: Vec<u8> = (0u8..=255).cycle().take(1000).collect();
let ops = vec![FileOp::Write {
path: "photo.png".into(),
bytes: payload.clone(),
}];
let decoded = decode(&encode(&ops).unwrap()).unwrap();
assert_eq!(decoded, ops);
}
#[test]
fn a_tampered_journal_is_refused_not_replayed() {
let ops = vec![FileOp::Write {
path: "child.md".into(),
bytes: b"hello".to_vec(),
}];
let mut bytes = encode(&ops).unwrap();
let mid = bytes.len() / 2;
bytes[mid] ^= 0xff;
let err = decode(&bytes).unwrap_err();
assert!(err.to_string().contains("corrupt"), "{err}");
}
#[test]
fn a_non_journal_file_is_rejected() {
assert!(decode(b"not a journal at all").is_err());
assert!(decode(b"").is_err());
}
#[test]
fn recovery_completes_a_change_set_that_had_not_started() {
let root = tmp("recover-none-applied");
std::fs::write(root.join("parent.md"), "old parent").unwrap();
let ops = vec![
FileOp::Write {
path: "child.md".into(),
bytes: b"child".to_vec(),
},
FileOp::Write {
path: "parent.md".into(),
bytes: b"new parent".to_vec(),
},
];
std::fs::write(Journal::default().path_in(&root), encode(&ops).unwrap()).unwrap();
let outcome = block_on(recover(&StdFs, &root)).unwrap();
assert_eq!(outcome, 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(),
"journal must be cleared after recovery"
);
}
#[test]
fn recovery_completes_a_partially_applied_change_set() {
let root = tmp("recover-partial");
std::fs::write(root.join("parent.md"), "old parent").unwrap();
let ops = vec![
FileOp::Write {
path: "child.md".into(),
bytes: b"child".to_vec(),
},
FileOp::Write {
path: "parent.md".into(),
bytes: b"new parent".to_vec(),
},
];
std::fs::write(Journal::default().path_in(&root), encode(&ops).unwrap()).unwrap();
std::fs::write(root.join("child.md"), "child").unwrap();
block_on(recover(&StdFs, &root)).unwrap();
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 recovery_rolls_a_rename_forward_from_either_side_of_the_crash() {
for already_moved in [false, true] {
let root = tmp(&format!("recover-rename-{already_moved}"));
let ops = vec![FileOp::Rename {
from: "a.md".into(),
to: "sub/a.md".into(),
}];
std::fs::write(Journal::default().path_in(&root), encode(&ops).unwrap()).unwrap();
if already_moved {
std::fs::create_dir_all(root.join("sub")).unwrap();
std::fs::write(root.join("sub/a.md"), "moved").unwrap();
} else {
std::fs::write(root.join("a.md"), "moved").unwrap();
}
block_on(recover(&StdFs, &root)).unwrap();
assert_eq!(read(&root, "sub/a.md").as_deref(), Some("moved"));
assert!(!root.join("a.md").exists());
assert!(!Journal::default().path_in(&root).exists());
}
}
#[test]
fn recovery_rolls_a_copy_forward_from_its_immutable_source() {
for already_copied in [false, true] {
let root = tmp(&format!("recover-copy-{already_copied}"));
std::fs::create_dir_all(root.join("history/blobs/9f")).unwrap();
std::fs::write(root.join("history/blobs/9f/86d081"), "captured bytes").unwrap();
std::fs::write(root.join("notes.md"), "damaged bytes").unwrap();
let ops = vec![FileOp::CopyFrom {
path: "notes.md".into(),
source: "history/blobs/9f/86d081".into(),
}];
std::fs::write(Journal::default().path_in(&root), encode(&ops).unwrap()).unwrap();
if already_copied {
std::fs::write(root.join("notes.md"), "captured bytes").unwrap();
}
block_on(recover(&StdFs, &root)).unwrap();
assert_eq!(read(&root, "notes.md").as_deref(), Some("captured bytes"));
assert!(!Journal::default().path_in(&root).exists());
assert!(root.join("history/blobs/9f/86d081").exists());
}
}
#[test]
fn a_copy_whose_source_is_gone_fails_replay_rather_than_inventing_a_state() {
let root = tmp("recover-copy-missing");
std::fs::write(root.join("notes.md"), "damaged bytes").unwrap();
let ops = vec![FileOp::CopyFrom {
path: "notes.md".into(),
source: "history/blobs/9f/86d081".into(),
}];
std::fs::write(Journal::default().path_in(&root), encode(&ops).unwrap()).unwrap();
let err = block_on(recover(&StdFs, &root)).unwrap_err();
assert!(err.to_string().contains("cannot copy"), "{err}");
assert!(Journal::default().path_in(&root).exists());
assert_eq!(read(&root, "notes.md").as_deref(), Some("damaged bytes"));
}
#[cfg(unix)]
#[test]
fn recovery_rolls_modes_and_links_forward_idempotently() {
use std::os::unix::fs::PermissionsExt as _;
let root = tmp("recover-modes-links");
std::fs::write(root.join("run.sh"), "#!/bin/sh").unwrap();
std::os::unix::fs::symlink("target.md", root.join("link.md")).unwrap();
let ops = vec![
FileOp::SetLink {
path: "link.md".into(),
target: "target.md".into(),
},
FileOp::SetExecutable {
path: "run.sh".into(),
executable: true,
},
];
std::fs::write(Journal::default().path_in(&root), encode(&ops).unwrap()).unwrap();
let outcome = block_on(recover(&StdFs, &root)).unwrap();
assert_eq!(outcome, Recovered::Applied(2));
assert_eq!(
std::fs::read_link(root.join("link.md")).unwrap(),
PathBuf::from("target.md")
);
let mode = std::fs::metadata(root.join("run.sh"))
.unwrap()
.permissions()
.mode();
assert_ne!(mode & 0o111, 0, "the bit must be set after recovery");
}
#[test]
fn recovery_refuses_a_journal_whose_paths_escape_the_root() {
let root = tmp("recover-escape");
let outside = root.join("../fstx-escaped-by-recovery.md");
let _ = std::fs::remove_file(&outside);
let ops = vec![FileOp::Write {
path: "../fstx-escaped-by-recovery.md".into(),
bytes: b"escaped".to_vec(),
}];
std::fs::write(Journal::default().path_in(&root), encode(&ops).unwrap()).unwrap();
let err = block_on(recover(&StdFs, &root)).unwrap_err();
assert!(matches!(err, crate::Error::Escape(_)), "{err:?}");
assert!(!outside.exists(), "nothing may land outside the root");
assert!(
Journal::default().path_in(&root).exists(),
"a refused journal is left in place, like any that cannot be trusted"
);
}
#[test]
fn an_impossible_op_count_is_refused_not_allocated() {
let mut bytes = Vec::new();
bytes.extend_from_slice(MAGIC);
bytes.extend_from_slice(&u64::MAX.to_le_bytes());
let checksum = fnv1a(&bytes);
bytes.extend_from_slice(&checksum.to_le_bytes());
let err = decode(&bytes).unwrap_err();
assert!(err.to_string().contains("op count"), "{err}");
}
#[test]
fn recovery_flushes_what_it_replayed_before_giving_up_the_journal() {
let root = tmp("recover-flush");
std::fs::write(root.join("a.md"), "a").unwrap();
let ops = vec![FileOp::Rename {
from: "a.md".into(),
to: "b.md".into(),
}];
std::fs::write(Journal::default().path_in(&root), encode(&ops).unwrap()).unwrap();
let fs = crate::fs_faults::RecordingFs::local();
let outcome = block_on(recover(&fs, &root)).unwrap();
assert_eq!(outcome, Recovered::Applied(1));
use crate::fs_faults::FsEvent;
assert_eq!(
fs.events(),
vec![
FsEvent::Rename(root.join("a.md"), root.join("b.md")),
FsEvent::Sync(root.clone(), crate::fs::Durability::Durable),
FsEvent::Remove(Journal::default().path_in(&root)),
]
);
}
#[test]
fn recovery_is_a_noop_when_there_is_no_journal() {
let root = tmp("recover-noop");
std::fs::write(root.join("doc.md"), "untouched").unwrap();
assert_eq!(
block_on(recover(&StdFs, &root)).unwrap(),
Recovered::Nothing
);
assert_eq!(read(&root, "doc.md").as_deref(), Some("untouched"));
}
#[test]
fn recovering_the_same_journal_twice_is_safe() {
let root = tmp("recover-twice");
std::fs::write(root.join("parent.md"), "old").unwrap();
let ops = vec![FileOp::Write {
path: "parent.md".into(),
bytes: b"new".to_vec(),
}];
let journal = encode(&ops).unwrap();
std::fs::write(Journal::default().path_in(&root), &journal).unwrap();
block_on(recover(&StdFs, &root)).unwrap();
std::fs::write(Journal::default().path_in(&root), &journal).unwrap();
block_on(recover(&StdFs, &root)).unwrap();
assert_eq!(read(&root, "parent.md").as_deref(), Some("new"));
assert!(!Journal::default().path_in(&root).exists());
}
#[test]
fn a_journal_name_must_be_a_single_component() {
for bad in ["", ".", "..", "a/b", "../elsewhere", "/absolute"] {
assert!(
Journal::named(bad).is_err(),
"{bad:?} should be refused as a journal name"
);
}
assert_eq!(
Journal::named(".myapp-journal").unwrap().name(),
".myapp-journal"
);
}
#[test]
fn apply_and_recover_meet_at_the_named_journal() {
let root = tmp("named-journal");
let journal = Journal::named(".myapp-journal").unwrap();
std::fs::write(root.join("parent.md"), "old parent").unwrap();
let ops = vec![
FileOp::Write {
path: "child.md".into(),
bytes: b"child".to_vec(),
},
FileOp::Write {
path: "parent.md".into(),
bytes: b"new parent".to_vec(),
},
];
std::fs::write(journal.path_in(&root), encode(&ops).unwrap()).unwrap();
assert_eq!(
block_on(Journal::default().recover(&StdFs, &root)).unwrap(),
Recovered::Nothing
);
assert_eq!(read(&root, "parent.md").as_deref(), Some("old parent"));
assert_eq!(
block_on(journal.recover(&StdFs, &root)).unwrap(),
Recovered::Applied(2)
);
assert_eq!(read(&root, "parent.md").as_deref(), Some("new parent"));
assert!(!journal.path_in(&root).exists());
}
#[test]
fn a_named_journal_round_trips_a_whole_apply() {
let root = tmp("named-apply");
let journal = Journal::named(".myapp-journal").unwrap();
let mut cs = crate::ChangeSet::new();
cs.write("a.md", "one");
cs.write("b.md", "two");
block_on(journal.apply(&cs, &StdFs, &root)).unwrap();
assert_eq!(read(&root, "a.md").as_deref(), Some("one"));
assert_eq!(read(&root, "b.md").as_deref(), Some("two"));
assert!(!journal.path_in(&root).exists());
assert!(!Journal::default().path_in(&root).exists());
}
#[test]
fn a_homed_journal_owns_no_path_in_the_root() {
let home = tmp("owns-home");
let root = tmp("owns-root");
let journal = Journal::default().kept_in(&home).unwrap();
assert!(journal.owns_path(&journal.path_in(&root)));
assert!(!journal.owns_path(&root.join(Journal::DEFAULT_NAME)));
}
#[test]
fn a_home_must_be_absolute() {
let err = Journal::default().kept_in("relative/dir").unwrap_err();
assert!(
matches!(err, crate::Error::InvalidJournalHome(_)),
"{err:?}"
);
}
#[test]
fn a_homed_journal_keeps_the_root_journal_free() {
let root = tmp("homed-apply");
let home = tmp("homed-apply-home");
let journal = Journal::default().kept_in(&home).unwrap();
let fs = crate::fs_faults::RecordingFs::local();
let mut cs = crate::ChangeSet::new();
cs.write("a.md", "a");
cs.write("b.md", "b");
block_on(journal.apply(&cs, &fs, &root)).unwrap();
assert_eq!(read(&root, "a.md").as_deref(), Some("a"));
let stray = fs.events().iter().any(|e| {
matches!(e, crate::fs_faults::FsEvent::Write(p)
if p.starts_with(&root)
&& p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.contains(Journal::DEFAULT_NAME)))
});
assert!(!stray, "events: {:?}", fs.events());
assert!(
!journal.path_in(&root).exists(),
"the homed journal is cleared after a clean apply"
);
}
#[test]
fn recovery_finds_a_homed_journal_and_applies_it_to_the_root() {
let root = tmp("homed-recover");
let home = tmp("homed-recover-home");
let journal = Journal::default().kept_in(&home).unwrap();
let ops = vec![FileOp::Write {
path: "restored.md".into(),
bytes: b"restored".to_vec(),
}];
std::fs::write(journal.path_in(&root), encode(&ops).unwrap()).unwrap();
let outcome = block_on(journal.recover(&StdFs, &root)).unwrap();
assert_eq!(outcome, Recovered::Applied(1));
assert_eq!(read(&root, "restored.md").as_deref(), Some("restored"));
assert!(!journal.path_in(&root).exists());
}
#[test]
fn a_stale_homed_journal_still_refuses_the_next_apply() {
let root = tmp("homed-stale");
let home = tmp("homed-stale-home");
let journal = Journal::default().kept_in(&home).unwrap();
std::fs::write(journal.path_in(&root), b"whatever a crash left").unwrap();
let mut cs = crate::ChangeSet::new();
cs.write("a.md", "a");
cs.write("b.md", "b");
let err = block_on(journal.apply(&cs, &StdFs, &root)).unwrap_err();
assert!(matches!(err, crate::Error::StaleJournal(_)), "{err:?}");
assert_eq!(read(&root, "a.md"), None);
}
#[test]
fn an_apply_makes_a_home_that_does_not_exist_yet() {
let root = tmp("homed-fresh");
let home = tmp("homed-fresh-home").join("nested/never-made");
let journal = Journal::default().kept_in(&home).unwrap();
let mut cs = crate::ChangeSet::new();
cs.write("a.md", "a");
cs.write("b.md", "b");
block_on(journal.apply(&cs, &StdFs, &root)).unwrap();
assert_eq!(read(&root, "b.md").as_deref(), Some("b"));
}
#[test]
fn a_freshly_made_home_is_flushed_before_the_intent_is_trusted_to_it() {
let base = tmp("homed-flush");
let home = base.join("nested/journals");
let journal = Journal::default().kept_in(&home).unwrap();
let root = tmp("homed-flush-root");
let fs = crate::fs_faults::RecordingFs::local();
let mut cs = crate::ChangeSet::new();
cs.write("a.md", "a");
cs.write("b.md", "b");
block_on(journal.apply(&cs, &fs, &root)).unwrap();
let events = fs.events();
let journal_written = events
.iter()
.position(|e| matches!(e, crate::fs_faults::FsEvent::Write(p) if journal.owns_path(p)))
.expect("the journal must be written");
for dir in [base, home.parent().unwrap().to_path_buf(), home] {
let flushed = events.iter().position(|e| {
matches!(e, crate::fs_faults::FsEvent::Sync(p, crate::fs::Durability::Durable)
if *p == dir)
});
match flushed {
Some(at) => assert!(
at < journal_written,
"{} flushed only after the journal was written",
dir.display()
),
None => panic!(
"{} never flushed durable; events: {events:?}",
dir.display()
),
}
}
}
#[test]
fn the_pre_extraction_magic_still_replays() {
let ops = vec![FileOp::Write {
path: "parent.md".into(),
bytes: b"new".to_vec(),
}];
let mut bytes = encode(&ops).unwrap();
assert_eq!(&bytes[..MAGIC.len()], MAGIC);
bytes[..LEGACY_MAGIC.len()].copy_from_slice(LEGACY_MAGIC);
let body_end = bytes.len() - 8;
let checksum = fnv1a(&bytes[..body_end]);
bytes[body_end..].copy_from_slice(&checksum.to_le_bytes());
assert_eq!(decode(&bytes).unwrap(), ops);
}
#[test]
fn a_journal_is_only_ever_written_with_the_current_magic() {
let bytes = encode(&[FileOp::Remove {
path: "gone.md".into(),
}])
.unwrap();
assert_eq!(&bytes[..MAGIC.len()], MAGIC);
assert_ne!(&bytes[..LEGACY_MAGIC.len()], LEGACY_MAGIC);
}
}