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