Skip to main content

envvault/cli/
mod.rs

1//! CLI module — Clap argument parser, output helpers, and command implementations.
2
3pub mod commands;
4pub mod env_parser;
5pub mod gitignore;
6pub mod output;
7
8use clap::Parser;
9
10use zeroize::Zeroizing;
11
12use crate::errors::{EnvVaultError, Result};
13
14/// Minimum password length to prevent trivially weak passwords.
15const MIN_PASSWORD_LEN: usize = 8;
16
17/// EnvVault CLI: encrypted environment variable manager.
18#[derive(Parser)]
19#[command(
20    name = "envvault",
21    about = "Encrypted environment variable manager",
22    version
23)]
24pub struct Cli {
25    #[command(subcommand)]
26    pub command: Commands,
27
28    /// Environment to use (default: dev)
29    #[arg(short, long, default_value = "dev", global = true)]
30    pub env: String,
31
32    /// Vault directory (default: .envvault)
33    #[arg(long, default_value = ".envvault", global = true)]
34    pub vault_dir: String,
35
36    /// Path to a keyfile for two-factor vault access
37    #[arg(long, global = true)]
38    pub keyfile: Option<String>,
39}
40
41/// All available subcommands.
42#[derive(clap::Subcommand)]
43pub enum Commands {
44    /// Initialize a new vault (auto-imports .env)
45    Init,
46
47    /// Set a secret (add or update)
48    Set {
49        /// Secret name (e.g. DATABASE_URL)
50        key: String,
51        /// Secret value (omit for interactive prompt)
52        value: Option<String>,
53        /// Skip the shell-history warning for inline values
54        #[arg(short, long)]
55        force: bool,
56    },
57
58    /// Get a secret's value
59    Get {
60        /// Secret name
61        key: String,
62        /// Copy to clipboard (auto-clears after 30 seconds)
63        #[arg(short = 'c', long)]
64        clipboard: bool,
65    },
66
67    /// List all secrets
68    List,
69
70    /// Delete a secret
71    Delete {
72        /// Secret name
73        key: String,
74        /// Skip confirmation prompt
75        #[arg(short, long)]
76        force: bool,
77    },
78
79    /// Run a command with secrets injected
80    Run {
81        /// Command and arguments (after --)
82        #[arg(trailing_var_arg = true, required = true)]
83        command: Vec<String>,
84
85        /// Start with a clean environment (only vault secrets, no inherited vars)
86        #[arg(long)]
87        clean_env: bool,
88
89        /// Only inject these secrets (comma-separated)
90        #[arg(long, value_delimiter = ',')]
91        only: Option<Vec<String>>,
92
93        /// Exclude these secrets (comma-separated)
94        #[arg(long, value_delimiter = ',')]
95        exclude: Option<Vec<String>>,
96
97        /// Replace secret values in child process output with [REDACTED]
98        #[arg(long)]
99        redact_output: bool,
100
101        /// Only allow these commands to run (comma-separated basenames)
102        #[arg(long, value_delimiter = ',')]
103        allowed_commands: Option<Vec<String>>,
104    },
105
106    /// Change the vault's master password
107    RotateKey {
108        /// Path to a new keyfile (or "none" to remove keyfile requirement)
109        #[arg(long)]
110        new_keyfile: Option<String>,
111    },
112
113    /// Export secrets to a file or stdout
114    Export {
115        /// Output format: env (default) or json
116        #[arg(short, long, default_value = "env")]
117        format: String,
118
119        /// Output file path (prints to stdout if omitted)
120        #[arg(short, long)]
121        output: Option<String>,
122    },
123
124    /// Import secrets from a file
125    Import {
126        /// Path to the file to import
127        file: String,
128
129        /// Import format: env (default) or json (auto-detected from extension)
130        #[arg(short, long)]
131        format: Option<String>,
132
133        /// Preview what would be imported without modifying the vault
134        #[arg(long)]
135        dry_run: bool,
136
137        /// Skip secrets that already exist in the vault
138        #[arg(long)]
139        skip_existing: bool,
140    },
141
142    /// Manage authentication methods (keyring, keyfile)
143    Auth {
144        #[command(subcommand)]
145        action: AuthAction,
146    },
147
148    /// Manage environments (list, clone, delete)
149    Env {
150        #[command(subcommand)]
151        action: EnvAction,
152    },
153
154    /// Compare secrets between two environments
155    Diff {
156        /// Target environment to compare against
157        target_env: String,
158        /// Show secret values in diff output
159        #[arg(long)]
160        show_values: bool,
161    },
162
163    /// Open secrets in an editor (decrypts to temp file, re-encrypts on save)
164    Edit,
165
166    /// Show version and check for updates
167    Version,
168
169    /// Update envvault to the latest version
170    Update,
171
172    /// Generate shell completion scripts
173    Completions {
174        /// Shell to generate completions for (bash, zsh, fish, powershell)
175        shell: String,
176    },
177
178    /// Scan files for leaked secrets (API keys, tokens, passwords)
179    Scan {
180        /// Exit with code 1 if secrets are found (for CI/CD)
181        #[arg(long)]
182        ci: bool,
183
184        /// Directory to scan (default: current directory)
185        #[arg(long)]
186        dir: Option<String>,
187
188        /// Path to a gitleaks-format TOML config for additional rules
189        #[arg(long)]
190        gitleaks_config: Option<String>,
191    },
192
193    /// Search secrets by name pattern (supports * and ? wildcards)
194    Search {
195        /// Glob pattern to match (e.g. DB_*, *_KEY, API_?)
196        pattern: String,
197    },
198
199    /// View, export, or purge the audit log
200    Audit {
201        /// Subcommand: export, purge (omit to view entries)
202        #[command(subcommand)]
203        action: Option<AuditAction>,
204        /// Number of entries to show (default: 50)
205        #[arg(long, default_value = "50")]
206        last: usize,
207        /// Show entries since a duration ago (e.g. 7d, 24h, 30m)
208        #[arg(long)]
209        since: Option<String>,
210    },
211}
212
213/// Audit subcommands for export and purge.
214#[derive(clap::Subcommand)]
215pub enum AuditAction {
216    /// Export audit log to JSON or CSV
217    Export {
218        /// Output format: json (default) or csv
219        #[arg(long, default_value = "json")]
220        format: String,
221        /// Output file path (prints to stdout if omitted)
222        #[arg(short, long)]
223        output: Option<String>,
224    },
225    /// Delete old audit entries
226    Purge {
227        /// Delete entries older than this duration (e.g. 90d, 24h)
228        #[arg(long)]
229        older_than: String,
230    },
231}
232
233/// Auth subcommands for keyring and keyfile management.
234#[derive(clap::Subcommand)]
235pub enum AuthAction {
236    /// Save vault password to OS keyring (auto-unlock)
237    Keyring {
238        /// Remove password from keyring instead of saving
239        #[arg(long)]
240        delete: bool,
241    },
242
243    /// Generate a new random keyfile
244    KeyfileGenerate {
245        /// Path for the keyfile (default: <vault_dir>/keyfile)
246        path: Option<String>,
247    },
248}
249
250/// Env subcommands for environment management.
251#[derive(clap::Subcommand)]
252pub enum EnvAction {
253    /// List all vault environments
254    List,
255
256    /// Clone an environment to a new name
257    Clone {
258        /// Target environment name
259        target: String,
260        /// Prompt for a different password for the new vault
261        #[arg(long)]
262        new_password: bool,
263    },
264
265    /// Delete a vault environment
266    Delete {
267        /// Environment name to delete
268        name: String,
269        /// Skip confirmation prompt
270        #[arg(short, long)]
271        force: bool,
272    },
273}
274
275// ---------------------------------------------------------------------------
276// Shared helpers used by multiple commands
277// ---------------------------------------------------------------------------
278
279/// Get the vault password, trying in order:
280/// 1. `ENVVAULT_PASSWORD` env var (CI/CD)
281/// 2. OS keyring (if compiled with `keyring-store` feature)
282/// 3. Interactive prompt
283///
284/// Returns `Zeroizing<String>` so the password is wiped from memory on drop.
285pub fn prompt_password() -> Result<Zeroizing<String>> {
286    prompt_password_for_vault(None)
287}
288
289/// Get the vault password with an optional vault path for keyring lookup.
290///
291/// Returns `Zeroizing<String>` so the password is wiped from memory on drop.
292pub fn prompt_password_for_vault(vault_id: Option<&str>) -> Result<Zeroizing<String>> {
293    // 1. Check the environment variable first (CI/CD friendly).
294    if let Ok(pw) = std::env::var("ENVVAULT_PASSWORD") {
295        if !pw.is_empty() {
296            return Ok(Zeroizing::new(pw));
297        }
298    }
299
300    // 2. Try the OS keyring (if feature enabled and vault_id provided).
301    #[cfg(feature = "keyring-store")]
302    if let Some(id) = vault_id {
303        match crate::keyring::get_password(id) {
304            Ok(Some(pw)) => return Ok(Zeroizing::new(pw)),
305            Ok(None) => {} // No stored password, continue to prompt.
306            Err(_) => {}   // Keyring unavailable, continue to prompt.
307        }
308    }
309
310    // Suppress unused variable warning when keyring feature is off.
311    #[cfg(not(feature = "keyring-store"))]
312    let _ = vault_id;
313
314    // 3. Fall back to interactive prompt.
315    let pw = dialoguer::Password::new()
316        .with_prompt("Enter vault password")
317        .interact()
318        .map_err(|e| EnvVaultError::CommandFailed(format!("password prompt: {e}")))?;
319    Ok(Zeroizing::new(pw))
320}
321
322/// Prompt for a new password with confirmation (used during `init`).
323///
324/// Also respects `ENVVAULT_PASSWORD` for scripted/CI usage.
325/// Enforces a minimum password length.
326///
327/// Returns `Zeroizing<String>` so the password is wiped from memory on drop.
328pub fn prompt_new_password() -> Result<Zeroizing<String>> {
329    // Check the environment variable first (CI/CD friendly).
330    if let Ok(pw) = std::env::var("ENVVAULT_PASSWORD") {
331        if !pw.is_empty() {
332            if pw.len() < MIN_PASSWORD_LEN {
333                return Err(EnvVaultError::CommandFailed(format!(
334                    "password must be at least {MIN_PASSWORD_LEN} characters"
335                )));
336            }
337            return Ok(Zeroizing::new(pw));
338        }
339    }
340
341    loop {
342        let password = dialoguer::Password::new()
343            .with_prompt("Choose vault password")
344            .with_confirmation(
345                "Confirm vault password",
346                "Passwords do not match, try again",
347            )
348            .interact()
349            .map_err(|e| EnvVaultError::CommandFailed(format!("password prompt: {e}")))?;
350
351        if password.len() < MIN_PASSWORD_LEN {
352            output::warning(&format!(
353                "Password must be at least {MIN_PASSWORD_LEN} characters. Try again."
354            ));
355            continue;
356        }
357
358        return Ok(Zeroizing::new(password));
359    }
360}
361
362/// Build the full path to a vault file from the CLI arguments.
363///
364/// Example: `<cwd>/.envvault/dev.vault`
365pub fn vault_path(cli: &Cli) -> Result<std::path::PathBuf> {
366    let cwd = std::env::current_dir()?;
367    let env = &cli.env;
368    Ok(cwd.join(&cli.vault_dir).join(format!("{env}.vault")))
369}
370
371/// Load the keyfile bytes, checking in order:
372/// 1. `--keyfile` CLI argument
373/// 2. `keyfile_path` in `.envvault.toml`
374/// 3. `keyfile_path` in global config
375///
376/// Returns `None` if no keyfile is configured anywhere.
377pub fn load_keyfile(cli: &Cli) -> Result<Option<Vec<u8>>> {
378    // 1. CLI argument takes priority.
379    if let Some(path) = &cli.keyfile {
380        let bytes = crate::crypto::keyfile::load_keyfile(std::path::Path::new(path))?;
381        return Ok(Some(bytes));
382    }
383
384    // 2. Project-level config.
385    if let Ok(cwd) = std::env::current_dir() {
386        let settings = crate::config::Settings::load(&cwd).unwrap_or_default();
387        if let Some(ref path) = settings.keyfile_path {
388            let bytes = crate::crypto::keyfile::load_keyfile(std::path::Path::new(path))?;
389            return Ok(Some(bytes));
390        }
391    }
392
393    // 3. Global config.
394    let global = crate::config::GlobalConfig::load();
395    if let Some(ref path) = global.keyfile_path {
396        let bytes = crate::crypto::keyfile::load_keyfile(std::path::Path::new(path))?;
397        return Ok(Some(bytes));
398    }
399
400    Ok(None)
401}
402
403/// Validate that an environment name is safe and sensible.
404///
405/// Allowed: lowercase letters, digits, hyphens. Must not be empty
406/// or start/end with a hyphen. Max length 64 characters.
407/// This prevents accidental typos from silently creating new vault files.
408pub fn validate_env_name(name: &str) -> Result<()> {
409    if name.is_empty() {
410        return Err(EnvVaultError::ConfigError(
411            "environment name cannot be empty".into(),
412        ));
413    }
414
415    if name.len() > 64 {
416        return Err(EnvVaultError::ConfigError(
417            "environment name cannot exceed 64 characters".into(),
418        ));
419    }
420
421    if !name
422        .chars()
423        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
424    {
425        return Err(EnvVaultError::ConfigError(format!(
426            "environment name '{name}' is invalid — only lowercase letters, digits, and hyphens are allowed"
427        )));
428    }
429
430    if name.starts_with('-') || name.ends_with('-') {
431        return Err(EnvVaultError::ConfigError(format!(
432            "environment name '{name}' cannot start or end with a hyphen"
433        )));
434    }
435
436    Ok(())
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn valid_env_names() {
445        assert!(validate_env_name("dev").is_ok());
446        assert!(validate_env_name("staging").is_ok());
447        assert!(validate_env_name("prod").is_ok());
448        assert!(validate_env_name("us-east-1").is_ok());
449        assert!(validate_env_name("v2").is_ok());
450    }
451
452    #[test]
453    fn rejects_empty_name() {
454        assert!(validate_env_name("").is_err());
455    }
456
457    #[test]
458    fn rejects_uppercase() {
459        assert!(validate_env_name("Dev").is_err());
460        assert!(validate_env_name("PROD").is_err());
461    }
462
463    #[test]
464    fn rejects_special_chars() {
465        assert!(validate_env_name("dev.test").is_err());
466        assert!(validate_env_name("dev/test").is_err());
467        assert!(validate_env_name("dev test").is_err());
468        assert!(validate_env_name("dev_test").is_err());
469    }
470
471    #[test]
472    fn rejects_leading_trailing_hyphens() {
473        assert!(validate_env_name("-dev").is_err());
474        assert!(validate_env_name("dev-").is_err());
475    }
476
477    #[test]
478    fn rejects_too_long_name() {
479        let long_name = "a".repeat(65);
480        assert!(validate_env_name(&long_name).is_err());
481    }
482}