dynamic_cli/plugin/builtin/env.rs
1//! Standalone `EnvPlugin` (feature-gated).
2//!
3//! Displays environment variables, with sensitive-looking ones excluded
4//! by default — never a raw `std::env::vars()` dump. Only available with
5//! the `env-plugin` feature.
6//!
7//! # Filtering rule
8//!
9//! Unlike [`ArgumentDefinition::secure`][crate::config::schema::ArgumentDefinition]
10//! (DD-023), which relies on an explicit `secure: true` opt-in on a
11//! config-declared argument, environment variables have no such upfront
12//! declaration — the set of names is arbitrary and unknown ahead of
13//! time. An allow-list is therefore not viable here; this plugin instead
14//! uses a **deny-list of case-insensitive substrings** applied to each
15//! variable's name:
16//!
17//! ```text
18//! SECRET, TOKEN, KEY, PASS, CREDENTIAL, AUTH, PRIVATE
19//! ```
20//!
21//! Any variable whose name contains one of these substrings (case
22//! folded) is hidden — its value is never printed, never included in
23//! output, only counted. This is a heuristic, not a guarantee: an
24//! oddly-named secret can still slip through. See [`is_sensitive_name`]
25//! if a custom list is needed.
26
27use crate::context::ExecutionContext;
28use crate::executor::CommandHandler;
29use crate::parser::ParsedArgs;
30use crate::plugin::Plugin;
31use crate::Result;
32
33/// Case-insensitive substrings that mark an environment variable name as
34/// sensitive. See the module-level docs for the rationale.
35const SENSITIVE_NAME_SUBSTRINGS: &[&str] = &[
36 "SECRET",
37 "TOKEN",
38 "KEY",
39 "PASS",
40 "CREDENTIAL",
41 "AUTH",
42 "PRIVATE",
43];
44
45/// Returns `true` if `name` looks sensitive under the deny-list rule
46/// documented at the module level.
47fn is_sensitive_name(name: &str) -> bool {
48 let upper = name.to_uppercase();
49 SENSITIVE_NAME_SUBSTRINGS
50 .iter()
51 .any(|needle| upper.contains(needle))
52}
53
54/// Standalone plugin providing a single `env` command.
55///
56/// Prints environment variable names and values, skipping any name that
57/// looks sensitive (see the module-level filtering rule). The variable
58/// *name* itself is always shown for hidden entries — only its value is
59/// withheld — so the count and presence of hidden variables stays
60/// visible without leaking their contents.
61///
62/// # YAML config
63///
64/// ```yaml
65/// commands:
66/// - name: env
67/// implementation: env_show
68/// description: "Show environment variables (sensitive ones hidden)"
69/// required: false
70/// arguments: []
71/// options: []
72/// ```
73///
74/// # Example
75///
76/// ```
77/// use dynamic_cli::plugin::{EnvPlugin, Plugin};
78///
79/// let plugin = EnvPlugin::new();
80/// assert_eq!(plugin.name(), "env");
81///
82/// let handlers = plugin.handlers();
83/// assert_eq!(handlers.len(), 1);
84/// assert_eq!(handlers[0].0, "env_show");
85/// ```
86pub struct EnvPlugin;
87
88impl EnvPlugin {
89 /// Create a new `EnvPlugin`.
90 ///
91 /// # Example
92 ///
93 /// ```
94 /// use dynamic_cli::plugin::{EnvPlugin, Plugin};
95 ///
96 /// let plugin = EnvPlugin::new();
97 /// assert_eq!(plugin.name(), "env");
98 /// ```
99 pub fn new() -> Self {
100 Self
101 }
102}
103
104impl Default for EnvPlugin {
105 fn default() -> Self {
106 Self::new()
107 }
108}
109
110impl Plugin for EnvPlugin {
111 fn name(&self) -> &str {
112 "env"
113 }
114
115 fn version(&self) -> &str {
116 env!("CARGO_PKG_VERSION")
117 }
118
119 fn description(&self) -> &str {
120 "Filtered environment variable display (feature-gated, #46)"
121 }
122
123 fn handlers(&self) -> Vec<(String, Box<dyn CommandHandler>)> {
124 vec![("env_show".to_string(), Box::new(EnvShowHandler))]
125 }
126}
127
128/// Handler for `env_show` — prints environment variables, hiding
129/// sensitive-looking ones. See the module-level filtering rule.
130struct EnvShowHandler;
131
132impl CommandHandler for EnvShowHandler {
133 fn execute(&self, _ctx: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
134 let mut vars: Vec<(String, String)> = std::env::vars().collect();
135 vars.sort_by(|a, b| a.0.cmp(&b.0));
136
137 let mut hidden_count = 0usize;
138
139 for (name, value) in &vars {
140 if is_sensitive_name(name) {
141 hidden_count += 1;
142 println!("{name} = <hidden>");
143 } else {
144 println!("{name} = {value}");
145 }
146 }
147
148 if hidden_count > 0 {
149 println!("\n{hidden_count} variable(s) hidden (name looked sensitive)");
150 }
151
152 Ok(())
153 }
154}
155
156// ============================================================================
157// Tests
158// ============================================================================
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use std::any::Any;
164
165 #[derive(Default)]
166 struct TestContext;
167
168 impl ExecutionContext for TestContext {
169 fn as_any(&self) -> &dyn Any {
170 self
171 }
172 fn as_any_mut(&mut self) -> &mut dyn Any {
173 self
174 }
175 }
176
177 #[test]
178 fn test_env_plugin_metadata() {
179 let p = EnvPlugin::new();
180 assert_eq!(p.name(), "env");
181 assert!(!p.version().is_empty());
182 assert!(!p.description().is_empty());
183 }
184
185 #[test]
186 // EnvPlugin::default() is what's under test here (the Default impl
187 // itself, kept for clippy::new_without_default compliance); rewriting
188 // it to the bare unit-struct literal per clippy's own suggestion would
189 // stop exercising that impl and defeat the test's purpose.
190 #[allow(clippy::default_constructed_unit_structs)]
191 fn test_env_plugin_default() {
192 let p = EnvPlugin::default();
193 assert_eq!(p.name(), "env");
194 }
195
196 #[test]
197 fn test_env_plugin_handler_name() {
198 let handlers = EnvPlugin::new().handlers();
199 assert_eq!(handlers.len(), 1);
200 assert_eq!(handlers[0].0, "env_show");
201 }
202
203 #[test]
204 fn test_env_handler_executes() {
205 let handlers = EnvPlugin::new().handlers();
206 let (_, handler) = &handlers[0];
207 let mut ctx = TestContext;
208 assert!(handler
209 .execute(&mut ctx, &ParsedArgs::from_scalars(Default::default()))
210 .is_ok());
211 }
212
213 #[test]
214 fn test_env_plugin_is_send_sync() {
215 fn assert_send_sync<T: Send + Sync>(_: T) {}
216 assert_send_sync(EnvPlugin::new());
217 }
218
219 #[test]
220 fn test_is_sensitive_name_matches_expected_patterns() {
221 assert!(is_sensitive_name("API_SECRET"));
222 assert!(is_sensitive_name("AWS_ACCESS_KEY_ID"));
223 assert!(is_sensitive_name("DATABASE_PASSWORD"));
224 assert!(is_sensitive_name("GITHUB_TOKEN"));
225 assert!(is_sensitive_name("MY_APP_CREDENTIAL"));
226 assert!(is_sensitive_name("BASIC_AUTH_HEADER"));
227 assert!(is_sensitive_name("SSH_PRIVATE_KEY_PATH"));
228 // Case-insensitivity
229 assert!(is_sensitive_name("api_secret"));
230 assert!(is_sensitive_name("Api_Secret"));
231 }
232
233 #[test]
234 fn test_is_sensitive_name_leaves_ordinary_vars_alone() {
235 assert!(!is_sensitive_name("PATH"));
236 assert!(!is_sensitive_name("HOME"));
237 assert!(!is_sensitive_name("LANG"));
238 assert!(!is_sensitive_name("EDITOR"));
239 assert!(!is_sensitive_name("RUST_LOG"));
240 }
241}