1use std::fs::{self, File};
2use std::io::Write;
3use std::path::{Path, PathBuf};
4
5use fs2::FileExt;
6
7use crate::types::Vault;
8
9#[derive(Debug)]
11pub enum VaultError {
12 Io(std::io::Error),
13 Parse(String),
14}
15
16impl std::fmt::Display for VaultError {
17 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18 match self {
19 VaultError::Io(e) if e.kind() == std::io::ErrorKind::NotFound => {
20 write!(f, "vault file not found. Run `murk init` to create one")
21 }
22 VaultError::Io(e) => write!(f, "vault I/O error: {e}"),
23 VaultError::Parse(msg) => write!(f, "vault parse error: {msg}"),
24 }
25 }
26}
27
28impl From<std::io::Error> for VaultError {
29 fn from(e: std::io::Error) -> Self {
30 VaultError::Io(e)
31 }
32}
33
34pub fn parse(contents: &str) -> Result<Vault, VaultError> {
39 let vault: Vault = serde_json::from_str(contents).map_err(|e| {
40 VaultError::Parse(format!(
41 "invalid vault JSON: {e}. Vault may be corrupted — restore from git"
42 ))
43 })?;
44
45 let major = vault.version.split('.').next().unwrap_or("");
47 if major != "2" {
48 return Err(VaultError::Parse(format!(
49 "unsupported vault version: {}. This build of murk supports version 2.x",
50 vault.version
51 )));
52 }
53
54 Ok(vault)
55}
56
57pub fn read(path: &Path) -> Result<Vault, VaultError> {
59 let contents = fs::read_to_string(path)?;
60 parse(&contents)
61}
62
63#[derive(Debug)]
68pub struct VaultLock {
69 _file: File,
70 _path: PathBuf,
71}
72
73fn lock_path(vault_path: &Path) -> PathBuf {
75 let mut p = vault_path.as_os_str().to_owned();
76 p.push(".lock");
77 PathBuf::from(p)
78}
79
80pub fn lock(vault_path: &Path) -> Result<VaultLock, VaultError> {
85 let lp = lock_path(vault_path);
86
87 #[cfg(unix)]
89 let file = {
90 use std::os::unix::fs::OpenOptionsExt;
91 fs::OpenOptions::new()
92 .create(true)
93 .write(true)
94 .truncate(true)
95 .custom_flags(libc::O_NOFOLLOW)
96 .open(&lp)?
97 };
98 #[cfg(not(unix))]
99 let file = {
100 if lp.is_symlink() {
102 return Err(VaultError::Io(std::io::Error::new(
103 std::io::ErrorKind::InvalidInput,
104 format!(
105 "lock file is a symlink — refusing to follow: {}",
106 lp.display()
107 ),
108 )));
109 }
110 File::create(&lp)?
111 };
112 file.lock_exclusive().map_err(|e| {
113 VaultError::Io(std::io::Error::new(
114 e.kind(),
115 format!("failed to acquire vault lock: {e}"),
116 ))
117 })?;
118 Ok(VaultLock {
119 _file: file,
120 _path: lp,
121 })
122}
123
124pub fn write(path: &Path, vault: &Vault) -> Result<(), VaultError> {
129 let json = serde_json::to_string_pretty(vault)
130 .map_err(|e| VaultError::Parse(format!("failed to serialize vault: {e}")))?;
131
132 let dir = path.parent().unwrap_or(Path::new("."));
134 let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
135 tmp.write_all(json.as_bytes())?;
136 tmp.write_all(b"\n")?;
137 tmp.as_file().sync_all()?;
138 tmp.persist(path).map_err(|e| e.error)?;
139 Ok(())
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145 use crate::types::{SchemaEntry, SecretEntry, VAULT_VERSION};
146 use std::collections::BTreeMap;
147
148 fn test_vault() -> Vault {
149 let mut schema = BTreeMap::new();
150 schema.insert(
151 "DATABASE_URL".into(),
152 SchemaEntry {
153 description: "postgres connection string".into(),
154 example: Some("postgres://user:pass@host/db".into()),
155 tags: vec![],
156 ..Default::default()
157 },
158 );
159
160 Vault {
161 version: VAULT_VERSION.into(),
162 created: "2026-02-27T00:00:00Z".into(),
163 vault_name: ".murk".into(),
164 repo: String::new(),
165 recipients: vec!["age1test".into()],
166 schema,
167 secrets: BTreeMap::new(),
168 meta: "encrypted-meta".into(),
169 }
170 }
171
172 #[test]
173 fn roundtrip_read_write() {
174 let dir = std::env::temp_dir().join("murk_test_vault_v2");
175 fs::create_dir_all(&dir).unwrap();
176 let path = dir.join("test.murk");
177
178 let mut vault = test_vault();
179 vault.secrets.insert(
180 "DATABASE_URL".into(),
181 SecretEntry {
182 shared: "encrypted-value".into(),
183 scoped: BTreeMap::new(),
184 },
185 );
186
187 write(&path, &vault).unwrap();
188 let read_vault = read(&path).unwrap();
189
190 assert_eq!(read_vault.version, VAULT_VERSION);
191 assert_eq!(read_vault.recipients[0], "age1test");
192 assert!(read_vault.schema.contains_key("DATABASE_URL"));
193 assert!(read_vault.secrets.contains_key("DATABASE_URL"));
194
195 fs::remove_dir_all(&dir).unwrap();
196 }
197
198 #[test]
199 fn schema_is_sorted() {
200 let dir = std::env::temp_dir().join("murk_test_sorted_v2");
201 fs::create_dir_all(&dir).unwrap();
202 let path = dir.join("test.murk");
203
204 let mut vault = test_vault();
205 vault.schema.insert(
206 "ZZZ_KEY".into(),
207 SchemaEntry {
208 description: "last".into(),
209 example: None,
210 tags: vec![],
211 ..Default::default()
212 },
213 );
214 vault.schema.insert(
215 "AAA_KEY".into(),
216 SchemaEntry {
217 description: "first".into(),
218 example: None,
219 tags: vec![],
220 ..Default::default()
221 },
222 );
223
224 write(&path, &vault).unwrap();
225 let contents = fs::read_to_string(&path).unwrap();
226
227 let aaa_pos = contents.find("AAA_KEY").unwrap();
229 let db_pos = contents.find("DATABASE_URL").unwrap();
230 let zzz_pos = contents.find("ZZZ_KEY").unwrap();
231 assert!(aaa_pos < db_pos);
232 assert!(db_pos < zzz_pos);
233
234 fs::remove_dir_all(&dir).unwrap();
235 }
236
237 #[test]
238 fn missing_file_errors() {
239 let result = read(Path::new("/tmp/null.murk"));
240 assert!(result.is_err());
241 }
242
243 #[test]
244 fn parse_invalid_json() {
245 let result = parse("not json at all");
246 assert!(result.is_err());
247 let err = result.unwrap_err();
248 let msg = err.to_string();
249 assert!(msg.contains("vault parse error"));
250 assert!(msg.contains("Vault may be corrupted"));
251 }
252
253 #[test]
254 fn parse_empty_string() {
255 let result = parse("");
256 assert!(result.is_err());
257 }
258
259 #[test]
260 fn parse_valid_json() {
261 let json = serde_json::to_string(&test_vault()).unwrap();
262 let result = parse(&json);
263 assert!(result.is_ok());
264 assert_eq!(result.unwrap().version, VAULT_VERSION);
265 }
266
267 #[test]
268 fn parse_rejects_unknown_major_version() {
269 let mut vault = test_vault();
270 vault.version = "99.0".into();
271 let json = serde_json::to_string(&vault).unwrap();
272 let result = parse(&json);
273 let err = result.unwrap_err().to_string();
274 assert!(err.contains("unsupported vault version: 99.0"));
275 }
276
277 #[test]
278 fn parse_accepts_minor_version_bump() {
279 let mut vault = test_vault();
280 vault.version = "2.1".into();
281 let json = serde_json::to_string(&vault).unwrap();
282 let result = parse(&json);
283 assert!(result.is_ok());
284 }
285
286 #[test]
287 fn error_display_not_found() {
288 let err = VaultError::Io(std::io::Error::new(
289 std::io::ErrorKind::NotFound,
290 "no such file",
291 ));
292 let msg = err.to_string();
293 assert!(msg.contains("vault file not found"));
294 assert!(msg.contains("murk init"));
295 }
296
297 #[test]
298 fn error_display_io() {
299 let err = VaultError::Io(std::io::Error::new(
300 std::io::ErrorKind::PermissionDenied,
301 "denied",
302 ));
303 let msg = err.to_string();
304 assert!(msg.contains("vault I/O error"));
305 }
306
307 #[test]
308 fn error_display_parse() {
309 let err = VaultError::Parse("bad data".into());
310 assert!(err.to_string().contains("vault parse error: bad data"));
311 }
312
313 #[test]
314 fn error_from_io() {
315 let io_err = std::io::Error::new(std::io::ErrorKind::Other, "test");
316 let vault_err: VaultError = io_err.into();
317 assert!(matches!(vault_err, VaultError::Io(_)));
318 }
319
320 #[test]
321 fn scoped_entries_roundtrip() {
322 let dir = std::env::temp_dir().join("murk_test_scoped_rt");
323 fs::create_dir_all(&dir).unwrap();
324 let path = dir.join("test.murk");
325
326 let mut vault = test_vault();
327 let mut scoped = BTreeMap::new();
328 scoped.insert("age1bob".into(), "encrypted-for-bob".into());
329
330 vault.secrets.insert(
331 "DATABASE_URL".into(),
332 SecretEntry {
333 shared: "encrypted-value".into(),
334 scoped,
335 },
336 );
337
338 write(&path, &vault).unwrap();
339 let read_vault = read(&path).unwrap();
340
341 let entry = &read_vault.secrets["DATABASE_URL"];
342 assert_eq!(entry.scoped["age1bob"], "encrypted-for-bob");
343
344 fs::remove_dir_all(&dir).unwrap();
345 }
346
347 #[test]
348 fn lock_creates_lock_file() {
349 let dir = std::env::temp_dir().join("murk_test_lock_create");
350 let _ = fs::remove_dir_all(&dir);
351 fs::create_dir_all(&dir).unwrap();
352 let vault_path = dir.join("test.murk");
353
354 let _lock = lock(&vault_path).unwrap();
355 assert!(lock_path(&vault_path).exists());
356
357 drop(_lock);
358 fs::remove_dir_all(&dir).unwrap();
359 }
360
361 #[cfg(unix)]
362 #[test]
363 fn lock_rejects_symlink() {
364 let dir = std::env::temp_dir().join("murk_test_lock_symlink");
365 let _ = fs::remove_dir_all(&dir);
366 fs::create_dir_all(&dir).unwrap();
367 let vault_path = dir.join("test.murk");
368 let lp = lock_path(&vault_path);
369
370 std::os::unix::fs::symlink("/tmp/evil", &lp).unwrap();
372
373 let result = lock(&vault_path);
374 assert!(result.is_err());
375 let msg = result.unwrap_err().to_string();
376 assert!(
378 msg.contains("symlink") || msg.contains("symbolic link"),
379 "unexpected error: {msg}"
380 );
381
382 fs::remove_dir_all(&dir).unwrap();
383 }
384
385 #[test]
386 fn write_is_atomic() {
387 let dir = std::env::temp_dir().join("murk_test_write_atomic");
388 let _ = fs::remove_dir_all(&dir);
389 fs::create_dir_all(&dir).unwrap();
390 let path = dir.join("test.murk");
391
392 let vault = test_vault();
393 write(&path, &vault).unwrap();
394
395 let contents = fs::read_to_string(&path).unwrap();
397 let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
398 assert_eq!(parsed["version"], VAULT_VERSION);
399
400 let mut vault2 = test_vault();
402 vault2.vault_name = "updated.murk".into();
403 write(&path, &vault2).unwrap();
404 let contents2 = fs::read_to_string(&path).unwrap();
405 assert!(contents2.contains("updated.murk"));
406
407 fs::remove_dir_all(&dir).unwrap();
408 }
409
410 #[test]
411 fn schema_entry_timestamps_roundtrip() {
412 let dir = std::env::temp_dir().join("murk_test_timestamps");
413 let _ = fs::remove_dir_all(&dir);
414 fs::create_dir_all(&dir).unwrap();
415 let path = dir.join("test.murk");
416
417 let mut vault = test_vault();
418 vault.schema.insert(
419 "TIMED_KEY".into(),
420 SchemaEntry {
421 description: "has timestamps".into(),
422 created: Some("2026-03-29T00:00:00Z".into()),
423 updated: Some("2026-03-29T12:00:00Z".into()),
424 ..Default::default()
425 },
426 );
427
428 write(&path, &vault).unwrap();
429 let read_vault = read(&path).unwrap();
430 let entry = &read_vault.schema["TIMED_KEY"];
431 assert_eq!(entry.created.as_deref(), Some("2026-03-29T00:00:00Z"));
432 assert_eq!(entry.updated.as_deref(), Some("2026-03-29T12:00:00Z"));
433
434 fs::remove_dir_all(&dir).unwrap();
435 }
436
437 #[test]
438 fn schema_entry_without_timestamps_roundtrips() {
439 let dir = std::env::temp_dir().join("murk_test_no_timestamps");
440 let _ = fs::remove_dir_all(&dir);
441 fs::create_dir_all(&dir).unwrap();
442 let path = dir.join("test.murk");
443
444 let mut vault = test_vault();
445 vault.schema.insert(
446 "LEGACY".into(),
447 SchemaEntry {
448 description: "no timestamps".into(),
449 ..Default::default()
450 },
451 );
452
453 write(&path, &vault).unwrap();
454 let contents = fs::read_to_string(&path).unwrap();
455 let legacy_block = &contents[contents.find("LEGACY").unwrap()..];
458 let block_end = legacy_block.find('}').unwrap();
459 let legacy_block = &legacy_block[..block_end];
460 assert!(
461 !legacy_block.contains("created"),
462 "LEGACY entry should not have created timestamp"
463 );
464 assert!(
465 !legacy_block.contains("updated"),
466 "LEGACY entry should not have updated timestamp"
467 );
468
469 let read_vault = read(&path).unwrap();
470 assert!(read_vault.schema["LEGACY"].created.is_none());
471 assert!(read_vault.schema["LEGACY"].updated.is_none());
472
473 fs::remove_dir_all(&dir).unwrap();
474 }
475}