use std::path::{Path, PathBuf};
use crate::change::FileOp;
use crate::error::{Error, Result};
use crate::fs::Storage;
pub const JOURNAL_NAME: &str = ".prov-journal";
const MAGIC: &[u8; 8] = b"COLOJRN1";
#[cfg(test)]
pub(crate) fn is_journal_path(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.contains("prov-journal"))
}
pub(crate) 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)?;
}
}
}
let checksum = fnv1a(&buf);
buf.extend_from_slice(&checksum.to_le_bytes());
Ok(buf)
}
pub(crate) fn decode(bytes: &[u8]) -> Result<Vec<FileOp>> {
let corrupt = |what: &str| Error::Structure(format!("journal is corrupt: {what}"));
if bytes.len() < MAGIC.len() + 8 + 8 || &bytes[..MAGIC.len()] != MAGIC {
return Err(corrupt("not a prov 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()?,
},
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),
}
pub async fn recover<FS: Storage>(fs: &FS, root: &Path) -> Result<Recovered> {
let journal = root.join(JOURNAL_NAME);
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()))
}
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::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::Structure(format!(
"journal replay: 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::Structure(format!(
"journal cannot encode non-UTF-8 path: {}",
path.display()
))
})?;
put_bytes(buf, s.as_bytes());
Ok(())
}
struct Cursor<'a> {
bytes: &'a [u8],
at: usize,
}
impl Cursor<'_> {
fn short() -> Error {
Error::Structure("journal is 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::Structure("journal is 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!("prov-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 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(root.join(JOURNAL_NAME), 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!(
!root.join(JOURNAL_NAME).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(root.join(JOURNAL_NAME), 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!(!root.join(JOURNAL_NAME).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(root.join(JOURNAL_NAME), 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!(!root.join(JOURNAL_NAME).exists());
}
}
#[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(root.join(JOURNAL_NAME), &journal).unwrap();
block_on(recover(&StdFs, &root)).unwrap();
std::fs::write(root.join(JOURNAL_NAME), &journal).unwrap();
block_on(recover(&StdFs, &root)).unwrap();
assert_eq!(read(&root, "parent.md").as_deref(), Some("new"));
assert!(!root.join(JOURNAL_NAME).exists());
}
}