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                ..Default::default()
219            },
220        );
221
222        let mut murk = empty_murk();
223        murk.values.insert("FOO".into(), "bar".into());
224
225        let exports = export_secrets(&vault, &murk, "age1pk", &[]);
226        assert_eq!(exports.len(), 1);
227        assert_eq!(exports["FOO"], "bar");
228    }
229
230    #[test]
231    fn export_secrets_scoped_override() {
232        let mut vault = empty_vault();
233        vault.schema.insert(
234            "KEY".into(),
235            types::SchemaEntry {
236                description: String::new(),
237                example: None,
238                tags: vec![],
239                ..Default::default()
240            },
241        );
242
243        let mut murk = empty_murk();
244        murk.values.insert("KEY".into(), "shared".into());
245        let mut scoped = HashMap::new();
246        scoped.insert("age1pk".into(), "override".into());
247        murk.scoped.insert("KEY".into(), scoped);
248
249        let exports = export_secrets(&vault, &murk, "age1pk", &[]);
250        assert_eq!(exports["KEY"], "override");
251    }
252
253    #[test]
254    fn export_secrets_tag_filter() {
255        let mut vault = empty_vault();
256        vault.schema.insert(
257            "A".into(),
258            types::SchemaEntry {
259                description: String::new(),
260                example: None,
261                tags: vec!["db".into()],
262                ..Default::default()
263            },
264        );
265        vault.schema.insert(
266            "B".into(),
267            types::SchemaEntry {
268                description: String::new(),
269                example: None,
270                tags: vec!["api".into()],
271                ..Default::default()
272            },
273        );
274
275        let mut murk = empty_murk();
276        murk.values.insert("A".into(), "val_a".into());
277        murk.values.insert("B".into(), "val_b".into());
278
279        let exports = export_secrets(&vault, &murk, "age1pk", &["db".into()]);
280        assert_eq!(exports.len(), 1);
281        assert_eq!(exports["A"], "val_a");
282    }
283
284    #[test]
285    fn export_secrets_shell_escaping() {
286        let mut vault = empty_vault();
287        vault.schema.insert(
288            "KEY".into(),
289            types::SchemaEntry {
290                description: String::new(),
291                example: None,
292                tags: vec![],
293                ..Default::default()
294            },
295        );
296
297        let mut murk = empty_murk();
298        murk.values.insert("KEY".into(), "it's a test".into());
299
300        let exports = export_secrets(&vault, &murk, "age1pk", &[]);
301        assert_eq!(exports["KEY"], "it'\\''s a test");
302    }
303
304    #[test]
305    fn diff_secrets_no_changes() {
306        let old = HashMap::from([("K".into(), "V".into())]);
307        let new = old.clone();
308        assert!(diff_secrets(&old, &new).is_empty());
309    }
310
311    #[test]
312    fn diff_secrets_added() {
313        let old = HashMap::new();
314        let new = HashMap::from([("KEY".into(), "val".into())]);
315        let entries = diff_secrets(&old, &new);
316        assert_eq!(entries.len(), 1);
317        assert_eq!(entries[0].kind, DiffKind::Added);
318        assert_eq!(entries[0].key, "KEY");
319        assert_eq!(entries[0].new_value.as_deref(), Some("val"));
320    }
321
322    #[test]
323    fn diff_secrets_removed() {
324        let old = HashMap::from([("KEY".into(), "val".into())]);
325        let new = HashMap::new();
326        let entries = diff_secrets(&old, &new);
327        assert_eq!(entries.len(), 1);
328        assert_eq!(entries[0].kind, DiffKind::Removed);
329        assert_eq!(entries[0].old_value.as_deref(), Some("val"));
330    }
331
332    #[test]
333    fn diff_secrets_changed() {
334        let old = HashMap::from([("KEY".into(), "old_val".into())]);
335        let new = HashMap::from([("KEY".into(), "new_val".into())]);
336        let entries = diff_secrets(&old, &new);
337        assert_eq!(entries.len(), 1);
338        assert_eq!(entries[0].kind, DiffKind::Changed);
339        assert_eq!(entries[0].old_value.as_deref(), Some("old_val"));
340        assert_eq!(entries[0].new_value.as_deref(), Some("new_val"));
341    }
342
343    #[test]
344    fn diff_secrets_mixed() {
345        let old = HashMap::from([
346            ("KEEP".into(), "same".into()),
347            ("REMOVE".into(), "gone".into()),
348            ("CHANGE".into(), "old".into()),
349        ]);
350        let new = HashMap::from([
351            ("KEEP".into(), "same".into()),
352            ("ADD".into(), "new".into()),
353            ("CHANGE".into(), "new".into()),
354        ]);
355        let entries = diff_secrets(&old, &new);
356        assert_eq!(entries.len(), 3);
357
358        let kinds: Vec<&DiffKind> = entries.iter().map(|e| &e.kind).collect();
359        assert!(kinds.contains(&&DiffKind::Added));
360        assert!(kinds.contains(&&DiffKind::Removed));
361        assert!(kinds.contains(&&DiffKind::Changed));
362    }
363
364    #[test]
365    fn diff_secrets_sorted_by_key() {
366        let old = HashMap::new();
367        let new = HashMap::from([
368            ("Z".into(), "z".into()),
369            ("A".into(), "a".into()),
370            ("M".into(), "m".into()),
371        ]);
372        let entries = diff_secrets(&old, &new);
373        let keys: Vec<&str> = entries.iter().map(|e| e.key.as_str()).collect();
374        assert_eq!(keys, vec!["A", "M", "Z"]);
375    }
376
377    // ── format_diff_lines tests ──
378
379    #[test]
380    fn format_diff_lines_without_values() {
381        let entries = vec![
382            DiffEntry {
383                key: "NEW_KEY".into(),
384                kind: DiffKind::Added,
385                old_value: None,
386                new_value: Some("secret".into()),
387            },
388            DiffEntry {
389                key: "OLD_KEY".into(),
390                kind: DiffKind::Removed,
391                old_value: Some("old".into()),
392                new_value: None,
393            },
394            DiffEntry {
395                key: "MOD_KEY".into(),
396                kind: DiffKind::Changed,
397                old_value: Some("v1".into()),
398                new_value: Some("v2".into()),
399            },
400        ];
401        let lines = format_diff_lines(&entries, false);
402        assert_eq!(lines, vec!["+ NEW_KEY", "- OLD_KEY", "~ MOD_KEY"]);
403    }
404
405    #[test]
406    fn format_diff_lines_with_values() {
407        let entries = vec![
408            DiffEntry {
409                key: "KEY".into(),
410                kind: DiffKind::Added,
411                old_value: None,
412                new_value: Some("new_val".into()),
413            },
414            DiffEntry {
415                key: "KEY2".into(),
416                kind: DiffKind::Changed,
417                old_value: Some("old".into()),
418                new_value: Some("new".into()),
419            },
420        ];
421        let lines = format_diff_lines(&entries, true);
422        assert_eq!(lines[0], "+ KEY = new_val");
423        assert_eq!(lines[1], "~ KEY2 old → new");
424    }
425
426    #[test]
427    fn format_diff_lines_empty() {
428        let lines = format_diff_lines(&[], false);
429        assert!(lines.is_empty());
430    }
431
432    // ── resolve_secrets tests ──
433
434    #[test]
435    fn resolve_secrets_basic() {
436        let mut vault = empty_vault();
437        vault.schema.insert(
438            "FOO".into(),
439            types::SchemaEntry {
440                description: String::new(),
441                example: None,
442                tags: vec![],
443                ..Default::default()
444            },
445        );
446
447        let mut murk = empty_murk();
448        murk.values.insert("FOO".into(), "bar".into());
449
450        let resolved = resolve_secrets(&vault, &murk, "age1pk", &[]);
451        assert_eq!(resolved.len(), 1);
452        assert_eq!(resolved["FOO"], "bar");
453    }
454
455    #[test]
456    fn resolve_secrets_no_escaping() {
457        let mut vault = empty_vault();
458        vault.schema.insert(
459            "KEY".into(),
460            types::SchemaEntry {
461                description: String::new(),
462                example: None,
463                tags: vec![],
464                ..Default::default()
465            },
466        );
467
468        let mut murk = empty_murk();
469        murk.values.insert("KEY".into(), "it's a test".into());
470
471        let resolved = resolve_secrets(&vault, &murk, "age1pk", &[]);
472        assert_eq!(resolved["KEY"], "it's a test");
473    }
474
475    #[test]
476    fn resolve_secrets_scoped_override() {
477        let mut vault = empty_vault();
478        vault.schema.insert(
479            "KEY".into(),
480            types::SchemaEntry {
481                description: String::new(),
482                example: None,
483                tags: vec![],
484                ..Default::default()
485            },
486        );
487
488        let mut murk = empty_murk();
489        murk.values.insert("KEY".into(), "shared".into());
490        let mut scoped = HashMap::new();
491        scoped.insert("age1pk".into(), "override".into());
492        murk.scoped.insert("KEY".into(), scoped);
493
494        let resolved = resolve_secrets(&vault, &murk, "age1pk", &[]);
495        assert_eq!(resolved["KEY"], "override");
496    }
497
498    #[test]
499    fn resolve_secrets_tag_filter() {
500        let mut vault = empty_vault();
501        vault.schema.insert(
502            "A".into(),
503            types::SchemaEntry {
504                description: String::new(),
505                example: None,
506                tags: vec!["db".into()],
507                ..Default::default()
508            },
509        );
510        vault.schema.insert(
511            "B".into(),
512            types::SchemaEntry {
513                description: String::new(),
514                example: None,
515                tags: vec!["api".into()],
516                ..Default::default()
517            },
518        );
519
520        let mut murk = empty_murk();
521        murk.values.insert("A".into(), "val_a".into());
522        murk.values.insert("B".into(), "val_b".into());
523
524        let resolved = resolve_secrets(&vault, &murk, "age1pk", &["db".into()]);
525        assert_eq!(resolved.len(), 1);
526        assert_eq!(resolved["A"], "val_a");
527    }
528
529    #[test]
530    fn resolve_secrets_tag_in_schema_but_no_secret() {
531        let mut vault = empty_vault();
532        // Schema says key "ORPHAN" exists with tag "db", but no secret value.
533        vault.schema.insert(
534            "ORPHAN".into(),
535            types::SchemaEntry {
536                description: "orphan key".into(),
537                example: None,
538                tags: vec!["db".into()],
539                ..Default::default()
540            },
541        );
542        vault.schema.insert(
543            "REAL".into(),
544            types::SchemaEntry {
545                description: "has a value".into(),
546                example: None,
547                tags: vec!["db".into()],
548                ..Default::default()
549            },
550        );
551
552        let mut murk = empty_murk();
553        // Only REAL has a value, ORPHAN does not.
554        murk.values.insert("REAL".into(), "real_val".into());
555
556        let resolved = resolve_secrets(&vault, &murk, "age1pk", &["db".into()]);
557        // ORPHAN should not appear since it has no value.
558        assert_eq!(resolved.len(), 1);
559        assert_eq!(resolved["REAL"], "real_val");
560        assert!(!resolved.contains_key("ORPHAN"));
561    }
562
563    #[test]
564    fn resolve_secrets_scoped_pubkey_not_in_recipients() {
565        let mut vault = empty_vault();
566        vault.recipients = vec!["age1alice".into()];
567        vault.schema.insert(
568            "KEY".into(),
569            types::SchemaEntry {
570                description: String::new(),
571                example: None,
572                tags: vec![],
573                ..Default::default()
574            },
575        );
576
577        let mut murk = empty_murk();
578        murk.values.insert("KEY".into(), "shared".into());
579        // Scoped override for a pubkey NOT in vault.recipients.
580        let mut scoped = HashMap::new();
581        scoped.insert("age1outsider".into(), "outsider_val".into());
582        murk.scoped.insert("KEY".into(), scoped);
583
584        // The outsider's override should still be applied (resolve doesn't gate on recipient list).
585        let resolved = resolve_secrets(&vault, &murk, "age1outsider", &[]);
586        assert_eq!(resolved["KEY"], "outsider_val");
587
588        // Alice gets the shared value since she has no scoped override.
589        let resolved_alice = resolve_secrets(&vault, &murk, "age1alice", &[]);
590        assert_eq!(resolved_alice["KEY"], "shared");
591    }
592
593    // ── New edge-case tests ──
594
595    #[test]
596    fn export_secrets_empty_vault() {
597        let vault = empty_vault();
598        let murk = empty_murk();
599        let exports = export_secrets(&vault, &murk, "age1pk", &[]);
600        assert!(exports.is_empty());
601    }
602
603    #[test]
604    fn decrypt_vault_values_basic() {
605        let (secret, pubkey) = generate_keypair();
606        let recipient = make_recipient(&pubkey);
607        let identity = make_identity(&secret);
608
609        let mut vault = empty_vault();
610        vault.recipients = vec![pubkey];
611        vault.secrets.insert(
612            "KEY1".into(),
613            types::SecretEntry {
614                shared: crate::encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
615                scoped: std::collections::BTreeMap::new(),
616            },
617        );
618        vault.secrets.insert(
619            "KEY2".into(),
620            types::SecretEntry {
621                shared: crate::encrypt_value(b"val2", &[recipient]).unwrap(),
622                scoped: std::collections::BTreeMap::new(),
623            },
624        );
625
626        let values = crate::export::decrypt_vault_values(&vault, &identity);
627        assert_eq!(values.len(), 2);
628        assert_eq!(values["KEY1"], "val1");
629        assert_eq!(values["KEY2"], "val2");
630    }
631
632    #[test]
633    fn decrypt_vault_values_wrong_key_skips() {
634        let (_, pubkey) = generate_keypair();
635        let recipient = make_recipient(&pubkey);
636        let (wrong_secret, _) = generate_keypair();
637        let wrong_identity = make_identity(&wrong_secret);
638
639        let mut vault = empty_vault();
640        vault.recipients = vec![pubkey];
641        vault.secrets.insert(
642            "KEY1".into(),
643            types::SecretEntry {
644                shared: crate::encrypt_value(b"val1", &[recipient]).unwrap(),
645                scoped: std::collections::BTreeMap::new(),
646            },
647        );
648
649        let values = crate::export::decrypt_vault_values(&vault, &wrong_identity);
650        assert!(values.is_empty());
651    }
652
653    #[test]
654    fn decrypt_vault_values_empty_vault() {
655        let (secret, _) = generate_keypair();
656        let identity = make_identity(&secret);
657        let vault = empty_vault();
658
659        let values = crate::export::decrypt_vault_values(&vault, &identity);
660        assert!(values.is_empty());
661    }
662
663    #[test]
664    fn diff_secrets_both_empty() {
665        let old = HashMap::new();
666        let new = HashMap::new();
667        assert!(diff_secrets(&old, &new).is_empty());
668    }
669
670    // ── parse_and_decrypt_values tests ──
671
672    #[test]
673    fn parse_and_decrypt_values_roundtrip() {
674        let (secret, pubkey) = generate_keypair();
675        let recipient = make_recipient(&pubkey);
676        let identity = make_identity(&secret);
677
678        let mut vault = empty_vault();
679        vault.recipients = vec![pubkey];
680        vault.secrets.insert(
681            "KEY1".into(),
682            types::SecretEntry {
683                shared: crate::encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
684                scoped: std::collections::BTreeMap::new(),
685            },
686        );
687        vault.secrets.insert(
688            "KEY2".into(),
689            types::SecretEntry {
690                shared: crate::encrypt_value(b"val2", &[recipient]).unwrap(),
691                scoped: std::collections::BTreeMap::new(),
692            },
693        );
694
695        let json = serde_json::to_string(&vault).unwrap();
696        let values = parse_and_decrypt_values(&json, &identity).unwrap();
697        assert_eq!(values.len(), 2);
698        assert_eq!(values["KEY1"], "val1");
699        assert_eq!(values["KEY2"], "val2");
700    }
701
702    #[test]
703    fn parse_and_decrypt_values_invalid_json() {
704        let (secret, _) = generate_keypair();
705        let identity = make_identity(&secret);
706
707        let result = parse_and_decrypt_values("not valid json", &identity);
708        assert!(result.is_err());
709    }
710
711    #[test]
712    fn parse_and_decrypt_values_wrong_key() {
713        let (_, pubkey) = generate_keypair();
714        let recipient = make_recipient(&pubkey);
715        let (wrong_secret, _) = generate_keypair();
716        let wrong_identity = make_identity(&wrong_secret);
717
718        let mut vault = empty_vault();
719        vault.recipients = vec![pubkey];
720        vault.secrets.insert(
721            "KEY1".into(),
722            types::SecretEntry {
723                shared: crate::encrypt_value(b"val1", &[recipient]).unwrap(),
724                scoped: std::collections::BTreeMap::new(),
725            },
726        );
727
728        let json = serde_json::to_string(&vault).unwrap();
729        let values = parse_and_decrypt_values(&json, &wrong_identity).unwrap();
730        assert!(values.is_empty());
731    }
732}