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.
58///
59/// Rejects symlinks at the vault path to prevent a local attacker from
60/// redirecting vault operations to a different project's vault (and thus
61/// triggering auto key-file lookup against the attacker-controlled path).
62pub fn read(path: &Path) -> Result<Vault, VaultError> {
63    Ok(read_with_raw(path)?.0)
64}
65
66/// Read a .murk vault file and return both the parsed vault and the raw bytes.
67///
68/// Use this when a caller needs the raw file contents (e.g. for computing a
69/// content-addressed codename via `codename::from_bytes`) and must NOT bypass
70/// the symlink rejection and version check that `read` enforces. Callers
71/// should always prefer this or `read` over calling `fs::read(path)` directly.
72pub fn read_with_raw(path: &Path) -> Result<(Vault, Vec<u8>), VaultError> {
73    if path.is_symlink() {
74        return Err(VaultError::Io(std::io::Error::new(
75            std::io::ErrorKind::InvalidInput,
76            format!(
77                "vault file is a symlink — refusing to follow for security: {}",
78                path.display()
79            ),
80        )));
81    }
82    let contents = fs::read_to_string(path)?;
83    let vault = parse(&contents)?;
84    Ok((vault, contents.into_bytes()))
85}
86
87/// An exclusive advisory lock on a vault file.
88///
89/// Holds a `.murk.lock` file with an exclusive flock for the duration of a
90/// read-modify-write cycle. Dropped automatically when the guard goes out of scope.
91#[derive(Debug)]
92pub struct VaultLock {
93    _file: File,
94    _path: PathBuf,
95}
96
97/// Lock path for a given vault path (e.g. `.murk` → `.murk.lock`).
98fn lock_path(vault_path: &Path) -> PathBuf {
99    let mut p = vault_path.as_os_str().to_owned();
100    p.push(".lock");
101    PathBuf::from(p)
102}
103
104/// Acquire an exclusive advisory lock on the vault file.
105///
106/// Returns a guard that releases the lock when dropped. Use this around
107/// read-modify-write cycles to prevent concurrent writes from losing changes.
108pub fn lock(vault_path: &Path) -> Result<VaultLock, VaultError> {
109    let lp = lock_path(vault_path);
110
111    // Open lock file without following symlinks (race-safe on Unix).
112    #[cfg(unix)]
113    let file = {
114        use std::os::unix::fs::OpenOptionsExt;
115        fs::OpenOptions::new()
116            .create(true)
117            .write(true)
118            .truncate(true)
119            .custom_flags(libc::O_NOFOLLOW)
120            .open(&lp)?
121    };
122    #[cfg(not(unix))]
123    let file = {
124        // Fallback: check-then-open (still has TOCTOU on non-Unix).
125        if lp.is_symlink() {
126            return Err(VaultError::Io(std::io::Error::new(
127                std::io::ErrorKind::InvalidInput,
128                format!(
129                    "lock file is a symlink — refusing to follow: {}",
130                    lp.display()
131                ),
132            )));
133        }
134        File::create(&lp)?
135    };
136    file.lock_exclusive().map_err(|e| {
137        VaultError::Io(std::io::Error::new(
138            e.kind(),
139            format!("failed to acquire vault lock: {e}"),
140        ))
141    })?;
142    Ok(VaultLock {
143        _file: file,
144        _path: lp,
145    })
146}
147
148/// Write a vault to a .murk file as pretty-printed JSON.
149///
150/// Uses write-to-tempfile + rename for atomic writes — if the process is
151/// killed mid-write, the original file remains intact.
152pub fn write(path: &Path, vault: &Vault) -> Result<(), VaultError> {
153    let json = serde_json::to_string_pretty(vault)
154        .map_err(|e| VaultError::Parse(format!("failed to serialize vault: {e}")))?;
155
156    // Write to a sibling temp file, fsync, then atomically rename.
157    let dir = path.parent().unwrap_or(Path::new("."));
158    let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
159
160    // Restrict temp file permissions before writing plaintext JSON.
161    #[cfg(unix)]
162    {
163        use std::os::unix::fs::PermissionsExt;
164        tmp.as_file()
165            .set_permissions(fs::Permissions::from_mode(0o600))?;
166    }
167
168    tmp.write_all(json.as_bytes())?;
169    tmp.write_all(b"\n")?;
170    tmp.as_file().sync_all()?;
171    tmp.persist(path).map_err(|e| e.error)?;
172
173    // Fsync the parent directory so the rename is durable across power loss.
174    #[cfg(unix)]
175    {
176        if let Ok(d) = File::open(dir) {
177            let _ = d.sync_all();
178        }
179    }
180
181    Ok(())
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::types::{SchemaEntry, SecretEntry, VAULT_VERSION};
188    use std::collections::BTreeMap;
189
190    fn test_vault() -> Vault {
191        let mut schema = BTreeMap::new();
192        schema.insert(
193            "DATABASE_URL".into(),
194            SchemaEntry {
195                description: "postgres connection string".into(),
196                example: Some("postgres://user:pass@host/db".into()),
197                tags: vec![],
198                ..Default::default()
199            },
200        );
201
202        Vault {
203            version: VAULT_VERSION.into(),
204            created: "2026-02-27T00:00:00Z".into(),
205            vault_name: ".murk".into(),
206            repo: String::new(),
207            recipients: vec!["age1test".into()],
208            schema,
209            policy: None,
210            secrets: BTreeMap::new(),
211            meta: "encrypted-meta".into(),
212        }
213    }
214
215    #[test]
216    fn roundtrip_read_write() {
217        let dir = std::env::temp_dir().join("murk_test_vault_v2");
218        fs::create_dir_all(&dir).unwrap();
219        let path = dir.join("test.murk");
220
221        let mut vault = test_vault();
222        vault.secrets.insert(
223            "DATABASE_URL".into(),
224            SecretEntry {
225                shared: "encrypted-value".into(),
226                private: BTreeMap::new(),
227                grouped: std::collections::BTreeMap::default(),
228            },
229        );
230
231        write(&path, &vault).unwrap();
232        let read_vault = read(&path).unwrap();
233
234        assert_eq!(read_vault.version, VAULT_VERSION);
235        assert_eq!(read_vault.recipients[0], "age1test");
236        assert!(read_vault.schema.contains_key("DATABASE_URL"));
237        assert!(read_vault.secrets.contains_key("DATABASE_URL"));
238
239        fs::remove_dir_all(&dir).unwrap();
240    }
241
242    #[test]
243    fn schema_is_sorted() {
244        let dir = std::env::temp_dir().join("murk_test_sorted_v2");
245        fs::create_dir_all(&dir).unwrap();
246        let path = dir.join("test.murk");
247
248        let mut vault = test_vault();
249        vault.schema.insert(
250            "ZZZ_KEY".into(),
251            SchemaEntry {
252                description: "last".into(),
253                example: None,
254                tags: vec![],
255                ..Default::default()
256            },
257        );
258        vault.schema.insert(
259            "AAA_KEY".into(),
260            SchemaEntry {
261                description: "first".into(),
262                example: None,
263                tags: vec![],
264                ..Default::default()
265            },
266        );
267
268        write(&path, &vault).unwrap();
269        let contents = fs::read_to_string(&path).unwrap();
270
271        // BTreeMap ensures sorted output — AAA before DATABASE before ZZZ.
272        let aaa_pos = contents.find("AAA_KEY").unwrap();
273        let db_pos = contents.find("DATABASE_URL").unwrap();
274        let zzz_pos = contents.find("ZZZ_KEY").unwrap();
275        assert!(aaa_pos < db_pos);
276        assert!(db_pos < zzz_pos);
277
278        fs::remove_dir_all(&dir).unwrap();
279    }
280
281    #[test]
282    fn missing_file_errors() {
283        let result = read(Path::new("/tmp/null.murk"));
284        assert!(result.is_err());
285    }
286
287    #[test]
288    fn parse_invalid_json() {
289        let result = parse("not json at all");
290        assert!(result.is_err());
291        let err = result.unwrap_err();
292        let msg = err.to_string();
293        assert!(msg.contains("vault parse error"));
294        assert!(msg.contains("Vault may be corrupted"));
295    }
296
297    #[test]
298    fn parse_empty_string() {
299        let result = parse("");
300        assert!(result.is_err());
301    }
302
303    #[test]
304    fn parse_valid_json() {
305        let json = serde_json::to_string(&test_vault()).unwrap();
306        let result = parse(&json);
307        assert!(result.is_ok());
308        assert_eq!(result.unwrap().version, VAULT_VERSION);
309    }
310
311    #[test]
312    fn parse_rejects_unknown_major_version() {
313        let mut vault = test_vault();
314        vault.version = "99.0".into();
315        let json = serde_json::to_string(&vault).unwrap();
316        let result = parse(&json);
317        let err = result.unwrap_err().to_string();
318        assert!(err.contains("unsupported vault version: 99.0"));
319    }
320
321    #[test]
322    fn parse_accepts_minor_version_bump() {
323        let mut vault = test_vault();
324        vault.version = "2.1".into();
325        let json = serde_json::to_string(&vault).unwrap();
326        let result = parse(&json);
327        assert!(result.is_ok());
328    }
329
330    #[test]
331    fn error_display_not_found() {
332        let err = VaultError::Io(std::io::Error::new(
333            std::io::ErrorKind::NotFound,
334            "no such file",
335        ));
336        let msg = err.to_string();
337        assert!(msg.contains("vault file not found"));
338        assert!(msg.contains("murk init"));
339    }
340
341    #[test]
342    fn error_display_io() {
343        let err = VaultError::Io(std::io::Error::new(
344            std::io::ErrorKind::PermissionDenied,
345            "denied",
346        ));
347        let msg = err.to_string();
348        assert!(msg.contains("vault I/O error"));
349    }
350
351    #[test]
352    fn error_display_parse() {
353        let err = VaultError::Parse("bad data".into());
354        assert!(err.to_string().contains("vault parse error: bad data"));
355    }
356
357    #[test]
358    fn error_from_io() {
359        let io_err = std::io::Error::other("test");
360        let vault_err: VaultError = io_err.into();
361        assert!(matches!(vault_err, VaultError::Io(_)));
362    }
363
364    #[test]
365    fn scoped_entries_roundtrip() {
366        let dir = std::env::temp_dir().join("murk_test_scoped_rt");
367        fs::create_dir_all(&dir).unwrap();
368        let path = dir.join("test.murk");
369
370        let mut vault = test_vault();
371        let mut private = BTreeMap::new();
372        private.insert("age1bob".into(), "encrypted-for-bob".into());
373
374        vault.secrets.insert(
375            "DATABASE_URL".into(),
376            SecretEntry {
377                shared: "encrypted-value".into(),
378                private,
379                grouped: std::collections::BTreeMap::default(),
380            },
381        );
382
383        write(&path, &vault).unwrap();
384        let read_vault = read(&path).unwrap();
385
386        let entry = &read_vault.secrets["DATABASE_URL"];
387        assert_eq!(entry.private["age1bob"], "encrypted-for-bob");
388
389        fs::remove_dir_all(&dir).unwrap();
390    }
391
392    #[test]
393    fn lock_creates_lock_file() {
394        let dir = std::env::temp_dir().join("murk_test_lock_create");
395        let _ = fs::remove_dir_all(&dir);
396        fs::create_dir_all(&dir).unwrap();
397        let vault_path = dir.join("test.murk");
398
399        let lock = lock(&vault_path).unwrap();
400        assert!(lock_path(&vault_path).exists());
401
402        drop(lock);
403        fs::remove_dir_all(&dir).unwrap();
404    }
405
406    #[cfg(unix)]
407    #[test]
408    fn lock_rejects_symlink() {
409        let dir = std::env::temp_dir().join("murk_test_lock_symlink");
410        let _ = fs::remove_dir_all(&dir);
411        fs::create_dir_all(&dir).unwrap();
412        let vault_path = dir.join("test.murk");
413        let lp = lock_path(&vault_path);
414
415        // Create a symlink where the lock file would go.
416        std::os::unix::fs::symlink("/tmp/evil", &lp).unwrap();
417
418        let result = lock(&vault_path);
419        assert!(result.is_err());
420        let msg = result.unwrap_err().to_string();
421        // On Unix with O_NOFOLLOW, we get a "too many levels of symbolic links" error.
422        assert!(
423            msg.contains("symlink") || msg.contains("symbolic link"),
424            "unexpected error: {msg}"
425        );
426
427        fs::remove_dir_all(&dir).unwrap();
428    }
429
430    #[test]
431    fn write_is_atomic() {
432        let dir = std::env::temp_dir().join("murk_test_write_atomic");
433        let _ = fs::remove_dir_all(&dir);
434        fs::create_dir_all(&dir).unwrap();
435        let path = dir.join("test.murk");
436
437        let vault = test_vault();
438        write(&path, &vault).unwrap();
439
440        // File should exist and be valid JSON.
441        let contents = fs::read_to_string(&path).unwrap();
442        let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
443        assert_eq!(parsed["version"], VAULT_VERSION);
444
445        // Overwrite with a new vault — should atomically replace.
446        let mut vault2 = test_vault();
447        vault2.vault_name = "updated.murk".into();
448        write(&path, &vault2).unwrap();
449        let contents2 = fs::read_to_string(&path).unwrap();
450        assert!(contents2.contains("updated.murk"));
451
452        fs::remove_dir_all(&dir).unwrap();
453    }
454
455    #[test]
456    fn schema_entry_timestamps_roundtrip() {
457        let dir = std::env::temp_dir().join("murk_test_timestamps");
458        let _ = fs::remove_dir_all(&dir);
459        fs::create_dir_all(&dir).unwrap();
460        let path = dir.join("test.murk");
461
462        let mut vault = test_vault();
463        vault.schema.insert(
464            "TIMED_KEY".into(),
465            SchemaEntry {
466                description: "has timestamps".into(),
467                created: Some("2026-03-29T00:00:00Z".into()),
468                updated: Some("2026-03-29T12:00:00Z".into()),
469                ..Default::default()
470            },
471        );
472
473        write(&path, &vault).unwrap();
474        let read_vault = read(&path).unwrap();
475        let entry = &read_vault.schema["TIMED_KEY"];
476        assert_eq!(entry.created.as_deref(), Some("2026-03-29T00:00:00Z"));
477        assert_eq!(entry.updated.as_deref(), Some("2026-03-29T12:00:00Z"));
478
479        fs::remove_dir_all(&dir).unwrap();
480    }
481
482    #[test]
483    fn schema_entry_without_timestamps_roundtrips() {
484        let dir = std::env::temp_dir().join("murk_test_no_timestamps");
485        let _ = fs::remove_dir_all(&dir);
486        fs::create_dir_all(&dir).unwrap();
487        let path = dir.join("test.murk");
488
489        let mut vault = test_vault();
490        vault.schema.insert(
491            "LEGACY".into(),
492            SchemaEntry {
493                description: "no timestamps".into(),
494                ..Default::default()
495            },
496        );
497
498        write(&path, &vault).unwrap();
499        let contents = fs::read_to_string(&path).unwrap();
500        // Schema timestamps should be omitted from JSON when None.
501        // Check that the LEGACY entry block doesn't contain timestamp fields.
502        let legacy_block = &contents[contents.find("LEGACY").unwrap()..];
503        let block_end = legacy_block.find('}').unwrap();
504        let legacy_block = &legacy_block[..block_end];
505        assert!(
506            !legacy_block.contains("created"),
507            "LEGACY entry should not have created timestamp"
508        );
509        assert!(
510            !legacy_block.contains("updated"),
511            "LEGACY entry should not have updated timestamp"
512        );
513
514        let read_vault = read(&path).unwrap();
515        assert!(read_vault.schema["LEGACY"].created.is_none());
516        assert!(read_vault.schema["LEGACY"].updated.is_none());
517
518        fs::remove_dir_all(&dir).unwrap();
519    }
520}