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 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
18pub 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 let tmp_path = write_temp_file(&secrets)?;
31
32 let editor = find_editor();
34
35 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 let edited_content = fs::read_to_string(&tmp_path)
51 .map_err(|e| EnvVaultError::EditorError(format!("failed to read edited file: {e}")))?;
52
53 secure_delete(&tmp_path);
55
56 let new_secrets = parse_edited_content(&edited_content);
57
58 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
84fn 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 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 #[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
137fn 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
154pub 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
165fn 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 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 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
201fn 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}