Skip to main content

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}