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