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