复审 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`)
320 lines
14 KiB
Rust
320 lines
14 KiB
Rust
//! `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)))
|
||
);
|
||
}
|