Skip to main content

envvault/cli/commands/
env_clone.rs

1//! `envvault env clone` — clone an environment's secrets to a new vault.
2
3use 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
13/// Execute `envvault env clone <target>`.
14pub 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    // Open source vault and decrypt all secrets.
31    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    // Determine the target password.
38    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    // Create the target vault with the same (or new) password.
46    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    // Copy all secrets.
56    let count = secrets.len();
57    for (name, value) in &secrets {
58        target_store.set_secret(name, value)?;
59    }
60    target_store.save()?;
61
62    // Zeroize plaintext secrets.
63    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        // Clone dev -> staging.
112        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        // Verify target has the same secrets.
125        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        // Attempting to create at an existing path should fail.
146        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        // Clone dev -> staging with a different password.
162        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        // Original password should NOT work on clone.
175        assert!(VaultStore::open(&staging_path, b"source-pass1", None).is_err());
176
177        // New password should work.
178        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}