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#[cfg(test)]
155mod tests {
156    use super::*;
157    use crate::testutil::*;
158    use crate::types;
159
160    #[test]
161    fn export_secrets_basic() {
162        let mut vault = empty_vault();
163        vault.schema.insert(
164            "FOO".into(),
165            types::SchemaEntry {
166                description: String::new(),
167                example: None,
168                tags: vec![],
169            },
170        );
171
172        let mut murk = empty_murk();
173        murk.values.insert("FOO".into(), "bar".into());
174
175        let exports = export_secrets(&vault, &murk, "age1pk", &[]);
176        assert_eq!(exports.len(), 1);
177        assert_eq!(exports["FOO"], "bar");
178    }
179
180    #[test]
181    fn export_secrets_scoped_override() {
182        let mut vault = empty_vault();
183        vault.schema.insert(
184            "KEY".into(),
185            types::SchemaEntry {
186                description: String::new(),
187                example: None,
188                tags: vec![],
189            },
190        );
191
192        let mut murk = empty_murk();
193        murk.values.insert("KEY".into(), "shared".into());
194        let mut scoped = HashMap::new();
195        scoped.insert("age1pk".into(), "override".into());
196        murk.scoped.insert("KEY".into(), scoped);
197
198        let exports = export_secrets(&vault, &murk, "age1pk", &[]);
199        assert_eq!(exports["KEY"], "override");
200    }
201
202    #[test]
203    fn export_secrets_tag_filter() {
204        let mut vault = empty_vault();
205        vault.schema.insert(
206            "A".into(),
207            types::SchemaEntry {
208                description: String::new(),
209                example: None,
210                tags: vec!["db".into()],
211            },
212        );
213        vault.schema.insert(
214            "B".into(),
215            types::SchemaEntry {
216                description: String::new(),
217                example: None,
218                tags: vec!["api".into()],
219            },
220        );
221
222        let mut murk = empty_murk();
223        murk.values.insert("A".into(), "val_a".into());
224        murk.values.insert("B".into(), "val_b".into());
225
226        let exports = export_secrets(&vault, &murk, "age1pk", &["db".into()]);
227        assert_eq!(exports.len(), 1);
228        assert_eq!(exports["A"], "val_a");
229    }
230
231    #[test]
232    fn export_secrets_shell_escaping() {
233        let mut vault = empty_vault();
234        vault.schema.insert(
235            "KEY".into(),
236            types::SchemaEntry {
237                description: String::new(),
238                example: None,
239                tags: vec![],
240            },
241        );
242
243        let mut murk = empty_murk();
244        murk.values.insert("KEY".into(), "it's a test".into());
245
246        let exports = export_secrets(&vault, &murk, "age1pk", &[]);
247        assert_eq!(exports["KEY"], "it'\\''s a test");
248    }
249
250    #[test]
251    fn diff_secrets_no_changes() {
252        let old = HashMap::from([("K".into(), "V".into())]);
253        let new = old.clone();
254        assert!(diff_secrets(&old, &new).is_empty());
255    }
256
257    #[test]
258    fn diff_secrets_added() {
259        let old = HashMap::new();
260        let new = HashMap::from([("KEY".into(), "val".into())]);
261        let entries = diff_secrets(&old, &new);
262        assert_eq!(entries.len(), 1);
263        assert_eq!(entries[0].kind, DiffKind::Added);
264        assert_eq!(entries[0].key, "KEY");
265        assert_eq!(entries[0].new_value.as_deref(), Some("val"));
266    }
267
268    #[test]
269    fn diff_secrets_removed() {
270        let old = HashMap::from([("KEY".into(), "val".into())]);
271        let new = HashMap::new();
272        let entries = diff_secrets(&old, &new);
273        assert_eq!(entries.len(), 1);
274        assert_eq!(entries[0].kind, DiffKind::Removed);
275        assert_eq!(entries[0].old_value.as_deref(), Some("val"));
276    }
277
278    #[test]
279    fn diff_secrets_changed() {
280        let old = HashMap::from([("KEY".into(), "old_val".into())]);
281        let new = HashMap::from([("KEY".into(), "new_val".into())]);
282        let entries = diff_secrets(&old, &new);
283        assert_eq!(entries.len(), 1);
284        assert_eq!(entries[0].kind, DiffKind::Changed);
285        assert_eq!(entries[0].old_value.as_deref(), Some("old_val"));
286        assert_eq!(entries[0].new_value.as_deref(), Some("new_val"));
287    }
288
289    #[test]
290    fn diff_secrets_mixed() {
291        let old = HashMap::from([
292            ("KEEP".into(), "same".into()),
293            ("REMOVE".into(), "gone".into()),
294            ("CHANGE".into(), "old".into()),
295        ]);
296        let new = HashMap::from([
297            ("KEEP".into(), "same".into()),
298            ("ADD".into(), "new".into()),
299            ("CHANGE".into(), "new".into()),
300        ]);
301        let entries = diff_secrets(&old, &new);
302        assert_eq!(entries.len(), 3);
303
304        let kinds: Vec<&DiffKind> = entries.iter().map(|e| &e.kind).collect();
305        assert!(kinds.contains(&&DiffKind::Added));
306        assert!(kinds.contains(&&DiffKind::Removed));
307        assert!(kinds.contains(&&DiffKind::Changed));
308    }
309
310    #[test]
311    fn diff_secrets_sorted_by_key() {
312        let old = HashMap::new();
313        let new = HashMap::from([
314            ("Z".into(), "z".into()),
315            ("A".into(), "a".into()),
316            ("M".into(), "m".into()),
317        ]);
318        let entries = diff_secrets(&old, &new);
319        let keys: Vec<&str> = entries.iter().map(|e| e.key.as_str()).collect();
320        assert_eq!(keys, vec!["A", "M", "Z"]);
321    }
322
323    // ── resolve_secrets tests ──
324
325    #[test]
326    fn resolve_secrets_basic() {
327        let mut vault = empty_vault();
328        vault.schema.insert(
329            "FOO".into(),
330            types::SchemaEntry {
331                description: String::new(),
332                example: None,
333                tags: vec![],
334            },
335        );
336
337        let mut murk = empty_murk();
338        murk.values.insert("FOO".into(), "bar".into());
339
340        let resolved = resolve_secrets(&vault, &murk, "age1pk", &[]);
341        assert_eq!(resolved.len(), 1);
342        assert_eq!(resolved["FOO"], "bar");
343    }
344
345    #[test]
346    fn resolve_secrets_no_escaping() {
347        let mut vault = empty_vault();
348        vault.schema.insert(
349            "KEY".into(),
350            types::SchemaEntry {
351                description: String::new(),
352                example: None,
353                tags: vec![],
354            },
355        );
356
357        let mut murk = empty_murk();
358        murk.values.insert("KEY".into(), "it's a test".into());
359
360        let resolved = resolve_secrets(&vault, &murk, "age1pk", &[]);
361        assert_eq!(resolved["KEY"], "it's a test");
362    }
363
364    #[test]
365    fn resolve_secrets_scoped_override() {
366        let mut vault = empty_vault();
367        vault.schema.insert(
368            "KEY".into(),
369            types::SchemaEntry {
370                description: String::new(),
371                example: None,
372                tags: vec![],
373            },
374        );
375
376        let mut murk = empty_murk();
377        murk.values.insert("KEY".into(), "shared".into());
378        let mut scoped = HashMap::new();
379        scoped.insert("age1pk".into(), "override".into());
380        murk.scoped.insert("KEY".into(), scoped);
381
382        let resolved = resolve_secrets(&vault, &murk, "age1pk", &[]);
383        assert_eq!(resolved["KEY"], "override");
384    }
385
386    #[test]
387    fn resolve_secrets_tag_filter() {
388        let mut vault = empty_vault();
389        vault.schema.insert(
390            "A".into(),
391            types::SchemaEntry {
392                description: String::new(),
393                example: None,
394                tags: vec!["db".into()],
395            },
396        );
397        vault.schema.insert(
398            "B".into(),
399            types::SchemaEntry {
400                description: String::new(),
401                example: None,
402                tags: vec!["api".into()],
403            },
404        );
405
406        let mut murk = empty_murk();
407        murk.values.insert("A".into(), "val_a".into());
408        murk.values.insert("B".into(), "val_b".into());
409
410        let resolved = resolve_secrets(&vault, &murk, "age1pk", &["db".into()]);
411        assert_eq!(resolved.len(), 1);
412        assert_eq!(resolved["A"], "val_a");
413    }
414
415    #[test]
416    fn resolve_secrets_tag_in_schema_but_no_secret() {
417        let mut vault = empty_vault();
418        // Schema says key "ORPHAN" exists with tag "db", but no secret value.
419        vault.schema.insert(
420            "ORPHAN".into(),
421            types::SchemaEntry {
422                description: "orphan key".into(),
423                example: None,
424                tags: vec!["db".into()],
425            },
426        );
427        vault.schema.insert(
428            "REAL".into(),
429            types::SchemaEntry {
430                description: "has a value".into(),
431                example: None,
432                tags: vec!["db".into()],
433            },
434        );
435
436        let mut murk = empty_murk();
437        // Only REAL has a value, ORPHAN does not.
438        murk.values.insert("REAL".into(), "real_val".into());
439
440        let resolved = resolve_secrets(&vault, &murk, "age1pk", &["db".into()]);
441        // ORPHAN should not appear since it has no value.
442        assert_eq!(resolved.len(), 1);
443        assert_eq!(resolved["REAL"], "real_val");
444        assert!(!resolved.contains_key("ORPHAN"));
445    }
446
447    #[test]
448    fn resolve_secrets_scoped_pubkey_not_in_recipients() {
449        let mut vault = empty_vault();
450        vault.recipients = vec!["age1alice".into()];
451        vault.schema.insert(
452            "KEY".into(),
453            types::SchemaEntry {
454                description: String::new(),
455                example: None,
456                tags: vec![],
457            },
458        );
459
460        let mut murk = empty_murk();
461        murk.values.insert("KEY".into(), "shared".into());
462        // Scoped override for a pubkey NOT in vault.recipients.
463        let mut scoped = HashMap::new();
464        scoped.insert("age1outsider".into(), "outsider_val".into());
465        murk.scoped.insert("KEY".into(), scoped);
466
467        // The outsider's override should still be applied (resolve doesn't gate on recipient list).
468        let resolved = resolve_secrets(&vault, &murk, "age1outsider", &[]);
469        assert_eq!(resolved["KEY"], "outsider_val");
470
471        // Alice gets the shared value since she has no scoped override.
472        let resolved_alice = resolve_secrets(&vault, &murk, "age1alice", &[]);
473        assert_eq!(resolved_alice["KEY"], "shared");
474    }
475
476    // ── New edge-case tests ──
477
478    #[test]
479    fn export_secrets_empty_vault() {
480        let vault = empty_vault();
481        let murk = empty_murk();
482        let exports = export_secrets(&vault, &murk, "age1pk", &[]);
483        assert!(exports.is_empty());
484    }
485
486    #[test]
487    fn decrypt_vault_values_basic() {
488        let (secret, pubkey) = generate_keypair();
489        let recipient = make_recipient(&pubkey);
490        let identity = make_identity(&secret);
491
492        let mut vault = empty_vault();
493        vault.recipients = vec![pubkey];
494        vault.secrets.insert(
495            "KEY1".into(),
496            types::SecretEntry {
497                shared: crate::encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
498                scoped: std::collections::BTreeMap::new(),
499            },
500        );
501        vault.secrets.insert(
502            "KEY2".into(),
503            types::SecretEntry {
504                shared: crate::encrypt_value(b"val2", &[recipient]).unwrap(),
505                scoped: std::collections::BTreeMap::new(),
506            },
507        );
508
509        let values = crate::export::decrypt_vault_values(&vault, &identity);
510        assert_eq!(values.len(), 2);
511        assert_eq!(values["KEY1"], "val1");
512        assert_eq!(values["KEY2"], "val2");
513    }
514
515    #[test]
516    fn decrypt_vault_values_wrong_key_skips() {
517        let (_, pubkey) = generate_keypair();
518        let recipient = make_recipient(&pubkey);
519        let (wrong_secret, _) = generate_keypair();
520        let wrong_identity = make_identity(&wrong_secret);
521
522        let mut vault = empty_vault();
523        vault.recipients = vec![pubkey];
524        vault.secrets.insert(
525            "KEY1".into(),
526            types::SecretEntry {
527                shared: crate::encrypt_value(b"val1", &[recipient]).unwrap(),
528                scoped: std::collections::BTreeMap::new(),
529            },
530        );
531
532        let values = crate::export::decrypt_vault_values(&vault, &wrong_identity);
533        assert!(values.is_empty());
534    }
535
536    #[test]
537    fn decrypt_vault_values_empty_vault() {
538        let (secret, _) = generate_keypair();
539        let identity = make_identity(&secret);
540        let vault = empty_vault();
541
542        let values = crate::export::decrypt_vault_values(&vault, &identity);
543        assert!(values.is_empty());
544    }
545
546    #[test]
547    fn diff_secrets_both_empty() {
548        let old = HashMap::new();
549        let new = HashMap::new();
550        assert!(diff_secrets(&old, &new).is_empty());
551    }
552
553    // ── parse_and_decrypt_values tests ──
554
555    #[test]
556    fn parse_and_decrypt_values_roundtrip() {
557        let (secret, pubkey) = generate_keypair();
558        let recipient = make_recipient(&pubkey);
559        let identity = make_identity(&secret);
560
561        let mut vault = empty_vault();
562        vault.recipients = vec![pubkey];
563        vault.secrets.insert(
564            "KEY1".into(),
565            types::SecretEntry {
566                shared: crate::encrypt_value(b"val1", &[recipient.clone()]).unwrap(),
567                scoped: std::collections::BTreeMap::new(),
568            },
569        );
570        vault.secrets.insert(
571            "KEY2".into(),
572            types::SecretEntry {
573                shared: crate::encrypt_value(b"val2", &[recipient]).unwrap(),
574                scoped: std::collections::BTreeMap::new(),
575            },
576        );
577
578        let json = serde_json::to_string(&vault).unwrap();
579        let values = parse_and_decrypt_values(&json, &identity).unwrap();
580        assert_eq!(values.len(), 2);
581        assert_eq!(values["KEY1"], "val1");
582        assert_eq!(values["KEY2"], "val2");
583    }
584
585    #[test]
586    fn parse_and_decrypt_values_invalid_json() {
587        let (secret, _) = generate_keypair();
588        let identity = make_identity(&secret);
589
590        let result = parse_and_decrypt_values("not valid json", &identity);
591        assert!(result.is_err());
592    }
593
594    #[test]
595    fn parse_and_decrypt_values_wrong_key() {
596        let (_, pubkey) = generate_keypair();
597        let recipient = make_recipient(&pubkey);
598        let (wrong_secret, _) = generate_keypair();
599        let wrong_identity = make_identity(&wrong_secret);
600
601        let mut vault = empty_vault();
602        vault.recipients = vec![pubkey];
603        vault.secrets.insert(
604            "KEY1".into(),
605            types::SecretEntry {
606                shared: crate::encrypt_value(b"val1", &[recipient]).unwrap(),
607                scoped: std::collections::BTreeMap::new(),
608            },
609        );
610
611        let json = serde_json::to_string(&vault).unwrap();
612        let values = parse_and_decrypt_values(&json, &wrong_identity).unwrap();
613        assert!(values.is_empty());
614    }
615}