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 policy: None,
210 secrets: BTreeMap::new(),
211 meta: "encrypted-meta".into(),
212 }
213 }
214
215 #[test]
216 fn roundtrip_read_write() {
217 let dir = std::env::temp_dir().join("murk_test_vault_v2");
218 fs::create_dir_all(&dir).unwrap();
219 let path = dir.join("test.murk");
220
221 let mut vault = test_vault();
222 vault.secrets.insert(
223 "DATABASE_URL".into(),
224 SecretEntry {
225 shared: "encrypted-value".into(),
226 private: BTreeMap::new(),
227 grouped: std::collections::BTreeMap::default(),
228 },
229 );
230
231 write(&path, &vault).unwrap();
232 let read_vault = read(&path).unwrap();
233
234 assert_eq!(read_vault.version, VAULT_VERSION);
235 assert_eq!(read_vault.recipients[0], "age1test");
236 assert!(read_vault.schema.contains_key("DATABASE_URL"));
237 assert!(read_vault.secrets.contains_key("DATABASE_URL"));
238
239 fs::remove_dir_all(&dir).unwrap();
240 }
241
242 #[test]
243 fn schema_is_sorted() {
244 let dir = std::env::temp_dir().join("murk_test_sorted_v2");
245 fs::create_dir_all(&dir).unwrap();
246 let path = dir.join("test.murk");
247
248 let mut vault = test_vault();
249 vault.schema.insert(
250 "ZZZ_KEY".into(),
251 SchemaEntry {
252 description: "last".into(),
253 example: None,
254 tags: vec![],
255 ..Default::default()
256 },
257 );
258 vault.schema.insert(
259 "AAA_KEY".into(),
260 SchemaEntry {
261 description: "first".into(),
262 example: None,
263 tags: vec![],
264 ..Default::default()
265 },
266 );
267
268 write(&path, &vault).unwrap();
269 let contents = fs::read_to_string(&path).unwrap();
270
271 let aaa_pos = contents.find("AAA_KEY").unwrap();
273 let db_pos = contents.find("DATABASE_URL").unwrap();
274 let zzz_pos = contents.find("ZZZ_KEY").unwrap();
275 assert!(aaa_pos < db_pos);
276 assert!(db_pos < zzz_pos);
277
278 fs::remove_dir_all(&dir).unwrap();
279 }
280
281 #[test]
282 fn missing_file_errors() {
283 let result = read(Path::new("/tmp/null.murk"));
284 assert!(result.is_err());
285 }
286
287 #[test]
288 fn parse_invalid_json() {
289 let result = parse("not json at all");
290 assert!(result.is_err());
291 let err = result.unwrap_err();
292 let msg = err.to_string();
293 assert!(msg.contains("vault parse error"));
294 assert!(msg.contains("Vault may be corrupted"));
295 }
296
297 #[test]
298 fn parse_empty_string() {
299 let result = parse("");
300 assert!(result.is_err());
301 }
302
303 #[test]
304 fn parse_valid_json() {
305 let json = serde_json::to_string(&test_vault()).unwrap();
306 let result = parse(&json);
307 assert!(result.is_ok());
308 assert_eq!(result.unwrap().version, VAULT_VERSION);
309 }
310
311 #[test]
312 fn parse_rejects_unknown_major_version() {
313 let mut vault = test_vault();
314 vault.version = "99.0".into();
315 let json = serde_json::to_string(&vault).unwrap();
316 let result = parse(&json);
317 let err = result.unwrap_err().to_string();
318 assert!(err.contains("unsupported vault version: 99.0"));
319 }
320
321 #[test]
322 fn parse_accepts_minor_version_bump() {
323 let mut vault = test_vault();
324 vault.version = "2.1".into();
325 let json = serde_json::to_string(&vault).unwrap();
326 let result = parse(&json);
327 assert!(result.is_ok());
328 }
329
330 #[test]
331 fn error_display_not_found() {
332 let err = VaultError::Io(std::io::Error::new(
333 std::io::ErrorKind::NotFound,
334 "no such file",
335 ));
336 let msg = err.to_string();
337 assert!(msg.contains("vault file not found"));
338 assert!(msg.contains("murk init"));
339 }
340
341 #[test]
342 fn error_display_io() {
343 let err = VaultError::Io(std::io::Error::new(
344 std::io::ErrorKind::PermissionDenied,
345 "denied",
346 ));
347 let msg = err.to_string();
348 assert!(msg.contains("vault I/O error"));
349 }
350
351 #[test]
352 fn error_display_parse() {
353 let err = VaultError::Parse("bad data".into());
354 assert!(err.to_string().contains("vault parse error: bad data"));
355 }
356
357 #[test]
358 fn error_from_io() {
359 let io_err = std::io::Error::other("test");
360 let vault_err: VaultError = io_err.into();
361 assert!(matches!(vault_err, VaultError::Io(_)));
362 }
363
364 #[test]
365 fn scoped_entries_roundtrip() {
366 let dir = std::env::temp_dir().join("murk_test_scoped_rt");
367 fs::create_dir_all(&dir).unwrap();
368 let path = dir.join("test.murk");
369
370 let mut vault = test_vault();
371 let mut private = BTreeMap::new();
372 private.insert("age1bob".into(), "encrypted-for-bob".into());
373
374 vault.secrets.insert(
375 "DATABASE_URL".into(),
376 SecretEntry {
377 shared: "encrypted-value".into(),
378 private,
379 grouped: std::collections::BTreeMap::default(),
380 },
381 );
382
383 write(&path, &vault).unwrap();
384 let read_vault = read(&path).unwrap();
385
386 let entry = &read_vault.secrets["DATABASE_URL"];
387 assert_eq!(entry.private["age1bob"], "encrypted-for-bob");
388
389 fs::remove_dir_all(&dir).unwrap();
390 }
391
392 #[test]
393 fn lock_creates_lock_file() {
394 let dir = std::env::temp_dir().join("murk_test_lock_create");
395 let _ = fs::remove_dir_all(&dir);
396 fs::create_dir_all(&dir).unwrap();
397 let vault_path = dir.join("test.murk");
398
399 let lock = lock(&vault_path).unwrap();
400 assert!(lock_path(&vault_path).exists());
401
402 drop(lock);
403 fs::remove_dir_all(&dir).unwrap();
404 }
405
406 #[cfg(unix)]
407 #[test]
408 fn lock_rejects_symlink() {
409 let dir = std::env::temp_dir().join("murk_test_lock_symlink");
410 let _ = fs::remove_dir_all(&dir);
411 fs::create_dir_all(&dir).unwrap();
412 let vault_path = dir.join("test.murk");
413 let lp = lock_path(&vault_path);
414
415 std::os::unix::fs::symlink("/tmp/evil", &lp).unwrap();
417
418 let result = lock(&vault_path);
419 assert!(result.is_err());
420 let msg = result.unwrap_err().to_string();
421 assert!(
423 msg.contains("symlink") || msg.contains("symbolic link"),
424 "unexpected error: {msg}"
425 );
426
427 fs::remove_dir_all(&dir).unwrap();
428 }
429
430 #[test]
431 fn write_is_atomic() {
432 let dir = std::env::temp_dir().join("murk_test_write_atomic");
433 let _ = fs::remove_dir_all(&dir);
434 fs::create_dir_all(&dir).unwrap();
435 let path = dir.join("test.murk");
436
437 let vault = test_vault();
438 write(&path, &vault).unwrap();
439
440 let contents = fs::read_to_string(&path).unwrap();
442 let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
443 assert_eq!(parsed["version"], VAULT_VERSION);
444
445 let mut vault2 = test_vault();
447 vault2.vault_name = "updated.murk".into();
448 write(&path, &vault2).unwrap();
449 let contents2 = fs::read_to_string(&path).unwrap();
450 assert!(contents2.contains("updated.murk"));
451
452 fs::remove_dir_all(&dir).unwrap();
453 }
454
455 #[test]
456 fn schema_entry_timestamps_roundtrip() {
457 let dir = std::env::temp_dir().join("murk_test_timestamps");
458 let _ = fs::remove_dir_all(&dir);
459 fs::create_dir_all(&dir).unwrap();
460 let path = dir.join("test.murk");
461
462 let mut vault = test_vault();
463 vault.schema.insert(
464 "TIMED_KEY".into(),
465 SchemaEntry {
466 description: "has timestamps".into(),
467 created: Some("2026-03-29T00:00:00Z".into()),
468 updated: Some("2026-03-29T12:00:00Z".into()),
469 ..Default::default()
470 },
471 );
472
473 write(&path, &vault).unwrap();
474 let read_vault = read(&path).unwrap();
475 let entry = &read_vault.schema["TIMED_KEY"];
476 assert_eq!(entry.created.as_deref(), Some("2026-03-29T00:00:00Z"));
477 assert_eq!(entry.updated.as_deref(), Some("2026-03-29T12:00:00Z"));
478
479 fs::remove_dir_all(&dir).unwrap();
480 }
481
482 #[test]
483 fn schema_entry_without_timestamps_roundtrips() {
484 let dir = std::env::temp_dir().join("murk_test_no_timestamps");
485 let _ = fs::remove_dir_all(&dir);
486 fs::create_dir_all(&dir).unwrap();
487 let path = dir.join("test.murk");
488
489 let mut vault = test_vault();
490 vault.schema.insert(
491 "LEGACY".into(),
492 SchemaEntry {
493 description: "no timestamps".into(),
494 ..Default::default()
495 },
496 );
497
498 write(&path, &vault).unwrap();
499 let contents = fs::read_to_string(&path).unwrap();
500 let legacy_block = &contents[contents.find("LEGACY").unwrap()..];
503 let block_end = legacy_block.find('}').unwrap();
504 let legacy_block = &legacy_block[..block_end];
505 assert!(
506 !legacy_block.contains("created"),
507 "LEGACY entry should not have created timestamp"
508 );
509 assert!(
510 !legacy_block.contains("updated"),
511 "LEGACY entry should not have updated timestamp"
512 );
513
514 let read_vault = read(&path).unwrap();
515 assert!(read_vault.schema["LEGACY"].created.is_none());
516 assert!(read_vault.schema["LEGACY"].updated.is_none());
517
518 fs::remove_dir_all(&dir).unwrap();
519 }
520}