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        // `me` is a per-identity override layered on top of the base tier — it
22        // does not change which group owns the key, so shared/grouped are left
23        // untouched.
24        let pubkey = identity.pubkey_string().expect("valid identity has pubkey");
25        murk.private
26            .entry(key.into())
27            .or_default()
28            .insert(pubkey, Zeroizing::new(value.to_owned()));
29    } else {
30        // Setting the shared (everyone) value makes `everyone` the base tier, so
31        // any named-group assignment is dropped — otherwise the stale grouped
32        // ciphertext would still win over the new shared value for members.
33        murk.grouped.remove(key);
34        murk.values
35            .insert(key.into(), Zeroizing::new(value.to_owned()));
36    }
37
38    upsert_schema(vault, key, desc, tags)
39}
40
41/// Add or update a secret encrypted to a named group. The operator must be a
42/// member of the group (so they can read it and re-encrypt it later). Assigning
43/// a secret to a group makes the group its sole base tier: any existing shared
44/// value and other group assignments are dropped so non-members can't read it.
45/// Returns true if the key was new (no existing schema entry).
46pub fn add_grouped_secret(
47    vault: &mut types::Vault,
48    murk: &mut types::Murk,
49    key: &str,
50    value: &str,
51    desc: Option<&str>,
52    group: &str,
53    tags: &[String],
54    operator_pubkey: &str,
55) -> Result<bool, crate::error::MurkError> {
56    use crate::error::MurkError;
57
58    let members = murk
59        .groups
60        .get(group)
61        .ok_or_else(|| MurkError::Group(format!("group not found: {group}")))?;
62    if !members.iter().any(|pk| pk == operator_pubkey) {
63        return Err(MurkError::Group(format!(
64            "you must be a member of group \"{group}\" to add secrets to it"
65        )));
66    }
67
68    // The group becomes the sole base tier for this key.
69    murk.values.remove(key);
70    let entry = murk.grouped.entry(key.into()).or_default();
71    entry.clear();
72    entry.insert(group.into(), Zeroizing::new(value.to_owned()));
73
74    Ok(upsert_schema(vault, key, desc, tags))
75}
76
77/// Insert or update the schema entry for a key, bumping `updated`. Returns true
78/// if the key was new and no description was supplied (the caller uses this to
79/// decide whether to print a "describe this key" hint).
80fn upsert_schema(vault: &mut types::Vault, key: &str, desc: Option<&str>, tags: &[String]) -> bool {
81    let is_new = !vault.schema.contains_key(key);
82
83    let now = now_utc();
84    if let Some(entry) = vault.schema.get_mut(key) {
85        if let Some(d) = desc {
86            entry.description = d.into();
87        }
88        if !tags.is_empty() {
89            for t in tags {
90                if !entry.tags.contains(t) {
91                    entry.tags.push(t.clone());
92                }
93            }
94        }
95        entry.updated = Some(now);
96        // A value write satisfies any outstanding post-revoke rotation, so the
97        // marker is cleared — its presence always means "still owed a rotation".
98        entry.revoked_at = None;
99    } else {
100        vault.schema.insert(
101            key.into(),
102            types::SchemaEntry {
103                description: desc.unwrap_or("").into(),
104                example: None,
105                tags: tags.to_vec(),
106                created: Some(now.clone()),
107                updated: Some(now),
108                ..Default::default()
109            },
110        );
111    }
112
113    is_new && desc.is_none()
114}
115
116/// Mark `keys` as owing a post-revoke rotation, stamping each with `revoked_at`.
117///
118/// Called when a recipient is revoked and rotation is deferred: the revoked
119/// recipient can still decrypt the live value from git history until it changes,
120/// so the obligation is recorded durably (and survives the user declining the
121/// rotation prompt). `doctor` surfaces it until a value write clears it. Keys
122/// without a schema entry are skipped — `rotation_health` only reads the schema,
123/// so an unschematized key could not be flagged anyway. `now` is injected to keep
124/// this testable and consistent with [`rotation_health`].
125pub fn mark_revoked(vault: &mut types::Vault, keys: &[String], now: chrono::DateTime<chrono::Utc>) {
126    let ts = now.format("%Y-%m-%dT%H:%M:%SZ").to_string();
127    for key in keys {
128        if let Some(entry) = vault.schema.get_mut(key) {
129            entry.revoked_at = Some(ts.clone());
130        }
131    }
132}
133
134/// Remove a secret from the working state and schema.
135pub fn remove_secret(vault: &mut types::Vault, murk: &mut types::Murk, key: &str) {
136    murk.values.remove(key);
137    murk.private.remove(key);
138    murk.grouped.remove(key);
139    vault.schema.remove(key);
140}
141
142/// Look up a decrypted value. Resolution order, highest priority first:
143/// a personal scoped override, then a named-group value we can read, then the
144/// shared (everyone) value.
145pub fn get_secret<'a>(murk: &'a types::Murk, key: &str, pubkey: &str) -> Option<&'a str> {
146    if let Some(value) = murk.private.get(key).and_then(|m| m.get(pubkey)) {
147        return Some(value.as_str());
148    }
149    if let Some(value) = murk.grouped.get(key).and_then(|m| m.values().next()) {
150        return Some(value.as_str());
151    }
152    murk.values.get(key).map(|v| v.as_str())
153}
154
155/// Return key names from the vault schema, optionally filtered by tags.
156pub fn list_keys<'a>(vault: &'a types::Vault, tags: &[String]) -> Vec<&'a str> {
157    vault
158        .schema
159        .iter()
160        .filter(|(_, entry)| tags.is_empty() || entry.tags.iter().any(|t| tags.contains(t)))
161        .map(|(key, _)| key.as_str())
162        .collect()
163}
164
165/// Import multiple secrets at once.
166///
167/// For each `(key, value)` pair, inserts the value into murk and ensures a
168/// schema entry exists. Returns the list of imported key names.
169///
170/// Values arrive already wrapped in [`Zeroizing`] so callers do not have to
171/// hold plaintext in a bare `String` across the import boundary.
172pub fn import_secrets(
173    vault: &mut types::Vault,
174    murk: &mut types::Murk,
175    pairs: &[(String, Zeroizing<String>)],
176) -> Vec<String> {
177    let now = now_utc();
178    let mut imported = Vec::new();
179    for (key, value) in pairs {
180        // Shared (everyone) base tier — drop any prior group assignment.
181        murk.grouped.remove(key);
182        murk.values.insert(key.clone(), value.clone());
183
184        if let Some(entry) = vault.schema.get_mut(key.as_str()) {
185            entry.updated = Some(now.clone());
186            // A value write clears any outstanding post-revoke rotation marker.
187            entry.revoked_at = None;
188        } else {
189            vault.schema.insert(
190                key.clone(),
191                types::SchemaEntry {
192                    description: String::new(),
193                    example: None,
194                    tags: vec![],
195                    created: Some(now.clone()),
196                    updated: Some(now.clone()),
197                    ..Default::default()
198                },
199            );
200        }
201
202        imported.push(key.clone());
203    }
204    imported
205}
206
207/// Update a key's plaintext schema metadata.
208///
209/// `rotation_interval_days` and `expires_at` are tri-state patches so a
210/// `describe` that omits them never clobbers sticky rotation policy:
211/// - `None`        — leave the existing value untouched
212/// - `Some(None)`  — clear it
213/// - `Some(Some)`  — set it
214pub fn describe_key(
215    vault: &mut types::Vault,
216    key: &str,
217    description: &str,
218    example: Option<&str>,
219    tags: &[String],
220    rotation_interval_days: Option<Option<u32>>,
221    expires_at: Option<Option<&str>>,
222) {
223    if let Some(entry) = vault.schema.get_mut(key) {
224        entry.description = description.into();
225        entry.example = example.map(Into::into);
226        if !tags.is_empty() {
227            entry.tags = tags.to_vec();
228        }
229        if let Some(patch) = rotation_interval_days {
230            entry.rotation_interval_days = patch;
231        }
232        if let Some(patch) = expires_at {
233            entry.expires_at = patch.map(Into::into);
234        }
235    } else {
236        let now = now_utc();
237        vault.schema.insert(
238            key.into(),
239            types::SchemaEntry {
240                description: description.into(),
241                example: example.map(Into::into),
242                tags: tags.to_vec(),
243                created: Some(now.clone()),
244                updated: Some(now),
245                // flatten() turns the tri-state patch into the stored value:
246                // a clear (Some(None)) and an absent patch (None) both mean None.
247                rotation_interval_days: rotation_interval_days.flatten(),
248                expires_at: expires_at.flatten().map(Into::into),
249                revoked_at: None,
250            },
251        );
252    }
253}
254
255/// Days of lead time before a hard `expires_at` is flagged as "expiring soon".
256pub const EXPIRY_WARN_DAYS: i64 = 14;
257
258/// A rotation-hygiene problem found by [`rotation_health`].
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub enum RotationIssue {
261    /// `rotation_interval_days` has elapsed since the value was last changed.
262    Overdue {
263        key: String,
264        last_rotated: String,
265        interval_days: u32,
266        overdue_days: i64,
267    },
268    /// A rotation interval is set but there is no `updated` timestamp to anchor it.
269    NoBaseline { key: String, interval_days: u32 },
270    /// `expires_at` is in the past.
271    Expired {
272        key: String,
273        expired_at: String,
274        days_ago: i64,
275    },
276    /// `expires_at` falls within [`EXPIRY_WARN_DAYS`] of now.
277    ExpiringSoon {
278        key: String,
279        expires_at: String,
280        days_left: i64,
281    },
282    /// A recipient who could read this key was revoked and it has not been
283    /// rotated since — the revoked recipient can still decrypt the live value
284    /// from git history. Driven by the `revoked_at` marker's presence.
285    RevokePending { key: String, since: String },
286    /// A stored timestamp could not be parsed as RFC-3339.
287    BadTimestamp {
288        key: String,
289        field: &'static str,
290        value: String,
291    },
292}
293
294/// Evaluate per-key rotation hygiene against `now`.
295///
296/// Reads only the plaintext schema, so it runs without decrypting the vault.
297/// `now` is injected (rather than read from the clock) to keep this pure and
298/// deterministically testable.
299pub fn rotation_health(
300    vault: &types::Vault,
301    now: chrono::DateTime<chrono::Utc>,
302) -> Vec<RotationIssue> {
303    use chrono::Duration;
304
305    let mut issues = Vec::new();
306    for (key, entry) in &vault.schema {
307        // Soft rotation interval, anchored on the last value change (`updated`).
308        if let Some(days) = entry.rotation_interval_days {
309            match &entry.updated {
310                Some(ts) => match parse_ts(ts) {
311                    Some(updated) => {
312                        let due = updated + Duration::days(i64::from(days));
313                        if now > due {
314                            issues.push(RotationIssue::Overdue {
315                                key: key.clone(),
316                                last_rotated: ts.clone(),
317                                interval_days: days,
318                                overdue_days: (now - due).num_days(),
319                            });
320                        }
321                    }
322                    None => issues.push(RotationIssue::BadTimestamp {
323                        key: key.clone(),
324                        field: "updated",
325                        value: ts.clone(),
326                    }),
327                },
328                None => issues.push(RotationIssue::NoBaseline {
329                    key: key.clone(),
330                    interval_days: days,
331                }),
332            }
333        }
334
335        // Hard expiry.
336        if let Some(ts) = &entry.expires_at {
337            match parse_ts(ts) {
338                Some(expiry) if now >= expiry => issues.push(RotationIssue::Expired {
339                    key: key.clone(),
340                    expired_at: ts.clone(),
341                    days_ago: (now - expiry).num_days(),
342                }),
343                Some(expiry) if expiry - now <= Duration::days(EXPIRY_WARN_DAYS) => {
344                    issues.push(RotationIssue::ExpiringSoon {
345                        key: key.clone(),
346                        expires_at: ts.clone(),
347                        days_left: (expiry - now).num_days(),
348                    });
349                }
350                Some(_) => {}
351                None => issues.push(RotationIssue::BadTimestamp {
352                    key: key.clone(),
353                    field: "expires_at",
354                    value: ts.clone(),
355                }),
356            }
357        }
358
359        // Outstanding post-revoke rotation. The marker's presence is the signal
360        // (a value write clears it), so this is independent of `now`.
361        if let Some(since) = &entry.revoked_at {
362            issues.push(RotationIssue::RevokePending {
363                key: key.clone(),
364                since: since.clone(),
365            });
366        }
367    }
368    issues
369}
370
371/// Parse an ISO-8601 / RFC-3339 timestamp (the format `now_utc` emits) into UTC.
372fn parse_ts(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
373    chrono::DateTime::parse_from_rfc3339(s)
374        .ok()
375        .map(|dt| dt.with_timezone(&chrono::Utc))
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use crate::testutil::*;
382    use std::collections::HashMap;
383
384    #[test]
385    fn add_secret_shared() {
386        let (secret, _) = generate_keypair();
387        let identity = make_identity(&secret);
388        let mut vault = empty_vault();
389        let mut murk = empty_murk();
390
391        let needs_hint = add_secret(
392            &mut vault,
393            &mut murk,
394            "KEY",
395            "value",
396            None,
397            false,
398            &[],
399            &identity,
400        );
401
402        assert!(needs_hint);
403        assert_eq!(murk.values["KEY"].as_str(), "value");
404        assert!(vault.schema.contains_key("KEY"));
405        assert!(vault.schema["KEY"].description.is_empty());
406    }
407
408    #[test]
409    fn add_secret_with_description() {
410        let (secret, _) = generate_keypair();
411        let identity = make_identity(&secret);
412        let mut vault = empty_vault();
413        let mut murk = empty_murk();
414
415        let needs_hint = add_secret(
416            &mut vault,
417            &mut murk,
418            "KEY",
419            "value",
420            Some("a desc"),
421            false,
422            &[],
423            &identity,
424        );
425
426        assert!(!needs_hint);
427        assert_eq!(vault.schema["KEY"].description, "a desc");
428    }
429
430    #[test]
431    fn add_secret_scoped() {
432        let (secret, pubkey) = generate_keypair();
433        let identity = make_identity(&secret);
434        let mut vault = empty_vault();
435        let mut murk = empty_murk();
436
437        add_secret(
438            &mut vault,
439            &mut murk,
440            "KEY",
441            "scoped_val",
442            None,
443            true,
444            &[],
445            &identity,
446        );
447
448        assert!(!murk.values.contains_key("KEY"));
449        assert_eq!(murk.private["KEY"][&pubkey].as_str(), "scoped_val");
450    }
451
452    #[test]
453    fn add_secret_merges_tags() {
454        let (secret, _) = generate_keypair();
455        let identity = make_identity(&secret);
456        let mut vault = empty_vault();
457        let mut murk = empty_murk();
458
459        let tags1 = vec!["db".into()];
460        add_secret(
461            &mut vault, &mut murk, "KEY", "v1", None, false, &tags1, &identity,
462        );
463        assert_eq!(vault.schema["KEY"].tags, vec!["db"]);
464
465        let tags2 = vec!["backend".into()];
466        add_secret(
467            &mut vault, &mut murk, "KEY", "v2", None, false, &tags2, &identity,
468        );
469        assert_eq!(vault.schema["KEY"].tags, vec!["db", "backend"]);
470
471        // Adding duplicate tag should not create duplicates.
472        let tags3 = vec!["db".into()];
473        add_secret(
474            &mut vault, &mut murk, "KEY", "v3", None, false, &tags3, &identity,
475        );
476        assert_eq!(vault.schema["KEY"].tags, vec!["db", "backend"]);
477    }
478
479    #[test]
480    fn add_secret_updates_existing_desc() {
481        let (secret, _) = generate_keypair();
482        let identity = make_identity(&secret);
483        let mut vault = empty_vault();
484        let mut murk = empty_murk();
485
486        add_secret(
487            &mut vault,
488            &mut murk,
489            "KEY",
490            "v1",
491            Some("old"),
492            false,
493            &[],
494            &identity,
495        );
496        add_secret(
497            &mut vault,
498            &mut murk,
499            "KEY",
500            "v2",
501            Some("new"),
502            false,
503            &[],
504            &identity,
505        );
506        assert_eq!(vault.schema["KEY"].description, "new");
507    }
508
509    #[test]
510    fn remove_secret_clears_all() {
511        let mut vault = empty_vault();
512        vault.schema.insert(
513            "KEY".into(),
514            types::SchemaEntry {
515                description: "desc".into(),
516                example: None,
517                tags: vec![],
518                ..Default::default()
519            },
520        );
521        let mut murk = empty_murk();
522        murk.values.insert("KEY".into(), secret("val"));
523        let mut scoped = HashMap::new();
524        scoped.insert("age1pk".into(), secret("scoped_val"));
525        murk.private.insert("KEY".into(), scoped);
526
527        remove_secret(&mut vault, &mut murk, "KEY");
528
529        assert!(!murk.values.contains_key("KEY"));
530        assert!(!murk.private.contains_key("KEY"));
531        assert!(!vault.schema.contains_key("KEY"));
532    }
533
534    #[test]
535    fn get_secret_shared_value() {
536        let mut murk = empty_murk();
537        murk.values.insert("KEY".into(), secret("shared_val"));
538
539        assert_eq!(get_secret(&murk, "KEY", "age1pk"), Some("shared_val"));
540    }
541
542    #[test]
543    fn get_secret_scoped_overrides_shared() {
544        let mut murk = empty_murk();
545        murk.values.insert("KEY".into(), secret("shared_val"));
546        let mut scoped = HashMap::new();
547        scoped.insert("age1pk".into(), secret("scoped_val"));
548        murk.private.insert("KEY".into(), scoped);
549
550        assert_eq!(get_secret(&murk, "KEY", "age1pk"), Some("scoped_val"));
551    }
552
553    #[test]
554    fn get_secret_missing_returns_none() {
555        let murk = empty_murk();
556        assert_eq!(get_secret(&murk, "NONEXISTENT", "age1pk"), None);
557    }
558
559    #[test]
560    fn list_keys_no_filter() {
561        let mut vault = empty_vault();
562        vault.schema.insert(
563            "A".into(),
564            types::SchemaEntry {
565                description: String::new(),
566                example: None,
567                tags: vec![],
568                ..Default::default()
569            },
570        );
571        vault.schema.insert(
572            "B".into(),
573            types::SchemaEntry {
574                description: String::new(),
575                example: None,
576                tags: vec![],
577                ..Default::default()
578            },
579        );
580
581        let keys = list_keys(&vault, &[]);
582        assert_eq!(keys, vec!["A", "B"]);
583    }
584
585    #[test]
586    fn list_keys_with_tag_filter() {
587        let mut vault = empty_vault();
588        vault.schema.insert(
589            "A".into(),
590            types::SchemaEntry {
591                description: String::new(),
592                example: None,
593                tags: vec!["db".into()],
594                ..Default::default()
595            },
596        );
597        vault.schema.insert(
598            "B".into(),
599            types::SchemaEntry {
600                description: String::new(),
601                example: None,
602                tags: vec!["api".into()],
603                ..Default::default()
604            },
605        );
606        vault.schema.insert(
607            "C".into(),
608            types::SchemaEntry {
609                description: String::new(),
610                example: None,
611                tags: vec![],
612                ..Default::default()
613            },
614        );
615
616        let keys = list_keys(&vault, &["db".into()]);
617        assert_eq!(keys, vec!["A"]);
618    }
619
620    #[test]
621    fn list_keys_no_matches() {
622        let mut vault = empty_vault();
623        vault.schema.insert(
624            "A".into(),
625            types::SchemaEntry {
626                description: String::new(),
627                example: None,
628                tags: vec!["db".into()],
629                ..Default::default()
630            },
631        );
632
633        let keys = list_keys(&vault, &["nonexistent".into()]);
634        assert!(keys.is_empty());
635    }
636
637    #[test]
638    fn describe_key_creates_new() {
639        let mut vault = empty_vault();
640        describe_key(
641            &mut vault,
642            "KEY",
643            "a description",
644            Some("example"),
645            &["tag".into()],
646            None,
647            None,
648        );
649
650        assert_eq!(vault.schema["KEY"].description, "a description");
651        assert_eq!(vault.schema["KEY"].example.as_deref(), Some("example"));
652        assert_eq!(vault.schema["KEY"].tags, vec!["tag"]);
653    }
654
655    #[test]
656    fn describe_key_updates_existing() {
657        let mut vault = empty_vault();
658        vault.schema.insert(
659            "KEY".into(),
660            types::SchemaEntry {
661                description: "old".into(),
662                example: Some("old_ex".into()),
663                tags: vec!["old_tag".into()],
664                ..Default::default()
665            },
666        );
667
668        describe_key(
669            &mut vault,
670            "KEY",
671            "new",
672            None,
673            &["new_tag".into()],
674            None,
675            None,
676        );
677
678        assert_eq!(vault.schema["KEY"].description, "new");
679        assert_eq!(vault.schema["KEY"].example, None);
680        assert_eq!(vault.schema["KEY"].tags, vec!["new_tag"]);
681    }
682
683    #[test]
684    fn describe_key_preserves_tags_if_empty() {
685        let mut vault = empty_vault();
686        vault.schema.insert(
687            "KEY".into(),
688            types::SchemaEntry {
689                description: "old".into(),
690                example: None,
691                tags: vec!["keep".into()],
692                ..Default::default()
693            },
694        );
695
696        describe_key(&mut vault, "KEY", "new desc", None, &[], None, None);
697
698        assert_eq!(vault.schema["KEY"].tags, vec!["keep"]);
699    }
700
701    // ── New edge-case tests ──
702
703    #[test]
704    fn add_secret_overwrite_shared_with_scoped() {
705        let (secret, pubkey) = generate_keypair();
706        let identity = make_identity(&secret);
707        let mut vault = empty_vault();
708        let mut murk = empty_murk();
709
710        add_secret(
711            &mut vault,
712            &mut murk,
713            "KEY",
714            "shared_val",
715            None,
716            false,
717            &[],
718            &identity,
719        );
720        assert_eq!(murk.values["KEY"].as_str(), "shared_val");
721
722        add_secret(
723            &mut vault,
724            &mut murk,
725            "KEY",
726            "scoped_val",
727            None,
728            true,
729            &[],
730            &identity,
731        );
732        // Shared value still exists, scoped override added.
733        assert_eq!(murk.values["KEY"].as_str(), "shared_val");
734        assert_eq!(murk.private["KEY"][&pubkey].as_str(), "scoped_val");
735    }
736
737    #[test]
738    fn add_secret_empty_value() {
739        let (secret, _) = generate_keypair();
740        let identity = make_identity(&secret);
741        let mut vault = empty_vault();
742        let mut murk = empty_murk();
743
744        add_secret(
745            &mut vault,
746            &mut murk,
747            "KEY",
748            "",
749            None,
750            false,
751            &[],
752            &identity,
753        );
754        assert_eq!(murk.values["KEY"].as_str(), "");
755    }
756
757    #[test]
758    fn import_secrets_basic() {
759        let mut vault = empty_vault();
760        let mut murk = empty_murk();
761
762        let pairs = vec![
763            ("KEY1".into(), Zeroizing::new("val1".into())),
764            ("KEY2".into(), Zeroizing::new("val2".into())),
765        ];
766        let imported = import_secrets(&mut vault, &mut murk, &pairs);
767
768        assert_eq!(imported, vec!["KEY1", "KEY2"]);
769        assert_eq!(murk.values["KEY1"].as_str(), "val1");
770        assert_eq!(murk.values["KEY2"].as_str(), "val2");
771        assert!(vault.schema.contains_key("KEY1"));
772        assert!(vault.schema.contains_key("KEY2"));
773    }
774
775    #[test]
776    fn import_secrets_existing_schema_preserved() {
777        let mut vault = empty_vault();
778        vault.schema.insert(
779            "KEY1".into(),
780            types::SchemaEntry {
781                description: "existing desc".into(),
782                example: Some("ex".into()),
783                tags: vec!["tag".into()],
784                ..Default::default()
785            },
786        );
787        let mut murk = empty_murk();
788
789        let pairs = vec![("KEY1".into(), Zeroizing::new("new_val".into()))];
790        import_secrets(&mut vault, &mut murk, &pairs);
791
792        assert_eq!(murk.values["KEY1"].as_str(), "new_val");
793        assert_eq!(vault.schema["KEY1"].description, "existing desc");
794    }
795
796    #[test]
797    fn import_secrets_empty() {
798        let mut vault = empty_vault();
799        let mut murk = empty_murk();
800        let imported = import_secrets(&mut vault, &mut murk, &[]);
801        assert!(imported.is_empty());
802    }
803
804    #[test]
805    fn remove_secret_nonexistent() {
806        let mut vault = empty_vault();
807        let mut murk = empty_murk();
808
809        // Should not panic.
810        remove_secret(&mut vault, &mut murk, "NONEXISTENT");
811    }
812
813    // ── Rotation metadata ──
814
815    #[test]
816    fn describe_key_sets_rotation_and_expiry_on_new_key() {
817        let mut vault = empty_vault();
818        describe_key(
819            &mut vault,
820            "TOKEN",
821            "api token",
822            None,
823            &[],
824            Some(Some(90)),
825            Some(Some("2026-09-01T23:59:59Z")),
826        );
827        let e = &vault.schema["TOKEN"];
828        assert_eq!(e.rotation_interval_days, Some(90));
829        assert_eq!(e.expires_at.as_deref(), Some("2026-09-01T23:59:59Z"));
830    }
831
832    #[test]
833    fn describe_key_rotation_patch_is_sticky_and_clearable() {
834        let mut vault = empty_vault();
835        describe_key(&mut vault, "K", "d", None, &[], Some(Some(30)), None);
836        assert_eq!(vault.schema["K"].rotation_interval_days, Some(30));
837
838        // A later describe that omits the flag (None) preserves the interval.
839        describe_key(&mut vault, "K", "d2", None, &[], None, None);
840        assert_eq!(vault.schema["K"].rotation_interval_days, Some(30));
841
842        // Some(None) clears it.
843        describe_key(&mut vault, "K", "d3", None, &[], Some(None), None);
844        assert_eq!(vault.schema["K"].rotation_interval_days, None);
845    }
846
847    fn ts(s: &str) -> chrono::DateTime<chrono::Utc> {
848        chrono::DateTime::parse_from_rfc3339(s)
849            .unwrap()
850            .with_timezone(&chrono::Utc)
851    }
852
853    fn vault_with(entry: types::SchemaEntry) -> types::Vault {
854        let mut v = empty_vault();
855        v.schema.insert("K".into(), entry);
856        v
857    }
858
859    #[test]
860    fn rotation_health_flags_overdue() {
861        let vault = vault_with(types::SchemaEntry {
862            updated: Some("2026-01-01T00:00:00Z".into()),
863            rotation_interval_days: Some(30),
864            ..Default::default()
865        });
866        // 60 days later: 30 past due.
867        let issues = rotation_health(&vault, ts("2026-03-02T00:00:00Z"));
868        assert_eq!(issues.len(), 1);
869        assert!(matches!(
870            &issues[0],
871            RotationIssue::Overdue { key, overdue_days, .. } if key == "K" && *overdue_days == 30
872        ));
873    }
874
875    #[test]
876    fn rotation_health_silent_when_within_interval() {
877        let vault = vault_with(types::SchemaEntry {
878            updated: Some("2026-01-01T00:00:00Z".into()),
879            rotation_interval_days: Some(90),
880            ..Default::default()
881        });
882        assert!(rotation_health(&vault, ts("2026-02-01T00:00:00Z")).is_empty());
883    }
884
885    #[test]
886    fn rotation_health_flags_no_baseline() {
887        let vault = vault_with(types::SchemaEntry {
888            rotation_interval_days: Some(30),
889            ..Default::default()
890        });
891        assert!(matches!(
892            &rotation_health(&vault, ts("2026-03-02T00:00:00Z"))[0],
893            RotationIssue::NoBaseline {
894                interval_days: 30,
895                ..
896            }
897        ));
898    }
899
900    #[test]
901    fn rotation_health_flags_expired_and_expiring_soon() {
902        let expired = vault_with(types::SchemaEntry {
903            expires_at: Some("2026-01-01T00:00:00Z".into()),
904            ..Default::default()
905        });
906        assert!(matches!(
907            &rotation_health(&expired, ts("2026-01-11T00:00:00Z"))[0],
908            RotationIssue::Expired { days_ago: 10, .. }
909        ));
910
911        let soon = vault_with(types::SchemaEntry {
912            expires_at: Some("2026-01-10T00:00:00Z".into()),
913            ..Default::default()
914        });
915        assert!(matches!(
916            &rotation_health(&soon, ts("2026-01-01T00:00:00Z"))[0],
917            RotationIssue::ExpiringSoon { days_left: 9, .. }
918        ));
919
920        // Far out: silent.
921        assert!(rotation_health(&soon, ts("2025-06-01T00:00:00Z")).is_empty());
922    }
923
924    #[test]
925    fn rotation_health_flags_bad_timestamp() {
926        let vault = vault_with(types::SchemaEntry {
927            expires_at: Some("not-a-date".into()),
928            ..Default::default()
929        });
930        assert!(matches!(
931            &rotation_health(&vault, ts("2026-01-01T00:00:00Z"))[0],
932            RotationIssue::BadTimestamp {
933                field: "expires_at",
934                ..
935            }
936        ));
937    }
938
939    #[test]
940    fn rotation_health_flags_revoke_pending() {
941        // The marker's presence is the signal — independent of `now` and of any
942        // interval/expiry policy.
943        let vault = vault_with(types::SchemaEntry {
944            revoked_at: Some("2026-06-18T00:00:00Z".into()),
945            ..Default::default()
946        });
947        assert!(matches!(
948            &rotation_health(&vault, ts("2030-01-01T00:00:00Z"))[0],
949            RotationIssue::RevokePending { key, since }
950                if key == "K" && since == "2026-06-18T00:00:00Z"
951        ));
952    }
953
954    #[test]
955    fn mark_revoked_stamps_only_existing_schema_entries() {
956        let mut vault = vault_with(types::SchemaEntry::default());
957        mark_revoked(
958            &mut vault,
959            &["K".into(), "ABSENT".into()],
960            ts("2026-06-18T12:00:00Z"),
961        );
962        assert_eq!(
963            vault.schema["K"].revoked_at.as_deref(),
964            Some("2026-06-18T12:00:00Z")
965        );
966        // A key with no schema entry is silently skipped — nothing to flag.
967        assert!(!vault.schema.contains_key("ABSENT"));
968    }
969
970    #[test]
971    fn value_write_clears_revoke_marker() {
972        // A rotation (any value write) must clear the obligation: presence of
973        // `revoked_at` always means "still owed a rotation since the revoke".
974        let mut vault = vault_with(types::SchemaEntry {
975            description: "d".into(),
976            revoked_at: Some("2026-06-18T00:00:00Z".into()),
977            ..Default::default()
978        });
979        let mut murk = empty_murk();
980        import_secrets(&mut vault, &mut murk, &[("K".into(), secret("new"))]);
981        assert_eq!(vault.schema["K"].revoked_at, None);
982        assert!(rotation_health(&vault, ts("2030-01-01T00:00:00Z")).is_empty());
983    }
984}