use anyhow::Result;
use crate::commands::hook::{self, HookState};
use crate::config::Registry;
use crate::constants;
use crate::daemon;
use crate::json;
use crate::output;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Guaranteed,
Safe,
Widened,
Neutral,
}
impl Verdict {
fn mark(self) -> &'static str {
match self {
Verdict::Guaranteed | Verdict::Safe => "+",
Verdict::Widened => "!",
Verdict::Neutral => " ",
}
}
fn key(self) -> &'static str {
match self {
Verdict::Guaranteed => "guaranteed",
Verdict::Safe => "safe",
Verdict::Widened => "widened",
Verdict::Neutral => "neutral",
}
}
}
pub struct TrustRow {
pub key: &'static str,
pub subject: &'static str,
pub state: String,
pub verdict: Verdict,
}
impl TrustRow {
fn new(
key: &'static str,
subject: &'static str,
state: impl Into<String>,
verdict: Verdict,
) -> Self {
Self {
key,
subject,
state: state.into(),
verdict,
}
}
pub fn verdict_key(&self) -> &'static str {
self.verdict.key()
}
}
pub struct TrustReport {
pub guarantees: Vec<TrustRow>,
pub machine: Vec<TrustRow>,
}
impl TrustReport {
pub fn widened(&self) -> Vec<&str> {
self.machine
.iter()
.filter(|r| r.verdict == Verdict::Widened)
.map(|r| r.subject)
.collect()
}
}
pub fn run(json_output: bool) -> Result<()> {
let registry = Registry::load()?;
let report = build(®istry);
if json_output {
return json::emit(&json::trust_document(&report));
}
print_report(&report);
Ok(())
}
pub fn fix_ownership(assume_yes: bool) -> Result<()> {
let registry = Registry::load()?;
let affected = repositories_git_refuses(®istry);
if affected.is_empty() {
output::print_success("Git reads every registered repository. Nothing to fix.");
return Ok(());
}
let n = affected.len();
output::print_header(&format!(
"{n} {} Git will not read",
output::plural(n, "repository", "repositories")
));
for path in &affected {
println!(" {}", output::styled_path(path));
}
println!();
output::print_info(&format!(
"This adds {} to git's global `safe.directory` list, which tells Git to open {} despite the owner recorded on disk. It affects every tool on this machine that uses Git, not only dev-prune.",
output::plural(n, "this path", "these paths"),
output::plural(n, "it", "them")
));
output::print_info("Undo one with: git config --global --unset-all safe.directory <path>");
if !confirm_fix(assume_yes) {
return Ok(());
}
let existing = configured_safe_directories();
let mut added = 0usize;
for path in &affected {
let value = git_path_value(path);
if existing.iter().any(|e| e == &value) {
continue;
}
let status = crate::spawn::command("git")
.args(["config", "--global", "--add", "safe.directory", &value])
.status();
match status {
Ok(s) if s.success() => added += 1,
_ => output::print_warning(&format!("Could not add `{value}` — skipped.")),
}
}
output::print_success(&format!(
"Added {added} {}. Run `devp run --dry-run` to see what is now examinable.",
output::plural(added, "entry", "entries")
));
Ok(())
}
fn repositories_git_refuses(registry: &Registry) -> Vec<std::path::PathBuf> {
let mut affected: Vec<std::path::PathBuf> = registry
.repositories
.keys()
.filter(|path| path.exists())
.filter(|path| {
let output = crate::scanner::git::git_in(path)
.args(["rev-parse", "--git-dir"])
.output();
match output {
Ok(out) if !out.status.success() => String::from_utf8_lossy(&out.stderr)
.to_lowercase()
.contains(constants::GIT_DUBIOUS_OWNERSHIP),
_ => false,
}
})
.cloned()
.collect();
affected.sort();
affected
}
fn configured_safe_directories() -> Vec<String> {
let output = crate::spawn::command("git")
.args(["config", "--global", "--get-all", "safe.directory"])
.output();
match output {
Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect(),
_ => Vec::new(),
}
}
fn git_path_value(path: &std::path::Path) -> String {
path.display().to_string().replace('\\', "/")
}
fn confirm_fix(yes: bool) -> bool {
use std::io::{IsTerminal, Write};
if yes {
return true;
}
if !std::io::stdin().is_terminal() {
output::print_info("Not running in a terminal — pass `--yes` to write these.");
return false;
}
eprint!("Add them to git's safe.directory list? [y/N]: ");
if std::io::stderr().flush().is_err() {
return false;
}
let mut input = String::new();
if std::io::stdin().read_line(&mut input).is_err() {
return false;
}
matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
}
fn machine_answers() -> (String, String) {
let scheduler = std::thread::spawn(scheduler_state);
let hooks = hook_state();
let scheduler = scheduler
.join()
.unwrap_or_else(|_| "Unknown (the check did not finish)".to_string());
(scheduler, hooks)
}
fn with_progress<T>(work: impl FnOnce() -> T) -> T {
use std::io::{IsTerminal, Write};
let mut err = std::io::stderr();
let show = err.is_terminal();
if show {
let _ = write!(err, "{}", constants::READING_MACHINE);
let _ = err.flush();
}
let value = work();
if show {
let _ = write!(
err,
"\r{:width$}\r",
"",
width = constants::READING_MACHINE.chars().count()
);
let _ = err.flush();
}
value
}
pub(crate) fn build(registry: &Registry) -> TrustReport {
TrustReport {
guarantees: guarantees(),
machine: machine_state(registry),
}
}
fn guarantees() -> Vec<TrustRow> {
use Verdict::Guaranteed as G;
vec![
TrustRow::new(
"filesystem_scope",
"Filesystem scope",
"Registered Git repositories only",
G,
),
TrustRow::new(
"lockfile_verification",
"Lockfile verification",
"Required before every delete",
G,
),
TrustRow::new("symlinks", "Symlinks and junctions", "Refused", G),
TrustRow::new(
"nested_repositories",
"Nested repositories",
"Refused — no lockfile rebuilds someone else's history",
G,
),
TrustRow::new(
"build_outputs",
"Build outputs",
"Never deleted — no dist/, no .next/, no .gitignore rules",
G,
),
TrustRow::new(
"deletion_bypass",
"Deletion bypass",
"None — no flag disables a safety check",
G,
),
TrustRow::new(
"state_writes",
"State writes",
"Atomic — temp file, then rename",
G,
),
TrustRow::new("telemetry", "Telemetry", "None — there is no endpoint", G),
TrustRow::new(
"restore",
"Restore",
"`devp restore --last-run` rebuilds the last pass",
G,
),
]
}
fn machine_state(registry: &Registry) -> Vec<TrustRow> {
let s = ®istry.settings;
let (scheduler, hooks) = with_progress(machine_answers);
let mut rows = vec![
TrustRow::new(
"network",
"Network requests",
if s.update_check {
format!(
"Release check against GitHub, every {} days",
s.update_check_interval_days
)
} else {
"None — the release check is off".to_string()
},
Verdict::Safe,
),
TrustRow::new(
"auto_update",
"Auto-update",
if s.auto_update {
"On (the default) — a newer release installs itself after a pass"
} else {
"Off — updates only when you run `devp update --install`"
},
if s.auto_update {
Verdict::Neutral
} else {
Verdict::Safe
},
),
TrustRow::new(
"confirmation",
"Confirmation before deleting",
if s.require_confirmation {
"Required, except where you pass `--yes`"
} else {
"Off — `require_confirmation` is false"
},
if s.require_confirmation {
Verdict::Safe
} else {
Verdict::Widened
},
),
TrustRow::new(
"lockfile_rewrite",
"Lockfile rewriting",
if s.allow_manifest_rewrite {
"Allowed — a stale lockfile is regenerated instead of refused"
} else {
"Refused — verification is read-only"
},
if s.allow_manifest_rewrite {
Verdict::Widened
} else {
Verdict::Safe
},
),
TrustRow::new(
"scheduler",
"Background scheduler",
scheduler,
Verdict::Neutral,
),
TrustRow::new("git_hooks", "Git hooks", hooks, Verdict::Neutral),
];
let opt_in = opt_in_adapters(registry);
rows.push(TrustRow::new(
"opt_in_adapters",
"Opt-in adapters",
if opt_in.is_empty() {
"None — only dependency directories are deletable".to_string()
} else {
format!("{} — build trees are deletable too", opt_in.join(", "))
},
if opt_in.is_empty() {
Verdict::Safe
} else {
Verdict::Widened
},
));
rows.push(TrustRow::new(
"repositories",
"Registered repositories",
format!(
"{} — nothing outside them is ever read or written",
registry.repositories.len()
),
Verdict::Neutral,
));
rows.push(TrustRow::new(
"idle_window",
"Idle window",
format!(
"{} days of no commits and no file changes ({} for build trees, before any per-adapter window)",
s.idle_days,
s.build_idle_days.max(s.idle_days)
),
Verdict::Neutral,
));
rows.push(TrustRow::new(
"binary",
"Managed binary",
output::clean_path(daemon::get_exe_path()),
Verdict::Neutral,
));
rows
}
fn opt_in_adapters(registry: &Registry) -> Vec<&'static str> {
let s = ®istry.settings;
[
("cargo", s.enable_cargo),
("gradle", s.enable_gradle),
("maven", s.enable_maven),
("swift", s.enable_swift),
("dart", s.enable_dart),
("mix_build", s.enable_mix_build),
]
.into_iter()
.filter_map(|(name, on)| on.then_some(name))
.collect()
}
fn scheduler_state() -> String {
match daemon::daemon_status() {
Ok(daemon::DaemonStatus::Installed) => "Installed — prunes on its own".to_string(),
Ok(daemon::DaemonStatus::NotInstalled) => {
"Not installed — nothing runs unless you run it".to_string()
}
Ok(daemon::DaemonStatus::Unknown(why)) => format!("Unknown ({why})"),
Err(e) => format!("Unknown ({e})"),
}
}
fn hook_state() -> String {
if !hook::git_available() {
return "Not installed — git is not on PATH".to_string();
}
match hook::state() {
Ok(HookState::Active) => "Installed — new repositories register themselves".to_string(),
Ok(HookState::Absent) => {
"Not installed — repositories register only when you say so".to_string()
}
Ok(HookState::Chained { previous, .. }) => {
format!("Installed, chained to `{previous}`")
}
Ok(HookState::Foreign(p)) => format!("Not ours — `core.hooksPath` belongs to `{p}`"),
Err(e) => format!("Unknown ({e})"),
}
}
fn print_report(report: &TrustReport) {
output::print_header(&format!("What dev-prune {} may do", constants::VERSION));
println!();
println!(" Guaranteed by the code, on every machine");
println!();
for row in &report.guarantees {
print_row(row);
}
println!();
println!(" On this machine");
println!();
for row in &report.machine {
print_row(row);
}
println!();
let widened = report.widened();
if widened.is_empty() {
output::print_success(
"Nothing on this machine widens what dev-prune may do without asking.",
);
} else {
output::print_info(&format!(
"{} {} what dev-prune may do without asking: {}. Each was switched on \
deliberately; `devp config show` has them.",
widened.len(),
if widened.len() == 1 {
"setting widens"
} else {
"settings widen"
},
widened.join(", ")
));
}
output::print_info(
"The guarantees above are enforced in `src/engine.rs` and described in full at \
docs/SAFETY_INVARIANTS.md. None of them has a bypass flag.",
);
}
fn print_row(row: &TrustRow) {
println!(
" {} {:<30} {}",
row.verdict.mark(),
row.subject,
row.state
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_directory_values_use_the_spelling_git_compares_against() {
let path = std::path::Path::new("V:\\Code\\Project");
assert_eq!(git_path_value(path), "V:/Code/Project");
}
#[test]
fn the_default_machine_widens_nothing() {
let registry = Registry::default();
let report = build(®istry);
assert!(
report.widened().is_empty(),
"a fresh install reports {:?} as widened",
report.widened()
);
}
#[test]
fn every_widening_setting_shows_up_by_name() {
let mut registry = Registry::default();
registry.settings.require_confirmation = false;
registry.settings.allow_manifest_rewrite = true;
registry.settings.enable_gradle = true;
let report = build(®istry);
let widened = report.widened();
assert_eq!(widened.len(), 3, "got {widened:?}");
assert!(widened.contains(&"Opt-in adapters"));
}
#[test]
fn opt_in_adapters_are_listed_in_a_stable_order() {
let mut registry = Registry::default();
registry.settings.enable_swift = true;
registry.settings.enable_gradle = true;
assert_eq!(opt_in_adapters(®istry), vec!["gradle", "swift"]);
}
#[test]
fn every_row_key_is_unique() {
let report = build(&Registry::default());
let mut keys: Vec<&str> = report
.guarantees
.iter()
.chain(report.machine.iter())
.map(|r| r.key)
.collect();
let total = keys.len();
keys.sort_unstable();
keys.dedup();
assert_eq!(keys.len(), total);
}
#[test]
fn guarantees_never_depend_on_settings() {
let mut registry = Registry::default();
registry.settings.allow_manifest_rewrite = true;
registry.settings.auto_update = true;
let with = build(®istry);
let without = build(&Registry::default());
let states = |r: &TrustReport| -> Vec<String> {
r.guarantees.iter().map(|g| g.state.clone()).collect()
};
assert_eq!(states(&with), states(&without));
}
}