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    /// The version this copy was built as, read out of its own bytes. `None` for a
117    /// build older than the stamp, which is every release before 1.17.0.
118    pub version: Option<String>,
119    /// `latest release` when this copy matches the newest release dev-prune has been
120    /// told about, `newest here` when no release check has run and this is simply the
121    /// highest version on the machine. `None` otherwise.
122    ///
123    /// Two different claims and two different words, because a machine that has never
124    /// asked GitHub anything cannot honestly call anything "latest".
125    pub marker: Option<&'static str>,
126    /// Whether this is the executable answering right now.
127    pub running: bool,
128    /// Which package manager put this copy here, as a word — `cargo`, `npm`, `uv`,
129    /// `standalone`. The path alone does not answer "why do I have three of these",
130    /// and the answer decides which command removes the one you did not want.
131    pub channel: &'static str,
132    /// Why this one's digest differs from the others, when it does.
133    pub note: Option<&'static str>,
134}
135
136impl BinaryIdentity {
137    /// The scan report for this file's digest, if it could be hashed.
138    pub fn scan_url(&self) -> Option<String> {
139        self.sha256
140            .as_ref()
141            .map(|h| format!("{}/{h}", constants::VIRUSTOTAL_FILE_BASE))
142    }
143}
144
145/// The whole report.
146pub struct TrustReport {
147    /// Structural guarantees. Identical on every machine.
148    pub guarantees: Vec<TrustRow>,
149    /// Live state, read from the registry and the OS.
150    pub machine: Vec<TrustRow>,
151    /// Every executable dev-prune owns here, the one that is running first.
152    ///
153    /// Filled by [`run`] and not by [`build`]: hashing three executables costs more than
154    /// every other row in this report put together, and the first-run configurator opens
155    /// on `build` while the user is waiting at a blank terminal.
156    pub binaries: Vec<BinaryIdentity>,
157}
158
159impl TrustReport {
160    /// Every setting on this machine that widens what dev-prune may do without asking.
161    ///
162    /// Not a score. A list, because "trust level: MEDIUM" tells nobody which switch to
163    /// look at, and the only useful version of this answer is the names.
164    pub fn widened(&self) -> Vec<&str> {
165        self.machine
166            .iter()
167            .filter(|r| r.verdict == Verdict::Widened)
168            .map(|r| r.subject)
169            .collect()
170    }
171}
172
173/// Run the `trust` command.
174pub fn run(json_output: bool) -> Result<()> {
175    let registry = Registry::load()?;
176    let mut report = build(&registry);
177    report.binaries = binaries(registry.latest_known_version.as_deref());
178
179    if json_output {
180        return json::emit(&json::trust_document(&report));
181    }
182
183    print_report(&report);
184    Ok(())
185}
186
187/// Add every registered repository Git refuses to read to its global `safe.directory`.
188///
189/// Git will not read a working tree whose owner on disk is not the account running it,
190/// and on Windows that state is routine and permanent: a reinstall, a restored backup or
191/// a drive carried between machines leaves the old account's identifier on every
192/// directory. `devp run` cannot date such a repository, and a repository whose age is
193/// unknown is one nothing is ever deleted from — so on an affected machine a large part
194/// of the registry silently does nothing until this is resolved.
195///
196/// Git's own suggestion is one `git config` invocation per repository, printed inside a
197/// twelve-line message, once per repository. This is that suggestion, applied to the
198/// repositories dev-prune already knows about, after showing which ones and asking.
199///
200/// It belongs to `trust` rather than to `run` because it widens what Git will open for
201/// every tool on the machine, not just this one. That is exactly the kind of change this
202/// command exists to make visible.
203pub fn fix_ownership(assume_yes: bool) -> Result<()> {
204    let registry = Registry::load()?;
205    let affected = repositories_git_refuses(&registry);
206
207    if affected.is_empty() {
208        output::print_success("Git reads every registered repository. Nothing to fix.");
209        return Ok(());
210    }
211
212    let n = affected.len();
213    output::print_header(&format!(
214        "{n} {} Git will not read",
215        output::plural(n, "repository", "repositories")
216    ));
217    for path in &affected {
218        println!("    {}", output::styled_path(path));
219    }
220    println!();
221    output::print_info(&format!(
222        "This adds {} to git's global `safe.directory` list, which tells Git to open {} despite \
223         the owner recorded on disk. It affects every tool on this machine that uses Git, not only \
224         dev-prune.",
225        output::plural(n, "this path", "these paths"),
226        output::plural(n, "it", "them")
227    ));
228    output::print_info("Undo one with:  git config --global --unset-all safe.directory <path>");
229
230    if !confirm_fix(assume_yes) {
231        return Ok(());
232    }
233
234    // Read the existing list once rather than per repository: `--add` does not
235    // deduplicate, and a machine where this was run twice would accumulate a second copy
236    // of every entry in the user's global config forever.
237    let existing = configured_safe_directories();
238    let mut added = 0usize;
239    for path in &affected {
240        let value = git_path_value(path);
241        if existing.iter().any(|e| e == &value) {
242            continue;
243        }
244        let status = crate::spawn::command("git")
245            .args(["config", "--global", "--add", "safe.directory", &value])
246            .status();
247        match status {
248            Ok(s) if s.success() => added += 1,
249            _ => output::print_warning(&format!("Could not add `{value}` — skipped.")),
250        }
251    }
252
253    output::print_success(&format!(
254        "Added {added} {}. Run `devp run --dry-run` to see what is now examinable.",
255        output::plural(added, "entry", "entries")
256    ));
257    Ok(())
258}
259
260/// Every registered repository whose path exists but which Git refuses on ownership.
261///
262/// Asks Git directly rather than reusing a prune pass: the question is one `rev-parse`
263/// per repository, and a prune pass would also stat every dependency directory on the
264/// machine to answer it.
265fn repositories_git_refuses(registry: &Registry) -> Vec<std::path::PathBuf> {
266    let mut affected: Vec<std::path::PathBuf> = registry
267        .repositories
268        .keys()
269        .filter(|path| path.exists())
270        .filter(|path| {
271            let output = crate::scanner::git::git_in(path)
272                .args(["rev-parse", "--git-dir"])
273                .output();
274            match output {
275                Ok(out) if !out.status.success() => String::from_utf8_lossy(&out.stderr)
276                    .to_lowercase()
277                    .contains(constants::GIT_DUBIOUS_OWNERSHIP),
278                _ => false,
279            }
280        })
281        .cloned()
282        .collect();
283    // The list is shown to a person and then written to their config; a HashMap's order
284    // would put it in a different order every run.
285    affected.sort();
286    affected
287}
288
289/// The values already in the global `safe.directory` list.
290fn configured_safe_directories() -> Vec<String> {
291    let output = crate::spawn::command("git")
292        .args(["config", "--global", "--get-all", "safe.directory"])
293        .output();
294    match output {
295        Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
296            .lines()
297            .map(str::trim)
298            .filter(|l| !l.is_empty())
299            .map(str::to_string)
300            .collect(),
301        // An empty list and an unreadable config lead to the same behaviour — write the
302        // entry — and `--add` on a duplicate is untidy rather than harmful.
303        _ => Vec::new(),
304    }
305}
306
307/// A path in the spelling Git uses for `safe.directory`.
308///
309/// Forward slashes even on Windows: that is the form Git prints in its own refusal
310/// message and the form it compares against, and a backslash spelling is accepted by
311/// `git config` while never matching anything.
312fn git_path_value(path: &std::path::Path) -> String {
313    path.display().to_string().replace('\\', "/")
314}
315
316/// Ask before writing to the user's global Git configuration.
317///
318/// Default no, and a non-terminal gets a no with the flag to pass next time: this widens
319/// what Git will open for every tool on the machine, which is not something a piped
320/// invocation should be able to do by accident.
321fn confirm_fix(yes: bool) -> bool {
322    use std::io::{IsTerminal, Write};
323    if yes {
324        return true;
325    }
326    if !std::io::stdin().is_terminal() {
327        output::print_info("Not running in a terminal — pass `--yes` to write these.");
328        return false;
329    }
330    eprint!("Add them to git's safe.directory list? [y/N]: ");
331    if std::io::stderr().flush().is_err() {
332        return false;
333    }
334    let mut input = String::new();
335    if std::io::stdin().read_line(&mut input).is_err() {
336        return false;
337    }
338    matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
339}
340
341/// Ask the OS its two questions at the same time.
342///
343/// These are the only two rows that shell out — `schtasks` and `git config` — and
344/// together they were most of the second this report took to build. That second was
345/// spent before the configurator drew anything at all, which read as a slow tool rather
346/// than as two processes being waited on one after the other.
347fn machine_answers() -> (String, String) {
348    let scheduler = std::thread::spawn(scheduler_state);
349    let hooks = hook_state();
350    // A panic in the probe must not take the report down with it: the row's whole
351    // purpose is to say what is unknown.
352    let scheduler = scheduler
353        .join()
354        .unwrap_or_else(|_| "Unknown (the check did not finish)".to_string());
355    (scheduler, hooks)
356}
357
358/// Say what is happening while [`machine_answers`] blocks, and erase it afterwards.
359///
360/// Asking Windows for a scheduled task costs a second on its own, and it happens before
361/// the first screen of the configurator can be drawn — so without this the wizard opens
362/// on an empty terminal for long enough to look hung. Stderr, so a piped `--json` run is
363/// unaffected, and only when stderr is a terminal, so a log file never collects it.
364fn with_progress<T>(work: impl FnOnce() -> T) -> T {
365    use std::io::{IsTerminal, Write};
366
367    let mut err = std::io::stderr();
368    let show = err.is_terminal();
369    if show {
370        let _ = write!(err, "{}", constants::READING_MACHINE);
371        let _ = err.flush();
372    }
373    let value = work();
374    if show {
375        // Carriage return and overwrite rather than an erase sequence: this runs before
376        // the alternate screen is entered, on terminals that predate it.
377        let _ = write!(
378            err,
379            "\r{:width$}\r",
380            "",
381            width = constants::READING_MACHINE.chars().count()
382        );
383        let _ = err.flush();
384    }
385    value
386}
387
388/// Assemble the report from the code's own guarantees and this machine's actual state.
389///
390/// `pub(crate)` because the first-run configurator opens on this same report: the
391/// declaration a new user reads and what `devp trust` prints later must be the same
392/// text, or one of them is a marketing claim.
393pub(crate) fn build(registry: &Registry) -> TrustReport {
394    TrustReport {
395        guarantees: guarantees(),
396        machine: machine_state(registry),
397        binaries: Vec::new(),
398    }
399}
400
401/// The seven safety invariants plus the three promises that are not invariants but are
402/// asked about just as often: no telemetry, build outputs are never touched, and neither
403/// is container disk.
404///
405/// Every string here restates something enforced in `src/engine.rs` or `src/adapters/`.
406/// [`docs/SAFETY_INVARIANTS.md`](../../docs/SAFETY_INVARIANTS.md) is the long form.
407fn guarantees() -> Vec<TrustRow> {
408    use Verdict::Guaranteed as G;
409    vec![
410        TrustRow::new(
411            "filesystem_scope",
412            "Filesystem scope",
413            "Registered Git repositories only",
414            G,
415        ),
416        TrustRow::new(
417            "lockfile_verification",
418            "Lockfile verification",
419            "Required before every delete",
420            G,
421        ),
422        TrustRow::new("symlinks", "Symlinks and junctions", "Refused", G),
423        TrustRow::new(
424            "nested_repositories",
425            "Nested repositories",
426            "Refused — no lockfile rebuilds someone else's history",
427            G,
428        ),
429        TrustRow::new(
430            "build_outputs",
431            "Build outputs",
432            "Never deleted — no dist/, no .next/, no .gitignore rules",
433            G,
434        ),
435        TrustRow::new(
436            "container_disk",
437            "Container disk",
438            "Reported, never deleted — `devp caches docker` prints the commands",
439            G,
440        ),
441        TrustRow::new(
442            "deletion_bypass",
443            "Deletion bypass",
444            "None — no flag disables a safety check",
445            G,
446        ),
447        TrustRow::new(
448            "state_writes",
449            "State writes",
450            "Atomic — temp file, then rename",
451            G,
452        ),
453        TrustRow::new("telemetry", "Telemetry", "None — there is no endpoint", G),
454        TrustRow::new(
455            "restore",
456            "Restore",
457            "`devp restore --last-run` rebuilds the last pass",
458            G,
459        ),
460    ]
461}
462
463/// Everything read back from this machine rather than asserted.
464fn machine_state(registry: &Registry) -> Vec<TrustRow> {
465    let s = &registry.settings;
466    let (scheduler, hooks) = with_progress(machine_answers);
467    let mut rows = vec![
468        TrustRow::new(
469            "network",
470            "Network requests",
471            if s.update_check {
472                format!(
473                    "Release check against GitHub, every {} days",
474                    s.update_check_interval_days
475                )
476            } else {
477                "None — the release check is off".to_string()
478            },
479            Verdict::Safe,
480        ),
481        TrustRow::new(
482            "auto_update",
483            "Auto-update",
484            // The pin answers this row's question outright, so it answers it here rather
485            // than leaving the screen saying a pass will install a release it will not.
486            if s.version_lock {
487                "Off — `version_lock` pins this copy to the version it is"
488            } else if s.auto_update {
489                "On (the default) — a newer release installs itself after a pass"
490            } else {
491                "Off — updates only when you run `devp update --install`"
492            },
493            // Neutral, not widened, since 1.7.0: `Widened` means someone deliberately
494            // switched something on beyond the defaults, and this is now a default. Still
495            // its own row, because "replaces its own binary" is a fact anyone reading
496            // this screen came here to learn.
497            if s.auto_update && !s.version_lock {
498                Verdict::Neutral
499            } else {
500                Verdict::Safe
501            },
502        ),
503        TrustRow::new(
504            "confirmation",
505            "Confirmation before deleting",
506            if s.require_confirmation {
507                "Required, except where you pass `--yes`"
508            } else {
509                "Off — `require_confirmation` is false"
510            },
511            if s.require_confirmation {
512                Verdict::Safe
513            } else {
514                Verdict::Widened
515            },
516        ),
517        TrustRow::new(
518            "lockfile_rewrite",
519            "Lockfile rewriting",
520            if s.allow_manifest_rewrite {
521                "Allowed — a stale lockfile is regenerated instead of refused"
522            } else {
523                "Refused — verification is read-only"
524            },
525            if s.allow_manifest_rewrite {
526                Verdict::Widened
527            } else {
528                Verdict::Safe
529            },
530        ),
531        TrustRow::new(
532            "scheduler",
533            "Background scheduler",
534            scheduler,
535            Verdict::Neutral,
536        ),
537        TrustRow::new("git_hooks", "Git hooks", hooks, Verdict::Neutral),
538    ];
539
540    // Opt-in adapters delete compiled output, which every other adapter refuses to do.
541    // Someone reading this report to find out what is deletable on their machine needs
542    // to see that they turned that on.
543    let opt_in = opt_in_adapters(registry);
544    rows.push(TrustRow::new(
545        "opt_in_adapters",
546        "Opt-in adapters",
547        if opt_in.is_empty() {
548            "None — only dependency directories are deletable".to_string()
549        } else {
550            format!("{} — build trees are deletable too", opt_in.join(", "))
551        },
552        if opt_in.is_empty() {
553            Verdict::Safe
554        } else {
555            Verdict::Widened
556        },
557    ));
558
559    rows.push(TrustRow::new(
560        "repositories",
561        "Registered repositories",
562        format!(
563            "{} — nothing outside them is ever read or written",
564            registry.repositories.len()
565        ),
566        Verdict::Neutral,
567    ));
568    rows.push(TrustRow::new(
569        "idle_window",
570        "Idle window",
571        format!(
572            "{} days of no commits and no file changes ({} for build trees, before any per-adapter window)",
573            s.idle_days,
574            s.build_idle_days.max(s.idle_days)
575        ),
576        Verdict::Neutral,
577    ));
578    // The managed path, not `current_exe()`: on Windows the scheduler runs `devpw.exe`
579    // from the same directory and `devp update` replaces the managed copy, so the path
580    // that matters to someone asking what runs on this machine is the one dev-prune
581    // owns. The binaries section below hashes them all, this one is running or not.
582    rows.push(TrustRow::new(
583        "binary",
584        "Managed binary",
585        output::clean_path(daemon::get_exe_path()),
586        Verdict::Neutral,
587    ));
588
589    rows
590}
591
592/// Every executable dev-prune owns on this machine, hashed, the running one first.
593///
594/// This exists because of what an antivirus actually looks at. A scanner judges the file
595/// on the disk in front of it, so a checksum published on a release page answers nothing
596/// on its own — the question is whether *this* copy has that digest. Printing the hash
597/// beside the path lets anyone compare the two themselves, and turns "is the thing I
598/// installed the thing you published?" from a matter of trust into one line of `diff`.
599///
600/// The report used to know about the managed directory and the running file and nothing
601/// else, which meant a machine with `cargo install`ed copies of all three names beside
602/// each other saw exactly one of them listed. That is the wrong count to print under a
603/// heading reading "binaries on this machine", and it hides the case this report is for:
604/// several copies, different ages, and only one of them the one being scanned. The
605/// discovery is [`uninstall::find_stray_copies`] — everything on `PATH` plus the install
606/// directory of every channel, whether or not that channel is still on `PATH`.
607///
608/// Deliberately not [`daemon::get_exe_path`]: that repairs a stale managed copy as a
609/// side effect of being asked where it is, and a read-only report must not write to the
610/// machine it is describing. Nothing here runs any of the files it finds, which is not a
611/// stylistic point on Windows: `devpw.exe` is linked for the GUI subsystem and a shell
612/// that invokes it waits forever.
613pub(crate) fn binaries(latest_known: Option<&str>) -> Vec<BinaryIdentity> {
614    let mut found: Vec<(std::path::PathBuf, &'static str, Option<&'static str>)> = Vec::new();
615    let managed_exe = crate::setup::managed_exe_path().ok();
616
617    // The managed set, derived from the one path that is a constant rather than a
618    // reading. `devp` is a hard link or a copy of `dev-prune` and `devpw.exe` is a
619    // separate build target, which is exactly the distinction the digests will show.
620    if let Ok(managed) = crate::setup::managed_exe_path() {
621        let dir = managed.parent().map(|d| d.to_path_buf());
622        found.push((managed, "managed", None));
623        if let Some(dir) = dir {
624            let alias = if cfg!(windows) { "devp.exe" } else { "devp" };
625            // Note deliberately left empty here and decided from the digests below. It
626            // used to assert the identity unconditionally, which made this report state
627            // the one thing it is here to check rather than check it.
628            found.push((dir.join(alias), "alias", None));
629            if cfg!(windows) {
630                found.push((
631                    dir.join(constants::WINDOWS_WINDOWLESS_BIN),
632                    "windowless",
633                    Some(
634                        "A different digest by design — the same code linked for \
635                         the GUI subsystem, so the scheduler flashes no console window.",
636                    ),
637                ));
638            }
639        }
640    }
641
642    // Whatever is answering now, which on a `cargo install` machine or inside `npx` is
643    // none of the above. Pushed last and promoted first, so it is never listed twice.
644    let running = std::env::current_exe().ok();
645    if let Some(current) = running.clone() {
646        found.push((current, "other", None));
647    }
648
649    // Every other copy on this machine. Shims are skipped: npm's `.cmd` and `.ps1`
650    // wrappers are a few lines of text that run the real executable, and a SHA-256 of
651    // one compares against nothing published, so a row for it would be a digest that
652    // looks like an answer and is not.
653    for stray in crate::commands::uninstall::find_stray_copies() {
654        if is_executable_image(&stray.path) {
655            found.push((stray.path, "other", None));
656        }
657    }
658
659    let same = |a: &std::path::Path, b: &std::path::Path| -> bool {
660        // Canonicalised, because on Unix `devp` is a symlink to `dev-prune`: two names
661        // for one inode are one binary, and listing two digests for it would invite
662        // someone to go looking for a difference that cannot exist.
663        match (a.canonicalize(), b.canonicalize()) {
664            (Ok(a), Ok(b)) => a == b,
665            _ => a == b,
666        }
667    };
668
669    let mut rows: Vec<BinaryIdentity> = Vec::new();
670    for (path, role, note) in found {
671        if !path.is_file()
672            || rows
673                .iter()
674                .any(|r| same(std::path::Path::new(&r.path), &path))
675        {
676            continue;
677        }
678        let is_running = running.as_deref().is_some_and(|c| same(c, &path));
679        let (sha256, version) = read_identity(&path);
680        rows.push(BinaryIdentity {
681            role,
682            name: path
683                .file_name()
684                .map(|n| n.to_string_lossy().into_owned())
685                .unwrap_or_default(),
686            channel: crate::channel::Channel::detect_at(&path, managed_exe.as_deref()).badge(),
687            path: output::clean_path(&path),
688            sha256,
689            version,
690            marker: None,
691            running: is_running,
692            note,
693        });
694    }
695
696    annotate_alias(&mut rows);
697    mark_newest(&mut rows, latest_known);
698
699    // The user asked which one they are running; that answer goes at the top, and the
700    // rest are context for it.
701    rows.sort_by_key(|r| !r.running);
702    rows
703}
704
705/// Whether this file is a machine image rather than a wrapper around one.
706///
707/// The sweep that finds these deliberately casts wider — an uninstall has to delete the
708/// `.cmd` shim and the `.exe.old` an interrupted update left behind, or it leaves a
709/// working install under a name nobody checks. A trust report wants the opposite: the
710/// files an antivirus actually forms an opinion about.
711fn is_executable_image(path: &std::path::Path) -> bool {
712    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
713        return false;
714    };
715    let stems = ["dev-prune", "devp", "devpw"];
716    if cfg!(windows) {
717        stems
718            .iter()
719            .any(|s| name.eq_ignore_ascii_case(&format!("{s}.exe")))
720    } else {
721        stems.contains(&name)
722    }
723}
724
725/// Explain the alias row from the two digests, rather than from what ought to be true.
726///
727/// `devp` is the same bytes as `dev-prune` right up until an upgrade replaces one of them
728/// and not the other — which is the normal state between a `cargo install` and the next
729/// setup pass, since that pass only runs where somebody can see it report. Two different
730/// digests under a caption saying they are the same is the worst outcome available here:
731/// the mismatch a reader came to this report to find, explained away in the one sentence
732/// they will read instead of comparing the hashes themselves.
733///
734/// Says nothing when either file could not be hashed. "Same" and "stale" are both claims,
735/// and a missing digest supports neither.
736fn annotate_alias(rows: &mut [BinaryIdentity]) {
737    let managed = rows
738        .iter()
739        .find(|r| r.role == "managed")
740        .and_then(|r| r.sha256.clone());
741    let Some(alias) = rows.iter_mut().find(|r| r.role == "alias") else {
742        return;
743    };
744    alias.note = match (&managed, &alias.sha256) {
745        (Some(managed), Some(mine)) if managed == mine => Some(
746            "The same bytes as dev-prune under a second name, so a scanner \
747             builds one reputation record instead of two.",
748        ),
749        (Some(_), Some(_)) => Some(
750            "An earlier release under a second name: an upgrade replaced dev-prune \
751             and this has not caught up. The next dev-prune command you run in a \
752             terminal restores the pair.",
753        ),
754        _ => None,
755    };
756}
757
758/// Say which release put a copy there, and which of them is the newest.
759///
760/// `latest_known` is whatever the last update check recorded, which is read from the
761/// registry the report has already loaded — this asks the network for nothing. When it
762/// is absent the highest version found is still worth pointing at, but it is a claim
763/// about this machine and not about the project, and the two are labelled differently
764/// for that reason.
765fn mark_newest(rows: &mut [BinaryIdentity], latest_known: Option<&str>) {
766    if let Some(latest) = latest_known {
767        let mut any = false;
768        for r in rows.iter_mut() {
769            if r.version.as_deref().is_some_and(|v| v == latest) {
770                r.marker = Some("latest release");
771                any = true;
772            }
773        }
774        if any {
775            return;
776        }
777    }
778
779    // Nothing here is the latest release — either none matched, or no check has run.
780    // Point at the highest anyway, since the reason to read this list at all is usually
781    // "which of these three is the one I want to keep".
782    let Some(top) = rows
783        .iter()
784        .filter_map(|r| r.version.as_deref())
785        .max_by(|a, b| {
786            crate::commands::update::compare_versions(a, b).unwrap_or(std::cmp::Ordering::Equal)
787        })
788        .map(str::to_owned)
789    else {
790        return;
791    };
792    // A single stamped copy is not "the newest" of anything worth saying out loud.
793    if rows.iter().filter(|r| r.version.is_some()).count() < 2 {
794        return;
795    }
796    for r in rows.iter_mut() {
797        if r.version.as_deref() == Some(top.as_str()) {
798            r.marker = Some("newest here");
799        }
800    }
801}
802
803/// The version a build stamped into itself, from anywhere those bytes can be had.
804///
805/// Every hit on the mark is validated rather than the first one trusted, because each
806/// binary contains the mark twice: once in the stamp, and once as the search literal
807/// this function compares against. Only one of the two is followed by a version.
808pub(crate) fn version_from_stamp(haystack: &[u8]) -> Option<String> {
809    let mark = constants::VERSION_STAMP_MARK.as_bytes();
810    // Long enough for `999.999.999-rc.99`, short enough that a stray mark in the middle
811    // of a megabyte of code cannot drag arbitrary bytes into the report.
812    const MAX: usize = 32;
813
814    let mut from = 0;
815    while let Some(hit) = haystack[from..]
816        .windows(mark.len())
817        .position(|w| w == mark)
818        .map(|i| from + i)
819    {
820        from = hit + mark.len();
821        let tail = &haystack[from..haystack.len().min(from + MAX)];
822        if let Some(end) = tail.iter().position(|&b| b == b'/')
823            && let Ok(v) = std::str::from_utf8(&tail[..end])
824            // `compare_versions` returns `None` for anything that is not
825            // `major.minor.patch`, which is exactly the check wanted here: the other hit
826            // is followed by whatever the linker laid down next, and that never parses.
827            && crate::commands::update::compare_versions(v, v).is_some()
828        {
829            return Some(v.to_string());
830        }
831    }
832    None
833}
834
835/// The digest and the version of one file, from a single read of it.
836///
837/// One read rather than two: these are single-digit-megabyte executables and there can
838/// be four of them, and hashing and stamp-scanning the same bytes twice would double the
839/// slowest part of this report for nothing.
840fn read_identity(path: &std::path::Path) -> (Option<String>, Option<String>) {
841    let Ok(bytes) = std::fs::read(path) else {
842        return (None, None);
843    };
844    (Some(sha256_of_bytes(&bytes)), version_from_stamp(&bytes))
845}
846
847/// Lower-case hex SHA-256 of bytes already in hand.
848fn sha256_of_bytes(bytes: &[u8]) -> String {
849    use sha2::{Digest, Sha256};
850    use std::fmt::Write as _;
851
852    let mut h = Sha256::new();
853    h.update(bytes);
854    // Hex-encoded by hand: `sha2` 0.11 returns a `hybrid_array::Array`, which has no
855    // `LowerHex`, and every `.sha256` sidecar we publish is lower-case hex.
856    h.finalize().iter().fold(String::new(), |mut acc, b| {
857        let _ = write!(acc, "{b:02x}");
858        acc
859    })
860}
861
862/// Which opt-in adapters are switched on, in the order the report should name them.
863fn opt_in_adapters(registry: &Registry) -> Vec<&'static str> {
864    let s = &registry.settings;
865    [
866        ("cargo", s.enable_cargo),
867        ("gradle", s.enable_gradle),
868        ("maven", s.enable_maven),
869        ("swift", s.enable_swift),
870        ("dart", s.enable_dart),
871        ("mix_build", s.enable_mix_build),
872        ("vcpkg", s.enable_vcpkg),
873        ("cmake_build", s.enable_cmake_build),
874        ("dotnet_build", s.enable_dotnet_build),
875        ("godot", s.enable_godot),
876        ("unity", s.enable_unity),
877        ("unreal", s.enable_unreal),
878        ("defold", s.enable_defold),
879        ("cocos", s.enable_cocos),
880        ("zig", s.enable_zig),
881        ("stack", s.enable_stack),
882        ("cabal", s.enable_cabal),
883        ("sbt", s.enable_sbt),
884    ]
885    .into_iter()
886    .filter_map(|(name, on)| on.then_some(name))
887    .collect()
888}
889
890/// Whether anything prunes on its own on this machine.
891fn scheduler_state() -> String {
892    match daemon::daemon_status() {
893        Ok(daemon::DaemonStatus::Installed) => "Installed — prunes on its own".to_string(),
894        Ok(daemon::DaemonStatus::NotInstalled) => {
895            "Not installed — nothing runs unless you run it".to_string()
896        }
897        Ok(daemon::DaemonStatus::Unknown(why)) => format!("Unknown ({why})"),
898        Err(e) => format!("Unknown ({e})"),
899    }
900}
901
902/// Whether Git hooks auto-register repositories on this machine.
903fn hook_state() -> String {
904    if !hook::git_available() {
905        return "Not installed — git is not on PATH".to_string();
906    }
907    match hook::state() {
908        Ok(HookState::Active) => "Installed — new repositories register themselves".to_string(),
909        Ok(HookState::Absent) => {
910            "Not installed — repositories register only when you say so".to_string()
911        }
912        Ok(HookState::Chained { previous, .. }) => {
913            format!("Installed, chained to `{previous}`")
914        }
915        Ok(HookState::Foreign(p)) => format!("Not ours — `core.hooksPath` belongs to `{p}`"),
916        Err(e) => format!("Unknown ({e})"),
917    }
918}
919
920fn print_report(report: &TrustReport) {
921    output::print_header(&format!("What dev-prune {} may do", constants::VERSION));
922
923    println!();
924    println!("  Guaranteed by the code, on every machine");
925    println!();
926    for row in &report.guarantees {
927        print_row(row);
928    }
929
930    println!();
931    println!("  On this machine");
932    println!();
933    for row in &report.machine {
934        print_row(row);
935    }
936
937    println!();
938    let widened = report.widened();
939    if widened.is_empty() {
940        output::print_success(
941            "Nothing on this machine widens what dev-prune may do without asking.",
942        );
943    } else {
944        output::print_info(&format!(
945            "{} {} what dev-prune may do without asking: {}. Each was switched on \
946             deliberately; `devp config show` has them.",
947            widened.len(),
948            if widened.len() == 1 {
949                "setting widens"
950            } else {
951                "settings widen"
952            },
953            widened.join(", ")
954        ));
955    }
956    output::print_info(
957        "The guarantees above are enforced in `src/engine.rs` and described in full at \
958         docs/SAFETY_INVARIANTS.md. None of them has a bypass flag.",
959    );
960
961    print_binaries(&report.binaries);
962}
963
964/// The bottom section: what is actually on this disk, and the digest a scanner reads.
965fn print_binaries(binaries: &[BinaryIdentity]) {
966    if binaries.is_empty() {
967        return;
968    }
969
970    println!();
971    println!("  Binaries on this machine");
972    println!();
973    for b in binaries {
974        // Version first in the label, because the question this list is usually opened
975        // with is "which of these am I looking at" and the file names do not answer it —
976        // three of them are called some spelling of the same word.
977        let mut label = match &b.version {
978            Some(v) => format!("{} v{v}", b.name),
979            None => b.name.clone(),
980        };
981        if let Some(marker) = b.marker {
982            label.push_str(&format!(" ({marker})"));
983        }
984        if b.running {
985            label.push_str(" (running)");
986        }
987        let mark = if b.running { ">" } else { " " };
988        println!("  {mark}  {label:<44} {}", b.path);
989        println!("     {:<30} {}", "Installed with", b.channel);
990        if b.version.is_none() {
991            // Said rather than left blank: a missing version beside three that have one
992            // reads as a failure to look, and it is not — nothing before 1.17.0 carries
993            // a version anywhere a report could read it without running the file.
994            println!("     {:<30} not stamped — built before 1.17.0", "Version");
995        }
996        match (&b.sha256, b.scan_url()) {
997            (Some(hash), Some(url)) => {
998                println!("     {:<30} {hash}", "SHA-256");
999                println!("     {:<30} {url}", "Scan report");
1000            }
1001            // A file that is present and unreadable is worth saying out loud. Dropping
1002            // the row silently would read as "this one does not have a digest".
1003            _ => println!("     {:<30} could not be read", "SHA-256"),
1004        }
1005        if let Some(note) = b.note {
1006            println!("     {note}");
1007        }
1008        println!();
1009    }
1010
1011    output::print_info(
1012        "Those links are lookups by digest, not uploads — dev-prune sends no file \
1013         anywhere. A digest the service has never seen comes back `not found`, which \
1014         means unscanned rather than clean.",
1015    );
1016    output::print_info(
1017        "A copy installed from a release has the same SHA-256 as the asset published \
1018         beside it, so the two can be compared by hand. One built by `cargo install` was \
1019         compiled here and matches nothing published.",
1020    );
1021    if binaries.len() > 1 {
1022        output::print_info(
1023            "More than one copy is not a fault — a `cargo install` and an installer run \
1024             each leave one, and they update separately. `devp uninstall` lists them with \
1025             the command that removes each.",
1026        );
1027    }
1028}
1029
1030fn print_row(row: &TrustRow) {
1031    println!(
1032        "  {}  {:<30} {}",
1033        row.verdict.mark(),
1034        row.subject,
1035        row.state
1036    );
1037}
1038
1039#[cfg(test)]
1040mod tests {
1041    use super::*;
1042
1043    /// Two rows, `managed` and `alias`, with the digests a test wants to compare.
1044    fn pair(managed: Option<&str>, alias: Option<&str>) -> Vec<BinaryIdentity> {
1045        let row = |role: &'static str, sha: Option<&str>| BinaryIdentity {
1046            role,
1047            name: String::new(),
1048            path: String::new(),
1049            channel: "standalone",
1050            sha256: sha.map(str::to_string),
1051            version: None,
1052            marker: None,
1053            running: false,
1054            note: None,
1055        };
1056        vec![row("managed", managed), row("alias", alias)]
1057    }
1058
1059    fn alias_note(rows: &[BinaryIdentity]) -> Option<&'static str> {
1060        rows.iter().find(|r| r.role == "alias").unwrap().note
1061    }
1062
1063    #[test]
1064    fn the_alias_note_follows_the_digests_and_not_the_expectation() {
1065        // The whole point of printing two hashes is that somebody can compare them. A
1066        // caption asserting they match, printed above two that do not, is worse than no
1067        // caption at all.
1068        let mut same = pair(Some("aa"), Some("aa"));
1069        annotate_alias(&mut same);
1070        assert!(alias_note(&same).is_some_and(|n| n.contains("The same bytes")));
1071
1072        let mut stale = pair(Some("aa"), Some("bb"));
1073        annotate_alias(&mut stale);
1074        assert!(alias_note(&stale).is_some_and(|n| n.contains("An earlier release")));
1075
1076        // Neither claim is supportable without both digests.
1077        let mut unreadable = pair(Some("aa"), None);
1078        annotate_alias(&mut unreadable);
1079        assert!(alias_note(&unreadable).is_none());
1080    }
1081
1082    #[test]
1083    fn safe_directory_values_use_the_spelling_git_compares_against() {
1084        // A Windows-spelt value is accepted by `git config` and then never matches
1085        // anything, because Git normalises the directory it is checking to forward
1086        // slashes before comparing. A repair that silently does nothing is the worst
1087        // outcome available here.
1088        let path = std::path::Path::new("V:\\Code\\Project");
1089        assert_eq!(git_path_value(path), "V:/Code/Project");
1090    }
1091
1092    #[test]
1093    fn the_default_machine_widens_nothing() {
1094        let registry = Registry::default();
1095        let report = build(&registry);
1096        assert!(
1097            report.widened().is_empty(),
1098            "a fresh install reports {:?} as widened",
1099            report.widened()
1100        );
1101    }
1102
1103    #[test]
1104    fn every_widening_setting_shows_up_by_name() {
1105        let mut registry = Registry::default();
1106        registry.settings.require_confirmation = false;
1107        registry.settings.allow_manifest_rewrite = true;
1108        registry.settings.enable_gradle = true;
1109
1110        let report = build(&registry);
1111        let widened = report.widened();
1112        assert_eq!(widened.len(), 3, "got {widened:?}");
1113        // Named, not counted: a report that says "3 settings" and stops is a report
1114        // nobody can act on.
1115        assert!(widened.contains(&"Opt-in adapters"));
1116    }
1117
1118    #[test]
1119    fn opt_in_adapters_are_listed_in_a_stable_order() {
1120        let mut registry = Registry::default();
1121        registry.settings.enable_swift = true;
1122        registry.settings.enable_gradle = true;
1123        assert_eq!(opt_in_adapters(&registry), vec!["gradle", "swift"]);
1124    }
1125
1126    #[test]
1127    fn build_never_hashes_anything() {
1128        // The first-run configurator opens on `build`, and hashing every managed
1129        // executable there would put a visible pause in front of the first screen. The
1130        // command fills this in afterwards; `build` must leave it empty.
1131        assert!(build(&Registry::default()).binaries.is_empty());
1132    }
1133
1134    #[test]
1135    fn the_running_binary_is_listed_first_and_hashed() {
1136        let found = binaries(None);
1137        let running: Vec<&BinaryIdentity> = found.iter().filter(|b| b.running).collect();
1138        assert_eq!(running.len(), 1, "expected exactly one running binary");
1139        assert!(found[0].running, "the running binary must lead the list");
1140
1141        let hash = found[0]
1142            .sha256
1143            .as_deref()
1144            .expect("the running file is readable");
1145        assert_eq!(hash.len(), 64);
1146        assert!(
1147            hash.bytes()
1148                .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
1149        );
1150        assert_eq!(
1151            found[0].scan_url().unwrap(),
1152            format!("{}/{hash}", constants::VIRUSTOTAL_FILE_BASE)
1153        );
1154    }
1155
1156    #[test]
1157    fn every_executable_found_on_path_is_listed() {
1158        // The report used to know only about the managed directory and the running
1159        // file, so a machine carrying `dev-prune`, `devp` and `devpw` in `~/.cargo/bin`
1160        // saw one row and had no way to learn about the other two. Whatever the sweep
1161        // can find, this must print.
1162        let listed = binaries(None);
1163        let canon = |p: &std::path::Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
1164        let shown: Vec<std::path::PathBuf> = listed
1165            .iter()
1166            .map(|b| canon(std::path::Path::new(&b.path)))
1167            .collect();
1168        for stray in crate::commands::uninstall::find_stray_copies() {
1169            if !is_executable_image(&stray.path) {
1170                continue;
1171            }
1172            assert!(
1173                shown.contains(&canon(&stray.path)),
1174                "{} is on this machine but missing from the report",
1175                stray.path.display()
1176            );
1177        }
1178    }
1179
1180    /// A row with a version and nothing else a marker test cares about.
1181    fn stamped(version: Option<&str>) -> BinaryIdentity {
1182        BinaryIdentity {
1183            role: "other",
1184            name: String::new(),
1185            path: String::new(),
1186            channel: "standalone",
1187            sha256: None,
1188            version: version.map(str::to_string),
1189            marker: None,
1190            running: false,
1191            note: None,
1192        }
1193    }
1194
1195    #[test]
1196    fn the_stamp_survives_the_linker() {
1197        // The one failure mode this whole mechanism has. `#[used]` and an exported
1198        // symbol should keep the stamp in the file, but neither is a guarantee across
1199        // MSVC, ld and ld64, and a linker that dropped it would not fail a build — it
1200        // would just make every version in the report read "not stamped". This test
1201        // reads the executable it is running from, which carries the stamp for the same
1202        // reason a release binary does.
1203        let exe = std::env::current_exe().expect("a test knows its own path");
1204        let bytes = std::fs::read(&exe).expect("a test can read its own executable");
1205        assert_eq!(
1206            version_from_stamp(&bytes).as_deref(),
1207            Some(constants::VERSION),
1208            "no version stamp in {} — the linker dropped it",
1209            exe.display()
1210        );
1211    }
1212
1213    #[test]
1214    fn the_search_literal_is_not_mistaken_for_a_stamp() {
1215        // Every binary contains the mark twice: once in its stamp, and once as the
1216        // string this scan compares against. The bare mark is followed by whatever the
1217        // linker laid down next, so the scan has to validate each hit rather than
1218        // return the first.
1219        let mut haystack = constants::VERSION_STAMP_MARK.as_bytes().to_vec();
1220        haystack.extend_from_slice(b"not-a-version/");
1221        haystack.extend_from_slice(constants::VERSION_STAMP.as_bytes());
1222        assert_eq!(
1223            version_from_stamp(&haystack).as_deref(),
1224            Some(constants::VERSION)
1225        );
1226
1227        // And a mark with nothing usable after it is not a version.
1228        let mut orphan = constants::VERSION_STAMP_MARK.as_bytes().to_vec();
1229        orphan.extend_from_slice(b"1.2/");
1230        assert!(version_from_stamp(&orphan).is_none());
1231        assert!(version_from_stamp(b"no mark here at all").is_none());
1232    }
1233
1234    #[test]
1235    fn latest_is_only_claimed_when_a_release_check_has_answered() {
1236        // "latest" is a claim about the project and "newest here" is a claim about the
1237        // disk. A machine that has never asked GitHub anything can only make the second
1238        // one, and printing the first would be a guess dressed as a fact.
1239        let mut rows = vec![stamped(Some("1.16.0")), stamped(Some("1.17.0"))];
1240        mark_newest(&mut rows, None);
1241        assert_eq!(rows[0].marker, None);
1242        assert_eq!(rows[1].marker, Some("newest here"));
1243
1244        let mut rows = vec![stamped(Some("1.16.0")), stamped(Some("1.17.0"))];
1245        mark_newest(&mut rows, Some("1.17.0"));
1246        assert_eq!(rows[1].marker, Some("latest release"));
1247
1248        // A newer release exists than anything installed: nothing is "latest", and the
1249        // highest copy on the machine is still worth pointing at.
1250        let mut rows = vec![stamped(Some("1.16.0")), stamped(Some("1.17.0"))];
1251        mark_newest(&mut rows, Some("2.0.0"));
1252        assert_eq!(rows[1].marker, Some("newest here"));
1253
1254        // One stamped copy is not the newest of anything worth saying.
1255        let mut rows = vec![stamped(Some("1.17.0")), stamped(None)];
1256        mark_newest(&mut rows, None);
1257        assert_eq!(rows[0].marker, None);
1258    }
1259
1260    #[test]
1261    fn versions_order_by_component_and_not_by_string() {
1262        // "1.9.0" sorts after "1.10.0" as a string, which would put the `(newest here)`
1263        // marker on the older copy.
1264        let mut rows = vec![stamped(Some("1.9.0")), stamped(Some("1.10.0"))];
1265        mark_newest(&mut rows, None);
1266        assert_eq!(rows[1].marker, Some("newest here"));
1267    }
1268
1269    #[test]
1270    fn shims_are_not_hashed() {
1271        // A `.cmd` shim is a text file that runs the real one. Hashing it would print a
1272        // digest that compares against nothing we publish.
1273        assert!(!is_executable_image(std::path::Path::new("devp.cmd")));
1274        assert!(!is_executable_image(std::path::Path::new("devp.ps1")));
1275        assert!(!is_executable_image(std::path::Path::new(
1276            "dev-prune.exe.old"
1277        )));
1278        let real = if cfg!(windows) { "devpw.exe" } else { "devpw" };
1279        assert!(is_executable_image(std::path::Path::new(real)));
1280    }
1281
1282    #[test]
1283    fn one_file_is_never_listed_twice() {
1284        // On Unix `devp` is a symlink to `dev-prune`, and on Windows the two are byte
1285        // identical on purpose. Either way they are one binary, and printing two rows
1286        // for it would send someone looking for a difference that cannot exist.
1287        let found = binaries(None);
1288        let mut paths: Vec<&str> = found.iter().map(|b| b.path.as_str()).collect();
1289        let total = paths.len();
1290        paths.sort_unstable();
1291        paths.dedup();
1292        assert_eq!(
1293            paths.len(),
1294            total,
1295            "duplicate path in {found:?}",
1296            found = paths
1297        );
1298    }
1299
1300    #[test]
1301    fn every_row_key_is_unique() {
1302        // The keys are the `--json` contract; two rows sharing one silently drops a
1303        // fact from the document.
1304        let report = build(&Registry::default());
1305        let mut keys: Vec<&str> = report
1306            .guarantees
1307            .iter()
1308            .chain(report.machine.iter())
1309            .map(|r| r.key)
1310            .collect();
1311        let total = keys.len();
1312        keys.sort_unstable();
1313        keys.dedup();
1314        assert_eq!(keys.len(), total);
1315    }
1316
1317    #[test]
1318    fn guarantees_never_depend_on_settings() {
1319        // If a "guarantee" could be turned off it would not be one, and the report would
1320        // be claiming something the code does not enforce.
1321        let mut registry = Registry::default();
1322        registry.settings.allow_manifest_rewrite = true;
1323        registry.settings.auto_update = true;
1324        let with = build(&registry);
1325        let without = build(&Registry::default());
1326
1327        let states = |r: &TrustReport| -> Vec<String> {
1328            r.guarantees.iter().map(|g| g.state.clone()).collect()
1329        };
1330        assert_eq!(states(&with), states(&without));
1331    }
1332}