Skip to main content

devboy_storage/
expiry.rs

1//! Expiry + rotation reminders per [ADR-020] §3.
2//!
3//! Two timer concepts attach to every global-index entry:
4//!
5//! - **Hard expiry.** `entry.expires_at` is the upstream-reported
6//!   date the credential stops working. P9.2's liveness probes
7//!   write it back into the index when the upstream surfaces one
8//!   (GitLab `expires_at`, GitHub
9//!   `github-authentication-token-expiration` header).
10//! - **Rotation cadence.** `entry.last_rotated_at` +
11//!   `entry.rotate_every_days` is an *advisory* schedule the
12//!   user attaches per secret. The framework doesn't enforce
13//!   it; doctor warns when a secret is overdue.
14//!
15//! [`check_rotation_reminders`] is the pure function that walks
16//! the index against `today` and returns a list of warnings.
17//! Doctor (P10.1 / this commit's doctor wiring) renders them as a
18//! single `Warning` row.
19//!
20//! ## Warning windows
21//!
22//! Per the task spec for P9.3:
23//!
24//! - `now > expires_at - 7d`  → `ExpiringSoon` /
25//!   `Expired` (negative `days_remaining`).
26//! - `now > last_rotated_at + rotate_every_days - 7d` →
27//!   `RotationDueSoon` / `RotationOverdue`.
28//!
29//! Seven days is the warning window for *both* timers. P7.3
30//! already uses 14 days for the *informational* `expires_at`
31//! check on individual paths in `doctor`; this module is the
32//! *rotation reminders* surface and intentionally fires
33//! tighter.
34//!
35//! [ADR-020]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-020-secret-manifest-and-alias-resolution.md
36
37use chrono::NaiveDate;
38
39use crate::index::{GlobalIndex, IndexEntry};
40use crate::secret_path::SecretPath;
41
42/// Days-out-from-event when the warning starts to fire.
43pub const WARNING_WINDOW_DAYS: i64 = 7;
44
45// =============================================================================
46// Public types
47// =============================================================================
48
49/// One reminder produced by [`check_rotation_reminders`].
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct ExpiryWarning {
52    /// Which path the warning is about.
53    pub path: SecretPath,
54    /// Why we're warning.
55    pub kind: ExpiryWarningKind,
56}
57
58/// Reason for an [`ExpiryWarning`].
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum ExpiryWarningKind {
61    /// `entry.expires_at` is within `WARNING_WINDOW_DAYS` of
62    /// today.
63    ExpiringSoon {
64        /// `entry.expires_at` (verbatim ISO 8601 from the index).
65        expires_at: String,
66        /// Days from `today` until expiry. Positive.
67        days_remaining: i64,
68    },
69    /// `entry.expires_at` is in the past — the credential is
70    /// already expired.
71    Expired {
72        /// `entry.expires_at` (verbatim).
73        expires_at: String,
74        /// Days `today` is past expiry. Positive.
75        days_overdue: i64,
76    },
77    /// `last_rotated_at + rotate_every_days` is within
78    /// `WARNING_WINDOW_DAYS` of today — rotate soon.
79    RotationDueSoon {
80        /// `entry.last_rotated_at` (verbatim).
81        last_rotated_at: String,
82        /// `entry.rotate_every_days`.
83        rotate_every_days: u32,
84        /// Days until the schedule says rotate. Positive.
85        days_remaining: i64,
86    },
87    /// Rotation schedule already overdue.
88    RotationOverdue {
89        /// `entry.last_rotated_at` (verbatim).
90        last_rotated_at: String,
91        /// `entry.rotate_every_days`.
92        rotate_every_days: u32,
93        /// Days `today` is past the rotation schedule. Positive.
94        days_overdue: i64,
95    },
96}
97
98// =============================================================================
99// Pure check
100// =============================================================================
101
102/// Walk every entry in `index` and produce zero or more
103/// [`ExpiryWarning`]s relative to `today`.
104///
105/// Order of warnings:
106///
107/// 1. Sorted by path (the index is a `BTreeMap`, so iteration
108///    is already sorted — no extra sort needed).
109/// 2. Within a path, the function emits *both* an expiry
110///    warning and a rotation warning when both fire. The two
111///    timers are independent; doctor renders them as separate
112///    bullet points.
113pub fn check_rotation_reminders(index: &GlobalIndex, today: NaiveDate) -> Vec<ExpiryWarning> {
114    let mut out = Vec::new();
115    for (path, entry) in index.iter() {
116        if let Some(kind) = check_expiry(entry, today) {
117            out.push(ExpiryWarning {
118                path: path.clone(),
119                kind,
120            });
121        }
122        if let Some(kind) = check_rotation(entry, today) {
123            out.push(ExpiryWarning {
124                path: path.clone(),
125                kind,
126            });
127        }
128    }
129    out
130}
131
132fn check_expiry(entry: &IndexEntry, today: NaiveDate) -> Option<ExpiryWarningKind> {
133    let raw = entry.expires_at.as_deref()?;
134    let date = NaiveDate::parse_from_str(raw, "%Y-%m-%d").ok()?;
135    let delta = (date - today).num_days();
136    if delta < 0 {
137        Some(ExpiryWarningKind::Expired {
138            expires_at: raw.to_owned(),
139            days_overdue: -delta,
140        })
141    } else if delta <= WARNING_WINDOW_DAYS {
142        Some(ExpiryWarningKind::ExpiringSoon {
143            expires_at: raw.to_owned(),
144            days_remaining: delta,
145        })
146    } else {
147        None
148    }
149}
150
151fn check_rotation(entry: &IndexEntry, today: NaiveDate) -> Option<ExpiryWarningKind> {
152    let raw = entry.last_rotated_at.as_deref()?;
153    let cadence = entry.rotate_every_days?;
154    let last = NaiveDate::parse_from_str(raw, "%Y-%m-%d").ok()?;
155    let due = last + chrono::Duration::days(cadence as i64);
156    let delta = (due - today).num_days();
157    if delta < 0 {
158        Some(ExpiryWarningKind::RotationOverdue {
159            last_rotated_at: raw.to_owned(),
160            rotate_every_days: cadence,
161            days_overdue: -delta,
162        })
163    } else if delta <= WARNING_WINDOW_DAYS {
164        Some(ExpiryWarningKind::RotationDueSoon {
165            last_rotated_at: raw.to_owned(),
166            rotate_every_days: cadence,
167            days_remaining: delta,
168        })
169    } else {
170        None
171    }
172}
173
174// =============================================================================
175// Tests
176// =============================================================================
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::index::{GlobalIndex, IndexEntry};
182
183    fn p(s: &str) -> SecretPath {
184        SecretPath::parse(s).unwrap()
185    }
186
187    fn date(s: &str) -> NaiveDate {
188        NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
189    }
190
191    fn entry_with(
192        expires_at: Option<&str>,
193        last_rotated: Option<&str>,
194        cadence: Option<u32>,
195    ) -> IndexEntry {
196        IndexEntry {
197            expires_at: expires_at.map(str::to_owned),
198            last_rotated_at: last_rotated.map(str::to_owned),
199            rotate_every_days: cadence,
200            ..IndexEntry::default()
201        }
202    }
203
204    fn index_with_entry(path: &str, entry: IndexEntry) -> GlobalIndex {
205        let mut idx = GlobalIndex::new();
206        idx.insert(p(path), entry);
207        idx
208    }
209
210    // -- Expiry checks ----------------------------------------
211
212    #[test]
213    fn no_expires_at_no_warning() {
214        let idx = index_with_entry("a/b/c", entry_with(None, None, None));
215        let w = check_rotation_reminders(&idx, date("2026-05-01"));
216        assert!(w.is_empty());
217    }
218
219    #[test]
220    fn expires_at_far_future_no_warning() {
221        let idx = index_with_entry("a/b/c", entry_with(Some("2026-12-31"), None, None));
222        let w = check_rotation_reminders(&idx, date("2026-05-01"));
223        assert!(w.is_empty());
224    }
225
226    #[test]
227    fn expires_at_within_warning_window_emits_expiring_soon() {
228        let idx = index_with_entry("a/b/c", entry_with(Some("2026-05-05"), None, None));
229        let w = check_rotation_reminders(&idx, date("2026-05-01"));
230        assert_eq!(w.len(), 1);
231        match &w[0].kind {
232            ExpiryWarningKind::ExpiringSoon {
233                expires_at,
234                days_remaining,
235            } => {
236                assert_eq!(expires_at, "2026-05-05");
237                assert_eq!(*days_remaining, 4);
238            }
239            other => panic!("expected ExpiringSoon, got {other:?}"),
240        }
241    }
242
243    #[test]
244    fn expires_at_at_exact_window_boundary_emits_expiring_soon() {
245        // Today + 7 days = warning window cap (inclusive).
246        let idx = index_with_entry("a/b/c", entry_with(Some("2026-05-08"), None, None));
247        let w = check_rotation_reminders(&idx, date("2026-05-01"));
248        match &w.first().unwrap().kind {
249            ExpiryWarningKind::ExpiringSoon { days_remaining, .. } => {
250                assert_eq!(*days_remaining, 7);
251            }
252            other => panic!("expected ExpiringSoon, got {other:?}"),
253        }
254    }
255
256    #[test]
257    fn expires_at_one_day_past_window_no_warning() {
258        let idx = index_with_entry("a/b/c", entry_with(Some("2026-05-09"), None, None));
259        let w = check_rotation_reminders(&idx, date("2026-05-01"));
260        assert!(w.is_empty());
261    }
262
263    #[test]
264    fn expires_at_in_the_past_emits_expired_with_days_overdue() {
265        let idx = index_with_entry("a/b/c", entry_with(Some("2026-04-25"), None, None));
266        let w = check_rotation_reminders(&idx, date("2026-05-01"));
267        match &w.first().unwrap().kind {
268            ExpiryWarningKind::Expired {
269                expires_at,
270                days_overdue,
271            } => {
272                assert_eq!(expires_at, "2026-04-25");
273                assert_eq!(*days_overdue, 6);
274            }
275            other => panic!("expected Expired, got {other:?}"),
276        }
277    }
278
279    #[test]
280    fn unparseable_expires_at_yields_no_warning() {
281        let idx = index_with_entry("a/b/c", entry_with(Some("not-a-date"), None, None));
282        let w = check_rotation_reminders(&idx, date("2026-05-01"));
283        assert!(w.is_empty());
284    }
285
286    // -- Rotation checks --------------------------------------
287
288    #[test]
289    fn no_rotation_metadata_no_warning() {
290        let idx = index_with_entry("a/b/c", entry_with(None, Some("2026-04-01"), None));
291        let w = check_rotation_reminders(&idx, date("2026-05-01"));
292        assert!(w.is_empty());
293    }
294
295    #[test]
296    fn rotation_far_in_future_no_warning() {
297        // last_rotated 2026-01-01 + 90 days = 2026-04-01. Today
298        // 2026-01-15 → due in 76 days → no warning.
299        let idx = index_with_entry("a/b/c", entry_with(None, Some("2026-01-01"), Some(90)));
300        let w = check_rotation_reminders(&idx, date("2026-01-15"));
301        assert!(w.is_empty());
302    }
303
304    #[test]
305    fn rotation_due_soon_emits_warning() {
306        // last 2026-01-01 + 30 = 2026-01-31. Today 2026-01-29 →
307        // 2 days remaining.
308        let idx = index_with_entry("a/b/c", entry_with(None, Some("2026-01-01"), Some(30)));
309        let w = check_rotation_reminders(&idx, date("2026-01-29"));
310        match &w.first().unwrap().kind {
311            ExpiryWarningKind::RotationDueSoon {
312                last_rotated_at,
313                rotate_every_days,
314                days_remaining,
315            } => {
316                assert_eq!(last_rotated_at, "2026-01-01");
317                assert_eq!(*rotate_every_days, 30);
318                assert_eq!(*days_remaining, 2);
319            }
320            other => panic!("expected RotationDueSoon, got {other:?}"),
321        }
322    }
323
324    #[test]
325    fn rotation_overdue_emits_warning() {
326        // last 2026-01-01 + 30 = 2026-01-31. Today 2026-02-10 →
327        // 10 days overdue.
328        let idx = index_with_entry("a/b/c", entry_with(None, Some("2026-01-01"), Some(30)));
329        let w = check_rotation_reminders(&idx, date("2026-02-10"));
330        match &w.first().unwrap().kind {
331            ExpiryWarningKind::RotationOverdue { days_overdue, .. } => {
332                assert_eq!(*days_overdue, 10);
333            }
334            other => panic!("expected RotationOverdue, got {other:?}"),
335        }
336    }
337
338    // -- Joint expiry + rotation -------------------------------
339
340    #[test]
341    fn both_timers_within_window_emit_two_warnings() {
342        // Expiring 2026-05-05 (4 days out) + rotation due
343        // 2026-05-04 (last 2026-04-04 + 30).
344        let idx = index_with_entry(
345            "a/b/c",
346            entry_with(Some("2026-05-05"), Some("2026-04-04"), Some(30)),
347        );
348        let w = check_rotation_reminders(&idx, date("2026-05-01"));
349        assert_eq!(w.len(), 2);
350        // Same path on both warnings.
351        assert_eq!(w[0].path, w[1].path);
352        // Expiry warning emitted first per the loop structure.
353        assert!(matches!(w[0].kind, ExpiryWarningKind::ExpiringSoon { .. }));
354        assert!(matches!(
355            w[1].kind,
356            ExpiryWarningKind::RotationDueSoon { .. }
357        ));
358    }
359
360    // -- record_expiry / record_rotation -----------------------
361
362    #[test]
363    fn record_expiry_updates_existing_entry_returns_true() {
364        let mut idx = index_with_entry("a/b/c", entry_with(None, None, None));
365        let changed = idx.record_expiry(&p("a/b/c"), "2026-08-01");
366        assert!(changed);
367        let entry = idx.get(&p("a/b/c")).unwrap();
368        assert_eq!(entry.expires_at.as_deref(), Some("2026-08-01"));
369    }
370
371    #[test]
372    fn record_expiry_unchanged_returns_false() {
373        let mut idx = index_with_entry("a/b/c", entry_with(Some("2026-08-01"), None, None));
374        let changed = idx.record_expiry(&p("a/b/c"), "2026-08-01");
375        assert!(!changed, "no-op write should report unchanged");
376    }
377
378    #[test]
379    fn record_expiry_missing_path_returns_false() {
380        let mut idx = GlobalIndex::new();
381        let changed = idx.record_expiry(&p("a/b/c"), "2026-08-01");
382        assert!(!changed);
383    }
384
385    #[test]
386    fn record_rotation_updates_existing_entry() {
387        let mut idx = index_with_entry("a/b/c", entry_with(None, None, None));
388        let changed = idx.record_rotation(&p("a/b/c"), "2026-05-01");
389        assert!(changed);
390        let entry = idx.get(&p("a/b/c")).unwrap();
391        assert_eq!(entry.last_rotated_at.as_deref(), Some("2026-05-01"));
392    }
393
394    // -- save_to / save round-trip -----------------------------
395
396    #[test]
397    fn save_to_round_trip_preserves_entries() {
398        let dir = tempfile::TempDir::new().unwrap();
399        let path = dir.path().join("subdir").join("index.toml");
400        let mut idx = index_with_entry(
401            "team/x/y",
402            IndexEntry {
403                expires_at: Some("2026-08-01".to_owned()),
404                description: Some("test".to_owned()),
405                ..IndexEntry::default()
406            },
407        );
408        idx.record_expiry(&p("team/x/y"), "2026-09-01");
409        idx.save_to(&path).unwrap();
410
411        let reloaded = GlobalIndex::load_from(&path).unwrap();
412        let entry = reloaded.get(&p("team/x/y")).unwrap();
413        assert_eq!(entry.expires_at.as_deref(), Some("2026-09-01"));
414        assert_eq!(entry.description.as_deref(), Some("test"));
415    }
416}