1use std::collections::{BTreeMap, HashMap};
4
5use zeroize::Zeroizing;
6
7use crate::types;
8
9pub fn resolve_secrets(
15 vault: &types::Vault,
16 murk: &types::Murk,
17 pubkey: &str,
18 tags: &[String],
19) -> BTreeMap<String, Zeroizing<String>> {
20 let mut values = murk
21 .values
22 .iter()
23 .map(|(k, v)| (k.clone(), v.clone()))
24 .collect::<HashMap<String, Zeroizing<String>>>();
25
26 for (key, scoped_map) in &murk.scoped {
28 if let Some(value) = scoped_map.get(pubkey) {
29 values.insert(key.clone(), value.clone());
30 }
31 }
32
33 let allowed_keys: Option<std::collections::HashSet<&str>> = if tags.is_empty() {
35 None
36 } else {
37 Some(
38 vault
39 .schema
40 .iter()
41 .filter(|(_, e)| e.tags.iter().any(|t| tags.contains(t)))
42 .map(|(k, _)| k.as_str())
43 .collect(),
44 )
45 };
46
47 let mut result = BTreeMap::new();
48 for (k, v) in values {
49 if allowed_keys
50 .as_ref()
51 .is_some_and(|a| !a.contains(k.as_str()))
52 {
53 continue;
54 }
55 result.insert(k, v);
56 }
57 result
58}
59
60pub fn export_secrets(
63 vault: &types::Vault,
64 murk: &types::Murk,
65 pubkey: &str,
66 tags: &[String],
67) -> BTreeMap<String, Zeroizing<String>> {
68 resolve_secrets(vault, murk, pubkey, tags)
69 .into_iter()
70 .map(|(k, v)| (k, Zeroizing::new(v.replace('\'', "'\\''"))))
71 .collect()
72}
73
74pub fn decrypt_vault_values(
79 vault: &types::Vault,
80 identity: &crate::crypto::MurkIdentity,
81) -> HashMap<String, Zeroizing<String>> {
82 let pubkey = identity.pubkey_string().unwrap_or_default();
83 let mut values = HashMap::new();
84 for (key, entry) in &vault.secrets {
85 if !entry.shared.is_empty()
87 && let Ok(value) = crate::decrypt_value(&entry.shared, identity).and_then(|pt| {
88 crate::plaintext_bytes_to_zeroizing_string(&pt)
89 .map_err(|e| crate::error::MurkError::Secret(e.to_string()))
90 })
91 {
92 values.insert(key.clone(), value);
93 }
94 if let Some(encoded) = entry.scoped.get(&pubkey)
96 && let Ok(value) = crate::decrypt_value(encoded, identity).and_then(|pt| {
97 crate::plaintext_bytes_to_zeroizing_string(&pt)
98 .map_err(|e| crate::error::MurkError::Secret(e.to_string()))
99 })
100 {
101 values.insert(key.clone(), value);
102 }
103 }
104 values
105}
106
107pub fn parse_and_decrypt_values(
112 vault_contents: &str,
113 identity: &crate::crypto::MurkIdentity,
114) -> Result<HashMap<String, Zeroizing<String>>, String> {
115 let vault = crate::vault::parse(vault_contents).map_err(|e| e.to_string())?;
116 Ok(decrypt_vault_values(&vault, identity))
117}
118
119#[derive(Debug, PartialEq, Eq)]
121pub enum DiffKind {
122 Added,
123 Removed,
124 Changed,
125}
126
127#[derive(Debug)]
133pub struct DiffEntry {
134 pub key: String,
135 pub kind: DiffKind,
136 pub old_value: Option<Zeroizing<String>>,
137 pub new_value: Option<Zeroizing<String>>,
138}
139
140pub fn diff_secrets(
142 old: &HashMap<String, Zeroizing<String>>,
143 new: &HashMap<String, Zeroizing<String>>,
144) -> Vec<DiffEntry> {
145 let mut all_keys: Vec<&str> = old
146 .keys()
147 .chain(new.keys())
148 .map(String::as_str)
149 .collect::<std::collections::HashSet<_>>()
150 .into_iter()
151 .collect();
152 all_keys.sort_unstable();
153
154 let mut entries = Vec::new();
155 for key in all_keys {
156 match (old.get(key), new.get(key)) {
157 (None, Some(v)) => entries.push(DiffEntry {
158 key: key.into(),
159 kind: DiffKind::Added,
160 old_value: None,
161 new_value: Some(v.clone()),
162 }),
163 (Some(v), None) => entries.push(DiffEntry {
164 key: key.into(),
165 kind: DiffKind::Removed,
166 old_value: Some(v.clone()),
167 new_value: None,
168 }),
169 (Some(old_v), Some(new_v)) if **old_v != **new_v => entries.push(DiffEntry {
170 key: key.into(),
171 kind: DiffKind::Changed,
172 old_value: Some(old_v.clone()),
173 new_value: Some(new_v.clone()),
174 }),
175 _ => {}
176 }
177 }
178 entries
179}
180
181pub fn format_diff_lines(entries: &[DiffEntry], show_values: bool) -> Vec<String> {
184 entries
185 .iter()
186 .map(|entry| {
187 let symbol = match entry.kind {
188 DiffKind::Added => "+",
189 DiffKind::Removed => "-",
190 DiffKind::Changed => "~",
191 };
192 if show_values {
193 let old = entry.old_value.as_ref().map_or("", |v| v.as_str());
194 let new = entry.new_value.as_ref().map_or("", |v| v.as_str());
195 match entry.kind {
196 DiffKind::Added => format!("{symbol} {} = {}", entry.key, new),
197 DiffKind::Removed => format!("{symbol} {} = {}", entry.key, old),
198 DiffKind::Changed => {
199 format!("{symbol} {} {} → {}", entry.key, old, new)
200 }
201 }
202 } else {
203 format!("{symbol} {}", entry.key)
204 }
205 })
206 .collect()
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use crate::testutil::*;
213 use crate::types;
214
215 #[test]
216 fn export_secrets_basic() {
217 let mut vault = empty_vault();
218 vault.schema.insert(
219 "FOO".into(),
220 types::SchemaEntry {
221 description: String::new(),
222 example: None,
223 tags: vec![],
224 ..Default::default()
225 },
226 );
227
228 let mut murk = empty_murk();
229 murk.values.insert("FOO".into(), secret("bar"));
230
231 let exports = export_secrets(&vault, &murk, "age1pk", &[]);
232 assert_eq!(exports.len(), 1);
233 assert_eq!(exports["FOO"].as_str(), "bar");
234 }
235
236 #[test]
237 fn export_secrets_scoped_override() {
238 let mut vault = empty_vault();
239 vault.schema.insert(
240 "KEY".into(),
241 types::SchemaEntry {
242 description: String::new(),
243 example: None,
244 tags: vec![],
245 ..Default::default()
246 },
247 );
248
249 let mut murk = empty_murk();
250 murk.values.insert("KEY".into(), secret("shared"));
251 let mut scoped = HashMap::new();
252 scoped.insert("age1pk".into(), secret("override"));
253 murk.scoped.insert("KEY".into(), scoped);
254
255 let exports = export_secrets(&vault, &murk, "age1pk", &[]);
256 assert_eq!(exports["KEY"].as_str(), "override");
257 }
258
259 #[test]
260 fn export_secrets_tag_filter() {
261 let mut vault = empty_vault();
262 vault.schema.insert(
263 "A".into(),
264 types::SchemaEntry {
265 description: String::new(),
266 example: None,
267 tags: vec!["db".into()],
268 ..Default::default()
269 },
270 );
271 vault.schema.insert(
272 "B".into(),
273 types::SchemaEntry {
274 description: String::new(),
275 example: None,
276 tags: vec!["api".into()],
277 ..Default::default()
278 },
279 );
280
281 let mut murk = empty_murk();
282 murk.values.insert("A".into(), secret("val_a"));
283 murk.values.insert("B".into(), secret("val_b"));
284
285 let exports = export_secrets(&vault, &murk, "age1pk", &["db".into()]);
286 assert_eq!(exports.len(), 1);
287 assert_eq!(exports["A"].as_str(), "val_a");
288 }
289
290 #[test]
291 fn export_secrets_shell_escaping() {
292 let mut vault = empty_vault();
293 vault.schema.insert(
294 "KEY".into(),
295 types::SchemaEntry {
296 description: String::new(),
297 example: None,
298 tags: vec![],
299 ..Default::default()
300 },
301 );
302
303 let mut murk = empty_murk();
304 murk.values.insert("KEY".into(), secret("it's a test"));
305
306 let exports = export_secrets(&vault, &murk, "age1pk", &[]);
307 assert_eq!(exports["KEY"].as_str(), "it'\\''s a test");
308 }
309
310 #[test]
311 fn diff_secrets_no_changes() {
312 let old = HashMap::from([("K".into(), secret("V"))]);
313 let new = old.clone();
314 assert!(diff_secrets(&old, &new).is_empty());
315 }
316
317 #[test]
318 fn diff_secrets_added() {
319 let old = HashMap::new();
320 let new = HashMap::from([("KEY".into(), secret("val"))]);
321 let entries = diff_secrets(&old, &new);
322 assert_eq!(entries.len(), 1);
323 assert_eq!(entries[0].kind, DiffKind::Added);
324 assert_eq!(entries[0].key, "KEY");
325 assert_eq!(entries[0].new_value.as_deref(), Some(&String::from("val")));
326 }
327
328 #[test]
329 fn diff_secrets_removed() {
330 let old = HashMap::from([("KEY".into(), secret("val"))]);
331 let new = HashMap::new();
332 let entries = diff_secrets(&old, &new);
333 assert_eq!(entries.len(), 1);
334 assert_eq!(entries[0].kind, DiffKind::Removed);
335 assert_eq!(entries[0].old_value.as_deref(), Some(&String::from("val")));
336 }
337
338 #[test]
339 fn diff_secrets_changed() {
340 let old = HashMap::from([("KEY".into(), secret("old_val"))]);
341 let new = HashMap::from([("KEY".into(), secret("new_val"))]);
342 let entries = diff_secrets(&old, &new);
343 assert_eq!(entries.len(), 1);
344 assert_eq!(entries[0].kind, DiffKind::Changed);
345 assert_eq!(
346 entries[0].old_value.as_deref(),
347 Some(&String::from("old_val"))
348 );
349 assert_eq!(
350 entries[0].new_value.as_deref(),
351 Some(&String::from("new_val"))
352 );
353 }
354
355 #[test]
356 fn diff_secrets_mixed() {
357 let old = HashMap::from([
358 ("KEEP".into(), secret("same")),
359 ("REMOVE".into(), secret("gone")),
360 ("CHANGE".into(), secret("old")),
361 ]);
362 let new = HashMap::from([
363 ("KEEP".into(), secret("same")),
364 ("ADD".into(), secret("new")),
365 ("CHANGE".into(), secret("new")),
366 ]);
367 let entries = diff_secrets(&old, &new);
368 assert_eq!(entries.len(), 3);
369
370 let kinds: Vec<&DiffKind> = entries.iter().map(|e| &e.kind).collect();
371 assert!(kinds.contains(&&DiffKind::Added));
372 assert!(kinds.contains(&&DiffKind::Removed));
373 assert!(kinds.contains(&&DiffKind::Changed));
374 }
375
376 #[test]
377 fn diff_secrets_sorted_by_key() {
378 let old = HashMap::new();
379 let new = HashMap::from([
380 ("Z".into(), secret("z")),
381 ("A".into(), secret("a")),
382 ("M".into(), secret("m")),
383 ]);
384 let entries = diff_secrets(&old, &new);
385 let keys: Vec<&str> = entries.iter().map(|e| e.key.as_str()).collect();
386 assert_eq!(keys, vec!["A", "M", "Z"]);
387 }
388
389 #[test]
392 fn format_diff_lines_without_values() {
393 let entries = vec![
394 DiffEntry {
395 key: "NEW_KEY".into(),
396 kind: DiffKind::Added,
397 old_value: None,
398 new_value: Some(secret("secret")),
399 },
400 DiffEntry {
401 key: "OLD_KEY".into(),
402 kind: DiffKind::Removed,
403 old_value: Some(secret("old")),
404 new_value: None,
405 },
406 DiffEntry {
407 key: "MOD_KEY".into(),
408 kind: DiffKind::Changed,
409 old_value: Some(secret("v1")),
410 new_value: Some(secret("v2")),
411 },
412 ];
413 let lines = format_diff_lines(&entries, false);
414 assert_eq!(lines, vec!["+ NEW_KEY", "- OLD_KEY", "~ MOD_KEY"]);
415 }
416
417 #[test]
418 fn format_diff_lines_with_values() {
419 let entries = vec![
420 DiffEntry {
421 key: "KEY".into(),
422 kind: DiffKind::Added,
423 old_value: None,
424 new_value: Some(secret("new_val")),
425 },
426 DiffEntry {
427 key: "KEY2".into(),
428 kind: DiffKind::Changed,
429 old_value: Some(secret("old")),
430 new_value: Some(secret("new")),
431 },
432 ];
433 let lines = format_diff_lines(&entries, true);
434 assert_eq!(lines[0], "+ KEY = new_val");
435 assert_eq!(lines[1], "~ KEY2 old → new");
436 }
437
438 #[test]
439 fn format_diff_lines_empty() {
440 let lines = format_diff_lines(&[], false);
441 assert!(lines.is_empty());
442 }
443
444 #[test]
447 fn resolve_secrets_basic() {
448 let mut vault = empty_vault();
449 vault.schema.insert(
450 "FOO".into(),
451 types::SchemaEntry {
452 description: String::new(),
453 example: None,
454 tags: vec![],
455 ..Default::default()
456 },
457 );
458
459 let mut murk = empty_murk();
460 murk.values.insert("FOO".into(), secret("bar"));
461
462 let resolved = resolve_secrets(&vault, &murk, "age1pk", &[]);
463 assert_eq!(resolved.len(), 1);
464 assert_eq!(resolved["FOO"].as_str(), "bar");
465 }
466
467 #[test]
468 fn resolve_secrets_no_escaping() {
469 let mut vault = empty_vault();
470 vault.schema.insert(
471 "KEY".into(),
472 types::SchemaEntry {
473 description: String::new(),
474 example: None,
475 tags: vec![],
476 ..Default::default()
477 },
478 );
479
480 let mut murk = empty_murk();
481 murk.values.insert("KEY".into(), secret("it's a test"));
482
483 let resolved = resolve_secrets(&vault, &murk, "age1pk", &[]);
484 assert_eq!(resolved["KEY"].as_str(), "it's a test");
485 }
486
487 #[test]
488 fn resolve_secrets_scoped_override() {
489 let mut vault = empty_vault();
490 vault.schema.insert(
491 "KEY".into(),
492 types::SchemaEntry {
493 description: String::new(),
494 example: None,
495 tags: vec![],
496 ..Default::default()
497 },
498 );
499
500 let mut murk = empty_murk();
501 murk.values.insert("KEY".into(), secret("shared"));
502 let mut scoped = HashMap::new();
503 scoped.insert("age1pk".into(), secret("override"));
504 murk.scoped.insert("KEY".into(), scoped);
505
506 let resolved = resolve_secrets(&vault, &murk, "age1pk", &[]);
507 assert_eq!(resolved["KEY"].as_str(), "override");
508 }
509
510 #[test]
511 fn resolve_secrets_tag_filter() {
512 let mut vault = empty_vault();
513 vault.schema.insert(
514 "A".into(),
515 types::SchemaEntry {
516 description: String::new(),
517 example: None,
518 tags: vec!["db".into()],
519 ..Default::default()
520 },
521 );
522 vault.schema.insert(
523 "B".into(),
524 types::SchemaEntry {
525 description: String::new(),
526 example: None,
527 tags: vec!["api".into()],
528 ..Default::default()
529 },
530 );
531
532 let mut murk = empty_murk();
533 murk.values.insert("A".into(), secret("val_a"));
534 murk.values.insert("B".into(), secret("val_b"));
535
536 let resolved = resolve_secrets(&vault, &murk, "age1pk", &["db".into()]);
537 assert_eq!(resolved.len(), 1);
538 assert_eq!(resolved["A"].as_str(), "val_a");
539 }
540
541 #[test]
542 fn resolve_secrets_tag_in_schema_but_no_secret() {
543 let mut vault = empty_vault();
544 vault.schema.insert(
546 "ORPHAN".into(),
547 types::SchemaEntry {
548 description: "orphan key".into(),
549 example: None,
550 tags: vec!["db".into()],
551 ..Default::default()
552 },
553 );
554 vault.schema.insert(
555 "REAL".into(),
556 types::SchemaEntry {
557 description: "has a value".into(),
558 example: None,
559 tags: vec!["db".into()],
560 ..Default::default()
561 },
562 );
563
564 let mut murk = empty_murk();
565 murk.values.insert("REAL".into(), secret("real_val"));
567
568 let resolved = resolve_secrets(&vault, &murk, "age1pk", &["db".into()]);
569 assert_eq!(resolved.len(), 1);
571 assert_eq!(resolved["REAL"].as_str(), "real_val");
572 assert!(!resolved.contains_key("ORPHAN"));
573 }
574
575 #[test]
576 fn resolve_secrets_scoped_pubkey_not_in_recipients() {
577 let mut vault = empty_vault();
578 vault.recipients = vec!["age1alice".into()];
579 vault.schema.insert(
580 "KEY".into(),
581 types::SchemaEntry {
582 description: String::new(),
583 example: None,
584 tags: vec![],
585 ..Default::default()
586 },
587 );
588
589 let mut murk = empty_murk();
590 murk.values.insert("KEY".into(), secret("shared"));
591 let mut scoped = HashMap::new();
593 scoped.insert("age1outsider".into(), secret("outsider_val"));
594 murk.scoped.insert("KEY".into(), scoped);
595
596 let resolved = resolve_secrets(&vault, &murk, "age1outsider", &[]);
598 assert_eq!(resolved["KEY"].as_str(), "outsider_val");
599
600 let resolved_alice = resolve_secrets(&vault, &murk, "age1alice", &[]);
602 assert_eq!(resolved_alice["KEY"].as_str(), "shared");
603 }
604
605 #[test]
608 fn export_secrets_empty_vault() {
609 let vault = empty_vault();
610 let murk = empty_murk();
611 let exports = export_secrets(&vault, &murk, "age1pk", &[]);
612 assert!(exports.is_empty());
613 }
614
615 #[test]
616 fn decrypt_vault_values_basic() {
617 let (secret, pubkey) = generate_keypair();
618 let recipient = make_recipient(&pubkey);
619 let identity = make_identity(&secret);
620
621 let mut vault = empty_vault();
622 vault.recipients = vec![pubkey];
623 vault.secrets.insert(
624 "KEY1".into(),
625 types::SecretEntry {
626 shared: crate::encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap(),
627 scoped: std::collections::BTreeMap::new(),
628 },
629 );
630 vault.secrets.insert(
631 "KEY2".into(),
632 types::SecretEntry {
633 shared: crate::encrypt_value(b"val2", &[recipient]).unwrap(),
634 scoped: std::collections::BTreeMap::new(),
635 },
636 );
637
638 let values = crate::export::decrypt_vault_values(&vault, &identity);
639 assert_eq!(values.len(), 2);
640 assert_eq!(values["KEY1"].as_str(), "val1");
641 assert_eq!(values["KEY2"].as_str(), "val2");
642 }
643
644 #[test]
645 fn decrypt_vault_values_wrong_key_skips() {
646 let (_, pubkey) = generate_keypair();
647 let recipient = make_recipient(&pubkey);
648 let (wrong_secret, _) = generate_keypair();
649 let wrong_identity = make_identity(&wrong_secret);
650
651 let mut vault = empty_vault();
652 vault.recipients = vec![pubkey];
653 vault.secrets.insert(
654 "KEY1".into(),
655 types::SecretEntry {
656 shared: crate::encrypt_value(b"val1", &[recipient]).unwrap(),
657 scoped: std::collections::BTreeMap::new(),
658 },
659 );
660
661 let values = crate::export::decrypt_vault_values(&vault, &wrong_identity);
662 assert!(values.is_empty());
663 }
664
665 #[test]
666 fn decrypt_vault_values_empty_vault() {
667 let (secret, _) = generate_keypair();
668 let identity = make_identity(&secret);
669 let vault = empty_vault();
670
671 let values = crate::export::decrypt_vault_values(&vault, &identity);
672 assert!(values.is_empty());
673 }
674
675 #[test]
676 fn diff_secrets_both_empty() {
677 let old = HashMap::new();
678 let new = HashMap::new();
679 assert!(diff_secrets(&old, &new).is_empty());
680 }
681
682 #[test]
685 fn parse_and_decrypt_values_roundtrip() {
686 let (secret, pubkey) = generate_keypair();
687 let recipient = make_recipient(&pubkey);
688 let identity = make_identity(&secret);
689
690 let mut vault = empty_vault();
691 vault.recipients = vec![pubkey];
692 vault.secrets.insert(
693 "KEY1".into(),
694 types::SecretEntry {
695 shared: crate::encrypt_value(b"val1", std::slice::from_ref(&recipient)).unwrap(),
696 scoped: std::collections::BTreeMap::new(),
697 },
698 );
699 vault.secrets.insert(
700 "KEY2".into(),
701 types::SecretEntry {
702 shared: crate::encrypt_value(b"val2", &[recipient]).unwrap(),
703 scoped: std::collections::BTreeMap::new(),
704 },
705 );
706
707 let json = serde_json::to_string(&vault).unwrap();
708 let values = parse_and_decrypt_values(&json, &identity).unwrap();
709 assert_eq!(values.len(), 2);
710 assert_eq!(values["KEY1"].as_str(), "val1");
711 assert_eq!(values["KEY2"].as_str(), "val2");
712 }
713
714 #[test]
715 fn parse_and_decrypt_values_invalid_json() {
716 let (secret, _) = generate_keypair();
717 let identity = make_identity(&secret);
718
719 let result = parse_and_decrypt_values("not valid json", &identity);
720 assert!(result.is_err());
721 }
722
723 #[test]
724 fn parse_and_decrypt_values_wrong_key() {
725 let (_, pubkey) = generate_keypair();
726 let recipient = make_recipient(&pubkey);
727 let (wrong_secret, _) = generate_keypair();
728 let wrong_identity = make_identity(&wrong_secret);
729
730 let mut vault = empty_vault();
731 vault.recipients = vec![pubkey];
732 vault.secrets.insert(
733 "KEY1".into(),
734 types::SecretEntry {
735 shared: crate::encrypt_value(b"val1", &[recipient]).unwrap(),
736 scoped: std::collections::BTreeMap::new(),
737 },
738 );
739
740 let json = serde_json::to_string(&vault).unwrap();
741 let values = parse_and_decrypt_values(&json, &wrong_identity).unwrap();
742 assert!(values.is_empty());
743 }
744}