Skip to main content

murk_cli/
secrets.rs

1//! Secret CRUD operations on the in-memory `Murk` state.
2
3use crate::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: &age::x25519::Identity,
17) -> bool {
18    if scoped {
19        let pubkey = identity.to_public().to_string();
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/// Update or create a schema entry for a key.
81pub fn describe_key(
82    vault: &mut types::Vault,
83    key: &str,
84    description: &str,
85    example: Option<&str>,
86    tags: &[String],
87) {
88    if let Some(entry) = vault.schema.get_mut(key) {
89        entry.description = description.into();
90        entry.example = example.map(Into::into);
91        if !tags.is_empty() {
92            entry.tags = tags.to_vec();
93        }
94    } else {
95        vault.schema.insert(
96            key.into(),
97            types::SchemaEntry {
98                description: description.into(),
99                example: example.map(Into::into),
100                tags: tags.to_vec(),
101            },
102        );
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::testutil::*;
110    use std::collections::HashMap;
111
112    #[test]
113    fn add_secret_shared() {
114        let (secret, _) = generate_keypair();
115        let identity = make_identity(&secret);
116        let mut vault = empty_vault();
117        let mut murk = empty_murk();
118
119        let needs_hint = add_secret(
120            &mut vault,
121            &mut murk,
122            "KEY",
123            "value",
124            None,
125            false,
126            &[],
127            &identity,
128        );
129
130        assert!(needs_hint);
131        assert_eq!(murk.values["KEY"], "value");
132        assert!(vault.schema.contains_key("KEY"));
133        assert!(vault.schema["KEY"].description.is_empty());
134    }
135
136    #[test]
137    fn add_secret_with_description() {
138        let (secret, _) = generate_keypair();
139        let identity = make_identity(&secret);
140        let mut vault = empty_vault();
141        let mut murk = empty_murk();
142
143        let needs_hint = add_secret(
144            &mut vault,
145            &mut murk,
146            "KEY",
147            "value",
148            Some("a desc"),
149            false,
150            &[],
151            &identity,
152        );
153
154        assert!(!needs_hint);
155        assert_eq!(vault.schema["KEY"].description, "a desc");
156    }
157
158    #[test]
159    fn add_secret_scoped() {
160        let (secret, pubkey) = generate_keypair();
161        let identity = make_identity(&secret);
162        let mut vault = empty_vault();
163        let mut murk = empty_murk();
164
165        add_secret(
166            &mut vault,
167            &mut murk,
168            "KEY",
169            "scoped_val",
170            None,
171            true,
172            &[],
173            &identity,
174        );
175
176        assert!(!murk.values.contains_key("KEY"));
177        assert_eq!(murk.scoped["KEY"][&pubkey], "scoped_val");
178    }
179
180    #[test]
181    fn add_secret_merges_tags() {
182        let (secret, _) = generate_keypair();
183        let identity = make_identity(&secret);
184        let mut vault = empty_vault();
185        let mut murk = empty_murk();
186
187        let tags1 = vec!["db".into()];
188        add_secret(
189            &mut vault, &mut murk, "KEY", "v1", None, false, &tags1, &identity,
190        );
191        assert_eq!(vault.schema["KEY"].tags, vec!["db"]);
192
193        let tags2 = vec!["backend".into()];
194        add_secret(
195            &mut vault, &mut murk, "KEY", "v2", None, false, &tags2, &identity,
196        );
197        assert_eq!(vault.schema["KEY"].tags, vec!["db", "backend"]);
198
199        // Adding duplicate tag should not create duplicates.
200        let tags3 = vec!["db".into()];
201        add_secret(
202            &mut vault, &mut murk, "KEY", "v3", None, false, &tags3, &identity,
203        );
204        assert_eq!(vault.schema["KEY"].tags, vec!["db", "backend"]);
205    }
206
207    #[test]
208    fn add_secret_updates_existing_desc() {
209        let (secret, _) = generate_keypair();
210        let identity = make_identity(&secret);
211        let mut vault = empty_vault();
212        let mut murk = empty_murk();
213
214        add_secret(
215            &mut vault,
216            &mut murk,
217            "KEY",
218            "v1",
219            Some("old"),
220            false,
221            &[],
222            &identity,
223        );
224        add_secret(
225            &mut vault,
226            &mut murk,
227            "KEY",
228            "v2",
229            Some("new"),
230            false,
231            &[],
232            &identity,
233        );
234        assert_eq!(vault.schema["KEY"].description, "new");
235    }
236
237    #[test]
238    fn remove_secret_clears_all() {
239        let mut vault = empty_vault();
240        vault.schema.insert(
241            "KEY".into(),
242            types::SchemaEntry {
243                description: "desc".into(),
244                example: None,
245                tags: vec![],
246            },
247        );
248        let mut murk = empty_murk();
249        murk.values.insert("KEY".into(), "val".into());
250        let mut scoped = HashMap::new();
251        scoped.insert("age1pk".into(), "scoped_val".into());
252        murk.scoped.insert("KEY".into(), scoped);
253
254        remove_secret(&mut vault, &mut murk, "KEY");
255
256        assert!(!murk.values.contains_key("KEY"));
257        assert!(!murk.scoped.contains_key("KEY"));
258        assert!(!vault.schema.contains_key("KEY"));
259    }
260
261    #[test]
262    fn get_secret_shared_value() {
263        let mut murk = empty_murk();
264        murk.values.insert("KEY".into(), "shared_val".into());
265
266        assert_eq!(get_secret(&murk, "KEY", "age1pk"), Some("shared_val"));
267    }
268
269    #[test]
270    fn get_secret_scoped_overrides_shared() {
271        let mut murk = empty_murk();
272        murk.values.insert("KEY".into(), "shared_val".into());
273        let mut scoped = HashMap::new();
274        scoped.insert("age1pk".into(), "scoped_val".into());
275        murk.scoped.insert("KEY".into(), scoped);
276
277        assert_eq!(get_secret(&murk, "KEY", "age1pk"), Some("scoped_val"));
278    }
279
280    #[test]
281    fn get_secret_missing_returns_none() {
282        let murk = empty_murk();
283        assert_eq!(get_secret(&murk, "NONEXISTENT", "age1pk"), None);
284    }
285
286    #[test]
287    fn list_keys_no_filter() {
288        let mut vault = empty_vault();
289        vault.schema.insert(
290            "A".into(),
291            types::SchemaEntry {
292                description: String::new(),
293                example: None,
294                tags: vec![],
295            },
296        );
297        vault.schema.insert(
298            "B".into(),
299            types::SchemaEntry {
300                description: String::new(),
301                example: None,
302                tags: vec![],
303            },
304        );
305
306        let keys = list_keys(&vault, &[]);
307        assert_eq!(keys, vec!["A", "B"]);
308    }
309
310    #[test]
311    fn list_keys_with_tag_filter() {
312        let mut vault = empty_vault();
313        vault.schema.insert(
314            "A".into(),
315            types::SchemaEntry {
316                description: String::new(),
317                example: None,
318                tags: vec!["db".into()],
319            },
320        );
321        vault.schema.insert(
322            "B".into(),
323            types::SchemaEntry {
324                description: String::new(),
325                example: None,
326                tags: vec!["api".into()],
327            },
328        );
329        vault.schema.insert(
330            "C".into(),
331            types::SchemaEntry {
332                description: String::new(),
333                example: None,
334                tags: vec![],
335            },
336        );
337
338        let keys = list_keys(&vault, &["db".into()]);
339        assert_eq!(keys, vec!["A"]);
340    }
341
342    #[test]
343    fn list_keys_no_matches() {
344        let mut vault = empty_vault();
345        vault.schema.insert(
346            "A".into(),
347            types::SchemaEntry {
348                description: String::new(),
349                example: None,
350                tags: vec!["db".into()],
351            },
352        );
353
354        let keys = list_keys(&vault, &["nonexistent".into()]);
355        assert!(keys.is_empty());
356    }
357
358    #[test]
359    fn describe_key_creates_new() {
360        let mut vault = empty_vault();
361        describe_key(
362            &mut vault,
363            "KEY",
364            "a description",
365            Some("example"),
366            &["tag".into()],
367        );
368
369        assert_eq!(vault.schema["KEY"].description, "a description");
370        assert_eq!(vault.schema["KEY"].example.as_deref(), Some("example"));
371        assert_eq!(vault.schema["KEY"].tags, vec!["tag"]);
372    }
373
374    #[test]
375    fn describe_key_updates_existing() {
376        let mut vault = empty_vault();
377        vault.schema.insert(
378            "KEY".into(),
379            types::SchemaEntry {
380                description: "old".into(),
381                example: Some("old_ex".into()),
382                tags: vec!["old_tag".into()],
383            },
384        );
385
386        describe_key(&mut vault, "KEY", "new", None, &["new_tag".into()]);
387
388        assert_eq!(vault.schema["KEY"].description, "new");
389        assert_eq!(vault.schema["KEY"].example, None);
390        assert_eq!(vault.schema["KEY"].tags, vec!["new_tag"]);
391    }
392
393    #[test]
394    fn describe_key_preserves_tags_if_empty() {
395        let mut vault = empty_vault();
396        vault.schema.insert(
397            "KEY".into(),
398            types::SchemaEntry {
399                description: "old".into(),
400                example: None,
401                tags: vec!["keep".into()],
402            },
403        );
404
405        describe_key(&mut vault, "KEY", "new desc", None, &[]);
406
407        assert_eq!(vault.schema["KEY"].tags, vec!["keep"]);
408    }
409
410    // ── New edge-case tests ──
411
412    #[test]
413    fn add_secret_overwrite_shared_with_scoped() {
414        let (secret, pubkey) = generate_keypair();
415        let identity = make_identity(&secret);
416        let mut vault = empty_vault();
417        let mut murk = empty_murk();
418
419        add_secret(
420            &mut vault,
421            &mut murk,
422            "KEY",
423            "shared_val",
424            None,
425            false,
426            &[],
427            &identity,
428        );
429        assert_eq!(murk.values["KEY"], "shared_val");
430
431        add_secret(
432            &mut vault,
433            &mut murk,
434            "KEY",
435            "scoped_val",
436            None,
437            true,
438            &[],
439            &identity,
440        );
441        // Shared value still exists, scoped override added.
442        assert_eq!(murk.values["KEY"], "shared_val");
443        assert_eq!(murk.scoped["KEY"][&pubkey], "scoped_val");
444    }
445
446    #[test]
447    fn add_secret_empty_value() {
448        let (secret, _) = generate_keypair();
449        let identity = make_identity(&secret);
450        let mut vault = empty_vault();
451        let mut murk = empty_murk();
452
453        add_secret(
454            &mut vault,
455            &mut murk,
456            "KEY",
457            "",
458            None,
459            false,
460            &[],
461            &identity,
462        );
463        assert_eq!(murk.values["KEY"], "");
464    }
465
466    #[test]
467    fn remove_secret_nonexistent() {
468        let mut vault = empty_vault();
469        let mut murk = empty_murk();
470
471        // Should not panic.
472        remove_secret(&mut vault, &mut murk, "NONEXISTENT");
473    }
474}