Skip to main content

helixforge_engine/
drift.rs

1//! HELIX Drift: how a machine has moved away from a saved baseline or a
2//! workstation profile. A baseline records what was installed, the
3//! configuration files catalog entries write (by checksum, never their
4//! contents), a few machine settings and the entries HelixForge ran there.
5//! Comparing names what is missing, changed or new, and which entry would
6//! put a missing part back; nothing here changes the machine.
7
8use crate::{
9    doctor::System,
10    history,
11    inventory::{Inventory, Status},
12};
13use helixforge_catalog::Catalog;
14use helixforge_core::{Action, Entry, printable, valid_id};
15use helixforge_platform::LinuxInfo;
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18use std::{
19    collections::BTreeMap,
20    path::{Component, Path, PathBuf},
21};
22
23/// The baseline file format this version writes.
24pub const FORMAT: u32 = 1;
25
26/// Limits on what a shared baseline may ask a comparison to do.
27const MAX_FILE: u64 = 4 << 20;
28const MAX_RECORDS: usize = 10_000;
29const MAX_FILES: usize = 1_000;
30
31/// A saved state of a machine.
32#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
33pub struct Baseline {
34    #[serde(default = "format")]
35    pub format: u32,
36    pub name: String,
37    /// When it was taken, in Unix seconds.
38    #[serde(default)]
39    pub taken: u64,
40    /// The distribution it was taken on.
41    #[serde(default)]
42    pub system: String,
43    /// Installed entries.
44    #[serde(default, rename = "entry", skip_serializing_if = "Vec::is_empty")]
45    pub entries: Vec<Recorded>,
46    /// Entries HelixForge ran successfully there that leave no package.
47    #[serde(default, rename = "applied", skip_serializing_if = "Vec::is_empty")]
48    pub applied: Vec<Applied>,
49    /// Configuration files and folders, by checksum.
50    #[serde(default, rename = "file", skip_serializing_if = "Vec::is_empty")]
51    pub files: Vec<FileRecord>,
52    /// Machine settings: the login shell, display manager, repositories.
53    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
54    pub settings: BTreeMap<String, String>,
55}
56
57fn format() -> u32 {
58    FORMAT
59}
60
61/// An installed entry: its id, and where from and which version.
62#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
63pub struct Recorded {
64    pub id: String,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub source: Option<String>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub version: Option<String>,
69}
70
71/// An entry that ran, and when (Unix seconds).
72#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
73pub struct Applied {
74    pub id: String,
75    #[serde(default)]
76    pub when: u64,
77}
78
79/// One configuration path as it was.
80#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
81pub struct FileRecord {
82    /// With `~` for the home folder.
83    pub path: String,
84    pub kind: FileKind,
85    /// The file's checksum, or the folder's (names, kinds and contents).
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub sha256: Option<String>,
88    /// A link's target.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub target: Option<String>,
91    /// The entry that writes it.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub entry: Option<String>,
94}
95
96#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
97#[serde(rename_all = "lowercase")]
98pub enum FileKind {
99    #[default]
100    File,
101    Directory,
102    Link,
103    Missing,
104}
105
106impl FileKind {
107    fn label(self) -> &'static str {
108        match self {
109            FileKind::File => "a file",
110            FileKind::Directory => "a folder",
111            FileKind::Link => "a link",
112            FileKind::Missing => "missing",
113        }
114    }
115}
116
117/// What kind of difference.
118#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
119#[serde(rename_all = "kebab-case")]
120pub enum Kind {
121    /// An application the baseline had, or the profile names, is not installed.
122    Missing,
123    /// A configuration path is gone.
124    FileMissing,
125    /// An entry ran there and has not run here.
126    NotApplied,
127    /// A configuration path is something else now: a file became a link.
128    FileReplaced,
129    /// A configuration file or folder has other contents.
130    FileChanged,
131    /// A machine setting differs.
132    Setting,
133    /// Installed here and not in the baseline.
134    Added,
135    /// Installed in both, at another version or from another source.
136    Version,
137}
138
139impl Kind {
140    pub fn label(self) -> &'static str {
141        match self {
142            Kind::Missing | Kind::FileMissing => "Missing",
143            Kind::NotApplied => "Not run",
144            Kind::FileReplaced => "Replaced",
145            Kind::FileChanged => "Changed",
146            Kind::Setting => "Setting",
147            Kind::Added => "Added",
148            Kind::Version => "Version",
149        }
150    }
151
152    /// What the difference means, for its details.
153    pub fn explain(self) -> &'static str {
154        match self {
155            Kind::Missing => {
156                "The application was installed on the baseline machine, or the profile names it, and is not installed here."
157            }
158            Kind::FileMissing => "The configuration file or folder was there, and is not here.",
159            Kind::NotApplied => {
160                "HelixForge ran this entry successfully on the baseline machine, and its history here has no successful run. It leaves no package to look for, so it may still have been set up another way."
161            }
162            Kind::FileReplaced => {
163                "The path is another kind of thing now: a file became a link or a folder. A link into the Nix store means Home Manager manages it."
164            }
165            Kind::FileChanged => {
166                "The file or folder has other contents than when the baseline was saved. Only checksums are kept, so HelixForge cannot show what changed."
167            }
168            Kind::Setting => {
169                "A machine setting differs: the login shell, the display manager, the Arch repositories, Flatpak's remotes or the desktop."
170            }
171            Kind::Added => "Installed here, and not on the baseline machine.",
172            Kind::Version => "Installed in both, at another version or from another source.",
173        }
174    }
175
176    /// Whether this is part of the setup that is not here.
177    pub fn missing(self) -> bool {
178        matches!(self, Kind::Missing | Kind::FileMissing | Kind::NotApplied)
179    }
180}
181
182/// One difference.
183#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
184pub struct Difference {
185    pub kind: Kind,
186    /// The entry's name, the path or the setting.
187    pub subject: String,
188    pub was: Option<String>,
189    pub now: Option<String>,
190    /// The entry that would put it back, or installs it.
191    pub entry: Option<String>,
192    /// One sentence about it.
193    pub note: String,
194}
195
196/// What a comparison found.
197#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
198pub struct Report {
199    /// What it was compared with.
200    pub against: String,
201    /// Differences, most important first.
202    pub differences: Vec<Difference>,
203    /// How many things were the same.
204    pub matched: usize,
205    /// Entry ids the baseline or profile names that this catalog lacks.
206    pub unknown: Vec<String>,
207}
208
209impl Report {
210    /// The entries that would put back what is missing, in order, once each.
211    pub fn restore(&self) -> Vec<String> {
212        let mut ids: Vec<String> = Vec::new();
213        for difference in &self.differences {
214            if let Some(id) = &difference.entry
215                && difference.kind.missing()
216                && !ids.contains(id)
217            {
218                ids.push(id.clone());
219            }
220        }
221        ids
222    }
223
224    /// How many differences of each label, for a summary line.
225    pub fn counts(&self) -> Vec<(&'static str, usize)> {
226        let mut counts: Vec<(&'static str, usize)> = Vec::new();
227        for difference in &self.differences {
228            let label = difference.kind.label();
229            match counts.iter_mut().find(|(name, _)| *name == label) {
230                Some((_, count)) => *count += 1,
231                None => counts.push((label, 1)),
232            }
233        }
234        counts
235    }
236}
237
238/// Why a baseline cannot be used.
239#[derive(Debug, thiserror::Error)]
240pub enum BaselineError {
241    #[error("cannot read {0}: {1}")]
242    Read(PathBuf, String),
243    #[error("{0} is not a HelixForge baseline: {1}")]
244    Format(PathBuf, String),
245    #[error("{0} was written by a later HelixForge (format {1}); update HelixForge to read it")]
246    Later(PathBuf, u32),
247    #[error("the baseline contains control characters")]
248    Control,
249    #[error("the baseline records more than a baseline can")]
250    TooLarge,
251    #[error("the baseline names {0}, which is not an entry id")]
252    Id(String),
253    #[error("the baseline names a path HelixForge does not compare: {0}")]
254    Path(String),
255    #[error("the name {0:?} gives no usable file name; use letters or digits")]
256    Name(String),
257    #[error("cannot write {0}: {1}")]
258    Write(PathBuf, String),
259}
260
261/// Everything a capture or a comparison reads; tests supply their own.
262pub struct Sources<'a> {
263    pub catalog: &'a Catalog,
264    pub platform: &'a LinuxInfo,
265    pub inventory: &'a Inventory,
266    pub on_path: &'a dyn Fn(&str) -> Option<PathBuf>,
267    pub system: &'a dyn System,
268    pub home: &'a Path,
269    /// The runs recorded here, newest first.
270    pub history: &'a [history::Record],
271    /// The machine's settings, as a capture records them.
272    pub settings: &'a dyn Fn() -> BTreeMap<String, String>,
273}
274
275/// `$XDG_CONFIG_HOME/helixforge/baselines`, or under `~/.config`.
276pub fn dir() -> Option<PathBuf> {
277    crate::profiles::dir().and_then(|profiles| Some(profiles.parent()?.join("baselines")))
278}
279
280/// Record this machine as a baseline. `extra` adds paths of the user's own
281/// to the configuration files the catalog's entries write.
282pub fn capture(name: &str, taken: u64, sources: &Sources, extra: &[String]) -> Baseline {
283    let mut baseline = Baseline {
284        format: FORMAT,
285        name: name.trim().to_string(),
286        taken,
287        system: sources.platform.pretty_name.clone(),
288        settings: (sources.settings)(),
289        ..Baseline::default()
290    };
291    for entry in sources.catalog.entries() {
292        if baseline.entries.iter().any(|known| known.id == entry.id) {
293            continue;
294        }
295        if let Some(status) = sources
296            .inventory
297            .status(entry, sources.platform, sources.on_path)
298            && let Some((source, version)) = installed(&status)
299        {
300            baseline.entries.push(Recorded {
301                id: entry.id.clone(),
302                source: Some(source),
303                version,
304            });
305        }
306    }
307    baseline.applied = applied_here(sources);
308    let mut paths: Vec<(String, Option<String>)> = Vec::new();
309    for entry in sources.catalog.entries() {
310        for written in &entry.writes {
311            if !paths.iter().any(|(path, _)| path == written) {
312                paths.push((written.clone(), Some(entry.id.clone())));
313            }
314        }
315    }
316    for path in extra {
317        let shown = tilde(sources.home, path);
318        if !paths.iter().any(|(known, _)| *known == shown) {
319            paths.push((shown, None));
320        }
321    }
322    for (path, entry) in paths {
323        let Some(full) = expand(sources.home, &path) else {
324            continue;
325        };
326        let (kind, sha256, target) = look(&full);
327        // Paths no entry has written are left out, unless the user named them.
328        if kind == FileKind::Missing && entry.is_some() {
329            continue;
330        }
331        baseline.files.push(FileRecord {
332            path,
333            kind,
334            sha256,
335            target,
336            entry,
337        });
338    }
339    baseline
340}
341
342/// The installed state of a status as (source, version), or None when it
343/// is not installed.
344fn installed(status: &Status) -> Option<(String, Option<String>)> {
345    match status {
346        Status::Installed {
347            source, version, ..
348        }
349        | Status::Update {
350            source, version, ..
351        } => Some((source.clone(), version.clone())),
352        Status::Managed { by, version } => Some((by.clone(), version.clone())),
353        Status::Elsewhere { .. } => Some(("another install".to_string(), None)),
354        Status::Absent { .. } => None,
355    }
356}
357
358/// Entries without a package that HelixForge ran successfully here, newest
359/// run first: configurations and tweaks. Reports and profiles are left out.
360fn applied_here(sources: &Sources) -> Vec<Applied> {
361    let mut applied: Vec<Applied> = Vec::new();
362    for record in sources.history.iter().filter(|record| record.success) {
363        for id in &record.entries {
364            if applied.iter().any(|known| known.id == *id) {
365                continue;
366            }
367            if sources
368                .catalog
369                .find(id)
370                .is_some_and(|entry| leaves_no_package(sources.catalog, entry))
371            {
372                applied.push(Applied {
373                    id: id.clone(),
374                    when: record.unix_timestamp,
375                });
376            }
377        }
378    }
379    applied
380}
381
382/// Whether an entry changes the machine without a package to show for it.
383fn leaves_no_package(catalog: &Catalog, entry: &Entry) -> bool {
384    let report = catalog
385        .tabs
386        .iter()
387        .any(|tab| tab.id == "doctor" && tab.entries().iter().any(|other| other.id == entry.id));
388    entry.identity().is_none()
389        && !report
390        && matches!(entry.action, Action::Script { .. } | Action::Command { .. })
391}
392
393/// Compare the machine with a baseline.
394pub fn compare(baseline: &Baseline, sources: &Sources) -> Report {
395    let mut report = Report {
396        against: printable(&baseline.name),
397        ..Report::default()
398    };
399    let name = |id: &str| {
400        sources
401            .catalog
402            .find(id)
403            .map(|entry| entry.name.clone())
404            .unwrap_or_else(|| printable(id))
405    };
406    // Applications.
407    for recorded in &baseline.entries {
408        let Some(entry) = sources.catalog.find(&recorded.id) else {
409            report.unknown.push(printable(&recorded.id));
410            continue;
411        };
412        let was = shown(recorded.source.as_deref(), recorded.version.as_deref());
413        let status = sources
414            .inventory
415            .status(entry, sources.platform, sources.on_path);
416        match status.as_ref().and_then(installed) {
417            None => report.differences.push(Difference {
418                kind: Kind::Missing,
419                subject: entry.name.clone(),
420                was: Some(was.clone()),
421                now: None,
422                entry: Some(entry.id.clone()),
423                note: format!("was installed ({was}); not here"),
424            }),
425            Some((source, version)) => {
426                let now = shown(Some(&source), version.as_deref());
427                let moved = recorded.source.as_deref().is_some_and(|was| was != source);
428                let changed = match (&recorded.version, &version) {
429                    (Some(was), Some(now)) => was != now,
430                    _ => false,
431                };
432                if moved || changed {
433                    report.differences.push(Difference {
434                        kind: Kind::Version,
435                        subject: entry.name.clone(),
436                        was: Some(was.clone()),
437                        now: Some(now.clone()),
438                        entry: None,
439                        note: format!("{was} there, {now} here"),
440                    });
441                } else {
442                    report.matched += 1;
443                }
444            }
445        }
446    }
447    for entry in sources.catalog.entries() {
448        if baseline.entries.iter().any(|known| known.id == entry.id)
449            || report.differences.iter().any(|known| {
450                known.kind == Kind::Added && known.entry.as_deref() == Some(entry.id.as_str())
451            })
452        {
453            continue;
454        }
455        if let Some(status) = sources
456            .inventory
457            .status(entry, sources.platform, sources.on_path)
458            && let Some((source, version)) = installed(&status)
459        {
460            let now = shown(Some(&source), version.as_deref());
461            report.differences.push(Difference {
462                kind: Kind::Added,
463                subject: entry.name.clone(),
464                was: None,
465                now: Some(now.clone()),
466                entry: Some(entry.id.clone()),
467                note: format!("installed here ({now}); not in the baseline"),
468            });
469        }
470    }
471    // Entries that ran there.
472    let here = applied_here(sources);
473    for applied in &baseline.applied {
474        if sources.catalog.find(&applied.id).is_none() {
475            report.unknown.push(printable(&applied.id));
476            continue;
477        }
478        if here.iter().any(|known| known.id == applied.id) {
479            report.matched += 1;
480            continue;
481        }
482        report.differences.push(Difference {
483            kind: Kind::NotApplied,
484            subject: name(&applied.id),
485            was: Some("ran".to_string()),
486            now: None,
487            entry: Some(applied.id.clone()),
488            note: "ran on the baseline machine; no run of it here".to_string(),
489        });
490    }
491    // Configuration files.
492    for record in &baseline.files {
493        let Some(full) = expand(sources.home, &record.path) else {
494            continue;
495        };
496        let (kind, sha256, target) = look(&full);
497        let path = printable(&record.path);
498        let writer = record
499            .entry
500            .as_deref()
501            .filter(|id| sources.catalog.find(id).is_some())
502            .map(String::from);
503        let by = writer
504            .as_deref()
505            .map(|id| format!(" ({} writes it)", name(id)))
506            .unwrap_or_default();
507        let difference = |kind, note: String| Difference {
508            kind,
509            subject: path.clone(),
510            was: Some(record.kind.label().to_string()),
511            now: None,
512            entry: writer.clone(),
513            note,
514        };
515        if kind == record.kind {
516            let same = match kind {
517                FileKind::Link => same_target(record.target.as_deref(), target.as_deref()),
518                FileKind::File | FileKind::Directory => {
519                    record.sha256.is_none() || sha256.is_none() || record.sha256 == sha256
520                }
521                FileKind::Missing => true,
522            };
523            if same {
524                report.matched += 1;
525                continue;
526            }
527            let note = if kind == FileKind::Link {
528                format!(
529                    "the link points somewhere else: {} → {}",
530                    printable(record.target.as_deref().unwrap_or("?")),
531                    printable(target.as_deref().unwrap_or("?"))
532                )
533            } else {
534                format!("other contents{by}")
535            };
536            report.differences.push(Difference {
537                now: Some(kind.label().to_string()),
538                ..difference(Kind::FileChanged, note)
539            });
540        } else if kind == FileKind::Missing {
541            report.differences.push(difference(
542                Kind::FileMissing,
543                format!("was {}; gone{by}", record.kind.label()),
544            ));
545        } else {
546            let now = match (&kind, &target) {
547                (FileKind::Link, Some(target)) if target.starts_with("/nix/store/") => {
548                    "a link into the Nix store (Home Manager)".to_string()
549                }
550                (FileKind::Link, Some(target)) => format!("a link to {}", printable(target)),
551                _ => kind.label().to_string(),
552            };
553            report.differences.push(Difference {
554                now: Some(now.clone()),
555                ..difference(
556                    Kind::FileReplaced,
557                    format!("was {}, now {now}", record.kind.label()),
558                )
559            });
560        }
561    }
562    // Settings, where both sides know them.
563    let settings = (sources.settings)();
564    for (key, was) in &baseline.settings {
565        let label = setting_label(key);
566        match settings.get(key) {
567            Some(now) if now == was => report.matched += 1,
568            Some(now) => report.differences.push(Difference {
569                kind: Kind::Setting,
570                subject: label.clone(),
571                was: Some(printable(was)),
572                now: Some(printable(now)),
573                entry: None,
574                note: format!("{} there, {} here", printable(was), printable(now)),
575            }),
576            None if key == "desktop" => {}
577            None => report.differences.push(Difference {
578                kind: Kind::Setting,
579                subject: label.clone(),
580                was: Some(printable(was)),
581                now: None,
582                entry: None,
583                note: format!("{} there; not set here", printable(was)),
584            }),
585        }
586    }
587    report.differences.sort_by_key(|difference| difference.kind);
588    report.unknown.sort();
589    report.unknown.dedup();
590    report
591}
592
593/// Compare the machine with a profile: whether each entry it names is
594/// installed, its files are there, or HelixForge has run it here.
595pub fn compare_profile(name: &str, ids: &[String], sources: &Sources) -> Report {
596    let mut report = Report {
597        against: printable(name),
598        ..Report::default()
599    };
600    let mut seen: Vec<String> = Vec::new();
601    let mut queue: Vec<String> = ids.to_vec();
602    queue.reverse();
603    let here = applied_here(sources);
604    while let Some(id) = queue.pop() {
605        if seen.contains(&id) {
606            continue;
607        }
608        seen.push(id.clone());
609        let Some(entry) = sources.catalog.find(&id) else {
610            report.unknown.push(printable(&id));
611            continue;
612        };
613        if let Action::Profile { entries } = &entry.action {
614            queue.extend(entries.iter().rev().cloned());
615            continue;
616        }
617        if entry.identity().is_some() {
618            let status = sources
619                .inventory
620                .status(entry, sources.platform, sources.on_path);
621            if status.as_ref().and_then(installed).is_some() {
622                report.matched += 1;
623            } else {
624                report.differences.push(Difference {
625                    kind: Kind::Missing,
626                    subject: entry.name.clone(),
627                    was: None,
628                    now: None,
629                    entry: Some(entry.id.clone()),
630                    note: "not installed".to_string(),
631                });
632            }
633            continue;
634        }
635        if !entry.writes.is_empty() {
636            let missing: Vec<&String> = entry
637                .writes
638                .iter()
639                .filter(|path| {
640                    expand(sources.home, path).is_none_or(|full| look(&full).0 == FileKind::Missing)
641                })
642                .collect();
643            if missing.is_empty() {
644                report.matched += 1;
645            } else {
646                for path in missing {
647                    report.differences.push(Difference {
648                        kind: Kind::FileMissing,
649                        subject: printable(path),
650                        was: None,
651                        now: None,
652                        entry: Some(entry.id.clone()),
653                        note: format!("not here ({} writes it)", entry.name),
654                    });
655                }
656            }
657            continue;
658        }
659        if here.iter().any(|known| known.id == entry.id) {
660            report.matched += 1;
661        } else {
662            report.differences.push(Difference {
663                kind: Kind::NotApplied,
664                subject: entry.name.clone(),
665                was: None,
666                now: None,
667                entry: Some(entry.id.clone()),
668                note: "no run of it here, and nothing to check it by".to_string(),
669            });
670        }
671    }
672    report.differences.sort_by_key(|difference| difference.kind);
673    report.unknown.sort();
674    report.unknown.dedup();
675    report
676}
677
678/// The settings a baseline records, read from this machine.
679pub fn settings_here(system: &dyn System) -> BTreeMap<String, String> {
680    let mut settings = BTreeMap::new();
681    let (uid, _, _) = system.user();
682    if let Some(passwd) = system.read(Path::new("/etc/passwd")) {
683        let shell = passwd.lines().find_map(|line| {
684            let fields: Vec<&str> = line.split(':').collect();
685            (fields.len() >= 7 && fields[2].parse::<u32>().ok() == Some(uid))
686                .then(|| fields[6].to_string())
687        });
688        if let Some(shell) = shell.filter(|shell| !shell.is_empty()) {
689            settings.insert("shell".to_string(), shell);
690        }
691    }
692    if let Some(target) = system.link(Path::new("/etc/systemd/system/display-manager.service"))
693        && let Some(stem) = target.file_stem()
694    {
695        settings.insert(
696            "display-manager".to_string(),
697            stem.to_string_lossy().into_owned(),
698        );
699    }
700    if let Some(repositories) = system
701        .read(Path::new("/etc/pacman.conf"))
702        .map(|text| crate::preflight::repositories_in(&text))
703        .filter(|repositories| !repositories.is_empty())
704    {
705        settings.insert("repositories".to_string(), repositories.join(", "));
706    }
707    if system.on_path("flatpak")
708        && let Some((true, text)) = system.run(&["flatpak", "remotes", "--columns=name"])
709    {
710        let mut remotes: Vec<&str> = text
711            .lines()
712            .map(str::trim)
713            .filter(|name| !name.is_empty())
714            .collect();
715        remotes.sort_unstable();
716        remotes.dedup();
717        if !remotes.is_empty() {
718            settings.insert("flatpak-remotes".to_string(), remotes.join(", "));
719        }
720    }
721    if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP")
722        && !desktop.is_empty()
723    {
724        settings.insert("desktop".to_string(), desktop);
725    }
726    settings
727}
728
729fn setting_label(key: &str) -> String {
730    match key {
731        "shell" => "Login shell".to_string(),
732        "display-manager" => "Display manager".to_string(),
733        "repositories" => "Arch repositories".to_string(),
734        "flatpak-remotes" => "Flatpak remotes".to_string(),
735        "desktop" => "Desktop".to_string(),
736        other => printable(other),
737    }
738}
739
740fn shown(source: Option<&str>, version: Option<&str>) -> String {
741    match (source, version) {
742        (Some(source), Some(version)) => printable(&format!("{source} {version}")),
743        (Some(source), None) => printable(source),
744        (None, Some(version)) => printable(version),
745        (None, None) => "installed".to_string(),
746    }
747}
748
749/// Links into the Nix store count as the same: Home Manager points them at
750/// a new store path on every switch.
751fn same_target(was: Option<&str>, now: Option<&str>) -> bool {
752    match (was, now) {
753        (Some(was), Some(now)) => {
754            was == now || (was.starts_with("/nix/store/") && now.starts_with("/nix/store/"))
755        }
756        (was, now) => was == now,
757    }
758}
759
760/// A path with `~` for the home folder.
761fn tilde(home: &Path, path: &str) -> String {
762    match Path::new(path).strip_prefix(home) {
763        Ok(rest) if !home.as_os_str().is_empty() => {
764            if rest.as_os_str().is_empty() {
765                "~".to_string()
766            } else {
767                format!("~/{}", rest.display())
768            }
769        }
770        _ => path.to_string(),
771    }
772}
773
774/// `~` expanded; None for a path HelixForge does not compare: relative, or
775/// one that climbs with `..`.
776fn expand(home: &Path, path: &str) -> Option<PathBuf> {
777    let full = match path.strip_prefix("~/") {
778        Some(rest) => home.join(rest),
779        None if path == "~" => home.to_path_buf(),
780        None => PathBuf::from(path),
781    };
782    let plain = full.is_absolute()
783        && full
784            .components()
785            .all(|component| !matches!(component, Component::ParentDir | Component::CurDir));
786    plain.then_some(full)
787}
788
789/// Whether a path is one a baseline may name.
790pub fn valid_path(path: &str) -> bool {
791    !path.chars().any(char::is_control)
792        && path.len() <= 4096
793        && (path.starts_with("~/") || path.starts_with('/'))
794        && expand(Path::new("/home/user"), path).is_some()
795}
796
797/// A folder tree larger than this is compared by its presence only.
798const TREE_FILES: usize = 5_000;
799const TREE_BYTES: u64 = 256 << 20;
800const TREE_DEPTH: usize = 64;
801
802/// What a path is now: its kind, its checksum and a link's target.
803fn look(path: &Path) -> (FileKind, Option<String>, Option<String>) {
804    let Ok(metadata) = std::fs::symlink_metadata(path) else {
805        return (FileKind::Missing, None, None);
806    };
807    if metadata.file_type().is_symlink() {
808        let target = std::fs::read_link(path)
809            .ok()
810            .map(|target| target.to_string_lossy().into_owned());
811        return (FileKind::Link, None, target);
812    }
813    if metadata.is_dir() {
814        let mut hasher = Sha256::new();
815        let mut budget = (TREE_FILES, TREE_BYTES);
816        let sum =
817            tree(path, Path::new(""), &mut hasher, &mut budget).then(|| hex(&hasher.finalize()));
818        return (FileKind::Directory, sum, None);
819    }
820    // A device, a pipe or a socket is compared by its presence only: reading
821    // one could block or never end.
822    let sum = metadata
823        .is_file()
824        .then(|| file_sum(path, TREE_BYTES))
825        .flatten();
826    (FileKind::File, sum, None)
827}
828
829/// A regular file's checksum, reading at most `limit` bytes; None for
830/// anything else, or a file larger than that. The file is opened without
831/// following a link or waiting on a pipe, and checked once open, so a path
832/// swapped after it was looked at is not read either.
833fn file_sum(path: &Path, limit: u64) -> Option<String> {
834    use std::{io::Read, os::unix::fs::OpenOptionsExt};
835    let file = std::fs::OpenOptions::new()
836        .read(true)
837        .custom_flags(nix::libc::O_NOFOLLOW | nix::libc::O_NONBLOCK)
838        .open(path)
839        .ok()?;
840    let metadata = file.metadata().ok()?;
841    if !metadata.is_file() || metadata.len() > limit {
842        return None;
843    }
844    let mut hasher = Sha256::new();
845    let read = std::io::copy(&mut file.take(limit + 1), &mut hasher).ok()?;
846    (read <= limit).then(|| hex(&hasher.finalize()))
847}
848
849/// Hash a folder's names, kinds and contents in a fixed order; false when it
850/// is too large or unreadable to compare.
851fn tree(root: &Path, relative: &Path, hasher: &mut Sha256, budget: &mut (usize, u64)) -> bool {
852    if relative.components().count() > TREE_DEPTH {
853        return false;
854    }
855    let Ok(listing) = std::fs::read_dir(root.join(relative)) else {
856        return false;
857    };
858    let mut names: Vec<std::ffi::OsString> =
859        listing.flatten().map(|item| item.file_name()).collect();
860    names.sort();
861    for name in names {
862        if budget.0 == 0 {
863            return false;
864        }
865        budget.0 -= 1;
866        let child = relative.join(&name);
867        let full = root.join(&child);
868        let Ok(metadata) = std::fs::symlink_metadata(&full) else {
869            return false;
870        };
871        hasher.update(child.to_string_lossy().as_bytes());
872        hasher.update([0]);
873        if metadata.file_type().is_symlink() {
874            hasher.update(b"link ");
875            if let Ok(target) = std::fs::read_link(&full) {
876                hasher.update(target.to_string_lossy().as_bytes());
877            }
878        } else if metadata.is_dir() {
879            hasher.update(b"dir");
880            if !tree(root, &child, hasher, budget) {
881                return false;
882            }
883        } else if metadata.is_file() {
884            if metadata.len() > budget.1 {
885                return false;
886            }
887            hasher.update(b"file ");
888            match file_sum(&full, budget.1) {
889                Some(sum) => hasher.update(sum.as_bytes()),
890                None => return false,
891            }
892            budget.1 -= metadata.len();
893        } else {
894            // Devices, pipes and sockets count by name only.
895            hasher.update(b"special");
896        }
897        hasher.update([0]);
898    }
899    true
900}
901
902fn hex(bytes: &[u8]) -> String {
903    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
904}
905
906/// Read and check a baseline file.
907pub fn read(path: &Path) -> Result<Baseline, BaselineError> {
908    use std::io::Read;
909    let failed = |error: String| BaselineError::Read(path.to_path_buf(), error);
910    let mut text = String::new();
911    std::fs::File::open(path)
912        .and_then(|file| file.take(MAX_FILE + 1).read_to_string(&mut text))
913        .map_err(|error| failed(error.to_string()))?;
914    if text.len() as u64 > MAX_FILE {
915        return Err(failed("larger than a baseline can be".to_string()));
916    }
917    let baseline: Baseline = toml::from_str(&text)
918        .map_err(|error| BaselineError::Format(path.to_path_buf(), printable(error.message())))?;
919    if baseline.format > FORMAT {
920        return Err(BaselineError::Later(path.to_path_buf(), baseline.format));
921    }
922    check(&baseline)?;
923    Ok(baseline)
924}
925
926/// Plain text throughout, ids that are ids and paths HelixForge compares: a
927/// shared file could otherwise carry escape sequences into the terminal.
928pub fn check(baseline: &Baseline) -> Result<(), BaselineError> {
929    let control = |text: &str| text.chars().any(char::is_control);
930    let mut texts: Vec<&str> = vec![&baseline.name, &baseline.system];
931    for recorded in &baseline.entries {
932        texts.extend(recorded.source.as_deref());
933        texts.extend(recorded.version.as_deref());
934    }
935    for record in &baseline.files {
936        texts.extend(record.sha256.as_deref());
937        texts.extend(record.target.as_deref());
938    }
939    for (key, value) in &baseline.settings {
940        texts.push(key);
941        texts.push(value);
942    }
943    if texts.into_iter().any(control) {
944        return Err(BaselineError::Control);
945    }
946    if baseline.entries.len() > MAX_RECORDS
947        || baseline.applied.len() > MAX_RECORDS
948        || baseline.files.len() > MAX_FILES
949        || baseline.settings.len() > MAX_RECORDS
950    {
951        return Err(BaselineError::TooLarge);
952    }
953    let ids = baseline
954        .entries
955        .iter()
956        .map(|recorded| &recorded.id)
957        .chain(baseline.applied.iter().map(|applied| &applied.id))
958        .chain(
959            baseline
960                .files
961                .iter()
962                .filter_map(|record| record.entry.as_ref()),
963        );
964    for id in ids {
965        if !valid_id(id) {
966            return Err(BaselineError::Id(printable(id)));
967        }
968    }
969    if let Some(record) = baseline
970        .files
971        .iter()
972        .find(|record| !valid_path(&record.path))
973    {
974        return Err(BaselineError::Path(printable(&record.path)));
975    }
976    Ok(())
977}
978
979/// Write a baseline to a file, whole or not at all.
980pub fn write(path: &Path, baseline: &Baseline) -> Result<(), BaselineError> {
981    let failed = |error: String| BaselineError::Write(path.to_path_buf(), error);
982    check(baseline)?;
983    if let Some(parent) = path
984        .parent()
985        .filter(|parent| !parent.as_os_str().is_empty())
986    {
987        std::fs::create_dir_all(parent).map_err(|error| failed(error.to_string()))?;
988    }
989    let text = toml::to_string(baseline).map_err(|error| failed(error.to_string()))?;
990    let staging = path.with_extension("toml.new");
991    std::fs::write(
992        &staging,
993        format!("# A HelixForge baseline: helixforge drift check <file>\n{text}"),
994    )
995    .map_err(|error| failed(error.to_string()))?;
996    std::fs::rename(&staging, path).map_err(|error| failed(error.to_string()))
997}
998
999/// Save a baseline in `directory` under its name's slug, replacing one of
1000/// the same name. Returns the file.
1001pub fn save(directory: &Path, baseline: &Baseline) -> Result<PathBuf, BaselineError> {
1002    let slug = crate::profiles::slug(&baseline.name)
1003        .ok_or_else(|| BaselineError::Name(baseline.name.clone()))?;
1004    let path = directory.join(format!("{slug}.toml"));
1005    write(&path, baseline)?;
1006    Ok(path)
1007}
1008
1009/// The baselines in `directory`, newest first, each with its slug; files
1010/// that cannot be used are returned as problems.
1011pub fn load_all(directory: &Path) -> (Vec<(String, Baseline)>, Vec<String>) {
1012    let mut found = Vec::new();
1013    let mut problems = Vec::new();
1014    let Ok(listing) = std::fs::read_dir(directory) else {
1015        return (found, problems);
1016    };
1017    for path in listing.flatten().map(|item| item.path()) {
1018        if path.extension().is_none_or(|extension| extension != "toml") {
1019            continue;
1020        }
1021        let Some(slug) = path
1022            .file_stem()
1023            .and_then(|stem| crate::profiles::slug(&stem.to_string_lossy()))
1024        else {
1025            continue;
1026        };
1027        match read(&path) {
1028            Ok(baseline) => found.push((slug, baseline)),
1029            Err(error) => problems.push(error.to_string()),
1030        }
1031    }
1032    found.sort_by(|a, b| b.1.taken.cmp(&a.1.taken).then_with(|| a.0.cmp(&b.0)));
1033    problems.sort();
1034    (found, problems)
1035}
1036
1037/// Find a saved baseline by its name or slug.
1038pub fn find(directory: &Path, name: &str) -> Option<(String, Baseline)> {
1039    let wanted = crate::profiles::slug(name)?;
1040    load_all(directory)
1041        .0
1042        .into_iter()
1043        .find(|(slug, _)| *slug == wanted)
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048    use super::*;
1049    use crate::doctor::System;
1050    use helixforge_core::PackageManager;
1051    use std::collections::{HashMap, HashSet};
1052
1053    /// A machine whose settings and files come from a temporary home.
1054    struct Fake {
1055        home: PathBuf,
1056        passwd: String,
1057    }
1058
1059    impl System for Fake {
1060        fn read(&self, path: &Path) -> Option<String> {
1061            (path == Path::new("/etc/passwd")).then(|| self.passwd.clone())
1062        }
1063        fn exists(&self, _: &Path) -> bool {
1064            false
1065        }
1066        fn list(&self, _: &Path) -> Vec<String> {
1067            Vec::new()
1068        }
1069        fn link(&self, path: &Path) -> Option<PathBuf> {
1070            (path == Path::new("/etc/systemd/system/display-manager.service"))
1071                .then(|| PathBuf::from("/usr/lib/systemd/system/sddm.service"))
1072        }
1073        fn run(&self, _: &[&str]) -> Option<(bool, String)> {
1074            None
1075        }
1076        fn resolve(&self, _: &str) -> Result<Vec<String>, String> {
1077            Err("offline".into())
1078        }
1079        fn space(&self, _: &Path) -> Option<(u64, u64)> {
1080            None
1081        }
1082        fn owner(&self, _: &Path) -> Option<u32> {
1083            None
1084        }
1085        fn is_link(&self, _: &Path) -> bool {
1086            false
1087        }
1088        fn canonical(&self, _: &Path) -> Option<PathBuf> {
1089            None
1090        }
1091        fn user(&self) -> (u32, u32, PathBuf) {
1092            (1000, 1000, self.home.clone())
1093        }
1094        fn on_path(&self, _: &str) -> bool {
1095            false
1096        }
1097        fn processes(&self) -> Vec<String> {
1098            Vec::new()
1099        }
1100    }
1101
1102    fn arch() -> LinuxInfo {
1103        LinuxInfo {
1104            id: "arch".into(),
1105            pretty_name: "Arch Linux".into(),
1106            id_like: vec![],
1107            manager: Some(PackageManager::Arch),
1108        }
1109    }
1110
1111    fn inventory(packages: &[(&str, &str)]) -> Inventory {
1112        Inventory::from_parts(
1113            Some(PackageManager::Arch),
1114            Some(
1115                packages
1116                    .iter()
1117                    .map(|(name, version)| (name.to_string(), version.to_string()))
1118                    .collect(),
1119            ),
1120            HashMap::new(),
1121            HashMap::new(),
1122            HashSet::new(),
1123        )
1124    }
1125
1126    fn record(entries: &[&str], success: bool, when: u64) -> history::Record {
1127        history::Record {
1128            unix_timestamp: when,
1129            entries: entries.iter().map(|id| id.to_string()).collect(),
1130            names: Vec::new(),
1131            success,
1132            result: String::new(),
1133            log: None,
1134        }
1135    }
1136
1137    fn sources<'a>(
1138        catalog: &'a Catalog,
1139        platform: &'a LinuxInfo,
1140        inventory: &'a Inventory,
1141        fake: &'a Fake,
1142        history: &'a [history::Record],
1143        settings: &'a dyn Fn() -> BTreeMap<String, String>,
1144    ) -> Sources<'a> {
1145        Sources {
1146            catalog,
1147            platform,
1148            inventory,
1149            on_path: &|_| None,
1150            system: fake,
1151            home: &fake.home,
1152            history,
1153            settings,
1154        }
1155    }
1156
1157    #[test]
1158    fn a_fresh_machine_shows_what_the_old_one_had() {
1159        let catalog = Catalog::builtin(false).unwrap();
1160        let platform = arch();
1161        let old_home = tempfile::tempdir().unwrap();
1162        let ghostty = old_home.path().join(".config/ghostty");
1163        std::fs::create_dir_all(ghostty.join("themes")).unwrap();
1164        std::fs::write(ghostty.join("config"), "font-size = 12\n").unwrap();
1165        std::fs::write(old_home.path().join("notes.txt"), "mine\n").unwrap();
1166        let old = Fake {
1167            home: old_home.path().to_path_buf(),
1168            passwd: "me:x:1000:1000::/home/me:/usr/bin/fish\n".into(),
1169        };
1170        let had = inventory(&[("git", "2.47.0-1"), ("ripgrep", "14.1.1-1")]);
1171        let ran = [
1172            record(&["pacman-config"], true, 50),
1173            record(&["pacman-config", "neovim"], false, 45),
1174            record(&["git"], true, 40),
1175        ];
1176        let old_settings = || settings_here(&old);
1177        let extra = [old_home.path().join("notes.txt").display().to_string()];
1178        let baseline = capture(
1179            "Old laptop",
1180            100,
1181            &sources(&catalog, &platform, &had, &old, &ran, &old_settings),
1182            &extra,
1183        );
1184        let ids: Vec<&str> = baseline
1185            .entries
1186            .iter()
1187            .map(|known| known.id.as_str())
1188            .collect();
1189        assert_eq!(ids, ["git", "ripgrep"]);
1190        assert_eq!(baseline.entries[0].version.as_deref(), Some("2.47.0-1"));
1191        assert_eq!(baseline.settings["shell"], "/usr/bin/fish");
1192        assert_eq!(baseline.settings["display-manager"], "sddm");
1193        let paths: Vec<&str> = baseline
1194            .files
1195            .iter()
1196            .map(|record| record.path.as_str())
1197            .collect();
1198        assert!(paths.contains(&"~/.config/ghostty/config"), "{paths:?}");
1199        assert!(paths.contains(&"~/notes.txt"), "{paths:?}");
1200        assert!(
1201            !paths.iter().any(|path| path.contains("kitty")),
1202            "{paths:?}"
1203        );
1204        assert_eq!(baseline.applied.len(), 1);
1205        assert_eq!(baseline.applied[0].id, "pacman-config");
1206
1207        // Saved and read back unchanged.
1208        let directory = tempfile::tempdir().unwrap();
1209        let file = save(directory.path(), &baseline).unwrap();
1210        assert_eq!(file, directory.path().join("old-laptop.toml"));
1211        assert_eq!(read(&file).unwrap(), baseline);
1212        assert_eq!(find(directory.path(), "old laptop").unwrap().1, baseline);
1213
1214        // A fresh machine: only git, another shell, no configuration.
1215        let new_home = tempfile::tempdir().unwrap();
1216        let new = Fake {
1217            home: new_home.path().to_path_buf(),
1218            passwd: "me:x:1000:1000::/home/me:/bin/bash\n".into(),
1219        };
1220        let has = inventory(&[("git", "2.48.0-1"), ("neovim", "0.11.0-1")]);
1221        let new_settings = || settings_here(&new);
1222        let report = compare(
1223            &baseline,
1224            &sources(&catalog, &platform, &has, &new, &[], &new_settings),
1225        );
1226        let by_kind = |kind: Kind| -> Vec<&str> {
1227            report
1228                .differences
1229                .iter()
1230                .filter(|difference| difference.kind == kind)
1231                .map(|difference| difference.subject.as_str())
1232                .collect()
1233        };
1234        assert_eq!(by_kind(Kind::Missing), ["Ripgrep"]);
1235        assert!(by_kind(Kind::FileMissing).contains(&"~/.config/ghostty/config"));
1236        assert_eq!(by_kind(Kind::Setting), ["Login shell"]);
1237        assert_eq!(by_kind(Kind::Added), ["Neovim"]);
1238        assert_eq!(by_kind(Kind::Version), ["Git"]);
1239        // Most important first, and what restores the missing parts.
1240        assert_eq!(report.differences[0].kind, Kind::Missing);
1241        let restore = report.restore();
1242        assert_eq!(restore[0], "ripgrep");
1243        assert!(
1244            restore.contains(&"dotfiles-ghostty".to_string()),
1245            "{restore:?}"
1246        );
1247        assert!(!restore.contains(&"neovim".to_string()));
1248    }
1249
1250    #[test]
1251    fn changed_and_replaced_files_are_told_apart() {
1252        let catalog = Catalog::builtin(false).unwrap();
1253        let platform = arch();
1254        let home = tempfile::tempdir().unwrap();
1255        let config = home.path().join(".config/kitty");
1256        std::fs::create_dir_all(&config).unwrap();
1257        std::fs::write(config.join("kitty.conf"), "a\n").unwrap();
1258        let folder = home.path().join("tree");
1259        std::fs::create_dir_all(folder.join("inner")).unwrap();
1260        std::fs::write(folder.join("inner/file"), "x").unwrap();
1261        let fake = Fake {
1262            home: home.path().to_path_buf(),
1263            passwd: String::new(),
1264        };
1265        let empty = inventory(&[]);
1266        let none = BTreeMap::new;
1267        let at = |path: &Path| path.display().to_string();
1268        let baseline = capture(
1269            "Before",
1270            1,
1271            &sources(&catalog, &platform, &empty, &fake, &[], &none),
1272            &[
1273                at(&config.join("kitty.conf")),
1274                at(&folder),
1275                at(&home.path().join("gone")),
1276            ],
1277        );
1278        let compare_now = || {
1279            compare(
1280                &baseline,
1281                &sources(&catalog, &platform, &empty, &fake, &[], &none),
1282            )
1283        };
1284        let unchanged = compare_now();
1285        assert!(unchanged.differences.is_empty(), "{unchanged:?}");
1286        // A path recorded as missing that is still missing matches.
1287        assert_eq!(unchanged.matched, 3);
1288
1289        std::fs::write(folder.join("inner/file"), "y").unwrap();
1290        std::fs::remove_file(config.join("kitty.conf")).unwrap();
1291        std::os::unix::fs::symlink(
1292            "/nix/store/abc-home-manager-files/kitty.conf",
1293            config.join("kitty.conf"),
1294        )
1295        .unwrap();
1296        let report = compare_now();
1297        let kinds: Vec<(Kind, &str)> = report
1298            .differences
1299            .iter()
1300            .map(|difference| (difference.kind, difference.subject.as_str()))
1301            .collect();
1302        assert_eq!(
1303            kinds,
1304            [
1305                (Kind::FileReplaced, "~/.config/kitty/kitty.conf"),
1306                (Kind::FileChanged, "~/tree"),
1307            ]
1308        );
1309        assert!(report.differences[0].note.contains("Home Manager"));
1310    }
1311
1312    #[test]
1313    fn a_profile_is_checked_entry_by_entry() {
1314        let catalog = Catalog::builtin(false).unwrap();
1315        let platform = arch();
1316        let home = tempfile::tempdir().unwrap();
1317        let fake = Fake {
1318            home: home.path().to_path_buf(),
1319            passwd: String::new(),
1320        };
1321        let has = inventory(&[("git", "1")]);
1322        let none = BTreeMap::new;
1323        let report = compare_profile(
1324            "Developer basics",
1325            &[
1326                "developer-basics".to_string(),
1327                "dotfiles-ghostty".to_string(),
1328                "nope".to_string(),
1329            ],
1330            &sources(&catalog, &platform, &has, &fake, &[], &none),
1331        );
1332        assert_eq!(report.matched, 1);
1333        let missing: Vec<&str> = report
1334            .differences
1335            .iter()
1336            .filter(|difference| difference.kind == Kind::Missing)
1337            .map(|difference| difference.subject.as_str())
1338            .collect();
1339        assert_eq!(missing, ["Ripgrep", "fzf", "Neovim"]);
1340        assert!(
1341            report
1342                .differences
1343                .iter()
1344                .any(|difference| difference.kind == Kind::FileMissing
1345                    && difference.entry.as_deref() == Some("dotfiles-ghostty"))
1346        );
1347        assert_eq!(report.unknown, ["nope"]);
1348    }
1349
1350    #[test]
1351    fn devices_pipes_and_huge_files_are_never_read() {
1352        // /dev/zero would never end; a pipe would wait for a writer.
1353        let (kind, sum, _) = look(Path::new("/dev/zero"));
1354        assert_eq!((kind, sum), (FileKind::File, None));
1355        let directory = tempfile::tempdir().unwrap();
1356        let pipe = directory.path().join("pipe");
1357        nix::unistd::mkfifo(&pipe, nix::sys::stat::Mode::S_IRWXU).unwrap();
1358        assert_eq!(look(&pipe).1, None);
1359        let start = std::time::Instant::now();
1360        let (kind, sum, _) = look(directory.path());
1361        assert_eq!(kind, FileKind::Directory);
1362        assert!(sum.is_some());
1363        assert!(start.elapsed() < std::time::Duration::from_secs(5));
1364        let big = directory.path().join("big");
1365        std::fs::write(&big, vec![0u8; 64]).unwrap();
1366        assert!(file_sum(&big, 63).is_none());
1367        assert!(file_sum(&big, 64).is_some());
1368    }
1369
1370    #[test]
1371    fn shared_baselines_are_checked() {
1372        let base = Baseline {
1373            name: "x".into(),
1374            ..Baseline::default()
1375        };
1376        assert!(check(&base).is_ok());
1377        let evil = Baseline {
1378            name: "Evil\u{1b}[2J".into(),
1379            ..base.clone()
1380        };
1381        assert!(matches!(check(&evil), Err(BaselineError::Control)));
1382        for path in ["relative", "~/../etc/shadow", "/etc/../root", "~user/x"] {
1383            let bad = Baseline {
1384                files: vec![FileRecord {
1385                    path: path.into(),
1386                    ..FileRecord::default()
1387                }],
1388                ..base.clone()
1389            };
1390            assert!(check(&bad).is_err(), "{path}");
1391        }
1392        let bad_id = Baseline {
1393            entries: vec![Recorded {
1394                id: "Not An Id".into(),
1395                ..Recorded::default()
1396            }],
1397            ..base.clone()
1398        };
1399        assert!(check(&bad_id).is_err());
1400        let directory = tempfile::tempdir().unwrap();
1401        let later = directory.path().join("later.toml");
1402        std::fs::write(&later, "format = 9\nname = \"x\"\nunknown = 1\n").unwrap();
1403        assert!(matches!(read(&later), Err(BaselineError::Later(_, 9))));
1404        let huge = Baseline {
1405            files: (0..1001)
1406                .map(|index| FileRecord {
1407                    path: format!("~/f{index}"),
1408                    ..FileRecord::default()
1409                })
1410                .collect(),
1411            ..base.clone()
1412        };
1413        assert!(matches!(check(&huge), Err(BaselineError::TooLarge)));
1414        // Whatever is wrong, no error repeats an escape sequence it was given.
1415        for bad in [
1416            Baseline {
1417                name: "\u{1b}]52;c;x\u{7}".into(),
1418                ..huge.clone()
1419            },
1420            Baseline {
1421                entries: vec![Recorded {
1422                    id: "x\u{1b}[2J".into(),
1423                    ..Recorded::default()
1424                }],
1425                ..base.clone()
1426            },
1427        ] {
1428            let message = check(&bad).unwrap_err().to_string();
1429            assert!(!message.chars().any(char::is_control), "{message:?}");
1430        }
1431        let fine = directory.path().join("fine.toml");
1432        // Fields a later version adds are ignored.
1433        std::fs::write(&fine, "name = \"x\"\nunknown = 1\n").unwrap();
1434        assert_eq!(read(&fine).unwrap().name, "x");
1435    }
1436}