Skip to main content

cc_toolgate/commands/tools/
cargo.rs

1//! Subcommand-aware cargo evaluation.
2//!
3//! Distinguishes safe subcommands (build, test, clippy) from mutating ones
4//! (install, publish). Supports env-gated auto-allow and `--version`/`-V` detection.
5
6use super::super::CommandSpec;
7use crate::config::CargoConfig;
8use crate::eval::{CommandContext, Decision, RuleMatch};
9use std::collections::HashMap;
10
11/// Subcommand-aware cargo evaluator.
12///
13/// Evaluation order:
14/// 1. Safe subcommands → ALLOW (with redirection escalation)
15/// 2. Env-gated subcommands → ALLOW if all `config_env` entries match, else ASK
16/// 3. `--version` / `-V` → ALLOW
17/// 4. Everything else → ASK
18pub struct CargoSpec {
19    /// Subcommands that are always safe (e.g. `build`, `test`, `check`).
20    safe_subcommands: Vec<String>,
21    /// Subcommands allowed only when all `config_env` entries match.
22    allowed_with_config: Vec<String>,
23    /// Required env var name→value pairs that gate `allowed_with_config` subcommands.
24    config_env: HashMap<String, String>,
25}
26
27impl CargoSpec {
28    /// Build a cargo spec from configuration.
29    pub fn from_config(config: &CargoConfig) -> Self {
30        Self {
31            safe_subcommands: config.safe_subcommands.clone(),
32            allowed_with_config: config.allowed_with_config.clone(),
33            config_env: config.config_env.clone(),
34        }
35    }
36
37    /// Extract the cargo subcommand (first non-flag word after "cargo").
38    /// Handles env var prefixes like `CARGO_INSTALL_ROOT=/tmp cargo install`.
39    fn subcommand<'a>(ctx: &'a CommandContext) -> Option<&'a str> {
40        let mut iter = ctx.words.iter();
41        for word in iter.by_ref() {
42            if word == "cargo" {
43                return iter.find(|w| !w.starts_with('-')).map(|s| s.as_str());
44            }
45        }
46        None
47    }
48
49    /// Format config_env keys for reason strings.
50    fn env_keys_display(&self) -> String {
51        let mut keys: Vec<&str> = self.config_env.keys().map(|k| k.as_str()).collect();
52        keys.sort();
53        keys.join(", ")
54    }
55}
56
57impl CommandSpec for CargoSpec {
58    fn evaluate(&self, ctx: &CommandContext) -> RuleMatch {
59        let sub_str = Self::subcommand(ctx).unwrap_or("?");
60
61        if self.safe_subcommands.iter().any(|s| s == sub_str) {
62            if let Some(ref r) = ctx.redirection {
63                return RuleMatch {
64                    decision: Decision::Ask,
65                    reason: format!("cargo {sub_str} with {}", r.description),
66                };
67            }
68            return RuleMatch {
69                decision: Decision::Allow,
70                reason: format!("cargo {sub_str}"),
71            };
72        }
73
74        // Env-gated subcommands: allowed only when all config_env entries match
75        if self.allowed_with_config.iter().any(|s| s == sub_str) {
76            if !self.config_env.is_empty() && ctx.env_satisfies(&self.config_env) {
77                if let Some(ref r) = ctx.redirection {
78                    return RuleMatch {
79                        decision: Decision::Ask,
80                        reason: format!("cargo {sub_str} with {}", r.description),
81                    };
82                }
83                return RuleMatch {
84                    decision: Decision::Allow,
85                    reason: format!("cargo {sub_str} with {}", self.env_keys_display()),
86                };
87            }
88            return RuleMatch {
89                decision: Decision::Ask,
90                reason: format!("cargo {sub_str} requires confirmation"),
91            };
92        }
93
94        // --version / -V at any position
95        if ctx.has_any_flag(&["--version", "-V"]) {
96            return RuleMatch {
97                decision: Decision::Allow,
98                reason: "cargo --version".into(),
99            };
100        }
101
102        RuleMatch {
103            decision: Decision::Ask,
104            reason: format!("cargo {sub_str} requires confirmation"),
105        }
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use crate::config::Config;
113
114    fn spec() -> CargoSpec {
115        CargoSpec::from_config(&Config::default_config().cargo)
116    }
117
118    fn eval(cmd: &str) -> Decision {
119        let s = spec();
120        let ctx = CommandContext::from_command(cmd);
121        s.evaluate(&ctx).decision
122    }
123
124    #[test]
125    fn allow_build() {
126        assert_eq!(eval("cargo build --release"), Decision::Allow);
127    }
128
129    #[test]
130    fn allow_test() {
131        assert_eq!(eval("cargo test"), Decision::Allow);
132    }
133
134    #[test]
135    fn allow_clippy() {
136        assert_eq!(eval("cargo clippy"), Decision::Allow);
137    }
138
139    #[test]
140    fn allow_version() {
141        assert_eq!(eval("cargo --version"), Decision::Allow);
142    }
143
144    #[test]
145    fn allow_version_short() {
146        assert_eq!(eval("cargo -V"), Decision::Allow);
147    }
148
149    #[test]
150    fn ask_install() {
151        assert_eq!(eval("cargo install ripgrep"), Decision::Ask);
152    }
153
154    #[test]
155    fn ask_publish() {
156        assert_eq!(eval("cargo publish"), Decision::Ask);
157    }
158
159    #[test]
160    fn redir_build() {
161        assert_eq!(eval("cargo build --release > /tmp/log"), Decision::Ask);
162    }
163
164    // ── Env-gated commands ──
165
166    fn spec_with_env_gate() -> CargoSpec {
167        CargoSpec::from_config(&CargoConfig {
168            safe_subcommands: vec!["build".into(), "check".into(), "test".into()],
169            allowed_with_config: vec!["install".into(), "publish".into()],
170            config_env: HashMap::from([("CARGO_INSTALL_ROOT".into(), "/tmp/bin".into())]),
171        })
172    }
173
174    fn eval_with_env_gate(cmd: &str) -> Decision {
175        let s = spec_with_env_gate();
176        let ctx = CommandContext::from_command(cmd);
177        s.evaluate(&ctx).decision
178    }
179
180    #[test]
181    fn env_gate_install_with_matching_value() {
182        assert_eq!(
183            eval_with_env_gate("CARGO_INSTALL_ROOT=/tmp/bin cargo install ripgrep"),
184            Decision::Allow
185        );
186    }
187
188    #[test]
189    fn env_gate_install_with_wrong_value() {
190        assert_eq!(
191            eval_with_env_gate("CARGO_INSTALL_ROOT=/usr/local cargo install ripgrep"),
192            Decision::Ask
193        );
194    }
195
196    #[test]
197    fn env_gate_install_no_config() {
198        assert_eq!(eval_with_env_gate("cargo install ripgrep"), Decision::Ask);
199    }
200
201    #[test]
202    fn env_gate_publish_with_config() {
203        assert_eq!(
204            eval_with_env_gate("CARGO_INSTALL_ROOT=/tmp/bin cargo publish"),
205            Decision::Allow
206        );
207    }
208
209    #[test]
210    fn env_gate_build_still_safe_no_env() {
211        // safe_subcommands don't need the env var
212        assert_eq!(eval_with_env_gate("cargo build"), Decision::Allow);
213    }
214}