fs_transaction/journal.rs
1//! The write-ahead journal — what makes a whole [`ChangeSet`](crate::ChangeSet)
2//! crash-atomic, not just each file in it.
3//!
4//! [`crate::change`] already lands every file write atomically (via
5//! [`Storage::write_atomic`]) and unwinds the set in memory on any *error*. The
6//! one failure that leaves behind — a `kill -9` or a power cut *between* two of a
7//! set's writes — is what this closes. The mechanism is the classic write-ahead
8//! log, specialized to the one shape a change set takes: a sequence of
9//! whole-file writes, copies, renames, removes, mode flips, and links, each
10//! self-contained.
11//!
12//! ## The protocol
13//!
14//! Before touching a single file, [`ChangeSet::apply`](crate::ChangeSet::apply)
15//! writes this journal — the complete list of intended ops — and flushes it. That
16//! flush is the **commit point**. Because the journal is itself written through
17//! [`Storage::write_atomic`], it appears whole or not at all, so a crash leaves
18//! the disk in exactly one of two states:
19//!
20//! - **No journal** (the crash beat the commit point). No file write had
21//! started yet either, so the tree is untouched — nothing to recover.
22//! - **A whole journal** (the crash came after the commit point). Some, all, or
23//! none of the file writes may have landed. [`recover`] replays the journal
24//! forward — idempotently, so already-applied ops are no-ops — bringing the
25//! tree to the fully-applied state, then deletes the journal.
26//!
27//! So an interrupted change set always resolves to a *consistent* tree:
28//! either fully before it (the commit point was never reached) or fully after it
29//! (recovery rolled it forward). The one honesty worth stating plainly:
30//!
31//! > Which of the two an interruption yields depends on whether the process
32//! > kept control. An **error** returned mid-apply is unwound in memory — the
33//! > tree ends up fully *before*. A **crash** loses that chance, so recovery
34//! > rolls the journaled set fully *forward* instead. Both endpoints are
35//! > consistent; they are simply different consistent states, and this crate
36//! > does not pretend a lost-power change didn't happen when its intent was
37//! > already durably on disk.
38//!
39//! ## Format
40//!
41//! A compact, length-prefixed binary encoding with a magic header and a trailing
42//! checksum. The journal is ephemeral machine state, not something the user
43//! owns, so it is not meant to be read by hand — and binary keeps opaque
44//! payloads (an image staged for a write) exact without escaping.
45//! The checksum is belt-and-suspenders: `write_atomic` already makes the journal
46//! all-or-nothing, so a torn *write* is impossible, but bit-rot on the way back
47//! is not, and a journal that cannot be trusted must be refused loudly rather
48//! than replayed into corruption.
49//!
50//! ## Payload by reference
51//!
52//! One op — [`FileOp::CopyFrom`] — journals a *source path* in place of the bytes
53//! it will write. Without it, a change set putting a whole captured tree back
54//! would duplicate that entire tree into the journal at the commit point,
55//! making a restore two full-tree writes and bounding it by the total size of
56//! the tree rather than the number of files in it.
57//!
58//! Journaling a reference stays deterministic to replay, but only because the
59//! referent is *required* to be immutable. A content-addressed blob
60//! satisfies that by construction — its path is the digest of its own contents —
61//! so replay either finds exactly the bytes the set intended, or finds nothing
62//! and fails loudly. That requirement is a real obligation on whoever stages the
63//! op: pointed at a mutable file, it would let recovery write bytes the
64//! original change never intended, which is the one thing a write-ahead log
65//! exists to prevent.
66
67use std::borrow::Cow;
68use std::path::{Component, Path, PathBuf};
69
70use crate::change::FileOp;
71use crate::error::{Error, Result};
72use crate::fs::Storage;
73
74/// Where a change set's write-ahead journal lives — and, because they must
75/// agree about it, both halves of the protocol that depends on the answer.
76///
77/// The name is a single transient dotfile, by default in the root: it exists
78/// only between a change set's commit point and its completion, so in steady
79/// state the tree carries no journal at all, and no dotfolder is spawned to
80/// hold one. It survives a crash solely so [`Journal::recover`] can find it,
81/// and is removed the moment recovery (or a clean apply) finishes.
82///
83/// ## Why this is a type and not a parameter
84///
85/// [`apply`](Journal::apply) and [`recover`](Journal::recover) have to name the
86/// same file. If they disagree, nothing fails loudly — recovery simply looks
87/// where no journal is and reports [`Recovered::Nothing`], leaving an
88/// interrupted change half-applied forever. A caller that holds one `Journal`
89/// and uses it for both cannot make that mistake, which is why the two
90/// operations live on the value rather than taking the name separately.
91///
92/// [`ChangeSet::apply`](crate::ChangeSet::apply) and the free
93/// [`recover`] are shorthands for `Journal::default()`; reach for a named one
94/// when the default would collide with something the tree already means, or
95/// when an existing deployment already writes a journal under its own name.
96///
97/// ```
98/// # use fs_transaction::journal::Journal;
99/// let journal = Journal::named(".myapp-journal")?;
100/// assert_eq!(journal.name(), ".myapp-journal");
101/// // Not a single path component — refused rather than escaping the root.
102/// assert!(Journal::named("../elsewhere").is_err());
103/// # Ok::<(), fs_transaction::Error>(())
104/// ```
105///
106/// ## A journal outside the tree
107///
108/// By default the journal lands in the root it applies to, which is right for
109/// a tree only this machine writes. It is wrong for a tree something *syncs* —
110/// an iCloud or Dropbox folder — because the journal is this process's crash
111/// state, and a sync service cannot tell it from content: the file travels to
112/// machines that never crashed, where a recovery would replay *another
113/// machine's* intent against a tree that may have moved on, and an apply
114/// would refuse a "stale" journal no local change left behind.
115/// [`kept_in`](Journal::kept_in) is the fix: the journal lives in a directory
116/// the caller owns and nothing syncs (an application-support or cache
117/// directory), and the tree itself never holds a journal at all, transiently
118/// or otherwise.
119///
120/// Two obligations come with a homed journal, both the caller's. The home
121/// must be **absolute** — a relative one would resolve against whatever the
122/// process's current directory happens to be, and a journal written from one
123/// directory and sought from another is exactly the stranding this type
124/// exists to prevent; `kept_in` refuses anything else. And the pairing of
125/// home and root is not recorded anywhere: the journal does not know which
126/// tree it belongs to, so recovering it against a different root replays
127/// intent against the wrong tree. One home directory, one root, one name —
128/// a caller with several roots keeps several names.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct Journal {
131 name: Cow<'static, str>,
132 /// Where the journal file lives, when not in the root itself. Always an
133 /// absolute directory — [`Journal::kept_in`] refuses anything else.
134 home: Option<PathBuf>,
135}
136
137impl Journal {
138 /// The name used when none is given: a dotted, crate-namespaced file
139 /// unlikely to collide with anything the tree itself means.
140 pub const DEFAULT_NAME: &'static str = ".fstx-journal";
141
142 /// A journal under `name`, which must be a single path component — not
143 /// empty, not `.` or `..`, and containing no separator.
144 ///
145 /// The check is what keeps the name from being an escape hatch: it is
146 /// joined onto a caller-supplied root, and a name like `../../elsewhere`
147 /// would write outside the very tree
148 /// [`ChangeSet::apply`](crate::ChangeSet::apply) clamps every staged op
149 /// into.
150 pub fn named(name: impl Into<Cow<'static, str>>) -> Result<Self> {
151 let name = name.into();
152 let mut components = Path::new(name.as_ref()).components();
153 let single =
154 matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none();
155 if !single {
156 return Err(Error::InvalidJournalName(name.into_owned()));
157 }
158 Ok(Self { name, home: None })
159 }
160
161 /// This journal, kept in `home` instead of in the root it applies to.
162 ///
163 /// For trees something syncs — see the type docs for why the journal must
164 /// not live where a sync service can carry it to another machine, and for
165 /// the two obligations (an absolute home, and a stable home–root pairing)
166 /// that come with taking this.
167 ///
168 /// `home` is a directory; the journal keeps its [`name`](Journal::name)
169 /// inside it. A relative `home` is refused
170 /// ([`Error::InvalidJournalHome`]): it would resolve against the process's
171 /// current directory, which apply and a later recovery have no reason to
172 /// share.
173 ///
174 /// ```
175 /// # use fs_transaction::journal::Journal;
176 /// let journal = Journal::named(".myapp-journal")?
177 /// .kept_in("/var/lib/myapp/journals")?;
178 /// assert!(Journal::default().kept_in("not/absolute").is_err());
179 /// # Ok::<(), fs_transaction::Error>(())
180 /// ```
181 pub fn kept_in(self, home: impl Into<PathBuf>) -> Result<Self> {
182 let home = home.into();
183 if !home.is_absolute() {
184 return Err(Error::InvalidJournalHome(home));
185 }
186 Ok(Self {
187 home: Some(home),
188 ..self
189 })
190 }
191
192 /// The journal's file name.
193 pub fn name(&self) -> &str {
194 &self.name
195 }
196
197 /// The directory this journal is [kept in](Journal::kept_in), if it is
198 /// not the root itself. `apply` uses this to make the home before writing
199 /// into it — a root exists by the time anything applies against it, but a
200 /// cache directory may not.
201 pub(crate) fn home(&self) -> Option<&Path> {
202 self.home.as_deref()
203 }
204
205 /// Where this journal lives when applying to `root`: its
206 /// [home](Journal::kept_in) if it has one, otherwise the root itself.
207 pub fn path_in(&self, root: &Path) -> PathBuf {
208 match &self.home {
209 Some(home) => home.join(self.name.as_ref()),
210 None => root.join(self.name.as_ref()),
211 }
212 }
213
214 /// Whether `path` names this journal, or the staging sibling
215 /// [`Storage::write_atomic`] publishes it through.
216 ///
217 /// A containment test on the file name rather than an equality one, so
218 /// that both the journal and its transient `write_atomic` temporary are
219 /// recognized. A [homed](Journal::kept_in) journal additionally requires
220 /// the path to sit in its home: the homed design's whole point is that no
221 /// file in the root is this journal, so a same-named file there — synced
222 /// in, or simply a coincidence — must not be claimed. Used by a
223 /// fault-injecting backend to leave the journal's own writes alone and
224 /// fail only the file writes it means to.
225 pub fn owns_path(&self, path: &Path) -> bool {
226 let name_matches = path
227 .file_name()
228 .and_then(|n| n.to_str())
229 .is_some_and(|n| n.contains(self.name.as_ref()));
230 match &self.home {
231 Some(home) => name_matches && path.parent() == Some(home.as_path()),
232 None => name_matches,
233 }
234 }
235}
236
237impl Default for Journal {
238 fn default() -> Self {
239 Self {
240 name: Cow::Borrowed(Self::DEFAULT_NAME),
241 home: None,
242 }
243 }
244}
245
246/// The magic prefix stamped on every journal, embedding a one-byte format
247/// version (`1`). A file that does not start with this is not a journal this
248/// crate wrote — or is one from an incompatible future version — and is refused
249/// rather than guessed at.
250const MAGIC: &[u8; 8] = b"FSTXJRN1";
251
252/// The magic this crate stamped before it was lifted out of `prov`, where it
253/// was named after a project older still. Accepted on read and never written.
254///
255/// The format is byte-identical, so this costs one comparison — and refusing it
256/// would strand the one tree that can be carrying such a journal: a workspace
257/// interrupted mid-apply by the crash that is the whole reason a journal
258/// outlives its change. There is nothing to roll that forward but this.
259const LEGACY_MAGIC: &[u8; 8] = b"COLOJRN1";
260
261/// Serialize a change set's ops into journal bytes: `MAGIC`, the op count, each
262/// op, then a checksum over everything preceding it.
263pub fn encode(ops: &[FileOp]) -> Result<Vec<u8>> {
264 let mut buf = Vec::with_capacity(64);
265 buf.extend_from_slice(MAGIC);
266 buf.extend_from_slice(&(ops.len() as u64).to_le_bytes());
267 for op in ops {
268 match op {
269 FileOp::Write { path, bytes } => {
270 buf.push(0);
271 put_path(&mut buf, path)?;
272 put_bytes(&mut buf, bytes);
273 }
274 FileOp::Rename { from, to } => {
275 buf.push(1);
276 put_path(&mut buf, from)?;
277 put_path(&mut buf, to)?;
278 }
279 FileOp::Remove { path } => {
280 buf.push(2);
281 put_path(&mut buf, path)?;
282 }
283 // Two paths, no payload — the point of the op. See [`FileOp::CopyFrom`]
284 // for why journaling a *reference* is still deterministic to replay.
285 FileOp::CopyFrom { path, source } => {
286 buf.push(3);
287 put_path(&mut buf, path)?;
288 put_path(&mut buf, source)?;
289 }
290 FileOp::SetExecutable { path, executable } => {
291 buf.push(4);
292 put_path(&mut buf, path)?;
293 buf.push(u8::from(*executable));
294 }
295 // The target is encoded on `put_path`'s terms — UTF-8 or refused at
296 // the commit point — even though it is a link's text rather than a
297 // file of the tree: a journal must replay identically wherever it
298 // is read, and a mangled target is an invented one.
299 FileOp::SetLink { path, target } => {
300 buf.push(5);
301 put_path(&mut buf, path)?;
302 put_path(&mut buf, target)?;
303 }
304 }
305 }
306 let checksum = fnv1a(&buf);
307 buf.extend_from_slice(&checksum.to_le_bytes());
308 Ok(buf)
309}
310
311/// Parse journal bytes back into ops, verifying the magic and the checksum. A
312/// mismatch is an [`Error::Corrupt`] — a journal that cannot be trusted is
313/// refused, never partially replayed.
314pub fn decode(bytes: &[u8]) -> Result<Vec<FileOp>> {
315 let corrupt = |what: &str| Error::Corrupt(what.to_string());
316
317 let stamp = bytes.get(..MAGIC.len());
318 if bytes.len() < MAGIC.len() + 8 + 8
319 || !matches!(stamp, Some(m) if m == MAGIC || m == LEGACY_MAGIC)
320 {
321 return Err(corrupt("not a journal (bad header)"));
322 }
323 let body_end = bytes.len() - 8;
324 let stored = u64::from_le_bytes(bytes[body_end..].try_into().unwrap());
325 if fnv1a(&bytes[..body_end]) != stored {
326 return Err(corrupt("checksum mismatch"));
327 }
328
329 let mut cur = Cursor {
330 bytes: &bytes[..body_end],
331 at: MAGIC.len(),
332 };
333 let count = cur.take_u64()?;
334 // Each op costs at least its one-byte tag, so a count the body cannot
335 // possibly hold is a lie about the record, not a large journal — and it
336 // must be refused *before* it sizes an allocation, or a crafted header
337 // aborts the process instead of erroring.
338 if count > (cur.bytes.len() - cur.at) as u64 {
339 return Err(corrupt("op count exceeds the journal body"));
340 }
341 let mut ops = Vec::with_capacity(count as usize);
342 for _ in 0..count {
343 let op = match cur.take_u8()? {
344 0 => FileOp::Write {
345 path: cur.take_path()?,
346 bytes: cur.take_bytes()?.to_vec(),
347 },
348 1 => FileOp::Rename {
349 from: cur.take_path()?,
350 to: cur.take_path()?,
351 },
352 2 => FileOp::Remove {
353 path: cur.take_path()?,
354 },
355 3 => FileOp::CopyFrom {
356 path: cur.take_path()?,
357 source: cur.take_path()?,
358 },
359 4 => FileOp::SetExecutable {
360 path: cur.take_path()?,
361 // Strictly 0 or 1: any other byte means this is not the
362 // record it claims to be, and a journal that cannot be
363 // trusted is refused, never guessed at.
364 executable: match cur.take_u8()? {
365 0 => false,
366 1 => true,
367 other => {
368 return Err(corrupt(&format!("invalid executable flag {other}")));
369 }
370 },
371 },
372 5 => FileOp::SetLink {
373 path: cur.take_path()?,
374 target: cur.take_path()?,
375 },
376 other => return Err(corrupt(&format!("unknown op tag {other}"))),
377 };
378 ops.push(op);
379 }
380 if cur.at != cur.bytes.len() {
381 return Err(corrupt("trailing bytes after the last op"));
382 }
383 Ok(ops)
384}
385
386/// The outcome of a [`recover`] pass.
387#[derive(Debug, Clone, Copy, PartialEq, Eq)]
388pub enum Recovered {
389 /// No journal was present — steady state, the common case.
390 Nothing,
391 /// A journal was found and its `ops` ops were rolled forward, then it was
392 /// removed. The tree was interrupted mid-change and is now consistent.
393 Applied(usize),
394}
395
396impl Journal {
397 /// Finish any change set a crash left journaled at `root`, rolling the tree
398 /// forward to the fully-applied state, then remove the journal.
399 ///
400 /// The recovery entry point: run it before anything reads the tree, so an
401 /// interrupted change heals first. A no-op when no journal is present, so
402 /// it is cheap to call unconditionally. Replay is idempotent — a write
403 /// already landed is simply rewritten, a rename already done is recognized
404 /// and skipped — so recovering the *same* journal twice (a crash *during*
405 /// recovery) is safe.
406 ///
407 /// Must name the same journal the interrupted [`apply`](Journal::apply)
408 /// wrote. A different one finds nothing and reports
409 /// [`Recovered::Nothing`], which is why both live on this value.
410 pub async fn recover<FS: Storage>(&self, fs: &FS, root: &Path) -> Result<Recovered> {
411 let journal = self.path_in(root);
412 let bytes = match fs.read(&journal).await {
413 Ok(bytes) => bytes,
414 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Recovered::Nothing),
415 Err(e) => return Err(e.into()),
416 };
417 let ops = decode(&bytes)?;
418 // Clamped to the root on `apply`'s own terms, and with more reason: a
419 // journal is bytes found on disk, not a set this process staged, and
420 // the checksum authenticates nothing. An apply refuses an escaping
421 // path before writing; a replay that did not would be the same escape
422 // through the back door — refused instead, with the journal left in
423 // place like any other journal that cannot be trusted.
424 crate::change::guard_ops(&ops)?;
425 let mut touched = std::collections::BTreeSet::new();
426 for op in &ops {
427 replay(fs, root, op, &mut touched).await?;
428 }
429 // Recovery makes the same promise a clean apply does: once the
430 // journal is given up, the state it certified survives a power cut.
431 // The replayed renames, removals, bits, and fresh directory chains
432 // are flushed — barriers capped by one durable sync — before the
433 // journal goes; the deletion itself is not flushed, because a
434 // resurrected journal replays idempotently over ops already durable.
435 crate::fs::flush_all_durable(fs, touched, root).await?;
436 fs.remove_file(&journal).await?;
437 Ok(Recovered::Applied(ops.len()))
438 }
439}
440
441/// Recover the [default](Journal::DEFAULT_NAME) journal at `root` — shorthand
442/// for [`Journal::default().recover(..)`](Journal::recover).
443pub async fn recover<FS: Storage>(fs: &FS, root: &Path) -> Result<Recovered> {
444 Journal::default().recover(fs, root).await
445}
446
447/// Re-apply one journaled op, tolerant of it having already landed before the
448/// crash — this is what makes rolling a journal forward idempotent.
449///
450/// `touched` collects the same flush debt [`crate::change`]'s exec does — the
451/// entries, bits, and fresh chains no per-op call flushes — for
452/// [`Journal::recover`] to settle before the journal is given up.
453async fn replay<FS: Storage>(
454 fs: &FS,
455 root: &Path,
456 op: &FileOp,
457 touched: &mut std::collections::BTreeSet<PathBuf>,
458) -> Result<()> {
459 match op {
460 // Whole-file writes are idempotent by nature: writing the intended bytes
461 // again reaches the same state whether or not the crash beat this op.
462 // `replace`, on apply's own terms: the durability is the recovery's
463 // one batched flush, not the file's.
464 FileOp::Write { path, bytes } => {
465 let full = root.join(path);
466 ensure_parent(fs, &full, touched).await?;
467 fs.replace(&full, bytes).await?;
468 crate::change::settle_write_debt(fs, &full, touched).await?;
469 }
470 // Idempotent for the same reason a `Write` is — with the bytes fetched
471 // from the source rather than carried in the journal. That is sound
472 // exactly as far as the source is immutable ([`FileOp::CopyFrom`]): a
473 // content-addressed blob either holds the intended bytes or is gone, and
474 // gone is an error rather than a silent divergence, because replay must
475 // never invent a state the original set did not intend.
476 FileOp::CopyFrom { path, source } => {
477 let (full, source_full) = (root.join(path), root.join(source));
478 let bytes = fs.read(&source_full).await.map_err(|e| {
479 Error::Recovery(format!(
480 "cannot copy {} from {} — {e}",
481 full.display(),
482 source_full.display()
483 ))
484 })?;
485 ensure_parent(fs, &full, touched).await?;
486 fs.replace(&full, &bytes).await?;
487 crate::change::settle_write_debt(fs, &full, touched).await?;
488 }
489 // A remove of a file already gone is the state we wanted, not a failure.
490 FileOp::Remove { path } => {
491 let full = root.join(path);
492 match fs.remove_file(&full).await {
493 Ok(()) => {}
494 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
495 Err(e) => return Err(e.into()),
496 }
497 if let Some(dir) = crate::fs::parent_dir(&full) {
498 touched.insert(dir.to_path_buf());
499 }
500 }
501 // Setting a bit that is already set (or already cleared) reaches the
502 // same state — idempotent by nature, like a whole-file write. On a
503 // backend that models no bit the call no-ops, which is what the op
504 // means there. The link guard is apply's, for apply's reason: mode
505 // writes follow links, and a journal is bytes this process did not
506 // author.
507 FileOp::SetExecutable { path, executable } => {
508 let full = root.join(path);
509 crate::change::guard_not_link(fs, &full).await?;
510 fs.set_executable(&full, *executable).await?;
511 // The inode barriered while the name still resolves — a later op
512 // in this same journal may rename or remove it — and the parent
513 // batched, on exec's own terms.
514 fs.sync(&full, crate::fs::Durability::Ordered).await?;
515 if let Some(dir) = crate::fs::parent_dir(&full) {
516 touched.insert(dir.to_path_buf());
517 }
518 }
519 // `set_link` replaces whatever is at the path, so replaying it lands
520 // the same link whether the crash beat the op, interrupted it midway
521 // (a remove-then-remake backend caught between the two), or came
522 // after it was done.
523 FileOp::SetLink { path, target } => {
524 let full = root.join(path);
525 ensure_parent(fs, &full, touched).await?;
526 fs.set_link(&full, target).await?;
527 if let Some(dir) = crate::fs::parent_dir(&full) {
528 touched.insert(dir.to_path_buf());
529 }
530 }
531 // The one op that is not naturally idempotent: after it lands, the source
532 // is gone and the destination present, so a blind re-rename would fail.
533 // Recover by state — move it if the source is still there, accept it as
534 // done if only the destination is, and refuse only if *neither* exists,
535 // which no honest interruption of this set can produce.
536 FileOp::Rename { from, to } => {
537 let (from_full, to_full) = (root.join(from), root.join(to));
538 if fs.try_exists(&from_full).await? {
539 ensure_parent(fs, &to_full, touched).await?;
540 fs.rename(&from_full, &to_full).await?;
541 } else if fs.try_exists(&to_full).await? {
542 // Already renamed before the crash — nothing to redo.
543 } else {
544 return Err(Error::Recovery(format!(
545 "neither {} nor {} exists — cannot complete the rename",
546 from_full.display(),
547 to_full.display()
548 )));
549 }
550 // Both entries owe a flush whichever branch ran: even an
551 // already-done rename was done by a crashed process that never
552 // flushed it.
553 for side in [&from_full, &to_full] {
554 if let Some(dir) = crate::fs::parent_dir(side) {
555 touched.insert(dir.to_path_buf());
556 }
557 }
558 }
559 }
560 Ok(())
561}
562
563async fn ensure_parent<FS: Storage>(
564 fs: &FS,
565 full: &Path,
566 touched: &mut std::collections::BTreeSet<PathBuf>,
567) -> Result<()> {
568 if let Some(dir) = crate::fs::parent_dir(full) {
569 for made in crate::fs::create_dir_all_traced(fs, dir).await? {
570 touched.insert(made);
571 }
572 }
573 Ok(())
574}
575
576// ---- encoding helpers ----
577
578fn put_bytes(buf: &mut Vec<u8>, bytes: &[u8]) {
579 buf.extend_from_slice(&(bytes.len() as u64).to_le_bytes());
580 buf.extend_from_slice(bytes);
581}
582
583/// Encode a root-relative path as UTF-8, so a journal written on one platform
584/// replays identically on another. A path that is not UTF-8 is refused at the
585/// commit point rather than mangled into one that is.
586fn put_path(buf: &mut Vec<u8>, path: &Path) -> Result<()> {
587 let s = path
588 .to_str()
589 .ok_or_else(|| Error::NonUtf8Path(path.to_path_buf()))?;
590 put_bytes(buf, s.as_bytes());
591 Ok(())
592}
593
594/// A forward-only reader over the journal body, bounds-checking every take so a
595/// truncated or malformed record surfaces as an error rather than a panic.
596struct Cursor<'a> {
597 bytes: &'a [u8],
598 at: usize,
599}
600
601impl Cursor<'_> {
602 fn short() -> Error {
603 Error::Corrupt("unexpected end of data".into())
604 }
605
606 fn take(&mut self, n: usize) -> Result<&[u8]> {
607 let end = self.at.checked_add(n).ok_or_else(Self::short)?;
608 let slice = self.bytes.get(self.at..end).ok_or_else(Self::short)?;
609 self.at = end;
610 Ok(slice)
611 }
612
613 fn take_u8(&mut self) -> Result<u8> {
614 Ok(self.take(1)?[0])
615 }
616
617 fn take_u64(&mut self) -> Result<u64> {
618 Ok(u64::from_le_bytes(self.take(8)?.try_into().unwrap()))
619 }
620
621 fn take_bytes(&mut self) -> Result<&[u8]> {
622 let len = self.take_u64()? as usize;
623 self.take(len)
624 }
625
626 fn take_path(&mut self) -> Result<PathBuf> {
627 let bytes = self.take_bytes()?;
628 let s = std::str::from_utf8(bytes).map_err(|_| Error::Corrupt("non-UTF-8 path".into()))?;
629 Ok(PathBuf::from(s))
630 }
631}
632
633/// FNV-1a, 64-bit — a small, deterministic, dependency-free checksum. It guards
634/// against bit-rot in a journal read back after a crash; it is not, and need not
635/// be, cryptographic.
636fn fnv1a(data: &[u8]) -> u64 {
637 let mut hash = 0xcbf2_9ce4_8422_2325;
638 for &byte in data {
639 hash ^= u64::from(byte);
640 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
641 }
642 hash
643}
644
645#[cfg(test)]
646mod tests {
647 use super::*;
648 use crate::exec::block_on;
649 use crate::fs::StdFs;
650
651 fn tmp(name: &str) -> PathBuf {
652 let dir = std::env::temp_dir().join(format!("fstx-journal-{name}-{}", std::process::id()));
653 let _ = std::fs::remove_dir_all(&dir);
654 std::fs::create_dir_all(&dir).unwrap();
655 dir
656 }
657
658 fn read(root: &Path, rel: &str) -> Option<String> {
659 std::fs::read_to_string(root.join(rel)).ok()
660 }
661
662 // ---- encoding ----
663
664 #[test]
665 fn a_change_set_round_trips_through_the_journal() {
666 let ops = vec![
667 FileOp::Write {
668 path: "child.md".into(),
669 bytes: b"hello".to_vec(),
670 },
671 FileOp::Rename {
672 from: "a.md".into(),
673 to: "sub/a.md".into(),
674 },
675 FileOp::Remove {
676 path: "gone.md".into(),
677 },
678 FileOp::SetExecutable {
679 path: "run.sh".into(),
680 executable: true,
681 },
682 FileOp::SetLink {
683 path: "link.md".into(),
684 target: "../elsewhere.md".into(),
685 },
686 ];
687 let bytes = encode(&ops).unwrap();
688 assert_eq!(decode(&bytes).unwrap(), ops);
689 }
690
691 #[test]
692 fn an_invalid_executable_flag_is_refused_not_guessed() {
693 // The flag is strictly 0 or 1: any other byte means the record is not
694 // what it claims, and a journal that cannot be trusted is refused.
695 let ops = vec![FileOp::SetExecutable {
696 path: "run.sh".into(),
697 executable: true,
698 }];
699 let mut bytes = encode(&ops).unwrap();
700 // The flag is the byte just before the trailing 8-byte checksum.
701 let flag_at = bytes.len() - 8 - 1;
702 assert_eq!(bytes[flag_at], 1);
703 bytes[flag_at] = 7;
704 // Re-stamp the checksum so only the flag is at fault.
705 let body_end = bytes.len() - 8;
706 let sum = fnv1a(&bytes[..body_end]);
707 bytes[body_end..].copy_from_slice(&sum.to_le_bytes());
708 let err = decode(&bytes).unwrap_err();
709 assert!(err.to_string().contains("executable flag"), "{err}");
710 }
711
712 #[test]
713 fn a_copy_journals_a_reference_not_the_payload() {
714 // The point of the op, stated as an assertion: the journal for a copy is
715 // bounded by the path lengths, not by the size of what it will write.
716 // Without this, restoring a captured tree writes that whole tree
717 // into the journal before touching a single file.
718 let payload: Vec<u8> = vec![7; 512 * 1024];
719 let by_value = encode(&[FileOp::Write {
720 path: "notes/photo.jpg".into(),
721 bytes: payload.clone(),
722 }])
723 .unwrap();
724 let by_reference = encode(&[FileOp::CopyFrom {
725 path: "notes/photo.jpg".into(),
726 source: "history/blobs/9f/86d081".into(),
727 }])
728 .unwrap();
729 assert!(by_value.len() > payload.len(), "a Write carries its bytes");
730 assert!(
731 by_reference.len() < 128,
732 "a CopyFrom carries two paths: {} bytes",
733 by_reference.len()
734 );
735 assert_eq!(decode(&by_reference).unwrap().len(), 1);
736 }
737
738 #[test]
739 fn binary_payloads_survive_the_journal_verbatim() {
740 // An attached photo staged for a write is opaque bytes, not text — the
741 // journal must carry it exactly, with no escaping or UTF-8 assumption.
742 let payload: Vec<u8> = (0u8..=255).cycle().take(1000).collect();
743 let ops = vec![FileOp::Write {
744 path: "photo.png".into(),
745 bytes: payload.clone(),
746 }];
747 let decoded = decode(&encode(&ops).unwrap()).unwrap();
748 assert_eq!(decoded, ops);
749 }
750
751 #[test]
752 fn a_tampered_journal_is_refused_not_replayed() {
753 // The checksum's whole job: a journal whose bytes changed under it must be
754 // rejected loudly, never silently replayed into a corrupt tree.
755 let ops = vec![FileOp::Write {
756 path: "child.md".into(),
757 bytes: b"hello".to_vec(),
758 }];
759 let mut bytes = encode(&ops).unwrap();
760 let mid = bytes.len() / 2;
761 bytes[mid] ^= 0xff;
762 let err = decode(&bytes).unwrap_err();
763 assert!(err.to_string().contains("corrupt"), "{err}");
764 }
765
766 #[test]
767 fn a_non_journal_file_is_rejected() {
768 assert!(decode(b"not a journal at all").is_err());
769 assert!(decode(b"").is_err());
770 }
771
772 // ---- recovery: simulated crashes ----
773 //
774 // A unit test cannot pull the power, so it constructs the exact on-disk state
775 // a crash at a given instant would leave — a whole journal plus some prefix of
776 // its ops applied — and asserts recovery reaches the fully-applied state.
777
778 #[test]
779 fn recovery_completes_a_change_set_that_had_not_started() {
780 // Crash right after the commit point: journal on disk, no op applied yet.
781 let root = tmp("recover-none-applied");
782 std::fs::write(root.join("parent.md"), "old parent").unwrap();
783 let ops = vec![
784 FileOp::Write {
785 path: "child.md".into(),
786 bytes: b"child".to_vec(),
787 },
788 FileOp::Write {
789 path: "parent.md".into(),
790 bytes: b"new parent".to_vec(),
791 },
792 ];
793 std::fs::write(Journal::default().path_in(&root), encode(&ops).unwrap()).unwrap();
794
795 let outcome = block_on(recover(&StdFs, &root)).unwrap();
796
797 assert_eq!(outcome, Recovered::Applied(2));
798 assert_eq!(read(&root, "child.md").as_deref(), Some("child"));
799 assert_eq!(read(&root, "parent.md").as_deref(), Some("new parent"));
800 assert!(
801 !Journal::default().path_in(&root).exists(),
802 "journal must be cleared after recovery"
803 );
804 }
805
806 #[test]
807 fn recovery_completes_a_partially_applied_change_set() {
808 // Crash mid-apply: the first write landed, the second did not.
809 let root = tmp("recover-partial");
810 std::fs::write(root.join("parent.md"), "old parent").unwrap();
811 let ops = vec![
812 FileOp::Write {
813 path: "child.md".into(),
814 bytes: b"child".to_vec(),
815 },
816 FileOp::Write {
817 path: "parent.md".into(),
818 bytes: b"new parent".to_vec(),
819 },
820 ];
821 std::fs::write(Journal::default().path_in(&root), encode(&ops).unwrap()).unwrap();
822 // Simulate the first op having landed before the crash.
823 std::fs::write(root.join("child.md"), "child").unwrap();
824
825 block_on(recover(&StdFs, &root)).unwrap();
826
827 assert_eq!(read(&root, "child.md").as_deref(), Some("child"));
828 assert_eq!(read(&root, "parent.md").as_deref(), Some("new parent"));
829 assert!(!Journal::default().path_in(&root).exists());
830 }
831
832 #[test]
833 fn recovery_rolls_a_rename_forward_from_either_side_of_the_crash() {
834 // A rename is the one non-idempotent op. Recovery must complete it whether
835 // the crash struck before it (source still present) or after (only the
836 // destination present).
837 for already_moved in [false, true] {
838 let root = tmp(&format!("recover-rename-{already_moved}"));
839 let ops = vec![FileOp::Rename {
840 from: "a.md".into(),
841 to: "sub/a.md".into(),
842 }];
843 std::fs::write(Journal::default().path_in(&root), encode(&ops).unwrap()).unwrap();
844 if already_moved {
845 std::fs::create_dir_all(root.join("sub")).unwrap();
846 std::fs::write(root.join("sub/a.md"), "moved").unwrap();
847 } else {
848 std::fs::write(root.join("a.md"), "moved").unwrap();
849 }
850
851 block_on(recover(&StdFs, &root)).unwrap();
852
853 assert_eq!(read(&root, "sub/a.md").as_deref(), Some("moved"));
854 assert!(!root.join("a.md").exists());
855 assert!(!Journal::default().path_in(&root).exists());
856 }
857 }
858
859 #[test]
860 fn recovery_rolls_a_copy_forward_from_its_immutable_source() {
861 // The restore shape: a crash after the commit point, with the payload
862 // still sitting in a content-addressed blob. Replay reads it back and
863 // lands the file, whether or not the copy ran before the crash.
864 for already_copied in [false, true] {
865 let root = tmp(&format!("recover-copy-{already_copied}"));
866 std::fs::create_dir_all(root.join("history/blobs/9f")).unwrap();
867 std::fs::write(root.join("history/blobs/9f/86d081"), "captured bytes").unwrap();
868 std::fs::write(root.join("notes.md"), "damaged bytes").unwrap();
869 let ops = vec![FileOp::CopyFrom {
870 path: "notes.md".into(),
871 source: "history/blobs/9f/86d081".into(),
872 }];
873 std::fs::write(Journal::default().path_in(&root), encode(&ops).unwrap()).unwrap();
874 if already_copied {
875 std::fs::write(root.join("notes.md"), "captured bytes").unwrap();
876 }
877
878 block_on(recover(&StdFs, &root)).unwrap();
879
880 assert_eq!(read(&root, "notes.md").as_deref(), Some("captured bytes"));
881 assert!(!Journal::default().path_in(&root).exists());
882 // The source is read, never consumed: the blob is shared by every
883 // event that names it and must survive the restore.
884 assert!(root.join("history/blobs/9f/86d081").exists());
885 }
886 }
887
888 #[test]
889 fn a_copy_whose_source_is_gone_fails_replay_rather_than_inventing_a_state() {
890 // The cost of journaling a reference: if the referent is missing at replay
891 // time there is nothing to fall back on. That must be loud — writing
892 // nothing, or writing something else, would be recovery reaching a state
893 // the original change set never intended.
894 let root = tmp("recover-copy-missing");
895 std::fs::write(root.join("notes.md"), "damaged bytes").unwrap();
896 let ops = vec![FileOp::CopyFrom {
897 path: "notes.md".into(),
898 source: "history/blobs/9f/86d081".into(),
899 }];
900 std::fs::write(Journal::default().path_in(&root), encode(&ops).unwrap()).unwrap();
901
902 let err = block_on(recover(&StdFs, &root)).unwrap_err();
903 assert!(err.to_string().contains("cannot copy"), "{err}");
904 // The journal stays, so the next recovery can finish once the blob arrives.
905 assert!(Journal::default().path_in(&root).exists());
906 assert_eq!(read(&root, "notes.md").as_deref(), Some("damaged bytes"));
907 }
908
909 #[cfg(unix)]
910 #[test]
911 fn recovery_rolls_modes_and_links_forward_idempotently() {
912 use std::os::unix::fs::PermissionsExt as _;
913
914 // Crash after the commit point with the link already made and the bit
915 // not yet flipped: replay must redo both without tripping over the
916 // half that had landed.
917 let root = tmp("recover-modes-links");
918 std::fs::write(root.join("run.sh"), "#!/bin/sh").unwrap();
919 std::os::unix::fs::symlink("target.md", root.join("link.md")).unwrap();
920 let ops = vec![
921 FileOp::SetLink {
922 path: "link.md".into(),
923 target: "target.md".into(),
924 },
925 FileOp::SetExecutable {
926 path: "run.sh".into(),
927 executable: true,
928 },
929 ];
930 std::fs::write(Journal::default().path_in(&root), encode(&ops).unwrap()).unwrap();
931
932 let outcome = block_on(recover(&StdFs, &root)).unwrap();
933
934 assert_eq!(outcome, Recovered::Applied(2));
935 assert_eq!(
936 std::fs::read_link(root.join("link.md")).unwrap(),
937 PathBuf::from("target.md")
938 );
939 let mode = std::fs::metadata(root.join("run.sh"))
940 .unwrap()
941 .permissions()
942 .mode();
943 assert_ne!(mode & 0o111, 0, "the bit must be set after recovery");
944 }
945
946 #[test]
947 fn recovery_refuses_a_journal_whose_paths_escape_the_root() {
948 // A journal is bytes found on disk, not a set this process staged —
949 // synced from another machine, or planted — and the checksum
950 // authenticates nothing. Replay must clamp to the root exactly as an
951 // apply would, or the escape guard has a back door.
952 let root = tmp("recover-escape");
953 let outside = root.join("../fstx-escaped-by-recovery.md");
954 let _ = std::fs::remove_file(&outside);
955 let ops = vec![FileOp::Write {
956 path: "../fstx-escaped-by-recovery.md".into(),
957 bytes: b"escaped".to_vec(),
958 }];
959 std::fs::write(Journal::default().path_in(&root), encode(&ops).unwrap()).unwrap();
960
961 let err = block_on(recover(&StdFs, &root)).unwrap_err();
962
963 assert!(matches!(err, crate::Error::Escape(_)), "{err:?}");
964 assert!(!outside.exists(), "nothing may land outside the root");
965 assert!(
966 Journal::default().path_in(&root).exists(),
967 "a refused journal is left in place, like any that cannot be trusted"
968 );
969 }
970
971 #[test]
972 fn an_impossible_op_count_is_refused_not_allocated() {
973 // A crafted header claiming u64::MAX ops must surface as Corrupt —
974 // sizing a Vec by it aborts the process on capacity overflow, which
975 // is a denial of service handed to whoever can write the journal.
976 let mut bytes = Vec::new();
977 bytes.extend_from_slice(MAGIC);
978 bytes.extend_from_slice(&u64::MAX.to_le_bytes());
979 let checksum = fnv1a(&bytes);
980 bytes.extend_from_slice(&checksum.to_le_bytes());
981
982 let err = decode(&bytes).unwrap_err();
983 assert!(err.to_string().contains("op count"), "{err}");
984 }
985
986 #[test]
987 fn recovery_flushes_what_it_replayed_before_giving_up_the_journal() {
988 // Recovery keeps the same promise a clean apply does: once the
989 // journal is gone, the state it certified survives a power cut. The
990 // replayed rename's entries must be flushed before the deletion.
991 let root = tmp("recover-flush");
992 std::fs::write(root.join("a.md"), "a").unwrap();
993 let ops = vec![FileOp::Rename {
994 from: "a.md".into(),
995 to: "b.md".into(),
996 }];
997 std::fs::write(Journal::default().path_in(&root), encode(&ops).unwrap()).unwrap();
998
999 let fs = crate::fs_faults::RecordingFs::local();
1000 let outcome = block_on(recover(&fs, &root)).unwrap();
1001 assert_eq!(outcome, Recovered::Applied(1));
1002
1003 use crate::fs_faults::FsEvent;
1004 assert_eq!(
1005 fs.events(),
1006 vec![
1007 FsEvent::Rename(root.join("a.md"), root.join("b.md")),
1008 FsEvent::Sync(root.clone(), crate::fs::Durability::Durable),
1009 FsEvent::Remove(Journal::default().path_in(&root)),
1010 ]
1011 );
1012 }
1013
1014 #[test]
1015 fn recovery_is_a_noop_when_there_is_no_journal() {
1016 let root = tmp("recover-noop");
1017 std::fs::write(root.join("doc.md"), "untouched").unwrap();
1018 assert_eq!(
1019 block_on(recover(&StdFs, &root)).unwrap(),
1020 Recovered::Nothing
1021 );
1022 assert_eq!(read(&root, "doc.md").as_deref(), Some("untouched"));
1023 }
1024
1025 #[test]
1026 fn recovering_the_same_journal_twice_is_safe() {
1027 // A crash *during* recovery must be survivable: replaying an already-
1028 // recovered (or re-created) journal reaches the same state, never an error.
1029 let root = tmp("recover-twice");
1030 std::fs::write(root.join("parent.md"), "old").unwrap();
1031 let ops = vec![FileOp::Write {
1032 path: "parent.md".into(),
1033 bytes: b"new".to_vec(),
1034 }];
1035 let journal = encode(&ops).unwrap();
1036
1037 std::fs::write(Journal::default().path_in(&root), &journal).unwrap();
1038 block_on(recover(&StdFs, &root)).unwrap();
1039 // Recovery removed the journal; imagine the crash left it and re-run.
1040 std::fs::write(Journal::default().path_in(&root), &journal).unwrap();
1041 block_on(recover(&StdFs, &root)).unwrap();
1042
1043 assert_eq!(read(&root, "parent.md").as_deref(), Some("new"));
1044 assert!(!Journal::default().path_in(&root).exists());
1045 }
1046
1047 // ---- a configurable journal name ----
1048
1049 #[test]
1050 fn a_journal_name_must_be_a_single_component() {
1051 // The name is joined onto a caller-supplied root, so anything that could
1052 // climb out of it has to be refused where it is built, not where it is
1053 // used.
1054 for bad in ["", ".", "..", "a/b", "../elsewhere", "/absolute"] {
1055 assert!(
1056 Journal::named(bad).is_err(),
1057 "{bad:?} should be refused as a journal name"
1058 );
1059 }
1060 assert_eq!(
1061 Journal::named(".myapp-journal").unwrap().name(),
1062 ".myapp-journal"
1063 );
1064 }
1065
1066 #[test]
1067 fn apply_and_recover_meet_at_the_named_journal() {
1068 // The point of the type: a set applied under one name is recovered under
1069 // that same name, and the default is not it.
1070 let root = tmp("named-journal");
1071 let journal = Journal::named(".myapp-journal").unwrap();
1072 std::fs::write(root.join("parent.md"), "old parent").unwrap();
1073
1074 let ops = vec![
1075 FileOp::Write {
1076 path: "child.md".into(),
1077 bytes: b"child".to_vec(),
1078 },
1079 FileOp::Write {
1080 path: "parent.md".into(),
1081 bytes: b"new parent".to_vec(),
1082 },
1083 ];
1084 // The state a crash just after the commit point leaves behind.
1085 std::fs::write(journal.path_in(&root), encode(&ops).unwrap()).unwrap();
1086
1087 // The default journal names a file that is not there, so it finds
1088 // nothing — the silent-mismatch failure this API exists to make hard.
1089 assert_eq!(
1090 block_on(Journal::default().recover(&StdFs, &root)).unwrap(),
1091 Recovered::Nothing
1092 );
1093 assert_eq!(read(&root, "parent.md").as_deref(), Some("old parent"));
1094
1095 assert_eq!(
1096 block_on(journal.recover(&StdFs, &root)).unwrap(),
1097 Recovered::Applied(2)
1098 );
1099 assert_eq!(read(&root, "parent.md").as_deref(), Some("new parent"));
1100 assert!(!journal.path_in(&root).exists());
1101 }
1102
1103 #[test]
1104 fn a_named_journal_round_trips_a_whole_apply() {
1105 let root = tmp("named-apply");
1106 let journal = Journal::named(".myapp-journal").unwrap();
1107 let mut cs = crate::ChangeSet::new();
1108 cs.write("a.md", "one");
1109 cs.write("b.md", "two");
1110 block_on(journal.apply(&cs, &StdFs, &root)).unwrap();
1111
1112 assert_eq!(read(&root, "a.md").as_deref(), Some("one"));
1113 assert_eq!(read(&root, "b.md").as_deref(), Some("two"));
1114 // Cleared on a clean apply, and the default was never written.
1115 assert!(!journal.path_in(&root).exists());
1116 assert!(!Journal::default().path_in(&root).exists());
1117 }
1118
1119 // ---- a journal kept outside the tree ----
1120
1121 #[test]
1122 fn a_homed_journal_owns_no_path_in_the_root() {
1123 // The homed design's whole point: no file in the root is this
1124 // journal, so a same-named file there — synced in, or coincidence —
1125 // must not be claimed.
1126 let home = tmp("owns-home");
1127 let root = tmp("owns-root");
1128 let journal = Journal::default().kept_in(&home).unwrap();
1129 assert!(journal.owns_path(&journal.path_in(&root)));
1130 assert!(!journal.owns_path(&root.join(Journal::DEFAULT_NAME)));
1131 }
1132
1133 #[test]
1134 fn a_home_must_be_absolute() {
1135 // A relative home resolves against the process's current directory,
1136 // which apply and recovery have no reason to share — refused at
1137 // construction, not discovered as a stranded change.
1138 let err = Journal::default().kept_in("relative/dir").unwrap_err();
1139 assert!(
1140 matches!(err, crate::Error::InvalidJournalHome(_)),
1141 "{err:?}"
1142 );
1143 }
1144
1145 #[test]
1146 fn a_homed_journal_keeps_the_root_journal_free() {
1147 // The synced-folder deployment: the tree must never hold the journal,
1148 // transiently or otherwise, because a sync service cannot tell crash
1149 // state from content. The recording backend sees every write of the
1150 // apply, so "no write ever landed in the root under the journal's
1151 // name" is checked as stated, not just at the end.
1152 let root = tmp("homed-apply");
1153 let home = tmp("homed-apply-home");
1154 let journal = Journal::default().kept_in(&home).unwrap();
1155
1156 let fs = crate::fs_faults::RecordingFs::local();
1157 let mut cs = crate::ChangeSet::new();
1158 cs.write("a.md", "a");
1159 cs.write("b.md", "b");
1160 block_on(journal.apply(&cs, &fs, &root)).unwrap();
1161
1162 assert_eq!(read(&root, "a.md").as_deref(), Some("a"));
1163 let stray = fs.events().iter().any(|e| {
1164 matches!(e, crate::fs_faults::FsEvent::Write(p)
1165 if p.starts_with(&root)
1166 && p.file_name()
1167 .and_then(|n| n.to_str())
1168 .is_some_and(|n| n.contains(Journal::DEFAULT_NAME)))
1169 });
1170 assert!(!stray, "events: {:?}", fs.events());
1171 assert!(
1172 !journal.path_in(&root).exists(),
1173 "the homed journal is cleared after a clean apply"
1174 );
1175 }
1176
1177 #[test]
1178 fn recovery_finds_a_homed_journal_and_applies_it_to_the_root() {
1179 // The two halves meeting away from the tree: the journal lives in the
1180 // home, the ops land in the root.
1181 let root = tmp("homed-recover");
1182 let home = tmp("homed-recover-home");
1183 let journal = Journal::default().kept_in(&home).unwrap();
1184 let ops = vec![FileOp::Write {
1185 path: "restored.md".into(),
1186 bytes: b"restored".to_vec(),
1187 }];
1188 std::fs::write(journal.path_in(&root), encode(&ops).unwrap()).unwrap();
1189
1190 let outcome = block_on(journal.recover(&StdFs, &root)).unwrap();
1191
1192 assert_eq!(outcome, Recovered::Applied(1));
1193 assert_eq!(read(&root, "restored.md").as_deref(), Some("restored"));
1194 assert!(!journal.path_in(&root).exists());
1195 }
1196
1197 #[test]
1198 fn a_stale_homed_journal_still_refuses_the_next_apply() {
1199 let root = tmp("homed-stale");
1200 let home = tmp("homed-stale-home");
1201 let journal = Journal::default().kept_in(&home).unwrap();
1202 std::fs::write(journal.path_in(&root), b"whatever a crash left").unwrap();
1203
1204 let mut cs = crate::ChangeSet::new();
1205 cs.write("a.md", "a");
1206 cs.write("b.md", "b");
1207 let err = block_on(journal.apply(&cs, &StdFs, &root)).unwrap_err();
1208 assert!(matches!(err, crate::Error::StaleJournal(_)), "{err:?}");
1209 assert_eq!(read(&root, "a.md"), None);
1210 }
1211
1212 #[test]
1213 fn an_apply_makes_a_home_that_does_not_exist_yet() {
1214 // A cache directory on a fresh machine: the home is the journal's own
1215 // infrastructure, so the apply makes it rather than failing on it.
1216 let root = tmp("homed-fresh");
1217 let home = tmp("homed-fresh-home").join("nested/never-made");
1218 let journal = Journal::default().kept_in(&home).unwrap();
1219
1220 let mut cs = crate::ChangeSet::new();
1221 cs.write("a.md", "a");
1222 cs.write("b.md", "b");
1223 block_on(journal.apply(&cs, &StdFs, &root)).unwrap();
1224 assert_eq!(read(&root, "b.md").as_deref(), Some("b"));
1225 }
1226
1227 #[test]
1228 fn a_freshly_made_home_is_flushed_before_the_intent_is_trusted_to_it() {
1229 // The commit point is only as durable as the chain of names holding
1230 // it: a journal file flushed into a directory whose own entry was
1231 // never flushed is one a power cut deletes wholesale — a
1232 // half-applied set with no record to roll forward. Every directory
1233 // the home's making mints must be flushed durable before the journal
1234 // is written.
1235 let base = tmp("homed-flush");
1236 let home = base.join("nested/journals");
1237 let journal = Journal::default().kept_in(&home).unwrap();
1238 let root = tmp("homed-flush-root");
1239
1240 let fs = crate::fs_faults::RecordingFs::local();
1241 let mut cs = crate::ChangeSet::new();
1242 cs.write("a.md", "a");
1243 cs.write("b.md", "b");
1244 block_on(journal.apply(&cs, &fs, &root)).unwrap();
1245
1246 let events = fs.events();
1247 let journal_written = events
1248 .iter()
1249 .position(|e| matches!(e, crate::fs_faults::FsEvent::Write(p) if journal.owns_path(p)))
1250 .expect("the journal must be written");
1251 for dir in [base, home.parent().unwrap().to_path_buf(), home] {
1252 let flushed = events.iter().position(|e| {
1253 matches!(e, crate::fs_faults::FsEvent::Sync(p, crate::fs::Durability::Durable)
1254 if *p == dir)
1255 });
1256 match flushed {
1257 Some(at) => assert!(
1258 at < journal_written,
1259 "{} flushed only after the journal was written",
1260 dir.display()
1261 ),
1262 None => panic!(
1263 "{} never flushed durable; events: {events:?}",
1264 dir.display()
1265 ),
1266 }
1267 }
1268 }
1269
1270 #[test]
1271 fn the_pre_extraction_magic_still_replays() {
1272 // A journal written by `prov` before this crate was lifted out of it
1273 // carries the older stamp. The format is identical, and the only tree
1274 // that can be holding one is a tree interrupted mid-apply — refusing it
1275 // would strand exactly the change a journal exists to finish.
1276 let ops = vec![FileOp::Write {
1277 path: "parent.md".into(),
1278 bytes: b"new".to_vec(),
1279 }];
1280 let mut bytes = encode(&ops).unwrap();
1281 assert_eq!(&bytes[..MAGIC.len()], MAGIC);
1282 bytes[..LEGACY_MAGIC.len()].copy_from_slice(LEGACY_MAGIC);
1283 // The checksum covers the magic, so re-stamping invalidates it; a real
1284 // legacy journal carries the checksum for its own bytes.
1285 let body_end = bytes.len() - 8;
1286 let checksum = fnv1a(&bytes[..body_end]);
1287 bytes[body_end..].copy_from_slice(&checksum.to_le_bytes());
1288
1289 assert_eq!(decode(&bytes).unwrap(), ops);
1290 }
1291
1292 #[test]
1293 fn a_journal_is_only_ever_written_with_the_current_magic() {
1294 let bytes = encode(&[FileOp::Remove {
1295 path: "gone.md".into(),
1296 }])
1297 .unwrap();
1298 assert_eq!(&bytes[..MAGIC.len()], MAGIC);
1299 assert_ne!(&bytes[..LEGACY_MAGIC.len()], LEGACY_MAGIC);
1300 }
1301}