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 \
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    // Read the existing list once rather than per repository: `--add` does not
184    // deduplicate, and a machine where this was run twice would accumulate a second copy
185    // of every entry in the user's global config forever.
186    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
209/// Every registered repository whose path exists but which Git refuses on ownership.
210///
211/// Asks Git directly rather than reusing a prune pass: the question is one `rev-parse`
212/// per repository, and a prune pass would also stat every dependency directory on the
213/// machine to answer it.
214fn 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    // The list is shown to a person and then written to their config; a HashMap's order
233    // would put it in a different order every run.
234    affected.sort();
235    affected
236}
237
238/// The values already in the global `safe.directory` list.
239fn 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        // An empty list and an unreadable config lead to the same behaviour — write the
251        // entry — and `--add` on a duplicate is untidy rather than harmful.
252        _ => Vec::new(),
253    }
254}
255
256/// A path in the spelling Git uses for `safe.directory`.
257///
258/// Forward slashes even on Windows: that is the form Git prints in its own refusal
259/// message and the form it compares against, and a backslash spelling is accepted by
260/// `git config` while never matching anything.
261fn git_path_value(path: &std::path::Path) -> String {
262    path.display().to_string().replace('\\', "/")
263}
264
265/// Ask before writing to the user's global Git configuration.
266///
267/// Default no, and a non-terminal gets a no with the flag to pass next time: this widens
268/// what Git will open for every tool on the machine, which is not something a piped
269/// invocation should be able to do by accident.
270fn 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
290/// Ask the OS its two questions at the same time.
291///
292/// These are the only two rows that shell out — `schtasks` and `git config` — and
293/// together they were most of the second this report took to build. That second was
294/// spent before the configurator drew anything at all, which read as a slow tool rather
295/// than as two processes being waited on one after the other.
296fn machine_answers() -> (String, String) {
297    let scheduler = std::thread::spawn(scheduler_state);
298    let hooks = hook_state();
299    // A panic in the probe must not take the report down with it: the row's whole
300    // purpose is to say what is unknown.
301    let scheduler = scheduler
302        .join()
303        .unwrap_or_else(|_| "Unknown (the check did not finish)".to_string());
304    (scheduler, hooks)
305}
306
307/// Say what is happening while [`machine_answers`] blocks, and erase it afterwards.
308///
309/// Asking Windows for a scheduled task costs a second on its own, and it happens before
310/// the first screen of the configurator can be drawn — so without this the wizard opens
311/// on an empty terminal for long enough to look hung. Stderr, so a piped `--json` run is
312/// unaffected, and only when stderr is a terminal, so a log file never collects it.
313fn 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        // Carriage return and overwrite rather than an erase sequence: this runs before
325        // the alternate screen is entered, on terminals that predate it.
326        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
337/// Assemble the report from the code's own guarantees and this machine's actual state.
338///
339/// `pub(crate)` because the first-run configurator opens on this same report: the
340/// declaration a new user reads and what `devp trust` prints later must be the same
341/// text, or one of them is a marketing claim.
342pub(crate) fn build(registry: &Registry) -> TrustReport {
343    TrustReport {
344        guarantees: guarantees(),
345        machine: machine_state(registry),
346    }
347}
348
349/// The seven safety invariants plus the two promises that are not invariants but are
350/// asked about just as often: no telemetry, and build outputs are never touched.
351///
352/// Every string here restates something enforced in `src/engine.rs` or `src/adapters/`.
353/// [`docs/SAFETY_INVARIANTS.md`](../../docs/SAFETY_INVARIANTS.md) is the long form.
354fn guarantees() -> Vec<TrustRow> {
355    use Verdict::Guaranteed as G;
356    vec![
357        TrustRow::new(
358            "filesystem_scope",
359            "Filesystem scope",
360            "Registered Git repositories only",
361            G,
362        ),
363        TrustRow::new(
364            "lockfile_verification",
365            "Lockfile verification",
366            "Required before every delete",
367            G,
368        ),
369        TrustRow::new("symlinks", "Symlinks and junctions", "Refused", G),
370        TrustRow::new(
371            "nested_repositories",
372            "Nested repositories",
373            "Refused — no lockfile rebuilds someone else's history",
374            G,
375        ),
376        TrustRow::new(
377            "build_outputs",
378            "Build outputs",
379            "Never deleted — no dist/, no .next/, no .gitignore rules",
380            G,
381        ),
382        TrustRow::new(
383            "deletion_bypass",
384            "Deletion bypass",
385            "None — no flag disables a safety check",
386            G,
387        ),
388        TrustRow::new(
389            "state_writes",
390            "State writes",
391            "Atomic — temp file, then rename",
392            G,
393        ),
394        TrustRow::new("telemetry", "Telemetry", "None — there is no endpoint", G),
395        TrustRow::new(
396            "restore",
397            "Restore",
398            "`devp restore --last-run` rebuilds the last pass",
399            G,
400        ),
401    ]
402}
403
404/// Everything read back from this machine rather than asserted.
405fn machine_state(registry: &Registry) -> Vec<TrustRow> {
406    let s = &registry.settings;
407    let (scheduler, hooks) = with_progress(machine_answers);
408    let mut rows = vec![
409        TrustRow::new(
410            "network",
411            "Network requests",
412            if s.update_check {
413                format!(
414                    "Release check against GitHub, every {} days",
415                    s.update_check_interval_days
416                )
417            } else {
418                "None — the release check is off".to_string()
419            },
420            Verdict::Safe,
421        ),
422        TrustRow::new(
423            "auto_update",
424            "Auto-update",
425            // The pin answers this row's question outright, so it answers it here rather
426            // than leaving the screen saying a pass will install a release it will not.
427            if s.version_lock {
428                "Off — `version_lock` pins this copy to the version it is"
429            } else if s.auto_update {
430                "On (the default) — a newer release installs itself after a pass"
431            } else {
432                "Off — updates only when you run `devp update --install`"
433            },
434            // Neutral, not widened, since 1.7.0: `Widened` means someone deliberately
435            // switched something on beyond the defaults, and this is now a default. Still
436            // its own row, because "replaces its own binary" is a fact anyone reading
437            // this screen came here to learn.
438            if s.auto_update && !s.version_lock {
439                Verdict::Neutral
440            } else {
441                Verdict::Safe
442            },
443        ),
444        TrustRow::new(
445            "confirmation",
446            "Confirmation before deleting",
447            if s.require_confirmation {
448                "Required, except where you pass `--yes`"
449            } else {
450                "Off — `require_confirmation` is false"
451            },
452            if s.require_confirmation {
453                Verdict::Safe
454            } else {
455                Verdict::Widened
456            },
457        ),
458        TrustRow::new(
459            "lockfile_rewrite",
460            "Lockfile rewriting",
461            if s.allow_manifest_rewrite {
462                "Allowed — a stale lockfile is regenerated instead of refused"
463            } else {
464                "Refused — verification is read-only"
465            },
466            if s.allow_manifest_rewrite {
467                Verdict::Widened
468            } else {
469                Verdict::Safe
470            },
471        ),
472        TrustRow::new(
473            "scheduler",
474            "Background scheduler",
475            scheduler,
476            Verdict::Neutral,
477        ),
478        TrustRow::new("git_hooks", "Git hooks", hooks, Verdict::Neutral),
479    ];
480
481    // Opt-in adapters delete compiled output, which every other adapter refuses to do.
482    // Someone reading this report to find out what is deletable on their machine needs
483    // to see that they turned that on.
484    let opt_in = opt_in_adapters(registry);
485    rows.push(TrustRow::new(
486        "opt_in_adapters",
487        "Opt-in adapters",
488        if opt_in.is_empty() {
489            "None — only dependency directories are deletable".to_string()
490        } else {
491            format!("{} — build trees are deletable too", opt_in.join(", "))
492        },
493        if opt_in.is_empty() {
494            Verdict::Safe
495        } else {
496            Verdict::Widened
497        },
498    ));
499
500    rows.push(TrustRow::new(
501        "repositories",
502        "Registered repositories",
503        format!(
504            "{} — nothing outside them is ever read or written",
505            registry.repositories.len()
506        ),
507        Verdict::Neutral,
508    ));
509    rows.push(TrustRow::new(
510        "idle_window",
511        "Idle window",
512        format!(
513            "{} days of no commits and no file changes ({} for build trees, before any per-adapter window)",
514            s.idle_days,
515            s.build_idle_days.max(s.idle_days)
516        ),
517        Verdict::Neutral,
518    ));
519    // The managed path, not `current_exe()`: on Windows the scheduler runs a patched
520    // twin and `devp update` replaces the managed copy, so the path that matters to
521    // someone asking what runs on this machine is the one dev-prune owns.
522    rows.push(TrustRow::new(
523        "binary",
524        "Managed binary",
525        output::clean_path(daemon::get_exe_path()),
526        Verdict::Neutral,
527    ));
528
529    rows
530}
531
532/// Which opt-in adapters are switched on, in the order the report should name them.
533fn opt_in_adapters(registry: &Registry) -> Vec<&'static str> {
534    let s = &registry.settings;
535    [
536        ("cargo", s.enable_cargo),
537        ("gradle", s.enable_gradle),
538        ("maven", s.enable_maven),
539        ("swift", s.enable_swift),
540        ("dart", s.enable_dart),
541        ("mix_build", s.enable_mix_build),
542        ("vcpkg", s.enable_vcpkg),
543        ("cmake_build", s.enable_cmake_build),
544    ]
545    .into_iter()
546    .filter_map(|(name, on)| on.then_some(name))
547    .collect()
548}
549
550/// Whether anything prunes on its own on this machine.
551fn scheduler_state() -> String {
552    match daemon::daemon_status() {
553        Ok(daemon::DaemonStatus::Installed) => "Installed — prunes on its own".to_string(),
554        Ok(daemon::DaemonStatus::NotInstalled) => {
555            "Not installed — nothing runs unless you run it".to_string()
556        }
557        Ok(daemon::DaemonStatus::Unknown(why)) => format!("Unknown ({why})"),
558        Err(e) => format!("Unknown ({e})"),
559    }
560}
561
562/// Whether Git hooks auto-register repositories on this machine.
563fn hook_state() -> String {
564    if !hook::git_available() {
565        return "Not installed — git is not on PATH".to_string();
566    }
567    match hook::state() {
568        Ok(HookState::Active) => "Installed — new repositories register themselves".to_string(),
569        Ok(HookState::Absent) => {
570            "Not installed — repositories register only when you say so".to_string()
571        }
572        Ok(HookState::Chained { previous, .. }) => {
573            format!("Installed, chained to `{previous}`")
574        }
575        Ok(HookState::Foreign(p)) => format!("Not ours — `core.hooksPath` belongs to `{p}`"),
576        Err(e) => format!("Unknown ({e})"),
577    }
578}
579
580fn print_report(report: &TrustReport) {
581    output::print_header(&format!("What dev-prune {} may do", constants::VERSION));
582
583    println!();
584    println!("  Guaranteed by the code, on every machine");
585    println!();
586    for row in &report.guarantees {
587        print_row(row);
588    }
589
590    println!();
591    println!("  On this machine");
592    println!();
593    for row in &report.machine {
594        print_row(row);
595    }
596
597    println!();
598    let widened = report.widened();
599    if widened.is_empty() {
600        output::print_success(
601            "Nothing on this machine widens what dev-prune may do without asking.",
602        );
603    } else {
604        output::print_info(&format!(
605            "{} {} what dev-prune may do without asking: {}. Each was switched on \
606             deliberately; `devp config show` has them.",
607            widened.len(),
608            if widened.len() == 1 {
609                "setting widens"
610            } else {
611                "settings widen"
612            },
613            widened.join(", ")
614        ));
615    }
616    output::print_info(
617        "The guarantees above are enforced in `src/engine.rs` and described in full at \
618         docs/SAFETY_INVARIANTS.md. None of them has a bypass flag.",
619    );
620}
621
622fn print_row(row: &TrustRow) {
623    println!(
624        "  {}  {:<30} {}",
625        row.verdict.mark(),
626        row.subject,
627        row.state
628    );
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634
635    #[test]
636    fn safe_directory_values_use_the_spelling_git_compares_against() {
637        // A Windows-spelt value is accepted by `git config` and then never matches
638        // anything, because Git normalises the directory it is checking to forward
639        // slashes before comparing. A repair that silently does nothing is the worst
640        // outcome available here.
641        let path = std::path::Path::new("V:\\Code\\Project");
642        assert_eq!(git_path_value(path), "V:/Code/Project");
643    }
644
645    #[test]
646    fn the_default_machine_widens_nothing() {
647        let registry = Registry::default();
648        let report = build(&registry);
649        assert!(
650            report.widened().is_empty(),
651            "a fresh install reports {:?} as widened",
652            report.widened()
653        );
654    }
655
656    #[test]
657    fn every_widening_setting_shows_up_by_name() {
658        let mut registry = Registry::default();
659        registry.settings.require_confirmation = false;
660        registry.settings.allow_manifest_rewrite = true;
661        registry.settings.enable_gradle = true;
662
663        let report = build(&registry);
664        let widened = report.widened();
665        assert_eq!(widened.len(), 3, "got {widened:?}");
666        // Named, not counted: a report that says "3 settings" and stops is a report
667        // nobody can act on.
668        assert!(widened.contains(&"Opt-in adapters"));
669    }
670
671    #[test]
672    fn opt_in_adapters_are_listed_in_a_stable_order() {
673        let mut registry = Registry::default();
674        registry.settings.enable_swift = true;
675        registry.settings.enable_gradle = true;
676        assert_eq!(opt_in_adapters(&registry), vec!["gradle", "swift"]);
677    }
678
679    #[test]
680    fn every_row_key_is_unique() {
681        // The keys are the `--json` contract; two rows sharing one silently drops a
682        // fact from the document.
683        let report = build(&Registry::default());
684        let mut keys: Vec<&str> = report
685            .guarantees
686            .iter()
687            .chain(report.machine.iter())
688            .map(|r| r.key)
689            .collect();
690        let total = keys.len();
691        keys.sort_unstable();
692        keys.dedup();
693        assert_eq!(keys.len(), total);
694    }
695
696    #[test]
697    fn guarantees_never_depend_on_settings() {
698        // If a "guarantee" could be turned off it would not be one, and the report would
699        // be claiming something the code does not enforce.
700        let mut registry = Registry::default();
701        registry.settings.allow_manifest_rewrite = true;
702        registry.settings.auto_update = true;
703        let with = build(&registry);
704        let without = build(&Registry::default());
705
706        let states = |r: &TrustReport| -> Vec<String> {
707            r.guarantees.iter().map(|g| g.state.clone()).collect()
708        };
709        assert_eq!(states(&with), states(&without));
710    }
711}