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/// One executable dev-prune owns on this machine, and the digest a scanner sees.
102///
103/// A separate type from [`TrustRow`] because the hash has to survive into `--json` as a
104/// field of its own. Somebody comparing their copy against a published `.sha256` should
105/// not have to parse it back out of a sentence.
106pub struct BinaryIdentity {
107    /// Stable identifier for `--json`: what this executable is *for*, not what it is
108    /// named, so a reader does not have to know that `devp` is the short name.
109    pub role: &'static str,
110    /// The file name, for a human.
111    pub name: String,
112    /// Where it is.
113    pub path: String,
114    /// Lower-case hex SHA-256, or `None` when the file could not be read.
115    pub sha256: Option<String>,
116    /// Whether this is the executable answering right now.
117    pub running: bool,
118    /// Why this one's digest differs from the others, when it does.
119    pub note: Option<&'static str>,
120}
121
122impl BinaryIdentity {
123    /// The scan report for this file's digest, if it could be hashed.
124    pub fn scan_url(&self) -> Option<String> {
125        self.sha256
126            .as_ref()
127            .map(|h| format!("{}/{h}", constants::VIRUSTOTAL_FILE_BASE))
128    }
129}
130
131/// The whole report.
132pub struct TrustReport {
133    /// Structural guarantees. Identical on every machine.
134    pub guarantees: Vec<TrustRow>,
135    /// Live state, read from the registry and the OS.
136    pub machine: Vec<TrustRow>,
137    /// Every executable dev-prune owns here, the one that is running first.
138    ///
139    /// Filled by [`run`] and not by [`build`]: hashing three executables costs more than
140    /// every other row in this report put together, and the first-run configurator opens
141    /// on `build` while the user is waiting at a blank terminal.
142    pub binaries: Vec<BinaryIdentity>,
143}
144
145impl TrustReport {
146    /// Every setting on this machine that widens what dev-prune may do without asking.
147    ///
148    /// Not a score. A list, because "trust level: MEDIUM" tells nobody which switch to
149    /// look at, and the only useful version of this answer is the names.
150    pub fn widened(&self) -> Vec<&str> {
151        self.machine
152            .iter()
153            .filter(|r| r.verdict == Verdict::Widened)
154            .map(|r| r.subject)
155            .collect()
156    }
157}
158
159/// Run the `trust` command.
160pub fn run(json_output: bool) -> Result<()> {
161    let registry = Registry::load()?;
162    let mut report = build(&registry);
163    report.binaries = binaries();
164
165    if json_output {
166        return json::emit(&json::trust_document(&report));
167    }
168
169    print_report(&report);
170    Ok(())
171}
172
173/// Add every registered repository Git refuses to read to its global `safe.directory`.
174///
175/// Git will not read a working tree whose owner on disk is not the account running it,
176/// and on Windows that state is routine and permanent: a reinstall, a restored backup or
177/// a drive carried between machines leaves the old account's identifier on every
178/// directory. `devp run` cannot date such a repository, and a repository whose age is
179/// unknown is one nothing is ever deleted from — so on an affected machine a large part
180/// of the registry silently does nothing until this is resolved.
181///
182/// Git's own suggestion is one `git config` invocation per repository, printed inside a
183/// twelve-line message, once per repository. This is that suggestion, applied to the
184/// repositories dev-prune already knows about, after showing which ones and asking.
185///
186/// It belongs to `trust` rather than to `run` because it widens what Git will open for
187/// every tool on the machine, not just this one. That is exactly the kind of change this
188/// command exists to make visible.
189pub fn fix_ownership(assume_yes: bool) -> Result<()> {
190    let registry = Registry::load()?;
191    let affected = repositories_git_refuses(&registry);
192
193    if affected.is_empty() {
194        output::print_success("Git reads every registered repository. Nothing to fix.");
195        return Ok(());
196    }
197
198    let n = affected.len();
199    output::print_header(&format!(
200        "{n} {} Git will not read",
201        output::plural(n, "repository", "repositories")
202    ));
203    for path in &affected {
204        println!("    {}", output::styled_path(path));
205    }
206    println!();
207    output::print_info(&format!(
208        "This adds {} to git's global `safe.directory` list, which tells Git to open {} despite \
209         the owner recorded on disk. It affects every tool on this machine that uses Git, not only \
210         dev-prune.",
211        output::plural(n, "this path", "these paths"),
212        output::plural(n, "it", "them")
213    ));
214    output::print_info("Undo one with:  git config --global --unset-all safe.directory <path>");
215
216    if !confirm_fix(assume_yes) {
217        return Ok(());
218    }
219
220    // Read the existing list once rather than per repository: `--add` does not
221    // deduplicate, and a machine where this was run twice would accumulate a second copy
222    // of every entry in the user's global config forever.
223    let existing = configured_safe_directories();
224    let mut added = 0usize;
225    for path in &affected {
226        let value = git_path_value(path);
227        if existing.iter().any(|e| e == &value) {
228            continue;
229        }
230        let status = crate::spawn::command("git")
231            .args(["config", "--global", "--add", "safe.directory", &value])
232            .status();
233        match status {
234            Ok(s) if s.success() => added += 1,
235            _ => output::print_warning(&format!("Could not add `{value}` — skipped.")),
236        }
237    }
238
239    output::print_success(&format!(
240        "Added {added} {}. Run `devp run --dry-run` to see what is now examinable.",
241        output::plural(added, "entry", "entries")
242    ));
243    Ok(())
244}
245
246/// Every registered repository whose path exists but which Git refuses on ownership.
247///
248/// Asks Git directly rather than reusing a prune pass: the question is one `rev-parse`
249/// per repository, and a prune pass would also stat every dependency directory on the
250/// machine to answer it.
251fn repositories_git_refuses(registry: &Registry) -> Vec<std::path::PathBuf> {
252    let mut affected: Vec<std::path::PathBuf> = registry
253        .repositories
254        .keys()
255        .filter(|path| path.exists())
256        .filter(|path| {
257            let output = crate::scanner::git::git_in(path)
258                .args(["rev-parse", "--git-dir"])
259                .output();
260            match output {
261                Ok(out) if !out.status.success() => String::from_utf8_lossy(&out.stderr)
262                    .to_lowercase()
263                    .contains(constants::GIT_DUBIOUS_OWNERSHIP),
264                _ => false,
265            }
266        })
267        .cloned()
268        .collect();
269    // The list is shown to a person and then written to their config; a HashMap's order
270    // would put it in a different order every run.
271    affected.sort();
272    affected
273}
274
275/// The values already in the global `safe.directory` list.
276fn configured_safe_directories() -> Vec<String> {
277    let output = crate::spawn::command("git")
278        .args(["config", "--global", "--get-all", "safe.directory"])
279        .output();
280    match output {
281        Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
282            .lines()
283            .map(str::trim)
284            .filter(|l| !l.is_empty())
285            .map(str::to_string)
286            .collect(),
287        // An empty list and an unreadable config lead to the same behaviour — write the
288        // entry — and `--add` on a duplicate is untidy rather than harmful.
289        _ => Vec::new(),
290    }
291}
292
293/// A path in the spelling Git uses for `safe.directory`.
294///
295/// Forward slashes even on Windows: that is the form Git prints in its own refusal
296/// message and the form it compares against, and a backslash spelling is accepted by
297/// `git config` while never matching anything.
298fn git_path_value(path: &std::path::Path) -> String {
299    path.display().to_string().replace('\\', "/")
300}
301
302/// Ask before writing to the user's global Git configuration.
303///
304/// Default no, and a non-terminal gets a no with the flag to pass next time: this widens
305/// what Git will open for every tool on the machine, which is not something a piped
306/// invocation should be able to do by accident.
307fn confirm_fix(yes: bool) -> bool {
308    use std::io::{IsTerminal, Write};
309    if yes {
310        return true;
311    }
312    if !std::io::stdin().is_terminal() {
313        output::print_info("Not running in a terminal — pass `--yes` to write these.");
314        return false;
315    }
316    eprint!("Add them to git's safe.directory list? [y/N]: ");
317    if std::io::stderr().flush().is_err() {
318        return false;
319    }
320    let mut input = String::new();
321    if std::io::stdin().read_line(&mut input).is_err() {
322        return false;
323    }
324    matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
325}
326
327/// Ask the OS its two questions at the same time.
328///
329/// These are the only two rows that shell out — `schtasks` and `git config` — and
330/// together they were most of the second this report took to build. That second was
331/// spent before the configurator drew anything at all, which read as a slow tool rather
332/// than as two processes being waited on one after the other.
333fn machine_answers() -> (String, String) {
334    let scheduler = std::thread::spawn(scheduler_state);
335    let hooks = hook_state();
336    // A panic in the probe must not take the report down with it: the row's whole
337    // purpose is to say what is unknown.
338    let scheduler = scheduler
339        .join()
340        .unwrap_or_else(|_| "Unknown (the check did not finish)".to_string());
341    (scheduler, hooks)
342}
343
344/// Say what is happening while [`machine_answers`] blocks, and erase it afterwards.
345///
346/// Asking Windows for a scheduled task costs a second on its own, and it happens before
347/// the first screen of the configurator can be drawn — so without this the wizard opens
348/// on an empty terminal for long enough to look hung. Stderr, so a piped `--json` run is
349/// unaffected, and only when stderr is a terminal, so a log file never collects it.
350fn with_progress<T>(work: impl FnOnce() -> T) -> T {
351    use std::io::{IsTerminal, Write};
352
353    let mut err = std::io::stderr();
354    let show = err.is_terminal();
355    if show {
356        let _ = write!(err, "{}", constants::READING_MACHINE);
357        let _ = err.flush();
358    }
359    let value = work();
360    if show {
361        // Carriage return and overwrite rather than an erase sequence: this runs before
362        // the alternate screen is entered, on terminals that predate it.
363        let _ = write!(
364            err,
365            "\r{:width$}\r",
366            "",
367            width = constants::READING_MACHINE.chars().count()
368        );
369        let _ = err.flush();
370    }
371    value
372}
373
374/// Assemble the report from the code's own guarantees and this machine's actual state.
375///
376/// `pub(crate)` because the first-run configurator opens on this same report: the
377/// declaration a new user reads and what `devp trust` prints later must be the same
378/// text, or one of them is a marketing claim.
379pub(crate) fn build(registry: &Registry) -> TrustReport {
380    TrustReport {
381        guarantees: guarantees(),
382        machine: machine_state(registry),
383        binaries: Vec::new(),
384    }
385}
386
387/// The seven safety invariants plus the three promises that are not invariants but are
388/// asked about just as often: no telemetry, build outputs are never touched, and neither
389/// is container disk.
390///
391/// Every string here restates something enforced in `src/engine.rs` or `src/adapters/`.
392/// [`docs/SAFETY_INVARIANTS.md`](../../docs/SAFETY_INVARIANTS.md) is the long form.
393fn guarantees() -> Vec<TrustRow> {
394    use Verdict::Guaranteed as G;
395    vec![
396        TrustRow::new(
397            "filesystem_scope",
398            "Filesystem scope",
399            "Registered Git repositories only",
400            G,
401        ),
402        TrustRow::new(
403            "lockfile_verification",
404            "Lockfile verification",
405            "Required before every delete",
406            G,
407        ),
408        TrustRow::new("symlinks", "Symlinks and junctions", "Refused", G),
409        TrustRow::new(
410            "nested_repositories",
411            "Nested repositories",
412            "Refused — no lockfile rebuilds someone else's history",
413            G,
414        ),
415        TrustRow::new(
416            "build_outputs",
417            "Build outputs",
418            "Never deleted — no dist/, no .next/, no .gitignore rules",
419            G,
420        ),
421        TrustRow::new(
422            "container_disk",
423            "Container disk",
424            "Reported, never deleted — `devp caches docker` prints the commands",
425            G,
426        ),
427        TrustRow::new(
428            "deletion_bypass",
429            "Deletion bypass",
430            "None — no flag disables a safety check",
431            G,
432        ),
433        TrustRow::new(
434            "state_writes",
435            "State writes",
436            "Atomic — temp file, then rename",
437            G,
438        ),
439        TrustRow::new("telemetry", "Telemetry", "None — there is no endpoint", G),
440        TrustRow::new(
441            "restore",
442            "Restore",
443            "`devp restore --last-run` rebuilds the last pass",
444            G,
445        ),
446    ]
447}
448
449/// Everything read back from this machine rather than asserted.
450fn machine_state(registry: &Registry) -> Vec<TrustRow> {
451    let s = &registry.settings;
452    let (scheduler, hooks) = with_progress(machine_answers);
453    let mut rows = vec![
454        TrustRow::new(
455            "network",
456            "Network requests",
457            if s.update_check {
458                format!(
459                    "Release check against GitHub, every {} days",
460                    s.update_check_interval_days
461                )
462            } else {
463                "None — the release check is off".to_string()
464            },
465            Verdict::Safe,
466        ),
467        TrustRow::new(
468            "auto_update",
469            "Auto-update",
470            // The pin answers this row's question outright, so it answers it here rather
471            // than leaving the screen saying a pass will install a release it will not.
472            if s.version_lock {
473                "Off — `version_lock` pins this copy to the version it is"
474            } else if s.auto_update {
475                "On (the default) — a newer release installs itself after a pass"
476            } else {
477                "Off — updates only when you run `devp update --install`"
478            },
479            // Neutral, not widened, since 1.7.0: `Widened` means someone deliberately
480            // switched something on beyond the defaults, and this is now a default. Still
481            // its own row, because "replaces its own binary" is a fact anyone reading
482            // this screen came here to learn.
483            if s.auto_update && !s.version_lock {
484                Verdict::Neutral
485            } else {
486                Verdict::Safe
487            },
488        ),
489        TrustRow::new(
490            "confirmation",
491            "Confirmation before deleting",
492            if s.require_confirmation {
493                "Required, except where you pass `--yes`"
494            } else {
495                "Off — `require_confirmation` is false"
496            },
497            if s.require_confirmation {
498                Verdict::Safe
499            } else {
500                Verdict::Widened
501            },
502        ),
503        TrustRow::new(
504            "lockfile_rewrite",
505            "Lockfile rewriting",
506            if s.allow_manifest_rewrite {
507                "Allowed — a stale lockfile is regenerated instead of refused"
508            } else {
509                "Refused — verification is read-only"
510            },
511            if s.allow_manifest_rewrite {
512                Verdict::Widened
513            } else {
514                Verdict::Safe
515            },
516        ),
517        TrustRow::new(
518            "scheduler",
519            "Background scheduler",
520            scheduler,
521            Verdict::Neutral,
522        ),
523        TrustRow::new("git_hooks", "Git hooks", hooks, Verdict::Neutral),
524    ];
525
526    // Opt-in adapters delete compiled output, which every other adapter refuses to do.
527    // Someone reading this report to find out what is deletable on their machine needs
528    // to see that they turned that on.
529    let opt_in = opt_in_adapters(registry);
530    rows.push(TrustRow::new(
531        "opt_in_adapters",
532        "Opt-in adapters",
533        if opt_in.is_empty() {
534            "None — only dependency directories are deletable".to_string()
535        } else {
536            format!("{} — build trees are deletable too", opt_in.join(", "))
537        },
538        if opt_in.is_empty() {
539            Verdict::Safe
540        } else {
541            Verdict::Widened
542        },
543    ));
544
545    rows.push(TrustRow::new(
546        "repositories",
547        "Registered repositories",
548        format!(
549            "{} — nothing outside them is ever read or written",
550            registry.repositories.len()
551        ),
552        Verdict::Neutral,
553    ));
554    rows.push(TrustRow::new(
555        "idle_window",
556        "Idle window",
557        format!(
558            "{} days of no commits and no file changes ({} for build trees, before any per-adapter window)",
559            s.idle_days,
560            s.build_idle_days.max(s.idle_days)
561        ),
562        Verdict::Neutral,
563    ));
564    // The managed path, not `current_exe()`: on Windows the scheduler runs `devpw.exe`
565    // from the same directory and `devp update` replaces the managed copy, so the path
566    // that matters to someone asking what runs on this machine is the one dev-prune
567    // owns. The binaries section below hashes them all, this one is running or not.
568    rows.push(TrustRow::new(
569        "binary",
570        "Managed binary",
571        output::clean_path(daemon::get_exe_path()),
572        Verdict::Neutral,
573    ));
574
575    rows
576}
577
578/// Every executable dev-prune owns on this machine, hashed, the running one first.
579///
580/// This exists because of what an antivirus actually looks at. A scanner judges the file
581/// on the disk in front of it, so a checksum published on a release page answers nothing
582/// on its own — the question is whether *this* copy has that digest. Printing the hash
583/// beside the path lets anyone compare the two themselves, and turns "is the thing I
584/// installed the thing you published?" from a matter of trust into one line of `diff`.
585///
586/// Deliberately not [`daemon::get_exe_path`]: that repairs a stale managed copy as a
587/// side effect of being asked where it is, and a read-only report must not write to the
588/// machine it is describing.
589pub(crate) fn binaries() -> Vec<BinaryIdentity> {
590    let mut found: Vec<(std::path::PathBuf, &'static str, Option<&'static str>)> = Vec::new();
591
592    // The managed set, derived from the one path that is a constant rather than a
593    // reading. `devp` is a hard link or a copy of `dev-prune` and `devpw.exe` is a
594    // separate build target, which is exactly the distinction the digests will show.
595    if let Ok(managed) = crate::setup::managed_exe_path() {
596        let dir = managed.parent().map(|d| d.to_path_buf());
597        found.push((managed, "managed", None));
598        if let Some(dir) = dir {
599            let alias = if cfg!(windows) { "devp.exe" } else { "devp" };
600            // Note deliberately left empty here and decided from the digests below. It
601            // used to assert the identity unconditionally, which made this report state
602            // the one thing it is here to check rather than check it.
603            found.push((dir.join(alias), "alias", None));
604            if cfg!(windows) {
605                found.push((
606                    dir.join(constants::WINDOWS_WINDOWLESS_BIN),
607                    "windowless",
608                    Some(
609                        "A different digest by design — the same code linked for \
610                         the GUI subsystem, so the scheduler flashes no console window.",
611                    ),
612                ));
613            }
614        }
615    }
616
617    // Whatever is answering now, which on a `cargo install` machine or inside `npx` is
618    // none of the above. Pushed last and promoted first, so it is never listed twice.
619    let running = std::env::current_exe().ok();
620    if let Some(current) = running.clone() {
621        found.push((current, "other", None));
622    }
623
624    let same = |a: &std::path::Path, b: &std::path::Path| -> bool {
625        // Canonicalised, because on Unix `devp` is a symlink to `dev-prune`: two names
626        // for one inode are one binary, and listing two digests for it would invite
627        // someone to go looking for a difference that cannot exist.
628        match (a.canonicalize(), b.canonicalize()) {
629            (Ok(a), Ok(b)) => a == b,
630            _ => a == b,
631        }
632    };
633
634    let mut rows: Vec<BinaryIdentity> = Vec::new();
635    for (path, role, note) in found {
636        if !path.is_file()
637            || rows
638                .iter()
639                .any(|r| same(std::path::Path::new(&r.path), &path))
640        {
641            continue;
642        }
643        let is_running = running.as_deref().is_some_and(|c| same(c, &path));
644        rows.push(BinaryIdentity {
645            role,
646            name: path
647                .file_name()
648                .map(|n| n.to_string_lossy().into_owned())
649                .unwrap_or_default(),
650            path: output::clean_path(&path),
651            sha256: sha256_of(&path),
652            running: is_running,
653            note,
654        });
655    }
656
657    annotate_alias(&mut rows);
658
659    // The user asked which one they are running; that answer goes at the top, and the
660    // rest are context for it.
661    rows.sort_by_key(|r| !r.running);
662    rows
663}
664
665/// Explain the alias row from the two digests, rather than from what ought to be true.
666///
667/// `devp` is the same bytes as `dev-prune` right up until an upgrade replaces one of them
668/// and not the other — which is the normal state between a `cargo install` and the next
669/// setup pass, since that pass only runs where somebody can see it report. Two different
670/// digests under a caption saying they are the same is the worst outcome available here:
671/// the mismatch a reader came to this report to find, explained away in the one sentence
672/// they will read instead of comparing the hashes themselves.
673///
674/// Says nothing when either file could not be hashed. "Same" and "stale" are both claims,
675/// and a missing digest supports neither.
676fn annotate_alias(rows: &mut [BinaryIdentity]) {
677    let managed = rows
678        .iter()
679        .find(|r| r.role == "managed")
680        .and_then(|r| r.sha256.clone());
681    let Some(alias) = rows.iter_mut().find(|r| r.role == "alias") else {
682        return;
683    };
684    alias.note = match (&managed, &alias.sha256) {
685        (Some(managed), Some(mine)) if managed == mine => Some(
686            "The same bytes as dev-prune under a second name, so a scanner \
687             builds one reputation record instead of two.",
688        ),
689        (Some(_), Some(_)) => Some(
690            "An earlier release under a second name: an upgrade replaced dev-prune \
691             and this has not caught up. The next dev-prune command you run in a \
692             terminal restores the pair.",
693        ),
694        _ => None,
695    };
696}
697
698/// Lower-case hex SHA-256 of a file, or `None` if it cannot be read.
699///
700/// Read whole rather than streamed: these are single-digit-megabyte executables, and the
701/// alternative is a `Write` bound on the digest type that `sha2` may or may not offer in
702/// the next release.
703fn sha256_of(path: &std::path::Path) -> Option<String> {
704    use sha2::{Digest, Sha256};
705    use std::fmt::Write as _;
706
707    let bytes = std::fs::read(path).ok()?;
708    let mut h = Sha256::new();
709    h.update(&bytes);
710    // Hex-encoded by hand: `sha2` 0.11 returns a `hybrid_array::Array`, which has no
711    // `LowerHex`, and every `.sha256` sidecar we publish is lower-case hex.
712    Some(h.finalize().iter().fold(String::new(), |mut acc, b| {
713        let _ = write!(acc, "{b:02x}");
714        acc
715    }))
716}
717
718/// Which opt-in adapters are switched on, in the order the report should name them.
719fn opt_in_adapters(registry: &Registry) -> Vec<&'static str> {
720    let s = &registry.settings;
721    [
722        ("cargo", s.enable_cargo),
723        ("gradle", s.enable_gradle),
724        ("maven", s.enable_maven),
725        ("swift", s.enable_swift),
726        ("dart", s.enable_dart),
727        ("mix_build", s.enable_mix_build),
728        ("vcpkg", s.enable_vcpkg),
729        ("cmake_build", s.enable_cmake_build),
730    ]
731    .into_iter()
732    .filter_map(|(name, on)| on.then_some(name))
733    .collect()
734}
735
736/// Whether anything prunes on its own on this machine.
737fn scheduler_state() -> String {
738    match daemon::daemon_status() {
739        Ok(daemon::DaemonStatus::Installed) => "Installed — prunes on its own".to_string(),
740        Ok(daemon::DaemonStatus::NotInstalled) => {
741            "Not installed — nothing runs unless you run it".to_string()
742        }
743        Ok(daemon::DaemonStatus::Unknown(why)) => format!("Unknown ({why})"),
744        Err(e) => format!("Unknown ({e})"),
745    }
746}
747
748/// Whether Git hooks auto-register repositories on this machine.
749fn hook_state() -> String {
750    if !hook::git_available() {
751        return "Not installed — git is not on PATH".to_string();
752    }
753    match hook::state() {
754        Ok(HookState::Active) => "Installed — new repositories register themselves".to_string(),
755        Ok(HookState::Absent) => {
756            "Not installed — repositories register only when you say so".to_string()
757        }
758        Ok(HookState::Chained { previous, .. }) => {
759            format!("Installed, chained to `{previous}`")
760        }
761        Ok(HookState::Foreign(p)) => format!("Not ours — `core.hooksPath` belongs to `{p}`"),
762        Err(e) => format!("Unknown ({e})"),
763    }
764}
765
766fn print_report(report: &TrustReport) {
767    output::print_header(&format!("What dev-prune {} may do", constants::VERSION));
768
769    println!();
770    println!("  Guaranteed by the code, on every machine");
771    println!();
772    for row in &report.guarantees {
773        print_row(row);
774    }
775
776    println!();
777    println!("  On this machine");
778    println!();
779    for row in &report.machine {
780        print_row(row);
781    }
782
783    println!();
784    let widened = report.widened();
785    if widened.is_empty() {
786        output::print_success(
787            "Nothing on this machine widens what dev-prune may do without asking.",
788        );
789    } else {
790        output::print_info(&format!(
791            "{} {} what dev-prune may do without asking: {}. Each was switched on \
792             deliberately; `devp config show` has them.",
793            widened.len(),
794            if widened.len() == 1 {
795                "setting widens"
796            } else {
797                "settings widen"
798            },
799            widened.join(", ")
800        ));
801    }
802    output::print_info(
803        "The guarantees above are enforced in `src/engine.rs` and described in full at \
804         docs/SAFETY_INVARIANTS.md. None of them has a bypass flag.",
805    );
806
807    print_binaries(&report.binaries);
808}
809
810/// The bottom section: what is actually on this disk, and the digest a scanner reads.
811fn print_binaries(binaries: &[BinaryIdentity]) {
812    if binaries.is_empty() {
813        return;
814    }
815
816    println!();
817    println!("  Binaries on this machine");
818    println!();
819    for b in binaries {
820        let label = if b.running {
821            format!("{} (running)", b.name)
822        } else {
823            b.name.clone()
824        };
825        let mark = if b.running { ">" } else { " " };
826        println!("  {mark}  {label:<30} {}", b.path);
827        match (&b.sha256, b.scan_url()) {
828            (Some(hash), Some(url)) => {
829                println!("     {:<30} {hash}", "SHA-256");
830                println!("     {:<30} {url}", "Scan report");
831            }
832            // A file that is present and unreadable is worth saying out loud. Dropping
833            // the row silently would read as "this one does not have a digest".
834            _ => println!("     {:<30} could not be read", "SHA-256"),
835        }
836        if let Some(note) = b.note {
837            println!("     {note}");
838        }
839        println!();
840    }
841
842    output::print_info(
843        "Those links are lookups by digest, not uploads — dev-prune sends no file \
844         anywhere. A digest the service has never seen comes back `not found`, which \
845         means unscanned rather than clean.",
846    );
847    output::print_info(
848        "A copy installed from a release has the same SHA-256 as the asset published \
849         beside it, so the two can be compared by hand. One built by `cargo install` was \
850         compiled here and matches nothing published.",
851    );
852}
853
854fn print_row(row: &TrustRow) {
855    println!(
856        "  {}  {:<30} {}",
857        row.verdict.mark(),
858        row.subject,
859        row.state
860    );
861}
862
863#[cfg(test)]
864mod tests {
865    use super::*;
866
867    /// Two rows, `managed` and `alias`, with the digests a test wants to compare.
868    fn pair(managed: Option<&str>, alias: Option<&str>) -> Vec<BinaryIdentity> {
869        let row = |role: &'static str, sha: Option<&str>| BinaryIdentity {
870            role,
871            name: String::new(),
872            path: String::new(),
873            sha256: sha.map(str::to_string),
874            running: false,
875            note: None,
876        };
877        vec![row("managed", managed), row("alias", alias)]
878    }
879
880    fn alias_note(rows: &[BinaryIdentity]) -> Option<&'static str> {
881        rows.iter().find(|r| r.role == "alias").unwrap().note
882    }
883
884    #[test]
885    fn the_alias_note_follows_the_digests_and_not_the_expectation() {
886        // The whole point of printing two hashes is that somebody can compare them. A
887        // caption asserting they match, printed above two that do not, is worse than no
888        // caption at all.
889        let mut same = pair(Some("aa"), Some("aa"));
890        annotate_alias(&mut same);
891        assert!(alias_note(&same).is_some_and(|n| n.contains("The same bytes")));
892
893        let mut stale = pair(Some("aa"), Some("bb"));
894        annotate_alias(&mut stale);
895        assert!(alias_note(&stale).is_some_and(|n| n.contains("An earlier release")));
896
897        // Neither claim is supportable without both digests.
898        let mut unreadable = pair(Some("aa"), None);
899        annotate_alias(&mut unreadable);
900        assert!(alias_note(&unreadable).is_none());
901    }
902
903    #[test]
904    fn safe_directory_values_use_the_spelling_git_compares_against() {
905        // A Windows-spelt value is accepted by `git config` and then never matches
906        // anything, because Git normalises the directory it is checking to forward
907        // slashes before comparing. A repair that silently does nothing is the worst
908        // outcome available here.
909        let path = std::path::Path::new("V:\\Code\\Project");
910        assert_eq!(git_path_value(path), "V:/Code/Project");
911    }
912
913    #[test]
914    fn the_default_machine_widens_nothing() {
915        let registry = Registry::default();
916        let report = build(&registry);
917        assert!(
918            report.widened().is_empty(),
919            "a fresh install reports {:?} as widened",
920            report.widened()
921        );
922    }
923
924    #[test]
925    fn every_widening_setting_shows_up_by_name() {
926        let mut registry = Registry::default();
927        registry.settings.require_confirmation = false;
928        registry.settings.allow_manifest_rewrite = true;
929        registry.settings.enable_gradle = true;
930
931        let report = build(&registry);
932        let widened = report.widened();
933        assert_eq!(widened.len(), 3, "got {widened:?}");
934        // Named, not counted: a report that says "3 settings" and stops is a report
935        // nobody can act on.
936        assert!(widened.contains(&"Opt-in adapters"));
937    }
938
939    #[test]
940    fn opt_in_adapters_are_listed_in_a_stable_order() {
941        let mut registry = Registry::default();
942        registry.settings.enable_swift = true;
943        registry.settings.enable_gradle = true;
944        assert_eq!(opt_in_adapters(&registry), vec!["gradle", "swift"]);
945    }
946
947    #[test]
948    fn build_never_hashes_anything() {
949        // The first-run configurator opens on `build`, and hashing every managed
950        // executable there would put a visible pause in front of the first screen. The
951        // command fills this in afterwards; `build` must leave it empty.
952        assert!(build(&Registry::default()).binaries.is_empty());
953    }
954
955    #[test]
956    fn the_running_binary_is_listed_first_and_hashed() {
957        let found = binaries();
958        let running: Vec<&BinaryIdentity> = found.iter().filter(|b| b.running).collect();
959        assert_eq!(running.len(), 1, "expected exactly one running binary");
960        assert!(found[0].running, "the running binary must lead the list");
961
962        let hash = found[0]
963            .sha256
964            .as_deref()
965            .expect("the running file is readable");
966        assert_eq!(hash.len(), 64);
967        assert!(
968            hash.bytes()
969                .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
970        );
971        assert_eq!(
972            found[0].scan_url().unwrap(),
973            format!("{}/{hash}", constants::VIRUSTOTAL_FILE_BASE)
974        );
975    }
976
977    #[test]
978    fn one_file_is_never_listed_twice() {
979        // On Unix `devp` is a symlink to `dev-prune`, and on Windows the two are byte
980        // identical on purpose. Either way they are one binary, and printing two rows
981        // for it would send someone looking for a difference that cannot exist.
982        let found = binaries();
983        let mut paths: Vec<&str> = found.iter().map(|b| b.path.as_str()).collect();
984        let total = paths.len();
985        paths.sort_unstable();
986        paths.dedup();
987        assert_eq!(
988            paths.len(),
989            total,
990            "duplicate path in {found:?}",
991            found = paths
992        );
993    }
994
995    #[test]
996    fn every_row_key_is_unique() {
997        // The keys are the `--json` contract; two rows sharing one silently drops a
998        // fact from the document.
999        let report = build(&Registry::default());
1000        let mut keys: Vec<&str> = report
1001            .guarantees
1002            .iter()
1003            .chain(report.machine.iter())
1004            .map(|r| r.key)
1005            .collect();
1006        let total = keys.len();
1007        keys.sort_unstable();
1008        keys.dedup();
1009        assert_eq!(keys.len(), total);
1010    }
1011
1012    #[test]
1013    fn guarantees_never_depend_on_settings() {
1014        // If a "guarantee" could be turned off it would not be one, and the report would
1015        // be claiming something the code does not enforce.
1016        let mut registry = Registry::default();
1017        registry.settings.allow_manifest_rewrite = true;
1018        registry.settings.auto_update = true;
1019        let with = build(&registry);
1020        let without = build(&Registry::default());
1021
1022        let states = |r: &TrustReport| -> Vec<String> {
1023            r.guarantees.iter().map(|g| g.state.clone()).collect()
1024        };
1025        assert_eq!(states(&with), states(&without));
1026    }
1027}