跳转至

crates\nfarms-core\src\scripts.rs

源码路径:crates\nfarms-core\src\scripts.rs · 1185 行

//! 脚本下发 —— 从 `hub.nfarms.cn/script` 取脚本,按平台分 Windows / Linux 两套。
//!
//! **安全立场**(这条比功能本身重要):
//!
//! 1. 下载只落到 `libs\scripts\`(程序根下),绝不自动执行 —— 一把梭执行远端脚本
//!    是最容易被骂的设计;
//! 2. 清单里带 `sha256` 就校验,而且**没通过校验的字节一个瞬间都不占最终路径**
//!    (先写同目录 `.part` 临时文件,校验通过才改名 —— 见 `land_verified`);
//! 3. 校验不了就**如实说明「本次未校验」**:清单没给哈希、哈希写法认不出来
//!    (见 `normalize_sha256`)、本机两条哈希工具都用不了,都归这一条 ——
//!    但绝不把"写法不对"说成"内容不匹配";
//! 4. 覆盖同名脚本会在返回的说明里**写清楚**换了多少字节,不静默替换
//!    (那个名字下面就是会被 `-ExecutionPolicy Bypass` 执行的 `fetch-libs.ps1`);
//! 5. 官方清单源(`hub.nfarms.cn`)之外的地址会在说明里标出来源主机;
//! 6. 落地前显示将写入的完整路径,用户可以拒绝。
//!
//! 服务的响应格式我们不做假设:JSON 数组、`名字<TAB>地址`、纯地址列表三种都认。
//! 认不出来时返回**原始响应的前几百字节**,让人一眼看出服务端到底给了什么。

use std::path::{Path, PathBuf};
use std::time::Duration;

/// 脚本服务根地址。
pub const BASE: &str = "https://hub.nfarms.cn/script";

/// 当前平台标识(服务端按这个分两套脚本)。
pub fn platform() -> &'static str {
    if cfg!(windows) { "windows" } else { "linux" }
}

/// 清单地址。
pub fn index_url(platform: &str) -> String {
    index_url_at(BASE, platform)
}

/// 清单地址(根地址可换)。
fn index_url_at(base: &str, platform: &str) -> String {
    format!("{base}/{platform}/index.json")
}

/// 一条脚本。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ScriptEntry {
    pub name: String,
    pub url: String,
    /// 服务端给的哈希,**已清洗成 64 位小写十六进制**;没给或写法认不出来就是空
    /// (界面上按空/非空显示「无哈希 / 有哈希」,不能拿一个用不了的串冒充有哈希)
    pub sha256: String,
    /// 一句话说明
    pub note: String,
}

impl ScriptEntry {
    /// 文件名(去掉路径里的目录部分,防目录穿越)。
    pub fn file_name(&self) -> String {
        let n = self.name.rsplit(['/', '\\']).next().unwrap_or(&self.name);
        if n.is_empty() {
            "script.txt".to_string()
        } else {
            n.to_string()
        }
    }
}

/// 请求的三段超时预算(秒)。
///
/// 为什么不再是一条 `timeout_global`:ureq 的 global 是**端到端**的 ——
/// 从 DNS 一直盖到"把响应体读完"(`ureq-3.4.2/src/config.rs` 原文:
/// "Timeout for the entire call … end-to-end, from DNS lookup to finishing
/// reading the response body")。脚本可能有几 MB,20 秒的 global 会把
/// "传得慢但没坏"的下载判死,而用户看到的只是笼统的「读取响应失败: …」,
/// 根本看不出是被超时砍掉的。
///
/// 拆成三段之后:连不上仍然 10 秒内报错(界面不会被一个死主机挂住),
/// 读响应体则有自己的预算。ureq 的 `Timeouts::default()` 全是 `None`,
/// 而 `None` 就是**永不超时** —— 三段都必须显式给出。
const CONNECT_TIMEOUT_SECS: u64 = 10;
/// 等响应头的预算(不含响应体)。
const RESPONSE_TIMEOUT_SECS: u64 = 20;
/// 读响应体的总预算。必须比"等响应头"宽:这一段覆盖的是真正在传字节的时间,
/// 而脚本比清单大得多。
const BODY_TIMEOUT_SECS: u64 = 120;

const USER_AGENT: &str = "NFarmsTools/1.2 (+https://tools.nfarms.cn)";

fn agent() -> ureq::Agent {
    agent_with_body_timeout(Duration::from_secs(BODY_TIMEOUT_SECS))
}

/// 按给定的"读体预算"拼 agent,另外两段用上面的常量。
///
/// 抽成参数只为一件事:测试要能用几百毫秒的预算跑**真实**的读体超时
/// (`a_slow_body_is_cut_off_by_the_body_budget`)。否则想验证"读体不再被
/// 那条 20 秒 global 砍掉",测试就得真的干等 20 秒以上 —— 那种测试没人愿意留。
/// 生产路径唯一入口是 `agent()`,不会有人从别处传一个奇怪的预算进来。
fn agent_with_body_timeout(body: Duration) -> ureq::Agent {
    ureq::Agent::config_builder()
        .timeout_connect(Some(Duration::from_secs(CONNECT_TIMEOUT_SECS)))
        .timeout_recv_response(Some(Duration::from_secs(RESPONSE_TIMEOUT_SECS)))
        .timeout_recv_body(Some(body))
        .user_agent(USER_AGENT)
        .build()
        .into()
}

/// 拉清单。
///
/// 服务在统一登录门禁后面,没登录会拿到 401 —— 那种情况要**说清楚是 401**,
/// 而不是笼统的「拉取失败」:401 是「你没登录」,404 是「这个平台还没有脚本」。
pub fn list(platform: &str) -> Result<Vec<ScriptEntry>, String> {
    list_at(BASE, platform)
}

/// 与 `list` 同一实现,只是清单**根地址可换** —— 生产路径永远走 `list`(即
/// 传 `BASE`),这个口子是给测试的。
///
/// 为什么要开:401 / 403 / 404 三条分诊提示和"垃圾响应原样带出来"这段,
/// 全仓没有一个测试跑得到(核实报告点名的测试缺口),而这几句文案恰恰是
/// 用户唯一能看到的东西。把根地址参数化,比让测试去连真网络强 —— 快、
/// 离线、也不依赖服务端当时的状态。
fn list_at(base: &str, platform: &str) -> Result<Vec<ScriptEntry>, String> {
    let url = index_url_at(base, platform);
    let resp = match agent().get(&url).call() {
        Ok(r) => r,
        Err(ureq::Error::StatusCode(code)) => {
            let hint = match code {
                401 | 403 => " —— hub.nfarms.cn 在统一登录门禁后面,先登录再试",
                404 => " —— 这个平台还没有脚本清单",
                _ => "",
            };
            return Err(format!("HTTP {code}{hint}\nGET {url}"));
        }
        Err(e) => return Err(format!("拉取清单失败: {e}\nGET {url}")),
    };
    let text = resp
        .into_body()
        .read_to_string()
        .map_err(|e| format!("读取响应失败: {e}"))?;
    let list = parse_index(&text);
    if list.is_empty() {
        // 把原始响应带出来 —— 服务端到底给了什么,看一眼比猜强
        return Err(format!(
            "清单解析不了(既不是 JSON 数组,也不是每行「名字<TAB>地址」或纯地址列表)。\n\
             原始响应前 400 字节:\n{}",
            clip(&text, 400)
        ));
    }
    Ok(list)
}

/// 宽松解析清单:三种格式都认。
pub fn parse_index(text: &str) -> Vec<ScriptEntry> {
    let t = text.trim();
    if t.is_empty() {
        return Vec::new();
    }
    // 1) JSON 数组
    if t.starts_with('[')
        && let Ok(v) = serde_json::from_str::<serde_json::Value>(t)
        && let Some(arr) = v.as_array()
    {
        let mut out = Vec::new();
        for item in arr {
            if let Some(o) = item.as_object() {
                let url = o
                    .get("url")
                    .and_then(|x| x.as_str())
                    .unwrap_or_default()
                    .to_string();
                let name = o
                    .get("name")
                    .and_then(|x| x.as_str())
                    .map(|s| s.to_string())
                    .unwrap_or_else(|| url.rsplit('/').next().unwrap_or("").to_string());
                if url.is_empty() {
                    continue;
                }
                out.push(ScriptEntry {
                    name,
                    url,
                    // 清单是外部输入,`sha256` 的写法不受我们控制 —— 在这里就清洗成
                    // 64 位小写十六进制(见 `normalize_sha256`)。清洗放在解析处而不是
                    // 比对处,是因为界面按"空/非空"显示「无哈希 / 有哈希」
                    // (`core_pages::script`):格式认不出来的哈希挂着"有哈希"只会误导人。
                    sha256: o
                        .get("sha256")
                        .and_then(|x| x.as_str())
                        .and_then(normalize_sha256)
                        .unwrap_or_default(),
                    note: o
                        .get("note")
                        .and_then(|x| x.as_str())
                        .unwrap_or_default()
                        .to_string(),
                });
            } else if let Some(u) = item.as_str() {
                out.push(from_url(u));
            }
        }
        return out;
    }
    // 2) 每行「名字<TAB>地址」或「地址」
    let mut out = Vec::new();
    for line in t.lines() {
        let l = line.trim();
        if l.is_empty() || l.starts_with('#') {
            continue;
        }
        let (name, url, sha) = if let Some((a, b)) = l.split_once('\t') {
            let b = b.trim();
            // 第三列可能是哈希
            match b.split_once('\t') {
                Some((u, s)) => (a.trim().to_string(), u.trim().to_string(), s.trim().to_string()),
                None => (a.trim().to_string(), b.to_string(), String::new()),
            }
        } else {
            (String::new(), l.to_string(), String::new())
        };
        if !url.starts_with("http") {
            continue;
        }
        let mut e = from_url(&url);
        if !name.is_empty() {
            e.name = name;
        }
        e.sha256 = normalize_sha256(&sha).unwrap_or_default();
        out.push(e);
    }
    out
}

fn from_url(url: &str) -> ScriptEntry {
    ScriptEntry {
        name: url.rsplit('/').next().unwrap_or(url).to_string(),
        url: url.to_string(),
        sha256: String::new(),
        note: String::new(),
    }
}

/// 清单里的地址只允许 http(s) —— 其余 scheme 在下载入口就被挡掉。
///
/// `parse_index` 侧**故意保持宽松**(只按 `http` 前缀粗筛):服务端到底给了什么,
/// 得让它在界面上列出来才看得见。真正的拒绝放在这里,因为只有这里会真的发请求。
fn is_http_url(url: &str) -> bool {
    let u = url.trim().to_ascii_lowercase();
    u.starts_with("http://") || u.starts_with("https://")
}

/// 取 `scheme://host[:port]` 这一段(没有 `://` 就原样返回)。
fn origin(url: &str) -> &str {
    match url.find("://") {
        Some(i) => {
            let rest = &url[i + 3..];
            let end = rest.find('/').map_or(url.len(), |j| i + 3 + j);
            &url[..end]
        }
        None => url,
    }
}

/// 地址是否来自官方清单源 —— 只比 `scheme://host`,不比路径。
///
/// 比路径会误伤:`hub.nfarms.cn` 上换个目录(比如大文件挪去 `/dl/`)仍然是同一台、
/// 同一张证书下的来源。要比的是"谁在供货",不是"供货路径叫什么"。
///
/// 用 `origin` 而不是 `starts_with(BASE)` 也是同一个理由的另一个方向:
/// `https://hub.nfarms.cn.attacker.com/script/x` 能以 BASE 为前缀,却完全是另一台主机。
fn is_under_base(url: &str) -> bool {
    origin(url).eq_ignore_ascii_case(origin(BASE))
}

/// 下载一条脚本到 `dir`,返回落地路径。
///
/// 返回的第二个值是**校验说明**:要么「sha256 校验通过」,要么「本次未校验」
/// (没给哈希 / 写法认不出来 / 本机算不出来,都会写明是哪一种)—— 不含糊。
/// 这条说明后面还可能接上两段同样有必要说出口的话:
/// 「覆盖了同名文件(旧 N 字节 → 新 M 字节)」和「来源 <host>(不在 BASE 下)」。
/// 调用方(脚本页)把它原样显示并写进日志,所以**不要**把它当成固定文案去断言。
pub fn download(dir: &Path, entry: &ScriptEntry) -> Result<(PathBuf, String), String> {
    download_with_agent(dir, entry, &agent())
}

/// `download` 的本体。`ag` 抽成参数只为一件事:测试要能用"读体预算只有几百毫秒"
/// 的 agent 跑**真实**的读体超时(见 `agent_with_body_timeout` 的说明)。
/// 生产路径唯一的调用点就在上面那句,永远用 `agent()`。
fn download_with_agent(
    dir: &Path,
    entry: &ScriptEntry,
    ag: &ureq::Agent,
) -> Result<(PathBuf, String), String> {
    // 只走 http(s)。`file:` / `ftp:` 这类地址本来就不该出现在清单里,而 ureq 对它们
    // 只会丢一句自己的 "unsupported scheme" —— 用户看到的是英文内部错误,指不到
    // "这条清单条目有问题"这个真正的原因上。挡在这里,顺手也不建目录、不落地。
    if !is_http_url(&entry.url) {
        return Err(format!("只支持 http/https 地址,拒绝下载:{}", entry.url));
    }
    std::fs::create_dir_all(dir).map_err(|e| format!("建目录失败: {e}"))?;
    let dest = dir.join(entry.file_name());

    let bytes = match ag.get(&entry.url).call() {
        Ok(r) => r
            .into_body()
            .read_to_vec()
            .map_err(|e| format!("读取响应失败: {e}"))?,
        Err(ureq::Error::StatusCode(code)) => {
            return Err(format!("HTTP {code} —— 下载 {} 失败", entry.url));
        }
        Err(e) => return Err(format!("下载失败: {e}")),
    };

    let mut verify = land_verified(&dest, &bytes, &entry.sha256, sha256_file)?;

    // 清单不校验主机 —— 服务的响应格式我们不做假设,里面可能引用同门的其它域名
    // (例如大文件挪去 `dl.` 那台),硬拒会把本来能用的条目打死。核实报告给的两条路
    // 里,这里选"标出来源"而不是"只认 BASE 前缀",理由就是这条:宁可让用户看见
    // "从哪台主机下来的",也不要因为猜错服务端布局而让脚本下不下来。
    // 说明会跟着落盘路径一起进界面提示和日志(`core_pages::script`),不是丢进虚空。
    if !is_under_base(&entry.url) {
        verify.push_str(&format!(";来源 {}(不在 {BASE} 下)", origin(&entry.url)));
    }
    Ok((dest, verify))
}

/// 把字节落定到 `dest`:**先写同目录临时文件 → 校验 → 通过才改名到 `dest`**。
///
/// 这是本模块的安全要害:`dest` 落在 `libs\scripts\` 下,而那个目录里的
/// `fetch-libs.ps1` 会被 `powershell -ExecutionPolicy Bypass -File` 执行
/// (`paths.fetch_libs_script` + `local_model::spawn_fetch`)。所以**没通过校验的
/// 字节在任何一瞬间都不能占用 `dest` 这个名字**。
///
/// 旧实现是先把字节写进 `dest` 再算哈希(`certutil` 只认文件,只能这么做),
/// 哈希对不上时 `dest` 上已经躺着未校验的内容,而失配分支又不删 —— 等于把
/// 被篡改 / 半截下载的脚本放进了可执行位置。根因不是"少了一句删除",
/// 是"未校验的字节先占了最终路径",所以这里把顺序整个倒过来。
///
/// 选"临时文件 + 改名"而不是"失配时删 `dest`":后者会先把用户手里那份**好**脚本
/// 覆盖成坏字节、再把坏字节删掉,失败一次反而丢掉能用的文件。改名方案在失败时
/// `dest` 原封不动。
///
/// `sha256_of_file` 做成参数,是为了测试能塞一个"必定算错"的实现,不联网也能
/// 真正走到失配分支。
///
/// 另外两条口径同样属于"如实说明",都不改变落盘与否:
/// * 期望哈希先经 `normalize_sha256` 清洗 —— 写法认不出来时按"未校验"报,
///   而不是按"不匹配"报(理由见那个函数);
/// * 覆盖了同名且内容不同的文件时,说明里补一句"覆盖了同名文件 …"。
fn land_verified(
    dest: &Path,
    bytes: &[u8],
    expected_sha256: &str,
    sha256_of_file: impl Fn(&Path) -> Option<String>,
) -> Result<String, String> {
    let tmp = temp_sibling(dest);
    std::fs::write(&tmp, bytes).map_err(|e| format!("写文件失败: {e}"))?;

    // 覆盖之前先看一眼旧内容:说明里要如实报出"换掉了什么"。
    // 读内容而不只读长度,是为了把"其实一个字节都没变"和"真的换了"分开 ——
    // 前者报成覆盖只是噪音,会把真覆盖淹掉(见本节末尾)。
    let existing = std::fs::read(dest).ok();

    let mut verify = match normalize_sha256(expected_sha256) {
        // 清单没给哈希:如实说明未校验,但仍然落盘(保持原有行为不变)
        None if expected_sha256.trim().is_empty() => {
            format!("清单没给 sha256,本次**未校验**({} 字节)", bytes.len())
        }
        // 格式认不出来的哈希**不能报成"不匹配"**:那不是内容坏了,是清单写歪了。
        // 报成不匹配会把人往"下载被篡改"上带,方向正好是反的;把原值带出来才有得查。
        None => format!(
            "清单里的 sha256 不是 64 位十六进制({}),本次**未校验**({} 字节)",
            clip(expected_sha256.trim(), 40),
            bytes.len()
        ),
        Some(expected) => {
            // 没引 sha2:先问系统自带的哈希工具(Windows 的 certutil / coreutils 的
            // sha256sum),两条都不通才算"本机算不出来"
            match sha256_of_file(&tmp) {
                Some(a) if a.eq_ignore_ascii_case(&expected) => {
                    format!("sha256 校验通过({} 字节)", bytes.len())
                }
                Some(a) => {
                    // 关键:坏字节只存在于临时文件里,把它连同内容一起丢掉;`dest` 保持原样。
                    let _ = std::fs::remove_file(&tmp);
                    return Err(format!(
                        "sha256 不匹配,已放弃写入。\n期望 {expected}\n实际 {a}"
                    ));
                }
                None => format!(
                    "清单给了 sha256 但本机算不出来,本次**未校验**({} 字节)",
                    bytes.len()
                ),
            }
        }
    };

    // 同名不同内容 = 换掉了用户手里已有的脚本。核实报告点名的那一份
    // (`libs\scripts\fetch-libs.ps1`)恰好会被 `powershell -ExecutionPolicy Bypass`
    // 执行,换了内容却不吭声,用户没有第二次机会发现。
    //
    // 为什么**不**改名避让、也**不**拒绝覆盖:这个函数的主用途就是更新
    // `fetch-libs.ps1`,而它的路径是写死的(`Paths::fetch_libs_script()`)——
    // 改名等于"每次都下成功、每次都不生效";拒绝覆盖则让脚本永远停在旧版本。
    // 所以取第三条路:照常覆盖,但把"旧多大 → 新多大"写进说明。
    if let Some(old) = &existing
        && old != bytes
    {
        verify.push_str(&format!(
            ";覆盖了同名文件(旧 {} 字节 → 新 {} 字节)",
            old.len(),
            bytes.len()
        ));
    }

    // 通过校验(或清单本来就没要求校验)才让它占住最终名字。
    // `fs::rename` 覆盖已存在的目标在 Windows 上走 MOVEFILE_REPLACE_EXISTING,
    // 与旧 `fs::write` 的覆盖语义一致,不会因为目标已存在而失败。
    std::fs::rename(&tmp, dest).map_err(|e| {
        // 改名失败:收走临时文件,别在 libs\scripts\ 里攒下半个脚本
        let _ = std::fs::remove_file(&tmp);
        format!("写文件失败: {e}")
    })?;
    Ok(verify)
}

/// 给 `dest` 配一个**同目录**临时名。
///
/// 必须同目录:跨卷 rename 会失败。带 pid + 进程内序号,是为了两次下载同名脚本
/// 时不会互相踩(序号用原子量,不引额外依赖)。
fn temp_sibling(dest: &Path) -> PathBuf {
    use std::sync::atomic::{AtomicU64, Ordering};
    static SEQ: AtomicU64 = AtomicU64::new(0);
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    let mut name = dest.file_name().unwrap_or_default().to_os_string();
    name.push(format!(".part-{}-{n}", std::process::id()));
    dest.with_file_name(name)
}

/// 把一个"疑似 SHA-256"的字符串清洗成 64 位小写十六进制;认不出来返回 `None`。
///
/// 清单是外部输入,`sha256` 的写法不受我们控制:可能带 `sha256:` 前缀、可能全大写、
/// 可能照抄 `certutil` 的排版(每 4 个字节一个空格)。旧实现拿原串直接比,于是
/// **格式偏差一律报「不匹配」** —— 内容明明是对的,用户却被告知文件被改过,
/// 排查方向直接反了;更糟的是这种条目会**永远下不下来**。
///
/// 做法:先按原样扫出连续的十六进制段,再去掉空白扫一遍(覆盖空格分组的写法),
/// 取第一段正好 64 位的。抽不出就返回 `None`,调用方按"这条没给可用哈希"处理
/// (`land_verified` 会如实写"未校验"并带上原值),**绝不**按"内容不对"处理。
fn normalize_sha256(raw: &str) -> Option<String> {
    let squeezed: String = raw.chars().filter(|c| !c.is_whitespace()).collect();
    for candidate in [raw, squeezed.as_str()] {
        if let Some(h) = candidate
            .split(|c: char| !c.is_ascii_hexdigit())
            .find_map(as_sha256)
        {
            return Some(h);
        }
    }
    None
}

/// 一整段正好是 64 位十六进制才算数(统一转小写)。
fn as_sha256(s: &str) -> Option<String> {
    (s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit())).then(|| s.to_ascii_lowercase())
}

/// 算**已存在文件**的 SHA-256:两条路都走不通才算"本机算不出来"。
///
/// 只算,不写 —— 写盘由 `land_verified` 负责,而且只写临时文件。这个函数以前会
/// 先无条件把字节写进传入的路径,正是"未校验脚本留在可执行位置"的根因,
/// 所以这里坚决不再接受字节参数。
///
/// 为什么要有第二条路:旧实现只认 `certutil`,而 `crate::tools::capture` 在**所有**
/// 平台都硬走 `COMSPEC`/`cmd.exe`(那是 Windows 的壳)。非 Windows 上那条命令必然
/// 起不来,于是"清单明明给了哈希,校验却静默不发生",用户看到的还是一句"已落盘"。
/// 补上 `sha256sum` 之后,Linux 上校验是真的在跑。
fn sha256_file(path: &Path) -> Option<String> {
    sha256_file_via_certutil(path).or_else(|| sha256_file_via_sha256sum(path))
}

/// 用系统自带的 `certutil` 算 —— 零依赖,Windows 上一定有。
fn sha256_file_via_certutil(path: &Path) -> Option<String> {
    let out = crate::tools::capture(
        &format!("certutil -hashfile \"{}\" SHA256", path.display()),
        30_000,
    );
    parse_sha256_from_output(&out)
}

/// 用 `sha256sum` 算 —— 非 Windows 平台的兜底(coreutils 的基本件)。
///
/// 选外部命令而不是自己实现 SHA-256:手写一个哈希原语一旦算错,后果是
/// "内容正确的脚本被判成哈希不匹配并拒绝落盘",比"算不出来"难查得多;
/// 而 coreutils 的 `sha256sum` 在 POSIX 环境里基本是必装的。
///
/// Windows 上仍然经 `capture` 走(它会加 `CREATE_NO_WINDOW`,不闪控制台);
/// 非 Windows 上 `capture` 是死的(见 `sha256_file` 的说明),必须直接起进程。
#[cfg(windows)]
fn sha256_file_via_sha256sum(path: &Path) -> Option<String> {
    let out = crate::tools::capture(&format!("sha256sum \"{}\"", path.display()), 30_000);
    parse_sha256_from_output(&out)
}

#[cfg(not(windows))]
fn sha256_file_via_sha256sum(path: &Path) -> Option<String> {
    let o = std::process::Command::new("sha256sum")
        .arg(path)
        .output()
        .ok()?;
    if !o.status.success() {
        // 文件读不了时 sha256sum 返回非 0 —— 那种情况"算不出来"才是实话,
        // 不能拿半截 stdout 去当哈希比
        return None;
    }
    parse_sha256_from_output(&String::from_utf8_lossy(&o.stdout))
}

/// 从哈希工具的输出里抠出 SHA-256。两种排版都要认:
///
/// * `certutil` —— 哈希单独一行、按空格分组(`ba7816bf 8f01cfea …`),另外两行是
///   「SHA256 hash of file …」和「CertUtil: -hashfile 命令成功完成」的噪音;
/// * `sha256sum` —— `哈希  文件名` 挤在同一行。
///
/// 先按"整行去掉空白正好 64 位十六进制"找(certutil 的形态),再退回"取行首那段
/// 十六进制"(sha256sum 的形态)。顺序不能反:certutil 那行说明文字里带着文件名,
/// 万一文件名本身含 64 位十六进制,先按行首取就会把文件名当哈希 ——
/// 那样算出来的结果每次都对不上,变成"内容正确却拒绝落盘"。
fn parse_sha256_from_output(out: &str) -> Option<String> {
    for line in out.lines() {
        let squeezed: String = line.chars().filter(|c| !c.is_whitespace()).collect();
        if let Some(h) = as_sha256(&squeezed) {
            return Some(h);
        }
    }
    out.lines().find_map(|line| {
        let run: String = line
            .trim_start()
            .chars()
            .take_while(|c| c.is_ascii_hexdigit())
            .collect();
        as_sha256(&run)
    })
}

fn clip(s: &str, max: usize) -> String {
    if s.chars().count() <= max {
        s.to_string()
    } else {
        s.chars().take(max).collect::<String>() + "…"
    }
}

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

    #[test]
    fn parses_json_index() {
        // 清单里的哈希是大写的(服务端常见写法):解析时就该统一成小写,
        // 否则界面按"有哈希"显示、比对时又要额外容错,两处口径会漂。
        let h = "AB".repeat(32);
        let j = format!(
            r#"[{{"name":"a.ps1","url":"https://x/a.ps1","sha256":"{h}","note":"n"}},
                    "https://x/b.ps1"]"#
        );
        let l = parse_index(&j);
        assert_eq!(l.len(), 2);
        assert_eq!(l[0].name, "a.ps1");
        assert_eq!(l[0].sha256, h.to_lowercase());
        assert_eq!(l[1].name, "b.ps1");
    }

    #[test]
    fn parses_tab_and_plain_lines() {
        let h = "DEADBEEF".repeat(8);
        let t = format!(
            "# 注释\nsetup.ps1\thttps://x/setup.ps1\t{h}\nhttps://x/other.ps1\n不是地址\n"
        );
        let l = parse_index(&t);
        assert_eq!(l.len(), 2);
        assert_eq!(l[0].name, "setup.ps1");
        assert_eq!(l[0].sha256, h.to_lowercase());
        assert_eq!(l[1].name, "other.ps1");
        assert!(l[1].sha256.is_empty());
    }

    /// 清单里写歪的哈希不能被当成"有效哈希"留在条目上 —— 界面按空/非空显示
    /// 「无哈希 / 有哈希」,一个认不出来的值挂在那里只会让人以为会校验。
    #[test]
    fn malformed_hash_in_the_index_is_not_kept_as_a_hash() {
        let l = parse_index(
            r#"[{"name":"a.ps1","url":"https://x/a.ps1","sha256":"oops"},
                {"name":"b.ps1","url":"https://x/b.ps1","sha256":"DEADBEEF"}]"#,
        );
        assert_eq!(l.len(), 2);
        assert!(l[0].sha256.is_empty(), "{}", l[0].sha256);
        assert!(l[1].sha256.is_empty(), "{}", l[1].sha256);
    }

    #[test]
    fn garbage_yields_nothing_so_caller_can_show_raw() {
        assert!(parse_index("").is_empty());
        assert!(parse_index("<html>nope</html>").is_empty());
    }

    #[test]
    fn file_name_strips_directories() {
        // 防目录穿越:清单里写 `../../evil.ps1` 也只能落在目标目录里
        let e = ScriptEntry {
            name: "../../evil.ps1".into(),
            url: "https://x/e".into(),
            sha256: String::new(),
            note: String::new(),
        };
        assert_eq!(e.file_name(), "evil.ps1");
        let e2 = ScriptEntry {
            name: "a\\b\\c.ps1".into(),
            ..e
        };
        assert_eq!(e2.file_name(), "c.ps1");
    }

    /// 给测试准备一个干净的空目录。目录名带测试标签,同一个进程并行跑时不撞车。
    fn scratch_dir(tag: &str) -> PathBuf {
        let d = std::env::temp_dir().join(format!("nfarms-scripts-test-{}-{tag}", std::process::id()));
        let _ = std::fs::remove_dir_all(&d);
        std::fs::create_dir_all(&d).unwrap();
        d
    }

    /// 守核实报告那条 [高]:**哈希失配时,未校验的字节不能留在最终路径上**。
    ///
    /// 因为最终路径是 `libs\scripts\`,那里的 `fetch-libs.ps1` 会被
    /// `powershell -ExecutionPolicy Bypass -File` 执行。旧实现先把字节写进 dest
    /// 再算哈希,失配分支又不删 —— 被篡改的脚本就这样留在了可执行位置。
    ///
    /// 用一个"必定算错"的哈希实现复现失配,不依赖网络和 certutil。
    #[test]
    fn failed_sha256_check_leaves_nothing_at_the_executable_path() {
        let dir = scratch_dir("mismatch");
        let dest = dir.join("fetch-libs.ps1");

        let err = land_verified(&dest, b"Write-Host pwned", &"00".repeat(32), |_| {
            Some("11".repeat(32))
        })
        .expect_err("哈希对不上时必须报错");
        assert!(err.contains("不匹配"), "错误文案要说清是哈希不匹配: {err}");

        assert!(!dest.exists(), "校验失败的脚本绝不能被落到可执行路径上");
        assert_eq!(
            std::fs::read_dir(&dir).unwrap().count(),
            0,
            "临时文件也要收干净,不能留半个脚本在 libs\\scripts\\ 里"
        );
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 光删掉坏文件还不够:失败的一次下载不能把用户手里那份**好**脚本毁掉。
    ///
    /// 旧实现(先写 dest)会把好脚本覆盖成坏字节;若只改成"失配时删 dest",
    /// 就会把好脚本连带删掉。所以这里要求 dest 原封不动。
    #[test]
    fn failed_sha256_check_does_not_destroy_an_existing_good_script() {
        let dir = scratch_dir("keep-old");
        let dest = dir.join("fetch-libs.ps1");
        std::fs::write(&dest, b"# good script").unwrap();

        assert!(
            land_verified(&dest, b"# tampered", &"00".repeat(32), |_| Some(
                "22".repeat(32)
            ))
            .is_err()
        );
        assert_eq!(std::fs::read(&dest).unwrap(), b"# good script");
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 正向对照:校验通过时文件要真的落定,且不留临时文件。
    /// 大小写不同也算通过(原有语义,不能因为这次改动丢掉)。
    #[test]
    fn matching_sha256_commits_the_file_and_cleans_up() {
        let dir = scratch_dir("match");
        let dest = dir.join("a.ps1");

        let note = land_verified(&dest, b"hello", &"aa".repeat(32), |_| {
            Some("AA".repeat(32))
        })
        .unwrap();
        assert!(note.contains("校验通过"), "说明要如实写校验通过: {note}");
        assert_eq!(std::fs::read(&dest).unwrap(), b"hello");
        assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "不该留下临时文件");
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 清单没给哈希时照旧落盘,但说明必须如实写"未校验"(既有行为,不能改坏)。
    #[test]
    fn missing_sha256_still_lands_the_file_unverified() {
        let dir = scratch_dir("no-hash");
        let dest = dir.join("b.ps1");

        let note = land_verified(&dest, b"x", "", |_| panic!("没给哈希就不该去算哈希")).unwrap();
        assert!(note.contains("未校验"), "没给哈希就要说未校验: {note}");
        assert_eq!(std::fs::read(&dest).unwrap(), b"x");
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 本机算不出哈希(certutil 缺席 / 非 Windows 且没有 sha256sum)时仍然落盘
    /// 并如实标注 —— 既有行为。
    #[test]
    fn uncomputable_local_hash_still_lands_with_honest_note() {
        let dir = scratch_dir("no-certutil");
        let dest = dir.join("c.ps1");

        let note = land_verified(&dest, b"y", &"aa".repeat(32), |_| None).unwrap();
        assert!(note.contains("未校验"), "算不出哈希要说未校验: {note}");
        assert_eq!(std::fs::read(&dest).unwrap(), b"y");
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 临时名必须与最终名不同、且在同一目录(跨卷 rename 会失败)。
    #[test]
    fn temp_file_is_a_sibling_and_never_the_destination() {
        let dest = Path::new("C:\\root\\libs\\scripts\\fetch-libs.ps1");
        let tmp = temp_sibling(dest);
        assert_ne!(tmp, dest);
        assert_eq!(tmp.parent(), dest.parent());
        assert!(tmp.file_name().unwrap().to_string_lossy().starts_with("fetch-libs.ps1.part-"));
    }

    #[test]
    fn index_url_is_platform_specific() {
        assert!(index_url("windows").ends_with("/windows/index.json"));
        assert!(index_url("linux").ends_with("/linux/index.json"));
        assert_eq!(platform(), if cfg!(windows) { "windows" } else { "linux" });
    }

    #[test]
    fn list_on_unreachable_host_is_a_clean_error() {
        let u = index_url("windows");
        assert!(u.starts_with("https://hub.nfarms.cn/script/"));

        // 上面那句只是"地址拼对了"。这条测试的名字许诺的是"拉不到时给一个干净的
        // 错误" —— 以前没人验(注释自己承认不打网络),而 `list` 的错误分诊恰恰是
        // 用户唯一能看到的东西。
        //
        // 不连真网络:先把一个临时端口要过来再放掉,那个端口上就没人听了。
        // loopback 上的"端口没人听"是**立刻**被拒,不用等 10 秒连接超时,
        // 所以这条既快又不依赖外网和沙箱。
        let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
        let port = l.local_addr().unwrap().port();
        drop(l);

        let err = list_at(&format!("http://127.0.0.1:{port}"), "windows")
            .expect_err("没人听的端口必须报错,不能挂住,也不能 panic");
        assert!(err.contains("拉取清单失败"), "要说清是拉清单失败:{err}");
        assert!(
            err.contains("index.json"),
            "错误里要带出真正请求过的地址,不然没法排查:{err}"
        );
    }

    // ------------------------------------------------------------ 本轮修复的回归

    /// 清单里的哈希写法五花八门:大写、带前缀、按空格分组 —— 都得认。
    ///
    /// 旧实现拿原串直接比,这些写法**一律被报成"不匹配"**:内容对的文件被说成
    /// 被改过,而且这种条目永远下不下来(核实报告 mid 那组的第一、二条)。
    #[test]
    fn normalize_sha256_accepts_real_world_spellings() {
        let h = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
        assert_eq!(normalize_sha256(h), Some(h.to_string()));
        assert_eq!(normalize_sha256(&h.to_uppercase()), Some(h.to_string()));
        assert_eq!(normalize_sha256(&format!("sha256:{h}")), Some(h.to_string()));
        assert_eq!(
            normalize_sha256(&format!("  SHA-256 = {h}  ")),
            Some(h.to_string())
        );
        // certutil 的排版:每 4 个字节一个空格
        let grouped = h
            .as_bytes()
            .chunks(8)
            .map(|c| std::str::from_utf8(c).unwrap())
            .collect::<Vec<_>>()
            .join(" ");
        assert_eq!(normalize_sha256(&grouped), Some(h.to_string()));

        // 认不出来的:空、太短、太长、掺了非十六进制
        assert_eq!(normalize_sha256(""), None);
        assert_eq!(normalize_sha256("DEADBEEF"), None);
        assert_eq!(normalize_sha256(&h[..63]), None);
        assert_eq!(normalize_sha256(&format!("{h}00")), None);
        assert_eq!(normalize_sha256("no hash here"), None);
    }

    /// 期望哈希格式认不出来时:文件照落,但说明写"未校验"并带上原值 ——
    /// **绝不能**报成"不匹配"(那会把排查方向带到"下载被篡改"上去)。
    ///
    /// 让哈希实现直接 panic,是为了钉住"格式不对的哈希根本不该拿去比对"。
    #[test]
    fn malformed_manifest_hash_is_reported_as_unverified_not_as_a_mismatch() {
        let dir = scratch_dir("bad-hash");
        let dest = dir.join("x.ps1");

        let note = land_verified(&dest, b"body", "DEADBEEF", |_| {
            panic!("格式认不出来的哈希不该拿去比对")
        })
        .expect("格式问题不该拦下文件");

        assert!(note.contains("未校验"), "{note}");
        assert!(!note.contains("不匹配"), "格式问题不能报成哈希不匹配:{note}");
        assert!(note.contains("DEADBEEF"), "要把原值带出来才查得动:{note}");
        assert_eq!(std::fs::read(&dest).unwrap(), b"body");
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 同名不同内容 = 换掉了用户手里已有的脚本(而且 `libs\scripts\fetch-libs.ps1`
    /// 会被 `-ExecutionPolicy Bypass` 执行)。覆盖可以,但必须在说明里说出来。
    #[test]
    fn overwriting_a_different_script_is_reported_in_the_note() {
        let dir = scratch_dir("overwrite");
        let dest = dir.join("fetch-libs.ps1");
        std::fs::write(&dest, b"# old script").unwrap();

        let note = land_verified(&dest, b"# new script", "", |_| None).unwrap();
        assert!(
            note.contains("覆盖"),
            "换掉了已有脚本就要在说明里报出来:{note}"
        );
        assert_eq!(std::fs::read(&dest).unwrap(), b"# new script");

        // 内容没变就不该报"覆盖" —— 那是噪音,会把真覆盖淹掉
        let again = land_verified(&dest, b"# new script", "", |_| None).unwrap();
        assert!(!again.contains("覆盖"), "内容一样不算覆盖:{again}");
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// SHA-256("abc") 的发布向量。
    ///
    /// 提成模块级常量,是因为下面紧跟着的编译期断言要它,而
    /// `parses_both_certutil_and_sha256sum_output` 用的是同一个向量 ——
    /// 两处各写一份的话,哪天改错一处就成了两个互相矛盾的"标准答案"。
    const ABC_SHA256: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";

    // 编译期断言(const block,不是运行期 `assert!`):向量本身写坏一位
    // (少一位 / 多一位)的话,测试会以"算出来的哈希不对"的形态红,看起来像
    // 实现坏了 —— 在编译期挡住,能省一次往错方向排查。运行期测这个常量
    // 没有任何意义(每次测的都是同一个已经写死的事实)。
    const _: () = assert!(ABC_SHA256.len() == 64, "SHA-256 向量必须是 64 位十六进制");

    /// 真哈希器:拿已发布的向量验一遍(certutil / sha256sum 哪条能用就用哪条)。
    ///
    /// 这条同时是"非 Windows 上哈希校验静默失效"那条修复的落点:两条路都补上之后,
    /// 校验得**真的算得出东西**,而不只是说明里多一句话。
    #[test]
    fn sha256_file_matches_the_published_vector() {
        let dir = scratch_dir("vector");
        let p = dir.join("abc.bin");
        std::fs::write(&p, b"abc").unwrap();

        let got = sha256_file(&p);

        // "算不出来算不算回归"这件事在**编译期**就由平台定了(Windows 上 certutil
        // 是系统件;非 Windows 上 coreutils 不保证在),所以用 `#[cfg]` 把两条路
        // 分开编译。这里原来写的是运行期 `assert!(!cfg!(windows), …)`,那正是
        // clippy::assertions_on_constants 报的 "this assertion has a constant value":
        // 它的真假编译期就定了,摆在运行期既拦不住 Windows 上的回归(恒假那句要
        // 等真的走进 None 分支才响,而那条分支本身就是"回归已经发生"),又在非
        // Windows 上什么都没验证。直接把它提成 `const { … }` 也不行 —— 在 Windows
        // 上那就是一句恒假的编译期断言,整个测试目标直接编不过。
        #[cfg(windows)]
        assert_eq!(
            got.as_deref(),
            Some(ABC_SHA256),
            "Windows 上 certutil 是系统件,算不出来或算错都是真回归"
        );
        #[cfg(not(windows))]
        {
            // 非 Windows:算不出来算"如实说未校验",不是回归;但算得出来就必须对。
            if let Some(h) = got {
                assert_eq!(h, ABC_SHA256, "算出来的不是 SHA-256(\"abc\") 的标准向量");
            }
        }

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 两种工具的排版都要能从输出里抠出哈希(抠不出就等于"未校验")。
    #[test]
    fn parses_both_certutil_and_sha256sum_output() {
        let h = ABC_SHA256;
        let certutil = "SHA256 hash of file C:\\x\\a.ps1:\n\
                        ba7816bf 8f01cfea 414140de 5dae2223 b00361a3 96177a9c b410ff61 f20015ad\n\
                        CertUtil: -hashfile command completed successfully.\n";
        assert_eq!(parse_sha256_from_output(certutil), Some(h.to_string()));

        let sha256sum = format!("{h}  a.ps1\n");
        assert_eq!(parse_sha256_from_output(&sha256sum), Some(h.to_string()));

        assert_eq!(
            parse_sha256_from_output("'sha256sum' is not recognized as an internal command."),
            None
        );
    }

    /// 来源判定:只认官方 host。长得像的域名和子域名都必须判成"非官方"。
    #[test]
    fn origin_and_base_check_only_trust_the_official_host() {
        assert!(is_under_base(
            "https://hub.nfarms.cn/script/windows/fetch-libs.ps1"
        ));
        assert!(
            is_under_base("https://hub.nfarms.cn/dl/x.ps1"),
            "同一台主机换个目录仍然是官方源(比路径会误伤)"
        );
        assert!(is_under_base("HTTPS://HUB.NFARMS.CN/x.ps1"), "大小写不该影响判定");
        assert!(
            !is_under_base("https://hub.nfarms.cn.attacker.com/script/x.ps1"),
            "以 BASE 为前缀不等于同一个源"
        );
        assert!(!is_under_base("http://127.0.0.1:8080/x.ps1"));

        assert_eq!(origin("https://a.b:8443/p/q"), "https://a.b:8443");
        assert_eq!(origin("https://a.b"), "https://a.b");
        assert_eq!(origin("nonsense"), "nonsense");
    }

    /// 非 http(s) 的地址必须在**起请求之前**就被拒 —— 否则用户看到的是 ureq 的
    /// 英文内部错误("unsupported scheme"),而不是"这条清单条目有问题"。
    #[test]
    fn non_http_url_is_refused_before_any_request() {
        let dir = scratch_dir("scheme");
        let e = ScriptEntry {
            name: "evil.ps1".into(),
            url: "file:///C:/Windows/System32/evil.ps1".into(),
            sha256: String::new(),
            note: String::new(),
        };

        let err = download(&dir, &e).expect_err("file:// 不该被下载");
        assert!(err.contains("http"), "错误要说清只支持 http(s):{err}");
        assert!(!dir.join("evil.ps1").exists());
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 本地 HTTP 桩:接一次连接,回一段固定响应(状态行 + 响应体)。
    ///
    /// 端口用 `bind(…:0)` 要一个临时的 —— 写死端口会和机器上别的东西撞,
    /// 两个测试并行跑也会互相踩(核实报告"测试缺口"那类的统一做法)。
    ///
    /// `status` 收完整状态行(如 `"200 OK"` / `"404 Not Found"`),是为了让
    /// `list` 的 401 / 403 / 404 分诊真的被走到 —— 那三条分支以前没有任何测试;
    /// `body_delay` 用来验证"读响应体有自己的超时":响应头先发,体晚一点再发。
    fn serve_http_once(
        status: &str,
        body: Vec<u8>,
        body_delay: Duration,
    ) -> (String, std::thread::JoinHandle<()>) {
        use std::io::{BufRead, Write};
        let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
        let port = l.local_addr().unwrap().port();
        let status = status.to_string();
        let h = std::thread::spawn(move || {
            let Ok((mut s, _)) = l.accept() else { return };
            // 把请求头读到空行为止。桩只服务一次、只回固定内容,所以不解析请求。
            {
                let mut r = std::io::BufReader::new(s.try_clone().unwrap());
                let mut line = String::new();
                loop {
                    line.clear();
                    match r.read_line(&mut line) {
                        Ok(0) | Err(_) => break,
                        Ok(_) if line == "\r\n" || line == "\n" => break,
                        Ok(_) => {}
                    }
                }
            }
            let head = format!(
                "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
                body.len()
            );
            let _ = s.write_all(head.as_bytes());
            let _ = s.flush();
            // 响应头先出去、体隔一段再出去:ureq 的"等响应头"预算和"读体"预算是
            // 两条(`timeout_recv_response` / `timeout_recv_body`),只有分开发
            // 才验得出后者真的在管读体那一刻。
            if !body_delay.is_zero() {
                std::thread::sleep(body_delay);
            }
            let _ = s.write_all(&body);
            let _ = s.flush();
        });
        (format!("http://127.0.0.1:{port}/stub.ps1"), h)
    }

    /// 核实报告"测试缺口"那条:`scripts.rs` 的测试全都不触网,于是**整条下载链路
    /// 没有任何测试真的跑过**。这条用本地桩把 `download` 走完整:
    /// 起请求(带 UA 与超时的 `agent()`)→ 读体 → 落盘 → 返回说明。
    ///
    /// 顺带钉住"非官方来源要在说明里标出来"—— 桩跑在 127.0.0.1 上,不是官方源。
    #[test]
    fn download_lands_bytes_from_a_local_stub_and_labels_the_source() {
        let dir = scratch_dir("stub-ok");
        let body = b"# local stub\n".to_vec();
        let (url, server) = serve_http_once("200 OK", body.clone(), Duration::ZERO);
        let e = ScriptEntry {
            name: "stub.ps1".into(),
            url,
            sha256: String::new(),
            note: String::new(),
        };

        let (p, note) = download(&dir, &e).expect("本地桩的应答应当能落盘");
        assert_eq!(std::fs::read(&p).unwrap(), body);
        assert!(note.contains("未校验"), "没给哈希就要说未校验:{note}");
        assert!(
            note.contains("来源 http://127.0.0.1"),
            "非官方来源要在说明里标出来(带 scheme 才看得出是不是明文 http):{note}"
        );
        server.join().unwrap();
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 端到端形态的 [高] 那条:哈希对不上时,目标路径下**什么都不能有**,
    /// 用户手里已有的那份也不能被动过。桩只服务一次,所以不依赖外网。
    #[test]
    fn mismatched_hash_from_a_local_stub_never_lands() {
        let dir = scratch_dir("stub-bad");
        let dest = dir.join("stub.ps1");
        std::fs::write(&dest, "# 用户手里那份".as_bytes()).unwrap();

        // 本机连哈希都算不出来时(非 Windows 且没 sha256sum),走的是"如实说未校验"
        // 那条分支,压根到不了"不匹配" —— 这条测试对那种平台没有意义,直接跳过。
        if sha256_file(&dest).is_none() {
            let _ = std::fs::remove_dir_all(&dir);
            return;
        }

        let (url, server) = serve_http_once("200 OK", b"# tampered\n".to_vec(), Duration::ZERO);
        let e = ScriptEntry {
            name: "stub.ps1".into(),
            url,
            sha256: "00".repeat(32), // 故意的错哈希
            note: String::new(),
        };

        let err = download(&dir, &e).expect_err("哈希对不上必须报错");
        assert!(err.contains("不匹配"), "{err}");
        assert_eq!(
            std::fs::read(&dest).unwrap(),
            "# 用户手里那份".as_bytes(),
            "失败的一次下载不能动到已有文件"
        );
        server.join().unwrap();
        let _ = std::fs::remove_dir_all(&dir);
    }

    // ------------------------------------------- 本轮修复的回归(超时与 list)

    /// 守核实报告那条 [低]「只有 20 秒全局超时,而且它连"读完整个响应体"一起盖住」。
    ///
    /// ureq 的 `timeout_global` 是端到端的(DNS 到读完体),所以 20 秒 global 等于
    /// "一个传得慢但没坏的大脚本也会被砍死",而报出来的只是笼统的
    /// 「读取响应失败: …」。这条直接查 agent 上的三个字段:global 必须没有,
    /// 三段预算必须各就各位。
    #[test]
    fn agent_budgets_the_body_separately_instead_of_one_global_cap() {
        let ag = agent();
        let t = ag.config().timeouts();
        assert_eq!(
            t.global, None,
            "global 是端到端预算,会把读响应体一起算进去 —— 正是要拆掉的那条"
        );
        assert_eq!(t.connect, Some(Duration::from_secs(CONNECT_TIMEOUT_SECS)));
        assert_eq!(
            t.recv_response,
            Some(Duration::from_secs(RESPONSE_TIMEOUT_SECS))
        );
        assert_eq!(t.recv_body, Some(Duration::from_secs(BODY_TIMEOUT_SECS)));

        // 编译期断言(const block):三段预算都得是正数(0 秒 = 所有请求立刻失败),
        // 而且**读体的预算必须真的比"等响应头"宽** —— 这正是这条修复要保住的性质:
        // 旧的 20 秒 global 把两者绑成同一个数,读体等于没有自己的预算。
        // 摆成运行期 `assert!` 只是每次测试重验同一个常量;放 const block 里,
        // 改错的那一刻就编不过。
        const {
            assert!(
                CONNECT_TIMEOUT_SECS > 0 && RESPONSE_TIMEOUT_SECS > 0 && BODY_TIMEOUT_SECS > 0,
                "三段超时预算都必须是正数"
            );
            assert!(
                BODY_TIMEOUT_SECS > RESPONSE_TIMEOUT_SECS,
                "读体预算要比等响应头的预算宽,否则稍大的脚本会被判死"
            );
        }
    }

    /// 上一条的行为对照:读体预算真的在**读体那一刻**生效,不只是配置里写着。
    ///
    /// 桩先把响应头发出来,再隔 600ms 才发体;agent 的读体预算给 200ms。
    /// 只有"读体有自己的超时"时这里才会报「读取响应失败」—— 旧实现(读体也吃那条
    /// 20 秒 global)下这条会**下载成功**,于是测试红。
    #[test]
    fn a_slow_body_is_cut_off_by_the_body_budget() {
        let dir = scratch_dir("slow-body");
        let (url, server) = serve_http_once(
            "200 OK",
            b"# slow\n".to_vec(),
            Duration::from_millis(600),
        );
        let e = ScriptEntry {
            name: "slow.ps1".into(),
            url,
            sha256: String::new(),
            note: String::new(),
        };

        let ag = agent_with_body_timeout(Duration::from_millis(200));
        let err = download_with_agent(&dir, &e, &ag)
            .expect_err("读体超过自己的预算就必须报错,不能一直等下去");
        assert!(err.contains("读取响应失败"), "要说清是读响应失败:{err}");
        assert!(
            !dir.join("slow.ps1").exists(),
            "超时的半截内容一个字节都不能落盘"
        );
        server.join().unwrap();
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 核实报告点名的另一条测试缺口:`list()` 的网络与错误分诊(401 / 403 / 404 的
    /// 提示文案、以及"认不出来就把原始响应带出来")全仓没有一个测试跑得到 ——
    /// 而这几句文案恰恰是用户唯一能看到的东西。这里用本地桩逐条走一遍。
    #[test]
    fn list_tells_401_403_404_apart_and_surfaces_a_garbage_body() {
        for status in ["401 Unauthorized", "403 Forbidden"] {
            let (url, server) = serve_http_once(status, Vec::new(), Duration::ZERO);
            let err = list_at(origin(&url), "windows").expect_err("401/403 必须报错");
            assert!(err.contains(&status[..3]), "错误里要带原始状态码:{err}");
            assert!(
                err.contains("登录"),
                "401/403 是「你没登录」,说明里要说清:{err}"
            );
            server.join().unwrap();
        }

        let (url, server) = serve_http_once("404 Not Found", Vec::new(), Duration::ZERO);
        let err = list_at(origin(&url), "windows").expect_err("404 必须报错");
        assert!(err.contains("404"), "{err}");
        assert!(
            err.contains("还没有脚本清单"),
            "404 是「这个平台还没有」,不能报成没登录:{err}"
        );
        assert!(!err.contains("先登录"), "404 与 401/403 的提示不能混:{err}");
        server.join().unwrap();

        // 200 但内容认不出来:要把服务端**到底给了什么**带出来,别让人猜
        let (url, server) =
            serve_http_once("200 OK", b"<html>nope</html>".to_vec(), Duration::ZERO);
        let err = list_at(origin(&url), "windows").expect_err("垃圾响应必须报错");
        assert!(err.contains("原始响应"), "{err}");
        assert!(
            err.contains("<html>nope</html>"),
            "要把原始响应带出来才查得动:{err}"
        );
        server.join().unwrap();
    }

    /// 正向对照:`list()` 整条链路(起请求 → 读体 → `parse_index`)以前一次都没跑过。
    #[test]
    fn list_parses_a_json_index_over_http() {
        let body = format!(
            r#"[{{"name":"a.ps1","url":"{BASE}/windows/a.ps1","sha256":"{}","note":"n"}}]"#,
            ABC_SHA256.to_uppercase()
        );
        let (url, server) = serve_http_once("200 OK", body.into_bytes(), Duration::ZERO);

        let l = list_at(origin(&url), "windows").expect("本地桩给的 JSON 清单应当解析出来");
        assert_eq!(l.len(), 1);
        assert_eq!(l[0].name, "a.ps1");
        assert_eq!(
            l[0].sha256, ABC_SHA256,
            "清单里的大写哈希要在解析时就统一成小写"
        );
        server.join().unwrap();
    }
}