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();
}
}