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