Files
Unlucky-Launcher/crates/core/src/download/font/tests.rs
T
AskaEth 460da9330d fix(m168): 复审 r3 四项 —— 说准 panic 档、给两条修复补守卫、时限算术补洞
复审 r3(p2-4items / fix-then-merge)四项 + 顺手五条:

1. **P2-1 `catch_unwind` 在发布档是死代码,三处文档说了不成立的话**:
   `Cargo.toml` 的 `[profile.release]` 是 `panic = "abort"`(只有 `fast` 是 `unwind`),发布走
   `--release`。分两半写清楚:①**闸前移仍安全**——release 档 panic 是进程退出,不存在永久停在
   `Downloading`;`dev`/`test`/`fast` 档 `catch_unwind` 真会写 `Failed`;②但「下载失败不崩」在
   panic 这一支上对发布版**是反的**:注册/`load_font` panic ⇒ 启动器整体 abort,不是降级。
   `install.rs::plan_with_offline` 的文档(新增两档对照表)、`worker.rs` 模块文档与 `run` 注释全部
   按这个说法重写,不再用 `catch_unwind` 给「不崩」背书。

2. **P2-2 「启动不发起下载」这条修复没有守卫**:拆出纯函数
   `fonts::resolve_with_faces(setting, faces)`(`resolve_with` 只是 `system_faces()` 的包装),
   新增 `fonts::tests::resolving_fonts_never_starts_a_download`:给一张只有拉丁族的脸表
   (一定判 `NeedsDownload`),断言调用后 `install::status()` **不是** `Downloading`。
   变异(实跑):把 `install::after_probe(cjk_report.strategy)` 加回 `resolve_with_faces` ⇒
   `求值字体**不许**发起下载(状态从 Unknown 变成了 Downloading { bytes: 17772300 })`,红。

3. **P2-3 时限算术有洞 + `is_last` 没守卫**:改 `ATTEMPT_DEADLINE=60 s` / `TOTAL_DEADLINE=420 s`
   (原来 120/360 时,第一个候选吃掉 120 s 后最后一个只剩 240 s ⇒ 门槛 72.3 KiB/s,r1 实测
   67 KiB/s 需要 259 s,仍然必失败)。文档按新数值重算成一张表:第一个候选瞬间失败 ⇒ 420 s ⇒
   42.3 KiB/s;爬满 60 s 被切 ⇒ 360 s ⇒ 49.4 KiB/s,两者都低于实测最慢的 67 KiB/s。
   守卫:把同解的旧用例换成 **单候选 + attempt 300 ms / total 5 s**——有 `is_last` 时跑到总时限
   (≈5 s),没有时 300 ms 就切,两种实现可分辨。变异(实跑):删掉 `is_last` ⇒
   `the_last_candidate_gets_the_whole_remaining_budget` 红在
   `最后一个候选该拿整个剩余预算(≈5 s)…452.6035ms`。

4. **P2-4(b)(c)**:①`install::tests` 里三处判据用例改用 `plan_with_offline(..., false)`,把进程级
   `OFFLINE` 从判据里摘出去(否则同进程里 `headless(screenshot_options())` 会把它置 true 且从不
   还原 ⇒ 结果依赖执行顺序);②`fonts.rs` 注释改成真实机制——出图 harness 不经
   `App::with_presenter`、压根没有 `set_offline`,它零流量靠的是「`resolve_with` 不触发」。

顺手:①`sha256_mismatch…` 的 doc 改成「两层校验互备,本用例抓的是两层同时拆」(原来说
「拆任意一层就红」是假的);②`font.rs` 文档「把 90 s 压成 300 ms」→ 60 s/420 s;③`install.rs` 与
`catalog/font_install.rs` 的「≈17 MB」→ 16.9;④`worker::start` 的裸 `thread::spawn` 换成
`Builder::spawn`,起不来就落 `Failed`(发布档 panic = abort,不能让它 panic);线程体抽成 `run()`。

验证(`CARGO_TARGET_DIR=C:/Temp/ull-target/wt-168`,取退出码、不截断):
- `cargo test -p unlucky-app-iced --lib` → **688 passed / 0 failed / 2 ignored / exit 0**
- `cargo test -p unlucky-core --all-targets` → **16 个 target / 1189 passed / 0 failed / exit 0**
- `cargo test -p unlucky-i18n` → 31 passed / 0 failed / exit 0
- `cargo clippy -p unlucky-i18n -p unlucky-core -p unlucky-app-iced --all-targets -- -D warnings` → exit 0
- `cargo fmt --all -- --check` 干净;全仓无文件 >400;本轮改动文件最大 378(`app-iced/src/fonts.rs`)
2026-11-08 14:38:25 +08:00

320 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `download::font` 的守卫(三条都能变红,变异证明见 M168 报告)。
//!
//! ## 离线纪律
//!
//! 服务端是 `127.0.0.1` 上的回环监听器,把**内存里的假字体字节**当响应体发出去:不碰任何
//! 外网站点,跑 `cargo test` 不需要网络。真 CDN 的字节有没有变、17 MiB 要下多久,这些**不在**
//! 本文件的能力范围内(报告里写明)。
use std::path::Path;
use std::sync::atomic::Ordering;
use std::time::Duration;
use super::*;
#[path = "tests/support.rs"]
mod support;
use support::{dead_url, fake_font, root, serve, serve_trickle, sha256, spec_for};
/// 守卫 ③:**缓存命中不联网**。
///
/// 抓得住:把 `fetch_blocking` 开头的缓存分支删掉——URL 指向的死人端口会被真的去连,本用例红。
/// 抓不住:真机上 17 MiB 文件读盘有多快。
#[test]
fn cache_hit_never_touches_the_network() {
let dir = root("cache");
let body = fake_font(b"cached payload");
let spec = spec_for(vec![dead_url()], &body);
let path = cache_path(&dir, &spec);
std::fs::create_dir_all(path.parent().unwrap()).expect("建字体目录");
std::fs::write(&path, &body).expect("预置缓存");
let fetched = fetch_blocking(&dir, &spec).expect("缓存命中应成功");
assert!(fetched.from_cache, "必须报「来自缓存」");
assert_eq!(fetched.bytes, body);
assert_eq!(fetched.source, "cache");
std::fs::remove_dir_all(&dir).ok();
}
/// 守卫 ②:**sha256 不符时拒绝落盘**(正式路径上什么都不许留下)。
///
/// **两层校验是互备的**(复审 r3 顺手项①:上一版这里写「只拆一层也会红」是假的,复审变异实测
/// 单独拆掉引擎的 `with_sha256` 或单独短路本模块复验,本用例都**仍然绿**——另一层会接住)。
/// 本用例真正抓的是**两层同时被拆**:那时坏字节会被当成字体返回、`cache_path` 也会落盘,断言在
/// `expect_err` 处红(r1 的变异证明就是这么打的)。
///
/// 抓不住:单层的实现细节(比如 `verify::matches` 的大小写处理)——那要各自的单元用例。
#[test]
fn sha256_mismatch_is_refused_and_nothing_lands() {
let dir = root("mismatch");
let served = fake_font(b"what the cdn claims");
let expected = fake_font(b"what the pin expects");
let (url, _served) = serve(served.clone(), 4);
let spec = FontSpec::new(vec![url], sha256(&expected), served.len() as u64).expect("pin");
let error = fetch_blocking(&dir, &spec).expect_err("哈希不符必须失败");
assert_eq!(error.class(), "integrity", "{error}");
let path = cache_path(&dir, &spec);
assert!(
!path.exists(),
"校验不过的字体不许出现在 {}",
path.display()
);
// 字体目录里什么都不该留下:复验在写盘之前,所以连中间态都不存在。
let leftovers = std::fs::read_dir(path.parent().unwrap())
.map_or(0, |entries| entries.filter_map(Result::ok).count());
assert_eq!(leftovers, 0, "校验不过时字体目录必须干净");
std::fs::remove_dir_all(&dir).ok();
}
/// 守卫 ③ 的另一半:**下过一次就再也不下**(同进程第二次调用零请求)。
///
/// 抓得住:缓存写失败(换名没做)/ 缓存文件名不带哈希(pin 换了还命中旧文件)/ 每次调用都
/// 重新下载——第一条与第三条都会让 `served == 2`,本用例红。
#[test]
fn the_second_call_is_served_from_the_cache() {
let dir = root("second");
let body = fake_font(b"payload of the second call");
// 服务端最多伺候 4 条请求:若第二次调用又联网,计数会变成 2。
let (url, served) = serve(body.clone(), 4);
let spec = spec_for(vec![url], &body);
let first = fetch_blocking(&dir, &spec).expect("第一次应成功");
assert!(
!first.from_cache,
"第一次必须是真下载(否则这条用例什么也没证明"
);
assert_eq!(first.bytes, body);
let second = fetch_blocking(&dir, &spec).expect("第二次应命中缓存");
assert!(second.from_cache, "第二次不许再联网");
assert_eq!(
served.load(Ordering::SeqCst),
1,
"整个过程只该有一条 HTTP 请求"
);
std::fs::remove_dir_all(&dir).ok();
}
/// 坏缓存**自愈**:哈希不符的旧文件要被删掉重下,而不是被当成命中,也不是永远挡路。
#[test]
fn a_corrupt_cache_file_is_healed_by_a_fresh_download() {
let dir = root("heal");
let body = fake_font(b"fresh bytes");
let spec = spec_for(vec![dead_url()], &body);
let path = cache_path(&dir, &spec);
std::fs::create_dir_all(path.parent().unwrap()).expect("建字体目录");
std::fs::write(&path, fake_font(b"rotten bytes")).expect("预置坏缓存");
// 坏缓存 + 死人 URL ⇒ 只能报网络错(若把坏文件当命中会返回 Ok,本用例红)。
let error = fetch_blocking(&dir, &spec).expect_err("坏缓存不该被当成命中");
assert_eq!(error.class(), "network", "{error}");
assert!(!path.exists(), "坏缓存必须被删掉");
// 换成真服务端:同一次进程里重来一次就能落地(自愈)。
let (url, _served) = serve(body.clone(), 4);
let spec = spec_for(vec![url], &body);
let fetched = fetch_blocking(&dir, &spec).expect("自愈后应成功");
assert!(!fetched.from_cache);
assert!(path.exists());
std::fs::remove_dir_all(&dir).ok();
}
/// CDN 回了一个 HTML 错误页(哈希还对不上/对得上都一样):不许当字体落盘。
///
/// 抓得住:`looks_like_sfnt` 这道闸被删掉——哈希正确时它会让一堆 HTML 进字体系统。
/// 抓不住:真字体里子集不全这类内容问题(那要 cmap 检查)。
#[test]
fn an_html_error_page_is_not_a_font() {
let dir = root("html");
let body = b"<html><body>403 Forbidden</body></html>".to_vec();
let (url, _served) = serve(body.clone(), 4);
let spec = spec_for(vec![url], &body);
let error = fetch_blocking(&dir, &spec).expect_err("HTML 不是字体");
assert_eq!(error.class(), "integrity", "{error}");
assert!(!cache_path(&dir, &spec).exists());
std::fs::remove_dir_all(&dir).ok();
}
/// 守卫(M168 复审 r2 P2-1/P2-3):**单次尝试的时限会打断爬行连接并转下一个候选**。
///
/// 这个场景现在是真的:第一个候选是 `serve_trickle()`(发真实 CRLF 响应头,之后每 100 ms 一个
/// 字节),第二个候选是正常服务端。
///
/// 抓得住:①`serve_trickle` 的响应头写错(上一版就是这个病:真实换行字节 + 4 字节窗口 ⇒ 恒 false
/// ⇒ 一个字节都不发)——那样第一个候选会在**读不到响应头**时按网络错失败,用例里
/// `trickle_served` 的「服务端真的发过头」这条断言会红;②`budget` 不是 `min(单次, 剩余)` 而是
/// 直接用总预算 ⇒ 本用例会等到总预算(5 s)才换源,`elapsed < 3 s` 红;③时限被归成校验失败。
/// 抓不住:默认值(120 s / 360 s)在真弱网下够不够(那要真 CDN,见报告「未验」)。
#[test]
fn a_trickling_candidate_is_cut_by_the_attempt_deadline() {
let dir = root("deadline-attempt");
let good = fake_font(b"served by the second candidate");
let (good_url, _served) = serve(good.clone(), 4);
let (trickle_url, proof) = serve_trickle();
let spec = FontSpec::new(
vec![trickle_url, good_url],
sha256(&good),
good.len() as u64,
)
.expect("pin");
let started = std::time::Instant::now();
let fetched = fetch_blocking_with_limits(
&dir,
&spec,
Duration::from_millis(300),
Duration::from_secs(5),
)
.expect("第二个候选应当成功");
assert_eq!(fetched.bytes, good);
assert!(
started.elapsed() < Duration::from_secs(3),
"单次上限该在 300 ms 处切断第一个候选,实际 {:?}",
started.elapsed()
);
assert!(cache_path(&dir, &spec).exists());
// **这条是「夹具没坏」的判据**(复审 r2 P2-1):第一个候选必须真的发过响应头、真的挤过字节。
// 少了这两条,一个「什么都不发的服务端」也能让本用例绿——那证明的是「连接挂着被掐断」,
// 而不是我们想证明的「活着但在爬」。
assert!(
proof.header.load(Ordering::SeqCst),
"爬行服务端必须真的发出响应头"
);
assert!(
proof.dripped.load(Ordering::SeqCst) >= 1,
"爬行服务端必须真的挤出过字节(否则引擎的空闲超时那条路径根本没被造出来)"
);
std::fs::remove_dir_all(&dir).ok();
}
/// 守卫(M168 复审 r3 P2-3):**最后一个候选拿整个剩余预算**(`is_last` 那一支)。
///
/// 形状是刻意挑的:**单候选** + `attempt = 300 ms` / `total = 5 s`。
/// * 有 `is_last`:最后一个(也是唯一一个)候选拿到剩下的 ~5 s ⇒ 只在**总时限**处被打断,
/// 耗时 ≈5 s;
/// * 没有 `is_last`(一律 `attempt.min(remaining)`):300 ms 就切,耗时 ≈0.3 s。
///
/// 两种实现的解不同,于是这条用例能分辨它们——上一版给的是 `attempt=30 s > total=400 ms`,
/// 两种实现同解,天生测不出差别(复审 r3 就是据此说 `is_last` 没人守)。
///
/// 抓得住:删掉 `is_last` 那一支(或把预算写成 `attempt_deadline.min(remaining)` 一视同仁)⇒
/// `elapsed >= 3 s` 当场红;也抓得住服务端其实不发字节(`proof` 的两个断言)。
/// 抓不住:真弱网下 60 s / 420 s 够不够(要真 CDN,见报告「未验」)。
#[test]
fn the_last_candidate_gets_the_whole_remaining_budget() {
let dir = root("deadline-last");
let body = fake_font(b"not what the server sends");
let (trickle_url, proof) = serve_trickle();
let spec = FontSpec::new(vec![trickle_url], sha256(&body), body.len() as u64).expect("pin");
let started = std::time::Instant::now();
let error = fetch_blocking_with_limits(
&dir,
&spec,
Duration::from_millis(300),
Duration::from_secs(5),
)
.expect_err("爬行连接最终必须被总时限打断");
let elapsed = started.elapsed();
assert_eq!(error.class(), "network", "超时属于网络档:{error}");
assert!(
error.message().contains("超时"),
"文案要说得清是超时:{error}"
);
assert!(
elapsed >= Duration::from_secs(3),
"最后一个候选该拿整个剩余预算(≈5 s),而不是在 300 ms 处就被切:{elapsed:?}"
);
assert!(
elapsed < Duration::from_secs(9),
"不该超过总时限太多:{elapsed:?}"
);
assert!(!cache_path(&dir, &spec).exists(), "超时不许落盘");
assert!(
proof.dripped.load(Ordering::SeqCst) >= 1,
"这条用例必须掐在「有字节在动」的连接上,而不是一个不响的端口"
);
std::fs::remove_dir_all(&dir).ok();
}
/// pin 的 URL 口径:**https 一律可以,http 只给回环**(M168 复审次要项)。
///
/// 抓得住:把 `http://` 一律放行(注释说「必须 https」、实现说「随便」的老毛病),
/// 也抓得住收紧过头(连回环的单测服务都不让用 ⇒ 一批用例会红)。
#[test]
fn the_pin_only_accepts_https_or_loopback_http() {
let hash = "a".repeat(64);
assert!(FontSpec::new(vec!["https://mirror.example/a.ttf"], &hash, 1).is_ok());
assert!(FontSpec::new(vec!["http://127.0.0.1:8080/a.ttf"], &hash, 1).is_ok());
assert!(FontSpec::new(vec!["http://localhost:9/a.ttf"], &hash, 1).is_ok());
assert!(
FontSpec::new(vec!["http://127.0.0.2:9/a.ttf"], &hash, 1).is_ok(),
"127.0.0.0/8 整个都是回环"
);
assert!(
FontSpec::new(vec!["http://example.com/a.ttf"], &hash, 1).is_err(),
"非回环的明文 http 不许过"
);
assert!(FontSpec::new(vec!["ftp://example.com/a.ttf"], &hash, 1).is_err());
}
/// pin 自身不合法时**一个请求都不发**(免得为一个坏 pin 白跑一趟网络)。
#[test]
fn an_invalid_pin_never_reaches_the_network() {
let dir = root("pin");
let served = fake_font(b"x");
let (url, counter) = serve(served.clone(), 4);
// 哈希形态不对
assert!(FontSpec::new(vec![url.clone()], "not-a-hash", 10).is_err());
// 非 http(s)
assert!(FontSpec::new(vec!["file:///etc/passwd"], sha256(&served), 10).is_err());
// 没有 URL
assert!(FontSpec::new(Vec::<String>::new(), sha256(&served), 10).is_err());
// 超过大小上限
assert!(FontSpec::new(vec![url], sha256(&served), MAX_FONT_BYTES + 1).is_err());
// 零字节
assert!(FontSpec::new(vec!["https://example.invalid/f.ttf"], sha256(&served), 0).is_err());
assert_eq!(counter.load(Ordering::SeqCst), 0, "坏 pin 不许发请求");
std::fs::remove_dir_all(&dir).ok();
}
/// `looks_like_sfnt`:只认魔数 + 表目录,不猜内容。
#[test]
fn sfnt_sniffing_accepts_fonts_and_rejects_junk() {
assert!(looks_like_sfnt(&fake_font(b"x")));
// OTF(CFF 轮廓):魔数 OTTO + 一张表
let mut otf = b"OTTO".to_vec();
otf.extend_from_slice(&1u16.to_be_bytes());
otf.extend_from_slice(&[0u8; 6]);
otf.extend_from_slice(&[0u8; 16]);
assert!(looks_like_sfnt(&otf));
// TTC(字体集合):长度够就收
assert!(looks_like_sfnt(
b"ttcf\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
));
assert!(!looks_like_sfnt(b"<html>"));
assert!(!looks_like_sfnt(b"\x00\x01\x00\x00\x00\x00")); // 表数 0
assert!(!looks_like_sfnt(b"\x00\x01\x00\x00\x00\x02")); // 长度不够装两张表
assert!(!looks_like_sfnt(&[]));
}
/// 缓存文件名带 pin 的哈希段:换 pin(换字体/换子集)⇒ 换文件名 ⇒ 不会命中旧内容。
#[test]
fn the_cache_name_changes_with_the_pin() {
let dir: &Path = std::path::Path::new("/tmp/ull-font-name");
let a = FontSpec::new(vec!["https://x.invalid/a.ttf"], "a".repeat(64), 1).expect("pin a");
let b = FontSpec::new(vec!["https://x.invalid/a.ttf"], "b".repeat(64), 1).expect("pin b");
assert_ne!(cache_path(dir, &a), cache_path(dir, &b));
assert_eq!(
cache_path(dir, &a),
dir.join("fonts")
.join(format!("cjk-noto-sans-sc-{}.ttf", "a".repeat(16)))
);
}