Skip to main content

murk_cli/
export.rs

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