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 },
54
55 Get {
57 key: String,
59 },
60
61 List,
63
64 Delete {
66 key: String,
68 #[arg(short, long)]
70 force: bool,
71 },
72
73 Run {
75 #[arg(trailing_var_arg = true, required = true)]
77 command: Vec<String>,
78
79 #[arg(long)]
81 clean_env: bool,
82 },
83
84 RotateKey,
86
87 Export {
89 #[arg(short, long, default_value = "env")]
91 format: String,
92
93 #[arg(short, long)]
95 output: Option<String>,
96 },
97
98 Import {
100 file: String,
102
103 #[arg(short, long)]
105 format: Option<String>,
106 },
107
108 Auth {
110 #[command(subcommand)]
111 action: AuthAction,
112 },
113
114 Env {
116 #[command(subcommand)]
117 action: EnvAction,
118 },
119
120 Diff {
122 target_env: String,
124 #[arg(long)]
126 show_values: bool,
127 },
128
129 Edit,
131
132 Version,
134
135 Completions {
137 shell: String,
139 },
140
141 Audit {
143 #[arg(long, default_value = "50")]
145 last: usize,
146 #[arg(long)]
148 since: Option<String>,
149 },
150}
151
152#[derive(clap::Subcommand)]
154pub enum AuthAction {
155 Keyring {
157 #[arg(long)]
159 delete: bool,
160 },
161
162 KeyfileGenerate {
164 path: Option<String>,
166 },
167}
168
169#[derive(clap::Subcommand)]
171pub enum EnvAction {
172 List,
174
175 Clone {
177 target: String,
179 #[arg(long)]
181 new_password: bool,
182 },
183
184 Delete {
186 name: String,
188 #[arg(short, long)]
190 force: bool,
191 },
192}
193
194pub fn prompt_password() -> Result<Zeroizing<String>> {
205 prompt_password_for_vault(None)
206}
207
208pub fn prompt_password_for_vault(vault_id: Option<&str>) -> Result<Zeroizing<String>> {
212 if let Ok(pw) = std::env::var("ENVVAULT_PASSWORD") {
214 if !pw.is_empty() {
215 return Ok(Zeroizing::new(pw));
216 }
217 }
218
219 #[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) => {} Err(_) => {} }
227 }
228
229 #[cfg(not(feature = "keyring-store"))]
231 let _ = vault_id;
232
233 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
241pub fn prompt_new_password() -> Result<Zeroizing<String>> {
248 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
281pub 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
290pub 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
303pub 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}