1pub 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
14const MIN_PASSWORD_LEN: usize = 8;
16
17#[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 #[arg(short, long, default_value = "dev", global = true)]
30 pub env: String,
31
32 #[arg(long, default_value = ".envvault", global = true)]
34 pub vault_dir: String,
35
36 #[arg(long, global = true)]
38 pub keyfile: Option<String>,
39}
40
41#[derive(clap::Subcommand)]
43pub enum Commands {
44 Init,
46
47 Set {
49 key: String,
51 value: Option<String>,
53 #[arg(short, long)]
55 force: bool,
56 },
57
58 Get {
60 key: String,
62 #[arg(short = 'c', long)]
64 clipboard: bool,
65 },
66
67 List,
69
70 Delete {
72 key: String,
74 #[arg(short, long)]
76 force: bool,
77 },
78
79 Run {
81 #[arg(trailing_var_arg = true, required = true)]
83 command: Vec<String>,
84
85 #[arg(long)]
87 clean_env: bool,
88
89 #[arg(long, value_delimiter = ',')]
91 only: Option<Vec<String>>,
92
93 #[arg(long, value_delimiter = ',')]
95 exclude: Option<Vec<String>>,
96
97 #[arg(long)]
99 redact_output: bool,
100
101 #[arg(long, value_delimiter = ',')]
103 allowed_commands: Option<Vec<String>>,
104 },
105
106 RotateKey {
108 #[arg(long)]
110 new_keyfile: Option<String>,
111 },
112
113 Export {
115 #[arg(short, long, default_value = "env")]
117 format: String,
118
119 #[arg(short, long)]
121 output: Option<String>,
122 },
123
124 Import {
126 file: String,
128
129 #[arg(short, long)]
131 format: Option<String>,
132
133 #[arg(long)]
135 dry_run: bool,
136
137 #[arg(long)]
139 skip_existing: bool,
140 },
141
142 Auth {
144 #[command(subcommand)]
145 action: AuthAction,
146 },
147
148 Env {
150 #[command(subcommand)]
151 action: EnvAction,
152 },
153
154 Diff {
156 target_env: String,
158 #[arg(long)]
160 show_values: bool,
161 },
162
163 Edit,
165
166 Version,
168
169 Update,
171
172 Completions {
174 shell: String,
176 },
177
178 Scan {
180 #[arg(long)]
182 ci: bool,
183
184 #[arg(long)]
186 dir: Option<String>,
187
188 #[arg(long)]
190 gitleaks_config: Option<String>,
191 },
192
193 Search {
195 pattern: String,
197 },
198
199 Audit {
201 #[command(subcommand)]
203 action: Option<AuditAction>,
204 #[arg(long, default_value = "50")]
206 last: usize,
207 #[arg(long)]
209 since: Option<String>,
210 },
211}
212
213#[derive(clap::Subcommand)]
215pub enum AuditAction {
216 Export {
218 #[arg(long, default_value = "json")]
220 format: String,
221 #[arg(short, long)]
223 output: Option<String>,
224 },
225 Purge {
227 #[arg(long)]
229 older_than: String,
230 },
231}
232
233#[derive(clap::Subcommand)]
235pub enum AuthAction {
236 Keyring {
238 #[arg(long)]
240 delete: bool,
241 },
242
243 KeyfileGenerate {
245 path: Option<String>,
247 },
248}
249
250#[derive(clap::Subcommand)]
252pub enum EnvAction {
253 List,
255
256 Clone {
258 target: String,
260 #[arg(long)]
262 new_password: bool,
263 },
264
265 Delete {
267 name: String,
269 #[arg(short, long)]
271 force: bool,
272 },
273}
274
275pub fn prompt_password() -> Result<Zeroizing<String>> {
286 prompt_password_for_vault(None)
287}
288
289pub fn prompt_password_for_vault(vault_id: Option<&str>) -> Result<Zeroizing<String>> {
293 if let Ok(pw) = std::env::var("ENVVAULT_PASSWORD") {
295 if !pw.is_empty() {
296 return Ok(Zeroizing::new(pw));
297 }
298 }
299
300 #[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) => {} Err(_) => {} }
308 }
309
310 #[cfg(not(feature = "keyring-store"))]
312 let _ = vault_id;
313
314 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
322pub fn prompt_new_password() -> Result<Zeroizing<String>> {
329 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
362pub 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
371pub fn load_keyfile(cli: &Cli) -> Result<Option<Vec<u8>>> {
378 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 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 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
403pub 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}