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