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.
32pub fn parse(contents: &str) -> Result<Vault, VaultError> {
33    serde_json::from_str(contents).map_err(|e| {
34        VaultError::Parse(format!(
35            "invalid vault JSON: {e}. Vault may be corrupted — restore from git"
36        ))
37    })
38}
39
40/// Read a .murk vault file.
41pub fn read(path: &Path) -> Result<Vault, VaultError> {
42    let contents = fs::read_to_string(path)?;
43    parse(&contents)
44}
45
46/// Write a vault to a .murk file as pretty-printed JSON.
47pub fn write(path: &Path, vault: &Vault) -> Result<(), VaultError> {
48    let json = serde_json::to_string_pretty(vault)
49        .map_err(|e| VaultError::Parse(format!("failed to serialize vault: {e}")))?;
50    fs::write(path, json + "\n")?;
51    Ok(())
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57    use crate::types::{SchemaEntry, SecretEntry, VAULT_VERSION};
58    use std::collections::BTreeMap;
59
60    fn test_vault() -> Vault {
61        let mut schema = BTreeMap::new();
62        schema.insert(
63            "DATABASE_URL".into(),
64            SchemaEntry {
65                description: "postgres connection string".into(),
66                example: Some("postgres://user:pass@host/db".into()),
67                tags: vec![],
68            },
69        );
70
71        Vault {
72            version: VAULT_VERSION.into(),
73            created: "2026-02-27T00:00:00Z".into(),
74            vault_name: ".murk".into(),
75            repo: String::new(),
76            recipients: vec!["age1test".into()],
77            schema,
78            secrets: BTreeMap::new(),
79            meta: "encrypted-meta".into(),
80        }
81    }
82
83    #[test]
84    fn roundtrip_read_write() {
85        let dir = std::env::temp_dir().join("murk_test_vault_v2");
86        fs::create_dir_all(&dir).unwrap();
87        let path = dir.join("test.murk");
88
89        let mut vault = test_vault();
90        vault.secrets.insert(
91            "DATABASE_URL".into(),
92            SecretEntry {
93                shared: "encrypted-value".into(),
94                scoped: BTreeMap::new(),
95            },
96        );
97
98        write(&path, &vault).unwrap();
99        let read_vault = read(&path).unwrap();
100
101        assert_eq!(read_vault.version, VAULT_VERSION);
102        assert_eq!(read_vault.recipients[0], "age1test");
103        assert!(read_vault.schema.contains_key("DATABASE_URL"));
104        assert!(read_vault.secrets.contains_key("DATABASE_URL"));
105
106        fs::remove_dir_all(&dir).unwrap();
107    }
108
109    #[test]
110    fn schema_is_sorted() {
111        let dir = std::env::temp_dir().join("murk_test_sorted_v2");
112        fs::create_dir_all(&dir).unwrap();
113        let path = dir.join("test.murk");
114
115        let mut vault = test_vault();
116        vault.schema.insert(
117            "ZZZ_KEY".into(),
118            SchemaEntry {
119                description: "last".into(),
120                example: None,
121                tags: vec![],
122            },
123        );
124        vault.schema.insert(
125            "AAA_KEY".into(),
126            SchemaEntry {
127                description: "first".into(),
128                example: None,
129                tags: vec![],
130            },
131        );
132
133        write(&path, &vault).unwrap();
134        let contents = fs::read_to_string(&path).unwrap();
135
136        // BTreeMap ensures sorted output — AAA before DATABASE before ZZZ.
137        let aaa_pos = contents.find("AAA_KEY").unwrap();
138        let db_pos = contents.find("DATABASE_URL").unwrap();
139        let zzz_pos = contents.find("ZZZ_KEY").unwrap();
140        assert!(aaa_pos < db_pos);
141        assert!(db_pos < zzz_pos);
142
143        fs::remove_dir_all(&dir).unwrap();
144    }
145
146    #[test]
147    fn missing_file_errors() {
148        let result = read(Path::new("/tmp/null.murk"));
149        assert!(result.is_err());
150    }
151
152    #[test]
153    fn parse_invalid_json() {
154        let result = parse("not json at all");
155        assert!(result.is_err());
156        let err = result.unwrap_err();
157        let msg = err.to_string();
158        assert!(msg.contains("vault parse error"));
159        assert!(msg.contains("Vault may be corrupted"));
160    }
161
162    #[test]
163    fn parse_empty_string() {
164        let result = parse("");
165        assert!(result.is_err());
166    }
167
168    #[test]
169    fn parse_valid_json() {
170        let json = serde_json::to_string(&test_vault()).unwrap();
171        let result = parse(&json);
172        assert!(result.is_ok());
173        assert_eq!(result.unwrap().version, VAULT_VERSION);
174    }
175
176    #[test]
177    fn error_display_not_found() {
178        let err = VaultError::Io(std::io::Error::new(
179            std::io::ErrorKind::NotFound,
180            "no such file",
181        ));
182        let msg = err.to_string();
183        assert!(msg.contains("vault file not found"));
184        assert!(msg.contains("murk init"));
185    }
186
187    #[test]
188    fn error_display_io() {
189        let err = VaultError::Io(std::io::Error::new(
190            std::io::ErrorKind::PermissionDenied,
191            "denied",
192        ));
193        let msg = err.to_string();
194        assert!(msg.contains("vault I/O error"));
195    }
196
197    #[test]
198    fn error_display_parse() {
199        let err = VaultError::Parse("bad data".into());
200        assert!(err.to_string().contains("vault parse error: bad data"));
201    }
202
203    #[test]
204    fn error_from_io() {
205        let io_err = std::io::Error::new(std::io::ErrorKind::Other, "test");
206        let vault_err: VaultError = io_err.into();
207        assert!(matches!(vault_err, VaultError::Io(_)));
208    }
209
210    #[test]
211    fn scoped_entries_roundtrip() {
212        let dir = std::env::temp_dir().join("murk_test_scoped_rt");
213        fs::create_dir_all(&dir).unwrap();
214        let path = dir.join("test.murk");
215
216        let mut vault = test_vault();
217        let mut scoped = BTreeMap::new();
218        scoped.insert("age1bob".into(), "encrypted-for-bob".into());
219
220        vault.secrets.insert(
221            "DATABASE_URL".into(),
222            SecretEntry {
223                shared: "encrypted-value".into(),
224                scoped,
225            },
226        );
227
228        write(&path, &vault).unwrap();
229        let read_vault = read(&path).unwrap();
230
231        let entry = &read_vault.secrets["DATABASE_URL"];
232        assert_eq!(entry.scoped["age1bob"], "encrypted-for-bob");
233
234        fs::remove_dir_all(&dir).unwrap();
235    }
236}