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///
260/// Serializes with a `reason` tag (e.g. `{"reason": "overdue", "key": ...}`)
261/// so `rotate --list --json` output is stable for scripts and agents.
262#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
263#[serde(tag = "reason", rename_all = "snake_case")]
264pub enum RotationIssue {
265    /// `rotation_interval_days` has elapsed since the value was last changed.
266    Overdue {
267        key: String,
268        last_rotated: String,
269        interval_days: u32,
270        overdue_days: i64,
271    },
272    /// A rotation interval is set but there is no `updated` timestamp to anchor it.
273    NoBaseline { key: String, interval_days: u32 },
274    /// `expires_at` is in the past.
275    Expired {
276        key: String,
277        expired_at: String,
278        days_ago: i64,
279    },
280    /// `expires_at` falls within [`EXPIRY_WARN_DAYS`] of now.
281    ExpiringSoon {
282        key: String,
283        expires_at: String,
284        days_left: i64,
285    },
286    /// A recipient who could read this key was revoked and it has not been
287    /// rotated since — the revoked recipient can still decrypt the live value
288    /// from git history. Driven by the `revoked_at` marker's presence.
289    RevokePending { key: String, since: String },
290    /// A stored timestamp could not be parsed as RFC-3339.
291    BadTimestamp {
292        key: String,
293        field: &'static str,
294        value: String,
295    },
296}
297
298/// Evaluate per-key rotation hygiene against `now`.
299///
300/// Reads only the plaintext schema, so it runs without decrypting the vault.
301/// `now` is injected (rather than read from the clock) to keep this pure and
302/// deterministically testable.
303pub fn rotation_health(
304    vault: &types::Vault,
305    now: chrono::DateTime<chrono::Utc>,
306) -> Vec<RotationIssue> {
307    use chrono::Duration;
308
309    let mut issues = Vec::new();
310    for (key, entry) in &vault.schema {
311        // Soft rotation interval, anchored on the last value change (`updated`).
312        if let Some(days) = entry.rotation_interval_days {
313            match &entry.updated {
314                Some(ts) => match parse_ts(ts) {
315                    Some(updated) => {
316                        let due = updated + Duration::days(i64::from(days));
317                        if now > due {
318                            issues.push(RotationIssue::Overdue {
319                                key: key.clone(),
320                                last_rotated: ts.clone(),
321                                interval_days: days,
322                                overdue_days: (now - due).num_days(),
323                            });
324                        }
325                    }
326                    None => issues.push(RotationIssue::BadTimestamp {
327                        key: key.clone(),
328                        field: "updated",
329                        value: ts.clone(),
330                    }),
331                },
332                None => issues.push(RotationIssue::NoBaseline {
333                    key: key.clone(),
334                    interval_days: days,
335                }),
336            }
337        }
338
339        // Hard expiry.
340        if let Some(ts) = &entry.expires_at {
341            match parse_ts(ts) {
342                Some(expiry) if now >= expiry => issues.push(RotationIssue::Expired {
343                    key: key.clone(),
344                    expired_at: ts.clone(),
345                    days_ago: (now - expiry).num_days(),
346                }),
347                Some(expiry) if expiry - now <= Duration::days(EXPIRY_WARN_DAYS) => {
348                    issues.push(RotationIssue::ExpiringSoon {
349                        key: key.clone(),
350                        expires_at: ts.clone(),
351                        days_left: (expiry - now).num_days(),
352                    });
353                }
354                Some(_) => {}
355                None => issues.push(RotationIssue::BadTimestamp {
356                    key: key.clone(),
357                    field: "expires_at",
358                    value: ts.clone(),
359                }),
360            }
361        }
362
363        // Outstanding post-revoke rotation. The marker's presence is the signal
364        // (a value write clears it), so this is independent of `now`.
365        if let Some(since) = &entry.revoked_at {
366            issues.push(RotationIssue::RevokePending {
367                key: key.clone(),
368                since: since.clone(),
369            });
370        }
371    }
372    issues
373}
374
375/// Parse an ISO-8601 / RFC-3339 timestamp (the format `now_utc` emits) into UTC.
376fn parse_ts(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
377    chrono::DateTime::parse_from_rfc3339(s)
378        .ok()
379        .map(|dt| dt.with_timezone(&chrono::Utc))
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use crate::testutil::*;
386    use std::collections::HashMap;
387
388    #[test]
389    fn add_secret_shared() {
390        let (secret, _) = generate_keypair();
391        let identity = make_identity(&secret);
392        let mut vault = empty_vault();
393        let mut murk = empty_murk();
394
395        let needs_hint = add_secret(
396            &mut vault,
397            &mut murk,
398            "KEY",
399            "value",
400            None,
401            false,
402            &[],
403            &identity,
404        );
405
406        assert!(needs_hint);
407        assert_eq!(murk.values["KEY"].as_str(), "value");
408        assert!(vault.schema.contains_key("KEY"));
409        assert!(vault.schema["KEY"].description.is_empty());
410    }
411
412    #[test]
413    fn add_secret_with_description() {
414        let (secret, _) = generate_keypair();
415        let identity = make_identity(&secret);
416        let mut vault = empty_vault();
417        let mut murk = empty_murk();
418
419        let needs_hint = add_secret(
420            &mut vault,
421            &mut murk,
422            "KEY",
423            "value",
424            Some("a desc"),
425            false,
426            &[],
427            &identity,
428        );
429
430        assert!(!needs_hint);
431        assert_eq!(vault.schema["KEY"].description, "a desc");
432    }
433
434    #[test]
435    fn add_secret_scoped() {
436        let (secret, pubkey) = generate_keypair();
437        let identity = make_identity(&secret);
438        let mut vault = empty_vault();
439        let mut murk = empty_murk();
440
441        add_secret(
442            &mut vault,
443            &mut murk,
444            "KEY",
445            "scoped_val",
446            None,
447            true,
448            &[],
449            &identity,
450        );
451
452        assert!(!murk.values.contains_key("KEY"));
453        assert_eq!(murk.private["KEY"][&pubkey].as_str(), "scoped_val");
454    }
455
456    #[test]
457    fn add_secret_merges_tags() {
458        let (secret, _) = generate_keypair();
459        let identity = make_identity(&secret);
460        let mut vault = empty_vault();
461        let mut murk = empty_murk();
462
463        let tags1 = vec!["db".into()];
464        add_secret(
465            &mut vault, &mut murk, "KEY", "v1", None, false, &tags1, &identity,
466        );
467        assert_eq!(vault.schema["KEY"].tags, vec!["db"]);
468
469        let tags2 = vec!["backend".into()];
470        add_secret(
471            &mut vault, &mut murk, "KEY", "v2", None, false, &tags2, &identity,
472        );
473        assert_eq!(vault.schema["KEY"].tags, vec!["db", "backend"]);
474
475        // Adding duplicate tag should not create duplicates.
476        let tags3 = vec!["db".into()];
477        add_secret(
478            &mut vault, &mut murk, "KEY", "v3", None, false, &tags3, &identity,
479        );
480        assert_eq!(vault.schema["KEY"].tags, vec!["db", "backend"]);
481    }
482
483    #[test]
484    fn add_secret_updates_existing_desc() {
485        let (secret, _) = generate_keypair();
486        let identity = make_identity(&secret);
487        let mut vault = empty_vault();
488        let mut murk = empty_murk();
489
490        add_secret(
491            &mut vault,
492            &mut murk,
493            "KEY",
494            "v1",
495            Some("old"),
496            false,
497            &[],
498            &identity,
499        );
500        add_secret(
501            &mut vault,
502            &mut murk,
503            "KEY",
504            "v2",
505            Some("new"),
506            false,
507            &[],
508            &identity,
509        );
510        assert_eq!(vault.schema["KEY"].description, "new");
511    }
512
513    #[test]
514    fn remove_secret_clears_all() {
515        let mut vault = empty_vault();
516        vault.schema.insert(
517            "KEY".into(),
518            types::SchemaEntry {
519                description: "desc".into(),
520                example: None,
521                tags: vec![],
522                ..Default::default()
523            },
524        );
525        let mut murk = empty_murk();
526        murk.values.insert("KEY".into(), secret("val"));
527        let mut scoped = HashMap::new();
528        scoped.insert("age1pk".into(), secret("scoped_val"));
529        murk.private.insert("KEY".into(), scoped);
530
531        remove_secret(&mut vault, &mut murk, "KEY");
532
533        assert!(!murk.values.contains_key("KEY"));
534        assert!(!murk.private.contains_key("KEY"));
535        assert!(!vault.schema.contains_key("KEY"));
536    }
537
538    #[test]
539    fn get_secret_shared_value() {
540        let mut murk = empty_murk();
541        murk.values.insert("KEY".into(), secret("shared_val"));
542
543        assert_eq!(get_secret(&murk, "KEY", "age1pk"), Some("shared_val"));
544    }
545
546    #[test]
547    fn get_secret_scoped_overrides_shared() {
548        let mut murk = empty_murk();
549        murk.values.insert("KEY".into(), secret("shared_val"));
550        let mut scoped = HashMap::new();
551        scoped.insert("age1pk".into(), secret("scoped_val"));
552        murk.private.insert("KEY".into(), scoped);
553
554        assert_eq!(get_secret(&murk, "KEY", "age1pk"), Some("scoped_val"));
555    }
556
557    #[test]
558    fn get_secret_missing_returns_none() {
559        let murk = empty_murk();
560        assert_eq!(get_secret(&murk, "NONEXISTENT", "age1pk"), None);
561    }
562
563    #[test]
564    fn list_keys_no_filter() {
565        let mut vault = empty_vault();
566        vault.schema.insert(
567            "A".into(),
568            types::SchemaEntry {
569                description: String::new(),
570                example: None,
571                tags: vec![],
572                ..Default::default()
573            },
574        );
575        vault.schema.insert(
576            "B".into(),
577            types::SchemaEntry {
578                description: String::new(),
579                example: None,
580                tags: vec![],
581                ..Default::default()
582            },
583        );
584
585        let keys = list_keys(&vault, &[]);
586        assert_eq!(keys, vec!["A", "B"]);
587    }
588
589    #[test]
590    fn list_keys_with_tag_filter() {
591        let mut vault = empty_vault();
592        vault.schema.insert(
593            "A".into(),
594            types::SchemaEntry {
595                description: String::new(),
596                example: None,
597                tags: vec!["db".into()],
598                ..Default::default()
599            },
600        );
601        vault.schema.insert(
602            "B".into(),
603            types::SchemaEntry {
604                description: String::new(),
605                example: None,
606                tags: vec!["api".into()],
607                ..Default::default()
608            },
609        );
610        vault.schema.insert(
611            "C".into(),
612            types::SchemaEntry {
613                description: String::new(),
614                example: None,
615                tags: vec![],
616                ..Default::default()
617            },
618        );
619
620        let keys = list_keys(&vault, &["db".into()]);
621        assert_eq!(keys, vec!["A"]);
622    }
623
624    #[test]
625    fn list_keys_no_matches() {
626        let mut vault = empty_vault();
627        vault.schema.insert(
628            "A".into(),
629            types::SchemaEntry {
630                description: String::new(),
631                example: None,
632                tags: vec!["db".into()],
633                ..Default::default()
634            },
635        );
636
637        let keys = list_keys(&vault, &["nonexistent".into()]);
638        assert!(keys.is_empty());
639    }
640
641    #[test]
642    fn describe_key_creates_new() {
643        let mut vault = empty_vault();
644        describe_key(
645            &mut vault,
646            "KEY",
647            "a description",
648            Some("example"),
649            &["tag".into()],
650            None,
651            None,
652        );
653
654        assert_eq!(vault.schema["KEY"].description, "a description");
655        assert_eq!(vault.schema["KEY"].example.as_deref(), Some("example"));
656        assert_eq!(vault.schema["KEY"].tags, vec!["tag"]);
657    }
658
659    #[test]
660    fn describe_key_updates_existing() {
661        let mut vault = empty_vault();
662        vault.schema.insert(
663            "KEY".into(),
664            types::SchemaEntry {
665                description: "old".into(),
666                example: Some("old_ex".into()),
667                tags: vec!["old_tag".into()],
668                ..Default::default()
669            },
670        );
671
672        describe_key(
673            &mut vault,
674            "KEY",
675            "new",
676            None,
677            &["new_tag".into()],
678            None,
679            None,
680        );
681
682        assert_eq!(vault.schema["KEY"].description, "new");
683        assert_eq!(vault.schema["KEY"].example, None);
684        assert_eq!(vault.schema["KEY"].tags, vec!["new_tag"]);
685    }
686
687    #[test]
688    fn describe_key_preserves_tags_if_empty() {
689        let mut vault = empty_vault();
690        vault.schema.insert(
691            "KEY".into(),
692            types::SchemaEntry {
693                description: "old".into(),
694                example: None,
695                tags: vec!["keep".into()],
696                ..Default::default()
697            },
698        );
699
700        describe_key(&mut vault, "KEY", "new desc", None, &[], None, None);
701
702        assert_eq!(vault.schema["KEY"].tags, vec!["keep"]);
703    }
704
705    // ── New edge-case tests ──
706
707    #[test]
708    fn add_secret_overwrite_shared_with_scoped() {
709        let (secret, pubkey) = generate_keypair();
710        let identity = make_identity(&secret);
711        let mut vault = empty_vault();
712        let mut murk = empty_murk();
713
714        add_secret(
715            &mut vault,
716            &mut murk,
717            "KEY",
718            "shared_val",
719            None,
720            false,
721            &[],
722            &identity,
723        );
724        assert_eq!(murk.values["KEY"].as_str(), "shared_val");
725
726        add_secret(
727            &mut vault,
728            &mut murk,
729            "KEY",
730            "scoped_val",
731            None,
732            true,
733            &[],
734            &identity,
735        );
736        // Shared value still exists, scoped override added.
737        assert_eq!(murk.values["KEY"].as_str(), "shared_val");
738        assert_eq!(murk.private["KEY"][&pubkey].as_str(), "scoped_val");
739    }
740
741    #[test]
742    fn add_secret_empty_value() {
743        let (secret, _) = generate_keypair();
744        let identity = make_identity(&secret);
745        let mut vault = empty_vault();
746        let mut murk = empty_murk();
747
748        add_secret(
749            &mut vault,
750            &mut murk,
751            "KEY",
752            "",
753            None,
754            false,
755            &[],
756            &identity,
757        );
758        assert_eq!(murk.values["KEY"].as_str(), "");
759    }
760
761    #[test]
762    fn import_secrets_basic() {
763        let mut vault = empty_vault();
764        let mut murk = empty_murk();
765
766        let pairs = vec![
767            ("KEY1".into(), Zeroizing::new("val1".into())),
768            ("KEY2".into(), Zeroizing::new("val2".into())),
769        ];
770        let imported = import_secrets(&mut vault, &mut murk, &pairs);
771
772        assert_eq!(imported, vec!["KEY1", "KEY2"]);
773        assert_eq!(murk.values["KEY1"].as_str(), "val1");
774        assert_eq!(murk.values["KEY2"].as_str(), "val2");
775        assert!(vault.schema.contains_key("KEY1"));
776        assert!(vault.schema.contains_key("KEY2"));
777    }
778
779    #[test]
780    fn import_secrets_existing_schema_preserved() {
781        let mut vault = empty_vault();
782        vault.schema.insert(
783            "KEY1".into(),
784            types::SchemaEntry {
785                description: "existing desc".into(),
786                example: Some("ex".into()),
787                tags: vec!["tag".into()],
788                ..Default::default()
789            },
790        );
791        let mut murk = empty_murk();
792
793        let pairs = vec![("KEY1".into(), Zeroizing::new("new_val".into()))];
794        import_secrets(&mut vault, &mut murk, &pairs);
795
796        assert_eq!(murk.values["KEY1"].as_str(), "new_val");
797        assert_eq!(vault.schema["KEY1"].description, "existing desc");
798    }
799
800    #[test]
801    fn import_secrets_empty() {
802        let mut vault = empty_vault();
803        let mut murk = empty_murk();
804        let imported = import_secrets(&mut vault, &mut murk, &[]);
805        assert!(imported.is_empty());
806    }
807
808    #[test]
809    fn remove_secret_nonexistent() {
810        let mut vault = empty_vault();
811        let mut murk = empty_murk();
812
813        // Should not panic.
814        remove_secret(&mut vault, &mut murk, "NONEXISTENT");
815    }
816
817    // ── Rotation metadata ──
818
819    #[test]
820    fn describe_key_sets_rotation_and_expiry_on_new_key() {
821        let mut vault = empty_vault();
822        describe_key(
823            &mut vault,
824            "TOKEN",
825            "api token",
826            None,
827            &[],
828            Some(Some(90)),
829            Some(Some("2026-09-01T23:59:59Z")),
830        );
831        let e = &vault.schema["TOKEN"];
832        assert_eq!(e.rotation_interval_days, Some(90));
833        assert_eq!(e.expires_at.as_deref(), Some("2026-09-01T23:59:59Z"));
834    }
835
836    #[test]
837    fn describe_key_rotation_patch_is_sticky_and_clearable() {
838        let mut vault = empty_vault();
839        describe_key(&mut vault, "K", "d", None, &[], Some(Some(30)), None);
840        assert_eq!(vault.schema["K"].rotation_interval_days, Some(30));
841
842        // A later describe that omits the flag (None) preserves the interval.
843        describe_key(&mut vault, "K", "d2", None, &[], None, None);
844        assert_eq!(vault.schema["K"].rotation_interval_days, Some(30));
845
846        // Some(None) clears it.
847        describe_key(&mut vault, "K", "d3", None, &[], Some(None), None);
848        assert_eq!(vault.schema["K"].rotation_interval_days, None);
849    }
850
851    fn ts(s: &str) -> chrono::DateTime<chrono::Utc> {
852        chrono::DateTime::parse_from_rfc3339(s)
853            .unwrap()
854            .with_timezone(&chrono::Utc)
855    }
856
857    fn vault_with(entry: types::SchemaEntry) -> types::Vault {
858        let mut v = empty_vault();
859        v.schema.insert("K".into(), entry);
860        v
861    }
862
863    #[test]
864    fn rotation_health_flags_overdue() {
865        let vault = vault_with(types::SchemaEntry {
866            updated: Some("2026-01-01T00:00:00Z".into()),
867            rotation_interval_days: Some(30),
868            ..Default::default()
869        });
870        // 60 days later: 30 past due.
871        let issues = rotation_health(&vault, ts("2026-03-02T00:00:00Z"));
872        assert_eq!(issues.len(), 1);
873        assert!(matches!(
874            &issues[0],
875            RotationIssue::Overdue { key, overdue_days, .. } if key == "K" && *overdue_days == 30
876        ));
877    }
878
879    #[test]
880    fn rotation_health_silent_when_within_interval() {
881        let vault = vault_with(types::SchemaEntry {
882            updated: Some("2026-01-01T00:00:00Z".into()),
883            rotation_interval_days: Some(90),
884            ..Default::default()
885        });
886        assert!(rotation_health(&vault, ts("2026-02-01T00:00:00Z")).is_empty());
887    }
888
889    #[test]
890    fn rotation_health_flags_no_baseline() {
891        let vault = vault_with(types::SchemaEntry {
892            rotation_interval_days: Some(30),
893            ..Default::default()
894        });
895        assert!(matches!(
896            &rotation_health(&vault, ts("2026-03-02T00:00:00Z"))[0],
897            RotationIssue::NoBaseline {
898                interval_days: 30,
899                ..
900            }
901        ));
902    }
903
904    #[test]
905    fn rotation_health_flags_expired_and_expiring_soon() {
906        let expired = vault_with(types::SchemaEntry {
907            expires_at: Some("2026-01-01T00:00:00Z".into()),
908            ..Default::default()
909        });
910        assert!(matches!(
911            &rotation_health(&expired, ts("2026-01-11T00:00:00Z"))[0],
912            RotationIssue::Expired { days_ago: 10, .. }
913        ));
914
915        let soon = vault_with(types::SchemaEntry {
916            expires_at: Some("2026-01-10T00:00:00Z".into()),
917            ..Default::default()
918        });
919        assert!(matches!(
920            &rotation_health(&soon, ts("2026-01-01T00:00:00Z"))[0],
921            RotationIssue::ExpiringSoon { days_left: 9, .. }
922        ));
923
924        // Far out: silent.
925        assert!(rotation_health(&soon, ts("2025-06-01T00:00:00Z")).is_empty());
926    }
927
928    #[test]
929    fn rotation_health_flags_bad_timestamp() {
930        let vault = vault_with(types::SchemaEntry {
931            expires_at: Some("not-a-date".into()),
932            ..Default::default()
933        });
934        assert!(matches!(
935            &rotation_health(&vault, ts("2026-01-01T00:00:00Z"))[0],
936            RotationIssue::BadTimestamp {
937                field: "expires_at",
938                ..
939            }
940        ));
941    }
942
943    #[test]
944    fn rotation_health_flags_revoke_pending() {
945        // The marker's presence is the signal — independent of `now` and of any
946        // interval/expiry policy.
947        let vault = vault_with(types::SchemaEntry {
948            revoked_at: Some("2026-06-18T00:00:00Z".into()),
949            ..Default::default()
950        });
951        assert!(matches!(
952            &rotation_health(&vault, ts("2030-01-01T00:00:00Z"))[0],
953            RotationIssue::RevokePending { key, since }
954                if key == "K" && since == "2026-06-18T00:00:00Z"
955        ));
956    }
957
958    #[test]
959    fn mark_revoked_stamps_only_existing_schema_entries() {
960        let mut vault = vault_with(types::SchemaEntry::default());
961        mark_revoked(
962            &mut vault,
963            &["K".into(), "ABSENT".into()],
964            ts("2026-06-18T12:00:00Z"),
965        );
966        assert_eq!(
967            vault.schema["K"].revoked_at.as_deref(),
968            Some("2026-06-18T12:00:00Z")
969        );
970        // A key with no schema entry is silently skipped — nothing to flag.
971        assert!(!vault.schema.contains_key("ABSENT"));
972    }
973
974    #[test]
975    fn value_write_clears_revoke_marker() {
976        // A rotation (any value write) must clear the obligation: presence of
977        // `revoked_at` always means "still owed a rotation since the revoke".
978        let mut vault = vault_with(types::SchemaEntry {
979            description: "d".into(),
980            revoked_at: Some("2026-06-18T00:00:00Z".into()),
981            ..Default::default()
982        });
983        let mut murk = empty_murk();
984        import_secrets(&mut vault, &mut murk, &[("K".into(), secret("new"))]);
985        assert_eq!(vault.schema["K"].revoked_at, None);
986        assert!(rotation_health(&vault, ts("2030-01-01T00:00:00Z")).is_empty());
987    }
988}