1use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15
16use crate::layer::Line;
17use crate::manifest::LayerManifest;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum RollbackVerdict {
22 Accept,
24 Rollback {
26 line: String,
27 presented: u64,
28 high_water: u64,
29 },
30 BelowFloor {
44 line: String,
45 presented: u64,
46 floor: u64,
47 },
48}
49
50#[derive(Debug)]
53pub struct HighWaterMarks {
54 path: PathBuf,
55 marks: BTreeMap<String, u64>,
56}
57
58#[derive(Debug, thiserror::Error)]
59pub enum RollbackError {
60 #[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 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 pub fn mark(&self, line: &Line) -> Option<u64> {
97 self.marks.get(&line.to_string()).copied()
98 }
99
100 pub fn check(&self, manifest: &LayerManifest) -> RollbackVerdict {
104 self.check_with_floor(manifest, None)
105 }
106
107 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 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 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
168pub 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
178pub fn epoch_days(rfc3339: &str) -> Option<i64> {
185 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 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 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 #[test]
234 fn the_leap_rule_holds_at_the_year_the_solver_found() {
235 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 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 #[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 #[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 #[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 #[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 assert_eq!(
320 hwm.check(&manifest("2026.07.0", 1)),
321 RollbackVerdict::Accept
322 );
323 }
324
325 #[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 #[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 #[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 assert!(matches!(
363 HighWaterMarks::load(tmp.path()),
364 Err(RollbackError::Corrupt { .. })
365 ));
366 }
367
368 #[test]
370 fn epoch_day_arithmetic_matches_the_civil_calendar() {
371 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 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 #[test]
405 fn epoch_days_enforces_the_exact_yyyy_mm_dd_t_shape() {
406 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 for bad in [
412 "2026-08-7T00:00:00Z", "2026X08-07T00:00:00Z", "2026-08X07T00:00:00Z", "202608-07T00:00:00Z", ] {
417 assert_eq!(epoch_days(bad), None, "{bad}");
418 }
419 }
420
421 #[test]
423 fn epoch_days_applies_the_full_gregorian_leap_rule() {
424 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 assert!(epoch_days("2023-02-28T00:00:00Z").is_some());
439 assert_eq!(epoch_days("2024-02-30T00:00:00Z"), None);
440 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 #[test]
447 fn staleness_threshold_boundary_is_strictly_greater_than() {
448 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 #[test]
461 fn an_unreadable_state_file_is_an_io_error_not_first_contact() {
462 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 #[test]
475 fn staleness_is_a_pure_function_of_issued_at_now_and_threshold() {
476 assert_eq!(
478 staleness_warning("2026-04-01T00:00:00Z", "2026-07-10T00:00:00Z", 90),
479 Some(100)
480 );
481 assert_eq!(
483 staleness_warning("2026-06-30T00:00:00Z", "2026-07-10T00:00:00Z", 90),
484 None
485 );
486 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 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 #[test]
536 fn a_first_contact_below_the_signed_floor_is_refused() {
537 let (_t, hwm) = fresh();
538 assert_eq!(
540 hwm.check(&manifest("2026.07.2", 2)),
541 RollbackVerdict::Accept
542 );
543 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 #[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 #[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 #[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 #[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 #[test]
619 fn the_floor_applies_to_the_line_it_was_stated_for() {
620 let (_t, hwm) = with_mark("2026.07", 9);
621 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}