Skip to main content

murk_cli/
vault.rs

1use std::fs;
2use std::path::Path;
3
4use crate::types::Vault;
5
6/// Errors that can occur during vault file operations.
7#[derive(Debug)]
8pub enum VaultError {
9    Io(std::io::Error),
10    Parse(String),
11}
12
13impl std::fmt::Display for VaultError {
14    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15        match self {
16            VaultError::Io(e) if e.kind() == std::io::ErrorKind::NotFound => {
17                write!(f, "vault file not found. Run `murk init` to create one")
18            }
19            VaultError::Io(e) => write!(f, "vault I/O error: {e}"),
20            VaultError::Parse(msg) => write!(f, "vault parse error: {msg}"),
21        }
22    }
23}
24
25impl From<std::io::Error> for VaultError {
26    fn from(e: std::io::Error) -> Self {
27        VaultError::Io(e)
28    }
29}
30
31/// Parse vault from a JSON string.
32///
33/// Rejects vaults with an unrecognized major version to prevent
34/// silently misinterpreting a newer format.
35pub fn parse(contents: &str) -> Result<Vault, VaultError> {
36    let vault: Vault = serde_json::from_str(contents).map_err(|e| {
37        VaultError::Parse(format!(
38            "invalid vault JSON: {e}. Vault may be corrupted — restore from git"
39        ))
40    })?;
41
42    // Accept any 2.x version (same major).
43    let major = vault.version.split('.').next().unwrap_or("");
44    if major != "2" {
45        return Err(VaultError::Parse(format!(
46            "unsupported vault version: {}. This build of murk supports version 2.x",
47            vault.version
48        )));
49    }
50
51    Ok(vault)
52}
53
54/// Read a .murk vault file.
55pub fn read(path: &Path) -> Result<Vault, VaultError> {
56    let contents = fs::read_to_string(path)?;
57    parse(&contents)
58}
59
60/// Write a vault to a .murk file as pretty-printed JSON.
61pub fn write(path: &Path, vault: &Vault) -> Result<(), VaultError> {
62    let json = serde_json::to_string_pretty(vault)
63        .map_err(|e| VaultError::Parse(format!("failed to serialize vault: {e}")))?;
64    fs::write(path, json + "\n")?;
65    Ok(())
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use crate::types::{SchemaEntry, SecretEntry, VAULT_VERSION};
72    use std::collections::BTreeMap;
73
74    fn test_vault() -> Vault {
75        let mut schema = BTreeMap::new();
76        schema.insert(
77            "DATABASE_URL".into(),
78            SchemaEntry {
79                description: "postgres connection string".into(),
80                example: Some("postgres://user:pass@host/db".into()),
81                tags: vec![],
82            },
83        );
84
85        Vault {
86            version: VAULT_VERSION.into(),
87            created: "2026-02-27T00:00:00Z".into(),
88            vault_name: ".murk".into(),
89            repo: String::new(),
90            recipients: vec!["age1test".into()],
91            schema,
92            secrets: BTreeMap::new(),
93            meta: "encrypted-meta".into(),
94        }
95    }
96
97    #[test]
98    fn roundtrip_read_write() {
99        let dir = std::env::temp_dir().join("murk_test_vault_v2");
100        fs::create_dir_all(&dir).unwrap();
101        let path = dir.join("test.murk");
102
103        let mut vault = test_vault();
104        vault.secrets.insert(
105            "DATABASE_URL".into(),
106            SecretEntry {
107                shared: "encrypted-value".into(),
108                scoped: BTreeMap::new(),
109            },
110        );
111
112        write(&path, &vault).unwrap();
113        let read_vault = read(&path).unwrap();
114
115        assert_eq!(read_vault.version, VAULT_VERSION);
116        assert_eq!(read_vault.recipients[0], "age1test");
117        assert!(read_vault.schema.contains_key("DATABASE_URL"));
118        assert!(read_vault.secrets.contains_key("DATABASE_URL"));
119
120        fs::remove_dir_all(&dir).unwrap();
121    }
122
123    #[test]
124    fn schema_is_sorted() {
125        let dir = std::env::temp_dir().join("murk_test_sorted_v2");
126        fs::create_dir_all(&dir).unwrap();
127        let path = dir.join("test.murk");
128
129        let mut vault = test_vault();
130        vault.schema.insert(
131            "ZZZ_KEY".into(),
132            SchemaEntry {
133                description: "last".into(),
134                example: None,
135                tags: vec![],
136            },
137        );
138        vault.schema.insert(
139            "AAA_KEY".into(),
140            SchemaEntry {
141                description: "first".into(),
142                example: None,
143                tags: vec![],
144            },
145        );
146
147        write(&path, &vault).unwrap();
148        let contents = fs::read_to_string(&path).unwrap();
149
150        // BTreeMap ensures sorted output — AAA before DATABASE before ZZZ.
151        let aaa_pos = contents.find("AAA_KEY").unwrap();
152        let db_pos = contents.find("DATABASE_URL").unwrap();
153        let zzz_pos = contents.find("ZZZ_KEY").unwrap();
154        assert!(aaa_pos < db_pos);
155        assert!(db_pos < zzz_pos);
156
157        fs::remove_dir_all(&dir).unwrap();
158    }
159
160    #[test]
161    fn missing_file_errors() {
162        let result = read(Path::new("/tmp/null.murk"));
163        assert!(result.is_err());
164    }
165
166    #[test]
167    fn parse_invalid_json() {
168        let result = parse("not json at all");
169        assert!(result.is_err());
170        let err = result.unwrap_err();
171        let msg = err.to_string();
172        assert!(msg.contains("vault parse error"));
173        assert!(msg.contains("Vault may be corrupted"));
174    }
175
176    #[test]
177    fn parse_empty_string() {
178        let result = parse("");
179        assert!(result.is_err());
180    }
181
182    #[test]
183    fn parse_valid_json() {
184        let json = serde_json::to_string(&test_vault()).unwrap();
185        let result = parse(&json);
186        assert!(result.is_ok());
187        assert_eq!(result.unwrap().version, VAULT_VERSION);
188    }
189
190    #[test]
191    fn parse_rejects_unknown_major_version() {
192        let mut vault = test_vault();
193        vault.version = "99.0".into();
194        let json = serde_json::to_string(&vault).unwrap();
195        let result = parse(&json);
196        let err = result.unwrap_err().to_string();
197        assert!(err.contains("unsupported vault version: 99.0"));
198    }
199
200    #[test]
201    fn parse_accepts_minor_version_bump() {
202        let mut vault = test_vault();
203        vault.version = "2.1".into();
204        let json = serde_json::to_string(&vault).unwrap();
205        let result = parse(&json);
206        assert!(result.is_ok());
207    }
208
209    #[test]
210    fn error_display_not_found() {
211        let err = VaultError::Io(std::io::Error::new(
212            std::io::ErrorKind::NotFound,
213            "no such file",
214        ));
215        let msg = err.to_string();
216        assert!(msg.contains("vault file not found"));
217        assert!(msg.contains("murk init"));
218    }
219
220    #[test]
221    fn error_display_io() {
222        let err = VaultError::Io(std::io::Error::new(
223            std::io::ErrorKind::PermissionDenied,
224            "denied",
225        ));
226        let msg = err.to_string();
227        assert!(msg.contains("vault I/O error"));
228    }
229
230    #[test]
231    fn error_display_parse() {
232        let err = VaultError::Parse("bad data".into());
233        assert!(err.to_string().contains("vault parse error: bad data"));
234    }
235
236    #[test]
237    fn error_from_io() {
238        let io_err = std::io::Error::new(std::io::ErrorKind::Other, "test");
239        let vault_err: VaultError = io_err.into();
240        assert!(matches!(vault_err, VaultError::Io(_)));
241    }
242
243    #[test]
244    fn scoped_entries_roundtrip() {
245        let dir = std::env::temp_dir().join("murk_test_scoped_rt");
246        fs::create_dir_all(&dir).unwrap();
247        let path = dir.join("test.murk");
248
249        let mut vault = test_vault();
250        let mut scoped = BTreeMap::new();
251        scoped.insert("age1bob".into(), "encrypted-for-bob".into());
252
253        vault.secrets.insert(
254            "DATABASE_URL".into(),
255            SecretEntry {
256                shared: "encrypted-value".into(),
257                scoped,
258            },
259        );
260
261        write(&path, &vault).unwrap();
262        let read_vault = read(&path).unwrap();
263
264        let entry = &read_vault.secrets["DATABASE_URL"];
265        assert_eq!(entry.scoped["age1bob"], "encrypted-for-bob");
266
267        fs::remove_dir_all(&dir).unwrap();
268    }
269}