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 TrustReport {
103 pub guarantees: Vec<TrustRow>,
105 pub machine: Vec<TrustRow>,
107}
108
109impl TrustReport {
110 pub fn widened(&self) -> Vec<&str> {
115 self.machine
116 .iter()
117 .filter(|r| r.verdict == Verdict::Widened)
118 .map(|r| r.subject)
119 .collect()
120 }
121}
122
123pub fn run(json_output: bool) -> Result<()> {
125 let registry = Registry::load()?;
126 let report = build(®istry);
127
128 if json_output {
129 return json::emit(&json::trust_document(&report));
130 }
131
132 print_report(&report);
133 Ok(())
134}
135
136pub fn fix_ownership(assume_yes: bool) -> Result<()> {
153 let registry = Registry::load()?;
154 let affected = repositories_git_refuses(®istry);
155
156 if affected.is_empty() {
157 output::print_success("Git reads every registered repository. Nothing to fix.");
158 return Ok(());
159 }
160
161 let n = affected.len();
162 output::print_header(&format!(
163 "{n} {} Git will not read",
164 output::plural(n, "repository", "repositories")
165 ));
166 for path in &affected {
167 println!(" {}", output::styled_path(path));
168 }
169 println!();
170 output::print_info(&format!(
171 "This adds {} to git's global `safe.directory` list, which tells Git to open {} despite \
172 the owner recorded on disk. It affects every tool on this machine that uses Git, not only \
173 dev-prune.",
174 output::plural(n, "this path", "these paths"),
175 output::plural(n, "it", "them")
176 ));
177 output::print_info("Undo one with: git config --global --unset-all safe.directory <path>");
178
179 if !confirm_fix(assume_yes) {
180 return Ok(());
181 }
182
183 let existing = configured_safe_directories();
187 let mut added = 0usize;
188 for path in &affected {
189 let value = git_path_value(path);
190 if existing.iter().any(|e| e == &value) {
191 continue;
192 }
193 let status = crate::spawn::command("git")
194 .args(["config", "--global", "--add", "safe.directory", &value])
195 .status();
196 match status {
197 Ok(s) if s.success() => added += 1,
198 _ => output::print_warning(&format!("Could not add `{value}` — skipped.")),
199 }
200 }
201
202 output::print_success(&format!(
203 "Added {added} {}. Run `devp run --dry-run` to see what is now examinable.",
204 output::plural(added, "entry", "entries")
205 ));
206 Ok(())
207}
208
209fn repositories_git_refuses(registry: &Registry) -> Vec<std::path::PathBuf> {
215 let mut affected: Vec<std::path::PathBuf> = registry
216 .repositories
217 .keys()
218 .filter(|path| path.exists())
219 .filter(|path| {
220 let output = crate::scanner::git::git_in(path)
221 .args(["rev-parse", "--git-dir"])
222 .output();
223 match output {
224 Ok(out) if !out.status.success() => String::from_utf8_lossy(&out.stderr)
225 .to_lowercase()
226 .contains(constants::GIT_DUBIOUS_OWNERSHIP),
227 _ => false,
228 }
229 })
230 .cloned()
231 .collect();
232 affected.sort();
235 affected
236}
237
238fn configured_safe_directories() -> Vec<String> {
240 let output = crate::spawn::command("git")
241 .args(["config", "--global", "--get-all", "safe.directory"])
242 .output();
243 match output {
244 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
245 .lines()
246 .map(str::trim)
247 .filter(|l| !l.is_empty())
248 .map(str::to_string)
249 .collect(),
250 _ => Vec::new(),
253 }
254}
255
256fn git_path_value(path: &std::path::Path) -> String {
262 path.display().to_string().replace('\\', "/")
263}
264
265fn confirm_fix(yes: bool) -> bool {
271 use std::io::{IsTerminal, Write};
272 if yes {
273 return true;
274 }
275 if !std::io::stdin().is_terminal() {
276 output::print_info("Not running in a terminal — pass `--yes` to write these.");
277 return false;
278 }
279 eprint!("Add them to git's safe.directory list? [y/N]: ");
280 if std::io::stderr().flush().is_err() {
281 return false;
282 }
283 let mut input = String::new();
284 if std::io::stdin().read_line(&mut input).is_err() {
285 return false;
286 }
287 matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
288}
289
290fn machine_answers() -> (String, String) {
297 let scheduler = std::thread::spawn(scheduler_state);
298 let hooks = hook_state();
299 let scheduler = scheduler
302 .join()
303 .unwrap_or_else(|_| "Unknown (the check did not finish)".to_string());
304 (scheduler, hooks)
305}
306
307fn with_progress<T>(work: impl FnOnce() -> T) -> T {
314 use std::io::{IsTerminal, Write};
315
316 let mut err = std::io::stderr();
317 let show = err.is_terminal();
318 if show {
319 let _ = write!(err, "{}", constants::READING_MACHINE);
320 let _ = err.flush();
321 }
322 let value = work();
323 if show {
324 let _ = write!(
327 err,
328 "\r{:width$}\r",
329 "",
330 width = constants::READING_MACHINE.chars().count()
331 );
332 let _ = err.flush();
333 }
334 value
335}
336
337pub(crate) fn build(registry: &Registry) -> TrustReport {
343 TrustReport {
344 guarantees: guarantees(),
345 machine: machine_state(registry),
346 }
347}
348
349fn guarantees() -> Vec<TrustRow> {
356 use Verdict::Guaranteed as G;
357 vec![
358 TrustRow::new(
359 "filesystem_scope",
360 "Filesystem scope",
361 "Registered Git repositories only",
362 G,
363 ),
364 TrustRow::new(
365 "lockfile_verification",
366 "Lockfile verification",
367 "Required before every delete",
368 G,
369 ),
370 TrustRow::new("symlinks", "Symlinks and junctions", "Refused", G),
371 TrustRow::new(
372 "nested_repositories",
373 "Nested repositories",
374 "Refused — no lockfile rebuilds someone else's history",
375 G,
376 ),
377 TrustRow::new(
378 "build_outputs",
379 "Build outputs",
380 "Never deleted — no dist/, no .next/, no .gitignore rules",
381 G,
382 ),
383 TrustRow::new(
384 "container_disk",
385 "Container disk",
386 "Reported, never deleted — `devp caches docker` prints the commands",
387 G,
388 ),
389 TrustRow::new(
390 "deletion_bypass",
391 "Deletion bypass",
392 "None — no flag disables a safety check",
393 G,
394 ),
395 TrustRow::new(
396 "state_writes",
397 "State writes",
398 "Atomic — temp file, then rename",
399 G,
400 ),
401 TrustRow::new("telemetry", "Telemetry", "None — there is no endpoint", G),
402 TrustRow::new(
403 "restore",
404 "Restore",
405 "`devp restore --last-run` rebuilds the last pass",
406 G,
407 ),
408 ]
409}
410
411fn machine_state(registry: &Registry) -> Vec<TrustRow> {
413 let s = ®istry.settings;
414 let (scheduler, hooks) = with_progress(machine_answers);
415 let mut rows = vec![
416 TrustRow::new(
417 "network",
418 "Network requests",
419 if s.update_check {
420 format!(
421 "Release check against GitHub, every {} days",
422 s.update_check_interval_days
423 )
424 } else {
425 "None — the release check is off".to_string()
426 },
427 Verdict::Safe,
428 ),
429 TrustRow::new(
430 "auto_update",
431 "Auto-update",
432 if s.version_lock {
435 "Off — `version_lock` pins this copy to the version it is"
436 } else if s.auto_update {
437 "On (the default) — a newer release installs itself after a pass"
438 } else {
439 "Off — updates only when you run `devp update --install`"
440 },
441 if s.auto_update && !s.version_lock {
446 Verdict::Neutral
447 } else {
448 Verdict::Safe
449 },
450 ),
451 TrustRow::new(
452 "confirmation",
453 "Confirmation before deleting",
454 if s.require_confirmation {
455 "Required, except where you pass `--yes`"
456 } else {
457 "Off — `require_confirmation` is false"
458 },
459 if s.require_confirmation {
460 Verdict::Safe
461 } else {
462 Verdict::Widened
463 },
464 ),
465 TrustRow::new(
466 "lockfile_rewrite",
467 "Lockfile rewriting",
468 if s.allow_manifest_rewrite {
469 "Allowed — a stale lockfile is regenerated instead of refused"
470 } else {
471 "Refused — verification is read-only"
472 },
473 if s.allow_manifest_rewrite {
474 Verdict::Widened
475 } else {
476 Verdict::Safe
477 },
478 ),
479 TrustRow::new(
480 "scheduler",
481 "Background scheduler",
482 scheduler,
483 Verdict::Neutral,
484 ),
485 TrustRow::new("git_hooks", "Git hooks", hooks, Verdict::Neutral),
486 ];
487
488 let opt_in = opt_in_adapters(registry);
492 rows.push(TrustRow::new(
493 "opt_in_adapters",
494 "Opt-in adapters",
495 if opt_in.is_empty() {
496 "None — only dependency directories are deletable".to_string()
497 } else {
498 format!("{} — build trees are deletable too", opt_in.join(", "))
499 },
500 if opt_in.is_empty() {
501 Verdict::Safe
502 } else {
503 Verdict::Widened
504 },
505 ));
506
507 rows.push(TrustRow::new(
508 "repositories",
509 "Registered repositories",
510 format!(
511 "{} — nothing outside them is ever read or written",
512 registry.repositories.len()
513 ),
514 Verdict::Neutral,
515 ));
516 rows.push(TrustRow::new(
517 "idle_window",
518 "Idle window",
519 format!(
520 "{} days of no commits and no file changes ({} for build trees, before any per-adapter window)",
521 s.idle_days,
522 s.build_idle_days.max(s.idle_days)
523 ),
524 Verdict::Neutral,
525 ));
526 rows.push(TrustRow::new(
530 "binary",
531 "Managed binary",
532 output::clean_path(daemon::get_exe_path()),
533 Verdict::Neutral,
534 ));
535
536 rows
537}
538
539fn opt_in_adapters(registry: &Registry) -> Vec<&'static str> {
541 let s = ®istry.settings;
542 [
543 ("cargo", s.enable_cargo),
544 ("gradle", s.enable_gradle),
545 ("maven", s.enable_maven),
546 ("swift", s.enable_swift),
547 ("dart", s.enable_dart),
548 ("mix_build", s.enable_mix_build),
549 ("vcpkg", s.enable_vcpkg),
550 ("cmake_build", s.enable_cmake_build),
551 ]
552 .into_iter()
553 .filter_map(|(name, on)| on.then_some(name))
554 .collect()
555}
556
557fn scheduler_state() -> String {
559 match daemon::daemon_status() {
560 Ok(daemon::DaemonStatus::Installed) => "Installed — prunes on its own".to_string(),
561 Ok(daemon::DaemonStatus::NotInstalled) => {
562 "Not installed — nothing runs unless you run it".to_string()
563 }
564 Ok(daemon::DaemonStatus::Unknown(why)) => format!("Unknown ({why})"),
565 Err(e) => format!("Unknown ({e})"),
566 }
567}
568
569fn hook_state() -> String {
571 if !hook::git_available() {
572 return "Not installed — git is not on PATH".to_string();
573 }
574 match hook::state() {
575 Ok(HookState::Active) => "Installed — new repositories register themselves".to_string(),
576 Ok(HookState::Absent) => {
577 "Not installed — repositories register only when you say so".to_string()
578 }
579 Ok(HookState::Chained { previous, .. }) => {
580 format!("Installed, chained to `{previous}`")
581 }
582 Ok(HookState::Foreign(p)) => format!("Not ours — `core.hooksPath` belongs to `{p}`"),
583 Err(e) => format!("Unknown ({e})"),
584 }
585}
586
587fn print_report(report: &TrustReport) {
588 output::print_header(&format!("What dev-prune {} may do", constants::VERSION));
589
590 println!();
591 println!(" Guaranteed by the code, on every machine");
592 println!();
593 for row in &report.guarantees {
594 print_row(row);
595 }
596
597 println!();
598 println!(" On this machine");
599 println!();
600 for row in &report.machine {
601 print_row(row);
602 }
603
604 println!();
605 let widened = report.widened();
606 if widened.is_empty() {
607 output::print_success(
608 "Nothing on this machine widens what dev-prune may do without asking.",
609 );
610 } else {
611 output::print_info(&format!(
612 "{} {} what dev-prune may do without asking: {}. Each was switched on \
613 deliberately; `devp config show` has them.",
614 widened.len(),
615 if widened.len() == 1 {
616 "setting widens"
617 } else {
618 "settings widen"
619 },
620 widened.join(", ")
621 ));
622 }
623 output::print_info(
624 "The guarantees above are enforced in `src/engine.rs` and described in full at \
625 docs/SAFETY_INVARIANTS.md. None of them has a bypass flag.",
626 );
627}
628
629fn print_row(row: &TrustRow) {
630 println!(
631 " {} {:<30} {}",
632 row.verdict.mark(),
633 row.subject,
634 row.state
635 );
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641
642 #[test]
643 fn safe_directory_values_use_the_spelling_git_compares_against() {
644 let path = std::path::Path::new("V:\\Code\\Project");
649 assert_eq!(git_path_value(path), "V:/Code/Project");
650 }
651
652 #[test]
653 fn the_default_machine_widens_nothing() {
654 let registry = Registry::default();
655 let report = build(®istry);
656 assert!(
657 report.widened().is_empty(),
658 "a fresh install reports {:?} as widened",
659 report.widened()
660 );
661 }
662
663 #[test]
664 fn every_widening_setting_shows_up_by_name() {
665 let mut registry = Registry::default();
666 registry.settings.require_confirmation = false;
667 registry.settings.allow_manifest_rewrite = true;
668 registry.settings.enable_gradle = true;
669
670 let report = build(®istry);
671 let widened = report.widened();
672 assert_eq!(widened.len(), 3, "got {widened:?}");
673 assert!(widened.contains(&"Opt-in adapters"));
676 }
677
678 #[test]
679 fn opt_in_adapters_are_listed_in_a_stable_order() {
680 let mut registry = Registry::default();
681 registry.settings.enable_swift = true;
682 registry.settings.enable_gradle = true;
683 assert_eq!(opt_in_adapters(®istry), vec!["gradle", "swift"]);
684 }
685
686 #[test]
687 fn every_row_key_is_unique() {
688 let report = build(&Registry::default());
691 let mut keys: Vec<&str> = report
692 .guarantees
693 .iter()
694 .chain(report.machine.iter())
695 .map(|r| r.key)
696 .collect();
697 let total = keys.len();
698 keys.sort_unstable();
699 keys.dedup();
700 assert_eq!(keys.len(), total);
701 }
702
703 #[test]
704 fn guarantees_never_depend_on_settings() {
705 let mut registry = Registry::default();
708 registry.settings.allow_manifest_rewrite = true;
709 registry.settings.auto_update = true;
710 let with = build(®istry);
711 let without = build(&Registry::default());
712
713 let states = |r: &TrustReport| -> Vec<String> {
714 r.guarantees.iter().map(|g| g.state.clone()).collect()
715 };
716 assert_eq!(states(&with), states(&without));
717 }
718}