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/// Ask the OS its two questions at the same time.
137///
138/// These are the only two rows that shell out — `schtasks` and `git config` — and
139/// together they were most of the second this report took to build. That second was
140/// spent before the configurator drew anything at all, which read as a slow tool rather
141/// than as two processes being waited on one after the other.
142fn machine_answers() -> (String, String) {
143    let scheduler = std::thread::spawn(scheduler_state);
144    let hooks = hook_state();
145    // A panic in the probe must not take the report down with it: the row's whole
146    // purpose is to say what is unknown.
147    let scheduler = scheduler
148        .join()
149        .unwrap_or_else(|_| "Unknown (the check did not finish)".to_string());
150    (scheduler, hooks)
151}
152
153/// Say what is happening while [`machine_answers`] blocks, and erase it afterwards.
154///
155/// Asking Windows for a scheduled task costs a second on its own, and it happens before
156/// the first screen of the configurator can be drawn — so without this the wizard opens
157/// on an empty terminal for long enough to look hung. Stderr, so a piped `--json` run is
158/// unaffected, and only when stderr is a terminal, so a log file never collects it.
159fn with_progress<T>(work: impl FnOnce() -> T) -> T {
160    use std::io::{IsTerminal, Write};
161
162    let mut err = std::io::stderr();
163    let show = err.is_terminal();
164    if show {
165        let _ = write!(err, "{}", constants::READING_MACHINE);
166        let _ = err.flush();
167    }
168    let value = work();
169    if show {
170        // Carriage return and overwrite rather than an erase sequence: this runs before
171        // the alternate screen is entered, on terminals that predate it.
172        let _ = write!(
173            err,
174            "\r{:width$}\r",
175            "",
176            width = constants::READING_MACHINE.chars().count()
177        );
178        let _ = err.flush();
179    }
180    value
181}
182
183/// Assemble the report from the code's own guarantees and this machine's actual state.
184///
185/// `pub(crate)` because the first-run configurator opens on this same report: the
186/// declaration a new user reads and what `devp trust` prints later must be the same
187/// text, or one of them is a marketing claim.
188pub(crate) fn build(registry: &Registry) -> TrustReport {
189    TrustReport {
190        guarantees: guarantees(),
191        machine: machine_state(registry),
192    }
193}
194
195/// The seven safety invariants plus the two promises that are not invariants but are
196/// asked about just as often: no telemetry, and build outputs are never touched.
197///
198/// Every string here restates something enforced in `src/engine.rs` or `src/adapters/`.
199/// [`docs/SAFETY_INVARIANTS.md`](../../docs/SAFETY_INVARIANTS.md) is the long form.
200fn guarantees() -> Vec<TrustRow> {
201    use Verdict::Guaranteed as G;
202    vec![
203        TrustRow::new(
204            "filesystem_scope",
205            "Filesystem scope",
206            "Registered Git repositories only",
207            G,
208        ),
209        TrustRow::new(
210            "lockfile_verification",
211            "Lockfile verification",
212            "Required before every delete",
213            G,
214        ),
215        TrustRow::new("symlinks", "Symlinks and junctions", "Refused", G),
216        TrustRow::new(
217            "nested_repositories",
218            "Nested repositories",
219            "Refused — no lockfile rebuilds someone else's history",
220            G,
221        ),
222        TrustRow::new(
223            "build_outputs",
224            "Build outputs",
225            "Never deleted — no dist/, no .next/, no .gitignore rules",
226            G,
227        ),
228        TrustRow::new(
229            "deletion_bypass",
230            "Deletion bypass",
231            "None — no flag disables a safety check",
232            G,
233        ),
234        TrustRow::new(
235            "state_writes",
236            "State writes",
237            "Atomic — temp file, then rename",
238            G,
239        ),
240        TrustRow::new("telemetry", "Telemetry", "None — there is no endpoint", G),
241        TrustRow::new(
242            "restore",
243            "Restore",
244            "`devp restore --last-run` rebuilds the last pass",
245            G,
246        ),
247    ]
248}
249
250/// Everything read back from this machine rather than asserted.
251fn machine_state(registry: &Registry) -> Vec<TrustRow> {
252    let s = &registry.settings;
253    let (scheduler, hooks) = with_progress(machine_answers);
254    let mut rows = vec![
255        TrustRow::new(
256            "network",
257            "Network requests",
258            if s.update_check {
259                format!(
260                    "Release check against GitHub, every {} days",
261                    s.update_check_interval_days
262                )
263            } else {
264                "None — the release check is off".to_string()
265            },
266            Verdict::Safe,
267        ),
268        TrustRow::new(
269            "auto_update",
270            "Auto-update",
271            if s.auto_update {
272                "On — dev-prune replaces its own binary"
273            } else {
274                "Off — updates only when you run `devp update`"
275            },
276            if s.auto_update {
277                Verdict::Widened
278            } else {
279                Verdict::Safe
280            },
281        ),
282        TrustRow::new(
283            "confirmation",
284            "Confirmation before deleting",
285            if s.require_confirmation {
286                "Required, except where you pass `--yes`"
287            } else {
288                "Off — `require_confirmation` is false"
289            },
290            if s.require_confirmation {
291                Verdict::Safe
292            } else {
293                Verdict::Widened
294            },
295        ),
296        TrustRow::new(
297            "lockfile_rewrite",
298            "Lockfile rewriting",
299            if s.allow_manifest_rewrite {
300                "Allowed — a stale lockfile is regenerated instead of refused"
301            } else {
302                "Refused — verification is read-only"
303            },
304            if s.allow_manifest_rewrite {
305                Verdict::Widened
306            } else {
307                Verdict::Safe
308            },
309        ),
310        TrustRow::new(
311            "scheduler",
312            "Background scheduler",
313            scheduler,
314            Verdict::Neutral,
315        ),
316        TrustRow::new("git_hooks", "Git hooks", hooks, Verdict::Neutral),
317    ];
318
319    // Opt-in adapters delete compiled output, which every other adapter refuses to do.
320    // Someone reading this report to find out what is deletable on their machine needs
321    // to see that they turned that on.
322    let opt_in = opt_in_adapters(registry);
323    rows.push(TrustRow::new(
324        "opt_in_adapters",
325        "Opt-in adapters",
326        if opt_in.is_empty() {
327            "None — only dependency directories are deletable".to_string()
328        } else {
329            format!("{} — build trees are deletable too", opt_in.join(", "))
330        },
331        if opt_in.is_empty() {
332            Verdict::Safe
333        } else {
334            Verdict::Widened
335        },
336    ));
337
338    rows.push(TrustRow::new(
339        "repositories",
340        "Registered repositories",
341        format!(
342            "{} — nothing outside them is ever read or written",
343            registry.repositories.len()
344        ),
345        Verdict::Neutral,
346    ));
347    rows.push(TrustRow::new(
348        "idle_window",
349        "Idle window",
350        format!(
351            "{} days of no commits and no file changes ({} for build trees, before any per-adapter window)",
352            s.idle_days,
353            s.build_idle_days.max(s.idle_days)
354        ),
355        Verdict::Neutral,
356    ));
357    // The managed path, not `current_exe()`: on Windows the scheduler runs a patched
358    // twin and `devp update` replaces the managed copy, so the path that matters to
359    // someone asking what runs on this machine is the one dev-prune owns.
360    rows.push(TrustRow::new(
361        "binary",
362        "Managed binary",
363        output::clean_path(daemon::get_exe_path()),
364        Verdict::Neutral,
365    ));
366
367    rows
368}
369
370/// Which opt-in adapters are switched on, in the order the report should name them.
371fn opt_in_adapters(registry: &Registry) -> Vec<&'static str> {
372    let s = &registry.settings;
373    [
374        ("cargo", s.enable_cargo),
375        ("gradle", s.enable_gradle),
376        ("maven", s.enable_maven),
377        ("swift", s.enable_swift),
378        ("dart", s.enable_dart),
379    ]
380    .into_iter()
381    .filter_map(|(name, on)| on.then_some(name))
382    .collect()
383}
384
385/// Whether anything prunes on its own on this machine.
386fn scheduler_state() -> String {
387    match daemon::daemon_status() {
388        Ok(daemon::DaemonStatus::Installed) => "Installed — prunes on its own".to_string(),
389        Ok(daemon::DaemonStatus::NotInstalled) => {
390            "Not installed — nothing runs unless you run it".to_string()
391        }
392        Ok(daemon::DaemonStatus::Unknown(why)) => format!("Unknown ({why})"),
393        Err(e) => format!("Unknown ({e})"),
394    }
395}
396
397/// Whether Git hooks auto-register repositories on this machine.
398fn hook_state() -> String {
399    if !hook::git_available() {
400        return "Not installed — git is not on PATH".to_string();
401    }
402    match hook::state() {
403        Ok(HookState::Active) => "Installed — new repositories register themselves".to_string(),
404        Ok(HookState::Absent) => {
405            "Not installed — repositories register only when you say so".to_string()
406        }
407        Ok(HookState::Chained { previous, .. }) => {
408            format!("Installed, chained to `{previous}`")
409        }
410        Ok(HookState::Foreign(p)) => format!("Not ours — `core.hooksPath` belongs to `{p}`"),
411        Err(e) => format!("Unknown ({e})"),
412    }
413}
414
415fn print_report(report: &TrustReport) {
416    output::print_header(&format!("What dev-prune {} may do", constants::VERSION));
417
418    println!();
419    println!("  Guaranteed by the code, on every machine");
420    println!();
421    for row in &report.guarantees {
422        print_row(row);
423    }
424
425    println!();
426    println!("  On this machine");
427    println!();
428    for row in &report.machine {
429        print_row(row);
430    }
431
432    println!();
433    let widened = report.widened();
434    if widened.is_empty() {
435        output::print_success(
436            "Nothing on this machine widens what dev-prune may do without asking.",
437        );
438    } else {
439        output::print_info(&format!(
440            "{} {} what dev-prune may do without asking: {}. Each was switched on \
441             deliberately; `devp config show` has them.",
442            widened.len(),
443            if widened.len() == 1 {
444                "setting widens"
445            } else {
446                "settings widen"
447            },
448            widened.join(", ")
449        ));
450    }
451    output::print_info(
452        "The guarantees above are enforced in `src/engine.rs` and described in full at \
453         docs/SAFETY_INVARIANTS.md. None of them has a bypass flag.",
454    );
455}
456
457fn print_row(row: &TrustRow) {
458    println!(
459        "  {}  {:<30} {}",
460        row.verdict.mark(),
461        row.subject,
462        row.state
463    );
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    #[test]
471    fn the_default_machine_widens_nothing() {
472        let registry = Registry::default();
473        let report = build(&registry);
474        assert!(
475            report.widened().is_empty(),
476            "a fresh install reports {:?} as widened",
477            report.widened()
478        );
479    }
480
481    #[test]
482    fn every_widening_setting_shows_up_by_name() {
483        let mut registry = Registry::default();
484        registry.settings.auto_update = true;
485        registry.settings.require_confirmation = false;
486        registry.settings.allow_manifest_rewrite = true;
487        registry.settings.enable_gradle = true;
488
489        let report = build(&registry);
490        let widened = report.widened();
491        assert_eq!(widened.len(), 4, "got {widened:?}");
492        // Named, not counted: a report that says "3 settings" and stops is a report
493        // nobody can act on.
494        assert!(widened.contains(&"Auto-update"));
495        assert!(widened.contains(&"Opt-in adapters"));
496    }
497
498    #[test]
499    fn opt_in_adapters_are_listed_in_a_stable_order() {
500        let mut registry = Registry::default();
501        registry.settings.enable_swift = true;
502        registry.settings.enable_gradle = true;
503        assert_eq!(opt_in_adapters(&registry), vec!["gradle", "swift"]);
504    }
505
506    #[test]
507    fn every_row_key_is_unique() {
508        // The keys are the `--json` contract; two rows sharing one silently drops a
509        // fact from the document.
510        let report = build(&Registry::default());
511        let mut keys: Vec<&str> = report
512            .guarantees
513            .iter()
514            .chain(report.machine.iter())
515            .map(|r| r.key)
516            .collect();
517        let total = keys.len();
518        keys.sort_unstable();
519        keys.dedup();
520        assert_eq!(keys.len(), total);
521    }
522
523    #[test]
524    fn guarantees_never_depend_on_settings() {
525        // If a "guarantee" could be turned off it would not be one, and the report would
526        // be claiming something the code does not enforce.
527        let mut registry = Registry::default();
528        registry.settings.allow_manifest_rewrite = true;
529        registry.settings.auto_update = true;
530        let with = build(&registry);
531        let without = build(&Registry::default());
532
533        let states = |r: &TrustReport| -> Vec<String> {
534            r.guarantees.iter().map(|g| g.state.clone()).collect()
535        };
536        assert_eq!(states(&with), states(&without));
537    }
538}