Skip to main content

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    fn test_env_plugin_default() {
187        let p = EnvPlugin::default();
188        assert_eq!(p.name(), "env");
189    }
190
191    #[test]
192    fn test_env_plugin_handler_name() {
193        let handlers = EnvPlugin::new().handlers();
194        assert_eq!(handlers.len(), 1);
195        assert_eq!(handlers[0].0, "env_show");
196    }
197
198    #[test]
199    fn test_env_handler_executes() {
200        let handlers = EnvPlugin::new().handlers();
201        let (_, handler) = &handlers[0];
202        let mut ctx = TestContext;
203        assert!(handler
204            .execute(&mut ctx, &ParsedArgs::from_scalars(Default::default()))
205            .is_ok());
206    }
207
208    #[test]
209    fn test_env_plugin_is_send_sync() {
210        fn assert_send_sync<T: Send + Sync>(_: T) {}
211        assert_send_sync(EnvPlugin::new());
212    }
213
214    #[test]
215    fn test_is_sensitive_name_matches_expected_patterns() {
216        assert!(is_sensitive_name("API_SECRET"));
217        assert!(is_sensitive_name("AWS_ACCESS_KEY_ID"));
218        assert!(is_sensitive_name("DATABASE_PASSWORD"));
219        assert!(is_sensitive_name("GITHUB_TOKEN"));
220        assert!(is_sensitive_name("MY_APP_CREDENTIAL"));
221        assert!(is_sensitive_name("BASIC_AUTH_HEADER"));
222        assert!(is_sensitive_name("SSH_PRIVATE_KEY_PATH"));
223        // Case-insensitivity
224        assert!(is_sensitive_name("api_secret"));
225        assert!(is_sensitive_name("Api_Secret"));
226    }
227
228    #[test]
229    fn test_is_sensitive_name_leaves_ordinary_vars_alone() {
230        assert!(!is_sensitive_name("PATH"));
231        assert!(!is_sensitive_name("HOME"));
232        assert!(!is_sensitive_name("LANG"));
233        assert!(!is_sensitive_name("EDITOR"));
234        assert!(!is_sensitive_name("RUST_LOG"));
235    }
236}