Skip to main content

dev_prune/commands/
trust.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Handler for `dev-prune trust`.
5//
6// A tool that deletes directories on a schedule has to answer "what exactly is this
7// allowed to do on my machine?", and until this command existed the honest answer was
8// "read four documentation pages and three `devp config` keys". This prints it.
9//
10// Two kinds of row, and the distinction is the whole point:
11//
12//   - **Guarantees** are structural. They come from the code paths in `engine.rs`, they
13//     have no setting and no flag, and a build where one of them did not hold would be a
14//     bug rather than a configuration. They read the same on every machine.
15//   - **This machine** is read live — the scheduler, the Git hooks, the settings that
16//     widen what dev-prune may do. These differ per machine and are the reason the
17//     command exists at all.
18//
19// Nothing here is a self-assessment. Every "this machine" row is a value read back from
20// the registry or the OS, and the three rows that can lower the verdict are named
21// individually so the report cannot congratulate itself in the abstract.
22
23use 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/// What one row says about the state it reports.
33#[derive(Clone, Copy, PartialEq, Eq)]
34pub enum Verdict {
35    /// Structural, with no setting and no flag that changes it.
36    Guaranteed,
37    /// True on this machine, and the safe answer.
38    Safe,
39    /// True on this machine, and something the reader should know about. Never a
40    /// failure — every one of these is a choice someone made deliberately.
41    Widened,
42    /// Neither safe nor widened: a fact worth printing, like where the config lives.
43    Neutral,
44}
45
46impl Verdict {
47    /// The glyph in front of the row.
48    fn mark(self) -> &'static str {
49        match self {
50            Verdict::Guaranteed | Verdict::Safe => "+",
51            Verdict::Widened => "!",
52            Verdict::Neutral => " ",
53        }
54    }
55
56    /// The word `--json` uses. Separate from the prose so rewording a row never breaks
57    /// a script reading the document.
58    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
68/// One line of the report.
69pub struct TrustRow {
70    /// Stable identifier for `--json`.
71    pub key: &'static str,
72    /// What is being reported, for a human.
73    pub subject: &'static str,
74    /// The state it is in, in the user's terms.
75    pub state: String,
76    /// How to read that state.
77    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    /// The `--json` word for this row's verdict.
96    pub fn verdict_key(&self) -> &'static str {
97        self.verdict.key()
98    }
99}
100
101/// The whole report.
102pub struct TrustReport {
103    /// Structural guarantees. Identical on every machine.
104    pub guarantees: Vec<TrustRow>,
105    /// Live state, read from the registry and the OS.
106    pub machine: Vec<TrustRow>,
107}
108
109impl TrustReport {
110    /// Every setting on this machine that widens what dev-prune may do without asking.
111    ///
112    /// Not a score. A list, because "trust level: MEDIUM" tells nobody which switch to
113    /// look at, and the only useful version of this answer is the names.
114    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
123/// Run the `trust` command.
124pub fn run(json_output: bool) -> Result<()> {
125    let registry = Registry::load()?;
126    let report = build(&registry);
127
128    if json_output {
129        return json::emit(&json::trust_document(&report));
130    }
131
132    print_report(&report);
133    Ok(())
134}
135
136/// Add every registered repository Git refuses to read to its global `safe.directory`.
137///
138/// Git will not read a working tree whose owner on disk is not the account running it,
139/// and on Windows that state is routine and permanent: a reinstall, a restored backup or
140/// a drive carried between machines leaves the old account's identifier on every
141/// directory. `devp run` cannot date such a repository, and a repository whose age is
142/// unknown is one nothing is ever deleted from — so on an affected machine a large part
143/// of the registry silently does nothing until this is resolved.
144///
145/// Git's own suggestion is one `git config` invocation per repository, printed inside a
146/// twelve-line message, once per repository. This is that suggestion, applied to the
147/// repositories dev-prune already knows about, after showing which ones and asking.
148///
149/// It belongs to `trust` rather than to `run` because it widens what Git will open for
150/// every tool on the machine, not just this one. That is exactly the kind of change this
151/// command exists to make visible.
152pub fn fix_ownership(assume_yes: bool) -> Result<()> {
153    let registry = Registry::load()?;
154    let affected = repositories_git_refuses(&registry);
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    // Read the existing list once rather than per repository: `--add` does not
182    // deduplicate, and a machine where this was run twice would accumulate a second copy
183    // of every entry in the user's global config forever.
184    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
207/// Every registered repository whose path exists but which Git refuses on ownership.
208///
209/// Asks Git directly rather than reusing a prune pass: the question is one `rev-parse`
210/// per repository, and a prune pass would also stat every dependency directory on the
211/// machine to answer it.
212fn 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    // The list is shown to a person and then written to their config; a HashMap's order
231    // would put it in a different order every run.
232    affected.sort();
233    affected
234}
235
236/// The values already in the global `safe.directory` list.
237fn 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        // An empty list and an unreadable config lead to the same behaviour — write the
249        // entry — and `--add` on a duplicate is untidy rather than harmful.
250        _ => Vec::new(),
251    }
252}
253
254/// A path in the spelling Git uses for `safe.directory`.
255///
256/// Forward slashes even on Windows: that is the form Git prints in its own refusal
257/// message and the form it compares against, and a backslash spelling is accepted by
258/// `git config` while never matching anything.
259fn git_path_value(path: &std::path::Path) -> String {
260    path.display().to_string().replace('\\', "/")
261}
262
263/// Ask before writing to the user's global Git configuration.
264///
265/// Default no, and a non-terminal gets a no with the flag to pass next time: this widens
266/// what Git will open for every tool on the machine, which is not something a piped
267/// invocation should be able to do by accident.
268fn 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
288/// Ask the OS its two questions at the same time.
289///
290/// These are the only two rows that shell out — `schtasks` and `git config` — and
291/// together they were most of the second this report took to build. That second was
292/// spent before the configurator drew anything at all, which read as a slow tool rather
293/// than as two processes being waited on one after the other.
294fn machine_answers() -> (String, String) {
295    let scheduler = std::thread::spawn(scheduler_state);
296    let hooks = hook_state();
297    // A panic in the probe must not take the report down with it: the row's whole
298    // purpose is to say what is unknown.
299    let scheduler = scheduler
300        .join()
301        .unwrap_or_else(|_| "Unknown (the check did not finish)".to_string());
302    (scheduler, hooks)
303}
304
305/// Say what is happening while [`machine_answers`] blocks, and erase it afterwards.
306///
307/// Asking Windows for a scheduled task costs a second on its own, and it happens before
308/// the first screen of the configurator can be drawn — so without this the wizard opens
309/// on an empty terminal for long enough to look hung. Stderr, so a piped `--json` run is
310/// unaffected, and only when stderr is a terminal, so a log file never collects it.
311fn 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        // Carriage return and overwrite rather than an erase sequence: this runs before
323        // the alternate screen is entered, on terminals that predate it.
324        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
335/// Assemble the report from the code's own guarantees and this machine's actual state.
336///
337/// `pub(crate)` because the first-run configurator opens on this same report: the
338/// declaration a new user reads and what `devp trust` prints later must be the same
339/// text, or one of them is a marketing claim.
340pub(crate) fn build(registry: &Registry) -> TrustReport {
341    TrustReport {
342        guarantees: guarantees(),
343        machine: machine_state(registry),
344    }
345}
346
347/// The seven safety invariants plus the two promises that are not invariants but are
348/// asked about just as often: no telemetry, and build outputs are never touched.
349///
350/// Every string here restates something enforced in `src/engine.rs` or `src/adapters/`.
351/// [`docs/SAFETY_INVARIANTS.md`](../../docs/SAFETY_INVARIANTS.md) is the long form.
352fn 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
402/// Everything read back from this machine rather than asserted.
403fn machine_state(registry: &Registry) -> Vec<TrustRow> {
404    let s = &registry.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            // Neutral, not widened, since 1.7.0: `Widened` means someone deliberately
429            // switched something on beyond the defaults, and this is now a default. Still
430            // its own row, because "replaces its own binary" is a fact anyone reading
431            // this screen came here to learn.
432            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    // Opt-in adapters delete compiled output, which every other adapter refuses to do.
476    // Someone reading this report to find out what is deletable on their machine needs
477    // to see that they turned that on.
478    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    // The managed path, not `current_exe()`: on Windows the scheduler runs a patched
514    // twin and `devp update` replaces the managed copy, so the path that matters to
515    // someone asking what runs on this machine is the one dev-prune owns.
516    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
526/// Which opt-in adapters are switched on, in the order the report should name them.
527fn opt_in_adapters(registry: &Registry) -> Vec<&'static str> {
528    let s = &registry.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
542/// Whether anything prunes on its own on this machine.
543fn 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
554/// Whether Git hooks auto-register repositories on this machine.
555fn 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        // A Windows-spelt value is accepted by `git config` and then never matches
630        // anything, because Git normalises the directory it is checking to forward
631        // slashes before comparing. A repair that silently does nothing is the worst
632        // outcome available here.
633        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(&registry);
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(&registry);
656        let widened = report.widened();
657        assert_eq!(widened.len(), 3, "got {widened:?}");
658        // Named, not counted: a report that says "3 settings" and stops is a report
659        // nobody can act on.
660        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(&registry), vec!["gradle", "swift"]);
669    }
670
671    #[test]
672    fn every_row_key_is_unique() {
673        // The keys are the `--json` contract; two rows sharing one silently drops a
674        // fact from the document.
675        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        // If a "guarantee" could be turned off it would not be one, and the report would
691        // be claiming something the code does not enforce.
692        let mut registry = Registry::default();
693        registry.settings.allow_manifest_rewrite = true;
694        registry.settings.auto_update = true;
695        let with = build(&registry);
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}