1use 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
23pub const FORMAT: u32 = 1;
25
26const MAX_FILE: u64 = 4 << 20;
28const MAX_RECORDS: usize = 10_000;
29const MAX_FILES: usize = 1_000;
30
31#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
33pub struct Baseline {
34 #[serde(default = "format")]
35 pub format: u32,
36 pub name: String,
37 #[serde(default)]
39 pub taken: u64,
40 #[serde(default)]
42 pub system: String,
43 #[serde(default, rename = "entry", skip_serializing_if = "Vec::is_empty")]
45 pub entries: Vec<Recorded>,
46 #[serde(default, rename = "applied", skip_serializing_if = "Vec::is_empty")]
48 pub applied: Vec<Applied>,
49 #[serde(default, rename = "file", skip_serializing_if = "Vec::is_empty")]
51 pub files: Vec<FileRecord>,
52 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
54 pub settings: BTreeMap<String, String>,
55}
56
57fn format() -> u32 {
58 FORMAT
59}
60
61#[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#[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#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
81pub struct FileRecord {
82 pub path: String,
84 pub kind: FileKind,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub sha256: Option<String>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub target: Option<String>,
91 #[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#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
119#[serde(rename_all = "kebab-case")]
120pub enum Kind {
121 Missing,
123 FileMissing,
125 NotApplied,
127 FileReplaced,
129 FileChanged,
131 Setting,
133 Added,
135 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 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 pub fn missing(self) -> bool {
178 matches!(self, Kind::Missing | Kind::FileMissing | Kind::NotApplied)
179 }
180}
181
182#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
184pub struct Difference {
185 pub kind: Kind,
186 pub subject: String,
188 pub was: Option<String>,
189 pub now: Option<String>,
190 pub entry: Option<String>,
192 pub note: String,
194}
195
196#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
198pub struct Report {
199 pub against: String,
201 pub differences: Vec<Difference>,
203 pub matched: usize,
205 pub unknown: Vec<String>,
207}
208
209impl Report {
210 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 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#[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
261pub 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 pub history: &'a [history::Record],
271 pub settings: &'a dyn Fn() -> BTreeMap<String, String>,
273 pub render: &'a dyn Fn(&[String]) -> Result<String, String>,
276}
277
278pub fn dir() -> Option<PathBuf> {
280 crate::profiles::dir().and_then(|profiles| Some(profiles.parent()?.join("baselines")))
281}
282
283pub 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 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
345fn 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
361fn 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
385fn 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
402fn 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
413pub 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 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 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 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 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
621pub 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
707fn 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
717pub 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
788fn 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
799pub(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
813fn 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
828pub 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
836const TREE_FILES: usize = 5_000;
838const TREE_BYTES: u64 = 256 << 20;
839const TREE_DEPTH: usize = 64;
840
841fn 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 let sum = metadata
862 .is_file()
863 .then(|| file_sum(path, TREE_BYTES))
864 .flatten();
865 (FileKind::File, sum, None)
866}
867
868fn 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
888fn 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 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
945pub 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
965pub 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
1018pub 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
1038pub 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
1048pub 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
1076pub 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 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 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 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 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 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 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 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 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 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 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 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}