跳转至

crates\nfarms-core\src\netclock.rs

源码路径:crates\nfarms-core\src\netclock.rs · 447 行

//! 时间同步 —— 对时服务器 `zerolink.nfarms.cn`。
//!
//! **不是完整 NTP**:完整 SNTP 要发 48 字节的二进制报文、算四个时间戳。
//! 这里用的是「读 HTTP 响应的 `Date` 头」——精度差一些(通常几十毫秒),
//! 但零依赖、穿得过任何代理,对「本机时间偏了多少」这个问题足够了。
//!
//! 两个必须做对的细节:
//!
//! 1. **取往返的中点**。`Date` 头是服务器**发出响应那一刻**的时间,
//!    而我们是在收到响应时才读的。所以偏移 = 服务器时间 − (发出请求 + 收到响应)/2,
//!    直接用「收到响应的本机时间」会把整个网络往返都算成时钟偏差。
//! 2. **只报偏差,不做任何校时尝试**。普通用户没有 `SE_SYSTEMTIME_NAME` 特权,
//!    `SetSystemTime` 必然失败,所以本模块从头到尾**没有**「改本机时间」这条分支,
//!    也不存在「改失败就降级成只报告」的兜底 —— 界面如实显示偏差多少秒,
//!    真要纠正得用管理员权限跑 `w32tm /resync`。
//!    (以前这里写的是「系统时间改不了就只报偏差」,读起来像有那么一条降级分支,
//!    实际上一次 `SetSystemTime` 都没调过,白让人找。)

use std::sync::OnceLock;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

/// 默认对时服务器。
pub const DEFAULT_SERVER: &str = "https://zerolink.nfarms.cn/";

/// 偏差超过这个毫秒数就值得提醒(判据见 [`ClockProbe::is_significant`])。
///
/// 抽成常量而不是内联在函数体里:函数与单元测试都读同一处,改阈值不会只改一半
/// —— 以前 `5000` 埋在 `is_significant()` 里,测试只能自己再抄一个字面量,
/// 于是改了阈值测试还在拿旧值断言,静默失效。
pub const SIGNIFICANT_MS: i64 = 5000;

/// 对时请求的 User-Agent。
///
/// `1.2` 是**产品版本**(对齐 C# 版 `NFarmsTools.exe` 的 1.2,也是 `tools.rs` /
/// `weather.rs` / `scripts.rs` 里同一串 UA),**不是 crate 版本**,所以这里刻意
/// 不写 `env!("CARGO_PKG_VERSION")`:那会变成 workspace 的 `0.1.0`,同一台服务器
/// 会从同一个程序的不同功能收到两个自称版本,反而更乱。
/// 要真正统一得把那几个文件的 UA 一起抽成公共常量(需动别的文件,不在本次范围)。
const HTTP_USER_AGENT: &str = "NFarmsTools/1.2 (+https://tools.nfarms.cn)";

/// 一次对时的结果。
///
/// 早先这里还有 `local_before_ms` / `local_after_ms`(发出请求、收到响应时的本机
/// 时刻),但全仓没有任何读取方 —— 它们完全由 `server_ms`、`offset_ms`、`rtt_ms`
/// 三者决定,是纯冗余。删掉而不是留着:lib crate 的 `pub` 字段不会触发
/// `dead_code` 警告,留着就是一份永远没人验的公开 API。以后真需要排查时再加,
/// 并且同一笔改动里就把消费方(界面或日志)接上。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ClockProbe {
    /// 服务器返回的时刻(Unix 毫秒)
    pub server_ms: i64,
    /// 往返耗时(毫秒)
    ///
    /// 注意它含建连耗时:agent 是复用的(见 [`shared_agent`]),只有进程内第一次
    /// 对时会有 TCP/TLS 握手,那一轮 `rtt_ms` 会明显偏大。`offset_ms` 取的是往返
    /// 中点,所以不受这个偏差影响,不用去校正它。
    pub rtt_ms: i64,
    /// 服务器 − 本机(毫秒)。正数表示本机慢了。
    pub offset_ms: i64,
    /// 从哪台服务器取的
    pub server: String,
}

impl ClockProbe {
    /// 人话描述:`慢 2.418 秒` / `快 0.031 秒` / `一致(< 1 ms)`。
    pub fn describe(&self) -> String {
        let ms = self.offset_ms;
        if ms.abs() < 1 {
            return "一致(偏差 < 1 ms)".to_string();
        }
        let (dir, v) = if ms > 0 { ("慢", ms) } else { ("快", -ms) };
        if v < 1000 {
            format!("本机{dir} {v} ms")
        } else {
            format!("本机{dir} {:.3} 秒", v as f64 / 1000.0)
        }
    }

    /// 偏差是否已经大到值得提醒(超过 [`SIGNIFICANT_MS`])。
    ///
    /// 5 秒不是拍脑袋:Kerberos 默认容忍 5 分钟,但 TLS 证书校验、日志对齐、
    /// 以及这里最关心的**拓扑验证器的耗时判定**都会在秒级偏差下开始失真。
    pub fn is_significant(&self) -> bool {
        self.offset_ms.abs() > SIGNIFICANT_MS
    }
}

/// 复用的 HTTP agent。
///
/// `ureq::Agent` 内部带连接池,复用才能省掉每次对时的 TCP/TLS 握手。
/// 以前每次 [`probe`] 都新建一个 agent,于是 `rtt_ms` 里凭空多一整次握手
/// (对时本身通常几十毫秒,握手可能上百毫秒),界面上「往返耗时」常年虚高。
/// 用 `OnceLock` 而不是引 `lazy_static`/`once_cell`:标准库够用,且 agent 只在
/// 首次对时构造,不联网、纯本地初始化。
fn shared_agent() -> &'static ureq::Agent {
    static AGENT: OnceLock<ureq::Agent> = OnceLock::new();
    AGENT.get_or_init(|| {
        ureq::Agent::config_builder()
            .timeout_global(Some(Duration::from_secs(15)))
            .user_agent(HTTP_USER_AGENT)
            .build()
            .into()
    })
}

/// 向服务器要一次时间。
pub fn probe(server: &str) -> Result<ClockProbe, String> {
    let url = if server.trim().is_empty() {
        DEFAULT_SERVER
    } else {
        server.trim()
    };

    let agent = shared_agent();

    let before = now_ms();
    let resp = agent
        .get(url)
        .call()
        .map_err(|e| format!("请求 {url} 失败: {e}"))?;
    let after = now_ms();

    let date = resp
        .headers()
        .get("date")
        .and_then(|v| v.to_str().ok())
        .map(|s| s.to_string());
    let Some(date) = date else {
        return Err(format!(
            "{url} 的响应里没有 Date 头 —— 服务器没按 HTTP 规范返回时间"
        ));
    };
    let server_ms = parse_http_date(&date)
        .ok_or_else(|| format!("Date 头看不懂: {date}(只认 RFC 7231 的 IMF-fixdate 格式)"))?;

    let rtt_ms = after - before;
    // 关键:用往返的**中点**去比,而不是用收到响应那一刻
    let local_mid = before + rtt_ms / 2;
    Ok(ClockProbe {
        server_ms,
        rtt_ms,
        offset_ms: server_ms - local_mid,
        server: url.to_string(),
    })
}

/// 本机当前 Unix 毫秒。
///
/// 系统时间早于 1970-01-01 时 `duration_since` 会 `Err`,这里直接 panic。
/// 以前是 `.unwrap_or(0)`:那种情况下静默返回 1970-01-01,调用方拿到一个
/// 看着合法的时间戳,偏移量算出来是「本机慢 55 年」还不会报错 —— 错得比崩了
/// 更难查。这个分支在现代 Windows 上实际到不了(要到 1970 年前得手动改表),
/// 所以选「炸掉」不选「给个错值」。同理毫秒数超出 `i64` 也不静默回绕。
pub fn now_ms() -> i64 {
    let since_epoch = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("本机系统时间早于 1970-01-01,拿不到 Unix 时间戳");
    i64::try_from(since_epoch.as_millis()).expect("本机系统时间超出 i64 毫秒表示范围")
}

/// 解析 HTTP 日期(`Sun, 06 Nov 1994 08:49:37 GMT`)→ Unix 毫秒。
///
/// 手写解析而不是引 `httpdate`:格式就一种(IMF-fixdate),
/// 而且这里只需要它,引入一个依赖不值当。
///
/// 输入来自用户在「服务器」输入框里自己填的地址所返回的 `Date` 头,所以任何一段
/// 不合规都必须返回 `None`,不能默默算出一个数:算错了比报「看不懂」危险得多
/// —— 界面会照着一个错误偏差去提示用户「你的机器慢 5 小时」。
pub fn parse_http_date(s: &str) -> Option<i64> {
    let s = s.trim();
    // 去掉 "Sun, " 这一截星期
    let rest = match s.find(',') {
        Some(i) => s[i + 1..].trim(),
        None => s,
    };
    // 现在应当是 "06 Nov 1994 08:49:37 GMT" —— 恰好 5 段。
    // 以前只判 `>= 4`:第 5 段(时区)从头到尾没人读,第 6 段起被静默丢弃,
    // 于是 "... 08:49:37 -0500" 与 "... GMT" 得到同一个时间戳(整整偏 5 小时),
    // "... GMT extra" 也被照单全收。
    let parts: Vec<&str> = rest.split_whitespace().collect();
    if parts.len() != 5 {
        return None;
    }
    let day: i64 = parts[0].parse().ok()?;
    let mon = month_from_abbr(parts[1])?;
    let year: i64 = parts[2].parse().ok()?;
    // 年份上界不是洁癖:`days * 86400 * 1000` 在 i64 里放不下 29 亿年级别的年份,
    // 越界后 debug 构建 panic、release 构建静默回绕。日则按月校验,
    // 否则 "... 31 Nov 1994 ..." 这种不存在的日期会算出一个凭空多一天的时间戳。
    if !(1970..=9999).contains(&year) || !(1..=days_in_month(year, mon)).contains(&day) {
        return None;
    }
    let hms: Vec<&str> = parts[3].split(':').collect();
    if hms.len() != 3 {
        return None;
    }
    let h: i64 = hms[0].parse().ok()?;
    let m: i64 = hms[1].parse().ok()?;
    let sec: i64 = hms[2].parse().ok()?;
    // 用区间而不是 `<= 23`:负数也解析得出来(`"-8".parse::<i64>()` 是 Ok),
    // 只判上界会放 `-8:-1:-1` 进去。
    if !(0..=23).contains(&h) || !(0..=59).contains(&m) || !(0..=59).contains(&sec) {
        return None;
    }
    let zone = zone_offset_secs(parts[4])?;

    let days = days_from_civil(year, mon, day);
    // 上面的年份上界已经保证不会溢出,这里照样全用 checked:边界是「当前」的约定,
    // 将来放宽年份范围时不该靠人记得回来补乘法保护。
    let secs = days
        .checked_mul(86_400)?
        .checked_add(h * 3_600 + m * 60 + sec)?
        // 时区偏移是「本地时刻相对 UTC」,所以要减掉才是 UTC 时间戳。
        .checked_sub(zone)?;
    secs.checked_mul(1000)
}

/// `Date` 头尾部的时区标记 → 本地相对 UTC 的秒数(`GMT`/`UTC` 为 0,`-0500` 为 -18000)。
///
/// 只认 `GMT`、`UTC` 和 `±HHMM` 三种写法,别的(拼错的、6 位数的、`CST` 这种
/// 有歧义的缩写)一律 `None`。宁可让界面报「Date 头看不懂」,也不要按 UTC 硬算
/// —— 那正是这条修复之前的毛病:`-0500` 被当成 0,时间戳凭空偏 5 小时还不报错。
fn zone_offset_secs(tz: &str) -> Option<i64> {
    if tz.eq_ignore_ascii_case("GMT") || tz.eq_ignore_ascii_case("UTC") {
        return Some(0);
    }
    let bytes = tz.as_bytes();
    if bytes.len() != 5 || !(bytes[0] == b'+' || bytes[0] == b'-') {
        return None;
    }
    // 先确认后 4 位全是 ASCII 数字,后面按字节下标切片才不会切在 UTF-8 边界外。
    if !tz[1..].bytes().all(|b| b.is_ascii_digit()) {
        return None;
    }
    let hh: i64 = tz[1..3].parse().ok()?;
    let mm: i64 = tz[3..5].parse().ok()?;
    if hh > 23 || mm > 59 {
        return None;
    }
    let secs = hh * 3_600 + mm * 60;
    Some(if bytes[0] == b'-' { -secs } else { secs })
}

/// 该年该月的天数(用于拒绝 31 Nov / 29 Feb 2023 这类不存在的日期)。
fn days_in_month(year: i64, mon: i64) -> i64 {
    match mon {
        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
        4 | 6 | 9 | 11 => 30,
        2 if is_leap_year(year) => 29,
        2 => 28,
        _ => 0,
    }
}

/// 公历闰年(格里高利历规则)。
fn is_leap_year(y: i64) -> bool {
    (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
}

fn month_from_abbr(s: &str) -> Option<i64> {
    let m = match s.to_ascii_lowercase().as_str() {
        "jan" => 1,
        "feb" => 2,
        "mar" => 3,
        "apr" => 4,
        "may" => 5,
        "jun" => 6,
        "jul" => 7,
        "aug" => 8,
        "sep" => 9,
        "oct" => 10,
        "nov" => 11,
        "dec" => 12,
        _ => return None,
    };
    Some(m)
}

/// 公历 → 自 1970-01-01 起的天数(Howard Hinnant 的算法,和 `time.rs` 里那套配套)。
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
    let y = if m <= 2 { y - 1 } else { y };
    let era = if y >= 0 { y } else { y - 399 } / 400;
    let yoe = y - era * 400;
    let mp = (m + 9) % 12;
    let doy = (153 * mp + 2) / 5 + d - 1;
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    era * 146097 + doe - 719468
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_imf_fixdate() {
        // RFC 7231 里的例子:Sun, 06 Nov 1994 08:49:37 GMT = 784111777 秒
        let ms = parse_http_date("Sun, 06 Nov 1994 08:49:37 GMT").unwrap();
        assert_eq!(ms, 784_111_777_000);
    }

    #[test]
    fn parses_without_weekday() {
        assert_eq!(
            parse_http_date("06 Nov 1994 08:49:37 GMT"),
            Some(784_111_777_000)
        );
    }

    #[test]
    fn rejects_garbage() {
        assert!(parse_http_date("").is_none());
        assert!(parse_http_date("not a date").is_none());
        assert!(parse_http_date("Sun, 06 Xxx 1994 08:49:37 GMT").is_none());
        assert!(parse_http_date("Sun, 06 Nov 1994 08:49 GMT").is_none());
        // 少了时区段(4 段)也必须是 None:时区是必填的 IMF-fixdate 第 5 段
        assert!(parse_http_date("Sun, 06 Nov 1994 08:49:37").is_none());
    }

    /// 时区标记必须真的参与换算 —— 这是回归测试:以前 `parts[4]` 根本没人读,
    /// 下面每个非 GMT 的串都会得到和 GMT 一样的时间戳(偏 5 小时 30 分还不报错)。
    #[test]
    fn applies_timezone_offset() {
        const BASE: i64 = 784_111_777_000; // 1994-11-06 08:49:37 UTC
        // -0500:本地时刻比 UTC 晚 5 小时的写法,UTC 要 +5h
        assert_eq!(
            parse_http_date("Sun, 06 Nov 1994 08:49:37 -0500"),
            Some(BASE + 5 * 3_600_000)
        );
        // +0530:UTC 要 -5.5h
        assert_eq!(
            parse_http_date("Sun, 06 Nov 1994 08:49:37 +0530"),
            Some(BASE - 5 * 3_600_000 - 30 * 60_000)
        );
        // 零偏移的三种写法等价
        assert_eq!(parse_http_date("Sun, 06 Nov 1994 08:49:37 +0000"), Some(BASE));
        assert_eq!(parse_http_date("Sun, 06 Nov 1994 08:49:37 -0000"), Some(BASE));
        assert_eq!(parse_http_date("Sun, 06 Nov 1994 08:49:37 UTC"), Some(BASE));
    }

    /// 认不出的时区、越界偏移、多余字段一律 None —— 不许再静默按 UTC 硬算。
    #[test]
    fn rejects_bad_timezone_and_extra_fields() {
        for s in [
            "Sun, 06 Nov 1994 08:49:37 XYZ",       // 认不出的缩写
            "Sun, 06 Nov 1994 08:49:37 CST",       // 有歧义的缩写,不能猜
            "Sun, 06 Nov 1994 08:49:37 -05",       // 位数不对
            "Sun, 06 Nov 1994 08:49:37 -05000",    // 位数不对
            "Sun, 06 Nov 1994 08:49:37 +2500",     // 小时越界
            "Sun, 06 Nov 1994 08:49:37 +0570",     // 分钟越界
            "Sun, 06 Nov 1994 08:49:37 0500",      // 少了符号
            "Sun, 06 Nov 1994 08:49:37 GMT extra", // 多余字段,以前被静默丢弃
        ] {
            assert!(parse_http_date(s).is_none(), "本该拒绝:{s}");
        }
    }

    /// 取值范围与溢出保护:合法历法之外的输入不许算出数来,超大年份不许 panic/回绕。
    #[test]
    fn rejects_out_of_range_fields() {
        // 不存在的日期(必须按月校验,不能只查 1..=31)
        assert!(parse_http_date("Wed, 31 Nov 1994 08:49:37 GMT").is_none());
        assert!(parse_http_date("Wed, 29 Feb 2023 08:49:37 GMT").is_none());
        assert!(parse_http_date("Sat, 31 Apr 2021 08:49:37 GMT").is_none());
        // 时间越界(含负数)
        assert!(parse_http_date("Sun, 06 Nov 1994 24:00:00 GMT").is_none());
        assert!(parse_http_date("Sun, 06 Nov 1994 08:60:00 GMT").is_none());
        assert!(parse_http_date("Sun, 06 Nov 1994 08:49:60 GMT").is_none());
        assert!(parse_http_date("Sun, 06 Nov 1994 -8:-1:-1 GMT").is_none());
        assert!(parse_http_date("Sun, 00 Nov 1994 08:49:37 GMT").is_none());
        // 年份越界:i64 放得下,但 days*86400*1000 放不下
        assert!(parse_http_date("Sun, 06 Nov 292278994 08:49:37 GMT").is_none());
        assert!(parse_http_date("Sun, 06 Nov 99999999999999999999 08:49:37 GMT").is_none());
        // 闰年判定仍然正确(2100 能被 4 整除但被 100 整除、不被 400 整除 → 平年)
        assert!(parse_http_date("Thu, 29 Feb 2024 00:00:00 GMT").is_some());
        assert!(parse_http_date("Tue, 29 Feb 2000 00:00:00 GMT").is_some());
        assert!(parse_http_date("Mon, 29 Feb 2100 00:00:00 GMT").is_none());
    }

    /// User-Agent 里那串版本是产品版本(1.2),不是 crate 版本;这条测试钉住
    /// 「别顺手改成 CARGO_PKG_VERSION」—— 同一台服务器从同一程序的不同功能
    /// (`tools.rs`/`weather.rs`/`scripts.rs` 都是 1.2)收到两个自称版本会更乱。
    #[test]
    fn user_agent_is_product_version_not_crate_version() {
        assert!(HTTP_USER_AGENT.starts_with("NFarmsTools/1.2 "));
        // 长度安全:防止手滑把它整串删空
        assert!(HTTP_USER_AGENT.contains("tools.nfarms.cn"));
    }

    /// agent 必须复用:以前每次 `probe()` 都新建一个,连接池等于没有。
    #[test]
    fn agent_is_reused_across_calls() {
        assert!(std::ptr::eq(shared_agent(), shared_agent()));
    }

    #[test]
    fn epoch_and_leap_day() {
        assert_eq!(parse_http_date("Thu, 01 Jan 1970 00:00:00 GMT"), Some(0));
        // 2024-02-29 是闰日
        let leap = parse_http_date("Thu, 29 Feb 2024 00:00:00 GMT").unwrap();
        let day = leap / 86_400_000;
        // 2024-02-28 到 2024-02-29 应当正好差一天
        let prev = parse_http_date("Wed, 28 Feb 2024 00:00:00 GMT").unwrap() / 86_400_000;
        assert_eq!(day - prev, 1);
    }

    #[test]
    fn describe_says_slow_or_fast() {
        let mut p = ClockProbe {
            server_ms: 0,
            rtt_ms: 0,
            offset_ms: 2418,
            server: String::new(),
        };
        assert!(p.describe().contains("慢"));
        assert!(p.describe().contains("2.418 秒"));
        p.offset_ms = -31;
        assert!(p.describe().contains("快"));
        assert!(p.describe().contains("31 ms"));
        p.offset_ms = 0;
        assert!(p.describe().contains("一致"));
    }

    /// 阈值读写同一处常量,改阈值时测试跟着走(而不是各写一个字面量)。
    #[test]
    fn significance_threshold_uses_significant_ms() {
        let mut p = ClockProbe {
            server_ms: 0,
            rtt_ms: 0,
            offset_ms: SIGNIFICANT_MS,
            server: String::new(),
        };
        assert!(!p.is_significant(), "等于阈值不算显著(判据是 >)");
        p.offset_ms = SIGNIFICANT_MS + 1;
        assert!(p.is_significant());
        p.offset_ms = -SIGNIFICANT_MS - 1;
        assert!(p.is_significant(), "负偏差同样要判显著");
    }

    #[test]
    fn now_ms_is_sane() {
        // 2020-01-01 之后、2100 之前
        let n = now_ms();
        assert!(n > 1_577_836_800_000, "本机时间明显不对:{n}");
        assert!(n < 4_102_444_800_000, "本机时间明显不对:{n}");
    }
}