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 three promises that are not invariants but are
350/// asked about just as often: no telemetry, build outputs are never touched, and neither
351/// is container disk.
352///
353/// Every string here restates something enforced in `src/engine.rs` or `src/adapters/`.
354/// [`docs/SAFETY_INVARIANTS.md`](../../docs/SAFETY_INVARIANTS.md) is the long form.
355fn 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
411/// Everything read back from this machine rather than asserted.
412fn machine_state(registry: &Registry) -> Vec<TrustRow> {
413    let s = &registry.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            // The pin answers this row's question outright, so it answers it here rather
433            // than leaving the screen saying a pass will install a release it will not.
434            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            // Neutral, not widened, since 1.7.0: `Widened` means someone deliberately
442            // switched something on beyond the defaults, and this is now a default. Still
443            // its own row, because "replaces its own binary" is a fact anyone reading
444            // this screen came here to learn.
445            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    // Opt-in adapters delete compiled output, which every other adapter refuses to do.
489    // Someone reading this report to find out what is deletable on their machine needs
490    // to see that they turned that on.
491    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    // The managed path, not `current_exe()`: on Windows the scheduler runs a patched
527    // twin and `devp update` replaces the managed copy, so the path that matters to
528    // someone asking what runs on this machine is the one dev-prune owns.
529    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
539/// Which opt-in adapters are switched on, in the order the report should name them.
540fn opt_in_adapters(registry: &Registry) -> Vec<&'static str> {
541    let s = &registry.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
557/// Whether anything prunes on its own on this machine.
558fn 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
569/// Whether Git hooks auto-register repositories on this machine.
570fn 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        // A Windows-spelt value is accepted by `git config` and then never matches
645        // anything, because Git normalises the directory it is checking to forward
646        // slashes before comparing. A repair that silently does nothing is the worst
647        // outcome available here.
648        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(&registry);
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(&registry);
671        let widened = report.widened();
672        assert_eq!(widened.len(), 3, "got {widened:?}");
673        // Named, not counted: a report that says "3 settings" and stops is a report
674        // nobody can act on.
675        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(&registry), vec!["gradle", "swift"]);
684    }
685
686    #[test]
687    fn every_row_key_is_unique() {
688        // The keys are the `--json` contract; two rows sharing one silently drops a
689        // fact from the document.
690        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        // If a "guarantee" could be turned off it would not be one, and the report would
706        // be claiming something the code does not enforce.
707        let mut registry = Registry::default();
708        registry.settings.allow_manifest_rewrite = true;
709        registry.settings.auto_update = true;
710        let with = build(&registry);
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}