1use std::fs;
2use std::path::Path;
3
4use crate::types::Vault;
5
6#[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
31pub 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 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
54pub fn read(path: &Path) -> Result<Vault, VaultError> {
56 let contents = fs::read_to_string(path)?;
57 parse(&contents)
58}
59
60pub 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 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}