1use 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#[derive(Clone, Copy, PartialEq, Eq)]
34pub enum Verdict {
35 Guaranteed,
37 Safe,
39 Widened,
42 Neutral,
44}
45
46impl Verdict {
47 fn mark(self) -> &'static str {
49 match self {
50 Verdict::Guaranteed | Verdict::Safe => "+",
51 Verdict::Widened => "!",
52 Verdict::Neutral => " ",
53 }
54 }
55
56 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
68pub struct TrustRow {
70 pub key: &'static str,
72 pub subject: &'static str,
74 pub state: String,
76 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 pub fn verdict_key(&self) -> &'static str {
97 self.verdict.key()
98 }
99}
100
101pub struct TrustReport {
103 pub guarantees: Vec<TrustRow>,
105 pub machine: Vec<TrustRow>,
107}
108
109impl TrustReport {
110 pub fn widened(&self) -> Vec<&str> {
115 self.machine
116 .iter()
117 .filter(|r| r.verdict == Verdict::Widened)
118 .map(|r| r.subject)
119 .collect()
120 }
121}
122
123pub fn run(json_output: bool) -> Result<()> {
125 let registry = Registry::load()?;
126 let report = build(®istry);
127
128 if json_output {
129 return json::emit(&json::trust_document(&report));
130 }
131
132 print_report(&report);
133 Ok(())
134}
135
136fn machine_answers() -> (String, String) {
143 let scheduler = std::thread::spawn(scheduler_state);
144 let hooks = hook_state();
145 let scheduler = scheduler
148 .join()
149 .unwrap_or_else(|_| "Unknown (the check did not finish)".to_string());
150 (scheduler, hooks)
151}
152
153fn with_progress<T>(work: impl FnOnce() -> T) -> T {
160 use std::io::{IsTerminal, Write};
161
162 let mut err = std::io::stderr();
163 let show = err.is_terminal();
164 if show {
165 let _ = write!(err, "{}", constants::READING_MACHINE);
166 let _ = err.flush();
167 }
168 let value = work();
169 if show {
170 let _ = write!(
173 err,
174 "\r{:width$}\r",
175 "",
176 width = constants::READING_MACHINE.chars().count()
177 );
178 let _ = err.flush();
179 }
180 value
181}
182
183pub(crate) fn build(registry: &Registry) -> TrustReport {
189 TrustReport {
190 guarantees: guarantees(),
191 machine: machine_state(registry),
192 }
193}
194
195fn guarantees() -> Vec<TrustRow> {
201 use Verdict::Guaranteed as G;
202 vec![
203 TrustRow::new(
204 "filesystem_scope",
205 "Filesystem scope",
206 "Registered Git repositories only",
207 G,
208 ),
209 TrustRow::new(
210 "lockfile_verification",
211 "Lockfile verification",
212 "Required before every delete",
213 G,
214 ),
215 TrustRow::new("symlinks", "Symlinks and junctions", "Refused", G),
216 TrustRow::new(
217 "nested_repositories",
218 "Nested repositories",
219 "Refused — no lockfile rebuilds someone else's history",
220 G,
221 ),
222 TrustRow::new(
223 "build_outputs",
224 "Build outputs",
225 "Never deleted — no dist/, no .next/, no .gitignore rules",
226 G,
227 ),
228 TrustRow::new(
229 "deletion_bypass",
230 "Deletion bypass",
231 "None — no flag disables a safety check",
232 G,
233 ),
234 TrustRow::new(
235 "state_writes",
236 "State writes",
237 "Atomic — temp file, then rename",
238 G,
239 ),
240 TrustRow::new("telemetry", "Telemetry", "None — there is no endpoint", G),
241 TrustRow::new(
242 "restore",
243 "Restore",
244 "`devp restore --last-run` rebuilds the last pass",
245 G,
246 ),
247 ]
248}
249
250fn machine_state(registry: &Registry) -> Vec<TrustRow> {
252 let s = ®istry.settings;
253 let (scheduler, hooks) = with_progress(machine_answers);
254 let mut rows = vec![
255 TrustRow::new(
256 "network",
257 "Network requests",
258 if s.update_check {
259 format!(
260 "Release check against GitHub, every {} days",
261 s.update_check_interval_days
262 )
263 } else {
264 "None — the release check is off".to_string()
265 },
266 Verdict::Safe,
267 ),
268 TrustRow::new(
269 "auto_update",
270 "Auto-update",
271 if s.auto_update {
272 "On — dev-prune replaces its own binary"
273 } else {
274 "Off — updates only when you run `devp update`"
275 },
276 if s.auto_update {
277 Verdict::Widened
278 } else {
279 Verdict::Safe
280 },
281 ),
282 TrustRow::new(
283 "confirmation",
284 "Confirmation before deleting",
285 if s.require_confirmation {
286 "Required, except where you pass `--yes`"
287 } else {
288 "Off — `require_confirmation` is false"
289 },
290 if s.require_confirmation {
291 Verdict::Safe
292 } else {
293 Verdict::Widened
294 },
295 ),
296 TrustRow::new(
297 "lockfile_rewrite",
298 "Lockfile rewriting",
299 if s.allow_manifest_rewrite {
300 "Allowed — a stale lockfile is regenerated instead of refused"
301 } else {
302 "Refused — verification is read-only"
303 },
304 if s.allow_manifest_rewrite {
305 Verdict::Widened
306 } else {
307 Verdict::Safe
308 },
309 ),
310 TrustRow::new(
311 "scheduler",
312 "Background scheduler",
313 scheduler,
314 Verdict::Neutral,
315 ),
316 TrustRow::new("git_hooks", "Git hooks", hooks, Verdict::Neutral),
317 ];
318
319 let opt_in = opt_in_adapters(registry);
323 rows.push(TrustRow::new(
324 "opt_in_adapters",
325 "Opt-in adapters",
326 if opt_in.is_empty() {
327 "None — only dependency directories are deletable".to_string()
328 } else {
329 format!("{} — build trees are deletable too", opt_in.join(", "))
330 },
331 if opt_in.is_empty() {
332 Verdict::Safe
333 } else {
334 Verdict::Widened
335 },
336 ));
337
338 rows.push(TrustRow::new(
339 "repositories",
340 "Registered repositories",
341 format!(
342 "{} — nothing outside them is ever read or written",
343 registry.repositories.len()
344 ),
345 Verdict::Neutral,
346 ));
347 rows.push(TrustRow::new(
348 "idle_window",
349 "Idle window",
350 format!(
351 "{} days of no commits and no file changes ({} for build trees, before any per-adapter window)",
352 s.idle_days,
353 s.build_idle_days.max(s.idle_days)
354 ),
355 Verdict::Neutral,
356 ));
357 rows.push(TrustRow::new(
361 "binary",
362 "Managed binary",
363 output::clean_path(daemon::get_exe_path()),
364 Verdict::Neutral,
365 ));
366
367 rows
368}
369
370fn opt_in_adapters(registry: &Registry) -> Vec<&'static str> {
372 let s = ®istry.settings;
373 [
374 ("cargo", s.enable_cargo),
375 ("gradle", s.enable_gradle),
376 ("maven", s.enable_maven),
377 ("swift", s.enable_swift),
378 ]
379 .into_iter()
380 .filter_map(|(name, on)| on.then_some(name))
381 .collect()
382}
383
384fn scheduler_state() -> String {
386 match daemon::daemon_status() {
387 Ok(daemon::DaemonStatus::Installed) => "Installed — prunes on its own".to_string(),
388 Ok(daemon::DaemonStatus::NotInstalled) => {
389 "Not installed — nothing runs unless you run it".to_string()
390 }
391 Ok(daemon::DaemonStatus::Unknown(why)) => format!("Unknown ({why})"),
392 Err(e) => format!("Unknown ({e})"),
393 }
394}
395
396fn hook_state() -> String {
398 if !hook::git_available() {
399 return "Not installed — git is not on PATH".to_string();
400 }
401 match hook::state() {
402 Ok(HookState::Active) => "Installed — new repositories register themselves".to_string(),
403 Ok(HookState::Absent) => {
404 "Not installed — repositories register only when you say so".to_string()
405 }
406 Ok(HookState::Chained { previous, .. }) => {
407 format!("Installed, chained to `{previous}`")
408 }
409 Ok(HookState::Foreign(p)) => format!("Not ours — `core.hooksPath` belongs to `{p}`"),
410 Err(e) => format!("Unknown ({e})"),
411 }
412}
413
414fn print_report(report: &TrustReport) {
415 output::print_header(&format!("What dev-prune {} may do", constants::VERSION));
416
417 println!();
418 println!(" Guaranteed by the code, on every machine");
419 println!();
420 for row in &report.guarantees {
421 print_row(row);
422 }
423
424 println!();
425 println!(" On this machine");
426 println!();
427 for row in &report.machine {
428 print_row(row);
429 }
430
431 println!();
432 let widened = report.widened();
433 if widened.is_empty() {
434 output::print_success(
435 "Nothing on this machine widens what dev-prune may do without asking.",
436 );
437 } else {
438 output::print_info(&format!(
439 "{} {} what dev-prune may do without asking: {}. Each was switched on \
440 deliberately; `devp config show` has them.",
441 widened.len(),
442 if widened.len() == 1 {
443 "setting widens"
444 } else {
445 "settings widen"
446 },
447 widened.join(", ")
448 ));
449 }
450 output::print_info(
451 "The guarantees above are enforced in `src/engine.rs` and described in full at \
452 docs/SAFETY_INVARIANTS.md. None of them has a bypass flag.",
453 );
454}
455
456fn print_row(row: &TrustRow) {
457 println!(
458 " {} {:<30} {}",
459 row.verdict.mark(),
460 row.subject,
461 row.state
462 );
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468
469 #[test]
470 fn the_default_machine_widens_nothing() {
471 let registry = Registry::default();
472 let report = build(®istry);
473 assert!(
474 report.widened().is_empty(),
475 "a fresh install reports {:?} as widened",
476 report.widened()
477 );
478 }
479
480 #[test]
481 fn every_widening_setting_shows_up_by_name() {
482 let mut registry = Registry::default();
483 registry.settings.auto_update = true;
484 registry.settings.require_confirmation = false;
485 registry.settings.allow_manifest_rewrite = true;
486 registry.settings.enable_gradle = true;
487
488 let report = build(®istry);
489 let widened = report.widened();
490 assert_eq!(widened.len(), 4, "got {widened:?}");
491 assert!(widened.contains(&"Auto-update"));
494 assert!(widened.contains(&"Opt-in adapters"));
495 }
496
497 #[test]
498 fn opt_in_adapters_are_listed_in_a_stable_order() {
499 let mut registry = Registry::default();
500 registry.settings.enable_swift = true;
501 registry.settings.enable_gradle = true;
502 assert_eq!(opt_in_adapters(®istry), vec!["gradle", "swift"]);
503 }
504
505 #[test]
506 fn every_row_key_is_unique() {
507 let report = build(&Registry::default());
510 let mut keys: Vec<&str> = report
511 .guarantees
512 .iter()
513 .chain(report.machine.iter())
514 .map(|r| r.key)
515 .collect();
516 let total = keys.len();
517 keys.sort_unstable();
518 keys.dedup();
519 assert_eq!(keys.len(), total);
520 }
521
522 #[test]
523 fn guarantees_never_depend_on_settings() {
524 let mut registry = Registry::default();
527 registry.settings.allow_manifest_rewrite = true;
528 registry.settings.auto_update = true;
529 let with = build(®istry);
530 let without = build(&Registry::default());
531
532 let states = |r: &TrustReport| -> Vec<String> {
533 r.guarantees.iter().map(|g| g.state.clone()).collect()
534 };
535 assert_eq!(states(&with), states(&without));
536 }
537}