Skip to main content

rlmctl_core/guard/
journal.rs

1//! Write-ahead restore journal for freeze-guard interventions.
2//!
3//! Records all freeze/cap actions with boot_id, inode, and value guards to safely restore
4//! memory.high on process restart or boot. Uses append-only JSON lines with a boot_id header;
5//! stale entries are discarded on boot mismatch.
6//!
7//! # Crash Recovery
8//! On open(), the journal performs WAL tail recovery: if a crash left a partial non-newline-terminated
9//! line (torn write), the file is truncated at that point and synced before returning.
10//!
11//! # Concurrency
12//! All mutating operations (append, remove, clear) are serialized via an internal Mutex to prevent
13//! read-modify-write conflicts. Safe for concurrent access from multiple threads, but append() will
14//! block if remove()/clear() is in progress and vice versa.
15
16use common::Error;
17use serde::{Deserialize, Serialize};
18use std::fs::{self, File, OpenOptions};
19use std::io::{BufRead, BufReader, Read, Write};
20use std::os::unix::fs::OpenOptionsExt;
21use std::path::{Path, PathBuf};
22use std::sync::{atomic::AtomicU64, atomic::Ordering, Mutex};
23
24/// Owner-only permissions for the journal: entries record cgroup paths and
25/// systemd unit names, so both the live file and the temp file `write_entries`
26/// atomically renames over it are created `0600` rather than inheriting the
27/// process umask. Only takes effect when a call actually creates the file
28/// (`O_CREAT`); an already-existing file's mode is left as-is.
29const JOURNAL_FILE_MODE: u32 = 0o600;
30
31// Global counter for unique temp file names per call (guards against multi-threaded stomping).
32static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
33
34/// Action recorded in the journal: freeze or soft cap.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub enum JournalAction {
37    Freeze,
38    Cap,
39}
40
41/// One restore entry: identifies a cgroup and the action taken, with guards for safe restoration.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct JournalEntry {
44    /// Relative cgroupfs path, e.g., "/user.slice/user-1000.slice/user@1000.service/app.slice/app-firefox-12.scope"
45    pub cgroup: String,
46    /// Directory inode at time of action (used to detect cgroup recreation).
47    pub inode: u64,
48    /// Systemd unit name if applicable.
49    pub unit: Option<String>,
50    /// Action taken: Freeze or Cap.
51    pub action: JournalAction,
52    /// Cap only: memory.high value before we modified it.
53    pub prev_high: Option<String>,
54    /// Cap only: memory.high value we wrote.
55    pub our_high: Option<String>,
56}
57
58/// Write-ahead restore journal: crash-safe record of freeze/cap interventions.
59pub struct Journal {
60    path: PathBuf,
61    boot_id: String,
62    // Serializes all mutating operations to prevent torn writes and temp-file collisions.
63    mutation_lock: Mutex<()>,
64}
65
66impl Journal {
67    /// Read journal entries straight off disk without opening a `Journal`
68    /// handle, safe to call from a *second* process (e.g. the CLI) while
69    /// the daemon holds its own live `Journal` and may be appending.
70    ///
71    /// Unlike [`Journal::open`]/[`Journal::entries`], this never truncates,
72    /// rewrites, or performs WAL tail recovery: `open()`'s recovery path
73    /// (`set_len` from a stale read, or a boot-mismatch truncate) is a
74    /// TOCTOU race against the daemon's own writes when run from an
75    /// unrelated process with no cross-process lock: a daemon `append`
76    /// landing between this function's read and a hypothetical fix-up would
77    /// be silently discarded, losing a crash-restore record. This function
78    /// only ever reads: a torn trailing line or any other unparseable line
79    /// is skipped in memory, the file on disk is left byte-for-byte as it
80    /// was found.
81    ///
82    /// Returns an empty vec if the file is missing/unreadable, the header is
83    /// missing/invalid, or the header's `boot_id` doesn't match `boot_id`
84    /// (stale entries from a prior boot are not "current", but reading them
85    /// is not this function's job to discard on disk).
86    pub fn read_entries(path: &Path, boot_id: &str) -> Vec<JournalEntry> {
87        let Ok(contents) = fs::read_to_string(path) else {
88            return vec![];
89        };
90        let mut lines = contents.lines();
91
92        let Some(header_line) = lines.next() else {
93            return vec![];
94        };
95        let Ok(header) = serde_json::from_str::<serde_json::Value>(header_line) else {
96            return vec![];
97        };
98        let Some(stored_boot_id) = header.get("boot_id").and_then(|v| v.as_str()) else {
99            return vec![];
100        };
101        if stored_boot_id != boot_id {
102            return vec![];
103        }
104
105        lines
106            .filter_map(|line| serde_json::from_str::<JournalEntry>(line).ok())
107            .collect()
108    }
109
110    /// Opens the journal file, creating parent directories if needed.
111    ///
112    /// If the file exists and its boot_id header differs from the provided `boot_id`,
113    /// the file is truncated (entries from a prior boot are discarded).
114    ///
115    /// # Arguments
116    /// * `path` - Path to the journal file
117    /// * `boot_id` - Current boot identifier (passed by caller, not read from cgfs)
118    pub fn open(path: PathBuf, boot_id: String) -> common::Result<Self> {
119        // Create parent directories if they don't exist.
120        if let Some(parent) = path.parent() {
121            fs::create_dir_all(parent)?;
122        }
123
124        let journal = Journal {
125            path: path.clone(),
126            boot_id: boot_id.clone(),
127            mutation_lock: Mutex::new(()),
128        };
129
130        // If file exists, check boot_id header.
131        if path.exists() {
132            let file = File::open(&path)?;
133            let reader = BufReader::new(file);
134            let mut lines = reader.lines();
135
136            // Read first line (header).
137            if let Some(Ok(header_line)) = lines.next() {
138                if let Ok(header) = serde_json::from_str::<serde_json::Value>(&header_line) {
139                    if let Some(stored_boot_id) = header.get("boot_id").and_then(|v| v.as_str()) {
140                        if stored_boot_id != boot_id {
141                            // Boot ID mismatch: truncate file and write new header.
142                            journal.write_header()?;
143                            return Ok(journal);
144                        }
145                        // Boot ID matches: perform WAL tail recovery and then return.
146                        journal.recover_tail()?;
147                        return Ok(journal);
148                    }
149                }
150            }
151
152            // Header missing or invalid: rewrite.
153            journal.write_header()?;
154        } else {
155            // File doesn't exist: create with header.
156            journal.write_header()?;
157        }
158
159        Ok(journal)
160    }
161
162    /// Perform WAL tail recovery: truncate at the first line that fails to parse or lacks a newline.
163    /// This recovers from torn writes caused by crashes mid-append.
164    fn recover_tail(&self) -> common::Result<()> {
165        let mut file = File::open(&self.path)?;
166        let mut contents = String::new();
167        file.read_to_string(&mut contents)?;
168
169        let mut byte_offset = 0;
170        let mut found_corruption = false;
171
172        for (idx, line) in contents.lines().enumerate() {
173            if idx == 0 {
174                // Header line: just track bytes.
175                byte_offset += line.len() + 1; // +1 for newline
176                continue;
177            }
178
179            // Check if this line is a valid JournalEntry.
180            let is_valid = serde_json::from_str::<JournalEntry>(line).is_ok();
181
182            // Check if line ends with newline in the original file (the lines iterator strips it).
183            // We need to verify the line is actually followed by a newline in the file content.
184            let line_start = byte_offset;
185            let line_with_newline_len = line.len() + 1;
186            byte_offset += line_with_newline_len;
187
188            // If this line didn't parse, or if we've reached EOF and the last line wasn't terminated
189            // (lines() doesn't tell us if the last line had a newline), we need to check.
190            if !is_valid {
191                found_corruption = true;
192                // Truncate before this line.
193                if line_start > 0 {
194                    self.truncate_at(line_start)?;
195                }
196                break;
197            }
198        }
199
200        // Check if the file ends without a newline (torn write scenario).
201        if !found_corruption && !contents.is_empty() && !contents.ends_with('\n') {
202            // Last line is unterminated. Find where it starts.
203            let last_line_start = contents.rfind('\n').map(|i| i + 1).unwrap_or(0);
204            // Truncate before this unterminated line.
205            self.truncate_at(last_line_start)?;
206        }
207
208        Ok(())
209    }
210
211    /// Truncate the journal file at the given byte offset and sync.
212    fn truncate_at(&self, byte_offset: usize) -> common::Result<()> {
213        let file = OpenOptions::new().write(true).open(&self.path)?;
214        file.set_len(byte_offset as u64)?;
215        file.sync_data()?;
216        Ok(())
217    }
218
219    /// Write the journal header (boot_id line) and fsync.
220    fn write_header(&self) -> common::Result<()> {
221        let mut file = OpenOptions::new()
222            .write(true)
223            .create(true)
224            .truncate(true)
225            .mode(JOURNAL_FILE_MODE)
226            .open(&self.path)?;
227
228        let header = serde_json::json!({ "boot_id": self.boot_id });
229        writeln!(file, "{}", header)?;
230        file.sync_data()?;
231
232        Ok(())
233    }
234
235    /// Append a journal entry (write-ahead: fsyncs before returning).
236    pub fn append(&self, e: &JournalEntry) -> common::Result<()> {
237        let _guard = self.mutation_lock.lock().unwrap();
238
239        let mut file = OpenOptions::new().append(true).open(&self.path)?;
240
241        let json_line = serde_json::to_string(e)
242            .map_err(|err| Error::Cgroup(format!("journal serialization error: {}", err)))?;
243        writeln!(file, "{}", json_line)?;
244        file.sync_data()?;
245
246        Ok(())
247    }
248
249    /// Retrieve all valid entries from the current boot.
250    pub fn entries(&self) -> Vec<JournalEntry> {
251        let Ok(file) = File::open(&self.path) else {
252            return vec![];
253        };
254
255        let reader = BufReader::new(file);
256        let mut entries = vec![];
257
258        for (idx, line) in reader.lines().enumerate() {
259            if idx == 0 {
260                // Skip header.
261                continue;
262            }
263
264            if let Ok(line) = line {
265                if let Ok(entry) = serde_json::from_str::<JournalEntry>(&line) {
266                    entries.push(entry);
267                } else {
268                    // Corrupt line: skip silently.
269                    tracing::warn!("Journal: skipping corrupt line {}", idx);
270                }
271            }
272        }
273
274        entries
275    }
276
277    /// Remove all entries matching the given cgroup (atomic rewrite: temp file + rename + fsync).
278    pub fn remove(&self, cgroup: &str) -> common::Result<()> {
279        let _guard = self.mutation_lock.lock().unwrap();
280
281        let entries = self
282            .entries()
283            .into_iter()
284            .filter(|e| e.cgroup != cgroup)
285            .collect::<Vec<_>>();
286
287        self.write_entries(&entries)?;
288        Ok(())
289    }
290
291    /// Clear all entries, leaving only the header (clean shutdown compaction).
292    pub fn clear(&self) -> common::Result<()> {
293        let _guard = self.mutation_lock.lock().unwrap();
294        self.write_entries(&[])?;
295        Ok(())
296    }
297
298    /// Atomically swap all entries for `cgroup` with `entries` (an empty
299    /// slice removes them), in a single rewrite; every other cgroup's
300    /// entries are preserved untouched. Unlike a separate `remove` followed
301    /// by `append`, there is no window where the on-disk journal has fewer
302    /// (or zero) records for `cgroup` than reality: the old and new entries
303    /// for `cgroup` are swapped in one `write_entries` call under the
304    /// mutation lock, so a crash either lands before (old entries intact)
305    /// or after (new entries intact), never in between.
306    pub fn replace(&self, cgroup: &str, entries: &[JournalEntry]) -> common::Result<()> {
307        let _guard = self.mutation_lock.lock().unwrap();
308
309        let mut all: Vec<JournalEntry> = self
310            .entries()
311            .into_iter()
312            .filter(|e| e.cgroup != cgroup)
313            .collect();
314        all.extend(entries.iter().cloned());
315
316        self.write_entries(&all)?;
317        Ok(())
318    }
319
320    /// Rewrite the journal with a new set of entries (atomic: temp file + rename + fsync).
321    fn write_entries(&self, entries: &[JournalEntry]) -> common::Result<()> {
322        let parent = self
323            .path
324            .parent()
325            .ok_or_else(|| common::Error::Cgroup("Journal path has no parent".to_string()))?;
326
327        // Create temp file with unique name: pid + per-call counter (prevents multi-threaded stomping).
328        let counter = TEMP_COUNTER.fetch_add(1, Ordering::SeqCst);
329        let temp_path = parent.join(format!(".journal-tmp-{}-{}", std::process::id(), counter));
330
331        let mut file = OpenOptions::new()
332            .write(true)
333            .create(true)
334            .truncate(true)
335            .mode(JOURNAL_FILE_MODE)
336            .open(&temp_path)?;
337
338        // Write header.
339        let header = serde_json::json!({ "boot_id": self.boot_id });
340        writeln!(file, "{}", header)?;
341
342        // Write entries.
343        for entry in entries {
344            let json_line = serde_json::to_string(entry)
345                .map_err(|err| Error::Cgroup(format!("journal serialization error: {}", err)))?;
346            writeln!(file, "{}", json_line)?;
347        }
348
349        // Fsync before rename.
350        file.sync_data()?;
351        drop(file);
352
353        // Atomic rename.
354        fs::rename(&temp_path, &self.path)?;
355
356        // Fsync parent directory (best-effort on supported systems).
357        if let Ok(dir) = fs::File::open(parent) {
358            let _ = dir.sync_all();
359        }
360
361        Ok(())
362    }
363}
364
365/// Determine whether a journal entry's memory.high should be restored.
366///
367/// Returns true if:
368/// - The inode matches the current cgroup inode (cgroup hasn't been recreated), AND
369/// - For Cap entries: current memory.high matches our_high (no one else changed it), OR
370/// - For Freeze entries: inode alone is sufficient
371pub fn should_restore(
372    e: &JournalEntry,
373    current_inode: Option<u64>,
374    current_high: Option<&str>,
375) -> bool {
376    if current_inode != Some(e.inode) {
377        return false;
378    }
379
380    match e.action {
381        JournalAction::Freeze => true,
382        JournalAction::Cap => e.our_high.as_deref() == current_high,
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    fn entry(cg: &str) -> JournalEntry {
391        JournalEntry {
392            cgroup: cg.into(),
393            inode: 42,
394            unit: None,
395            action: JournalAction::Cap,
396            prev_high: Some("max".into()),
397            our_high: Some("1000000".into()),
398        }
399    }
400
401    #[test]
402    fn append_then_entries_roundtrip() {
403        let dir = tempfile::tempdir().unwrap();
404        let j = Journal::open(dir.path().join("j.jsonl"), "boot-a".into()).unwrap();
405        j.append(&entry("/x/a")).unwrap();
406        j.append(&entry("/x/b")).unwrap();
407        assert_eq!(j.entries().len(), 2);
408        assert_eq!(j.entries()[0].cgroup, "/x/a");
409    }
410
411    /// Fix round 1, ruling R15: the journal records cgroup paths and unit
412    /// names, so it must be owner-only (`0600`) both when first created
413    /// (`write_header`) and after any full rewrite (`write_entries`'s
414    /// temp-file-then-rename, exercised here via `remove`), never whatever
415    /// the process umask would otherwise give a newly created file.
416    #[test]
417    fn journal_file_is_owner_only_after_create_and_after_rewrite() {
418        use std::os::unix::fs::PermissionsExt;
419
420        let dir = tempfile::tempdir().unwrap();
421        let p = dir.path().join("j.jsonl");
422        let j = Journal::open(p.clone(), "boot-a".into()).unwrap();
423        let mode_of = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777;
424        assert_eq!(mode_of(&p), 0o600, "journal file must be created 0600");
425
426        j.append(&entry("/x/a")).unwrap();
427        j.append(&entry("/x/b")).unwrap();
428        assert_eq!(
429            mode_of(&p),
430            0o600,
431            "journal file must stay 0600 after append"
432        );
433
434        // `remove` rewrites the whole file via write_entries's temp-file +
435        // atomic-rename mechanism (the same one `replace`/`clear` use).
436        j.remove("/x/a").unwrap();
437        assert_eq!(mode_of(&p), 0o600, "rewritten journal file must stay 0600");
438    }
439
440    #[test]
441    fn stale_boot_id_truncates() {
442        let dir = tempfile::tempdir().unwrap();
443        let p = dir.path().join("j.jsonl");
444        let j = Journal::open(p.clone(), "boot-a".into()).unwrap();
445        j.append(&entry("/x/a")).unwrap();
446        drop(j);
447        let j2 = Journal::open(p, "boot-b".into()).unwrap();
448        assert!(
449            j2.entries().is_empty(),
450            "prior-boot entries must be discarded"
451        );
452    }
453
454    #[test]
455    fn remove_deletes_only_matching_cgroup() {
456        let dir = tempfile::tempdir().unwrap();
457        let j = Journal::open(dir.path().join("j.jsonl"), "b".into()).unwrap();
458        j.append(&entry("/x/a")).unwrap();
459        j.append(&entry("/x/b")).unwrap();
460        j.remove("/x/a").unwrap();
461        let e = j.entries();
462        assert_eq!(e.len(), 1);
463        assert_eq!(e[0].cgroup, "/x/b");
464    }
465
466    /// Task 6 review, fix round 2: `replace` must swap only the target
467    /// cgroup's entries in one atomic rewrite; another cgroup's entry is
468    /// left byte-for-byte untouched, and everything survives a re-open
469    /// under the same boot_id (proving it's durably on disk, not just
470    /// in-memory).
471    #[test]
472    fn replace_swaps_only_target_cgroup_atomically() {
473        let dir = tempfile::tempdir().unwrap();
474        let p = dir.path().join("j.jsonl");
475        let j = Journal::open(p.clone(), "boot-a".into()).unwrap();
476        j.append(&entry("/x")).unwrap();
477        j.append(&entry("/y")).unwrap();
478
479        let mut corrected = entry("/x");
480        corrected.our_high = Some("corrected".into());
481        j.replace("/x", &[corrected]).unwrap();
482
483        let entries = j.entries();
484        assert_eq!(entries.len(), 2, "one entry per cgroup, as before");
485        assert!(
486            entries
487                .iter()
488                .any(|e| e.cgroup == "/y" && e == &entry("/y")),
489            "y's entry must be byte-for-byte untouched: {entries:?}"
490        );
491        assert!(
492            entries
493                .iter()
494                .any(|e| e.cgroup == "/x" && e.our_high.as_deref() == Some("corrected")),
495            "x's entry must be replaced with the corrected value: {entries:?}"
496        );
497
498        drop(j);
499        let j2 = Journal::open(p, "boot-a".into()).unwrap();
500        assert_eq!(
501            j2.entries().len(),
502            2,
503            "both entries still readable after re-open with the same boot_id"
504        );
505    }
506
507    #[test]
508    fn replace_with_empty_slice_removes_the_cgroup() {
509        let dir = tempfile::tempdir().unwrap();
510        let j = Journal::open(dir.path().join("j.jsonl"), "b".into()).unwrap();
511        j.append(&entry("/x")).unwrap();
512        j.append(&entry("/y")).unwrap();
513        j.replace("/x", &[]).unwrap();
514        let e = j.entries();
515        assert_eq!(e.len(), 1);
516        assert_eq!(e[0].cgroup, "/y");
517    }
518
519    #[test]
520    fn clear_leaves_header_only() {
521        let dir = tempfile::tempdir().unwrap();
522        let j = Journal::open(dir.path().join("j.jsonl"), "b".into()).unwrap();
523        j.append(&entry("/x/a")).unwrap();
524        j.clear().unwrap();
525        assert!(j.entries().is_empty());
526    }
527
528    #[test]
529    fn corrupt_lines_are_skipped_not_fatal() {
530        let dir = tempfile::tempdir().unwrap();
531        let p = dir.path().join("j.jsonl");
532        let j = Journal::open(p.clone(), "b".into()).unwrap();
533        j.append(&entry("/x/a")).unwrap();
534        use std::io::Write;
535        let mut f = std::fs::OpenOptions::new().append(true).open(&p).unwrap();
536        writeln!(f, "{{garbage").unwrap();
537        assert_eq!(j.entries().len(), 1);
538    }
539
540    #[test]
541    fn should_restore_guards() {
542        let e = entry("/x/a"); // inode 42, our_high "1000000"
543        assert!(should_restore(&e, Some(42), Some("1000000")));
544        assert!(
545            !should_restore(&e, Some(43), Some("1000000")),
546            "inode mismatch must skip"
547        );
548        assert!(
549            !should_restore(&e, None, Some("1000000")),
550            "cgroup gone must skip"
551        );
552        assert!(
553            !should_restore(&e, Some(42), Some("999")),
554            "someone changed high must skip"
555        );
556        let f = JournalEntry {
557            action: JournalAction::Freeze,
558            prev_high: None,
559            our_high: None,
560            ..e
561        };
562        assert!(
563            should_restore(&f, Some(42), None),
564            "freeze entries only need inode"
565        );
566    }
567
568    #[test]
569    fn wal_tail_recovery_on_reopen() {
570        // Regression test for torn-write recovery: crash during append leaves partial line,
571        // next open() truncates it, next append() succeeds with synced data.
572        let dir = tempfile::tempdir().unwrap();
573        let p = dir.path().join("j.jsonl");
574
575        // Write a valid entry.
576        let j = Journal::open(p.clone(), "boot-a".into()).unwrap();
577        j.append(&entry("/x/a")).unwrap();
578        drop(j);
579
580        // Simulate crash: append raw unterminated garbage to the file.
581        use std::io::Write;
582        let mut f = std::fs::OpenOptions::new().append(true).open(&p).unwrap();
583        write!(f, "{{partial").unwrap(); // No newline, incomplete JSON.
584        drop(f);
585
586        // Re-open journal (same boot_id): should truncate the garbage and recover.
587        let j2 = Journal::open(p.clone(), "boot-a".into()).unwrap();
588
589        // Append a new entry: this should succeed and be readable.
590        j2.append(&entry("/x/b")).unwrap();
591
592        // Both entries must be present: old entry + new entry (garbage discarded).
593        let entries = j2.entries();
594        assert_eq!(entries.len(), 2, "both old and new entry must survive");
595        assert_eq!(entries[0].cgroup, "/x/a", "old entry first");
596        assert_eq!(entries[1].cgroup, "/x/b", "new entry second");
597    }
598
599    /// Task 8 review fix: `read_entries` (used by a second process, e.g. the
600    /// CLI, that must never race the daemon's live `Journal` handle) skips a
601    /// torn trailing line in memory instead of `open()`'s WAL tail recovery
602    /// (which would `set_len` the file on disk). Proves both halves: the
603    /// valid entries are still returned, and the on-disk bytes are provably
604    /// untouched (identical before/after).
605    #[test]
606    fn read_entries_is_read_only_and_skips_torn_trailing_line() {
607        let dir = tempfile::tempdir().unwrap();
608        let p = dir.path().join("j.jsonl");
609
610        let j = Journal::open(p.clone(), "boot-a".into()).unwrap();
611        j.append(&entry("/x/a")).unwrap();
612        j.append(&entry("/x/b")).unwrap();
613        drop(j);
614
615        // Simulate a crash mid-append: torn, non-newline-terminated trailing line.
616        use std::io::Write;
617        let mut f = std::fs::OpenOptions::new().append(true).open(&p).unwrap();
618        write!(f, "{{partial").unwrap();
619        drop(f);
620
621        let before = fs::read(&p).unwrap();
622        let entries = Journal::read_entries(&p, "boot-a");
623        let after = fs::read(&p).unwrap();
624
625        assert_eq!(
626            entries.len(),
627            2,
628            "torn trailing line must be skipped, not recovered: {entries:?}"
629        );
630        assert_eq!(entries[0].cgroup, "/x/a");
631        assert_eq!(entries[1].cgroup, "/x/b");
632        assert_eq!(
633            before, after,
634            "read_entries must never mutate the journal file on disk"
635        );
636    }
637
638    /// A boot_id mismatch means the entries are stale, but `read_entries`
639    /// must never truncate the file to express that (only `open()`, which
640    /// owns the file, is allowed to do that); it just reports nothing.
641    #[test]
642    fn read_entries_returns_empty_on_boot_mismatch_without_mutating() {
643        let dir = tempfile::tempdir().unwrap();
644        let p = dir.path().join("j.jsonl");
645
646        let j = Journal::open(p.clone(), "boot-a".into()).unwrap();
647        j.append(&entry("/x/a")).unwrap();
648        drop(j);
649
650        let before = fs::read(&p).unwrap();
651        let entries = Journal::read_entries(&p, "boot-b");
652        let after = fs::read(&p).unwrap();
653
654        assert!(
655            entries.is_empty(),
656            "stale-boot entries must not be returned"
657        );
658        assert_eq!(
659            before, after,
660            "read_entries must not truncate on boot_id mismatch"
661        );
662    }
663}