Skip to main content

rippy_cli/
trust_cmd.rs

1//! CLI handler for `rippy trust` — manage trust for project config files.
2
3use std::path::Path;
4use std::process::ExitCode;
5
6use crate::cli::TrustArgs;
7use crate::config::find_project_config;
8use crate::error::RippyError;
9use crate::trust::{TrustDb, TrustStatus};
10
11/// Run the `rippy trust` subcommand.
12///
13/// # Errors
14///
15/// Returns `RippyError::Trust` on trust database or config errors.
16pub fn run(args: &TrustArgs) -> Result<ExitCode, RippyError> {
17    if args.list {
18        return list_trusted();
19    }
20
21    let cwd = std::env::current_dir()
22        .map_err(|e| RippyError::Trust(format!("could not determine working directory: {e}")))?;
23
24    let config_path = find_project_config(&cwd).ok_or_else(|| {
25        RippyError::Trust("no project config found (.rippy.toml, .rippy, or .dippy)".to_string())
26    })?;
27
28    if args.revoke {
29        return revoke_trust(&config_path);
30    }
31
32    if args.status {
33        return show_status(&config_path);
34    }
35
36    trust_config(&config_path, args.yes)
37}
38
39/// Add the project config to the trust database.
40fn trust_config(config_path: &Path, skip_confirm: bool) -> Result<ExitCode, RippyError> {
41    let content = std::fs::read_to_string(config_path)
42        .map_err(|e| RippyError::Trust(format!("could not read {}: {e}", config_path.display())))?;
43
44    if !skip_confirm {
45        print_config_summary(config_path, &content);
46        eprintln!();
47        eprint!("Trust this config? [y/N] ");
48        let mut answer = String::new();
49        std::io::stdin()
50            .read_line(&mut answer)
51            .map_err(|e| RippyError::Trust(format!("could not read confirmation: {e}")))?;
52        if !answer.trim().eq_ignore_ascii_case("y") {
53            eprintln!("[rippy] trust cancelled");
54            return Ok(ExitCode::from(1));
55        }
56    }
57
58    let mut db = TrustDb::load();
59    db.trust(config_path, &content);
60    db.save()?;
61
62    eprintln!("[rippy] trusted: {}", config_path.display());
63    Ok(ExitCode::SUCCESS)
64}
65
66/// Print a summary of the config file contents with safety analysis.
67fn print_config_summary(path: &Path, content: &str) {
68    eprintln!("Project config: {}", path.display());
69    if let Some(repo_id) = crate::trust::detect_repo_id(path) {
70        eprintln!("Repository: {repo_id}");
71        eprintln!("  (future config changes in this repo will be auto-trusted)");
72    }
73    eprintln!("---");
74    for line in content.lines() {
75        eprintln!("  {line}");
76    }
77    eprintln!("---");
78
79    let stats = analyze_config_safety(content);
80    eprintln!(
81        "{} line(s), {} rule(s): {} allow, {} ask, {} deny",
82        content.lines().count(),
83        stats.allow + stats.ask + stats.deny,
84        stats.allow,
85        stats.ask,
86        stats.deny,
87    );
88
89    if stats.allow > 0 || stats.sets_default_allow {
90        eprintln!();
91        eprintln!("WARNING: this config WEAKENS protections:");
92        if stats.allow > 0 {
93            eprintln!(
94                "  - {} allow rule(s) will auto-approve commands",
95                stats.allow
96            );
97        }
98        if stats.sets_default_allow {
99            eprintln!("  - sets default action to allow (all unknown commands auto-approved)");
100        }
101    }
102}
103
104/// Lightweight safety analysis of config content.
105struct ConfigSafety {
106    allow: usize,
107    ask: usize,
108    deny: usize,
109    sets_default_allow: bool,
110}
111
112fn analyze_config_safety(content: &str) -> ConfigSafety {
113    let mut stats = ConfigSafety {
114        allow: 0,
115        ask: 0,
116        deny: 0,
117        sets_default_allow: false,
118    };
119
120    for line in content.lines() {
121        let trimmed = line.trim();
122        // Line-based format
123        if trimmed.starts_with("allow ") {
124            stats.allow += 1;
125        } else if trimmed.starts_with("ask ") {
126            stats.ask += 1;
127        } else if trimmed.starts_with("deny") {
128            stats.deny += 1;
129        } else if trimmed.starts_with("set default allow") {
130            stats.sets_default_allow = true;
131        }
132        // TOML format
133        if trimmed.contains("action") && trimmed.contains("allow") && !trimmed.contains("deny") {
134            stats.allow += 1;
135        } else if trimmed.contains("action") && trimmed.contains("ask") {
136            stats.ask += 1;
137        } else if trimmed.contains("action") && trimmed.contains("deny") {
138            stats.deny += 1;
139        }
140        if trimmed.contains("default") && trimmed.contains("allow") && !trimmed.starts_with('#') {
141            stats.sets_default_allow = true;
142        }
143    }
144    stats
145}
146
147/// Remove trust for the project config.
148fn revoke_trust(config_path: &Path) -> Result<ExitCode, RippyError> {
149    let mut db = TrustDb::load();
150    if db.revoke(config_path) {
151        db.save()?;
152        eprintln!("[rippy] trust revoked: {}", config_path.display());
153        Ok(ExitCode::SUCCESS)
154    } else {
155        eprintln!(
156            "[rippy] no trust entry found for: {}",
157            config_path.display()
158        );
159        Ok(ExitCode::from(1))
160    }
161}
162
163/// Show the trust status of the current project config.
164fn show_status(config_path: &Path) -> Result<ExitCode, RippyError> {
165    let content = std::fs::read_to_string(config_path)
166        .map_err(|e| RippyError::Trust(format!("could not read {}: {e}", config_path.display())))?;
167
168    let db = TrustDb::load();
169    let status = db.check(config_path, &content);
170    match status {
171        TrustStatus::Trusted => {
172            eprintln!("[rippy] trusted: {}", config_path.display());
173            Ok(ExitCode::SUCCESS)
174        }
175        TrustStatus::Untrusted => {
176            eprintln!(
177                "[rippy] untrusted: {} — run `rippy trust` to approve",
178                config_path.display()
179            );
180            Ok(ExitCode::from(2))
181        }
182        TrustStatus::Modified { .. } => {
183            eprintln!(
184                "[rippy] modified since last trust: {} — run `rippy trust` to re-approve",
185                config_path.display()
186            );
187            Ok(ExitCode::from(2))
188        }
189    }
190}
191
192/// List all trusted project configs.
193#[allow(clippy::unnecessary_wraps)]
194fn list_trusted() -> Result<ExitCode, RippyError> {
195    let db = TrustDb::load();
196    if db.is_empty() {
197        eprintln!("[rippy] no trusted project configs");
198    } else {
199        for (path, entry) in db.list() {
200            eprintln!(
201                "{path}  (trusted {}, hash {})",
202                entry.trusted_at, entry.hash
203            );
204        }
205    }
206    Ok(ExitCode::SUCCESS)
207}