use crate::faults::{Fault, Faults};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum RtcInterpretation {
Utc,
LocalTime,
}
impl RtcInterpretation {
pub fn skew_ms(self, zone: &str, unix_secs: i64) -> i64 {
match self {
RtcInterpretation::Utc => 0,
RtcInterpretation::LocalTime => utc_offset_seconds(zone, unix_secs) * 1000,
}
}
}
pub fn utc_offset_seconds(zone: &str, unix_secs: i64) -> i64 {
let (winter, summer) = match zone {
"se-sto1" | "de-fra1" | "nl-ams1" | "es-mad1" | "pl-waw1" | "fr-par1" => (3600, 7200),
"fi-hel1" => (7200, 10800),
"uk-lon1" | "ie-dub1" | "pt-lis1" => (0, 3600),
_ => return 0,
};
if is_eu_summer_time(unix_secs) {
summer
} else {
winter
}
}
pub fn is_eu_summer_time(unix_secs: i64) -> bool {
let (y, _, _) = civil_from_days(unix_secs.div_euclid(86_400));
let start = days_from_civil(y, 3, last_sunday(y, 3)) * 86_400 + 3_600;
let end = days_from_civil(y, 10, last_sunday(y, 10)) * 86_400 + 3_600;
unix_secs >= start && unix_secs < end
}
fn last_sunday(y: i64, m: i64) -> i64 {
let last = days_in_month(y, m);
for d in (1..=last).rev() {
let days = days_from_civil(y, m, d);
if days.rem_euclid(7) == 3 {
return d;
}
}
unreachable!("every month has a Sunday")
}
fn days_in_month(y: i64, m: i64) -> i64 {
match m {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
_ if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29,
_ => 28,
}
}
pub fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let mp = if m > 2 { m - 3 } else { m + 9 };
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
pub fn civil_from_days(z: i64) -> (i64, i64, i64) {
let z = z + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
(if m <= 2 { y + 1 } else { y }, m, d)
}
pub const DEFAULT_WINDOW_MS: u64 = 300_000;
#[derive(Clone, Copy, Debug)]
pub struct SkewWindow {
pub pm_ms: u64,
}
impl Default for SkewWindow {
fn default() -> Self {
SkewWindow { pm_ms: DEFAULT_WINDOW_MS }
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Verdict {
Accepted,
Refused { by_ms: u64, message: String },
}
impl SkewWindow {
pub fn check(&self, skew_ms: i64) -> Verdict {
let away = skew_ms.unsigned_abs();
if away <= self.pm_ms {
return Verdict::Accepted;
}
Verdict::Refused {
by_ms: away - self.pm_ms,
message: format!(
"the credential is {away} ms away from this server's clock; the window is ±{} ms",
self.pm_ms
),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Red {
pub row: &'static str,
pub why: String,
}
pub fn cascade(skew_ms: i64, window: SkewWindow) -> Vec<Red> {
let mut reds = vec![];
if skew_ms.unsigned_abs() <= 1_000 {
return reds;
}
reds.push(Red {
row: "skew-refusal",
why: format!("the front's signed time is {} ms away; refused as too skewed", skew_ms.unsigned_abs()),
});
reds.push(Red { row: "no-quorum", why: "one refused answer is not a quorum".into() });
reds.push(Red { row: "clock-unchanged", why: "nothing was accepted, so nothing was set".into() });
if let Verdict::Refused { message, .. } = window.check(skew_ms) {
reds.push(Red { row: "console-key", why: message });
reds.push(Red { row: "banner", why: "git.gunnar.rs:2222 connection timed out".into() });
reds.push(Red { row: "monetize-poll", why: "tenants=0 failures=1".into() });
}
reds
}
pub fn udp_reply_arrives(faults: &Faults) -> bool {
!faults.fires(Fault::UdpInboundDropped)
}
pub fn ntp_answer(faults: &Faults, true_unix_secs: i64) -> Option<i64> {
udp_reply_arrives(faults).then_some(true_unix_secs)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_appliance_is_skewed_and_the_front_is_not() {
let t = days_from_civil(2026, 9, 20) * 86_400;
let appliance = RtcInterpretation::LocalTime.skew_ms("se-sto1", t);
let front = RtcInterpretation::Utc.skew_ms("se-sto1", t);
assert_eq!(appliance, 7_200_000, "1 h 59 m 58.7 s was measured; the mechanism is exactly 2 h");
assert_eq!(front, 0, "same hypervisor, same zone, correct — that is the half that names the cause");
}
#[test]
fn the_skew_follows_the_calendar_not_a_constant() {
let summer = days_from_civil(2026, 9, 20) * 86_400;
let winter = days_from_civil(2026, 1, 15) * 86_400;
assert_eq!(RtcInterpretation::LocalTime.skew_ms("se-sto1", summer), 7_200_000);
assert_eq!(RtcInterpretation::LocalTime.skew_ms("se-sto1", winter), 3_600_000);
assert_eq!(RtcInterpretation::LocalTime.skew_ms("fi-hel1", summer), 10_800_000);
assert_eq!(RtcInterpretation::LocalTime.skew_ms("fi-hel1", winter), 7_200_000);
assert_eq!(RtcInterpretation::LocalTime.skew_ms("uk-lon1", winter), 0);
assert_eq!(RtcInterpretation::LocalTime.skew_ms("uk-lon1", summer), 3_600_000);
}
#[test]
fn the_switch_is_on_the_last_sunday() {
assert_eq!(last_sunday(2026, 3), 29);
assert_eq!(last_sunday(2026, 10), 25);
assert_eq!(last_sunday(2027, 3), 28);
let just_before = days_from_civil(2026, 3, 29) * 86_400 + 3_599;
let just_after = days_from_civil(2026, 3, 29) * 86_400 + 3_601;
assert!(!is_eu_summer_time(just_before));
assert!(is_eu_summer_time(just_after));
let oct_before = days_from_civil(2026, 10, 25) * 86_400 + 3_599;
let oct_after = days_from_civil(2026, 10, 25) * 86_400 + 3_601;
assert!(is_eu_summer_time(oct_before));
assert!(!is_eu_summer_time(oct_after));
}
#[test]
fn the_civil_conversions_round_trip() {
for (y, m, d) in [(1970, 1, 1), (2000, 2, 29), (2026, 9, 20), (2027, 12, 31)] {
assert_eq!(civil_from_days(days_from_civil(y, m, d)), (y, m, d));
}
}
#[test]
fn the_credential_refusal_is_the_measured_sentence() {
let w = SkewWindow::default();
match w.check(7_198_751) {
Verdict::Refused { message, .. } => assert_eq!(
message,
"the credential is 7198751 ms away from this server's clock; the window is ±300000 ms"
),
v => panic!("{v:?}"),
}
assert_eq!(w.check(299_999), Verdict::Accepted);
assert_eq!(w.check(-299_999), Verdict::Accepted);
}
#[test]
fn one_wrong_clock_is_six_red_rows() {
let reds = cascade(7_198_668, SkewWindow::default());
let rows: Vec<&str> = reds.iter().map(|r| r.row).collect();
assert_eq!(
rows,
vec!["skew-refusal", "no-quorum", "clock-unchanged", "console-key", "banner", "monetize-poll"]
);
assert!(reds[3].why.contains("±300000 ms"), "{}", reds[3].why);
assert!(cascade(468, SkewWindow::default()).is_empty(), "the front's 468 ms is fine");
}
#[test]
fn widening_the_window_still_leaves_a_red() {
for window_ms in [300_000u64, 1_000_000, 7_200_000, 86_400_000] {
let reds = cascade(7_198_668, SkewWindow { pm_ms: window_ms });
assert!(
!reds.is_empty(),
"a ±{window_ms} ms window turned a wrong clock green, which must never be possible"
);
assert_eq!(reds[0].row, "skew-refusal", "the first red is always the box refusing to fix itself");
}
assert!(cascade(RtcInterpretation::Utc.skew_ms("se-sto1", 0), SkewWindow::default()).is_empty());
}
#[test]
fn no_udp_reply_ever_comes_back() {
let f = Faults::none();
assert!(!udp_reply_arrives(&f), "dropped inbound UDP is the provider's NORMAL, so it is the default");
assert_eq!(ntp_answer(&f, 1_788_436_800), None);
f.disarm(Fault::UdpInboundDropped);
assert_eq!(ntp_answer(&f, 1_788_436_800), Some(1_788_436_800));
}
}