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    ]
379    .into_iter()
380    .filter_map(|(name, on)| on.then_some(name))
381    .collect()
382}
383
384/// Whether anything prunes on its own on this machine.
385fn scheduler_state() -> String {
386    match daemon::daemon_status() {
387        Ok(daemon::DaemonStatus::Installed) => "Installed — prunes on its own".to_string(),
388        Ok(daemon::DaemonStatus::NotInstalled) => {
389            "Not installed — nothing runs unless you run it".to_string()
390        }
391        Ok(daemon::DaemonStatus::Unknown(why)) => format!("Unknown ({why})"),
392        Err(e) => format!("Unknown ({e})"),
393    }
394}
395
396/// Whether Git hooks auto-register repositories on this machine.
397fn hook_state() -> String {
398    if !hook::git_available() {
399        return "Not installed — git is not on PATH".to_string();
400    }
401    match hook::state() {
402        Ok(HookState::Active) => "Installed — new repositories register themselves".to_string(),
403        Ok(HookState::Absent) => {
404            "Not installed — repositories register only when you say so".to_string()
405        }
406        Ok(HookState::Chained { previous, .. }) => {
407            format!("Installed, chained to `{previous}`")
408        }
409        Ok(HookState::Foreign(p)) => format!("Not ours — `core.hooksPath` belongs to `{p}`"),
410        Err(e) => format!("Unknown ({e})"),
411    }
412}
413
414fn print_report(report: &TrustReport) {
415    output::print_header(&format!("What dev-prune {} may do", constants::VERSION));
416
417    println!();
418    println!("  Guaranteed by the code, on every machine");
419    println!();
420    for row in &report.guarantees {
421        print_row(row);
422    }
423
424    println!();
425    println!("  On this machine");
426    println!();
427    for row in &report.machine {
428        print_row(row);
429    }
430
431    println!();
432    let widened = report.widened();
433    if widened.is_empty() {
434        output::print_success(
435            "Nothing on this machine widens what dev-prune may do without asking.",
436        );
437    } else {
438        output::print_info(&format!(
439            "{} {} what dev-prune may do without asking: {}. Each was switched on \
440             deliberately; `devp config show` has them.",
441            widened.len(),
442            if widened.len() == 1 {
443                "setting widens"
444            } else {
445                "settings widen"
446            },
447            widened.join(", ")
448        ));
449    }
450    output::print_info(
451        "The guarantees above are enforced in `src/engine.rs` and described in full at \
452         docs/SAFETY_INVARIANTS.md. None of them has a bypass flag.",
453    );
454}
455
456fn print_row(row: &TrustRow) {
457    println!(
458        "  {}  {:<30} {}",
459        row.verdict.mark(),
460        row.subject,
461        row.state
462    );
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    #[test]
470    fn the_default_machine_widens_nothing() {
471        let registry = Registry::default();
472        let report = build(&registry);
473        assert!(
474            report.widened().is_empty(),
475            "a fresh install reports {:?} as widened",
476            report.widened()
477        );
478    }
479
480    #[test]
481    fn every_widening_setting_shows_up_by_name() {
482        let mut registry = Registry::default();
483        registry.settings.auto_update = true;
484        registry.settings.require_confirmation = false;
485        registry.settings.allow_manifest_rewrite = true;
486        registry.settings.enable_gradle = true;
487
488        let report = build(&registry);
489        let widened = report.widened();
490        assert_eq!(widened.len(), 4, "got {widened:?}");
491        // Named, not counted: a report that says "3 settings" and stops is a report
492        // nobody can act on.
493        assert!(widened.contains(&"Auto-update"));
494        assert!(widened.contains(&"Opt-in adapters"));
495    }
496
497    #[test]
498    fn opt_in_adapters_are_listed_in_a_stable_order() {
499        let mut registry = Registry::default();
500        registry.settings.enable_swift = true;
501        registry.settings.enable_gradle = true;
502        assert_eq!(opt_in_adapters(&registry), vec!["gradle", "swift"]);
503    }
504
505    #[test]
506    fn every_row_key_is_unique() {
507        // The keys are the `--json` contract; two rows sharing one silently drops a
508        // fact from the document.
509        let report = build(&Registry::default());
510        let mut keys: Vec<&str> = report
511            .guarantees
512            .iter()
513            .chain(report.machine.iter())
514            .map(|r| r.key)
515            .collect();
516        let total = keys.len();
517        keys.sort_unstable();
518        keys.dedup();
519        assert_eq!(keys.len(), total);
520    }
521
522    #[test]
523    fn guarantees_never_depend_on_settings() {
524        // If a "guarantee" could be turned off it would not be one, and the report would
525        // be claiming something the code does not enforce.
526        let mut registry = Registry::default();
527        registry.settings.allow_manifest_rewrite = true;
528        registry.settings.auto_update = true;
529        let with = build(&registry);
530        let without = build(&Registry::default());
531
532        let states = |r: &TrustReport| -> Vec<String> {
533            r.guarantees.iter().map(|g| g.state.clone()).collect()
534        };
535        assert_eq!(states(&with), states(&without));
536    }
537}