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