1use anyhow::Result;
24
25use crate::commands::hook::{self, HookState};
26use crate::config::Registry;
27use crate::constants;
28use crate::daemon;
29use crate::json;
30use crate::output;
31
32#[derive(Clone, Copy, PartialEq, Eq)]
34pub enum Verdict {
35 Guaranteed,
37 Safe,
39 Widened,
42 Neutral,
44}
45
46impl Verdict {
47 fn mark(self) -> &'static str {
49 match self {
50 Verdict::Guaranteed | Verdict::Safe => "+",
51 Verdict::Widened => "!",
52 Verdict::Neutral => " ",
53 }
54 }
55
56 fn key(self) -> &'static str {
59 match self {
60 Verdict::Guaranteed => "guaranteed",
61 Verdict::Safe => "safe",
62 Verdict::Widened => "widened",
63 Verdict::Neutral => "neutral",
64 }
65 }
66}
67
68pub struct TrustRow {
70 pub key: &'static str,
72 pub subject: &'static str,
74 pub state: String,
76 pub verdict: Verdict,
78}
79
80impl TrustRow {
81 fn new(
82 key: &'static str,
83 subject: &'static str,
84 state: impl Into<String>,
85 verdict: Verdict,
86 ) -> Self {
87 Self {
88 key,
89 subject,
90 state: state.into(),
91 verdict,
92 }
93 }
94
95 pub fn verdict_key(&self) -> &'static str {
97 self.verdict.key()
98 }
99}
100
101pub struct BinaryIdentity {
107 pub role: &'static str,
110 pub name: String,
112 pub path: String,
114 pub sha256: Option<String>,
116 pub version: Option<String>,
119 pub marker: Option<&'static str>,
126 pub running: bool,
128 pub channel: &'static str,
132 pub note: Option<&'static str>,
134}
135
136impl BinaryIdentity {
137 pub fn scan_url(&self) -> Option<String> {
139 self.sha256
140 .as_ref()
141 .map(|h| format!("{}/{h}", constants::VIRUSTOTAL_FILE_BASE))
142 }
143}
144
145pub struct TrustReport {
147 pub guarantees: Vec<TrustRow>,
149 pub machine: Vec<TrustRow>,
151 pub binaries: Vec<BinaryIdentity>,
157}
158
159impl TrustReport {
160 pub fn widened(&self) -> Vec<&str> {
165 self.machine
166 .iter()
167 .filter(|r| r.verdict == Verdict::Widened)
168 .map(|r| r.subject)
169 .collect()
170 }
171}
172
173pub fn run(json_output: bool) -> Result<()> {
175 let registry = Registry::load()?;
176 let mut report = build(®istry);
177 report.binaries = binaries(registry.latest_known_version.as_deref());
178
179 if json_output {
180 return json::emit(&json::trust_document(&report));
181 }
182
183 print_report(&report);
184 Ok(())
185}
186
187pub fn fix_ownership(assume_yes: bool) -> Result<()> {
204 let registry = Registry::load()?;
205 let affected = repositories_git_refuses(®istry);
206
207 if affected.is_empty() {
208 output::print_success("Git reads every registered repository. Nothing to fix.");
209 return Ok(());
210 }
211
212 let n = affected.len();
213 output::print_header(&format!(
214 "{n} {} Git will not read",
215 output::plural(n, "repository", "repositories")
216 ));
217 for path in &affected {
218 println!(" {}", output::styled_path(path));
219 }
220 println!();
221 output::print_info(&format!(
222 "This adds {} to git's global `safe.directory` list, which tells Git to open {} despite \
223 the owner recorded on disk. It affects every tool on this machine that uses Git, not only \
224 dev-prune.",
225 output::plural(n, "this path", "these paths"),
226 output::plural(n, "it", "them")
227 ));
228 output::print_info("Undo one with: git config --global --unset-all safe.directory <path>");
229
230 if !confirm_fix(assume_yes) {
231 return Ok(());
232 }
233
234 let existing = configured_safe_directories();
238 let mut added = 0usize;
239 for path in &affected {
240 let value = git_path_value(path);
241 if existing.iter().any(|e| e == &value) {
242 continue;
243 }
244 let status = crate::spawn::command("git")
245 .args(["config", "--global", "--add", "safe.directory", &value])
246 .status();
247 match status {
248 Ok(s) if s.success() => added += 1,
249 _ => output::print_warning(&format!("Could not add `{value}` — skipped.")),
250 }
251 }
252
253 output::print_success(&format!(
254 "Added {added} {}. Run `devp run --dry-run` to see what is now examinable.",
255 output::plural(added, "entry", "entries")
256 ));
257 Ok(())
258}
259
260fn repositories_git_refuses(registry: &Registry) -> Vec<std::path::PathBuf> {
266 let mut affected: Vec<std::path::PathBuf> = registry
267 .repositories
268 .keys()
269 .filter(|path| path.exists())
270 .filter(|path| {
271 let output = crate::scanner::git::git_in(path)
272 .args(["rev-parse", "--git-dir"])
273 .output();
274 match output {
275 Ok(out) if !out.status.success() => String::from_utf8_lossy(&out.stderr)
276 .to_lowercase()
277 .contains(constants::GIT_DUBIOUS_OWNERSHIP),
278 _ => false,
279 }
280 })
281 .cloned()
282 .collect();
283 affected.sort();
286 affected
287}
288
289fn configured_safe_directories() -> Vec<String> {
291 let output = crate::spawn::command("git")
292 .args(["config", "--global", "--get-all", "safe.directory"])
293 .output();
294 match output {
295 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
296 .lines()
297 .map(str::trim)
298 .filter(|l| !l.is_empty())
299 .map(str::to_string)
300 .collect(),
301 _ => Vec::new(),
304 }
305}
306
307fn git_path_value(path: &std::path::Path) -> String {
313 path.display().to_string().replace('\\', "/")
314}
315
316fn confirm_fix(yes: bool) -> bool {
322 use std::io::{IsTerminal, Write};
323 if yes {
324 return true;
325 }
326 if !std::io::stdin().is_terminal() {
327 output::print_info("Not running in a terminal — pass `--yes` to write these.");
328 return false;
329 }
330 eprint!("Add them to git's safe.directory list? [y/N]: ");
331 if std::io::stderr().flush().is_err() {
332 return false;
333 }
334 let mut input = String::new();
335 if std::io::stdin().read_line(&mut input).is_err() {
336 return false;
337 }
338 matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
339}
340
341fn machine_answers() -> (String, String) {
348 let scheduler = std::thread::spawn(scheduler_state);
349 let hooks = hook_state();
350 let scheduler = scheduler
353 .join()
354 .unwrap_or_else(|_| "Unknown (the check did not finish)".to_string());
355 (scheduler, hooks)
356}
357
358fn with_progress<T>(work: impl FnOnce() -> T) -> T {
365 use std::io::{IsTerminal, Write};
366
367 let mut err = std::io::stderr();
368 let show = err.is_terminal();
369 if show {
370 let _ = write!(err, "{}", constants::READING_MACHINE);
371 let _ = err.flush();
372 }
373 let value = work();
374 if show {
375 let _ = write!(
378 err,
379 "\r{:width$}\r",
380 "",
381 width = constants::READING_MACHINE.chars().count()
382 );
383 let _ = err.flush();
384 }
385 value
386}
387
388pub(crate) fn build(registry: &Registry) -> TrustReport {
394 TrustReport {
395 guarantees: guarantees(),
396 machine: machine_state(registry),
397 binaries: Vec::new(),
398 }
399}
400
401fn guarantees() -> Vec<TrustRow> {
408 use Verdict::Guaranteed as G;
409 vec![
410 TrustRow::new(
411 "filesystem_scope",
412 "Filesystem scope",
413 "Registered Git repositories only",
414 G,
415 ),
416 TrustRow::new(
417 "lockfile_verification",
418 "Lockfile verification",
419 "Required before every delete",
420 G,
421 ),
422 TrustRow::new("symlinks", "Symlinks and junctions", "Refused", G),
423 TrustRow::new(
424 "nested_repositories",
425 "Nested repositories",
426 "Refused — no lockfile rebuilds someone else's history",
427 G,
428 ),
429 TrustRow::new(
430 "build_outputs",
431 "Build outputs",
432 "Never deleted — no dist/, no .next/, no .gitignore rules",
433 G,
434 ),
435 TrustRow::new(
436 "container_disk",
437 "Container disk",
438 "Reported, never deleted — `devp caches docker` prints the commands",
439 G,
440 ),
441 TrustRow::new(
442 "deletion_bypass",
443 "Deletion bypass",
444 "None — no flag disables a safety check",
445 G,
446 ),
447 TrustRow::new(
448 "state_writes",
449 "State writes",
450 "Atomic — temp file, then rename",
451 G,
452 ),
453 TrustRow::new("telemetry", "Telemetry", "None — there is no endpoint", G),
454 TrustRow::new(
455 "restore",
456 "Restore",
457 "`devp restore --last-run` rebuilds the last pass",
458 G,
459 ),
460 ]
461}
462
463fn machine_state(registry: &Registry) -> Vec<TrustRow> {
465 let s = ®istry.settings;
466 let (scheduler, hooks) = with_progress(machine_answers);
467 let mut rows = vec![
468 TrustRow::new(
469 "network",
470 "Network requests",
471 if s.update_check {
472 format!(
473 "Release check against GitHub, every {} days",
474 s.update_check_interval_days
475 )
476 } else {
477 "None — the release check is off".to_string()
478 },
479 Verdict::Safe,
480 ),
481 TrustRow::new(
482 "auto_update",
483 "Auto-update",
484 if s.version_lock {
487 "Off — `version_lock` pins this copy to the version it is"
488 } else if s.auto_update {
489 "On (the default) — a newer release installs itself after a pass"
490 } else {
491 "Off — updates only when you run `devp update --install`"
492 },
493 if s.auto_update && !s.version_lock {
498 Verdict::Neutral
499 } else {
500 Verdict::Safe
501 },
502 ),
503 TrustRow::new(
504 "confirmation",
505 "Confirmation before deleting",
506 if s.require_confirmation {
507 "Required, except where you pass `--yes`"
508 } else {
509 "Off — `require_confirmation` is false"
510 },
511 if s.require_confirmation {
512 Verdict::Safe
513 } else {
514 Verdict::Widened
515 },
516 ),
517 TrustRow::new(
518 "lockfile_rewrite",
519 "Lockfile rewriting",
520 if s.allow_manifest_rewrite {
521 "Allowed — a stale lockfile is regenerated instead of refused"
522 } else {
523 "Refused — verification is read-only"
524 },
525 if s.allow_manifest_rewrite {
526 Verdict::Widened
527 } else {
528 Verdict::Safe
529 },
530 ),
531 TrustRow::new(
532 "scheduler",
533 "Background scheduler",
534 scheduler,
535 Verdict::Neutral,
536 ),
537 TrustRow::new("git_hooks", "Git hooks", hooks, Verdict::Neutral),
538 ];
539
540 let opt_in = opt_in_adapters(registry);
544 rows.push(TrustRow::new(
545 "opt_in_adapters",
546 "Opt-in adapters",
547 if opt_in.is_empty() {
548 "None — only dependency directories are deletable".to_string()
549 } else {
550 format!("{} — build trees are deletable too", opt_in.join(", "))
551 },
552 if opt_in.is_empty() {
553 Verdict::Safe
554 } else {
555 Verdict::Widened
556 },
557 ));
558
559 rows.push(TrustRow::new(
560 "repositories",
561 "Registered repositories",
562 format!(
563 "{} — nothing outside them is ever read or written",
564 registry.repositories.len()
565 ),
566 Verdict::Neutral,
567 ));
568 rows.push(TrustRow::new(
569 "idle_window",
570 "Idle window",
571 format!(
572 "{} days of no commits and no file changes ({} for build trees, before any per-adapter window)",
573 s.idle_days,
574 s.build_idle_days.max(s.idle_days)
575 ),
576 Verdict::Neutral,
577 ));
578 rows.push(TrustRow::new(
583 "binary",
584 "Managed binary",
585 output::clean_path(daemon::get_exe_path()),
586 Verdict::Neutral,
587 ));
588
589 rows
590}
591
592pub(crate) fn binaries(latest_known: Option<&str>) -> Vec<BinaryIdentity> {
614 let mut found: Vec<(std::path::PathBuf, &'static str, Option<&'static str>)> = Vec::new();
615 let managed_exe = crate::setup::managed_exe_path().ok();
616
617 if let Ok(managed) = crate::setup::managed_exe_path() {
621 let dir = managed.parent().map(|d| d.to_path_buf());
622 found.push((managed, "managed", None));
623 if let Some(dir) = dir {
624 let alias = if cfg!(windows) { "devp.exe" } else { "devp" };
625 found.push((dir.join(alias), "alias", None));
629 if cfg!(windows) {
630 found.push((
631 dir.join(constants::WINDOWS_WINDOWLESS_BIN),
632 "windowless",
633 Some(
634 "A different digest by design — the same code linked for \
635 the GUI subsystem, so the scheduler flashes no console window.",
636 ),
637 ));
638 }
639 }
640 }
641
642 let running = std::env::current_exe().ok();
645 if let Some(current) = running.clone() {
646 found.push((current, "other", None));
647 }
648
649 for stray in crate::commands::uninstall::find_stray_copies() {
654 if is_executable_image(&stray.path) {
655 found.push((stray.path, "other", None));
656 }
657 }
658
659 let same = |a: &std::path::Path, b: &std::path::Path| -> bool {
660 match (a.canonicalize(), b.canonicalize()) {
664 (Ok(a), Ok(b)) => a == b,
665 _ => a == b,
666 }
667 };
668
669 let mut rows: Vec<BinaryIdentity> = Vec::new();
670 for (path, role, note) in found {
671 if !path.is_file()
672 || rows
673 .iter()
674 .any(|r| same(std::path::Path::new(&r.path), &path))
675 {
676 continue;
677 }
678 let is_running = running.as_deref().is_some_and(|c| same(c, &path));
679 let (sha256, version) = read_identity(&path);
680 rows.push(BinaryIdentity {
681 role,
682 name: path
683 .file_name()
684 .map(|n| n.to_string_lossy().into_owned())
685 .unwrap_or_default(),
686 channel: crate::channel::Channel::detect_at(&path, managed_exe.as_deref()).badge(),
687 path: output::clean_path(&path),
688 sha256,
689 version,
690 marker: None,
691 running: is_running,
692 note,
693 });
694 }
695
696 annotate_alias(&mut rows);
697 mark_newest(&mut rows, latest_known);
698
699 rows.sort_by_key(|r| !r.running);
702 rows
703}
704
705fn is_executable_image(path: &std::path::Path) -> bool {
712 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
713 return false;
714 };
715 let stems = ["dev-prune", "devp", "devpw"];
716 if cfg!(windows) {
717 stems
718 .iter()
719 .any(|s| name.eq_ignore_ascii_case(&format!("{s}.exe")))
720 } else {
721 stems.contains(&name)
722 }
723}
724
725fn annotate_alias(rows: &mut [BinaryIdentity]) {
737 let managed = rows
738 .iter()
739 .find(|r| r.role == "managed")
740 .and_then(|r| r.sha256.clone());
741 let Some(alias) = rows.iter_mut().find(|r| r.role == "alias") else {
742 return;
743 };
744 alias.note = match (&managed, &alias.sha256) {
745 (Some(managed), Some(mine)) if managed == mine => Some(
746 "The same bytes as dev-prune under a second name, so a scanner \
747 builds one reputation record instead of two.",
748 ),
749 (Some(_), Some(_)) => Some(
750 "An earlier release under a second name: an upgrade replaced dev-prune \
751 and this has not caught up. The next dev-prune command you run in a \
752 terminal restores the pair.",
753 ),
754 _ => None,
755 };
756}
757
758fn mark_newest(rows: &mut [BinaryIdentity], latest_known: Option<&str>) {
766 if let Some(latest) = latest_known {
767 let mut any = false;
768 for r in rows.iter_mut() {
769 if r.version.as_deref().is_some_and(|v| v == latest) {
770 r.marker = Some("latest release");
771 any = true;
772 }
773 }
774 if any {
775 return;
776 }
777 }
778
779 let Some(top) = rows
783 .iter()
784 .filter_map(|r| r.version.as_deref())
785 .max_by(|a, b| {
786 crate::commands::update::compare_versions(a, b).unwrap_or(std::cmp::Ordering::Equal)
787 })
788 .map(str::to_owned)
789 else {
790 return;
791 };
792 if rows.iter().filter(|r| r.version.is_some()).count() < 2 {
794 return;
795 }
796 for r in rows.iter_mut() {
797 if r.version.as_deref() == Some(top.as_str()) {
798 r.marker = Some("newest here");
799 }
800 }
801}
802
803pub(crate) fn version_from_stamp(haystack: &[u8]) -> Option<String> {
809 let mark = constants::VERSION_STAMP_MARK.as_bytes();
810 const MAX: usize = 32;
813
814 let mut from = 0;
815 while let Some(hit) = haystack[from..]
816 .windows(mark.len())
817 .position(|w| w == mark)
818 .map(|i| from + i)
819 {
820 from = hit + mark.len();
821 let tail = &haystack[from..haystack.len().min(from + MAX)];
822 if let Some(end) = tail.iter().position(|&b| b == b'/')
823 && let Ok(v) = std::str::from_utf8(&tail[..end])
824 && crate::commands::update::compare_versions(v, v).is_some()
828 {
829 return Some(v.to_string());
830 }
831 }
832 None
833}
834
835fn read_identity(path: &std::path::Path) -> (Option<String>, Option<String>) {
841 let Ok(bytes) = std::fs::read(path) else {
842 return (None, None);
843 };
844 (Some(sha256_of_bytes(&bytes)), version_from_stamp(&bytes))
845}
846
847fn sha256_of_bytes(bytes: &[u8]) -> String {
849 use sha2::{Digest, Sha256};
850 use std::fmt::Write as _;
851
852 let mut h = Sha256::new();
853 h.update(bytes);
854 h.finalize().iter().fold(String::new(), |mut acc, b| {
857 let _ = write!(acc, "{b:02x}");
858 acc
859 })
860}
861
862fn opt_in_adapters(registry: &Registry) -> Vec<&'static str> {
864 let s = ®istry.settings;
865 [
866 ("cargo", s.enable_cargo),
867 ("gradle", s.enable_gradle),
868 ("maven", s.enable_maven),
869 ("swift", s.enable_swift),
870 ("dart", s.enable_dart),
871 ("mix_build", s.enable_mix_build),
872 ("vcpkg", s.enable_vcpkg),
873 ("cmake_build", s.enable_cmake_build),
874 ("dotnet_build", s.enable_dotnet_build),
875 ("godot", s.enable_godot),
876 ("unity", s.enable_unity),
877 ("unreal", s.enable_unreal),
878 ("defold", s.enable_defold),
879 ("cocos", s.enable_cocos),
880 ("zig", s.enable_zig),
881 ("stack", s.enable_stack),
882 ("cabal", s.enable_cabal),
883 ("sbt", s.enable_sbt),
884 ]
885 .into_iter()
886 .filter_map(|(name, on)| on.then_some(name))
887 .collect()
888}
889
890fn scheduler_state() -> String {
892 match daemon::daemon_status() {
893 Ok(daemon::DaemonStatus::Installed) => "Installed — prunes on its own".to_string(),
894 Ok(daemon::DaemonStatus::NotInstalled) => {
895 "Not installed — nothing runs unless you run it".to_string()
896 }
897 Ok(daemon::DaemonStatus::Unknown(why)) => format!("Unknown ({why})"),
898 Err(e) => format!("Unknown ({e})"),
899 }
900}
901
902fn hook_state() -> String {
904 if !hook::git_available() {
905 return "Not installed — git is not on PATH".to_string();
906 }
907 match hook::state() {
908 Ok(HookState::Active) => "Installed — new repositories register themselves".to_string(),
909 Ok(HookState::Absent) => {
910 "Not installed — repositories register only when you say so".to_string()
911 }
912 Ok(HookState::Chained { previous, .. }) => {
913 format!("Installed, chained to `{previous}`")
914 }
915 Ok(HookState::Foreign(p)) => format!("Not ours — `core.hooksPath` belongs to `{p}`"),
916 Err(e) => format!("Unknown ({e})"),
917 }
918}
919
920fn print_report(report: &TrustReport) {
921 output::print_header(&format!("What dev-prune {} may do", constants::VERSION));
922
923 println!();
924 println!(" Guaranteed by the code, on every machine");
925 println!();
926 for row in &report.guarantees {
927 print_row(row);
928 }
929
930 println!();
931 println!(" On this machine");
932 println!();
933 for row in &report.machine {
934 print_row(row);
935 }
936
937 println!();
938 let widened = report.widened();
939 if widened.is_empty() {
940 output::print_success(
941 "Nothing on this machine widens what dev-prune may do without asking.",
942 );
943 } else {
944 output::print_info(&format!(
945 "{} {} what dev-prune may do without asking: {}. Each was switched on \
946 deliberately; `devp config show` has them.",
947 widened.len(),
948 if widened.len() == 1 {
949 "setting widens"
950 } else {
951 "settings widen"
952 },
953 widened.join(", ")
954 ));
955 }
956 output::print_info(
957 "The guarantees above are enforced in `src/engine.rs` and described in full at \
958 docs/SAFETY_INVARIANTS.md. None of them has a bypass flag.",
959 );
960
961 print_binaries(&report.binaries);
962}
963
964fn print_binaries(binaries: &[BinaryIdentity]) {
966 if binaries.is_empty() {
967 return;
968 }
969
970 println!();
971 println!(" Binaries on this machine");
972 println!();
973 for b in binaries {
974 let mut label = match &b.version {
978 Some(v) => format!("{} v{v}", b.name),
979 None => b.name.clone(),
980 };
981 if let Some(marker) = b.marker {
982 label.push_str(&format!(" ({marker})"));
983 }
984 if b.running {
985 label.push_str(" (running)");
986 }
987 let mark = if b.running { ">" } else { " " };
988 println!(" {mark} {label:<44} {}", b.path);
989 println!(" {:<30} {}", "Installed with", b.channel);
990 if b.version.is_none() {
991 println!(" {:<30} not stamped — built before 1.17.0", "Version");
995 }
996 match (&b.sha256, b.scan_url()) {
997 (Some(hash), Some(url)) => {
998 println!(" {:<30} {hash}", "SHA-256");
999 println!(" {:<30} {url}", "Scan report");
1000 }
1001 _ => println!(" {:<30} could not be read", "SHA-256"),
1004 }
1005 if let Some(note) = b.note {
1006 println!(" {note}");
1007 }
1008 println!();
1009 }
1010
1011 output::print_info(
1012 "Those links are lookups by digest, not uploads — dev-prune sends no file \
1013 anywhere. A digest the service has never seen comes back `not found`, which \
1014 means unscanned rather than clean.",
1015 );
1016 output::print_info(
1017 "A copy installed from a release has the same SHA-256 as the asset published \
1018 beside it, so the two can be compared by hand. One built by `cargo install` was \
1019 compiled here and matches nothing published.",
1020 );
1021 if binaries.len() > 1 {
1022 output::print_info(
1023 "More than one copy is not a fault — a `cargo install` and an installer run \
1024 each leave one, and they update separately. `devp uninstall` lists them with \
1025 the command that removes each.",
1026 );
1027 }
1028}
1029
1030fn print_row(row: &TrustRow) {
1031 println!(
1032 " {} {:<30} {}",
1033 row.verdict.mark(),
1034 row.subject,
1035 row.state
1036 );
1037}
1038
1039#[cfg(test)]
1040mod tests {
1041 use super::*;
1042
1043 fn pair(managed: Option<&str>, alias: Option<&str>) -> Vec<BinaryIdentity> {
1045 let row = |role: &'static str, sha: Option<&str>| BinaryIdentity {
1046 role,
1047 name: String::new(),
1048 path: String::new(),
1049 channel: "standalone",
1050 sha256: sha.map(str::to_string),
1051 version: None,
1052 marker: None,
1053 running: false,
1054 note: None,
1055 };
1056 vec![row("managed", managed), row("alias", alias)]
1057 }
1058
1059 fn alias_note(rows: &[BinaryIdentity]) -> Option<&'static str> {
1060 rows.iter().find(|r| r.role == "alias").unwrap().note
1061 }
1062
1063 #[test]
1064 fn the_alias_note_follows_the_digests_and_not_the_expectation() {
1065 let mut same = pair(Some("aa"), Some("aa"));
1069 annotate_alias(&mut same);
1070 assert!(alias_note(&same).is_some_and(|n| n.contains("The same bytes")));
1071
1072 let mut stale = pair(Some("aa"), Some("bb"));
1073 annotate_alias(&mut stale);
1074 assert!(alias_note(&stale).is_some_and(|n| n.contains("An earlier release")));
1075
1076 let mut unreadable = pair(Some("aa"), None);
1078 annotate_alias(&mut unreadable);
1079 assert!(alias_note(&unreadable).is_none());
1080 }
1081
1082 #[test]
1083 fn safe_directory_values_use_the_spelling_git_compares_against() {
1084 let path = std::path::Path::new("V:\\Code\\Project");
1089 assert_eq!(git_path_value(path), "V:/Code/Project");
1090 }
1091
1092 #[test]
1093 fn the_default_machine_widens_nothing() {
1094 let registry = Registry::default();
1095 let report = build(®istry);
1096 assert!(
1097 report.widened().is_empty(),
1098 "a fresh install reports {:?} as widened",
1099 report.widened()
1100 );
1101 }
1102
1103 #[test]
1104 fn every_widening_setting_shows_up_by_name() {
1105 let mut registry = Registry::default();
1106 registry.settings.require_confirmation = false;
1107 registry.settings.allow_manifest_rewrite = true;
1108 registry.settings.enable_gradle = true;
1109
1110 let report = build(®istry);
1111 let widened = report.widened();
1112 assert_eq!(widened.len(), 3, "got {widened:?}");
1113 assert!(widened.contains(&"Opt-in adapters"));
1116 }
1117
1118 #[test]
1119 fn opt_in_adapters_are_listed_in_a_stable_order() {
1120 let mut registry = Registry::default();
1121 registry.settings.enable_swift = true;
1122 registry.settings.enable_gradle = true;
1123 assert_eq!(opt_in_adapters(®istry), vec!["gradle", "swift"]);
1124 }
1125
1126 #[test]
1127 fn build_never_hashes_anything() {
1128 assert!(build(&Registry::default()).binaries.is_empty());
1132 }
1133
1134 #[test]
1135 fn the_running_binary_is_listed_first_and_hashed() {
1136 let found = binaries(None);
1137 let running: Vec<&BinaryIdentity> = found.iter().filter(|b| b.running).collect();
1138 assert_eq!(running.len(), 1, "expected exactly one running binary");
1139 assert!(found[0].running, "the running binary must lead the list");
1140
1141 let hash = found[0]
1142 .sha256
1143 .as_deref()
1144 .expect("the running file is readable");
1145 assert_eq!(hash.len(), 64);
1146 assert!(
1147 hash.bytes()
1148 .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
1149 );
1150 assert_eq!(
1151 found[0].scan_url().unwrap(),
1152 format!("{}/{hash}", constants::VIRUSTOTAL_FILE_BASE)
1153 );
1154 }
1155
1156 #[test]
1157 fn every_executable_found_on_path_is_listed() {
1158 let listed = binaries(None);
1163 let canon = |p: &std::path::Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
1164 let shown: Vec<std::path::PathBuf> = listed
1165 .iter()
1166 .map(|b| canon(std::path::Path::new(&b.path)))
1167 .collect();
1168 for stray in crate::commands::uninstall::find_stray_copies() {
1169 if !is_executable_image(&stray.path) {
1170 continue;
1171 }
1172 assert!(
1173 shown.contains(&canon(&stray.path)),
1174 "{} is on this machine but missing from the report",
1175 stray.path.display()
1176 );
1177 }
1178 }
1179
1180 fn stamped(version: Option<&str>) -> BinaryIdentity {
1182 BinaryIdentity {
1183 role: "other",
1184 name: String::new(),
1185 path: String::new(),
1186 channel: "standalone",
1187 sha256: None,
1188 version: version.map(str::to_string),
1189 marker: None,
1190 running: false,
1191 note: None,
1192 }
1193 }
1194
1195 #[test]
1196 fn the_stamp_survives_the_linker() {
1197 let exe = std::env::current_exe().expect("a test knows its own path");
1204 let bytes = std::fs::read(&exe).expect("a test can read its own executable");
1205 assert_eq!(
1206 version_from_stamp(&bytes).as_deref(),
1207 Some(constants::VERSION),
1208 "no version stamp in {} — the linker dropped it",
1209 exe.display()
1210 );
1211 }
1212
1213 #[test]
1214 fn the_search_literal_is_not_mistaken_for_a_stamp() {
1215 let mut haystack = constants::VERSION_STAMP_MARK.as_bytes().to_vec();
1220 haystack.extend_from_slice(b"not-a-version/");
1221 haystack.extend_from_slice(constants::VERSION_STAMP.as_bytes());
1222 assert_eq!(
1223 version_from_stamp(&haystack).as_deref(),
1224 Some(constants::VERSION)
1225 );
1226
1227 let mut orphan = constants::VERSION_STAMP_MARK.as_bytes().to_vec();
1229 orphan.extend_from_slice(b"1.2/");
1230 assert!(version_from_stamp(&orphan).is_none());
1231 assert!(version_from_stamp(b"no mark here at all").is_none());
1232 }
1233
1234 #[test]
1235 fn latest_is_only_claimed_when_a_release_check_has_answered() {
1236 let mut rows = vec![stamped(Some("1.16.0")), stamped(Some("1.17.0"))];
1240 mark_newest(&mut rows, None);
1241 assert_eq!(rows[0].marker, None);
1242 assert_eq!(rows[1].marker, Some("newest here"));
1243
1244 let mut rows = vec![stamped(Some("1.16.0")), stamped(Some("1.17.0"))];
1245 mark_newest(&mut rows, Some("1.17.0"));
1246 assert_eq!(rows[1].marker, Some("latest release"));
1247
1248 let mut rows = vec![stamped(Some("1.16.0")), stamped(Some("1.17.0"))];
1251 mark_newest(&mut rows, Some("2.0.0"));
1252 assert_eq!(rows[1].marker, Some("newest here"));
1253
1254 let mut rows = vec![stamped(Some("1.17.0")), stamped(None)];
1256 mark_newest(&mut rows, None);
1257 assert_eq!(rows[0].marker, None);
1258 }
1259
1260 #[test]
1261 fn versions_order_by_component_and_not_by_string() {
1262 let mut rows = vec![stamped(Some("1.9.0")), stamped(Some("1.10.0"))];
1265 mark_newest(&mut rows, None);
1266 assert_eq!(rows[1].marker, Some("newest here"));
1267 }
1268
1269 #[test]
1270 fn shims_are_not_hashed() {
1271 assert!(!is_executable_image(std::path::Path::new("devp.cmd")));
1274 assert!(!is_executable_image(std::path::Path::new("devp.ps1")));
1275 assert!(!is_executable_image(std::path::Path::new(
1276 "dev-prune.exe.old"
1277 )));
1278 let real = if cfg!(windows) { "devpw.exe" } else { "devpw" };
1279 assert!(is_executable_image(std::path::Path::new(real)));
1280 }
1281
1282 #[test]
1283 fn one_file_is_never_listed_twice() {
1284 let found = binaries(None);
1288 let mut paths: Vec<&str> = found.iter().map(|b| b.path.as_str()).collect();
1289 let total = paths.len();
1290 paths.sort_unstable();
1291 paths.dedup();
1292 assert_eq!(
1293 paths.len(),
1294 total,
1295 "duplicate path in {found:?}",
1296 found = paths
1297 );
1298 }
1299
1300 #[test]
1301 fn every_row_key_is_unique() {
1302 let report = build(&Registry::default());
1305 let mut keys: Vec<&str> = report
1306 .guarantees
1307 .iter()
1308 .chain(report.machine.iter())
1309 .map(|r| r.key)
1310 .collect();
1311 let total = keys.len();
1312 keys.sort_unstable();
1313 keys.dedup();
1314 assert_eq!(keys.len(), total);
1315 }
1316
1317 #[test]
1318 fn guarantees_never_depend_on_settings() {
1319 let mut registry = Registry::default();
1322 registry.settings.allow_manifest_rewrite = true;
1323 registry.settings.auto_update = true;
1324 let with = build(®istry);
1325 let without = build(&Registry::default());
1326
1327 let states = |r: &TrustReport| -> Vec<String> {
1328 r.guarantees.iter().map(|g| g.state.clone()).collect()
1329 };
1330 assert_eq!(states(&with), states(&without));
1331 }
1332}