Skip to main content

envvault/cli/commands/
edit.rs

1//! `envvault edit` — open secrets in an editor.
2//!
3//! Decrypts all secrets to a temporary file, launches `$VISUAL` / `$EDITOR` / `vi`,
4//! and applies any changes back to the vault on save.
5
6use std::collections::HashMap;
7use std::fs;
8use std::io::Write;
9use std::path::PathBuf;
10use std::process::Command;
11
12use zeroize::Zeroize;
13
14use crate::cli::env_parser::parse_env_line;
15use crate::cli::output;
16use crate::cli::{load_keyfile, prompt_password_for_vault, vault_path, Cli};
17use crate::errors::{EnvVaultError, Result};
18use crate::vault::VaultStore;
19
20/// Execute the `edit` command.
21pub fn execute(cli: &Cli) -> Result<()> {
22    let path = vault_path(cli)?;
23
24    let keyfile = load_keyfile(cli)?;
25    let vault_id = path.to_string_lossy();
26    let password = prompt_password_for_vault(Some(&vault_id))?;
27    let mut store = VaultStore::open(&path, password.as_bytes(), keyfile.as_deref())?;
28
29    let mut secrets = store.get_all_secrets()?;
30
31    // Write secrets to a temp file in KEY=VALUE format.
32    let tmp_path = write_temp_file(&secrets)?;
33
34    // Find the editor.
35    let editor = find_editor();
36
37    // Launch editor.
38    let status = Command::new(&editor)
39        .arg(&tmp_path)
40        .status()
41        .map_err(|e| EnvVaultError::EditorError(format!("failed to launch '{editor}': {e}")))?;
42
43    if !status.success() {
44        secure_delete(&tmp_path);
45        for v in secrets.values_mut() {
46            v.zeroize();
47        }
48        return Err(EnvVaultError::EditorError(format!(
49            "editor exited with code {}",
50            status.code().unwrap_or(-1)
51        )));
52    }
53
54    // Parse the edited file.
55    let mut edited_content = fs::read_to_string(&tmp_path)
56        .map_err(|e| EnvVaultError::EditorError(format!("failed to read edited file: {e}")))?;
57
58    // Securely wipe and delete temp file immediately.
59    secure_delete(&tmp_path);
60
61    let mut new_secrets = parse_edited_content(&edited_content);
62
63    // Zeroize the raw edited content — no longer needed.
64    edited_content.zeroize();
65
66    // Compute and apply changes.
67    let (added, removed, changed) = apply_changes(&mut store, &secrets, &new_secrets)?;
68
69    // Zeroize plaintext secrets from memory — no longer needed.
70    for v in secrets.values_mut() {
71        v.zeroize();
72    }
73    for v in new_secrets.values_mut() {
74        v.zeroize();
75    }
76
77    if added == 0 && removed == 0 && changed == 0 {
78        output::info("No changes detected.");
79        return Ok(());
80    }
81
82    store.save()?;
83
84    crate::audit::log_audit(
85        cli,
86        "edit",
87        None,
88        Some(&format!(
89            "{added} added, {removed} removed, {changed} changed"
90        )),
91    );
92
93    output::success(&format!(
94        "Edit complete: {added} added, {removed} removed, {changed} changed"
95    ));
96
97    Ok(())
98}
99
100/// Write secrets to a temp file in KEY=VALUE format.
101/// Returns the path to the temp file.
102fn write_temp_file(secrets: &HashMap<String, String>) -> Result<PathBuf> {
103    let mut sorted: Vec<(&String, &String)> = secrets.iter().collect();
104    sorted.sort_by_key(|(k, _)| *k);
105
106    // Build a unique temp file path using PID + timestamp.
107    let tmp_dir = std::env::temp_dir();
108    let filename = format!(
109        "envvault-edit-{}-{}.env",
110        std::process::id(),
111        chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
112    );
113    let tmp_path = tmp_dir.join(filename);
114
115    // Create the file with restrictive permissions atomically (no TOCTOU race).
116    #[cfg(unix)]
117    let mut file = {
118        use std::os::unix::fs::OpenOptionsExt;
119        fs::OpenOptions::new()
120            .write(true)
121            .create_new(true)
122            .mode(0o600)
123            .open(&tmp_path)
124            .map_err(|e| EnvVaultError::EditorError(format!("failed to create temp file: {e}")))?
125    };
126
127    #[cfg(not(unix))]
128    let mut file = fs::File::create(&tmp_path)
129        .map_err(|e| EnvVaultError::EditorError(format!("failed to create temp file: {e}")))?;
130
131    writeln!(file, "# EnvVault — edit secrets below (KEY=VALUE format)")?;
132    writeln!(file, "# Lines starting with '#' are ignored")?;
133    writeln!(file)?;
134
135    for (key, value) in &sorted {
136        if value.contains(' ')
137            || value.contains('#')
138            || value.contains('"')
139            || value.contains('\n')
140            || value.is_empty()
141        {
142            let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
143            writeln!(file, "{key}=\"{escaped}\"")?;
144        } else {
145            writeln!(file, "{key}={value}")?;
146        }
147    }
148
149    file.flush()?;
150    Ok(tmp_path)
151}
152
153/// Find the user's preferred editor, checking in order:
154/// 1. `.envvault.toml` `editor` field
155/// 2. Global config `editor` field
156/// 3. `$VISUAL` environment variable
157/// 4. `$EDITOR` environment variable
158/// 5. `"vi"` fallback
159fn find_editor() -> String {
160    // 1. Project-level config.
161    if let Ok(cwd) = std::env::current_dir() {
162        if let Ok(settings) = crate::config::Settings::load(&cwd) {
163            if let Some(editor) = settings.editor {
164                if !editor.is_empty() {
165                    return editor;
166                }
167            }
168        }
169    }
170
171    // 2. Global config.
172    let global = crate::config::GlobalConfig::load();
173    if let Some(editor) = global.editor {
174        if !editor.is_empty() {
175            return editor;
176        }
177    }
178
179    // 3. $VISUAL
180    if let Ok(editor) = std::env::var("VISUAL") {
181        if !editor.is_empty() {
182            return editor;
183        }
184    }
185
186    // 4. $EDITOR
187    if let Ok(editor) = std::env::var("EDITOR") {
188        if !editor.is_empty() {
189            return editor;
190        }
191    }
192
193    // 5. Fallback
194    "vi".to_string()
195}
196
197/// Parse edited content back into a key-value map.
198pub fn parse_edited_content(content: &str) -> HashMap<String, String> {
199    let mut map = HashMap::new();
200    for line in content.lines() {
201        if let Some((key, value)) = parse_env_line(line) {
202            map.insert(key.to_string(), value.to_string());
203        }
204    }
205    map
206}
207
208/// Apply changes between old and new secrets. Returns (added, removed, changed) counts.
209fn apply_changes(
210    store: &mut VaultStore,
211    old: &HashMap<String, String>,
212    new: &HashMap<String, String>,
213) -> Result<(usize, usize, usize)> {
214    let mut added = 0;
215    let mut removed = 0;
216    let mut changed = 0;
217
218    // Add or update secrets.
219    for (key, new_value) in new {
220        match old.get(key) {
221            Some(old_value) if old_value == new_value => {}
222            Some(_) => {
223                store.set_secret(key, new_value)?;
224                changed += 1;
225            }
226            None => {
227                store.set_secret(key, new_value)?;
228                added += 1;
229            }
230        }
231    }
232
233    // Remove deleted secrets.
234    for key in old.keys() {
235        if !new.contains_key(key) {
236            store.delete_secret(key)?;
237            removed += 1;
238        }
239    }
240
241    Ok((added, removed, changed))
242}
243
244/// Overwrite a file's contents with zeros before deleting it.
245/// This reduces the chance of secret recovery from disk.
246/// Best-effort: failures are silently ignored.
247fn secure_delete(path: &PathBuf) {
248    if let Ok(metadata) = fs::metadata(path) {
249        let len = metadata.len() as usize;
250        if len > 0 {
251            if let Ok(mut file) = fs::OpenOptions::new().write(true).open(path) {
252                let zeros = vec![0u8; len];
253                let _ = file.write_all(&zeros);
254                let _ = file.flush();
255            }
256        }
257    }
258    let _ = fs::remove_file(path);
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn parse_edited_content_basic() {
267        let content = "KEY=value\nOTHER=123\n# comment\n\n";
268        let map = parse_edited_content(content);
269        assert_eq!(map["KEY"], "value");
270        assert_eq!(map["OTHER"], "123");
271        assert_eq!(map.len(), 2);
272    }
273
274    #[test]
275    fn parse_edited_content_with_quotes() {
276        let content = "KEY=\"hello world\"\nOTHER='single'\n";
277        let map = parse_edited_content(content);
278        assert_eq!(map["KEY"], "hello world");
279        assert_eq!(map["OTHER"], "single");
280    }
281
282    #[test]
283    fn find_editor_respects_env() {
284        let editor = find_editor();
285        assert!(!editor.is_empty());
286    }
287
288    #[test]
289    fn write_temp_file_creates_file() {
290        let mut secrets = HashMap::new();
291        secrets.insert("A".into(), "1".into());
292        secrets.insert("B".into(), "has space".into());
293
294        let tmp_path = write_temp_file(&secrets).unwrap();
295        let content = fs::read_to_string(&tmp_path).unwrap();
296        assert!(content.contains("A=1"));
297        assert!(content.contains("B=\"has space\""));
298        let _ = fs::remove_file(&tmp_path);
299    }
300
301    #[test]
302    fn write_temp_file_sets_permissions() {
303        let secrets = HashMap::new();
304        let tmp_path = write_temp_file(&secrets).unwrap();
305
306        #[cfg(unix)]
307        {
308            use std::os::unix::fs::PermissionsExt;
309            let perms = fs::metadata(&tmp_path).unwrap().permissions();
310            assert_eq!(perms.mode() & 0o777, 0o600);
311        }
312
313        let _ = fs::remove_file(&tmp_path);
314    }
315}