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    /// FIRST CONTACT, below the line's signed floor: rejected
31    /// (REQ-FIRSTCONTACT-001).
32    ///
33    /// A client with no mark for a line used to accept any counter, because
34    /// there was nothing to compare against. That is the one moment when
35    /// anti-rollback — the property this tool exists for — protects nobody,
36    /// and it is exactly the moment an attacker chooses: a brand-new checkout,
37    /// a fresh CI runner, a new machine. Every one of those is a first
38    /// contact, so "first contact is rare" is false in precisely the
39    /// environments varve is built for.
40    ///
41    /// The realm states a floor in its signed line-status, so a consumer with
42    /// no history still has one.
43    BelowFloor {
44        line: String,
45        presented: u64,
46        floor: u64,
47    },
48}
49
50/// Persisted high-water marks, one per release line, stored under the varve
51/// root (NOT inside the core — the core holds evidence, this is client state).
52#[derive(Debug)]
53pub struct HighWaterMarks {
54    path: PathBuf,
55    marks: BTreeMap<String, u64>,
56}
57
58#[derive(Debug, thiserror::Error)]
59pub enum RollbackError {
60    // The io source is NOT repeated in the message: anyhow's `{err:#}` chain
61    // already appends every source, and including it here printed the cause
62    // twice (varve#60).
63    #[error("io error at {path}")]
64    Io {
65        path: String,
66        #[source]
67        source: std::io::Error,
68    },
69    #[error(
70        "{path}: high-water-mark state is corrupt: {reason} — refusing to guess; repair or remove the file"
71    )]
72    Corrupt { path: String, reason: String },
73}
74
75impl HighWaterMarks {
76    /// Load the marks stored under `root` (missing file = first contact).
77    pub fn load(root: &Path) -> Result<Self, RollbackError> {
78        let path = root.join("state").join("high-water-marks.json");
79        let marks = match std::fs::read(&path) {
80            Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| RollbackError::Corrupt {
81                path: path.display().to_string(),
82                reason: e.to_string(),
83            })?,
84            Err(e) if e.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(),
85            Err(source) => {
86                return Err(RollbackError::Io {
87                    path: path.display().to_string(),
88                    source,
89                });
90            }
91        };
92        Ok(HighWaterMarks { path, marks })
93    }
94
95    /// The recorded mark for a line, if any.
96    pub fn mark(&self, line: &Line) -> Option<u64> {
97        self.marks.get(&line.to_string()).copied()
98    }
99
100    /// Check a manifest against the marks. `Accept` does NOT advance the
101    /// mark — call [`Self::advance`] after the layer is fully verified and
102    /// laid down, so a failed install cannot burn the mark.
103    pub fn check(&self, manifest: &LayerManifest) -> RollbackVerdict {
104        self.check_with_floor(manifest, None)
105    }
106
107    /// The check, with the realm's signed per-line floor when one is known
108    /// (REQ-FIRSTCONTACT-001).
109    ///
110    /// `floor` must come from a line-status document that has ALREADY been
111    /// verified against the realm's trust root. An unverified floor would be
112    /// an attacker-chosen number, and a floor of zero from a forged document
113    /// is worse than no floor at all — it looks like protection.
114    ///
115    /// The local mark still wins where it is higher: a consumer who has
116    /// accepted counter 9 must not be walked back to a realm-stated floor of
117    /// 3. The floor raises the bottom for someone who has no history; it never
118    /// lowers it for someone who does.
119    pub fn check_with_floor(
120        &self,
121        manifest: &LayerManifest,
122        floor: Option<u64>,
123    ) -> RollbackVerdict {
124        let line = manifest.layer.line().to_string();
125        match self.marks.get(&line) {
126            Some(&high_water) if manifest.counter < high_water => RollbackVerdict::Rollback {
127                line,
128                presented: manifest.counter,
129                high_water,
130            },
131            // A recorded mark at or above the presented counter is the
132            // consumer's own history and is authoritative; the floor has
133            // nothing to add.
134            Some(_) => RollbackVerdict::Accept,
135            None => match floor {
136                Some(floor) if manifest.counter < floor => RollbackVerdict::BelowFloor {
137                    line,
138                    presented: manifest.counter,
139                    floor,
140                },
141                _ => RollbackVerdict::Accept,
142            },
143        }
144    }
145
146    /// Record acceptance of a manifest: raise the line's mark to the
147    /// manifest's counter (never lowers) and persist.
148    pub fn advance(&mut self, manifest: &LayerManifest) -> Result<(), RollbackError> {
149        let line = manifest.layer.line().to_string();
150        let mark = self.marks.entry(line).or_insert(0);
151        *mark = (*mark).max(manifest.counter);
152        self.persist()
153    }
154
155    fn persist(&self) -> Result<(), RollbackError> {
156        let io = |path: &Path, source: std::io::Error| RollbackError::Io {
157            path: path.display().to_string(),
158            source,
159        };
160        let dir = self.path.parent().expect("state file has a parent");
161        std::fs::create_dir_all(dir).map_err(|e| io(dir, e))?;
162        let bytes = serde_json::to_vec_pretty(&self.marks).expect("marks serialize");
163        std::fs::write(&self.path, bytes).map_err(|e| io(&self.path, e))?;
164        Ok(())
165    }
166}
167
168/// Staleness verdict: how old is the layer's issued-at relative to `now`?
169/// Both are RFC 3339 strings; `threshold_days` is policy supplied by the
170/// caller. Returns `Some(age_days)` when the layer is older than the
171/// threshold — a warning, never a rejection: a frozen consumer's layer aging
172/// is expected, staying silently ignorant of it is not.
173pub fn staleness_warning(issued_at: &str, now: &str, threshold_days: u32) -> Option<i64> {
174    let age = epoch_days(now)? - epoch_days(issued_at)?;
175    (age > i64::from(threshold_days)).then_some(age)
176}
177
178/// Days since the civil epoch for the date part of an RFC 3339 timestamp.
179/// Day resolution is deliberate: staleness policy is measured in days, so
180/// sub-day precision would only manufacture spurious boundary cases.
181// Public so the manifest parser can reject a malformed issued-at at parse
182// time (F2, 2026-08-08 audit) — the producer and the staleness verdict must
183// agree on what a valid date is, so there is one function.
184pub fn epoch_days(rfc3339: &str) -> Option<i64> {
185    // Accept "YYYY-MM-DD", optionally followed by "T…" (the time part is not
186    // used at day resolution). The date must be exactly 10 chars with dashes
187    // at positions 4 and 7 — each guard independently reachable.
188    let date = rfc3339.split_once('T').map_or(rfc3339, |(d, _)| d);
189    let b = date.as_bytes();
190    if date.len() != 10 || b[4] != b'-' || b[7] != b'-' {
191        return None;
192    }
193    let y: i64 = date[0..4].parse().ok()?;
194    let m: i64 = date[5..7].parse().ok()?;
195    let d: i64 = date[8..10].parse().ok()?;
196    if !(1..=12).contains(&m) {
197        return None;
198    }
199    // Real days-per-month incl. the Gregorian leap rule — Feb 31 is not a
200    // date (the old 1..=31 check let impossible days through).
201    let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
202    let dim = [
203        31,
204        if leap { 29 } else { 28 },
205        31,
206        30,
207        31,
208        30,
209        31,
210        31,
211        30,
212        31,
213        30,
214        31,
215    ];
216    if d < 1 || d > dim[(m - 1) as usize] {
217        return None;
218    }
219    // Howard Hinnant's days_from_civil.
220    let y = y - i64::from(m <= 2);
221    let era = if y >= 0 { y } else { y - 399 } / 400;
222    let yoe = y - era * 400;
223    let mp = (m + 9) % 12;
224    let doy = (153 * mp + 2) / 5 + d - 1;
225    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
226    Some(era * 146_097 + doe - 719_468)
227}
228
229#[cfg(test)]
230mod tests {
231
232    // rivet: verifies REQ-PROOF-001
233    #[test]
234    fn the_leap_rule_holds_at_the_year_the_solver_found() {
235        // cargo-mutants left three survivors here on 2026-08-08, all
236        // "replace || with && in epoch_days" — the Gregorian leap predicate.
237        // proptest never sampled a year that distinguishes the mutant. ordeal
238        // did: y = 8192 (divisible by 4, not by 100, not by 400), so it is a
239        // leap year under the correct rule and NOT under the mutant. That
240        // makes 8192-02-29 the date the mutant must get wrong.
241        // See proofs/epoch-days-leap-mutant-is-distinguishable.smt2.
242        assert!(
243            epoch_days("8192-02-29").is_some(),
244            "8192 is a leap year: 8192-02-29 must be a real date"
245        );
246        // The neighbouring non-leap cases the same predicate must reject.
247        assert!(
248            epoch_days("8100-02-29").is_none(),
249            "8100 %% 100 == 0, %% 400 != 0"
250        );
251        assert!(epoch_days("8000-02-29").is_some(), "8000 %% 400 == 0");
252        assert!(
253            epoch_days("8193-02-29").is_none(),
254            "8193 is not divisible by 4"
255        );
256    }
257    use super::*;
258    use crate::manifest::{LayerManifest, fixtures};
259
260    fn manifest(layer: &str, counter: u64) -> LayerManifest {
261        LayerManifest::parse(&fixtures::manifest(
262            layer,
263            "qualified",
264            counter,
265            "2026-07-31T09:14:00Z",
266        ))
267        .unwrap()
268    }
269
270    // rivet: verifies REQ-ROLLBACK-001
271    #[test]
272    fn first_contact_accepts_and_advance_records_the_mark() {
273        let tmp = tempfile::tempdir().unwrap();
274        let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
275        let m = manifest("2026.07.0", 3);
276        assert_eq!(hwm.check(&m), RollbackVerdict::Accept);
277        assert_eq!(hwm.mark(m.layer.line()), None, "check must not advance");
278        hwm.advance(&m).unwrap();
279        assert_eq!(hwm.mark(m.layer.line()), Some(3));
280    }
281
282    // rivet: verifies REQ-ROLLBACK-001
283    #[test]
284    fn a_counter_below_the_mark_is_rejected() {
285        let tmp = tempfile::tempdir().unwrap();
286        let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
287        hwm.advance(&manifest("2026.07.1", 4)).unwrap();
288        let verdict = hwm.check(&manifest("2026.07.0", 3));
289        assert_eq!(
290            verdict,
291            RollbackVerdict::Rollback {
292                line: "2026.07".into(),
293                presented: 3,
294                high_water: 4
295            }
296        );
297    }
298
299    // rivet: verifies REQ-ROLLBACK-001
300    #[test]
301    fn an_equal_counter_reinstalls_cleanly() {
302        let tmp = tempfile::tempdir().unwrap();
303        let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
304        hwm.advance(&manifest("2026.07.0", 3)).unwrap();
305        assert_eq!(
306            hwm.check(&manifest("2026.07.0", 3)),
307            RollbackVerdict::Accept
308        );
309    }
310
311    // rivet: verifies REQ-ROLLBACK-001
312    #[test]
313    fn counters_are_scoped_per_line() {
314        let tmp = tempfile::tempdir().unwrap();
315        let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
316        hwm.advance(&manifest("2026.08.0", 9)).unwrap();
317        // The August line's mark must not embargo the July line: wohl stays
318        // frozen on July without being pressured forward.
319        assert_eq!(
320            hwm.check(&manifest("2026.07.0", 1)),
321            RollbackVerdict::Accept
322        );
323    }
324
325    // rivet: verifies REQ-ROLLBACK-001
326    #[test]
327    fn marks_survive_a_new_session() {
328        let tmp = tempfile::tempdir().unwrap();
329        {
330            let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
331            hwm.advance(&manifest("2026.07.1", 5)).unwrap();
332        }
333        let hwm = HighWaterMarks::load(tmp.path()).unwrap();
334        assert_eq!(
335            hwm.check(&manifest("2026.07.0", 2)),
336            RollbackVerdict::Rollback {
337                line: "2026.07".into(),
338                presented: 2,
339                high_water: 5
340            }
341        );
342    }
343
344    // rivet: verifies REQ-ROLLBACK-001
345    #[test]
346    fn advance_never_lowers_a_mark() {
347        let tmp = tempfile::tempdir().unwrap();
348        let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
349        hwm.advance(&manifest("2026.07.1", 5)).unwrap();
350        hwm.advance(&manifest("2026.07.0", 2)).unwrap();
351        assert_eq!(hwm.mark(manifest("2026.07.0", 2).layer.line()), Some(5));
352    }
353
354    // rivet: verifies REQ-ROLLBACK-001
355    #[test]
356    fn corrupt_state_is_an_error_not_a_reset() {
357        let tmp = tempfile::tempdir().unwrap();
358        let state_dir = tmp.path().join("state");
359        std::fs::create_dir_all(&state_dir).unwrap();
360        std::fs::write(state_dir.join("high-water-marks.json"), b"{ nope").unwrap();
361        // A silent reset would reopen the rollback window; refuse instead.
362        assert!(matches!(
363            HighWaterMarks::load(tmp.path()),
364            Err(RollbackError::Corrupt { .. })
365        ));
366    }
367
368    // rivet: verifies REQ-ROLLBACK-001
369    #[test]
370    fn epoch_day_arithmetic_matches_the_civil_calendar() {
371        // ABSOLUTE anchors (differences would let constant-offset mutants
372        // cancel), computed independently: epoch, leap days, century rules,
373        // and year 0000 — the one reachable negative-era branch. Kills the
374        // arithmetic mutants in the Hinnant algorithm.
375        for (ts, days) in [
376            ("1970-01-01T00:00:00Z", 0i64),
377            ("1970-01-02T00:00:00Z", 1),
378            ("1969-12-31T00:00:00Z", -1),
379            ("2000-02-29T00:00:00Z", 11016),
380            ("2026-08-07T00:00:00Z", 20672),
381            ("2026-03-01T00:00:00Z", 20513),
382            ("2024-02-29T00:00:00Z", 19782),
383            ("2100-01-01T00:00:00Z", 47482),
384            ("1900-03-01T00:00:00Z", -25508),
385            ("2026-12-31T00:00:00Z", 20818),
386            ("0000-03-01T00:00:00Z", -719468),
387            ("0000-01-01T00:00:00Z", -719528),
388            ("0000-02-29T00:00:00Z", -719469),
389        ] {
390            assert_eq!(epoch_days(ts), Some(days), "epoch_days({ts})");
391        }
392        // Out-of-range calendar fields are None, not a number.
393        for bad in [
394            "2026-13-01T00:00:00Z",
395            "2026-00-01T00:00:00Z",
396            "2026-01-32T00:00:00Z",
397            "2026-01-00T00:00:00Z",
398        ] {
399            assert_eq!(epoch_days(bad), None, "{bad}");
400        }
401    }
402
403    // rivet: verifies REQ-ROLLBACK-001
404    #[test]
405    fn epoch_days_enforces_the_exact_yyyy_mm_dd_t_shape() {
406        // A bare 10-char date (no time) is valid; anything after must be 'T'.
407        assert_eq!(epoch_days("2026-08-07"), Some(20672));
408        assert_eq!(epoch_days("2026-08-07 00:00:00Z"), None, "space, not T");
409        assert_eq!(epoch_days("2026-08-07X"), None, "non-T separator");
410        // Field widths are exact; extra dash-fields rejected.
411        for bad in [
412            "2026-08-7T00:00:00Z",  // date part only 9 chars -> len != 10
413            "2026X08-07T00:00:00Z", // dash-at-4 missing
414            "2026-08X07T00:00:00Z", // dash-at-7 missing
415            "202608-07T00:00:00Z",  // shifted, both dashes wrong
416        ] {
417            assert_eq!(epoch_days(bad), None, "{bad}");
418        }
419    }
420
421    // rivet: verifies REQ-ROLLBACK-001
422    #[test]
423    fn epoch_days_applies_the_full_gregorian_leap_rule() {
424        // Feb 29 valid only on real leap years — exercises %4, %100, %400
425        // independently so no single leap-condition mutant survives.
426        assert!(epoch_days("2024-02-29T00:00:00Z").is_some(), "2024 %4 leap");
427        assert_eq!(epoch_days("2023-02-29T00:00:00Z"), None, "2023 non-leap");
428        assert_eq!(
429            epoch_days("1900-02-29T00:00:00Z"),
430            None,
431            "1900 %100 non-leap"
432        );
433        assert!(
434            epoch_days("2000-02-29T00:00:00Z").is_some(),
435            "2000 %400 leap"
436        );
437        // And Feb 28 is always valid, Feb 30 never.
438        assert!(epoch_days("2023-02-28T00:00:00Z").is_some());
439        assert_eq!(epoch_days("2024-02-30T00:00:00Z"), None);
440        // 30-day month boundary.
441        assert!(epoch_days("2026-04-30T00:00:00Z").is_some());
442        assert_eq!(epoch_days("2026-04-31T00:00:00Z"), None, "April has 30");
443    }
444
445    // rivet: verifies REQ-ROLLBACK-001
446    #[test]
447    fn staleness_threshold_boundary_is_strictly_greater_than() {
448        // Exactly at the threshold: quiet. One past: warn.
449        assert_eq!(
450            staleness_warning("2026-07-01T00:00:00Z", "2026-07-31T00:00:00Z", 30),
451            None
452        );
453        assert_eq!(
454            staleness_warning("2026-07-01T00:00:00Z", "2026-08-01T00:00:00Z", 30),
455            Some(31)
456        );
457    }
458
459    // rivet: verifies REQ-ROLLBACK-001
460    #[test]
461    fn an_unreadable_state_file_is_an_io_error_not_first_contact() {
462        // A directory where the state file should be: reading errors with
463        // something other than NotFound — must surface, never silently
464        // reset the marks (that would reopen the rollback window).
465        let tmp = tempfile::tempdir().unwrap();
466        std::fs::create_dir_all(tmp.path().join("state/high-water-marks.json")).unwrap();
467        assert!(matches!(
468            HighWaterMarks::load(tmp.path()),
469            Err(RollbackError::Io { .. })
470        ));
471    }
472
473    // rivet: verifies REQ-ROLLBACK-001
474    #[test]
475    fn staleness_is_a_pure_function_of_issued_at_now_and_threshold() {
476        // 100 days old, threshold 90: warn with the age.
477        assert_eq!(
478            staleness_warning("2026-04-01T00:00:00Z", "2026-07-10T00:00:00Z", 90),
479            Some(100)
480        );
481        // 10 days old, threshold 90: quiet.
482        assert_eq!(
483            staleness_warning("2026-06-30T00:00:00Z", "2026-07-10T00:00:00Z", 90),
484            None
485        );
486        // Unparseable issued-at: warn conservatively? No — the manifest
487        // parser already rejected it; here both inputs are trusted RFC 3339.
488        // Clock skew (issued-at in the future) is quiet, not negative-aged.
489        assert_eq!(
490            staleness_warning("2026-08-01T00:00:00Z", "2026-07-10T00:00:00Z", 90),
491            None
492        );
493    }
494}
495
496#[cfg(test)]
497mod first_contact_tests {
498    use super::*;
499
500    use crate::manifest::{LayerManifest, fixtures};
501
502    fn manifest(layer: &str, counter: u64) -> LayerManifest {
503        LayerManifest::parse(&fixtures::manifest(
504            layer,
505            "qualified",
506            counter,
507            "2026-07-31T09:14:00Z",
508        ))
509        .unwrap()
510    }
511
512    /// A fresh client, no marks file, nothing recorded — the state every new
513    /// machine starts in.
514    fn fresh() -> (tempfile::TempDir, HighWaterMarks) {
515        let tmp = tempfile::tempdir().unwrap();
516        let hwm = HighWaterMarks::load(tmp.path()).unwrap();
517        (tmp, hwm)
518    }
519
520    fn with_mark(line: &str, counter: u64) -> (tempfile::TempDir, HighWaterMarks) {
521        let tmp = tempfile::tempdir().unwrap();
522        let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
523        hwm.advance(&manifest(&format!("{line}.{counter}"), counter))
524            .unwrap();
525        (tmp, hwm)
526    }
527
528    /// The hole this closes. A consumer with no history accepted ANY counter,
529    /// because there was nothing to compare against — the one moment the
530    /// anti-rollback property protects nobody, and the moment an attacker
531    /// picks. A fresh checkout, a new CI runner, a new machine: every one is a
532    /// first contact, so "first contact is rare" is false in exactly the
533    /// environments varve is built for.
534    // rivet: verifies REQ-FIRSTCONTACT-001
535    #[test]
536    fn a_first_contact_below_the_signed_floor_is_refused() {
537        let (_t, hwm) = fresh();
538        // No mark for this line at all.
539        assert_eq!(
540            hwm.check(&manifest("2026.07.2", 2)),
541            RollbackVerdict::Accept
542        );
543        // With a realm-stated floor, the same layer is refused.
544        match hwm.check_with_floor(&manifest("2026.07.2", 2), Some(5)) {
545            RollbackVerdict::BelowFloor {
546                line,
547                presented,
548                floor,
549            } => {
550                assert_eq!(line, "2026.07");
551                assert_eq!(presented, 2);
552                assert_eq!(floor, 5);
553            }
554            other => panic!("expected BelowFloor, got {other:?}"),
555        }
556    }
557
558    // rivet: verifies REQ-FIRSTCONTACT-001
559    #[test]
560    fn a_first_contact_at_or_above_the_floor_is_accepted() {
561        let (_t, hwm) = fresh();
562        assert_eq!(
563            hwm.check_with_floor(&manifest("2026.07.5", 5), Some(5)),
564            RollbackVerdict::Accept
565        );
566        assert_eq!(
567            hwm.check_with_floor(&manifest("2026.07.9", 9), Some(5)),
568            RollbackVerdict::Accept
569        );
570    }
571
572    /// The floor raises the bottom for someone with no history. It must never
573    /// LOWER it for someone who has one: a consumer who has accepted counter 9
574    /// cannot be walked back to a realm-stated floor of 3.
575    // rivet: verifies REQ-FIRSTCONTACT-001
576    #[test]
577    fn a_floor_below_a_recorded_mark_does_not_reopen_the_window() {
578        let (_t, hwm) = with_mark("2026.07", 9);
579        match hwm.check_with_floor(&manifest("2026.07.3", 3), Some(3)) {
580            RollbackVerdict::Rollback { high_water, .. } => assert_eq!(high_water, 9),
581            other => panic!("the local mark must still win: {other:?}"),
582        }
583    }
584
585    /// A line with no stated floor behaves exactly as before, so a realm that
586    /// has not adopted this keeps working.
587    // rivet: verifies REQ-FIRSTCONTACT-001
588    #[test]
589    fn a_line_with_no_stated_floor_is_unchanged() {
590        let (_t, hwm) = fresh();
591        assert_eq!(
592            hwm.check_with_floor(&manifest("2026.07.0", 0), None),
593            RollbackVerdict::Accept
594        );
595        assert_eq!(
596            hwm.check(&manifest("2026.07.0", 0)),
597            hwm.check_with_floor(&manifest("2026.07.0", 0), None)
598        );
599    }
600
601    /// A floor of zero is not protection, and must not read as if it were —
602    /// it accepts everything, which is what an attacker would choose if they
603    /// could pick the number. (They cannot: the floor is only read from a
604    /// line-status already verified against the realm root.)
605    // rivet: verifies REQ-FIRSTCONTACT-001
606    #[test]
607    fn a_floor_of_zero_accepts_everything_exactly_as_no_floor_does() {
608        let (_t, hwm) = fresh();
609        assert_eq!(
610            hwm.check_with_floor(&manifest("2026.07.0", 0), Some(0)),
611            RollbackVerdict::Accept
612        );
613    }
614
615    /// The floor is per LINE. A floor learned for one line must not silently
616    /// govern another — lines advance independently.
617    // rivet: verifies REQ-FIRSTCONTACT-001
618    #[test]
619    fn the_floor_applies_to_the_line_it_was_stated_for() {
620        let (_t, hwm) = with_mark("2026.07", 9);
621        // A different line has no mark, so the floor governs it.
622        match hwm.check_with_floor(&manifest("2026.08.1", 1), Some(4)) {
623            RollbackVerdict::BelowFloor { line, .. } => assert_eq!(line, "2026.08"),
624            other => panic!("{other:?}"),
625        }
626    }
627}