Skip to main content

murk_cli/
secrets.rs

1//! Secret CRUD operations on the in-memory `Murk` state.
2
3use crate::{crypto, types};
4
5/// Add or update a secret in the working state.
6/// If `scoped` is true, stores in scoped (encrypted to self only).
7/// Returns true if the key was new (no existing schema entry).
8pub fn add_secret(
9    vault: &mut types::Vault,
10    murk: &mut types::Murk,
11    key: &str,
12    value: &str,
13    desc: Option<&str>,
14    scoped: bool,
15    tags: &[String],
16    identity: &crypto::MurkIdentity,
17) -> bool {
18    if scoped {
19        let pubkey = identity.pubkey_string().expect("valid identity has pubkey");
20        murk.scoped
21            .entry(key.into())
22            .or_default()
23            .insert(pubkey, value.into());
24    } else {
25        murk.values.insert(key.into(), value.into());
26    }
27
28    let is_new = !vault.schema.contains_key(key);
29
30    if let Some(entry) = vault.schema.get_mut(key) {
31        if let Some(d) = desc {
32            entry.description = d.into();
33        }
34        if !tags.is_empty() {
35            for t in tags {
36                if !entry.tags.contains(t) {
37                    entry.tags.push(t.clone());
38                }
39            }
40        }
41    } else {
42        vault.schema.insert(
43            key.into(),
44            types::SchemaEntry {
45                description: desc.unwrap_or("").into(),
46                example: None,
47                tags: tags.to_vec(),
48            },
49        );
50    }
51
52    is_new && desc.is_none()
53}
54
55/// Remove a secret from the working state and schema.
56pub fn remove_secret(vault: &mut types::Vault, murk: &mut types::Murk, key: &str) {
57    murk.values.remove(key);
58    murk.scoped.remove(key);
59    vault.schema.remove(key);
60}
61
62/// Look up a decrypted value. Scoped overrides take priority over shared values.
63pub fn get_secret<'a>(murk: &'a types::Murk, key: &str, pubkey: &str) -> Option<&'a str> {
64    if let Some(value) = murk.scoped.get(key).and_then(|m| m.get(pubkey)) {
65        return Some(value.as_str());
66    }
67    murk.values.get(key).map(String::as_str)
68}
69
70/// Return key names from the vault schema, optionally filtered by tags.
71pub fn list_keys<'a>(vault: &'a types::Vault, tags: &[String]) -> Vec<&'a str> {
72    vault
73        .schema
74        .iter()
75        .filter(|(_, entry)| tags.is_empty() || entry.tags.iter().any(|t| tags.contains(t)))
76        .map(|(key, _)| key.as_str())
77        .collect()
78}
79
80/// Import multiple secrets at once.
81///
82/// For each `(key, value)` pair, inserts the value into murk and ensures a
83/// schema entry exists. Returns the list of imported key names.
84pub fn import_secrets(
85    vault: &mut types::Vault,
86    murk: &mut types::Murk,
87    pairs: &[(String, String)],
88) -> Vec<String> {
89    let mut imported = Vec::new();
90    for (key, value) in pairs {
91        murk.values.insert(key.clone(), value.clone());
92
93        if !vault.schema.contains_key(key.as_str()) {
94            vault.schema.insert(
95                key.clone(),
96                types::SchemaEntry {
97                    description: String::new(),
98                    example: None,
99                    tags: vec![],
100                },
101            );
102        }
103
104        imported.push(key.clone());
105    }
106    imported
107}
108
109/// Update or create a schema entry for a key.
110pub fn describe_key(
111    vault: &mut types::Vault,
112    key: &str,
113    description: &str,
114    example: Option<&str>,
115    tags: &[String],
116) {
117    if let Some(entry) = vault.schema.get_mut(key) {
118        entry.description = description.into();
119        entry.example = example.map(Into::into);
120        if !tags.is_empty() {
121            entry.tags = tags.to_vec();
122        }
123    } else {
124        vault.schema.insert(
125            key.into(),
126            types::SchemaEntry {
127                description: description.into(),
128                example: example.map(Into::into),
129                tags: tags.to_vec(),
130            },
131        );
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::testutil::*;
139    use std::collections::HashMap;
140
141    #[test]
142    fn add_secret_shared() {
143        let (secret, _) = generate_keypair();
144        let identity = make_identity(&secret);
145        let mut vault = empty_vault();
146        let mut murk = empty_murk();
147
148        let needs_hint = add_secret(
149            &mut vault,
150            &mut murk,
151            "KEY",
152            "value",
153            None,
154            false,
155            &[],
156            &identity,
157        );
158
159        assert!(needs_hint);
160        assert_eq!(murk.values["KEY"], "value");
161        assert!(vault.schema.contains_key("KEY"));
162        assert!(vault.schema["KEY"].description.is_empty());
163    }
164
165    #[test]
166    fn add_secret_with_description() {
167        let (secret, _) = generate_keypair();
168        let identity = make_identity(&secret);
169        let mut vault = empty_vault();
170        let mut murk = empty_murk();
171
172        let needs_hint = add_secret(
173            &mut vault,
174            &mut murk,
175            "KEY",
176            "value",
177            Some("a desc"),
178            false,
179            &[],
180            &identity,
181        );
182
183        assert!(!needs_hint);
184        assert_eq!(vault.schema["KEY"].description, "a desc");
185    }
186
187    #[test]
188    fn add_secret_scoped() {
189        let (secret, pubkey) = generate_keypair();
190        let identity = make_identity(&secret);
191        let mut vault = empty_vault();
192        let mut murk = empty_murk();
193
194        add_secret(
195            &mut vault,
196            &mut murk,
197            "KEY",
198            "scoped_val",
199            None,
200            true,
201            &[],
202            &identity,
203        );
204
205        assert!(!murk.values.contains_key("KEY"));
206        assert_eq!(murk.scoped["KEY"][&pubkey], "scoped_val");
207    }
208
209    #[test]
210    fn add_secret_merges_tags() {
211        let (secret, _) = generate_keypair();
212        let identity = make_identity(&secret);
213        let mut vault = empty_vault();
214        let mut murk = empty_murk();
215
216        let tags1 = vec!["db".into()];
217        add_secret(
218            &mut vault, &mut murk, "KEY", "v1", None, false, &tags1, &identity,
219        );
220        assert_eq!(vault.schema["KEY"].tags, vec!["db"]);
221
222        let tags2 = vec!["backend".into()];
223        add_secret(
224            &mut vault, &mut murk, "KEY", "v2", None, false, &tags2, &identity,
225        );
226        assert_eq!(vault.schema["KEY"].tags, vec!["db", "backend"]);
227
228        // Adding duplicate tag should not create duplicates.
229        let tags3 = vec!["db".into()];
230        add_secret(
231            &mut vault, &mut murk, "KEY", "v3", None, false, &tags3, &identity,
232        );
233        assert_eq!(vault.schema["KEY"].tags, vec!["db", "backend"]);
234    }
235
236    #[test]
237    fn add_secret_updates_existing_desc() {
238        let (secret, _) = generate_keypair();
239        let identity = make_identity(&secret);
240        let mut vault = empty_vault();
241        let mut murk = empty_murk();
242
243        add_secret(
244            &mut vault,
245            &mut murk,
246            "KEY",
247            "v1",
248            Some("old"),
249            false,
250            &[],
251            &identity,
252        );
253        add_secret(
254            &mut vault,
255            &mut murk,
256            "KEY",
257            "v2",
258            Some("new"),
259            false,
260            &[],
261            &identity,
262        );
263        assert_eq!(vault.schema["KEY"].description, "new");
264    }
265
266    #[test]
267    fn remove_secret_clears_all() {
268        let mut vault = empty_vault();
269        vault.schema.insert(
270            "KEY".into(),
271            types::SchemaEntry {
272                description: "desc".into(),
273                example: None,
274                tags: vec![],
275            },
276        );
277        let mut murk = empty_murk();
278        murk.values.insert("KEY".into(), "val".into());
279        let mut scoped = HashMap::new();
280        scoped.insert("age1pk".into(), "scoped_val".into());
281        murk.scoped.insert("KEY".into(), scoped);
282
283        remove_secret(&mut vault, &mut murk, "KEY");
284
285        assert!(!murk.values.contains_key("KEY"));
286        assert!(!murk.scoped.contains_key("KEY"));
287        assert!(!vault.schema.contains_key("KEY"));
288    }
289
290    #[test]
291    fn get_secret_shared_value() {
292        let mut murk = empty_murk();
293        murk.values.insert("KEY".into(), "shared_val".into());
294
295        assert_eq!(get_secret(&murk, "KEY", "age1pk"), Some("shared_val"));
296    }
297
298    #[test]
299    fn get_secret_scoped_overrides_shared() {
300        let mut murk = empty_murk();
301        murk.values.insert("KEY".into(), "shared_val".into());
302        let mut scoped = HashMap::new();
303        scoped.insert("age1pk".into(), "scoped_val".into());
304        murk.scoped.insert("KEY".into(), scoped);
305
306        assert_eq!(get_secret(&murk, "KEY", "age1pk"), Some("scoped_val"));
307    }
308
309    #[test]
310    fn get_secret_missing_returns_none() {
311        let murk = empty_murk();
312        assert_eq!(get_secret(&murk, "NONEXISTENT", "age1pk"), None);
313    }
314
315    #[test]
316    fn list_keys_no_filter() {
317        let mut vault = empty_vault();
318        vault.schema.insert(
319            "A".into(),
320            types::SchemaEntry {
321                description: String::new(),
322                example: None,
323                tags: vec![],
324            },
325        );
326        vault.schema.insert(
327            "B".into(),
328            types::SchemaEntry {
329                description: String::new(),
330                example: None,
331                tags: vec![],
332            },
333        );
334
335        let keys = list_keys(&vault, &[]);
336        assert_eq!(keys, vec!["A", "B"]);
337    }
338
339    #[test]
340    fn list_keys_with_tag_filter() {
341        let mut vault = empty_vault();
342        vault.schema.insert(
343            "A".into(),
344            types::SchemaEntry {
345                description: String::new(),
346                example: None,
347                tags: vec!["db".into()],
348            },
349        );
350        vault.schema.insert(
351            "B".into(),
352            types::SchemaEntry {
353                description: String::new(),
354                example: None,
355                tags: vec!["api".into()],
356            },
357        );
358        vault.schema.insert(
359            "C".into(),
360            types::SchemaEntry {
361                description: String::new(),
362                example: None,
363                tags: vec![],
364            },
365        );
366
367        let keys = list_keys(&vault, &["db".into()]);
368        assert_eq!(keys, vec!["A"]);
369    }
370
371    #[test]
372    fn list_keys_no_matches() {
373        let mut vault = empty_vault();
374        vault.schema.insert(
375            "A".into(),
376            types::SchemaEntry {
377                description: String::new(),
378                example: None,
379                tags: vec!["db".into()],
380            },
381        );
382
383        let keys = list_keys(&vault, &["nonexistent".into()]);
384        assert!(keys.is_empty());
385    }
386
387    #[test]
388    fn describe_key_creates_new() {
389        let mut vault = empty_vault();
390        describe_key(
391            &mut vault,
392            "KEY",
393            "a description",
394            Some("example"),
395            &["tag".into()],
396        );
397
398        assert_eq!(vault.schema["KEY"].description, "a description");
399        assert_eq!(vault.schema["KEY"].example.as_deref(), Some("example"));
400        assert_eq!(vault.schema["KEY"].tags, vec!["tag"]);
401    }
402
403    #[test]
404    fn describe_key_updates_existing() {
405        let mut vault = empty_vault();
406        vault.schema.insert(
407            "KEY".into(),
408            types::SchemaEntry {
409                description: "old".into(),
410                example: Some("old_ex".into()),
411                tags: vec!["old_tag".into()],
412            },
413        );
414
415        describe_key(&mut vault, "KEY", "new", None, &["new_tag".into()]);
416
417        assert_eq!(vault.schema["KEY"].description, "new");
418        assert_eq!(vault.schema["KEY"].example, None);
419        assert_eq!(vault.schema["KEY"].tags, vec!["new_tag"]);
420    }
421
422    #[test]
423    fn describe_key_preserves_tags_if_empty() {
424        let mut vault = empty_vault();
425        vault.schema.insert(
426            "KEY".into(),
427            types::SchemaEntry {
428                description: "old".into(),
429                example: None,
430                tags: vec!["keep".into()],
431            },
432        );
433
434        describe_key(&mut vault, "KEY", "new desc", None, &[]);
435
436        assert_eq!(vault.schema["KEY"].tags, vec!["keep"]);
437    }
438
439    // ── New edge-case tests ──
440
441    #[test]
442    fn add_secret_overwrite_shared_with_scoped() {
443        let (secret, pubkey) = generate_keypair();
444        let identity = make_identity(&secret);
445        let mut vault = empty_vault();
446        let mut murk = empty_murk();
447
448        add_secret(
449            &mut vault,
450            &mut murk,
451            "KEY",
452            "shared_val",
453            None,
454            false,
455            &[],
456            &identity,
457        );
458        assert_eq!(murk.values["KEY"], "shared_val");
459
460        add_secret(
461            &mut vault,
462            &mut murk,
463            "KEY",
464            "scoped_val",
465            None,
466            true,
467            &[],
468            &identity,
469        );
470        // Shared value still exists, scoped override added.
471        assert_eq!(murk.values["KEY"], "shared_val");
472        assert_eq!(murk.scoped["KEY"][&pubkey], "scoped_val");
473    }
474
475    #[test]
476    fn add_secret_empty_value() {
477        let (secret, _) = generate_keypair();
478        let identity = make_identity(&secret);
479        let mut vault = empty_vault();
480        let mut murk = empty_murk();
481
482        add_secret(
483            &mut vault,
484            &mut murk,
485            "KEY",
486            "",
487            None,
488            false,
489            &[],
490            &identity,
491        );
492        assert_eq!(murk.values["KEY"], "");
493    }
494
495    #[test]
496    fn import_secrets_basic() {
497        let mut vault = empty_vault();
498        let mut murk = empty_murk();
499
500        let pairs = vec![
501            ("KEY1".into(), "val1".into()),
502            ("KEY2".into(), "val2".into()),
503        ];
504        let imported = import_secrets(&mut vault, &mut murk, &pairs);
505
506        assert_eq!(imported, vec!["KEY1", "KEY2"]);
507        assert_eq!(murk.values["KEY1"], "val1");
508        assert_eq!(murk.values["KEY2"], "val2");
509        assert!(vault.schema.contains_key("KEY1"));
510        assert!(vault.schema.contains_key("KEY2"));
511    }
512
513    #[test]
514    fn import_secrets_existing_schema_preserved() {
515        let mut vault = empty_vault();
516        vault.schema.insert(
517            "KEY1".into(),
518            types::SchemaEntry {
519                description: "existing desc".into(),
520                example: Some("ex".into()),
521                tags: vec!["tag".into()],
522            },
523        );
524        let mut murk = empty_murk();
525
526        let pairs = vec![("KEY1".into(), "new_val".into())];
527        import_secrets(&mut vault, &mut murk, &pairs);
528
529        assert_eq!(murk.values["KEY1"], "new_val");
530        assert_eq!(vault.schema["KEY1"].description, "existing desc");
531    }
532
533    #[test]
534    fn import_secrets_empty() {
535        let mut vault = empty_vault();
536        let mut murk = empty_murk();
537        let imported = import_secrets(&mut vault, &mut murk, &[]);
538        assert!(imported.is_empty());
539    }
540
541    #[test]
542    fn remove_secret_nonexistent() {
543        let mut vault = empty_vault();
544        let mut murk = empty_murk();
545
546        // Should not panic.
547        remove_secret(&mut vault, &mut murk, "NONEXISTENT");
548    }
549}