Skip to main content

murk_cli/
vault.rs

1use std::fs::{self, File};
2use std::io::Write;
3use std::path::{Path, PathBuf};
4
5use fs2::FileExt;
6
7use crate::types::Vault;
8
9/// Errors that can occur during vault file operations.
10#[derive(Debug)]
11pub enum VaultError {
12    Io(std::io::Error),
13    Parse(String),
14}
15
16impl std::fmt::Display for VaultError {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        match self {
19            VaultError::Io(e) if e.kind() == std::io::ErrorKind::NotFound => {
20                write!(f, "vault file not found. Run `murk init` to create one")
21            }
22            VaultError::Io(e) => write!(f, "vault I/O error: {e}"),
23            VaultError::Parse(msg) => write!(f, "vault parse error: {msg}"),
24        }
25    }
26}
27
28impl From<std::io::Error> for VaultError {
29    fn from(e: std::io::Error) -> Self {
30        VaultError::Io(e)
31    }
32}
33
34/// Parse vault from a JSON string.
35///
36/// Rejects vaults with an unrecognized major version to prevent
37/// silently misinterpreting a newer format.
38pub fn parse(contents: &str) -> Result<Vault, VaultError> {
39    let vault: Vault = serde_json::from_str(contents).map_err(|e| {
40        VaultError::Parse(format!(
41            "invalid vault JSON: {e}. Vault may be corrupted — restore from git"
42        ))
43    })?;
44
45    // Accept any 2.x version (same major).
46    let major = vault.version.split('.').next().unwrap_or("");
47    if major != "2" {
48        return Err(VaultError::Parse(format!(
49            "unsupported vault version: {}. This build of murk supports version 2.x",
50            vault.version
51        )));
52    }
53
54    Ok(vault)
55}
56
57/// Read a .murk vault file.
58pub fn read(path: &Path) -> Result<Vault, VaultError> {
59    let contents = fs::read_to_string(path)?;
60    parse(&contents)
61}
62
63/// An exclusive advisory lock on a vault file.
64///
65/// Holds a `.murk.lock` file with an exclusive flock for the duration of a
66/// read-modify-write cycle. Dropped automatically when the guard goes out of scope.
67#[derive(Debug)]
68pub struct VaultLock {
69    _file: File,
70    _path: PathBuf,
71}
72
73/// Lock path for a given vault path (e.g. `.murk` → `.murk.lock`).
74fn lock_path(vault_path: &Path) -> PathBuf {
75    let mut p = vault_path.as_os_str().to_owned();
76    p.push(".lock");
77    PathBuf::from(p)
78}
79
80/// Acquire an exclusive advisory lock on the vault file.
81///
82/// Returns a guard that releases the lock when dropped. Use this around
83/// read-modify-write cycles to prevent concurrent writes from losing changes.
84pub fn lock(vault_path: &Path) -> Result<VaultLock, VaultError> {
85    let lp = lock_path(vault_path);
86
87    // Open lock file without following symlinks (race-safe on Unix).
88    #[cfg(unix)]
89    let file = {
90        use std::os::unix::fs::OpenOptionsExt;
91        fs::OpenOptions::new()
92            .create(true)
93            .write(true)
94            .truncate(true)
95            .custom_flags(libc::O_NOFOLLOW)
96            .open(&lp)?
97    };
98    #[cfg(not(unix))]
99    let file = {
100        // Fallback: check-then-open (still has TOCTOU on non-Unix).
101        if lp.is_symlink() {
102            return Err(VaultError::Io(std::io::Error::new(
103                std::io::ErrorKind::InvalidInput,
104                format!(
105                    "lock file is a symlink — refusing to follow: {}",
106                    lp.display()
107                ),
108            )));
109        }
110        File::create(&lp)?
111    };
112    file.lock_exclusive().map_err(|e| {
113        VaultError::Io(std::io::Error::new(
114            e.kind(),
115            format!("failed to acquire vault lock: {e}"),
116        ))
117    })?;
118    Ok(VaultLock {
119        _file: file,
120        _path: lp,
121    })
122}
123
124/// Write a vault to a .murk file as pretty-printed JSON.
125///
126/// Uses write-to-tempfile + rename for atomic writes — if the process is
127/// killed mid-write, the original file remains intact.
128pub fn write(path: &Path, vault: &Vault) -> Result<(), VaultError> {
129    let json = serde_json::to_string_pretty(vault)
130        .map_err(|e| VaultError::Parse(format!("failed to serialize vault: {e}")))?;
131
132    // Write to a sibling temp file, fsync, then atomically rename.
133    let dir = path.parent().unwrap_or(Path::new("."));
134    let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
135    tmp.write_all(json.as_bytes())?;
136    tmp.write_all(b"\n")?;
137    tmp.as_file().sync_all()?;
138    tmp.persist(path).map_err(|e| e.error)?;
139    Ok(())
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::types::{SchemaEntry, SecretEntry, VAULT_VERSION};
146    use std::collections::BTreeMap;
147
148    fn test_vault() -> Vault {
149        let mut schema = BTreeMap::new();
150        schema.insert(
151            "DATABASE_URL".into(),
152            SchemaEntry {
153                description: "postgres connection string".into(),
154                example: Some("postgres://user:pass@host/db".into()),
155                tags: vec![],
156                ..Default::default()
157            },
158        );
159
160        Vault {
161            version: VAULT_VERSION.into(),
162            created: "2026-02-27T00:00:00Z".into(),
163            vault_name: ".murk".into(),
164            repo: String::new(),
165            recipients: vec!["age1test".into()],
166            schema,
167            secrets: BTreeMap::new(),
168            meta: "encrypted-meta".into(),
169        }
170    }
171
172    #[test]
173    fn roundtrip_read_write() {
174        let dir = std::env::temp_dir().join("murk_test_vault_v2");
175        fs::create_dir_all(&dir).unwrap();
176        let path = dir.join("test.murk");
177
178        let mut vault = test_vault();
179        vault.secrets.insert(
180            "DATABASE_URL".into(),
181            SecretEntry {
182                shared: "encrypted-value".into(),
183                scoped: BTreeMap::new(),
184            },
185        );
186
187        write(&path, &vault).unwrap();
188        let read_vault = read(&path).unwrap();
189
190        assert_eq!(read_vault.version, VAULT_VERSION);
191        assert_eq!(read_vault.recipients[0], "age1test");
192        assert!(read_vault.schema.contains_key("DATABASE_URL"));
193        assert!(read_vault.secrets.contains_key("DATABASE_URL"));
194
195        fs::remove_dir_all(&dir).unwrap();
196    }
197
198    #[test]
199    fn schema_is_sorted() {
200        let dir = std::env::temp_dir().join("murk_test_sorted_v2");
201        fs::create_dir_all(&dir).unwrap();
202        let path = dir.join("test.murk");
203
204        let mut vault = test_vault();
205        vault.schema.insert(
206            "ZZZ_KEY".into(),
207            SchemaEntry {
208                description: "last".into(),
209                example: None,
210                tags: vec![],
211                ..Default::default()
212            },
213        );
214        vault.schema.insert(
215            "AAA_KEY".into(),
216            SchemaEntry {
217                description: "first".into(),
218                example: None,
219                tags: vec![],
220                ..Default::default()
221            },
222        );
223
224        write(&path, &vault).unwrap();
225        let contents = fs::read_to_string(&path).unwrap();
226
227        // BTreeMap ensures sorted output — AAA before DATABASE before ZZZ.
228        let aaa_pos = contents.find("AAA_KEY").unwrap();
229        let db_pos = contents.find("DATABASE_URL").unwrap();
230        let zzz_pos = contents.find("ZZZ_KEY").unwrap();
231        assert!(aaa_pos < db_pos);
232        assert!(db_pos < zzz_pos);
233
234        fs::remove_dir_all(&dir).unwrap();
235    }
236
237    #[test]
238    fn missing_file_errors() {
239        let result = read(Path::new("/tmp/null.murk"));
240        assert!(result.is_err());
241    }
242
243    #[test]
244    fn parse_invalid_json() {
245        let result = parse("not json at all");
246        assert!(result.is_err());
247        let err = result.unwrap_err();
248        let msg = err.to_string();
249        assert!(msg.contains("vault parse error"));
250        assert!(msg.contains("Vault may be corrupted"));
251    }
252
253    #[test]
254    fn parse_empty_string() {
255        let result = parse("");
256        assert!(result.is_err());
257    }
258
259    #[test]
260    fn parse_valid_json() {
261        let json = serde_json::to_string(&test_vault()).unwrap();
262        let result = parse(&json);
263        assert!(result.is_ok());
264        assert_eq!(result.unwrap().version, VAULT_VERSION);
265    }
266
267    #[test]
268    fn parse_rejects_unknown_major_version() {
269        let mut vault = test_vault();
270        vault.version = "99.0".into();
271        let json = serde_json::to_string(&vault).unwrap();
272        let result = parse(&json);
273        let err = result.unwrap_err().to_string();
274        assert!(err.contains("unsupported vault version: 99.0"));
275    }
276
277    #[test]
278    fn parse_accepts_minor_version_bump() {
279        let mut vault = test_vault();
280        vault.version = "2.1".into();
281        let json = serde_json::to_string(&vault).unwrap();
282        let result = parse(&json);
283        assert!(result.is_ok());
284    }
285
286    #[test]
287    fn error_display_not_found() {
288        let err = VaultError::Io(std::io::Error::new(
289            std::io::ErrorKind::NotFound,
290            "no such file",
291        ));
292        let msg = err.to_string();
293        assert!(msg.contains("vault file not found"));
294        assert!(msg.contains("murk init"));
295    }
296
297    #[test]
298    fn error_display_io() {
299        let err = VaultError::Io(std::io::Error::new(
300            std::io::ErrorKind::PermissionDenied,
301            "denied",
302        ));
303        let msg = err.to_string();
304        assert!(msg.contains("vault I/O error"));
305    }
306
307    #[test]
308    fn error_display_parse() {
309        let err = VaultError::Parse("bad data".into());
310        assert!(err.to_string().contains("vault parse error: bad data"));
311    }
312
313    #[test]
314    fn error_from_io() {
315        let io_err = std::io::Error::new(std::io::ErrorKind::Other, "test");
316        let vault_err: VaultError = io_err.into();
317        assert!(matches!(vault_err, VaultError::Io(_)));
318    }
319
320    #[test]
321    fn scoped_entries_roundtrip() {
322        let dir = std::env::temp_dir().join("murk_test_scoped_rt");
323        fs::create_dir_all(&dir).unwrap();
324        let path = dir.join("test.murk");
325
326        let mut vault = test_vault();
327        let mut scoped = BTreeMap::new();
328        scoped.insert("age1bob".into(), "encrypted-for-bob".into());
329
330        vault.secrets.insert(
331            "DATABASE_URL".into(),
332            SecretEntry {
333                shared: "encrypted-value".into(),
334                scoped,
335            },
336        );
337
338        write(&path, &vault).unwrap();
339        let read_vault = read(&path).unwrap();
340
341        let entry = &read_vault.secrets["DATABASE_URL"];
342        assert_eq!(entry.scoped["age1bob"], "encrypted-for-bob");
343
344        fs::remove_dir_all(&dir).unwrap();
345    }
346
347    #[test]
348    fn lock_creates_lock_file() {
349        let dir = std::env::temp_dir().join("murk_test_lock_create");
350        let _ = fs::remove_dir_all(&dir);
351        fs::create_dir_all(&dir).unwrap();
352        let vault_path = dir.join("test.murk");
353
354        let _lock = lock(&vault_path).unwrap();
355        assert!(lock_path(&vault_path).exists());
356
357        drop(_lock);
358        fs::remove_dir_all(&dir).unwrap();
359    }
360
361    #[cfg(unix)]
362    #[test]
363    fn lock_rejects_symlink() {
364        let dir = std::env::temp_dir().join("murk_test_lock_symlink");
365        let _ = fs::remove_dir_all(&dir);
366        fs::create_dir_all(&dir).unwrap();
367        let vault_path = dir.join("test.murk");
368        let lp = lock_path(&vault_path);
369
370        // Create a symlink where the lock file would go.
371        std::os::unix::fs::symlink("/tmp/evil", &lp).unwrap();
372
373        let result = lock(&vault_path);
374        assert!(result.is_err());
375        let msg = result.unwrap_err().to_string();
376        // On Unix with O_NOFOLLOW, we get a "too many levels of symbolic links" error.
377        assert!(
378            msg.contains("symlink") || msg.contains("symbolic link"),
379            "unexpected error: {msg}"
380        );
381
382        fs::remove_dir_all(&dir).unwrap();
383    }
384
385    #[test]
386    fn write_is_atomic() {
387        let dir = std::env::temp_dir().join("murk_test_write_atomic");
388        let _ = fs::remove_dir_all(&dir);
389        fs::create_dir_all(&dir).unwrap();
390        let path = dir.join("test.murk");
391
392        let vault = test_vault();
393        write(&path, &vault).unwrap();
394
395        // File should exist and be valid JSON.
396        let contents = fs::read_to_string(&path).unwrap();
397        let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
398        assert_eq!(parsed["version"], VAULT_VERSION);
399
400        // Overwrite with a new vault — should atomically replace.
401        let mut vault2 = test_vault();
402        vault2.vault_name = "updated.murk".into();
403        write(&path, &vault2).unwrap();
404        let contents2 = fs::read_to_string(&path).unwrap();
405        assert!(contents2.contains("updated.murk"));
406
407        fs::remove_dir_all(&dir).unwrap();
408    }
409
410    #[test]
411    fn schema_entry_timestamps_roundtrip() {
412        let dir = std::env::temp_dir().join("murk_test_timestamps");
413        let _ = fs::remove_dir_all(&dir);
414        fs::create_dir_all(&dir).unwrap();
415        let path = dir.join("test.murk");
416
417        let mut vault = test_vault();
418        vault.schema.insert(
419            "TIMED_KEY".into(),
420            SchemaEntry {
421                description: "has timestamps".into(),
422                created: Some("2026-03-29T00:00:00Z".into()),
423                updated: Some("2026-03-29T12:00:00Z".into()),
424                ..Default::default()
425            },
426        );
427
428        write(&path, &vault).unwrap();
429        let read_vault = read(&path).unwrap();
430        let entry = &read_vault.schema["TIMED_KEY"];
431        assert_eq!(entry.created.as_deref(), Some("2026-03-29T00:00:00Z"));
432        assert_eq!(entry.updated.as_deref(), Some("2026-03-29T12:00:00Z"));
433
434        fs::remove_dir_all(&dir).unwrap();
435    }
436
437    #[test]
438    fn schema_entry_without_timestamps_roundtrips() {
439        let dir = std::env::temp_dir().join("murk_test_no_timestamps");
440        let _ = fs::remove_dir_all(&dir);
441        fs::create_dir_all(&dir).unwrap();
442        let path = dir.join("test.murk");
443
444        let mut vault = test_vault();
445        vault.schema.insert(
446            "LEGACY".into(),
447            SchemaEntry {
448                description: "no timestamps".into(),
449                ..Default::default()
450            },
451        );
452
453        write(&path, &vault).unwrap();
454        let contents = fs::read_to_string(&path).unwrap();
455        // Schema timestamps should be omitted from JSON when None.
456        // Check that the LEGACY entry block doesn't contain timestamp fields.
457        let legacy_block = &contents[contents.find("LEGACY").unwrap()..];
458        let block_end = legacy_block.find('}').unwrap();
459        let legacy_block = &legacy_block[..block_end];
460        assert!(
461            !legacy_block.contains("created"),
462            "LEGACY entry should not have created timestamp"
463        );
464        assert!(
465            !legacy_block.contains("updated"),
466            "LEGACY entry should not have updated timestamp"
467        );
468
469        let read_vault = read(&path).unwrap();
470        assert!(read_vault.schema["LEGACY"].created.is_none());
471        assert!(read_vault.schema["LEGACY"].updated.is_none());
472
473        fs::remove_dir_all(&dir).unwrap();
474    }
475}