Skip to main content

murk_cli/
vault.rs

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