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