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