Skip to main content

murk_cli/
secrets.rs

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