1#![warn(clippy::pedantic)]
7#![allow(
8 clippy::doc_markdown,
9 clippy::cast_possible_wrap,
10 clippy::missing_errors_doc,
11 clippy::missing_panics_doc,
12 clippy::must_use_candidate,
13 clippy::similar_names,
14 clippy::unreadable_literal,
15 clippy::too_many_arguments,
16 clippy::implicit_hasher
17)]
18
19pub mod codename;
21pub mod crypto;
22pub mod env;
23pub mod export;
24pub mod integrity;
25pub mod merge;
26pub mod recipients;
27pub mod recovery;
28pub mod secrets;
29pub mod types;
30pub mod vault;
31
32#[cfg(test)]
34pub mod testutil;
35
36pub use env::{parse_env, resolve_key, warn_env_permissions};
38pub use export::{DiffEntry, DiffKind, diff_secrets, export_secrets, resolve_secrets};
39pub use recipients::{RevokeResult, authorize_recipient, revoke_recipient};
40pub use secrets::{add_secret, describe_key, get_secret, list_keys, remove_secret};
41
42use std::collections::{BTreeMap, HashMap};
43use std::path::Path;
44
45use age::secrecy::ExposeSecret;
46use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
47
48pub fn encrypt_value(
50 plaintext: &[u8],
51 recipients: &[age::x25519::Recipient],
52) -> Result<String, String> {
53 let ciphertext = crypto::encrypt(plaintext, recipients).map_err(|e| e.to_string())?;
54 Ok(BASE64.encode(&ciphertext))
55}
56
57pub fn decrypt_value(encoded: &str, identity: &age::x25519::Identity) -> Result<Vec<u8>, String> {
59 let ciphertext = BASE64
60 .decode(encoded)
61 .map_err(|e| format!("invalid base64: {e}"))?;
62 crypto::decrypt(&ciphertext, identity).map_err(|e| e.to_string())
63}
64
65pub fn load_vault(
69 vault_path: &str,
70) -> Result<(types::Vault, types::Murk, age::x25519::Identity), String> {
71 let path = Path::new(vault_path);
72 let secret_key = resolve_key()?;
73
74 let identity =
75 crypto::parse_identity(secret_key.expose_secret()).map_err(|e| {
76 format!("invalid MURK_KEY (expected AGE-SECRET-KEY-1...): {e}. Run `murk restore` to recover from your 24-word phrase")
77 })?;
78
79 let vault = vault::read(path).map_err(|e| e.to_string())?;
80 let pubkey = identity.to_public().to_string();
81
82 let mut values = HashMap::new();
84 for (key, entry) in &vault.secrets {
85 let plaintext = decrypt_value(&entry.shared, &identity).map_err(|_| {
86 "decryption failed — your MURK_KEY may not be a recipient of this vault. Check with `murk recipients`".to_string()
87 })?;
88 let value = String::from_utf8(plaintext)
89 .map_err(|e| format!("invalid UTF-8 in secret {key}: {e}"))?;
90 values.insert(key.clone(), value);
91 }
92
93 let mut scoped = HashMap::new();
95 for (key, entry) in &vault.secrets {
96 if let Some(encoded) = entry.scoped.get(&pubkey) {
97 if let Ok(plaintext) = decrypt_value(encoded, &identity) {
98 if let Ok(value) = String::from_utf8(plaintext) {
99 scoped
100 .entry(key.clone())
101 .or_insert_with(HashMap::new)
102 .insert(pubkey.clone(), value);
103 }
104 }
105 }
106 }
107
108 let recipients = if vault.meta.is_empty() {
110 HashMap::new()
111 } else if let Ok(plaintext) = decrypt_value(&vault.meta, &identity) {
112 let meta: types::Meta =
113 serde_json::from_slice(&plaintext).unwrap_or_else(|_| types::Meta {
114 recipients: HashMap::new(),
115 mac: String::new(),
116 });
117
118 if !meta.mac.is_empty() {
120 let expected = compute_mac(&vault);
121 if meta.mac != expected {
122 return Err(format!(
123 "integrity check failed: vault may have been tampered with (expected {}, got {})",
124 meta.mac, expected
125 ));
126 }
127 }
128
129 meta.recipients
130 } else {
131 HashMap::new()
132 };
133
134 let murk = types::Murk {
135 values,
136 recipients,
137 scoped,
138 };
139
140 Ok((vault, murk, identity))
141}
142
143pub fn save_vault(
146 vault_path: &str,
147 vault: &mut types::Vault,
148 original: &types::Murk,
149 current: &types::Murk,
150) -> Result<(), String> {
151 let recipients: Vec<age::x25519::Recipient> = vault
152 .recipients
153 .iter()
154 .map(|pk| crypto::parse_recipient(pk).map_err(|e| e.to_string()))
155 .collect::<Result<Vec<_>, _>>()?;
156
157 let recipients_changed = {
159 let mut current_pks: Vec<&str> = vault.recipients.iter().map(String::as_str).collect();
160 let mut original_pks: Vec<&str> = original.recipients.keys().map(String::as_str).collect();
161 current_pks.sort_unstable();
162 original_pks.sort_unstable();
163 current_pks != original_pks
164 };
165
166 let mut new_secrets = BTreeMap::new();
167
168 for (key, value) in ¤t.values {
169 let shared = if !recipients_changed && original.values.get(key) == Some(value) {
171 if let Some(existing) = vault.secrets.get(key) {
173 existing.shared.clone()
174 } else {
175 encrypt_value(value.as_bytes(), &recipients)?
176 }
177 } else {
178 encrypt_value(value.as_bytes(), &recipients)?
179 };
180
181 let mut scoped = vault
183 .secrets
184 .get(key)
185 .map(|e| e.scoped.clone())
186 .unwrap_or_default();
187
188 if let Some(key_scoped) = current.scoped.get(key) {
190 for (pk, val) in key_scoped {
191 let original_val = original.scoped.get(key).and_then(|m| m.get(pk));
192 if original_val == Some(val) {
193 } else {
195 let recipient = crypto::parse_recipient(pk).map_err(|e| e.to_string())?;
197 scoped.insert(pk.clone(), encrypt_value(val.as_bytes(), &[recipient])?);
198 }
199 }
200 }
201
202 if let Some(orig_key_scoped) = original.scoped.get(key) {
204 for pk in orig_key_scoped.keys() {
205 let still_present = current.scoped.get(key).is_some_and(|m| m.contains_key(pk));
206 if !still_present {
207 scoped.remove(pk);
208 }
209 }
210 }
211
212 new_secrets.insert(key.clone(), types::SecretEntry { shared, scoped });
213 }
214
215 vault.secrets = new_secrets;
216
217 let mac = compute_mac(vault);
219 let meta = types::Meta {
220 recipients: current.recipients.clone(),
221 mac,
222 };
223 let meta_json = serde_json::to_vec(&meta).map_err(|e| e.to_string())?;
224 vault.meta = encrypt_value(&meta_json, &recipients)?;
225
226 vault::write(Path::new(vault_path), vault).map_err(|e| e.to_string())
227}
228
229pub fn compute_mac(vault: &types::Vault) -> String {
232 use sha2::{Digest, Sha256};
233
234 let mut hasher = Sha256::new();
235
236 for key in vault.secrets.keys() {
238 hasher.update(key.as_bytes());
239 hasher.update(b"\x00");
240 }
241
242 for entry in vault.secrets.values() {
244 hasher.update(entry.shared.as_bytes());
245 hasher.update(b"\x00");
246 }
247
248 let mut pks = vault.recipients.clone();
250 pks.sort();
251 for pk in &pks {
252 hasher.update(pk.as_bytes());
253 hasher.update(b"\x00");
254 }
255
256 let digest = hasher.finalize();
257 format!(
258 "sha256:{}",
259 digest.iter().fold(String::new(), |mut s, b| {
260 use std::fmt::Write;
261 write!(s, "{b:02x}").unwrap();
262 s
263 })
264 )
265}
266
267pub fn now_utc() -> String {
269 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use crate::testutil::*;
276 use std::collections::BTreeMap;
277 use std::fs;
278
279 #[test]
280 fn encrypt_decrypt_value_roundtrip() {
281 let (secret, pubkey) = generate_keypair();
282 let recipient = make_recipient(&pubkey);
283 let identity = make_identity(&secret);
284
285 let encoded = encrypt_value(b"hello world", &[recipient]).unwrap();
286 let decrypted = decrypt_value(&encoded, &identity).unwrap();
287 assert_eq!(decrypted, b"hello world");
288 }
289
290 #[test]
291 fn decrypt_value_invalid_base64() {
292 let (secret, _) = generate_keypair();
293 let identity = make_identity(&secret);
294
295 let result = decrypt_value("not!valid!base64!!!", &identity);
296 assert!(result.is_err());
297 assert!(result.unwrap_err().contains("invalid base64"));
298 }
299
300 #[test]
301 fn encrypt_value_multiple_recipients() {
302 let (secret_a, pubkey_a) = generate_keypair();
303 let (secret_b, pubkey_b) = generate_keypair();
304
305 let recipients = vec![make_recipient(&pubkey_a), make_recipient(&pubkey_b)];
306 let encoded = encrypt_value(b"shared secret", &recipients).unwrap();
307
308 let id_a = make_identity(&secret_a);
310 let id_b = make_identity(&secret_b);
311 assert_eq!(decrypt_value(&encoded, &id_a).unwrap(), b"shared secret");
312 assert_eq!(decrypt_value(&encoded, &id_b).unwrap(), b"shared secret");
313 }
314
315 #[test]
316 fn decrypt_value_wrong_key_fails() {
317 let (_, pubkey) = generate_keypair();
318 let (wrong_secret, _) = generate_keypair();
319
320 let recipient = make_recipient(&pubkey);
321 let wrong_identity = make_identity(&wrong_secret);
322
323 let encoded = encrypt_value(b"secret", &[recipient]).unwrap();
324 assert!(decrypt_value(&encoded, &wrong_identity).is_err());
325 }
326
327 #[test]
328 fn compute_mac_deterministic() {
329 let vault = types::Vault {
330 version: "2.0".into(),
331 created: "2026-02-28T00:00:00Z".into(),
332 vault_name: ".murk".into(),
333 repo: String::new(),
334 recipients: vec!["age1abc".into()],
335 schema: BTreeMap::new(),
336 secrets: BTreeMap::new(),
337 meta: String::new(),
338 };
339
340 let mac1 = compute_mac(&vault);
341 let mac2 = compute_mac(&vault);
342 assert_eq!(mac1, mac2);
343 assert!(mac1.starts_with("sha256:"));
344 }
345
346 #[test]
347 fn compute_mac_changes_with_different_secrets() {
348 let mut vault = types::Vault {
349 version: "2.0".into(),
350 created: "2026-02-28T00:00:00Z".into(),
351 vault_name: ".murk".into(),
352 repo: String::new(),
353 recipients: vec!["age1abc".into()],
354 schema: BTreeMap::new(),
355 secrets: BTreeMap::new(),
356 meta: String::new(),
357 };
358
359 let mac_empty = compute_mac(&vault);
360
361 vault.secrets.insert(
362 "KEY".into(),
363 types::SecretEntry {
364 shared: "ciphertext".into(),
365 scoped: BTreeMap::new(),
366 },
367 );
368
369 let mac_with_secret = compute_mac(&vault);
370 assert_ne!(mac_empty, mac_with_secret);
371 }
372
373 #[test]
374 fn compute_mac_changes_with_different_recipients() {
375 let mut vault = types::Vault {
376 version: "2.0".into(),
377 created: "2026-02-28T00:00:00Z".into(),
378 vault_name: ".murk".into(),
379 repo: String::new(),
380 recipients: vec!["age1abc".into()],
381 schema: BTreeMap::new(),
382 secrets: BTreeMap::new(),
383 meta: String::new(),
384 };
385
386 let mac1 = compute_mac(&vault);
387 vault.recipients.push("age1xyz".into());
388 let mac2 = compute_mac(&vault);
389 assert_ne!(mac1, mac2);
390 }
391
392 #[test]
393 fn save_vault_preserves_unchanged_ciphertext() {
394 let (secret, pubkey) = generate_keypair();
395 let recipient = make_recipient(&pubkey);
396 let identity = make_identity(&secret);
397
398 let dir = std::env::temp_dir().join("murk_test_save_unchanged");
399 fs::create_dir_all(&dir).unwrap();
400 let path = dir.join("test.murk");
401
402 let shared = encrypt_value(b"original", &[recipient.clone()]).unwrap();
403 let mut vault = types::Vault {
404 version: "2.0".into(),
405 created: "2026-02-28T00:00:00Z".into(),
406 vault_name: ".murk".into(),
407 repo: String::new(),
408 recipients: vec![pubkey.clone()],
409 schema: BTreeMap::new(),
410 secrets: BTreeMap::new(),
411 meta: String::new(),
412 };
413 vault.secrets.insert(
414 "KEY1".into(),
415 types::SecretEntry {
416 shared: shared.clone(),
417 scoped: BTreeMap::new(),
418 },
419 );
420
421 let mut recipients_map = HashMap::new();
422 recipients_map.insert(pubkey.clone(), "alice".into());
423 let original = types::Murk {
424 values: HashMap::from([("KEY1".into(), "original".into())]),
425 recipients: recipients_map.clone(),
426 scoped: HashMap::new(),
427 };
428
429 let current = original.clone();
430 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
431
432 assert_eq!(vault.secrets["KEY1"].shared, shared);
433
434 let mut changed = current.clone();
435 changed.values.insert("KEY1".into(), "modified".into());
436 save_vault(path.to_str().unwrap(), &mut vault, &original, &changed).unwrap();
437
438 assert_ne!(vault.secrets["KEY1"].shared, shared);
439
440 let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity).unwrap();
441 assert_eq!(decrypted, b"modified");
442
443 fs::remove_dir_all(&dir).unwrap();
444 }
445
446 #[test]
447 fn save_vault_adds_new_secret() {
448 let (_, pubkey) = generate_keypair();
449 let recipient = make_recipient(&pubkey);
450
451 let dir = std::env::temp_dir().join("murk_test_save_add");
452 fs::create_dir_all(&dir).unwrap();
453 let path = dir.join("test.murk");
454
455 let shared = encrypt_value(b"val1", &[recipient.clone()]).unwrap();
456 let mut vault = types::Vault {
457 version: "2.0".into(),
458 created: "2026-02-28T00:00:00Z".into(),
459 vault_name: ".murk".into(),
460 repo: String::new(),
461 recipients: vec![pubkey.clone()],
462 schema: BTreeMap::new(),
463 secrets: BTreeMap::new(),
464 meta: String::new(),
465 };
466 vault.secrets.insert(
467 "KEY1".into(),
468 types::SecretEntry {
469 shared,
470 scoped: BTreeMap::new(),
471 },
472 );
473
474 let mut recipients_map = HashMap::new();
475 recipients_map.insert(pubkey.clone(), "alice".into());
476 let original = types::Murk {
477 values: HashMap::from([("KEY1".into(), "val1".into())]),
478 recipients: recipients_map.clone(),
479 scoped: HashMap::new(),
480 };
481
482 let mut current = original.clone();
483 current.values.insert("KEY2".into(), "val2".into());
484
485 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
486
487 assert!(vault.secrets.contains_key("KEY1"));
488 assert!(vault.secrets.contains_key("KEY2"));
489
490 fs::remove_dir_all(&dir).unwrap();
491 }
492
493 #[test]
494 fn save_vault_removes_deleted_secret() {
495 let (_, pubkey) = generate_keypair();
496 let recipient = make_recipient(&pubkey);
497
498 let dir = std::env::temp_dir().join("murk_test_save_remove");
499 fs::create_dir_all(&dir).unwrap();
500 let path = dir.join("test.murk");
501
502 let mut vault = types::Vault {
503 version: "2.0".into(),
504 created: "2026-02-28T00:00:00Z".into(),
505 vault_name: ".murk".into(),
506 repo: String::new(),
507 recipients: vec![pubkey.clone()],
508 schema: BTreeMap::new(),
509 secrets: BTreeMap::new(),
510 meta: String::new(),
511 };
512 vault.secrets.insert(
513 "KEY1".into(),
514 types::SecretEntry {
515 shared: encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
516 scoped: BTreeMap::new(),
517 },
518 );
519 vault.secrets.insert(
520 "KEY2".into(),
521 types::SecretEntry {
522 shared: encrypt_value(b"val2", &[recipient.clone()]).unwrap(),
523 scoped: BTreeMap::new(),
524 },
525 );
526
527 let mut recipients_map = HashMap::new();
528 recipients_map.insert(pubkey.clone(), "alice".into());
529 let original = types::Murk {
530 values: HashMap::from([
531 ("KEY1".into(), "val1".into()),
532 ("KEY2".into(), "val2".into()),
533 ]),
534 recipients: recipients_map.clone(),
535 scoped: HashMap::new(),
536 };
537
538 let mut current = original.clone();
539 current.values.remove("KEY2");
540
541 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
542
543 assert!(vault.secrets.contains_key("KEY1"));
544 assert!(!vault.secrets.contains_key("KEY2"));
545
546 fs::remove_dir_all(&dir).unwrap();
547 }
548
549 #[test]
550 fn save_vault_reencrypts_all_on_recipient_change() {
551 let (secret1, pubkey1) = generate_keypair();
552 let (_, pubkey2) = generate_keypair();
553 let recipient1 = make_recipient(&pubkey1);
554
555 let dir = std::env::temp_dir().join("murk_test_save_reencrypt");
556 fs::create_dir_all(&dir).unwrap();
557 let path = dir.join("test.murk");
558
559 let shared = encrypt_value(b"val1", &[recipient1.clone()]).unwrap();
560 let mut vault = types::Vault {
561 version: "2.0".into(),
562 created: "2026-02-28T00:00:00Z".into(),
563 vault_name: ".murk".into(),
564 repo: String::new(),
565 recipients: vec![pubkey1.clone(), pubkey2.clone()],
566 schema: BTreeMap::new(),
567 secrets: BTreeMap::new(),
568 meta: String::new(),
569 };
570 vault.secrets.insert(
571 "KEY1".into(),
572 types::SecretEntry {
573 shared: shared.clone(),
574 scoped: BTreeMap::new(),
575 },
576 );
577
578 let mut recipients_map = HashMap::new();
579 recipients_map.insert(pubkey1.clone(), "alice".into());
580 let original = types::Murk {
581 values: HashMap::from([("KEY1".into(), "val1".into())]),
582 recipients: recipients_map,
583 scoped: HashMap::new(),
584 };
585
586 let mut current_recipients = HashMap::new();
587 current_recipients.insert(pubkey1.clone(), "alice".into());
588 current_recipients.insert(pubkey2.clone(), "bob".into());
589 let current = types::Murk {
590 values: HashMap::from([("KEY1".into(), "val1".into())]),
591 recipients: current_recipients,
592 scoped: HashMap::new(),
593 };
594
595 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
596
597 assert_ne!(vault.secrets["KEY1"].shared, shared);
598
599 let identity1 = make_identity(&secret1);
600 let decrypted = decrypt_value(&vault.secrets["KEY1"].shared, &identity1).unwrap();
601 assert_eq!(decrypted, b"val1");
602
603 fs::remove_dir_all(&dir).unwrap();
604 }
605
606 #[test]
607 fn save_vault_scoped_entry_lifecycle() {
608 let (secret, pubkey) = generate_keypair();
609 let recipient = make_recipient(&pubkey);
610 let identity = make_identity(&secret);
611
612 let dir = std::env::temp_dir().join("murk_test_save_scoped");
613 fs::create_dir_all(&dir).unwrap();
614 let path = dir.join("test.murk");
615
616 let shared = encrypt_value(b"shared_val", &[recipient.clone()]).unwrap();
617 let mut vault = types::Vault {
618 version: "2.0".into(),
619 created: "2026-02-28T00:00:00Z".into(),
620 vault_name: ".murk".into(),
621 repo: String::new(),
622 recipients: vec![pubkey.clone()],
623 schema: BTreeMap::new(),
624 secrets: BTreeMap::new(),
625 meta: String::new(),
626 };
627 vault.secrets.insert(
628 "KEY1".into(),
629 types::SecretEntry {
630 shared,
631 scoped: BTreeMap::new(),
632 },
633 );
634
635 let mut recipients_map = HashMap::new();
636 recipients_map.insert(pubkey.clone(), "alice".into());
637 let original = types::Murk {
638 values: HashMap::from([("KEY1".into(), "shared_val".into())]),
639 recipients: recipients_map.clone(),
640 scoped: HashMap::new(),
641 };
642
643 let mut current = original.clone();
645 let mut key_scoped = HashMap::new();
646 key_scoped.insert(pubkey.clone(), "my_override".into());
647 current.scoped.insert("KEY1".into(), key_scoped);
648
649 save_vault(path.to_str().unwrap(), &mut vault, &original, ¤t).unwrap();
650
651 assert!(vault.secrets["KEY1"].scoped.contains_key(&pubkey));
652 let scoped_val = decrypt_value(&vault.secrets["KEY1"].scoped[&pubkey], &identity).unwrap();
653 assert_eq!(scoped_val, b"my_override");
654
655 let original_with_scoped = current.clone();
657 let mut current_no_scoped = original_with_scoped.clone();
658 current_no_scoped.scoped.remove("KEY1");
659
660 save_vault(
661 path.to_str().unwrap(),
662 &mut vault,
663 &original_with_scoped,
664 ¤t_no_scoped,
665 )
666 .unwrap();
667
668 assert!(vault.secrets["KEY1"].scoped.is_empty());
669
670 fs::remove_dir_all(&dir).unwrap();
671 }
672
673 #[test]
674 fn load_vault_validates_mac() {
675 let (secret, pubkey) = generate_keypair();
676 let recipient = make_recipient(&pubkey);
677 let identity = make_identity(&secret);
678
679 let dir = std::env::temp_dir().join("murk_test_load_mac");
680 fs::create_dir_all(&dir).unwrap();
681 let path = dir.join("test.murk");
682
683 let mut vault = types::Vault {
685 version: "2.0".into(),
686 created: "2026-02-28T00:00:00Z".into(),
687 vault_name: ".murk".into(),
688 repo: String::new(),
689 recipients: vec![pubkey.clone()],
690 schema: BTreeMap::new(),
691 secrets: BTreeMap::new(),
692 meta: String::new(),
693 };
694 vault.secrets.insert(
695 "KEY1".into(),
696 types::SecretEntry {
697 shared: encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
698 scoped: BTreeMap::new(),
699 },
700 );
701
702 let mut recipients_map = HashMap::new();
703 recipients_map.insert(pubkey.clone(), "alice".into());
704 let original = types::Murk {
705 values: HashMap::from([("KEY1".into(), "val1".into())]),
706 recipients: recipients_map,
707 scoped: HashMap::new(),
708 };
709
710 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
711
712 let mut tampered: types::Vault =
714 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
715 tampered.secrets.get_mut("KEY1").unwrap().shared =
716 encrypt_value(b"tampered", &[recipient]).unwrap();
717 fs::write(&path, serde_json::to_string_pretty(&tampered).unwrap()).unwrap();
718
719 unsafe { std::env::set_var("MURK_KEY", secret) };
721 unsafe { std::env::remove_var("MURK_KEY_FILE") };
722 let result = load_vault(path.to_str().unwrap());
723 unsafe { std::env::remove_var("MURK_KEY") };
724
725 let err = result.err().expect("expected MAC validation to fail");
726 assert!(
727 err.contains("integrity check failed"),
728 "expected integrity check failure, got: {err}"
729 );
730
731 fs::remove_dir_all(&dir).unwrap();
732 }
733
734 #[test]
735 fn load_vault_succeeds_with_valid_mac() {
736 let (secret, pubkey) = generate_keypair();
737 let recipient = make_recipient(&pubkey);
738
739 let dir = std::env::temp_dir().join("murk_test_load_valid_mac");
740 fs::create_dir_all(&dir).unwrap();
741 let path = dir.join("test.murk");
742
743 let mut vault = types::Vault {
744 version: "2.0".into(),
745 created: "2026-02-28T00:00:00Z".into(),
746 vault_name: ".murk".into(),
747 repo: String::new(),
748 recipients: vec![pubkey.clone()],
749 schema: BTreeMap::new(),
750 secrets: BTreeMap::new(),
751 meta: String::new(),
752 };
753 vault.secrets.insert(
754 "KEY1".into(),
755 types::SecretEntry {
756 shared: encrypt_value(b"val1", &[recipient]).unwrap(),
757 scoped: BTreeMap::new(),
758 },
759 );
760
761 let mut recipients_map = HashMap::new();
762 recipients_map.insert(pubkey.clone(), "alice".into());
763 let original = types::Murk {
764 values: HashMap::from([("KEY1".into(), "val1".into())]),
765 recipients: recipients_map,
766 scoped: HashMap::new(),
767 };
768
769 save_vault(path.to_str().unwrap(), &mut vault, &original, &original).unwrap();
770
771 unsafe { std::env::set_var("MURK_KEY", secret) };
773 unsafe { std::env::remove_var("MURK_KEY_FILE") };
774 let result = load_vault(path.to_str().unwrap());
775 unsafe { std::env::remove_var("MURK_KEY") };
776
777 assert!(result.is_ok());
778 let (_, murk, _) = result.unwrap();
779 assert_eq!(murk.values["KEY1"], "val1");
780
781 fs::remove_dir_all(&dir).unwrap();
782 }
783
784 #[test]
785 fn now_utc_format() {
786 let ts = now_utc();
787 assert!(ts.ends_with('Z'));
788 assert_eq!(ts.len(), 20);
789 assert_eq!(&ts[4..5], "-");
790 assert_eq!(&ts[7..8], "-");
791 assert_eq!(&ts[10..11], "T");
792 }
793}