Skip to main content

murk_cli/
secrets.rs

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