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> {
63 Ok(read_with_raw(path)?.0)
64}
65
66pub fn read_with_raw(path: &Path) -> Result<(Vault, Vec<u8>), VaultError> {
73 if path.is_symlink() {
74 return Err(VaultError::Io(std::io::Error::new(
75 std::io::ErrorKind::InvalidInput,
76 format!(
77 "vault file is a symlink — refusing to follow for security: {}",
78 path.display()
79 ),
80 )));
81 }
82 let contents = fs::read_to_string(path)?;
83 let vault = parse(&contents)?;
84 Ok((vault, contents.into_bytes()))
85}
86
87#[derive(Debug)]
92pub struct VaultLock {
93 _file: File,
94 _path: PathBuf,
95}
96
97fn lock_path(vault_path: &Path) -> PathBuf {
99 let mut p = vault_path.as_os_str().to_owned();
100 p.push(".lock");
101 PathBuf::from(p)
102}
103
104pub fn lock(vault_path: &Path) -> Result<VaultLock, VaultError> {
109 let lp = lock_path(vault_path);
110
111 #[cfg(unix)]
113 let file = {
114 use std::os::unix::fs::OpenOptionsExt;
115 fs::OpenOptions::new()
116 .create(true)
117 .write(true)
118 .truncate(true)
119 .custom_flags(libc::O_NOFOLLOW)
120 .open(&lp)?
121 };
122 #[cfg(not(unix))]
123 let file = {
124 if lp.is_symlink() {
126 return Err(VaultError::Io(std::io::Error::new(
127 std::io::ErrorKind::InvalidInput,
128 format!(
129 "lock file is a symlink — refusing to follow: {}",
130 lp.display()
131 ),
132 )));
133 }
134 File::create(&lp)?
135 };
136 file.lock_exclusive().map_err(|e| {
137 VaultError::Io(std::io::Error::new(
138 e.kind(),
139 format!("failed to acquire vault lock: {e}"),
140 ))
141 })?;
142 Ok(VaultLock {
143 _file: file,
144 _path: lp,
145 })
146}
147
148pub fn write(path: &Path, vault: &Vault) -> Result<(), VaultError> {
153 let json = serde_json::to_string_pretty(vault)
154 .map_err(|e| VaultError::Parse(format!("failed to serialize vault: {e}")))?;
155
156 let dir = path.parent().unwrap_or(Path::new("."));
158 let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
159
160 #[cfg(unix)]
162 {
163 use std::os::unix::fs::PermissionsExt;
164 tmp.as_file()
165 .set_permissions(fs::Permissions::from_mode(0o600))?;
166 }
167
168 tmp.write_all(json.as_bytes())?;
169 tmp.write_all(b"\n")?;
170 tmp.as_file().sync_all()?;
171 tmp.persist(path).map_err(|e| e.error)?;
172
173 #[cfg(unix)]
175 {
176 if let Ok(d) = File::open(dir) {
177 let _ = d.sync_all();
178 }
179 }
180
181 Ok(())
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use crate::types::{SchemaEntry, SecretEntry, VAULT_VERSION};
188 use std::collections::BTreeMap;
189
190 fn test_vault() -> Vault {
191 let mut schema = BTreeMap::new();
192 schema.insert(
193 "DATABASE_URL".into(),
194 SchemaEntry {
195 description: "postgres connection string".into(),
196 example: Some("postgres://user:pass@host/db".into()),
197 tags: vec![],
198 ..Default::default()
199 },
200 );
201
202 Vault {
203 version: VAULT_VERSION.into(),
204 created: "2026-02-27T00:00:00Z".into(),
205 vault_name: ".murk".into(),
206 repo: String::new(),
207 recipients: vec!["age1test".into()],
208 schema,
209 secrets: BTreeMap::new(),
210 meta: "encrypted-meta".into(),
211 }
212 }
213
214 #[test]
215 fn roundtrip_read_write() {
216 let dir = std::env::temp_dir().join("murk_test_vault_v2");
217 fs::create_dir_all(&dir).unwrap();
218 let path = dir.join("test.murk");
219
220 let mut vault = test_vault();
221 vault.secrets.insert(
222 "DATABASE_URL".into(),
223 SecretEntry {
224 shared: "encrypted-value".into(),
225 scoped: BTreeMap::new(),
226 },
227 );
228
229 write(&path, &vault).unwrap();
230 let read_vault = read(&path).unwrap();
231
232 assert_eq!(read_vault.version, VAULT_VERSION);
233 assert_eq!(read_vault.recipients[0], "age1test");
234 assert!(read_vault.schema.contains_key("DATABASE_URL"));
235 assert!(read_vault.secrets.contains_key("DATABASE_URL"));
236
237 fs::remove_dir_all(&dir).unwrap();
238 }
239
240 #[test]
241 fn schema_is_sorted() {
242 let dir = std::env::temp_dir().join("murk_test_sorted_v2");
243 fs::create_dir_all(&dir).unwrap();
244 let path = dir.join("test.murk");
245
246 let mut vault = test_vault();
247 vault.schema.insert(
248 "ZZZ_KEY".into(),
249 SchemaEntry {
250 description: "last".into(),
251 example: None,
252 tags: vec![],
253 ..Default::default()
254 },
255 );
256 vault.schema.insert(
257 "AAA_KEY".into(),
258 SchemaEntry {
259 description: "first".into(),
260 example: None,
261 tags: vec![],
262 ..Default::default()
263 },
264 );
265
266 write(&path, &vault).unwrap();
267 let contents = fs::read_to_string(&path).unwrap();
268
269 let aaa_pos = contents.find("AAA_KEY").unwrap();
271 let db_pos = contents.find("DATABASE_URL").unwrap();
272 let zzz_pos = contents.find("ZZZ_KEY").unwrap();
273 assert!(aaa_pos < db_pos);
274 assert!(db_pos < zzz_pos);
275
276 fs::remove_dir_all(&dir).unwrap();
277 }
278
279 #[test]
280 fn missing_file_errors() {
281 let result = read(Path::new("/tmp/null.murk"));
282 assert!(result.is_err());
283 }
284
285 #[test]
286 fn parse_invalid_json() {
287 let result = parse("not json at all");
288 assert!(result.is_err());
289 let err = result.unwrap_err();
290 let msg = err.to_string();
291 assert!(msg.contains("vault parse error"));
292 assert!(msg.contains("Vault may be corrupted"));
293 }
294
295 #[test]
296 fn parse_empty_string() {
297 let result = parse("");
298 assert!(result.is_err());
299 }
300
301 #[test]
302 fn parse_valid_json() {
303 let json = serde_json::to_string(&test_vault()).unwrap();
304 let result = parse(&json);
305 assert!(result.is_ok());
306 assert_eq!(result.unwrap().version, VAULT_VERSION);
307 }
308
309 #[test]
310 fn parse_rejects_unknown_major_version() {
311 let mut vault = test_vault();
312 vault.version = "99.0".into();
313 let json = serde_json::to_string(&vault).unwrap();
314 let result = parse(&json);
315 let err = result.unwrap_err().to_string();
316 assert!(err.contains("unsupported vault version: 99.0"));
317 }
318
319 #[test]
320 fn parse_accepts_minor_version_bump() {
321 let mut vault = test_vault();
322 vault.version = "2.1".into();
323 let json = serde_json::to_string(&vault).unwrap();
324 let result = parse(&json);
325 assert!(result.is_ok());
326 }
327
328 #[test]
329 fn error_display_not_found() {
330 let err = VaultError::Io(std::io::Error::new(
331 std::io::ErrorKind::NotFound,
332 "no such file",
333 ));
334 let msg = err.to_string();
335 assert!(msg.contains("vault file not found"));
336 assert!(msg.contains("murk init"));
337 }
338
339 #[test]
340 fn error_display_io() {
341 let err = VaultError::Io(std::io::Error::new(
342 std::io::ErrorKind::PermissionDenied,
343 "denied",
344 ));
345 let msg = err.to_string();
346 assert!(msg.contains("vault I/O error"));
347 }
348
349 #[test]
350 fn error_display_parse() {
351 let err = VaultError::Parse("bad data".into());
352 assert!(err.to_string().contains("vault parse error: bad data"));
353 }
354
355 #[test]
356 fn error_from_io() {
357 let io_err = std::io::Error::new(std::io::ErrorKind::Other, "test");
358 let vault_err: VaultError = io_err.into();
359 assert!(matches!(vault_err, VaultError::Io(_)));
360 }
361
362 #[test]
363 fn scoped_entries_roundtrip() {
364 let dir = std::env::temp_dir().join("murk_test_scoped_rt");
365 fs::create_dir_all(&dir).unwrap();
366 let path = dir.join("test.murk");
367
368 let mut vault = test_vault();
369 let mut scoped = BTreeMap::new();
370 scoped.insert("age1bob".into(), "encrypted-for-bob".into());
371
372 vault.secrets.insert(
373 "DATABASE_URL".into(),
374 SecretEntry {
375 shared: "encrypted-value".into(),
376 scoped,
377 },
378 );
379
380 write(&path, &vault).unwrap();
381 let read_vault = read(&path).unwrap();
382
383 let entry = &read_vault.secrets["DATABASE_URL"];
384 assert_eq!(entry.scoped["age1bob"], "encrypted-for-bob");
385
386 fs::remove_dir_all(&dir).unwrap();
387 }
388
389 #[test]
390 fn lock_creates_lock_file() {
391 let dir = std::env::temp_dir().join("murk_test_lock_create");
392 let _ = fs::remove_dir_all(&dir);
393 fs::create_dir_all(&dir).unwrap();
394 let vault_path = dir.join("test.murk");
395
396 let _lock = lock(&vault_path).unwrap();
397 assert!(lock_path(&vault_path).exists());
398
399 drop(_lock);
400 fs::remove_dir_all(&dir).unwrap();
401 }
402
403 #[cfg(unix)]
404 #[test]
405 fn lock_rejects_symlink() {
406 let dir = std::env::temp_dir().join("murk_test_lock_symlink");
407 let _ = fs::remove_dir_all(&dir);
408 fs::create_dir_all(&dir).unwrap();
409 let vault_path = dir.join("test.murk");
410 let lp = lock_path(&vault_path);
411
412 std::os::unix::fs::symlink("/tmp/evil", &lp).unwrap();
414
415 let result = lock(&vault_path);
416 assert!(result.is_err());
417 let msg = result.unwrap_err().to_string();
418 assert!(
420 msg.contains("symlink") || msg.contains("symbolic link"),
421 "unexpected error: {msg}"
422 );
423
424 fs::remove_dir_all(&dir).unwrap();
425 }
426
427 #[test]
428 fn write_is_atomic() {
429 let dir = std::env::temp_dir().join("murk_test_write_atomic");
430 let _ = fs::remove_dir_all(&dir);
431 fs::create_dir_all(&dir).unwrap();
432 let path = dir.join("test.murk");
433
434 let vault = test_vault();
435 write(&path, &vault).unwrap();
436
437 let contents = fs::read_to_string(&path).unwrap();
439 let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
440 assert_eq!(parsed["version"], VAULT_VERSION);
441
442 let mut vault2 = test_vault();
444 vault2.vault_name = "updated.murk".into();
445 write(&path, &vault2).unwrap();
446 let contents2 = fs::read_to_string(&path).unwrap();
447 assert!(contents2.contains("updated.murk"));
448
449 fs::remove_dir_all(&dir).unwrap();
450 }
451
452 #[test]
453 fn schema_entry_timestamps_roundtrip() {
454 let dir = std::env::temp_dir().join("murk_test_timestamps");
455 let _ = fs::remove_dir_all(&dir);
456 fs::create_dir_all(&dir).unwrap();
457 let path = dir.join("test.murk");
458
459 let mut vault = test_vault();
460 vault.schema.insert(
461 "TIMED_KEY".into(),
462 SchemaEntry {
463 description: "has timestamps".into(),
464 created: Some("2026-03-29T00:00:00Z".into()),
465 updated: Some("2026-03-29T12:00:00Z".into()),
466 ..Default::default()
467 },
468 );
469
470 write(&path, &vault).unwrap();
471 let read_vault = read(&path).unwrap();
472 let entry = &read_vault.schema["TIMED_KEY"];
473 assert_eq!(entry.created.as_deref(), Some("2026-03-29T00:00:00Z"));
474 assert_eq!(entry.updated.as_deref(), Some("2026-03-29T12:00:00Z"));
475
476 fs::remove_dir_all(&dir).unwrap();
477 }
478
479 #[test]
480 fn schema_entry_without_timestamps_roundtrips() {
481 let dir = std::env::temp_dir().join("murk_test_no_timestamps");
482 let _ = fs::remove_dir_all(&dir);
483 fs::create_dir_all(&dir).unwrap();
484 let path = dir.join("test.murk");
485
486 let mut vault = test_vault();
487 vault.schema.insert(
488 "LEGACY".into(),
489 SchemaEntry {
490 description: "no timestamps".into(),
491 ..Default::default()
492 },
493 );
494
495 write(&path, &vault).unwrap();
496 let contents = fs::read_to_string(&path).unwrap();
497 let legacy_block = &contents[contents.find("LEGACY").unwrap()..];
500 let block_end = legacy_block.find('}').unwrap();
501 let legacy_block = &legacy_block[..block_end];
502 assert!(
503 !legacy_block.contains("created"),
504 "LEGACY entry should not have created timestamp"
505 );
506 assert!(
507 !legacy_block.contains("updated"),
508 "LEGACY entry should not have updated timestamp"
509 );
510
511 let read_vault = read(&path).unwrap();
512 assert!(read_vault.schema["LEGACY"].created.is_none());
513 assert!(read_vault.schema["LEGACY"].updated.is_none());
514
515 fs::remove_dir_all(&dir).unwrap();
516 }
517}