Skip to main content

varve_core/
support.rs

1//! How long a layer is supported, and what to do when it is not
2//! (REQ-SUPPORTUNTIL-001).
3//!
4//! ## A capability nobody populated
5//!
6//! `REQ-KP-001` shipped this metadata in v0.5.0 and it is verified: a support
7//! window, DSSE-signed, attached as an OCI referrer so it can be added after
8//! deposit without changing the layer digest. `LineStatus::support_until`
9//! round-trips, is covered by tests, and `varve status` prints it.
10//!
11//! Nothing ever set it. Every published layer carried `None`, so every layer
12//! printed *"no stated support window"* while `docs/manifest-format.md` said a
13//! qualified channel "selects a line with a stated support window". A
14//! capability nobody populates is worse than a missing one: the code, the
15//! tests and the docs all imply a guarantee that no artifact carries.
16//!
17//! It was also never *parsed*. The field is a `String`, so `"2028-13-45"` or
18//! `"next year"` would have signed cleanly — and nothing downstream could act
19//! on it, which is why "warn when the window has passed" was not implementable
20//! before this module existed.
21//!
22//! ## Time is data here
23//!
24//! Nothing in this module samples a clock. `varve` samples once at the CLI
25//! boundary (`today_rfc3339`) and passes the day in, exactly as the staleness
26//! verdict already does. A library that reads the clock cannot be tested for
27//! what it does on a particular day, and "what does it do the day after
28//! expiry" is the only interesting question about it.
29
30use crate::rollback::epoch_days;
31use std::fmt;
32
33/// How long a channel supports a layer, in whole months from its issue date.
34///
35/// Policy, not per-release typing: a horizon a human enters each time is one
36/// that drifts, and the drift is invisible because every value looks
37/// plausible.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct Policy {
40    pub months: u32,
41}
42
43impl Policy {
44    /// The stated policy for a channel.
45    ///
46    /// `rolling` is short on purpose. It makes no qualification promise and
47    /// moves continuously; a long window would imply a stability it does not
48    /// have. `qualified` is where a long horizon belongs, because that is the
49    /// channel an assessor is pointed at.
50    pub fn for_channel(channel: &str) -> Option<Policy> {
51        match channel {
52            "rolling" => Some(Policy { months: 6 }),
53            "qualified" => Some(Policy { months: 24 }),
54            _ => None,
55        }
56    }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum SupportError {
61    UnknownChannel(String),
62    BadDate { field: &'static str, value: String },
63}
64
65impl fmt::Display for SupportError {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        match self {
68            SupportError::UnknownChannel(c) => write!(
69                f,
70                "no support policy is stated for channel `{c}` (known: rolling, \
71                 qualified). Refusing to invent one: a horizon nobody decided \
72                 is a promise nobody made."
73            ),
74            SupportError::BadDate { field, value } => write!(
75                f,
76                "{field} `{value}` is not a date (expected YYYY-MM-DD). This \
77                 field has never been validated, so an unparseable value would \
78                 sign cleanly and then be unusable by everything that reads it."
79            ),
80        }
81    }
82}
83
84impl std::error::Error for SupportError {}
85
86/// The support horizon for a layer issued on `issued_at`, as `YYYY-MM-DD`.
87pub fn horizon(issued_at: &str, channel: &str) -> Result<String, SupportError> {
88    let policy =
89        Policy::for_channel(channel).ok_or_else(|| SupportError::UnknownChannel(channel.into()))?;
90    let date = issued_at.split_once('T').map_or(issued_at, |(d, _)| d);
91    if epoch_days(date).is_none() {
92        return Err(SupportError::BadDate {
93            field: "issued-at",
94            value: issued_at.to_string(),
95        });
96    }
97    let y: i64 = date[0..4].parse().expect("checked by epoch_days");
98    let m: i64 = date[5..7].parse().expect("checked by epoch_days");
99    let d: i64 = date[8..10].parse().expect("checked by epoch_days");
100
101    let total = (m - 1) + i64::from(policy.months);
102    let (ny, nm) = (y + total / 12, total % 12 + 1);
103    // Clamp into the target month: adding 6 months to the 31st must not
104    // produce a date that does not exist. Ending a day early is correct; a
105    // date that cannot be parsed is not.
106    let nd = d.min(days_in_month(ny, nm));
107    Ok(format!("{ny:04}-{nm:02}-{nd:02}"))
108}
109
110fn days_in_month(y: i64, m: i64) -> i64 {
111    let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
112    match m {
113        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
114        4 | 6 | 9 | 11 => 30,
115        _ => {
116            if leap {
117                29
118            } else {
119                28
120            }
121        }
122    }
123}
124
125/// Where a layer stands against its stated horizon.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum Standing {
128    /// Supported, with this many days left. Zero means the last supported day.
129    Supported { days_left: i64 },
130    /// The window has passed, this many days ago.
131    Expired { days_ago: i64 },
132}
133
134impl Standing {
135    pub fn is_expired(self) -> bool {
136        matches!(self, Standing::Expired { .. })
137    }
138}
139
140/// Compare a stated horizon against a day. Neither is sampled here.
141pub fn standing(support_until: &str, today: &str) -> Result<Standing, SupportError> {
142    let until = epoch_days(support_until).ok_or_else(|| SupportError::BadDate {
143        field: "support-until",
144        value: support_until.to_string(),
145    })?;
146    let now = epoch_days(today).ok_or_else(|| SupportError::BadDate {
147        field: "today",
148        value: today.to_string(),
149    })?;
150    // The horizon day itself is still supported — a window stated as a date is
151    // read by humans as "through that day", and expiring at its start would
152    // surprise everyone by exactly one day.
153    if now <= until {
154        Ok(Standing::Supported {
155            days_left: until - now,
156        })
157    } else {
158        Ok(Standing::Expired {
159            days_ago: now - until,
160        })
161    }
162}
163
164/// What an operator is told. Never a refusal: an expired layer is a
165/// maintenance signal, and a tool that bricks a working build over a date
166/// gets removed from the build (REQ-SUPPORTUNTIL-001 clause 4).
167pub fn advisory(layer: &str, support_until: &str, standing: Standing) -> String {
168    match standing {
169        Standing::Expired { days_ago } => format!(
170            "layer {layer} passed its stated support window on {support_until}, \
171             {days_ago} day(s) ago. It still installs and still verifies — \
172             nothing about the bytes has changed. What has changed is that no \
173             one has undertaken to publish advisories or fixes for it, so a \
174             problem found tomorrow will not be announced against this layer. \
175             Move to a supported layer when you can."
176        ),
177        Standing::Supported { days_left } if days_left <= 30 => format!(
178            "layer {layer} is supported until {support_until}, {days_left} \
179             day(s) from now."
180        ),
181        Standing::Supported { days_left } => {
182            format!("layer {layer} is supported until {support_until} ({days_left} days)")
183        }
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    // rivet: verifies REQ-SUPPORTUNTIL-001
192    #[test]
193    fn a_horizon_is_derived_from_the_channel_not_typed_by_hand() {
194        assert_eq!(
195            horizon("2026-09-03T00:00:00Z", "rolling").unwrap(),
196            "2027-03-03"
197        );
198        assert_eq!(
199            horizon("2026-09-03T00:00:00Z", "qualified").unwrap(),
200            "2028-09-03"
201        );
202        // A date with no time part works too.
203        assert_eq!(horizon("2026-09-03", "rolling").unwrap(), "2027-03-03");
204    }
205
206    /// Adding six months to the 31st must not produce a date that does not
207    /// exist. Ending a day early is correct; an unparseable horizon is not —
208    /// and it would sign perfectly well, because nothing used to parse it.
209    // rivet: verifies REQ-SUPPORTUNTIL-001
210    #[test]
211    fn a_horizon_that_would_fall_on_a_day_that_does_not_exist_is_clamped() {
212        // 31 Aug + 6 months = 28/29 Feb, not 31 Feb.
213        assert_eq!(horizon("2026-08-31", "rolling").unwrap(), "2027-02-28");
214        // ...and the leap year is respected.
215        assert_eq!(horizon("2027-08-31", "rolling").unwrap(), "2028-02-29");
216        // 31 Oct + 6 months = 30 Apr.
217        assert_eq!(horizon("2026-10-31", "rolling").unwrap(), "2027-04-30");
218        // Every clamped result must itself parse.
219        for d in ["2026-08-31", "2026-10-31", "2027-08-31", "2026-12-31"] {
220            let h = horizon(d, "rolling").unwrap();
221            assert!(epoch_days(&h).is_some(), "{d} -> {h} does not parse");
222        }
223    }
224
225    /// The clamp is only exercised when the target month is SHORTER than the
226    /// issue day. My first tests all landed in short months, so a
227    /// `days_in_month` that returned 28 for every month passed them.
228    // rivet: verifies REQ-SUPPORTUNTIL-001
229    #[test]
230    fn a_thirty_one_day_target_month_keeps_all_thirty_one_days() {
231        // Jan 31 + 6 = Jul 31, and July has 31 days.
232        assert_eq!(horizon("2026-01-31", "rolling").unwrap(), "2026-07-31");
233        // Mar 31 + 6 = Sep 30 — September does not.
234        assert_eq!(horizon("2026-03-31", "rolling").unwrap(), "2026-09-30");
235        // Jul 31 + 6 = Jan 31.
236        assert_eq!(horizon("2026-07-31", "rolling").unwrap(), "2027-01-31");
237    }
238
239    /// The Gregorian rule has three parts and a leap check that only tested
240    /// `y % 4` would get two of them wrong once a century.
241    // rivet: verifies REQ-SUPPORTUNTIL-001
242    #[test]
243    fn february_follows_the_whole_gregorian_leap_rule() {
244        // 2024: divisible by 4 -> leap.
245        assert_eq!(horizon("2023-08-31", "rolling").unwrap(), "2024-02-29");
246        // 2100: divisible by 100, not by 400 -> NOT leap.
247        assert_eq!(horizon("2099-08-31", "rolling").unwrap(), "2100-02-28");
248        // 2000: divisible by 400 -> leap.
249        assert_eq!(horizon("1999-08-31", "rolling").unwrap(), "2000-02-29");
250        // 2026: not divisible by 4 -> not leap.
251        assert_eq!(horizon("2025-08-31", "rolling").unwrap(), "2026-02-28");
252    }
253
254    // rivet: verifies REQ-SUPPORTUNTIL-001
255    #[test]
256    fn a_supported_layer_does_not_report_itself_expired() {
257        assert!(!standing("2027-03-03", "2026-09-03").unwrap().is_expired());
258        assert!(!standing("2026-09-03", "2026-09-03").unwrap().is_expired());
259        assert!(standing("2026-09-02", "2026-09-03").unwrap().is_expired());
260    }
261
262    // rivet: verifies REQ-SUPPORTUNTIL-001
263    #[test]
264    fn the_year_rolls_over_correctly() {
265        assert_eq!(horizon("2026-12-15", "rolling").unwrap(), "2027-06-15");
266        assert_eq!(horizon("2026-07-01", "rolling").unwrap(), "2027-01-01");
267        assert_eq!(horizon("2026-01-15", "qualified").unwrap(), "2028-01-15");
268    }
269
270    /// A channel with no stated policy must not get an invented one.
271    // rivet: verifies REQ-SUPPORTUNTIL-001
272    #[test]
273    fn an_unknown_channel_gets_no_horizon_rather_than_a_guessed_one() {
274        let e = horizon("2026-09-03", "experimental").expect_err("must refuse");
275        assert!(matches!(e, SupportError::UnknownChannel(_)), "{e:?}");
276        assert!(e.to_string().contains("a promise nobody made"), "{e}");
277        assert_eq!(Policy::for_channel("nope"), None);
278    }
279
280    /// The field has never been validated. `"2028-13-45"` would have signed.
281    // rivet: verifies REQ-SUPPORTUNTIL-001
282    #[test]
283    fn an_unparseable_horizon_is_refused_rather_than_compared() {
284        for bad in [
285            "",
286            "next year",
287            "2028-13-45",
288            "2028-02-30",
289            "28-01-01",
290            "2028/01/01",
291        ] {
292            let e = standing(bad, "2026-09-03").expect_err(bad);
293            assert!(matches!(e, SupportError::BadDate { .. }), "{bad}: {e:?}");
294        }
295        assert!(horizon("not-a-date", "rolling").is_err());
296    }
297
298    /// A window stated as a date reads to a human as "through that day".
299    /// Expiring at its start would surprise everyone by exactly one day.
300    // rivet: verifies REQ-SUPPORTUNTIL-001
301    #[test]
302    fn the_stated_day_is_still_supported_and_the_next_one_is_not() {
303        assert_eq!(
304            standing("2027-03-03", "2027-03-03").unwrap(),
305            Standing::Supported { days_left: 0 }
306        );
307        assert_eq!(
308            standing("2027-03-03", "2027-03-04").unwrap(),
309            Standing::Expired { days_ago: 1 }
310        );
311        assert_eq!(
312            standing("2027-03-03", "2027-03-02").unwrap(),
313            Standing::Supported { days_left: 1 }
314        );
315    }
316
317    /// Clause 4. An expired layer is a maintenance signal, not a brick.
318    // rivet: verifies REQ-SUPPORTUNTIL-001
319    #[test]
320    fn an_expired_layer_is_explained_rather_than_refused() {
321        let s = standing("2026-01-01", "2026-09-03").unwrap();
322        assert!(s.is_expired());
323        let msg = advisory("2026.01.0", "2026-01-01", s);
324        assert!(msg.contains("still installs and still verifies"), "{msg}");
325        assert!(msg.contains("245 day(s) ago"), "{msg}");
326        // It says what actually changed, which is not the bytes.
327        assert!(msg.contains("advisories"), "{msg}");
328    }
329
330    // rivet: verifies REQ-SUPPORTUNTIL-001
331    #[test]
332    fn a_window_closing_soon_reads_differently_from_one_far_off() {
333        let soon = advisory(
334            "x",
335            "2026-09-20",
336            standing("2026-09-20", "2026-09-03").unwrap(),
337        );
338        assert!(soon.contains("17 day(s) from now"), "{soon}");
339        let far = advisory(
340            "x",
341            "2027-09-20",
342            standing("2027-09-20", "2026-09-03").unwrap(),
343        );
344        assert!(far.contains("(382 days)"), "{far}");
345    }
346}