envvault-cli 0.5.1

A local-first encrypted environment variable manager
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
//! CLI module — Clap argument parser, output helpers, and command implementations.

pub mod commands;
pub mod env_parser;
pub mod gitignore;
pub mod output;

use clap::Parser;

use zeroize::Zeroizing;

use crate::errors::{EnvVaultError, Result};

/// Minimum password length to prevent trivially weak passwords.
const MIN_PASSWORD_LEN: usize = 8;

/// EnvVault CLI: encrypted environment variable manager.
#[derive(Parser)]
#[command(
    name = "envvault",
    about = "Encrypted environment variable manager",
    version
)]
pub struct Cli {
    #[command(subcommand)]
    pub command: Commands,

    /// Environment to use (default: dev)
    #[arg(short, long, default_value = "dev", global = true)]
    pub env: String,

    /// Vault directory (default: .envvault)
    #[arg(long, default_value = ".envvault", global = true)]
    pub vault_dir: String,

    /// Path to a keyfile for two-factor vault access
    #[arg(long, global = true)]
    pub keyfile: Option<String>,
}

/// All available subcommands.
#[derive(clap::Subcommand)]
pub enum Commands {
    /// Initialize a new vault (auto-imports .env)
    Init,

    /// Set a secret (add or update)
    Set {
        /// Secret name (e.g. DATABASE_URL)
        key: String,
        /// Secret value (omit for interactive prompt)
        value: Option<String>,
        /// Skip the shell-history warning for inline values
        #[arg(short, long)]
        force: bool,
    },

    /// Get a secret's value
    Get {
        /// Secret name
        key: String,
        /// Copy to clipboard (auto-clears after 30 seconds)
        #[arg(short = 'c', long)]
        clipboard: bool,
    },

    /// List all secrets
    List,

    /// Delete a secret
    Delete {
        /// Secret name
        key: String,
        /// Skip confirmation prompt
        #[arg(short, long)]
        force: bool,
    },

    /// Run a command with secrets injected
    Run {
        /// Command and arguments (after --)
        #[arg(trailing_var_arg = true, required = true)]
        command: Vec<String>,

        /// Start with a clean environment (only vault secrets, no inherited vars)
        #[arg(long)]
        clean_env: bool,

        /// Only inject these secrets (comma-separated)
        #[arg(long, value_delimiter = ',')]
        only: Option<Vec<String>>,

        /// Exclude these secrets (comma-separated)
        #[arg(long, value_delimiter = ',')]
        exclude: Option<Vec<String>>,

        /// Replace secret values in child process output with [REDACTED]
        #[arg(long)]
        redact_output: bool,

        /// Only allow these commands to run (comma-separated basenames)
        #[arg(long, value_delimiter = ',')]
        allowed_commands: Option<Vec<String>>,
    },

    /// Change the vault's master password
    RotateKey {
        /// Path to a new keyfile (or "none" to remove keyfile requirement)
        #[arg(long)]
        new_keyfile: Option<String>,
    },

    /// Export secrets to a file or stdout
    Export {
        /// Output format: env (default) or json
        #[arg(short, long, default_value = "env")]
        format: String,

        /// Output file path (prints to stdout if omitted)
        #[arg(short, long)]
        output: Option<String>,
    },

    /// Import secrets from a file
    Import {
        /// Path to the file to import
        file: String,

        /// Import format: env (default) or json (auto-detected from extension)
        #[arg(short, long)]
        format: Option<String>,

        /// Preview what would be imported without modifying the vault
        #[arg(long)]
        dry_run: bool,

        /// Skip secrets that already exist in the vault
        #[arg(long)]
        skip_existing: bool,
    },

    /// Manage authentication methods (keyring, keyfile)
    Auth {
        #[command(subcommand)]
        action: AuthAction,
    },

    /// Manage environments (list, clone, delete)
    Env {
        #[command(subcommand)]
        action: EnvAction,
    },

    /// Compare secrets between two environments
    Diff {
        /// Target environment to compare against
        target_env: String,
        /// Show secret values in diff output
        #[arg(long)]
        show_values: bool,
    },

    /// Open secrets in an editor (decrypts to temp file, re-encrypts on save)
    Edit,

    /// Show version and check for updates
    Version,

    /// Update envvault to the latest version
    Update,

    /// Generate shell completion scripts
    Completions {
        /// Shell to generate completions for (bash, zsh, fish, powershell)
        shell: String,
    },

    /// Scan files for leaked secrets (API keys, tokens, passwords)
    Scan {
        /// Exit with code 1 if secrets are found (for CI/CD)
        #[arg(long)]
        ci: bool,

        /// Directory to scan (default: current directory)
        #[arg(long)]
        dir: Option<String>,

        /// Path to a gitleaks-format TOML config for additional rules
        #[arg(long)]
        gitleaks_config: Option<String>,
    },

    /// Search secrets by name pattern (supports * and ? wildcards)
    Search {
        /// Glob pattern to match (e.g. DB_*, *_KEY, API_?)
        pattern: String,
    },

    /// View, export, or purge the audit log
    Audit {
        /// Subcommand: export, purge (omit to view entries)
        #[command(subcommand)]
        action: Option<AuditAction>,
        /// Number of entries to show (default: 50)
        #[arg(long, default_value = "50")]
        last: usize,
        /// Show entries since a duration ago (e.g. 7d, 24h, 30m)
        #[arg(long)]
        since: Option<String>,
    },
}

/// Audit subcommands for export and purge.
#[derive(clap::Subcommand)]
pub enum AuditAction {
    /// Export audit log to JSON or CSV
    Export {
        /// Output format: json (default) or csv
        #[arg(long, default_value = "json")]
        format: String,
        /// Output file path (prints to stdout if omitted)
        #[arg(short, long)]
        output: Option<String>,
    },
    /// Delete old audit entries
    Purge {
        /// Delete entries older than this duration (e.g. 90d, 24h)
        #[arg(long)]
        older_than: String,
    },
}

/// Auth subcommands for keyring and keyfile management.
#[derive(clap::Subcommand)]
pub enum AuthAction {
    /// Save vault password to OS keyring (auto-unlock)
    Keyring {
        /// Remove password from keyring instead of saving
        #[arg(long)]
        delete: bool,
    },

    /// Generate a new random keyfile
    KeyfileGenerate {
        /// Path for the keyfile (default: <vault_dir>/keyfile)
        path: Option<String>,
    },
}

/// Env subcommands for environment management.
#[derive(clap::Subcommand)]
pub enum EnvAction {
    /// List all vault environments
    List,

    /// Clone an environment to a new name
    Clone {
        /// Target environment name
        target: String,
        /// Prompt for a different password for the new vault
        #[arg(long)]
        new_password: bool,
    },

    /// Delete a vault environment
    Delete {
        /// Environment name to delete
        name: String,
        /// Skip confirmation prompt
        #[arg(short, long)]
        force: bool,
    },
}

// ---------------------------------------------------------------------------
// Shared helpers used by multiple commands
// ---------------------------------------------------------------------------

/// Get the vault password, trying in order:
/// 1. `ENVVAULT_PASSWORD` env var (CI/CD)
/// 2. OS keyring (if compiled with `keyring-store` feature)
/// 3. Interactive prompt
///
/// Returns `Zeroizing<String>` so the password is wiped from memory on drop.
pub fn prompt_password() -> Result<Zeroizing<String>> {
    prompt_password_for_vault(None)
}

/// Get the vault password with an optional vault path for keyring lookup.
///
/// Returns `Zeroizing<String>` so the password is wiped from memory on drop.
pub fn prompt_password_for_vault(vault_id: Option<&str>) -> Result<Zeroizing<String>> {
    // 1. Check the environment variable first (CI/CD friendly).
    if let Ok(pw) = std::env::var("ENVVAULT_PASSWORD") {
        if !pw.is_empty() {
            return Ok(Zeroizing::new(pw));
        }
    }

    // 2. Try the OS keyring (if feature enabled and vault_id provided).
    #[cfg(feature = "keyring-store")]
    if let Some(id) = vault_id {
        match crate::keyring::get_password(id) {
            Ok(Some(pw)) => return Ok(Zeroizing::new(pw)),
            Ok(None) => {} // No stored password, continue to prompt.
            Err(_) => {}   // Keyring unavailable, continue to prompt.
        }
    }

    // Suppress unused variable warning when keyring feature is off.
    #[cfg(not(feature = "keyring-store"))]
    let _ = vault_id;

    // 3. Fall back to interactive prompt.
    let pw = dialoguer::Password::new()
        .with_prompt("Enter vault password")
        .interact()
        .map_err(|e| EnvVaultError::CommandFailed(format!("password prompt: {e}")))?;
    Ok(Zeroizing::new(pw))
}

/// Prompt for a new password with confirmation (used during `init`).
///
/// Also respects `ENVVAULT_PASSWORD` for scripted/CI usage.
/// Enforces a minimum password length.
///
/// Returns `Zeroizing<String>` so the password is wiped from memory on drop.
pub fn prompt_new_password() -> Result<Zeroizing<String>> {
    // Check the environment variable first (CI/CD friendly).
    if let Ok(pw) = std::env::var("ENVVAULT_PASSWORD") {
        if !pw.is_empty() {
            if pw.len() < MIN_PASSWORD_LEN {
                return Err(EnvVaultError::CommandFailed(format!(
                    "password must be at least {MIN_PASSWORD_LEN} characters"
                )));
            }
            return Ok(Zeroizing::new(pw));
        }
    }

    loop {
        let password = dialoguer::Password::new()
            .with_prompt("Choose vault password")
            .with_confirmation(
                "Confirm vault password",
                "Passwords do not match, try again",
            )
            .interact()
            .map_err(|e| EnvVaultError::CommandFailed(format!("password prompt: {e}")))?;

        if password.len() < MIN_PASSWORD_LEN {
            output::warning(&format!(
                "Password must be at least {MIN_PASSWORD_LEN} characters. Try again."
            ));
            continue;
        }

        return Ok(Zeroizing::new(password));
    }
}

/// Build the full path to a vault file from the CLI arguments.
///
/// Example: `<cwd>/.envvault/dev.vault`
pub fn vault_path(cli: &Cli) -> Result<std::path::PathBuf> {
    let cwd = std::env::current_dir()?;
    let env = &cli.env;
    Ok(cwd.join(&cli.vault_dir).join(format!("{env}.vault")))
}

/// Load the keyfile bytes, checking in order:
/// 1. `--keyfile` CLI argument
/// 2. `keyfile_path` in `.envvault.toml`
/// 3. `keyfile_path` in global config
///
/// Returns `None` if no keyfile is configured anywhere.
pub fn load_keyfile(cli: &Cli) -> Result<Option<Vec<u8>>> {
    // 1. CLI argument takes priority.
    if let Some(path) = &cli.keyfile {
        let bytes = crate::crypto::keyfile::load_keyfile(std::path::Path::new(path))?;
        return Ok(Some(bytes));
    }

    // 2. Project-level config.
    if let Ok(cwd) = std::env::current_dir() {
        let settings = crate::config::Settings::load(&cwd).unwrap_or_default();
        if let Some(ref path) = settings.keyfile_path {
            let bytes = crate::crypto::keyfile::load_keyfile(std::path::Path::new(path))?;
            return Ok(Some(bytes));
        }
    }

    // 3. Global config.
    let global = crate::config::GlobalConfig::load();
    if let Some(ref path) = global.keyfile_path {
        let bytes = crate::crypto::keyfile::load_keyfile(std::path::Path::new(path))?;
        return Ok(Some(bytes));
    }

    Ok(None)
}

/// Validate that an environment name is safe and sensible.
///
/// Allowed: lowercase letters, digits, hyphens. Must not be empty
/// or start/end with a hyphen. Max length 64 characters.
/// This prevents accidental typos from silently creating new vault files.
pub fn validate_env_name(name: &str) -> Result<()> {
    if name.is_empty() {
        return Err(EnvVaultError::ConfigError(
            "environment name cannot be empty".into(),
        ));
    }

    if name.len() > 64 {
        return Err(EnvVaultError::ConfigError(
            "environment name cannot exceed 64 characters".into(),
        ));
    }

    if !name
        .chars()
        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
    {
        return Err(EnvVaultError::ConfigError(format!(
            "environment name '{name}' is invalid — only lowercase letters, digits, and hyphens are allowed"
        )));
    }

    if name.starts_with('-') || name.ends_with('-') {
        return Err(EnvVaultError::ConfigError(format!(
            "environment name '{name}' cannot start or end with a hyphen"
        )));
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn valid_env_names() {
        assert!(validate_env_name("dev").is_ok());
        assert!(validate_env_name("staging").is_ok());
        assert!(validate_env_name("prod").is_ok());
        assert!(validate_env_name("us-east-1").is_ok());
        assert!(validate_env_name("v2").is_ok());
    }

    #[test]
    fn rejects_empty_name() {
        assert!(validate_env_name("").is_err());
    }

    #[test]
    fn rejects_uppercase() {
        assert!(validate_env_name("Dev").is_err());
        assert!(validate_env_name("PROD").is_err());
    }

    #[test]
    fn rejects_special_chars() {
        assert!(validate_env_name("dev.test").is_err());
        assert!(validate_env_name("dev/test").is_err());
        assert!(validate_env_name("dev test").is_err());
        assert!(validate_env_name("dev_test").is_err());
    }

    #[test]
    fn rejects_leading_trailing_hyphens() {
        assert!(validate_env_name("-dev").is_err());
        assert!(validate_env_name("dev-").is_err());
    }

    #[test]
    fn rejects_too_long_name() {
        let long_name = "a".repeat(65);
        assert!(validate_env_name(&long_name).is_err());
    }
}