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>,
}
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 })
}
pub fn name(&self) -> &str {
&self.name
}
pub fn path_in(&self, root: &Path) -> PathBuf {
root.join(self.name.as_ref())
}
pub fn owns_path(&self, path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.contains(self.name.as_ref()))
}
}
impl Default for Journal {
fn default() -> Self {
Self {
name: Cow::Borrowed(Self::DEFAULT_NAME),
}
}
}
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)?;
}
}
}
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()?;
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()?,
},
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)?;
for op in &ops {
replay(fs, root, op).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) -> Result<()> {
match op {
FileOp::Write { path, bytes } => {
let full = root.join(path);
ensure_parent(fs, &full).await?;
fs.write_atomic(&full, bytes).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).await?;
fs.write_atomic(&full, &bytes).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()),
}
}
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).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()
)));
}
}
}
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(())
}
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(),
},
];
let bytes = encode(&ops).unwrap();
assert_eq!(decode(&bytes).unwrap(), ops);
}
#[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"));
}
#[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 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);
}
}