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