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    },
54
55    /// Get a secret's value
56    Get {
57        /// Secret name
58        key: String,
59    },
60
61    /// List all secrets
62    List,
63
64    /// Delete a secret
65    Delete {
66        /// Secret name
67        key: String,
68        /// Skip confirmation prompt
69        #[arg(short, long)]
70        force: bool,
71    },
72
73    /// Run a command with secrets injected
74    Run {
75        /// Command and arguments (after --)
76        #[arg(trailing_var_arg = true, required = true)]
77        command: Vec<String>,
78
79        /// Start with a clean environment (only vault secrets, no inherited vars)
80        #[arg(long)]
81        clean_env: bool,
82    },
83
84    /// Change the vault's master password
85    RotateKey,
86
87    /// Export secrets to a file or stdout
88    Export {
89        /// Output format: env (default) or json
90        #[arg(short, long, default_value = "env")]
91        format: String,
92
93        /// Output file path (prints to stdout if omitted)
94        #[arg(short, long)]
95        output: Option<String>,
96    },
97
98    /// Import secrets from a file
99    Import {
100        /// Path to the file to import
101        file: String,
102
103        /// Import format: env (default) or json (auto-detected from extension)
104        #[arg(short, long)]
105        format: Option<String>,
106    },
107
108    /// Manage authentication methods (keyring, keyfile)
109    Auth {
110        #[command(subcommand)]
111        action: AuthAction,
112    },
113
114    /// Manage environments (list, clone, delete)
115    Env {
116        #[command(subcommand)]
117        action: EnvAction,
118    },
119
120    /// Compare secrets between two environments
121    Diff {
122        /// Target environment to compare against
123        target_env: String,
124        /// Show secret values in diff output
125        #[arg(long)]
126        show_values: bool,
127    },
128
129    /// Open secrets in an editor (decrypts to temp file, re-encrypts on save)
130    Edit,
131
132    /// Show version and check for updates
133    Version,
134
135    /// Generate shell completion scripts
136    Completions {
137        /// Shell to generate completions for (bash, zsh, fish, powershell)
138        shell: String,
139    },
140
141    /// View the audit log of vault operations
142    Audit {
143        /// Number of entries to show (default: 50)
144        #[arg(long, default_value = "50")]
145        last: usize,
146        /// Show entries since a duration ago (e.g. 7d, 24h, 30m)
147        #[arg(long)]
148        since: Option<String>,
149    },
150}
151
152/// Auth subcommands for keyring and keyfile management.
153#[derive(clap::Subcommand)]
154pub enum AuthAction {
155    /// Save vault password to OS keyring (auto-unlock)
156    Keyring {
157        /// Remove password from keyring instead of saving
158        #[arg(long)]
159        delete: bool,
160    },
161
162    /// Generate a new random keyfile
163    KeyfileGenerate {
164        /// Path for the keyfile (default: <vault_dir>/keyfile)
165        path: Option<String>,
166    },
167}
168
169/// Env subcommands for environment management.
170#[derive(clap::Subcommand)]
171pub enum EnvAction {
172    /// List all vault environments
173    List,
174
175    /// Clone an environment to a new name
176    Clone {
177        /// Target environment name
178        target: String,
179        /// Prompt for a different password for the new vault
180        #[arg(long)]
181        new_password: bool,
182    },
183
184    /// Delete a vault environment
185    Delete {
186        /// Environment name to delete
187        name: String,
188        /// Skip confirmation prompt
189        #[arg(short, long)]
190        force: bool,
191    },
192}
193
194// ---------------------------------------------------------------------------
195// Shared helpers used by multiple commands
196// ---------------------------------------------------------------------------
197
198/// Get the vault password, trying in order:
199/// 1. `ENVVAULT_PASSWORD` env var (CI/CD)
200/// 2. OS keyring (if compiled with `keyring-store` feature)
201/// 3. Interactive prompt
202///
203/// Returns `Zeroizing<String>` so the password is wiped from memory on drop.
204pub fn prompt_password() -> Result<Zeroizing<String>> {
205    prompt_password_for_vault(None)
206}
207
208/// Get the vault password with an optional vault path for keyring lookup.
209///
210/// Returns `Zeroizing<String>` so the password is wiped from memory on drop.
211pub fn prompt_password_for_vault(vault_id: Option<&str>) -> Result<Zeroizing<String>> {
212    // 1. Check the environment variable first (CI/CD friendly).
213    if let Ok(pw) = std::env::var("ENVVAULT_PASSWORD") {
214        if !pw.is_empty() {
215            return Ok(Zeroizing::new(pw));
216        }
217    }
218
219    // 2. Try the OS keyring (if feature enabled and vault_id provided).
220    #[cfg(feature = "keyring-store")]
221    if let Some(id) = vault_id {
222        match crate::keyring::get_password(id) {
223            Ok(Some(pw)) => return Ok(Zeroizing::new(pw)),
224            Ok(None) => {} // No stored password, continue to prompt.
225            Err(_) => {}   // Keyring unavailable, continue to prompt.
226        }
227    }
228
229    // Suppress unused variable warning when keyring feature is off.
230    #[cfg(not(feature = "keyring-store"))]
231    let _ = vault_id;
232
233    // 3. Fall back to interactive prompt.
234    let pw = dialoguer::Password::new()
235        .with_prompt("Enter vault password")
236        .interact()
237        .map_err(|e| EnvVaultError::CommandFailed(format!("password prompt: {e}")))?;
238    Ok(Zeroizing::new(pw))
239}
240
241/// Prompt for a new password with confirmation (used during `init`).
242///
243/// Also respects `ENVVAULT_PASSWORD` for scripted/CI usage.
244/// Enforces a minimum password length.
245///
246/// Returns `Zeroizing<String>` so the password is wiped from memory on drop.
247pub fn prompt_new_password() -> Result<Zeroizing<String>> {
248    // Check the environment variable first (CI/CD friendly).
249    if let Ok(pw) = std::env::var("ENVVAULT_PASSWORD") {
250        if !pw.is_empty() {
251            if pw.len() < MIN_PASSWORD_LEN {
252                return Err(EnvVaultError::CommandFailed(format!(
253                    "password must be at least {MIN_PASSWORD_LEN} characters"
254                )));
255            }
256            return Ok(Zeroizing::new(pw));
257        }
258    }
259
260    loop {
261        let password = dialoguer::Password::new()
262            .with_prompt("Choose vault password")
263            .with_confirmation(
264                "Confirm vault password",
265                "Passwords do not match, try again",
266            )
267            .interact()
268            .map_err(|e| EnvVaultError::CommandFailed(format!("password prompt: {e}")))?;
269
270        if password.len() < MIN_PASSWORD_LEN {
271            output::warning(&format!(
272                "Password must be at least {MIN_PASSWORD_LEN} characters. Try again."
273            ));
274            continue;
275        }
276
277        return Ok(Zeroizing::new(password));
278    }
279}
280
281/// Build the full path to a vault file from the CLI arguments.
282///
283/// Example: `<cwd>/.envvault/dev.vault`
284pub fn vault_path(cli: &Cli) -> Result<std::path::PathBuf> {
285    let cwd = std::env::current_dir()?;
286    let env = &cli.env;
287    Ok(cwd.join(&cli.vault_dir).join(format!("{env}.vault")))
288}
289
290/// Load the keyfile bytes from the path in CLI args, if provided.
291///
292/// Returns `None` if `--keyfile` was not passed.
293pub fn load_keyfile(cli: &Cli) -> Result<Option<Vec<u8>>> {
294    match &cli.keyfile {
295        Some(path) => {
296            let bytes = crate::crypto::keyfile::load_keyfile(std::path::Path::new(path))?;
297            Ok(Some(bytes))
298        }
299        None => Ok(None),
300    }
301}
302
303/// Validate that an environment name is safe and sensible.
304///
305/// Allowed: lowercase letters, digits, hyphens. Must not be empty
306/// or start/end with a hyphen. Max length 64 characters.
307/// This prevents accidental typos from silently creating new vault files.
308pub fn validate_env_name(name: &str) -> Result<()> {
309    if name.is_empty() {
310        return Err(EnvVaultError::ConfigError(
311            "environment name cannot be empty".into(),
312        ));
313    }
314
315    if name.len() > 64 {
316        return Err(EnvVaultError::ConfigError(
317            "environment name cannot exceed 64 characters".into(),
318        ));
319    }
320
321    if !name
322        .chars()
323        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
324    {
325        return Err(EnvVaultError::ConfigError(format!(
326            "environment name '{name}' is invalid — only lowercase letters, digits, and hyphens are allowed"
327        )));
328    }
329
330    if name.starts_with('-') || name.ends_with('-') {
331        return Err(EnvVaultError::ConfigError(format!(
332            "environment name '{name}' cannot start or end with a hyphen"
333        )));
334    }
335
336    Ok(())
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    #[test]
344    fn valid_env_names() {
345        assert!(validate_env_name("dev").is_ok());
346        assert!(validate_env_name("staging").is_ok());
347        assert!(validate_env_name("prod").is_ok());
348        assert!(validate_env_name("us-east-1").is_ok());
349        assert!(validate_env_name("v2").is_ok());
350    }
351
352    #[test]
353    fn rejects_empty_name() {
354        assert!(validate_env_name("").is_err());
355    }
356
357    #[test]
358    fn rejects_uppercase() {
359        assert!(validate_env_name("Dev").is_err());
360        assert!(validate_env_name("PROD").is_err());
361    }
362
363    #[test]
364    fn rejects_special_chars() {
365        assert!(validate_env_name("dev.test").is_err());
366        assert!(validate_env_name("dev/test").is_err());
367        assert!(validate_env_name("dev test").is_err());
368        assert!(validate_env_name("dev_test").is_err());
369    }
370
371    #[test]
372    fn rejects_leading_trailing_hyphens() {
373        assert!(validate_env_name("-dev").is_err());
374        assert!(validate_env_name("dev-").is_err());
375    }
376
377    #[test]
378    fn rejects_too_long_name() {
379        let long_name = "a".repeat(65);
380        assert!(validate_env_name(&long_name).is_err());
381    }
382}