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