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/// The kind of change in a diff entry.
65#[derive(Debug, PartialEq, Eq)]
66pub enum DiffKind {
67    Added,
68    Removed,
69    Changed,
70}
71
72/// A single entry in a secret diff.
73#[derive(Debug)]
74pub struct DiffEntry {
75    pub key: String,
76    pub kind: DiffKind,
77    pub old_value: Option<String>,
78    pub new_value: Option<String>,
79}
80
81/// Compare two sets of secret values and return the differences.
82pub fn diff_secrets(
83    old: &HashMap<String, String>,
84    new: &HashMap<String, String>,
85) -> Vec<DiffEntry> {
86    let mut all_keys: Vec<&str> = old
87        .keys()
88        .chain(new.keys())
89        .map(String::as_str)
90        .collect::<std::collections::HashSet<_>>()
91        .into_iter()
92        .collect();
93    all_keys.sort_unstable();
94
95    let mut entries = Vec::new();
96    for key in all_keys {
97        match (old.get(key), new.get(key)) {
98            (None, Some(v)) => entries.push(DiffEntry {
99                key: key.into(),
100                kind: DiffKind::Added,
101                old_value: None,
102                new_value: Some(v.clone()),
103            }),
104            (Some(v), None) => entries.push(DiffEntry {
105                key: key.into(),
106                kind: DiffKind::Removed,
107                old_value: Some(v.clone()),
108                new_value: None,
109            }),
110            (Some(old_v), Some(new_v)) if old_v != new_v => entries.push(DiffEntry {
111                key: key.into(),
112                kind: DiffKind::Changed,
113                old_value: Some(old_v.clone()),
114                new_value: Some(new_v.clone()),
115            }),
116            _ => {}
117        }
118    }
119    entries
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::testutil::*;
126    use crate::types;
127
128    #[test]
129    fn export_secrets_basic() {
130        let mut vault = empty_vault();
131        vault.schema.insert(
132            "FOO".into(),
133            types::SchemaEntry {
134                description: String::new(),
135                example: None,
136                tags: vec![],
137            },
138        );
139
140        let mut murk = empty_murk();
141        murk.values.insert("FOO".into(), "bar".into());
142
143        let exports = export_secrets(&vault, &murk, "age1pk", &[]);
144        assert_eq!(exports.len(), 1);
145        assert_eq!(exports["FOO"], "bar");
146    }
147
148    #[test]
149    fn export_secrets_scoped_override() {
150        let mut vault = empty_vault();
151        vault.schema.insert(
152            "KEY".into(),
153            types::SchemaEntry {
154                description: String::new(),
155                example: None,
156                tags: vec![],
157            },
158        );
159
160        let mut murk = empty_murk();
161        murk.values.insert("KEY".into(), "shared".into());
162        let mut scoped = HashMap::new();
163        scoped.insert("age1pk".into(), "override".into());
164        murk.scoped.insert("KEY".into(), scoped);
165
166        let exports = export_secrets(&vault, &murk, "age1pk", &[]);
167        assert_eq!(exports["KEY"], "override");
168    }
169
170    #[test]
171    fn export_secrets_tag_filter() {
172        let mut vault = empty_vault();
173        vault.schema.insert(
174            "A".into(),
175            types::SchemaEntry {
176                description: String::new(),
177                example: None,
178                tags: vec!["db".into()],
179            },
180        );
181        vault.schema.insert(
182            "B".into(),
183            types::SchemaEntry {
184                description: String::new(),
185                example: None,
186                tags: vec!["api".into()],
187            },
188        );
189
190        let mut murk = empty_murk();
191        murk.values.insert("A".into(), "val_a".into());
192        murk.values.insert("B".into(), "val_b".into());
193
194        let exports = export_secrets(&vault, &murk, "age1pk", &["db".into()]);
195        assert_eq!(exports.len(), 1);
196        assert_eq!(exports["A"], "val_a");
197    }
198
199    #[test]
200    fn export_secrets_shell_escaping() {
201        let mut vault = empty_vault();
202        vault.schema.insert(
203            "KEY".into(),
204            types::SchemaEntry {
205                description: String::new(),
206                example: None,
207                tags: vec![],
208            },
209        );
210
211        let mut murk = empty_murk();
212        murk.values.insert("KEY".into(), "it's a test".into());
213
214        let exports = export_secrets(&vault, &murk, "age1pk", &[]);
215        assert_eq!(exports["KEY"], "it'\\''s a test");
216    }
217
218    #[test]
219    fn diff_secrets_no_changes() {
220        let old = HashMap::from([("K".into(), "V".into())]);
221        let new = old.clone();
222        assert!(diff_secrets(&old, &new).is_empty());
223    }
224
225    #[test]
226    fn diff_secrets_added() {
227        let old = HashMap::new();
228        let new = HashMap::from([("KEY".into(), "val".into())]);
229        let entries = diff_secrets(&old, &new);
230        assert_eq!(entries.len(), 1);
231        assert_eq!(entries[0].kind, DiffKind::Added);
232        assert_eq!(entries[0].key, "KEY");
233        assert_eq!(entries[0].new_value.as_deref(), Some("val"));
234    }
235
236    #[test]
237    fn diff_secrets_removed() {
238        let old = HashMap::from([("KEY".into(), "val".into())]);
239        let new = HashMap::new();
240        let entries = diff_secrets(&old, &new);
241        assert_eq!(entries.len(), 1);
242        assert_eq!(entries[0].kind, DiffKind::Removed);
243        assert_eq!(entries[0].old_value.as_deref(), Some("val"));
244    }
245
246    #[test]
247    fn diff_secrets_changed() {
248        let old = HashMap::from([("KEY".into(), "old_val".into())]);
249        let new = HashMap::from([("KEY".into(), "new_val".into())]);
250        let entries = diff_secrets(&old, &new);
251        assert_eq!(entries.len(), 1);
252        assert_eq!(entries[0].kind, DiffKind::Changed);
253        assert_eq!(entries[0].old_value.as_deref(), Some("old_val"));
254        assert_eq!(entries[0].new_value.as_deref(), Some("new_val"));
255    }
256
257    #[test]
258    fn diff_secrets_mixed() {
259        let old = HashMap::from([
260            ("KEEP".into(), "same".into()),
261            ("REMOVE".into(), "gone".into()),
262            ("CHANGE".into(), "old".into()),
263        ]);
264        let new = HashMap::from([
265            ("KEEP".into(), "same".into()),
266            ("ADD".into(), "new".into()),
267            ("CHANGE".into(), "new".into()),
268        ]);
269        let entries = diff_secrets(&old, &new);
270        assert_eq!(entries.len(), 3);
271
272        let kinds: Vec<&DiffKind> = entries.iter().map(|e| &e.kind).collect();
273        assert!(kinds.contains(&&DiffKind::Added));
274        assert!(kinds.contains(&&DiffKind::Removed));
275        assert!(kinds.contains(&&DiffKind::Changed));
276    }
277
278    #[test]
279    fn diff_secrets_sorted_by_key() {
280        let old = HashMap::new();
281        let new = HashMap::from([
282            ("Z".into(), "z".into()),
283            ("A".into(), "a".into()),
284            ("M".into(), "m".into()),
285        ]);
286        let entries = diff_secrets(&old, &new);
287        let keys: Vec<&str> = entries.iter().map(|e| e.key.as_str()).collect();
288        assert_eq!(keys, vec!["A", "M", "Z"]);
289    }
290
291    // ── resolve_secrets tests ──
292
293    #[test]
294    fn resolve_secrets_basic() {
295        let mut vault = empty_vault();
296        vault.schema.insert(
297            "FOO".into(),
298            types::SchemaEntry {
299                description: String::new(),
300                example: None,
301                tags: vec![],
302            },
303        );
304
305        let mut murk = empty_murk();
306        murk.values.insert("FOO".into(), "bar".into());
307
308        let resolved = resolve_secrets(&vault, &murk, "age1pk", &[]);
309        assert_eq!(resolved.len(), 1);
310        assert_eq!(resolved["FOO"], "bar");
311    }
312
313    #[test]
314    fn resolve_secrets_no_escaping() {
315        let mut vault = empty_vault();
316        vault.schema.insert(
317            "KEY".into(),
318            types::SchemaEntry {
319                description: String::new(),
320                example: None,
321                tags: vec![],
322            },
323        );
324
325        let mut murk = empty_murk();
326        murk.values.insert("KEY".into(), "it's a test".into());
327
328        let resolved = resolve_secrets(&vault, &murk, "age1pk", &[]);
329        assert_eq!(resolved["KEY"], "it's a test");
330    }
331
332    #[test]
333    fn resolve_secrets_scoped_override() {
334        let mut vault = empty_vault();
335        vault.schema.insert(
336            "KEY".into(),
337            types::SchemaEntry {
338                description: String::new(),
339                example: None,
340                tags: vec![],
341            },
342        );
343
344        let mut murk = empty_murk();
345        murk.values.insert("KEY".into(), "shared".into());
346        let mut scoped = HashMap::new();
347        scoped.insert("age1pk".into(), "override".into());
348        murk.scoped.insert("KEY".into(), scoped);
349
350        let resolved = resolve_secrets(&vault, &murk, "age1pk", &[]);
351        assert_eq!(resolved["KEY"], "override");
352    }
353
354    #[test]
355    fn resolve_secrets_tag_filter() {
356        let mut vault = empty_vault();
357        vault.schema.insert(
358            "A".into(),
359            types::SchemaEntry {
360                description: String::new(),
361                example: None,
362                tags: vec!["db".into()],
363            },
364        );
365        vault.schema.insert(
366            "B".into(),
367            types::SchemaEntry {
368                description: String::new(),
369                example: None,
370                tags: vec!["api".into()],
371            },
372        );
373
374        let mut murk = empty_murk();
375        murk.values.insert("A".into(), "val_a".into());
376        murk.values.insert("B".into(), "val_b".into());
377
378        let resolved = resolve_secrets(&vault, &murk, "age1pk", &["db".into()]);
379        assert_eq!(resolved.len(), 1);
380        assert_eq!(resolved["A"], "val_a");
381    }
382
383    // ── New edge-case tests ──
384
385    #[test]
386    fn export_secrets_empty_vault() {
387        let vault = empty_vault();
388        let murk = empty_murk();
389        let exports = export_secrets(&vault, &murk, "age1pk", &[]);
390        assert!(exports.is_empty());
391    }
392
393    #[test]
394    fn diff_secrets_both_empty() {
395        let old = HashMap::new();
396        let new = HashMap::new();
397        assert!(diff_secrets(&old, &new).is_empty());
398    }
399}