1use crate::rollback::epoch_days;
31use std::fmt;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct Policy {
40 pub months: u32,
41}
42
43impl Policy {
44 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
86pub 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum Standing {
128 Supported { days_left: i64 },
130 Expired { days_ago: i64 },
132}
133
134impl Standing {
135 pub fn is_expired(self) -> bool {
136 matches!(self, Standing::Expired { .. })
137 }
138}
139
140pub 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 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
164pub 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 #[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 assert_eq!(horizon("2026-09-03", "rolling").unwrap(), "2027-03-03");
204 }
205
206 #[test]
211 fn a_horizon_that_would_fall_on_a_day_that_does_not_exist_is_clamped() {
212 assert_eq!(horizon("2026-08-31", "rolling").unwrap(), "2027-02-28");
214 assert_eq!(horizon("2027-08-31", "rolling").unwrap(), "2028-02-29");
216 assert_eq!(horizon("2026-10-31", "rolling").unwrap(), "2027-04-30");
218 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 #[test]
230 fn a_thirty_one_day_target_month_keeps_all_thirty_one_days() {
231 assert_eq!(horizon("2026-01-31", "rolling").unwrap(), "2026-07-31");
233 assert_eq!(horizon("2026-03-31", "rolling").unwrap(), "2026-09-30");
235 assert_eq!(horizon("2026-07-31", "rolling").unwrap(), "2027-01-31");
237 }
238
239 #[test]
243 fn february_follows_the_whole_gregorian_leap_rule() {
244 assert_eq!(horizon("2023-08-31", "rolling").unwrap(), "2024-02-29");
246 assert_eq!(horizon("2099-08-31", "rolling").unwrap(), "2100-02-28");
248 assert_eq!(horizon("1999-08-31", "rolling").unwrap(), "2000-02-29");
250 assert_eq!(horizon("2025-08-31", "rolling").unwrap(), "2026-02-28");
252 }
253
254 #[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 #[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 #[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 #[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 #[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 #[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 assert!(msg.contains("advisories"), "{msg}");
328 }
329
330 #[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}