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.
67pub struct VaultLock {
68    _file: File,
69    _path: PathBuf,
70}
71
72/// Lock path for a given vault path (e.g. `.murk` → `.murk.lock`).
73fn lock_path(vault_path: &Path) -> PathBuf {
74    let mut p = vault_path.as_os_str().to_owned();
75    p.push(".lock");
76    PathBuf::from(p)
77}
78
79/// Acquire an exclusive advisory lock on the vault file.
80///
81/// Returns a guard that releases the lock when dropped. Use this around
82/// read-modify-write cycles to prevent concurrent writes from losing changes.
83pub fn lock(vault_path: &Path) -> Result<VaultLock, VaultError> {
84    let lp = lock_path(vault_path);
85    if lp.is_symlink() {
86        return Err(VaultError::Io(std::io::Error::new(
87            std::io::ErrorKind::InvalidInput,
88            format!(
89                "lock file is a symlink — refusing to follow: {}",
90                lp.display()
91            ),
92        )));
93    }
94    let file = File::create(&lp)?;
95    file.lock_exclusive().map_err(|e| {
96        VaultError::Io(std::io::Error::new(
97            e.kind(),
98            format!("failed to acquire vault lock: {e}"),
99        ))
100    })?;
101    Ok(VaultLock {
102        _file: file,
103        _path: lp,
104    })
105}
106
107/// Write a vault to a .murk file as pretty-printed JSON.
108///
109/// Uses write-to-tempfile + rename for atomic writes — if the process is
110/// killed mid-write, the original file remains intact.
111pub fn write(path: &Path, vault: &Vault) -> Result<(), VaultError> {
112    let json = serde_json::to_string_pretty(vault)
113        .map_err(|e| VaultError::Parse(format!("failed to serialize vault: {e}")))?;
114
115    // Write to a sibling temp file, fsync, then atomically rename.
116    let dir = path.parent().unwrap_or(Path::new("."));
117    let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
118    tmp.write_all(json.as_bytes())?;
119    tmp.write_all(b"\n")?;
120    tmp.as_file().sync_all()?;
121    tmp.persist(path).map_err(|e| e.error)?;
122    Ok(())
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::types::{SchemaEntry, SecretEntry, VAULT_VERSION};
129    use std::collections::BTreeMap;
130
131    fn test_vault() -> Vault {
132        let mut schema = BTreeMap::new();
133        schema.insert(
134            "DATABASE_URL".into(),
135            SchemaEntry {
136                description: "postgres connection string".into(),
137                example: Some("postgres://user:pass@host/db".into()),
138                tags: vec![],
139            },
140        );
141
142        Vault {
143            version: VAULT_VERSION.into(),
144            created: "2026-02-27T00:00:00Z".into(),
145            vault_name: ".murk".into(),
146            repo: String::new(),
147            recipients: vec!["age1test".into()],
148            schema,
149            secrets: BTreeMap::new(),
150            meta: "encrypted-meta".into(),
151        }
152    }
153
154    #[test]
155    fn roundtrip_read_write() {
156        let dir = std::env::temp_dir().join("murk_test_vault_v2");
157        fs::create_dir_all(&dir).unwrap();
158        let path = dir.join("test.murk");
159
160        let mut vault = test_vault();
161        vault.secrets.insert(
162            "DATABASE_URL".into(),
163            SecretEntry {
164                shared: "encrypted-value".into(),
165                scoped: BTreeMap::new(),
166            },
167        );
168
169        write(&path, &vault).unwrap();
170        let read_vault = read(&path).unwrap();
171
172        assert_eq!(read_vault.version, VAULT_VERSION);
173        assert_eq!(read_vault.recipients[0], "age1test");
174        assert!(read_vault.schema.contains_key("DATABASE_URL"));
175        assert!(read_vault.secrets.contains_key("DATABASE_URL"));
176
177        fs::remove_dir_all(&dir).unwrap();
178    }
179
180    #[test]
181    fn schema_is_sorted() {
182        let dir = std::env::temp_dir().join("murk_test_sorted_v2");
183        fs::create_dir_all(&dir).unwrap();
184        let path = dir.join("test.murk");
185
186        let mut vault = test_vault();
187        vault.schema.insert(
188            "ZZZ_KEY".into(),
189            SchemaEntry {
190                description: "last".into(),
191                example: None,
192                tags: vec![],
193            },
194        );
195        vault.schema.insert(
196            "AAA_KEY".into(),
197            SchemaEntry {
198                description: "first".into(),
199                example: None,
200                tags: vec![],
201            },
202        );
203
204        write(&path, &vault).unwrap();
205        let contents = fs::read_to_string(&path).unwrap();
206
207        // BTreeMap ensures sorted output — AAA before DATABASE before ZZZ.
208        let aaa_pos = contents.find("AAA_KEY").unwrap();
209        let db_pos = contents.find("DATABASE_URL").unwrap();
210        let zzz_pos = contents.find("ZZZ_KEY").unwrap();
211        assert!(aaa_pos < db_pos);
212        assert!(db_pos < zzz_pos);
213
214        fs::remove_dir_all(&dir).unwrap();
215    }
216
217    #[test]
218    fn missing_file_errors() {
219        let result = read(Path::new("/tmp/null.murk"));
220        assert!(result.is_err());
221    }
222
223    #[test]
224    fn parse_invalid_json() {
225        let result = parse("not json at all");
226        assert!(result.is_err());
227        let err = result.unwrap_err();
228        let msg = err.to_string();
229        assert!(msg.contains("vault parse error"));
230        assert!(msg.contains("Vault may be corrupted"));
231    }
232
233    #[test]
234    fn parse_empty_string() {
235        let result = parse("");
236        assert!(result.is_err());
237    }
238
239    #[test]
240    fn parse_valid_json() {
241        let json = serde_json::to_string(&test_vault()).unwrap();
242        let result = parse(&json);
243        assert!(result.is_ok());
244        assert_eq!(result.unwrap().version, VAULT_VERSION);
245    }
246
247    #[test]
248    fn parse_rejects_unknown_major_version() {
249        let mut vault = test_vault();
250        vault.version = "99.0".into();
251        let json = serde_json::to_string(&vault).unwrap();
252        let result = parse(&json);
253        let err = result.unwrap_err().to_string();
254        assert!(err.contains("unsupported vault version: 99.0"));
255    }
256
257    #[test]
258    fn parse_accepts_minor_version_bump() {
259        let mut vault = test_vault();
260        vault.version = "2.1".into();
261        let json = serde_json::to_string(&vault).unwrap();
262        let result = parse(&json);
263        assert!(result.is_ok());
264    }
265
266    #[test]
267    fn error_display_not_found() {
268        let err = VaultError::Io(std::io::Error::new(
269            std::io::ErrorKind::NotFound,
270            "no such file",
271        ));
272        let msg = err.to_string();
273        assert!(msg.contains("vault file not found"));
274        assert!(msg.contains("murk init"));
275    }
276
277    #[test]
278    fn error_display_io() {
279        let err = VaultError::Io(std::io::Error::new(
280            std::io::ErrorKind::PermissionDenied,
281            "denied",
282        ));
283        let msg = err.to_string();
284        assert!(msg.contains("vault I/O error"));
285    }
286
287    #[test]
288    fn error_display_parse() {
289        let err = VaultError::Parse("bad data".into());
290        assert!(err.to_string().contains("vault parse error: bad data"));
291    }
292
293    #[test]
294    fn error_from_io() {
295        let io_err = std::io::Error::new(std::io::ErrorKind::Other, "test");
296        let vault_err: VaultError = io_err.into();
297        assert!(matches!(vault_err, VaultError::Io(_)));
298    }
299
300    #[test]
301    fn scoped_entries_roundtrip() {
302        let dir = std::env::temp_dir().join("murk_test_scoped_rt");
303        fs::create_dir_all(&dir).unwrap();
304        let path = dir.join("test.murk");
305
306        let mut vault = test_vault();
307        let mut scoped = BTreeMap::new();
308        scoped.insert("age1bob".into(), "encrypted-for-bob".into());
309
310        vault.secrets.insert(
311            "DATABASE_URL".into(),
312            SecretEntry {
313                shared: "encrypted-value".into(),
314                scoped,
315            },
316        );
317
318        write(&path, &vault).unwrap();
319        let read_vault = read(&path).unwrap();
320
321        let entry = &read_vault.secrets["DATABASE_URL"];
322        assert_eq!(entry.scoped["age1bob"], "encrypted-for-bob");
323
324        fs::remove_dir_all(&dir).unwrap();
325    }
326}