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 (or the profile names it), and its history here has no successful run. It leaves no package to look for. Where the files it writes, or the settings its installer renders, show it in place, it counts as set up; otherwise it may still have been set up another way, or before HelixForge 1.1 recorded runs."
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    /// The installers' renderers (`configure.py`), which only print: how an
274    /// entry that ran before its runs were recorded is seen to be in place.
275    pub render: &'a dyn Fn(&[String]) -> Result<String, String>,
276}
277
278/// `$XDG_CONFIG_HOME/helixforge/baselines`, or under `~/.config`.
279pub fn dir() -> Option<PathBuf> {
280    crate::profiles::dir().and_then(|profiles| Some(profiles.parent()?.join("baselines")))
281}
282
283/// Record this machine as a baseline. `extra` adds paths of the user's own
284/// to the configuration files the catalog's entries write.
285pub fn capture(name: &str, taken: u64, sources: &Sources, extra: &[String]) -> Baseline {
286    let mut baseline = Baseline {
287        format: FORMAT,
288        name: name.trim().to_string(),
289        taken,
290        system: sources.platform.pretty_name.clone(),
291        settings: (sources.settings)(),
292        ..Baseline::default()
293    };
294    for entry in sources.catalog.entries() {
295        if baseline.entries.iter().any(|known| known.id == entry.id) {
296            continue;
297        }
298        if let Some(status) = sources
299            .inventory
300            .status(entry, sources.platform, sources.on_path)
301            && let Some((source, version)) = installed(&status)
302        {
303            baseline.entries.push(Recorded {
304                id: entry.id.clone(),
305                source: Some(source),
306                version,
307            });
308        }
309    }
310    baseline.applied = applied_here(sources);
311    let mut paths: Vec<(String, Option<String>)> = Vec::new();
312    for entry in sources.catalog.entries() {
313        for written in &entry.writes {
314            if !paths.iter().any(|(path, _)| path == written) {
315                paths.push((written.clone(), Some(entry.id.clone())));
316            }
317        }
318    }
319    for path in extra {
320        let shown = tilde(sources.home, path);
321        if !paths.iter().any(|(known, _)| *known == shown) {
322            paths.push((shown, None));
323        }
324    }
325    for (path, entry) in paths {
326        let Some(full) = expand(sources.home, &path) else {
327            continue;
328        };
329        let (kind, sha256, target) = look(&full);
330        // Paths no entry has written are left out, unless the user named them.
331        if kind == FileKind::Missing && entry.is_some() {
332            continue;
333        }
334        baseline.files.push(FileRecord {
335            path,
336            kind,
337            sha256,
338            target,
339            entry,
340        });
341    }
342    baseline
343}
344
345/// The installed state of a status as (source, version), or None when it
346/// is not installed.
347fn installed(status: &Status) -> Option<(String, Option<String>)> {
348    match status {
349        Status::Installed {
350            source, version, ..
351        }
352        | Status::Update {
353            source, version, ..
354        } => Some((source.clone(), version.clone())),
355        Status::Managed { by, version } => Some((by.clone(), version.clone())),
356        Status::Elsewhere { .. } => Some(("another install".to_string(), None)),
357        Status::Absent { .. } => None,
358    }
359}
360
361/// Entries without a package that HelixForge ran successfully here, newest
362/// run first: configurations and tweaks. Reports and profiles are left out.
363fn applied_here(sources: &Sources) -> Vec<Applied> {
364    let mut applied: Vec<Applied> = Vec::new();
365    for record in sources.history.iter().filter(|record| record.success) {
366        for id in &record.entries {
367            if applied.iter().any(|known| known.id == *id) {
368                continue;
369            }
370            if sources
371                .catalog
372                .find(id)
373                .is_some_and(|entry| leaves_no_package(sources.catalog, entry))
374            {
375                applied.push(Applied {
376                    id: id.clone(),
377                    when: record.unix_timestamp,
378                });
379            }
380        }
381    }
382    applied
383}
384
385/// Whether what an entry sets up is here, though no run of it is recorded:
386/// it ran before HelixForge recorded runs (v1.1.0), or was set up by hand.
387/// The files it writes are all there, or the installer's own renderer finds
388/// its settings in place. `None` when nothing tells.
389fn set_up_here(entry: &Entry, sources: &Sources) -> Option<bool> {
390    if let Some(rendered) = crate::preview::rendered_in_place(entry, sources.system, sources.render)
391    {
392        return Some(rendered);
393    }
394    if entry.writes.is_empty() {
395        return None;
396    }
397    Some(entry.writes.iter().all(|path| {
398        expand(sources.home, path).is_some_and(|full| look(&full).0 != FileKind::Missing)
399    }))
400}
401
402/// Whether an entry changes the machine without a package to show for it.
403fn leaves_no_package(catalog: &Catalog, entry: &Entry) -> bool {
404    let report = catalog
405        .tabs
406        .iter()
407        .any(|tab| tab.id == "doctor" && tab.entries().iter().any(|other| other.id == entry.id));
408    entry.identity().is_none()
409        && !report
410        && matches!(entry.action, Action::Script { .. } | Action::Command { .. })
411}
412
413/// Compare the machine with a baseline.
414pub fn compare(baseline: &Baseline, sources: &Sources) -> Report {
415    let mut report = Report {
416        against: printable(&baseline.name),
417        ..Report::default()
418    };
419    let name = |id: &str| {
420        sources
421            .catalog
422            .find(id)
423            .map(|entry| entry.name.clone())
424            .unwrap_or_else(|| printable(id))
425    };
426    // Applications.
427    for recorded in &baseline.entries {
428        let Some(entry) = sources.catalog.find(&recorded.id) else {
429            report.unknown.push(printable(&recorded.id));
430            continue;
431        };
432        let was = shown(recorded.source.as_deref(), recorded.version.as_deref());
433        let status = sources
434            .inventory
435            .status(entry, sources.platform, sources.on_path);
436        match status.as_ref().and_then(installed) {
437            None => report.differences.push(Difference {
438                kind: Kind::Missing,
439                subject: entry.name.clone(),
440                was: Some(was.clone()),
441                now: None,
442                entry: Some(entry.id.clone()),
443                note: format!("was installed ({was}); not here"),
444            }),
445            Some((source, version)) => {
446                let now = shown(Some(&source), version.as_deref());
447                let moved = recorded.source.as_deref().is_some_and(|was| was != source);
448                let changed = match (&recorded.version, &version) {
449                    (Some(was), Some(now)) => was != now,
450                    _ => false,
451                };
452                if moved || changed {
453                    report.differences.push(Difference {
454                        kind: Kind::Version,
455                        subject: entry.name.clone(),
456                        was: Some(was.clone()),
457                        now: Some(now.clone()),
458                        entry: None,
459                        note: format!("{was} there, {now} here"),
460                    });
461                } else {
462                    report.matched += 1;
463                }
464            }
465        }
466    }
467    for entry in sources.catalog.entries() {
468        if baseline.entries.iter().any(|known| known.id == entry.id)
469            || report.differences.iter().any(|known| {
470                known.kind == Kind::Added && known.entry.as_deref() == Some(entry.id.as_str())
471            })
472        {
473            continue;
474        }
475        if let Some(status) = sources
476            .inventory
477            .status(entry, sources.platform, sources.on_path)
478            && let Some((source, version)) = installed(&status)
479        {
480            let now = shown(Some(&source), version.as_deref());
481            report.differences.push(Difference {
482                kind: Kind::Added,
483                subject: entry.name.clone(),
484                was: None,
485                now: Some(now.clone()),
486                entry: Some(entry.id.clone()),
487                note: format!("installed here ({now}); not in the baseline"),
488            });
489        }
490    }
491    // Entries that ran there.
492    let here = applied_here(sources);
493    for applied in &baseline.applied {
494        if sources.catalog.find(&applied.id).is_none() {
495            report.unknown.push(printable(&applied.id));
496            continue;
497        }
498        if here.iter().any(|known| known.id == applied.id) {
499            report.matched += 1;
500            continue;
501        }
502        let evidence = sources
503            .catalog
504            .find(&applied.id)
505            .and_then(|entry| set_up_here(entry, sources));
506        if evidence == Some(true) {
507            report.matched += 1;
508            continue;
509        }
510        report.differences.push(Difference {
511            kind: Kind::NotApplied,
512            subject: name(&applied.id),
513            was: Some("ran".to_string()),
514            now: None,
515            entry: Some(applied.id.clone()),
516            note: not_run_note("ran on the baseline machine", evidence),
517        });
518    }
519    // Configuration files.
520    for record in &baseline.files {
521        let Some(full) = expand(sources.home, &record.path) else {
522            continue;
523        };
524        let (kind, sha256, target) = look(&full);
525        let path = printable(&record.path);
526        let writer = record
527            .entry
528            .as_deref()
529            .filter(|id| sources.catalog.find(id).is_some())
530            .map(String::from);
531        let by = writer
532            .as_deref()
533            .map(|id| format!(" ({} writes it)", name(id)))
534            .unwrap_or_default();
535        let difference = |kind, note: String| Difference {
536            kind,
537            subject: path.clone(),
538            was: Some(record.kind.label().to_string()),
539            now: None,
540            entry: writer.clone(),
541            note,
542        };
543        if kind == record.kind {
544            let same = match kind {
545                FileKind::Link => same_target(record.target.as_deref(), target.as_deref()),
546                FileKind::File | FileKind::Directory => {
547                    record.sha256.is_none() || sha256.is_none() || record.sha256 == sha256
548                }
549                FileKind::Missing => true,
550            };
551            if same {
552                report.matched += 1;
553                continue;
554            }
555            let note = if kind == FileKind::Link {
556                format!(
557                    "the link points somewhere else: {} → {}",
558                    printable(record.target.as_deref().unwrap_or("?")),
559                    printable(target.as_deref().unwrap_or("?"))
560                )
561            } else {
562                format!("other contents{by}")
563            };
564            report.differences.push(Difference {
565                now: Some(kind.label().to_string()),
566                ..difference(Kind::FileChanged, note)
567            });
568        } else if kind == FileKind::Missing {
569            report.differences.push(difference(
570                Kind::FileMissing,
571                format!("was {}; gone{by}", record.kind.label()),
572            ));
573        } else {
574            let now = match (&kind, &target) {
575                (FileKind::Link, Some(target)) if target.starts_with("/nix/store/") => {
576                    "a link into the Nix store (Home Manager)".to_string()
577                }
578                (FileKind::Link, Some(target)) => format!("a link to {}", printable(target)),
579                _ => kind.label().to_string(),
580            };
581            report.differences.push(Difference {
582                now: Some(now.clone()),
583                ..difference(
584                    Kind::FileReplaced,
585                    format!("was {}, now {now}", record.kind.label()),
586                )
587            });
588        }
589    }
590    // Settings, where both sides know them.
591    let settings = (sources.settings)();
592    for (key, was) in &baseline.settings {
593        let label = setting_label(key);
594        match settings.get(key) {
595            Some(now) if now == was => report.matched += 1,
596            Some(now) => report.differences.push(Difference {
597                kind: Kind::Setting,
598                subject: label.clone(),
599                was: Some(printable(was)),
600                now: Some(printable(now)),
601                entry: None,
602                note: format!("{} there, {} here", printable(was), printable(now)),
603            }),
604            None if key == "desktop" => {}
605            None => report.differences.push(Difference {
606                kind: Kind::Setting,
607                subject: label.clone(),
608                was: Some(printable(was)),
609                now: None,
610                entry: None,
611                note: format!("{} there; not set here", printable(was)),
612            }),
613        }
614    }
615    report.differences.sort_by_key(|difference| difference.kind);
616    report.unknown.sort();
617    report.unknown.dedup();
618    report
619}
620
621/// Compare the machine with a profile: whether each entry it names is
622/// installed, its files are there, or HelixForge has run it here.
623pub fn compare_profile(name: &str, ids: &[String], sources: &Sources) -> Report {
624    let mut report = Report {
625        against: printable(name),
626        ..Report::default()
627    };
628    let mut seen: Vec<String> = Vec::new();
629    let mut queue: Vec<String> = ids.to_vec();
630    queue.reverse();
631    let here = applied_here(sources);
632    while let Some(id) = queue.pop() {
633        if seen.contains(&id) {
634            continue;
635        }
636        seen.push(id.clone());
637        let Some(entry) = sources.catalog.find(&id) else {
638            report.unknown.push(printable(&id));
639            continue;
640        };
641        if let Action::Profile { entries } = &entry.action {
642            queue.extend(entries.iter().rev().cloned());
643            continue;
644        }
645        if entry.identity().is_some() {
646            let status = sources
647                .inventory
648                .status(entry, sources.platform, sources.on_path);
649            if status.as_ref().and_then(installed).is_some() {
650                report.matched += 1;
651            } else {
652                report.differences.push(Difference {
653                    kind: Kind::Missing,
654                    subject: entry.name.clone(),
655                    was: None,
656                    now: None,
657                    entry: Some(entry.id.clone()),
658                    note: "not installed".to_string(),
659                });
660            }
661            continue;
662        }
663        if !entry.writes.is_empty() {
664            let missing: Vec<&String> = entry
665                .writes
666                .iter()
667                .filter(|path| {
668                    expand(sources.home, path).is_none_or(|full| look(&full).0 == FileKind::Missing)
669                })
670                .collect();
671            if missing.is_empty() {
672                report.matched += 1;
673            } else {
674                for path in missing {
675                    report.differences.push(Difference {
676                        kind: Kind::FileMissing,
677                        subject: printable(path),
678                        was: None,
679                        now: None,
680                        entry: Some(entry.id.clone()),
681                        note: format!("not here ({} writes it)", entry.name),
682                    });
683                }
684            }
685            continue;
686        }
687        let evidence = set_up_here(entry, sources);
688        if here.iter().any(|known| known.id == entry.id) || evidence == Some(true) {
689            report.matched += 1;
690        } else {
691            report.differences.push(Difference {
692                kind: Kind::NotApplied,
693                subject: entry.name.clone(),
694                was: None,
695                now: None,
696                entry: Some(entry.id.clone()),
697                note: not_run_note("the profile names it", evidence),
698            });
699        }
700    }
701    report.differences.sort_by_key(|difference| difference.kind);
702    report.unknown.sort();
703    report.unknown.dedup();
704    report
705}
706
707/// Why an entry counts as not run here.
708fn not_run_note(there: &str, evidence: Option<bool>) -> String {
709    match evidence {
710        Some(_) => format!("{there}; no run of it here, and what it sets is not in place"),
711        None => format!(
712            "{there}; no run of it recorded here, and nothing to check it by (runs before HelixForge 1.1 were not recorded)"
713        ),
714    }
715}
716
717/// The settings a baseline records, read from this machine.
718pub fn settings_here(system: &dyn System) -> BTreeMap<String, String> {
719    let mut settings = BTreeMap::new();
720    let (uid, _, _) = system.user();
721    if let Some(passwd) = system.read(Path::new("/etc/passwd")) {
722        let shell = passwd.lines().find_map(|line| {
723            let fields: Vec<&str> = line.split(':').collect();
724            (fields.len() >= 7 && fields[2].parse::<u32>().ok() == Some(uid))
725                .then(|| fields[6].to_string())
726        });
727        if let Some(shell) = shell.filter(|shell| !shell.is_empty()) {
728            settings.insert("shell".to_string(), shell);
729        }
730    }
731    if let Some(target) = system.link(Path::new("/etc/systemd/system/display-manager.service"))
732        && let Some(stem) = target.file_stem()
733    {
734        settings.insert(
735            "display-manager".to_string(),
736            stem.to_string_lossy().into_owned(),
737        );
738    }
739    if let Some(repositories) = system
740        .read(Path::new("/etc/pacman.conf"))
741        .map(|text| crate::preflight::repositories_in(&text))
742        .filter(|repositories| !repositories.is_empty())
743    {
744        settings.insert("repositories".to_string(), repositories.join(", "));
745    }
746    if system.on_path("flatpak")
747        && let Some((true, text)) = system.run(&["flatpak", "remotes", "--columns=name"])
748    {
749        let mut remotes: Vec<&str> = text
750            .lines()
751            .map(str::trim)
752            .filter(|name| !name.is_empty())
753            .collect();
754        remotes.sort_unstable();
755        remotes.dedup();
756        if !remotes.is_empty() {
757            settings.insert("flatpak-remotes".to_string(), remotes.join(", "));
758        }
759    }
760    if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP")
761        && !desktop.is_empty()
762    {
763        settings.insert("desktop".to_string(), desktop);
764    }
765    settings
766}
767
768fn setting_label(key: &str) -> String {
769    match key {
770        "shell" => "Login shell".to_string(),
771        "display-manager" => "Display manager".to_string(),
772        "repositories" => "Arch repositories".to_string(),
773        "flatpak-remotes" => "Flatpak remotes".to_string(),
774        "desktop" => "Desktop".to_string(),
775        other => printable(other),
776    }
777}
778
779fn shown(source: Option<&str>, version: Option<&str>) -> String {
780    match (source, version) {
781        (Some(source), Some(version)) => printable(&format!("{source} {version}")),
782        (Some(source), None) => printable(source),
783        (None, Some(version)) => printable(version),
784        (None, None) => "installed".to_string(),
785    }
786}
787
788/// Links into the Nix store count as the same: Home Manager points them at
789/// a new store path on every switch.
790fn same_target(was: Option<&str>, now: Option<&str>) -> bool {
791    match (was, now) {
792        (Some(was), Some(now)) => {
793            was == now || (was.starts_with("/nix/store/") && now.starts_with("/nix/store/"))
794        }
795        (was, now) => was == now,
796    }
797}
798
799/// A path with `~` for the home folder.
800pub(crate) fn tilde(home: &Path, path: &str) -> String {
801    match Path::new(path).strip_prefix(home) {
802        Ok(rest) if !home.as_os_str().is_empty() => {
803            if rest.as_os_str().is_empty() {
804                "~".to_string()
805            } else {
806                format!("~/{}", rest.display())
807            }
808        }
809        _ => path.to_string(),
810    }
811}
812
813/// `~` expanded; None for a path HelixForge does not compare: relative, or
814/// one that climbs with `..`.
815fn expand(home: &Path, path: &str) -> Option<PathBuf> {
816    let full = match path.strip_prefix("~/") {
817        Some(rest) => home.join(rest),
818        None if path == "~" => home.to_path_buf(),
819        None => PathBuf::from(path),
820    };
821    let plain = full.is_absolute()
822        && full
823            .components()
824            .all(|component| !matches!(component, Component::ParentDir | Component::CurDir));
825    plain.then_some(full)
826}
827
828/// Whether a path is one a baseline may name.
829pub fn valid_path(path: &str) -> bool {
830    !path.chars().any(char::is_control)
831        && path.len() <= 4096
832        && (path.starts_with("~/") || path.starts_with('/'))
833        && expand(Path::new("/home/user"), path).is_some()
834}
835
836/// A folder tree larger than this is compared by its presence only.
837const TREE_FILES: usize = 5_000;
838const TREE_BYTES: u64 = 256 << 20;
839const TREE_DEPTH: usize = 64;
840
841/// What a path is now: its kind, its checksum and a link's target.
842fn look(path: &Path) -> (FileKind, Option<String>, Option<String>) {
843    let Ok(metadata) = std::fs::symlink_metadata(path) else {
844        return (FileKind::Missing, None, None);
845    };
846    if metadata.file_type().is_symlink() {
847        let target = std::fs::read_link(path)
848            .ok()
849            .map(|target| target.to_string_lossy().into_owned());
850        return (FileKind::Link, None, target);
851    }
852    if metadata.is_dir() {
853        let mut hasher = Sha256::new();
854        let mut budget = (TREE_FILES, TREE_BYTES);
855        let sum =
856            tree(path, Path::new(""), &mut hasher, &mut budget).then(|| hex(&hasher.finalize()));
857        return (FileKind::Directory, sum, None);
858    }
859    // A device, a pipe or a socket is compared by its presence only: reading
860    // one could block or never end.
861    let sum = metadata
862        .is_file()
863        .then(|| file_sum(path, TREE_BYTES))
864        .flatten();
865    (FileKind::File, sum, None)
866}
867
868/// A regular file's checksum, reading at most `limit` bytes; None for
869/// anything else, or a file larger than that. The file is opened without
870/// following a link or waiting on a pipe, and checked once open, so a path
871/// swapped after it was looked at is not read either.
872fn file_sum(path: &Path, limit: u64) -> Option<String> {
873    use std::{io::Read, os::unix::fs::OpenOptionsExt};
874    let file = std::fs::OpenOptions::new()
875        .read(true)
876        .custom_flags(nix::libc::O_NOFOLLOW | nix::libc::O_NONBLOCK)
877        .open(path)
878        .ok()?;
879    let metadata = file.metadata().ok()?;
880    if !metadata.is_file() || metadata.len() > limit {
881        return None;
882    }
883    let mut hasher = Sha256::new();
884    let read = std::io::copy(&mut file.take(limit + 1), &mut hasher).ok()?;
885    (read <= limit).then(|| hex(&hasher.finalize()))
886}
887
888/// Hash a folder's names, kinds and contents in a fixed order; false when it
889/// is too large or unreadable to compare.
890fn tree(root: &Path, relative: &Path, hasher: &mut Sha256, budget: &mut (usize, u64)) -> bool {
891    if relative.components().count() > TREE_DEPTH {
892        return false;
893    }
894    let Ok(listing) = std::fs::read_dir(root.join(relative)) else {
895        return false;
896    };
897    let mut names: Vec<std::ffi::OsString> =
898        listing.flatten().map(|item| item.file_name()).collect();
899    names.sort();
900    for name in names {
901        if budget.0 == 0 {
902            return false;
903        }
904        budget.0 -= 1;
905        let child = relative.join(&name);
906        let full = root.join(&child);
907        let Ok(metadata) = std::fs::symlink_metadata(&full) else {
908            return false;
909        };
910        hasher.update(child.to_string_lossy().as_bytes());
911        hasher.update([0]);
912        if metadata.file_type().is_symlink() {
913            hasher.update(b"link ");
914            if let Ok(target) = std::fs::read_link(&full) {
915                hasher.update(target.to_string_lossy().as_bytes());
916            }
917        } else if metadata.is_dir() {
918            hasher.update(b"dir");
919            if !tree(root, &child, hasher, budget) {
920                return false;
921            }
922        } else if metadata.is_file() {
923            if metadata.len() > budget.1 {
924                return false;
925            }
926            hasher.update(b"file ");
927            match file_sum(&full, budget.1) {
928                Some(sum) => hasher.update(sum.as_bytes()),
929                None => return false,
930            }
931            budget.1 -= metadata.len();
932        } else {
933            // Devices, pipes and sockets count by name only.
934            hasher.update(b"special");
935        }
936        hasher.update([0]);
937    }
938    true
939}
940
941fn hex(bytes: &[u8]) -> String {
942    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
943}
944
945/// Read and check a baseline file.
946pub fn read(path: &Path) -> Result<Baseline, BaselineError> {
947    use std::io::Read;
948    let failed = |error: String| BaselineError::Read(path.to_path_buf(), error);
949    let mut text = String::new();
950    std::fs::File::open(path)
951        .and_then(|file| file.take(MAX_FILE + 1).read_to_string(&mut text))
952        .map_err(|error| failed(error.to_string()))?;
953    if text.len() as u64 > MAX_FILE {
954        return Err(failed("larger than a baseline can be".to_string()));
955    }
956    let baseline: Baseline = toml::from_str(&text)
957        .map_err(|error| BaselineError::Format(path.to_path_buf(), printable(error.message())))?;
958    if baseline.format > FORMAT {
959        return Err(BaselineError::Later(path.to_path_buf(), baseline.format));
960    }
961    check(&baseline)?;
962    Ok(baseline)
963}
964
965/// Plain text throughout, ids that are ids and paths HelixForge compares: a
966/// shared file could otherwise carry escape sequences into the terminal.
967pub fn check(baseline: &Baseline) -> Result<(), BaselineError> {
968    let control = |text: &str| text.chars().any(char::is_control);
969    let mut texts: Vec<&str> = vec![&baseline.name, &baseline.system];
970    for recorded in &baseline.entries {
971        texts.extend(recorded.source.as_deref());
972        texts.extend(recorded.version.as_deref());
973    }
974    for record in &baseline.files {
975        texts.extend(record.sha256.as_deref());
976        texts.extend(record.target.as_deref());
977    }
978    for (key, value) in &baseline.settings {
979        texts.push(key);
980        texts.push(value);
981    }
982    if texts.into_iter().any(control) {
983        return Err(BaselineError::Control);
984    }
985    if baseline.entries.len() > MAX_RECORDS
986        || baseline.applied.len() > MAX_RECORDS
987        || baseline.files.len() > MAX_FILES
988        || baseline.settings.len() > MAX_RECORDS
989    {
990        return Err(BaselineError::TooLarge);
991    }
992    let ids = baseline
993        .entries
994        .iter()
995        .map(|recorded| &recorded.id)
996        .chain(baseline.applied.iter().map(|applied| &applied.id))
997        .chain(
998            baseline
999                .files
1000                .iter()
1001                .filter_map(|record| record.entry.as_ref()),
1002        );
1003    for id in ids {
1004        if !valid_id(id) {
1005            return Err(BaselineError::Id(printable(id)));
1006        }
1007    }
1008    if let Some(record) = baseline
1009        .files
1010        .iter()
1011        .find(|record| !valid_path(&record.path))
1012    {
1013        return Err(BaselineError::Path(printable(&record.path)));
1014    }
1015    Ok(())
1016}
1017
1018/// Write a baseline to a file, whole or not at all.
1019pub fn write(path: &Path, baseline: &Baseline) -> Result<(), BaselineError> {
1020    let failed = |error: String| BaselineError::Write(path.to_path_buf(), error);
1021    check(baseline)?;
1022    if let Some(parent) = path
1023        .parent()
1024        .filter(|parent| !parent.as_os_str().is_empty())
1025    {
1026        std::fs::create_dir_all(parent).map_err(|error| failed(error.to_string()))?;
1027    }
1028    let text = toml::to_string(baseline).map_err(|error| failed(error.to_string()))?;
1029    let staging = path.with_extension("toml.new");
1030    std::fs::write(
1031        &staging,
1032        format!("# A HelixForge baseline: helixforge drift check <file>\n{text}"),
1033    )
1034    .map_err(|error| failed(error.to_string()))?;
1035    std::fs::rename(&staging, path).map_err(|error| failed(error.to_string()))
1036}
1037
1038/// Save a baseline in `directory` under its name's slug, replacing one of
1039/// the same name. Returns the file.
1040pub fn save(directory: &Path, baseline: &Baseline) -> Result<PathBuf, BaselineError> {
1041    let slug = crate::profiles::slug(&baseline.name)
1042        .ok_or_else(|| BaselineError::Name(baseline.name.clone()))?;
1043    let path = directory.join(format!("{slug}.toml"));
1044    write(&path, baseline)?;
1045    Ok(path)
1046}
1047
1048/// The baselines in `directory`, newest first, each with its slug; files
1049/// that cannot be used are returned as problems.
1050pub fn load_all(directory: &Path) -> (Vec<(String, Baseline)>, Vec<String>) {
1051    let mut found = Vec::new();
1052    let mut problems = Vec::new();
1053    let Ok(listing) = std::fs::read_dir(directory) else {
1054        return (found, problems);
1055    };
1056    for path in listing.flatten().map(|item| item.path()) {
1057        if path.extension().is_none_or(|extension| extension != "toml") {
1058            continue;
1059        }
1060        let Some(slug) = path
1061            .file_stem()
1062            .and_then(|stem| crate::profiles::slug(&stem.to_string_lossy()))
1063        else {
1064            continue;
1065        };
1066        match read(&path) {
1067            Ok(baseline) => found.push((slug, baseline)),
1068            Err(error) => problems.push(error.to_string()),
1069        }
1070    }
1071    found.sort_by(|a, b| b.1.taken.cmp(&a.1.taken).then_with(|| a.0.cmp(&b.0)));
1072    problems.sort();
1073    (found, problems)
1074}
1075
1076/// Find a saved baseline by its name or slug.
1077pub fn find(directory: &Path, name: &str) -> Option<(String, Baseline)> {
1078    let wanted = crate::profiles::slug(name)?;
1079    load_all(directory)
1080        .0
1081        .into_iter()
1082        .find(|(slug, _)| *slug == wanted)
1083}
1084
1085#[cfg(test)]
1086mod tests {
1087    use super::*;
1088    use crate::doctor::System;
1089    use helixforge_core::PackageManager;
1090    use std::collections::{HashMap, HashSet};
1091
1092    /// A machine whose settings and files come from a temporary home.
1093    struct Fake {
1094        home: PathBuf,
1095        passwd: String,
1096        files: HashMap<PathBuf, String>,
1097    }
1098
1099    impl System for Fake {
1100        fn read(&self, path: &Path) -> Option<String> {
1101            if path == Path::new("/etc/passwd") {
1102                return Some(self.passwd.clone());
1103            }
1104            self.files.get(path).cloned()
1105        }
1106        fn exists(&self, path: &Path) -> bool {
1107            self.files.contains_key(path)
1108        }
1109        fn list(&self, _: &Path) -> Vec<String> {
1110            Vec::new()
1111        }
1112        fn link(&self, path: &Path) -> Option<PathBuf> {
1113            (path == Path::new("/etc/systemd/system/display-manager.service"))
1114                .then(|| PathBuf::from("/usr/lib/systemd/system/sddm.service"))
1115        }
1116        fn run(&self, _: &[&str]) -> Option<(bool, String)> {
1117            None
1118        }
1119        fn resolve(&self, _: &str) -> Result<Vec<String>, String> {
1120            Err("offline".into())
1121        }
1122        fn space(&self, _: &Path) -> Option<(u64, u64)> {
1123            None
1124        }
1125        fn owner(&self, _: &Path) -> Option<u32> {
1126            None
1127        }
1128        fn is_link(&self, _: &Path) -> bool {
1129            false
1130        }
1131        fn canonical(&self, _: &Path) -> Option<PathBuf> {
1132            None
1133        }
1134        fn user(&self) -> (u32, u32, PathBuf) {
1135            (1000, 1000, self.home.clone())
1136        }
1137        fn on_path(&self, _: &str) -> bool {
1138            false
1139        }
1140        fn processes(&self) -> Vec<String> {
1141            Vec::new()
1142        }
1143    }
1144
1145    fn arch() -> LinuxInfo {
1146        LinuxInfo {
1147            id: "arch".into(),
1148            pretty_name: "Arch Linux".into(),
1149            id_like: vec![],
1150            manager: Some(PackageManager::Arch),
1151        }
1152    }
1153
1154    fn inventory(packages: &[(&str, &str)]) -> Inventory {
1155        Inventory::from_parts(
1156            Some(PackageManager::Arch),
1157            Some(
1158                packages
1159                    .iter()
1160                    .map(|(name, version)| (name.to_string(), version.to_string()))
1161                    .collect(),
1162            ),
1163            HashMap::new(),
1164            HashMap::new(),
1165            HashSet::new(),
1166        )
1167    }
1168
1169    fn record(entries: &[&str], success: bool, when: u64) -> history::Record {
1170        history::Record {
1171            unix_timestamp: when,
1172            entries: entries.iter().map(|id| id.to_string()).collect(),
1173            names: Vec::new(),
1174            success,
1175            result: String::new(),
1176            log: None,
1177        }
1178    }
1179
1180    fn sources<'a>(
1181        catalog: &'a Catalog,
1182        platform: &'a LinuxInfo,
1183        inventory: &'a Inventory,
1184        fake: &'a Fake,
1185        history: &'a [history::Record],
1186        settings: &'a dyn Fn() -> BTreeMap<String, String>,
1187    ) -> Sources<'a> {
1188        Sources {
1189            catalog,
1190            platform,
1191            inventory,
1192            on_path: &|_| None,
1193            system: fake,
1194            home: &fake.home,
1195            history,
1196            settings,
1197            render: &|_| Err("no renderer in these tests".to_string()),
1198        }
1199    }
1200
1201    #[test]
1202    fn a_fresh_machine_shows_what_the_old_one_had() {
1203        let catalog = Catalog::builtin(false).unwrap();
1204        let platform = arch();
1205        let old_home = tempfile::tempdir().unwrap();
1206        let ghostty = old_home.path().join(".config/ghostty");
1207        std::fs::create_dir_all(ghostty.join("themes")).unwrap();
1208        std::fs::write(ghostty.join("config"), "font-size = 12\n").unwrap();
1209        std::fs::write(old_home.path().join("notes.txt"), "mine\n").unwrap();
1210        let old = Fake {
1211            home: old_home.path().to_path_buf(),
1212            passwd: "me:x:1000:1000::/home/me:/usr/bin/fish\n".into(),
1213            files: HashMap::new(),
1214        };
1215        let had = inventory(&[("git", "2.47.0-1"), ("ripgrep", "14.1.1-1")]);
1216        let ran = [
1217            record(&["pacman-config"], true, 50),
1218            record(&["pacman-config", "neovim"], false, 45),
1219            record(&["git"], true, 40),
1220        ];
1221        let old_settings = || settings_here(&old);
1222        let extra = [old_home.path().join("notes.txt").display().to_string()];
1223        let baseline = capture(
1224            "Old laptop",
1225            100,
1226            &sources(&catalog, &platform, &had, &old, &ran, &old_settings),
1227            &extra,
1228        );
1229        let ids: Vec<&str> = baseline
1230            .entries
1231            .iter()
1232            .map(|known| known.id.as_str())
1233            .collect();
1234        assert_eq!(ids, ["git", "ripgrep"]);
1235        assert_eq!(baseline.entries[0].version.as_deref(), Some("2.47.0-1"));
1236        assert_eq!(baseline.settings["shell"], "/usr/bin/fish");
1237        assert_eq!(baseline.settings["display-manager"], "sddm");
1238        let paths: Vec<&str> = baseline
1239            .files
1240            .iter()
1241            .map(|record| record.path.as_str())
1242            .collect();
1243        assert!(paths.contains(&"~/.config/ghostty/config"), "{paths:?}");
1244        assert!(paths.contains(&"~/notes.txt"), "{paths:?}");
1245        assert!(
1246            !paths.iter().any(|path| path.contains("kitty")),
1247            "{paths:?}"
1248        );
1249        assert_eq!(baseline.applied.len(), 1);
1250        assert_eq!(baseline.applied[0].id, "pacman-config");
1251
1252        // Saved and read back unchanged.
1253        let directory = tempfile::tempdir().unwrap();
1254        let file = save(directory.path(), &baseline).unwrap();
1255        assert_eq!(file, directory.path().join("old-laptop.toml"));
1256        assert_eq!(read(&file).unwrap(), baseline);
1257        assert_eq!(find(directory.path(), "old laptop").unwrap().1, baseline);
1258
1259        // A fresh machine: only git, another shell, no configuration.
1260        let new_home = tempfile::tempdir().unwrap();
1261        let new = Fake {
1262            home: new_home.path().to_path_buf(),
1263            passwd: "me:x:1000:1000::/home/me:/bin/bash\n".into(),
1264            files: HashMap::new(),
1265        };
1266        let has = inventory(&[("git", "2.48.0-1"), ("neovim", "0.11.0-1")]);
1267        let new_settings = || settings_here(&new);
1268        let report = compare(
1269            &baseline,
1270            &sources(&catalog, &platform, &has, &new, &[], &new_settings),
1271        );
1272        let by_kind = |kind: Kind| -> Vec<&str> {
1273            report
1274                .differences
1275                .iter()
1276                .filter(|difference| difference.kind == kind)
1277                .map(|difference| difference.subject.as_str())
1278                .collect()
1279        };
1280        assert_eq!(by_kind(Kind::Missing), ["Ripgrep"]);
1281        assert!(by_kind(Kind::FileMissing).contains(&"~/.config/ghostty/config"));
1282        assert_eq!(by_kind(Kind::Setting), ["Login shell"]);
1283        assert_eq!(by_kind(Kind::Added), ["Neovim"]);
1284        assert_eq!(by_kind(Kind::Version), ["Git"]);
1285        // Most important first, and what restores the missing parts.
1286        assert_eq!(report.differences[0].kind, Kind::Missing);
1287        let restore = report.restore();
1288        assert_eq!(restore[0], "ripgrep");
1289        assert!(
1290            restore.contains(&"dotfiles-ghostty".to_string()),
1291            "{restore:?}"
1292        );
1293        assert!(!restore.contains(&"neovim".to_string()));
1294    }
1295
1296    #[test]
1297    fn changed_and_replaced_files_are_told_apart() {
1298        let catalog = Catalog::builtin(false).unwrap();
1299        let platform = arch();
1300        let home = tempfile::tempdir().unwrap();
1301        let config = home.path().join(".config/kitty");
1302        std::fs::create_dir_all(&config).unwrap();
1303        std::fs::write(config.join("kitty.conf"), "a\n").unwrap();
1304        let folder = home.path().join("tree");
1305        std::fs::create_dir_all(folder.join("inner")).unwrap();
1306        std::fs::write(folder.join("inner/file"), "x").unwrap();
1307        let fake = Fake {
1308            home: home.path().to_path_buf(),
1309            passwd: String::new(),
1310            files: HashMap::new(),
1311        };
1312        let empty = inventory(&[]);
1313        let none = BTreeMap::new;
1314        let at = |path: &Path| path.display().to_string();
1315        let baseline = capture(
1316            "Before",
1317            1,
1318            &sources(&catalog, &platform, &empty, &fake, &[], &none),
1319            &[
1320                at(&config.join("kitty.conf")),
1321                at(&folder),
1322                at(&home.path().join("gone")),
1323            ],
1324        );
1325        let compare_now = || {
1326            compare(
1327                &baseline,
1328                &sources(&catalog, &platform, &empty, &fake, &[], &none),
1329            )
1330        };
1331        let unchanged = compare_now();
1332        assert!(unchanged.differences.is_empty(), "{unchanged:?}");
1333        // A path recorded as missing that is still missing matches.
1334        assert_eq!(unchanged.matched, 3);
1335
1336        std::fs::write(folder.join("inner/file"), "y").unwrap();
1337        std::fs::remove_file(config.join("kitty.conf")).unwrap();
1338        std::os::unix::fs::symlink(
1339            "/nix/store/abc-home-manager-files/kitty.conf",
1340            config.join("kitty.conf"),
1341        )
1342        .unwrap();
1343        let report = compare_now();
1344        let kinds: Vec<(Kind, &str)> = report
1345            .differences
1346            .iter()
1347            .map(|difference| (difference.kind, difference.subject.as_str()))
1348            .collect();
1349        assert_eq!(
1350            kinds,
1351            [
1352                (Kind::FileReplaced, "~/.config/kitty/kitty.conf"),
1353                (Kind::FileChanged, "~/tree"),
1354            ]
1355        );
1356        assert!(report.differences[0].note.contains("Home Manager"));
1357    }
1358
1359    #[test]
1360    fn a_profile_is_checked_entry_by_entry() {
1361        let catalog = Catalog::builtin(false).unwrap();
1362        let platform = arch();
1363        let home = tempfile::tempdir().unwrap();
1364        let fake = Fake {
1365            home: home.path().to_path_buf(),
1366            passwd: String::new(),
1367            files: HashMap::new(),
1368        };
1369        let has = inventory(&[("git", "1")]);
1370        let none = BTreeMap::new;
1371        let report = compare_profile(
1372            "Developer basics",
1373            &[
1374                "developer-basics".to_string(),
1375                "dotfiles-ghostty".to_string(),
1376                "nope".to_string(),
1377            ],
1378            &sources(&catalog, &platform, &has, &fake, &[], &none),
1379        );
1380        assert_eq!(report.matched, 1);
1381        let missing: Vec<&str> = report
1382            .differences
1383            .iter()
1384            .filter(|difference| difference.kind == Kind::Missing)
1385            .map(|difference| difference.subject.as_str())
1386            .collect();
1387        assert_eq!(missing, ["Ripgrep", "fzf", "Neovim"]);
1388        assert!(
1389            report
1390                .differences
1391                .iter()
1392                .any(|difference| difference.kind == Kind::FileMissing
1393                    && difference.entry.as_deref() == Some("dotfiles-ghostty"))
1394        );
1395        assert_eq!(report.unknown, ["nope"]);
1396    }
1397
1398    #[test]
1399    fn devices_pipes_and_huge_files_are_never_read() {
1400        // /dev/zero would never end; a pipe would wait for a writer.
1401        let (kind, sum, _) = look(Path::new("/dev/zero"));
1402        assert_eq!((kind, sum), (FileKind::File, None));
1403        let directory = tempfile::tempdir().unwrap();
1404        let pipe = directory.path().join("pipe");
1405        nix::unistd::mkfifo(&pipe, nix::sys::stat::Mode::S_IRWXU).unwrap();
1406        assert_eq!(look(&pipe).1, None);
1407        let start = std::time::Instant::now();
1408        let (kind, sum, _) = look(directory.path());
1409        assert_eq!(kind, FileKind::Directory);
1410        assert!(sum.is_some());
1411        assert!(start.elapsed() < std::time::Duration::from_secs(5));
1412        let big = directory.path().join("big");
1413        std::fs::write(&big, vec![0u8; 64]).unwrap();
1414        assert!(file_sum(&big, 63).is_none());
1415        assert!(file_sum(&big, 64).is_some());
1416    }
1417
1418    #[test]
1419    fn shared_baselines_are_checked() {
1420        let base = Baseline {
1421            name: "x".into(),
1422            ..Baseline::default()
1423        };
1424        assert!(check(&base).is_ok());
1425        let evil = Baseline {
1426            name: "Evil\u{1b}[2J".into(),
1427            ..base.clone()
1428        };
1429        assert!(matches!(check(&evil), Err(BaselineError::Control)));
1430        for path in ["relative", "~/../etc/shadow", "/etc/../root", "~user/x"] {
1431            let bad = Baseline {
1432                files: vec![FileRecord {
1433                    path: path.into(),
1434                    ..FileRecord::default()
1435                }],
1436                ..base.clone()
1437            };
1438            assert!(check(&bad).is_err(), "{path}");
1439        }
1440        let bad_id = Baseline {
1441            entries: vec![Recorded {
1442                id: "Not An Id".into(),
1443                ..Recorded::default()
1444            }],
1445            ..base.clone()
1446        };
1447        assert!(check(&bad_id).is_err());
1448        let directory = tempfile::tempdir().unwrap();
1449        let later = directory.path().join("later.toml");
1450        std::fs::write(&later, "format = 9\nname = \"x\"\nunknown = 1\n").unwrap();
1451        assert!(matches!(read(&later), Err(BaselineError::Later(_, 9))));
1452        let huge = Baseline {
1453            files: (0..1001)
1454                .map(|index| FileRecord {
1455                    path: format!("~/f{index}"),
1456                    ..FileRecord::default()
1457                })
1458                .collect(),
1459            ..base.clone()
1460        };
1461        assert!(matches!(check(&huge), Err(BaselineError::TooLarge)));
1462        // Whatever is wrong, no error repeats an escape sequence it was given.
1463        for bad in [
1464            Baseline {
1465                name: "\u{1b}]52;c;x\u{7}".into(),
1466                ..huge.clone()
1467            },
1468            Baseline {
1469                entries: vec![Recorded {
1470                    id: "x\u{1b}[2J".into(),
1471                    ..Recorded::default()
1472                }],
1473                ..base.clone()
1474            },
1475        ] {
1476            let message = check(&bad).unwrap_err().to_string();
1477            assert!(!message.chars().any(char::is_control), "{message:?}");
1478        }
1479        let fine = directory.path().join("fine.toml");
1480        // Fields a later version adds are ignored.
1481        std::fs::write(&fine, "name = \"x\"\nunknown = 1\n").unwrap();
1482        assert_eq!(read(&fine).unwrap().name, "x");
1483    }
1484
1485    #[test]
1486    fn an_entry_whose_settings_are_in_place_counts_without_a_recorded_run() {
1487        let catalog = Catalog::builtin(false).unwrap();
1488        let platform = arch();
1489        let home = tempfile::tempdir().unwrap();
1490        let grub =
1491            "GRUB_TIMEOUT=5\nGRUB_THEME=\"/boot/grub/themes/commander-dracula-1080p/theme.txt\"\n";
1492        let fake = Fake {
1493            home: home.path().to_path_buf(),
1494            passwd: String::new(),
1495            files: HashMap::from([(PathBuf::from("/etc/default/grub"), grub.to_string())]),
1496        };
1497        let empty = inventory(&[]);
1498        let none = BTreeMap::new;
1499        let ids = ["grub-dracula".to_string(), "printer-driverless".to_string()];
1500        let report_with = |render: &dyn Fn(&[String]) -> Result<String, String>| {
1501            let sources = Sources {
1502                render,
1503                ..sources(&catalog, &platform, &empty, &fake, &[], &none)
1504            };
1505            compare_profile("Mine", &ids, &sources)
1506        };
1507        // The installer's renderer leaves the file as it is: the theme is in
1508        // place, run from an older HelixForge or by hand.
1509        let rendered_as_is = |arguments: &[String]| {
1510            assert_eq!(arguments[0], "grub");
1511            Ok(grub.to_string())
1512        };
1513        let report = report_with(&rendered_as_is);
1514        assert!(
1515            !report
1516                .differences
1517                .iter()
1518                .any(|difference| difference.entry.as_deref() == Some("grub-dracula")),
1519            "{report:?}"
1520        );
1521        assert_eq!(report.matched, 1);
1522        // Nothing tells for an entry with nothing to render or write.
1523        let other = report
1524            .differences
1525            .iter()
1526            .find(|difference| difference.entry.as_deref() == Some("printer-driverless"))
1527            .unwrap();
1528        assert_eq!(other.kind, Kind::NotApplied);
1529        assert!(
1530            other
1531                .note
1532                .contains("runs before HelixForge 1.1 were not recorded"),
1533            "{}",
1534            other.note
1535        );
1536        // A renderer that would change the file: not in place.
1537        let rendered_otherwise =
1538            |_: &[String]| Ok("GRUB_TIMEOUT=5\nGRUB_THEME=\"elsewhere\"\n".to_string());
1539        let report = report_with(&rendered_otherwise);
1540        let dracula = report
1541            .differences
1542            .iter()
1543            .find(|difference| difference.entry.as_deref() == Some("grub-dracula"))
1544            .unwrap();
1545        assert!(
1546            dracula.note.contains("what it sets is not in place"),
1547            "{}",
1548            dracula.note
1549        );
1550    }
1551}