Skip to main content

varve_core/
rollback.rs

1//! Anti-rollback and staleness verdicts (REQ-ROLLBACK-001, DD-005).
2//!
3//! The SUIT/Uptane discipline: the client persists, per release line, the
4//! highest release counter it has ever accepted — the high-water mark — and
5//! hard-rejects any layer below it. Counters are scoped *per line*, so a
6//! consumer frozen on 2026.07 keeps rollback protection inside that line
7//! without ever being pressured toward 2026.08.
8//!
9//! Time is an input, never sampled here: staleness verdicts are pure
10//! functions of (issued-at, now, threshold), so they are testable and the
11//! trusted base stays free of clock-reading policy.
12
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15
16use crate::layer::Line;
17use crate::manifest::LayerManifest;
18
19/// Outcome of the anti-rollback check for one manifest.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum RollbackVerdict {
22    /// Counter at or above the recorded mark (or first contact): acceptable.
23    Accept,
24    /// Counter strictly below the recorded high-water mark: rejected.
25    Rollback {
26        line: String,
27        presented: u64,
28        high_water: u64,
29    },
30}
31
32/// Persisted high-water marks, one per release line, stored under the varve
33/// root (NOT inside the core — the core holds evidence, this is client state).
34#[derive(Debug)]
35pub struct HighWaterMarks {
36    path: PathBuf,
37    marks: BTreeMap<String, u64>,
38}
39
40#[derive(Debug, thiserror::Error)]
41pub enum RollbackError {
42    // The io source is NOT repeated in the message: anyhow's `{err:#}` chain
43    // already appends every source, and including it here printed the cause
44    // twice (varve#60).
45    #[error("io error at {path}")]
46    Io {
47        path: String,
48        #[source]
49        source: std::io::Error,
50    },
51    #[error(
52        "{path}: high-water-mark state is corrupt: {reason} — refusing to guess; repair or remove the file"
53    )]
54    Corrupt { path: String, reason: String },
55}
56
57impl HighWaterMarks {
58    /// Load the marks stored under `root` (missing file = first contact).
59    pub fn load(root: &Path) -> Result<Self, RollbackError> {
60        let path = root.join("state").join("high-water-marks.json");
61        let marks = match std::fs::read(&path) {
62            Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| RollbackError::Corrupt {
63                path: path.display().to_string(),
64                reason: e.to_string(),
65            })?,
66            Err(e) if e.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(),
67            Err(source) => {
68                return Err(RollbackError::Io {
69                    path: path.display().to_string(),
70                    source,
71                });
72            }
73        };
74        Ok(HighWaterMarks { path, marks })
75    }
76
77    /// The recorded mark for a line, if any.
78    pub fn mark(&self, line: &Line) -> Option<u64> {
79        self.marks.get(&line.to_string()).copied()
80    }
81
82    /// Check a manifest against the marks. `Accept` does NOT advance the
83    /// mark — call [`Self::advance`] after the layer is fully verified and
84    /// laid down, so a failed install cannot burn the mark.
85    pub fn check(&self, manifest: &LayerManifest) -> RollbackVerdict {
86        let line = manifest.layer.line().to_string();
87        match self.marks.get(&line) {
88            Some(&high_water) if manifest.counter < high_water => RollbackVerdict::Rollback {
89                line,
90                presented: manifest.counter,
91                high_water,
92            },
93            _ => RollbackVerdict::Accept,
94        }
95    }
96
97    /// Record acceptance of a manifest: raise the line's mark to the
98    /// manifest's counter (never lowers) and persist.
99    pub fn advance(&mut self, manifest: &LayerManifest) -> Result<(), RollbackError> {
100        let line = manifest.layer.line().to_string();
101        let mark = self.marks.entry(line).or_insert(0);
102        *mark = (*mark).max(manifest.counter);
103        self.persist()
104    }
105
106    fn persist(&self) -> Result<(), RollbackError> {
107        let io = |path: &Path, source: std::io::Error| RollbackError::Io {
108            path: path.display().to_string(),
109            source,
110        };
111        let dir = self.path.parent().expect("state file has a parent");
112        std::fs::create_dir_all(dir).map_err(|e| io(dir, e))?;
113        let bytes = serde_json::to_vec_pretty(&self.marks).expect("marks serialize");
114        std::fs::write(&self.path, bytes).map_err(|e| io(&self.path, e))?;
115        Ok(())
116    }
117}
118
119/// Staleness verdict: how old is the layer's issued-at relative to `now`?
120/// Both are RFC 3339 strings; `threshold_days` is policy supplied by the
121/// caller. Returns `Some(age_days)` when the layer is older than the
122/// threshold — a warning, never a rejection: a frozen consumer's layer aging
123/// is expected, staying silently ignorant of it is not.
124pub fn staleness_warning(issued_at: &str, now: &str, threshold_days: u32) -> Option<i64> {
125    let age = epoch_days(now)? - epoch_days(issued_at)?;
126    (age > i64::from(threshold_days)).then_some(age)
127}
128
129/// Days since the civil epoch for the date part of an RFC 3339 timestamp.
130/// Day resolution is deliberate: staleness policy is measured in days, so
131/// sub-day precision would only manufacture spurious boundary cases.
132// Public so the manifest parser can reject a malformed issued-at at parse
133// time (F2, 2026-08-08 audit) — the producer and the staleness verdict must
134// agree on what a valid date is, so there is one function.
135pub fn epoch_days(rfc3339: &str) -> Option<i64> {
136    // Accept "YYYY-MM-DD", optionally followed by "T…" (the time part is not
137    // used at day resolution). The date must be exactly 10 chars with dashes
138    // at positions 4 and 7 — each guard independently reachable.
139    let date = rfc3339.split_once('T').map_or(rfc3339, |(d, _)| d);
140    let b = date.as_bytes();
141    if date.len() != 10 || b[4] != b'-' || b[7] != b'-' {
142        return None;
143    }
144    let y: i64 = date[0..4].parse().ok()?;
145    let m: i64 = date[5..7].parse().ok()?;
146    let d: i64 = date[8..10].parse().ok()?;
147    if !(1..=12).contains(&m) {
148        return None;
149    }
150    // Real days-per-month incl. the Gregorian leap rule — Feb 31 is not a
151    // date (the old 1..=31 check let impossible days through).
152    let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
153    let dim = [
154        31,
155        if leap { 29 } else { 28 },
156        31,
157        30,
158        31,
159        30,
160        31,
161        31,
162        30,
163        31,
164        30,
165        31,
166    ];
167    if d < 1 || d > dim[(m - 1) as usize] {
168        return None;
169    }
170    // Howard Hinnant's days_from_civil.
171    let y = y - i64::from(m <= 2);
172    let era = if y >= 0 { y } else { y - 399 } / 400;
173    let yoe = y - era * 400;
174    let mp = (m + 9) % 12;
175    let doy = (153 * mp + 2) / 5 + d - 1;
176    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
177    Some(era * 146_097 + doe - 719_468)
178}
179
180#[cfg(test)]
181mod tests {
182
183    // rivet: verifies REQ-PROOF-001
184    #[test]
185    fn the_leap_rule_holds_at_the_year_the_solver_found() {
186        // cargo-mutants left three survivors here on 2026-08-08, all
187        // "replace || with && in epoch_days" — the Gregorian leap predicate.
188        // proptest never sampled a year that distinguishes the mutant. ordeal
189        // did: y = 8192 (divisible by 4, not by 100, not by 400), so it is a
190        // leap year under the correct rule and NOT under the mutant. That
191        // makes 8192-02-29 the date the mutant must get wrong.
192        // See proofs/epoch-days-leap-mutant-is-distinguishable.smt2.
193        assert!(
194            epoch_days("8192-02-29").is_some(),
195            "8192 is a leap year: 8192-02-29 must be a real date"
196        );
197        // The neighbouring non-leap cases the same predicate must reject.
198        assert!(
199            epoch_days("8100-02-29").is_none(),
200            "8100 %% 100 == 0, %% 400 != 0"
201        );
202        assert!(epoch_days("8000-02-29").is_some(), "8000 %% 400 == 0");
203        assert!(
204            epoch_days("8193-02-29").is_none(),
205            "8193 is not divisible by 4"
206        );
207    }
208    use super::*;
209    use crate::manifest::{LayerManifest, fixtures};
210
211    fn manifest(layer: &str, counter: u64) -> LayerManifest {
212        LayerManifest::parse(&fixtures::manifest(
213            layer,
214            "qualified",
215            counter,
216            "2026-07-31T09:14:00Z",
217        ))
218        .unwrap()
219    }
220
221    // rivet: verifies REQ-ROLLBACK-001
222    #[test]
223    fn first_contact_accepts_and_advance_records_the_mark() {
224        let tmp = tempfile::tempdir().unwrap();
225        let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
226        let m = manifest("2026.07.0", 3);
227        assert_eq!(hwm.check(&m), RollbackVerdict::Accept);
228        assert_eq!(hwm.mark(m.layer.line()), None, "check must not advance");
229        hwm.advance(&m).unwrap();
230        assert_eq!(hwm.mark(m.layer.line()), Some(3));
231    }
232
233    // rivet: verifies REQ-ROLLBACK-001
234    #[test]
235    fn a_counter_below_the_mark_is_rejected() {
236        let tmp = tempfile::tempdir().unwrap();
237        let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
238        hwm.advance(&manifest("2026.07.1", 4)).unwrap();
239        let verdict = hwm.check(&manifest("2026.07.0", 3));
240        assert_eq!(
241            verdict,
242            RollbackVerdict::Rollback {
243                line: "2026.07".into(),
244                presented: 3,
245                high_water: 4
246            }
247        );
248    }
249
250    // rivet: verifies REQ-ROLLBACK-001
251    #[test]
252    fn an_equal_counter_reinstalls_cleanly() {
253        let tmp = tempfile::tempdir().unwrap();
254        let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
255        hwm.advance(&manifest("2026.07.0", 3)).unwrap();
256        assert_eq!(
257            hwm.check(&manifest("2026.07.0", 3)),
258            RollbackVerdict::Accept
259        );
260    }
261
262    // rivet: verifies REQ-ROLLBACK-001
263    #[test]
264    fn counters_are_scoped_per_line() {
265        let tmp = tempfile::tempdir().unwrap();
266        let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
267        hwm.advance(&manifest("2026.08.0", 9)).unwrap();
268        // The August line's mark must not embargo the July line: wohl stays
269        // frozen on July without being pressured forward.
270        assert_eq!(
271            hwm.check(&manifest("2026.07.0", 1)),
272            RollbackVerdict::Accept
273        );
274    }
275
276    // rivet: verifies REQ-ROLLBACK-001
277    #[test]
278    fn marks_survive_a_new_session() {
279        let tmp = tempfile::tempdir().unwrap();
280        {
281            let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
282            hwm.advance(&manifest("2026.07.1", 5)).unwrap();
283        }
284        let hwm = HighWaterMarks::load(tmp.path()).unwrap();
285        assert_eq!(
286            hwm.check(&manifest("2026.07.0", 2)),
287            RollbackVerdict::Rollback {
288                line: "2026.07".into(),
289                presented: 2,
290                high_water: 5
291            }
292        );
293    }
294
295    // rivet: verifies REQ-ROLLBACK-001
296    #[test]
297    fn advance_never_lowers_a_mark() {
298        let tmp = tempfile::tempdir().unwrap();
299        let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
300        hwm.advance(&manifest("2026.07.1", 5)).unwrap();
301        hwm.advance(&manifest("2026.07.0", 2)).unwrap();
302        assert_eq!(hwm.mark(manifest("2026.07.0", 2).layer.line()), Some(5));
303    }
304
305    // rivet: verifies REQ-ROLLBACK-001
306    #[test]
307    fn corrupt_state_is_an_error_not_a_reset() {
308        let tmp = tempfile::tempdir().unwrap();
309        let state_dir = tmp.path().join("state");
310        std::fs::create_dir_all(&state_dir).unwrap();
311        std::fs::write(state_dir.join("high-water-marks.json"), b"{ nope").unwrap();
312        // A silent reset would reopen the rollback window; refuse instead.
313        assert!(matches!(
314            HighWaterMarks::load(tmp.path()),
315            Err(RollbackError::Corrupt { .. })
316        ));
317    }
318
319    // rivet: verifies REQ-ROLLBACK-001
320    #[test]
321    fn epoch_day_arithmetic_matches_the_civil_calendar() {
322        // ABSOLUTE anchors (differences would let constant-offset mutants
323        // cancel), computed independently: epoch, leap days, century rules,
324        // and year 0000 — the one reachable negative-era branch. Kills the
325        // arithmetic mutants in the Hinnant algorithm.
326        for (ts, days) in [
327            ("1970-01-01T00:00:00Z", 0i64),
328            ("1970-01-02T00:00:00Z", 1),
329            ("1969-12-31T00:00:00Z", -1),
330            ("2000-02-29T00:00:00Z", 11016),
331            ("2026-08-07T00:00:00Z", 20672),
332            ("2026-03-01T00:00:00Z", 20513),
333            ("2024-02-29T00:00:00Z", 19782),
334            ("2100-01-01T00:00:00Z", 47482),
335            ("1900-03-01T00:00:00Z", -25508),
336            ("2026-12-31T00:00:00Z", 20818),
337            ("0000-03-01T00:00:00Z", -719468),
338            ("0000-01-01T00:00:00Z", -719528),
339            ("0000-02-29T00:00:00Z", -719469),
340        ] {
341            assert_eq!(epoch_days(ts), Some(days), "epoch_days({ts})");
342        }
343        // Out-of-range calendar fields are None, not a number.
344        for bad in [
345            "2026-13-01T00:00:00Z",
346            "2026-00-01T00:00:00Z",
347            "2026-01-32T00:00:00Z",
348            "2026-01-00T00:00:00Z",
349        ] {
350            assert_eq!(epoch_days(bad), None, "{bad}");
351        }
352    }
353
354    // rivet: verifies REQ-ROLLBACK-001
355    #[test]
356    fn epoch_days_enforces_the_exact_yyyy_mm_dd_t_shape() {
357        // A bare 10-char date (no time) is valid; anything after must be 'T'.
358        assert_eq!(epoch_days("2026-08-07"), Some(20672));
359        assert_eq!(epoch_days("2026-08-07 00:00:00Z"), None, "space, not T");
360        assert_eq!(epoch_days("2026-08-07X"), None, "non-T separator");
361        // Field widths are exact; extra dash-fields rejected.
362        for bad in [
363            "2026-08-7T00:00:00Z",  // date part only 9 chars -> len != 10
364            "2026X08-07T00:00:00Z", // dash-at-4 missing
365            "2026-08X07T00:00:00Z", // dash-at-7 missing
366            "202608-07T00:00:00Z",  // shifted, both dashes wrong
367        ] {
368            assert_eq!(epoch_days(bad), None, "{bad}");
369        }
370    }
371
372    // rivet: verifies REQ-ROLLBACK-001
373    #[test]
374    fn epoch_days_applies_the_full_gregorian_leap_rule() {
375        // Feb 29 valid only on real leap years — exercises %4, %100, %400
376        // independently so no single leap-condition mutant survives.
377        assert!(epoch_days("2024-02-29T00:00:00Z").is_some(), "2024 %4 leap");
378        assert_eq!(epoch_days("2023-02-29T00:00:00Z"), None, "2023 non-leap");
379        assert_eq!(
380            epoch_days("1900-02-29T00:00:00Z"),
381            None,
382            "1900 %100 non-leap"
383        );
384        assert!(
385            epoch_days("2000-02-29T00:00:00Z").is_some(),
386            "2000 %400 leap"
387        );
388        // And Feb 28 is always valid, Feb 30 never.
389        assert!(epoch_days("2023-02-28T00:00:00Z").is_some());
390        assert_eq!(epoch_days("2024-02-30T00:00:00Z"), None);
391        // 30-day month boundary.
392        assert!(epoch_days("2026-04-30T00:00:00Z").is_some());
393        assert_eq!(epoch_days("2026-04-31T00:00:00Z"), None, "April has 30");
394    }
395
396    // rivet: verifies REQ-ROLLBACK-001
397    #[test]
398    fn staleness_threshold_boundary_is_strictly_greater_than() {
399        // Exactly at the threshold: quiet. One past: warn.
400        assert_eq!(
401            staleness_warning("2026-07-01T00:00:00Z", "2026-07-31T00:00:00Z", 30),
402            None
403        );
404        assert_eq!(
405            staleness_warning("2026-07-01T00:00:00Z", "2026-08-01T00:00:00Z", 30),
406            Some(31)
407        );
408    }
409
410    // rivet: verifies REQ-ROLLBACK-001
411    #[test]
412    fn an_unreadable_state_file_is_an_io_error_not_first_contact() {
413        // A directory where the state file should be: reading errors with
414        // something other than NotFound — must surface, never silently
415        // reset the marks (that would reopen the rollback window).
416        let tmp = tempfile::tempdir().unwrap();
417        std::fs::create_dir_all(tmp.path().join("state/high-water-marks.json")).unwrap();
418        assert!(matches!(
419            HighWaterMarks::load(tmp.path()),
420            Err(RollbackError::Io { .. })
421        ));
422    }
423
424    // rivet: verifies REQ-ROLLBACK-001
425    #[test]
426    fn staleness_is_a_pure_function_of_issued_at_now_and_threshold() {
427        // 100 days old, threshold 90: warn with the age.
428        assert_eq!(
429            staleness_warning("2026-04-01T00:00:00Z", "2026-07-10T00:00:00Z", 90),
430            Some(100)
431        );
432        // 10 days old, threshold 90: quiet.
433        assert_eq!(
434            staleness_warning("2026-06-30T00:00:00Z", "2026-07-10T00:00:00Z", 90),
435            None
436        );
437        // Unparseable issued-at: warn conservatively? No — the manifest
438        // parser already rejected it; here both inputs are trusted RFC 3339.
439        // Clock skew (issued-at in the future) is quiet, not negative-aged.
440        assert_eq!(
441            staleness_warning("2026-08-01T00:00:00Z", "2026-07-10T00:00:00Z", 90),
442            None
443        );
444    }
445}