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 running: bool,
118 pub note: Option<&'static str>,
120}
121
122impl BinaryIdentity {
123 pub fn scan_url(&self) -> Option<String> {
125 self.sha256
126 .as_ref()
127 .map(|h| format!("{}/{h}", constants::VIRUSTOTAL_FILE_BASE))
128 }
129}
130
131pub struct TrustReport {
133 pub guarantees: Vec<TrustRow>,
135 pub machine: Vec<TrustRow>,
137 pub binaries: Vec<BinaryIdentity>,
143}
144
145impl TrustReport {
146 pub fn widened(&self) -> Vec<&str> {
151 self.machine
152 .iter()
153 .filter(|r| r.verdict == Verdict::Widened)
154 .map(|r| r.subject)
155 .collect()
156 }
157}
158
159pub fn run(json_output: bool) -> Result<()> {
161 let registry = Registry::load()?;
162 let mut report = build(®istry);
163 report.binaries = binaries();
164
165 if json_output {
166 return json::emit(&json::trust_document(&report));
167 }
168
169 print_report(&report);
170 Ok(())
171}
172
173pub fn fix_ownership(assume_yes: bool) -> Result<()> {
190 let registry = Registry::load()?;
191 let affected = repositories_git_refuses(®istry);
192
193 if affected.is_empty() {
194 output::print_success("Git reads every registered repository. Nothing to fix.");
195 return Ok(());
196 }
197
198 let n = affected.len();
199 output::print_header(&format!(
200 "{n} {} Git will not read",
201 output::plural(n, "repository", "repositories")
202 ));
203 for path in &affected {
204 println!(" {}", output::styled_path(path));
205 }
206 println!();
207 output::print_info(&format!(
208 "This adds {} to git's global `safe.directory` list, which tells Git to open {} despite \
209 the owner recorded on disk. It affects every tool on this machine that uses Git, not only \
210 dev-prune.",
211 output::plural(n, "this path", "these paths"),
212 output::plural(n, "it", "them")
213 ));
214 output::print_info("Undo one with: git config --global --unset-all safe.directory <path>");
215
216 if !confirm_fix(assume_yes) {
217 return Ok(());
218 }
219
220 let existing = configured_safe_directories();
224 let mut added = 0usize;
225 for path in &affected {
226 let value = git_path_value(path);
227 if existing.iter().any(|e| e == &value) {
228 continue;
229 }
230 let status = crate::spawn::command("git")
231 .args(["config", "--global", "--add", "safe.directory", &value])
232 .status();
233 match status {
234 Ok(s) if s.success() => added += 1,
235 _ => output::print_warning(&format!("Could not add `{value}` — skipped.")),
236 }
237 }
238
239 output::print_success(&format!(
240 "Added {added} {}. Run `devp run --dry-run` to see what is now examinable.",
241 output::plural(added, "entry", "entries")
242 ));
243 Ok(())
244}
245
246fn repositories_git_refuses(registry: &Registry) -> Vec<std::path::PathBuf> {
252 let mut affected: Vec<std::path::PathBuf> = registry
253 .repositories
254 .keys()
255 .filter(|path| path.exists())
256 .filter(|path| {
257 let output = crate::scanner::git::git_in(path)
258 .args(["rev-parse", "--git-dir"])
259 .output();
260 match output {
261 Ok(out) if !out.status.success() => String::from_utf8_lossy(&out.stderr)
262 .to_lowercase()
263 .contains(constants::GIT_DUBIOUS_OWNERSHIP),
264 _ => false,
265 }
266 })
267 .cloned()
268 .collect();
269 affected.sort();
272 affected
273}
274
275fn configured_safe_directories() -> Vec<String> {
277 let output = crate::spawn::command("git")
278 .args(["config", "--global", "--get-all", "safe.directory"])
279 .output();
280 match output {
281 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
282 .lines()
283 .map(str::trim)
284 .filter(|l| !l.is_empty())
285 .map(str::to_string)
286 .collect(),
287 _ => Vec::new(),
290 }
291}
292
293fn git_path_value(path: &std::path::Path) -> String {
299 path.display().to_string().replace('\\', "/")
300}
301
302fn confirm_fix(yes: bool) -> bool {
308 use std::io::{IsTerminal, Write};
309 if yes {
310 return true;
311 }
312 if !std::io::stdin().is_terminal() {
313 output::print_info("Not running in a terminal — pass `--yes` to write these.");
314 return false;
315 }
316 eprint!("Add them to git's safe.directory list? [y/N]: ");
317 if std::io::stderr().flush().is_err() {
318 return false;
319 }
320 let mut input = String::new();
321 if std::io::stdin().read_line(&mut input).is_err() {
322 return false;
323 }
324 matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
325}
326
327fn machine_answers() -> (String, String) {
334 let scheduler = std::thread::spawn(scheduler_state);
335 let hooks = hook_state();
336 let scheduler = scheduler
339 .join()
340 .unwrap_or_else(|_| "Unknown (the check did not finish)".to_string());
341 (scheduler, hooks)
342}
343
344fn with_progress<T>(work: impl FnOnce() -> T) -> T {
351 use std::io::{IsTerminal, Write};
352
353 let mut err = std::io::stderr();
354 let show = err.is_terminal();
355 if show {
356 let _ = write!(err, "{}", constants::READING_MACHINE);
357 let _ = err.flush();
358 }
359 let value = work();
360 if show {
361 let _ = write!(
364 err,
365 "\r{:width$}\r",
366 "",
367 width = constants::READING_MACHINE.chars().count()
368 );
369 let _ = err.flush();
370 }
371 value
372}
373
374pub(crate) fn build(registry: &Registry) -> TrustReport {
380 TrustReport {
381 guarantees: guarantees(),
382 machine: machine_state(registry),
383 binaries: Vec::new(),
384 }
385}
386
387fn guarantees() -> Vec<TrustRow> {
394 use Verdict::Guaranteed as G;
395 vec![
396 TrustRow::new(
397 "filesystem_scope",
398 "Filesystem scope",
399 "Registered Git repositories only",
400 G,
401 ),
402 TrustRow::new(
403 "lockfile_verification",
404 "Lockfile verification",
405 "Required before every delete",
406 G,
407 ),
408 TrustRow::new("symlinks", "Symlinks and junctions", "Refused", G),
409 TrustRow::new(
410 "nested_repositories",
411 "Nested repositories",
412 "Refused — no lockfile rebuilds someone else's history",
413 G,
414 ),
415 TrustRow::new(
416 "build_outputs",
417 "Build outputs",
418 "Never deleted — no dist/, no .next/, no .gitignore rules",
419 G,
420 ),
421 TrustRow::new(
422 "container_disk",
423 "Container disk",
424 "Reported, never deleted — `devp caches docker` prints the commands",
425 G,
426 ),
427 TrustRow::new(
428 "deletion_bypass",
429 "Deletion bypass",
430 "None — no flag disables a safety check",
431 G,
432 ),
433 TrustRow::new(
434 "state_writes",
435 "State writes",
436 "Atomic — temp file, then rename",
437 G,
438 ),
439 TrustRow::new("telemetry", "Telemetry", "None — there is no endpoint", G),
440 TrustRow::new(
441 "restore",
442 "Restore",
443 "`devp restore --last-run` rebuilds the last pass",
444 G,
445 ),
446 ]
447}
448
449fn machine_state(registry: &Registry) -> Vec<TrustRow> {
451 let s = ®istry.settings;
452 let (scheduler, hooks) = with_progress(machine_answers);
453 let mut rows = vec![
454 TrustRow::new(
455 "network",
456 "Network requests",
457 if s.update_check {
458 format!(
459 "Release check against GitHub, every {} days",
460 s.update_check_interval_days
461 )
462 } else {
463 "None — the release check is off".to_string()
464 },
465 Verdict::Safe,
466 ),
467 TrustRow::new(
468 "auto_update",
469 "Auto-update",
470 if s.version_lock {
473 "Off — `version_lock` pins this copy to the version it is"
474 } else if s.auto_update {
475 "On (the default) — a newer release installs itself after a pass"
476 } else {
477 "Off — updates only when you run `devp update --install`"
478 },
479 if s.auto_update && !s.version_lock {
484 Verdict::Neutral
485 } else {
486 Verdict::Safe
487 },
488 ),
489 TrustRow::new(
490 "confirmation",
491 "Confirmation before deleting",
492 if s.require_confirmation {
493 "Required, except where you pass `--yes`"
494 } else {
495 "Off — `require_confirmation` is false"
496 },
497 if s.require_confirmation {
498 Verdict::Safe
499 } else {
500 Verdict::Widened
501 },
502 ),
503 TrustRow::new(
504 "lockfile_rewrite",
505 "Lockfile rewriting",
506 if s.allow_manifest_rewrite {
507 "Allowed — a stale lockfile is regenerated instead of refused"
508 } else {
509 "Refused — verification is read-only"
510 },
511 if s.allow_manifest_rewrite {
512 Verdict::Widened
513 } else {
514 Verdict::Safe
515 },
516 ),
517 TrustRow::new(
518 "scheduler",
519 "Background scheduler",
520 scheduler,
521 Verdict::Neutral,
522 ),
523 TrustRow::new("git_hooks", "Git hooks", hooks, Verdict::Neutral),
524 ];
525
526 let opt_in = opt_in_adapters(registry);
530 rows.push(TrustRow::new(
531 "opt_in_adapters",
532 "Opt-in adapters",
533 if opt_in.is_empty() {
534 "None — only dependency directories are deletable".to_string()
535 } else {
536 format!("{} — build trees are deletable too", opt_in.join(", "))
537 },
538 if opt_in.is_empty() {
539 Verdict::Safe
540 } else {
541 Verdict::Widened
542 },
543 ));
544
545 rows.push(TrustRow::new(
546 "repositories",
547 "Registered repositories",
548 format!(
549 "{} — nothing outside them is ever read or written",
550 registry.repositories.len()
551 ),
552 Verdict::Neutral,
553 ));
554 rows.push(TrustRow::new(
555 "idle_window",
556 "Idle window",
557 format!(
558 "{} days of no commits and no file changes ({} for build trees, before any per-adapter window)",
559 s.idle_days,
560 s.build_idle_days.max(s.idle_days)
561 ),
562 Verdict::Neutral,
563 ));
564 rows.push(TrustRow::new(
569 "binary",
570 "Managed binary",
571 output::clean_path(daemon::get_exe_path()),
572 Verdict::Neutral,
573 ));
574
575 rows
576}
577
578pub(crate) fn binaries() -> Vec<BinaryIdentity> {
590 let mut found: Vec<(std::path::PathBuf, &'static str, Option<&'static str>)> = Vec::new();
591
592 if let Ok(managed) = crate::setup::managed_exe_path() {
596 let dir = managed.parent().map(|d| d.to_path_buf());
597 found.push((managed, "managed", None));
598 if let Some(dir) = dir {
599 let alias = if cfg!(windows) { "devp.exe" } else { "devp" };
600 found.push((dir.join(alias), "alias", None));
604 if cfg!(windows) {
605 found.push((
606 dir.join(constants::WINDOWS_WINDOWLESS_BIN),
607 "windowless",
608 Some(
609 "A different digest by design — the same code linked for \
610 the GUI subsystem, so the scheduler flashes no console window.",
611 ),
612 ));
613 }
614 }
615 }
616
617 let running = std::env::current_exe().ok();
620 if let Some(current) = running.clone() {
621 found.push((current, "other", None));
622 }
623
624 let same = |a: &std::path::Path, b: &std::path::Path| -> bool {
625 match (a.canonicalize(), b.canonicalize()) {
629 (Ok(a), Ok(b)) => a == b,
630 _ => a == b,
631 }
632 };
633
634 let mut rows: Vec<BinaryIdentity> = Vec::new();
635 for (path, role, note) in found {
636 if !path.is_file()
637 || rows
638 .iter()
639 .any(|r| same(std::path::Path::new(&r.path), &path))
640 {
641 continue;
642 }
643 let is_running = running.as_deref().is_some_and(|c| same(c, &path));
644 rows.push(BinaryIdentity {
645 role,
646 name: path
647 .file_name()
648 .map(|n| n.to_string_lossy().into_owned())
649 .unwrap_or_default(),
650 path: output::clean_path(&path),
651 sha256: sha256_of(&path),
652 running: is_running,
653 note,
654 });
655 }
656
657 annotate_alias(&mut rows);
658
659 rows.sort_by_key(|r| !r.running);
662 rows
663}
664
665fn annotate_alias(rows: &mut [BinaryIdentity]) {
677 let managed = rows
678 .iter()
679 .find(|r| r.role == "managed")
680 .and_then(|r| r.sha256.clone());
681 let Some(alias) = rows.iter_mut().find(|r| r.role == "alias") else {
682 return;
683 };
684 alias.note = match (&managed, &alias.sha256) {
685 (Some(managed), Some(mine)) if managed == mine => Some(
686 "The same bytes as dev-prune under a second name, so a scanner \
687 builds one reputation record instead of two.",
688 ),
689 (Some(_), Some(_)) => Some(
690 "An earlier release under a second name: an upgrade replaced dev-prune \
691 and this has not caught up. The next dev-prune command you run in a \
692 terminal restores the pair.",
693 ),
694 _ => None,
695 };
696}
697
698fn sha256_of(path: &std::path::Path) -> Option<String> {
704 use sha2::{Digest, Sha256};
705 use std::fmt::Write as _;
706
707 let bytes = std::fs::read(path).ok()?;
708 let mut h = Sha256::new();
709 h.update(&bytes);
710 Some(h.finalize().iter().fold(String::new(), |mut acc, b| {
713 let _ = write!(acc, "{b:02x}");
714 acc
715 }))
716}
717
718fn opt_in_adapters(registry: &Registry) -> Vec<&'static str> {
720 let s = ®istry.settings;
721 [
722 ("cargo", s.enable_cargo),
723 ("gradle", s.enable_gradle),
724 ("maven", s.enable_maven),
725 ("swift", s.enable_swift),
726 ("dart", s.enable_dart),
727 ("mix_build", s.enable_mix_build),
728 ("vcpkg", s.enable_vcpkg),
729 ("cmake_build", s.enable_cmake_build),
730 ]
731 .into_iter()
732 .filter_map(|(name, on)| on.then_some(name))
733 .collect()
734}
735
736fn scheduler_state() -> String {
738 match daemon::daemon_status() {
739 Ok(daemon::DaemonStatus::Installed) => "Installed — prunes on its own".to_string(),
740 Ok(daemon::DaemonStatus::NotInstalled) => {
741 "Not installed — nothing runs unless you run it".to_string()
742 }
743 Ok(daemon::DaemonStatus::Unknown(why)) => format!("Unknown ({why})"),
744 Err(e) => format!("Unknown ({e})"),
745 }
746}
747
748fn hook_state() -> String {
750 if !hook::git_available() {
751 return "Not installed — git is not on PATH".to_string();
752 }
753 match hook::state() {
754 Ok(HookState::Active) => "Installed — new repositories register themselves".to_string(),
755 Ok(HookState::Absent) => {
756 "Not installed — repositories register only when you say so".to_string()
757 }
758 Ok(HookState::Chained { previous, .. }) => {
759 format!("Installed, chained to `{previous}`")
760 }
761 Ok(HookState::Foreign(p)) => format!("Not ours — `core.hooksPath` belongs to `{p}`"),
762 Err(e) => format!("Unknown ({e})"),
763 }
764}
765
766fn print_report(report: &TrustReport) {
767 output::print_header(&format!("What dev-prune {} may do", constants::VERSION));
768
769 println!();
770 println!(" Guaranteed by the code, on every machine");
771 println!();
772 for row in &report.guarantees {
773 print_row(row);
774 }
775
776 println!();
777 println!(" On this machine");
778 println!();
779 for row in &report.machine {
780 print_row(row);
781 }
782
783 println!();
784 let widened = report.widened();
785 if widened.is_empty() {
786 output::print_success(
787 "Nothing on this machine widens what dev-prune may do without asking.",
788 );
789 } else {
790 output::print_info(&format!(
791 "{} {} what dev-prune may do without asking: {}. Each was switched on \
792 deliberately; `devp config show` has them.",
793 widened.len(),
794 if widened.len() == 1 {
795 "setting widens"
796 } else {
797 "settings widen"
798 },
799 widened.join(", ")
800 ));
801 }
802 output::print_info(
803 "The guarantees above are enforced in `src/engine.rs` and described in full at \
804 docs/SAFETY_INVARIANTS.md. None of them has a bypass flag.",
805 );
806
807 print_binaries(&report.binaries);
808}
809
810fn print_binaries(binaries: &[BinaryIdentity]) {
812 if binaries.is_empty() {
813 return;
814 }
815
816 println!();
817 println!(" Binaries on this machine");
818 println!();
819 for b in binaries {
820 let label = if b.running {
821 format!("{} (running)", b.name)
822 } else {
823 b.name.clone()
824 };
825 let mark = if b.running { ">" } else { " " };
826 println!(" {mark} {label:<30} {}", b.path);
827 match (&b.sha256, b.scan_url()) {
828 (Some(hash), Some(url)) => {
829 println!(" {:<30} {hash}", "SHA-256");
830 println!(" {:<30} {url}", "Scan report");
831 }
832 _ => println!(" {:<30} could not be read", "SHA-256"),
835 }
836 if let Some(note) = b.note {
837 println!(" {note}");
838 }
839 println!();
840 }
841
842 output::print_info(
843 "Those links are lookups by digest, not uploads — dev-prune sends no file \
844 anywhere. A digest the service has never seen comes back `not found`, which \
845 means unscanned rather than clean.",
846 );
847 output::print_info(
848 "A copy installed from a release has the same SHA-256 as the asset published \
849 beside it, so the two can be compared by hand. One built by `cargo install` was \
850 compiled here and matches nothing published.",
851 );
852}
853
854fn print_row(row: &TrustRow) {
855 println!(
856 " {} {:<30} {}",
857 row.verdict.mark(),
858 row.subject,
859 row.state
860 );
861}
862
863#[cfg(test)]
864mod tests {
865 use super::*;
866
867 fn pair(managed: Option<&str>, alias: Option<&str>) -> Vec<BinaryIdentity> {
869 let row = |role: &'static str, sha: Option<&str>| BinaryIdentity {
870 role,
871 name: String::new(),
872 path: String::new(),
873 sha256: sha.map(str::to_string),
874 running: false,
875 note: None,
876 };
877 vec![row("managed", managed), row("alias", alias)]
878 }
879
880 fn alias_note(rows: &[BinaryIdentity]) -> Option<&'static str> {
881 rows.iter().find(|r| r.role == "alias").unwrap().note
882 }
883
884 #[test]
885 fn the_alias_note_follows_the_digests_and_not_the_expectation() {
886 let mut same = pair(Some("aa"), Some("aa"));
890 annotate_alias(&mut same);
891 assert!(alias_note(&same).is_some_and(|n| n.contains("The same bytes")));
892
893 let mut stale = pair(Some("aa"), Some("bb"));
894 annotate_alias(&mut stale);
895 assert!(alias_note(&stale).is_some_and(|n| n.contains("An earlier release")));
896
897 let mut unreadable = pair(Some("aa"), None);
899 annotate_alias(&mut unreadable);
900 assert!(alias_note(&unreadable).is_none());
901 }
902
903 #[test]
904 fn safe_directory_values_use_the_spelling_git_compares_against() {
905 let path = std::path::Path::new("V:\\Code\\Project");
910 assert_eq!(git_path_value(path), "V:/Code/Project");
911 }
912
913 #[test]
914 fn the_default_machine_widens_nothing() {
915 let registry = Registry::default();
916 let report = build(®istry);
917 assert!(
918 report.widened().is_empty(),
919 "a fresh install reports {:?} as widened",
920 report.widened()
921 );
922 }
923
924 #[test]
925 fn every_widening_setting_shows_up_by_name() {
926 let mut registry = Registry::default();
927 registry.settings.require_confirmation = false;
928 registry.settings.allow_manifest_rewrite = true;
929 registry.settings.enable_gradle = true;
930
931 let report = build(®istry);
932 let widened = report.widened();
933 assert_eq!(widened.len(), 3, "got {widened:?}");
934 assert!(widened.contains(&"Opt-in adapters"));
937 }
938
939 #[test]
940 fn opt_in_adapters_are_listed_in_a_stable_order() {
941 let mut registry = Registry::default();
942 registry.settings.enable_swift = true;
943 registry.settings.enable_gradle = true;
944 assert_eq!(opt_in_adapters(®istry), vec!["gradle", "swift"]);
945 }
946
947 #[test]
948 fn build_never_hashes_anything() {
949 assert!(build(&Registry::default()).binaries.is_empty());
953 }
954
955 #[test]
956 fn the_running_binary_is_listed_first_and_hashed() {
957 let found = binaries();
958 let running: Vec<&BinaryIdentity> = found.iter().filter(|b| b.running).collect();
959 assert_eq!(running.len(), 1, "expected exactly one running binary");
960 assert!(found[0].running, "the running binary must lead the list");
961
962 let hash = found[0]
963 .sha256
964 .as_deref()
965 .expect("the running file is readable");
966 assert_eq!(hash.len(), 64);
967 assert!(
968 hash.bytes()
969 .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
970 );
971 assert_eq!(
972 found[0].scan_url().unwrap(),
973 format!("{}/{hash}", constants::VIRUSTOTAL_FILE_BASE)
974 );
975 }
976
977 #[test]
978 fn one_file_is_never_listed_twice() {
979 let found = binaries();
983 let mut paths: Vec<&str> = found.iter().map(|b| b.path.as_str()).collect();
984 let total = paths.len();
985 paths.sort_unstable();
986 paths.dedup();
987 assert_eq!(
988 paths.len(),
989 total,
990 "duplicate path in {found:?}",
991 found = paths
992 );
993 }
994
995 #[test]
996 fn every_row_key_is_unique() {
997 let report = build(&Registry::default());
1000 let mut keys: Vec<&str> = report
1001 .guarantees
1002 .iter()
1003 .chain(report.machine.iter())
1004 .map(|r| r.key)
1005 .collect();
1006 let total = keys.len();
1007 keys.sort_unstable();
1008 keys.dedup();
1009 assert_eq!(keys.len(), total);
1010 }
1011
1012 #[test]
1013 fn guarantees_never_depend_on_settings() {
1014 let mut registry = Registry::default();
1017 registry.settings.allow_manifest_rewrite = true;
1018 registry.settings.auto_update = true;
1019 let with = build(®istry);
1020 let without = build(&Registry::default());
1021
1022 let states = |r: &TrustReport| -> Vec<String> {
1023 r.guarantees.iter().map(|g| g.state.clone()).collect()
1024 };
1025 assert_eq!(states(&with), states(&without));
1026 }
1027}