envvault/cli/commands/
edit.rs1use 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
20pub 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 let tmp_path = write_temp_file(&secrets)?;
33
34 let editor = find_editor();
36
37 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 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 secure_delete(&tmp_path);
60
61 let mut new_secrets = parse_edited_content(&edited_content);
62
63 edited_content.zeroize();
65
66 let (added, removed, changed) = apply_changes(&mut store, &secrets, &new_secrets)?;
68
69 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
100fn 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 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 #[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
153fn find_editor() -> String {
160 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 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 if let Ok(editor) = std::env::var("VISUAL") {
181 if !editor.is_empty() {
182 return editor;
183 }
184 }
185
186 if let Ok(editor) = std::env::var("EDITOR") {
188 if !editor.is_empty() {
189 return editor;
190 }
191 }
192
193 "vi".to_string()
195}
196
197pub 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
208fn 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 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 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
244fn 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}