envvault/cli/commands/
env_clone.rs1use zeroize::Zeroize;
4
5use crate::cli::output;
6use crate::cli::{
7 load_keyfile, prompt_new_password, prompt_password_for_vault, validate_env_name, Cli,
8};
9use crate::config::Settings;
10use crate::errors::{EnvVaultError, Result};
11use crate::vault::VaultStore;
12
13pub fn execute(cli: &Cli, target: &str, new_password: bool) -> Result<()> {
15 validate_env_name(target)?;
16
17 let cwd = std::env::current_dir()?;
18 let vault_dir = cwd.join(&cli.vault_dir);
19 let env = &cli.env;
20 let source_path = vault_dir.join(format!("{env}.vault"));
21 let target_path = vault_dir.join(format!("{target}.vault"));
22
23 if !source_path.exists() {
24 return Err(EnvVaultError::EnvironmentNotFound(cli.env.clone()));
25 }
26 if target_path.exists() {
27 return Err(EnvVaultError::VaultAlreadyExists(target_path));
28 }
29
30 let keyfile = load_keyfile(cli)?;
32 let vault_id = source_path.to_string_lossy();
33 let password = prompt_password_for_vault(Some(&vault_id))?;
34 let source = VaultStore::open(&source_path, password.as_bytes(), keyfile.as_deref())?;
35 let mut secrets = source.get_all_secrets()?;
36
37 let target_pw = if new_password {
39 output::info("Choose a password for the new vault.");
40 prompt_new_password()?
41 } else {
42 password
43 };
44
45 let settings = Settings::load(&cwd)?;
47 let mut target_store = VaultStore::create(
48 &target_path,
49 target_pw.as_bytes(),
50 target,
51 Some(&settings.argon2_params()),
52 keyfile.as_deref(),
53 )?;
54
55 let count = secrets.len();
57 for (name, value) in &secrets {
58 target_store.set_secret(name, value)?;
59 }
60 target_store.save()?;
61
62 for value in secrets.values_mut() {
64 value.zeroize();
65 }
66
67 crate::audit::log_audit(
68 cli,
69 "env-clone",
70 None,
71 Some(&format!("{count} secrets, {env} -> {target}")),
72 );
73
74 output::success(&format!(
75 "Cloned {} secrets from '{}' to '{}' environment",
76 count, cli.env, target
77 ));
78
79 Ok(())
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85 use std::collections::HashMap;
86
87 fn create_test_vault(
88 dir: &std::path::Path,
89 env: &str,
90 password: &str,
91 secrets: &HashMap<String, String>,
92 ) {
93 let vault_path = dir.join(format!("{env}.vault"));
94 let mut store =
95 VaultStore::create(&vault_path, password.as_bytes(), env, None, None).unwrap();
96 for (k, v) in secrets {
97 store.set_secret(k, v).unwrap();
98 }
99 store.save().unwrap();
100 }
101
102 #[test]
103 fn clone_copies_all_secrets() {
104 let dir = tempfile::TempDir::new().unwrap();
105 let mut secrets = HashMap::new();
106 secrets.insert("DB_URL".into(), "postgres://localhost".into());
107 secrets.insert("API_KEY".into(), "secret123".into());
108
109 create_test_vault(dir.path(), "dev", "testpassword1", &secrets);
110
111 let staging_path = dir.path().join("staging.vault");
113 let source_path = dir.path().join("dev.vault");
114 let source = VaultStore::open(&source_path, b"testpassword1", None).unwrap();
115 let source_secrets = source.get_all_secrets().unwrap();
116
117 let mut target =
118 VaultStore::create(&staging_path, b"testpassword1", "staging", None, None).unwrap();
119 for (k, v) in &source_secrets {
120 target.set_secret(k, v).unwrap();
121 }
122 target.save().unwrap();
123
124 let reopened = VaultStore::open(&staging_path, b"testpassword1", None).unwrap();
126 let target_secrets = reopened.get_all_secrets().unwrap();
127 assert_eq!(target_secrets.len(), 2);
128 assert_eq!(target_secrets["DB_URL"], "postgres://localhost");
129 assert_eq!(target_secrets["API_KEY"], "secret123");
130 }
131
132 #[test]
133 fn clone_rejects_invalid_target_name() {
134 let result = validate_env_name("INVALID");
135 assert!(result.is_err());
136 }
137
138 #[test]
139 fn clone_fails_if_target_exists() {
140 let dir = tempfile::TempDir::new().unwrap();
141 let secrets = HashMap::new();
142 create_test_vault(dir.path(), "dev", "testpassword1", &secrets);
143 create_test_vault(dir.path(), "staging", "testpassword1", &secrets);
144
145 let target_path = dir.path().join("staging.vault");
147 let result = VaultStore::create(&target_path, b"testpassword1", "staging", None, None);
148 assert!(
149 result.is_err(),
150 "should fail when target vault already exists"
151 );
152 }
153
154 #[test]
155 fn clone_with_different_password() {
156 let dir = tempfile::TempDir::new().unwrap();
157 let mut secrets = HashMap::new();
158 secrets.insert("DB_URL".into(), "postgres://localhost".into());
159 create_test_vault(dir.path(), "dev", "source-pass1", &secrets);
160
161 let source_path = dir.path().join("dev.vault");
163 let staging_path = dir.path().join("staging.vault");
164 let source = VaultStore::open(&source_path, b"source-pass1", None).unwrap();
165 let source_secrets = source.get_all_secrets().unwrap();
166
167 let mut target =
168 VaultStore::create(&staging_path, b"target-pass2", "staging", None, None).unwrap();
169 for (k, v) in &source_secrets {
170 target.set_secret(k, v).unwrap();
171 }
172 target.save().unwrap();
173
174 assert!(VaultStore::open(&staging_path, b"source-pass1", None).is_err());
176
177 let reopened = VaultStore::open(&staging_path, b"target-pass2", None).unwrap();
179 assert_eq!(
180 reopened.get_secret("DB_URL").unwrap(),
181 "postgres://localhost"
182 );
183 }
184}