fix(m168): 复审 r1 五项 —— 修红的 integration target、独立失败域、整体时限

复审 r1(p1-1/fix-then-merge)五项全改:

1. **p1-1 CI 会红的 4 条 integration test**(`crates/core/tests/setting/`,我上一轮只跑了 `--lib`,
   scope 漏掉了覆盖 schema 版本号的目录式 target):
   - `java_install_keys` / `isolation_keys` / `appearance_keys`:版本断言 1.6.0 → 1.7.0(M168 的
     `ui.font_cdn`),并写明「每加一批键推一次 minor 是纪律」。
   - `update_keys::a_read_only_launcher_config_never_retires_the_legacy_file`:夹具从**写死
     1.7.0** 改成 `launcher_version().minor + 1` 经 `SchemaRef::url()` 生成。写死数字的版本一推
     这条守卫就变成「同版本」⇒ 文件可写 ⇒ 迁移真的执行 ⇒ 旧文件被退役,而断言还绿着(用户数据
     守卫静默失效)。相对推导不会过期。
2. **p2-1 备用镜像不备用**:`cdn` 与 `fastly` 同属 Fastly(都 CNAME 到 jsdelivr.map.fastly.net),
   换掉。现在首选 `gcore.jsdelivr.net`(Cloudflare,独立失败域),二选 `cdn.jsdelivr.net`
   (Fastly)。注释里放了实测表(本机 + 复审机两份数据、字节数、sha256、IPv6 慢路径),
   并写清排序依据(Fastly 侧 IPv6 实测 67 KB/s ⇒ 丢掉 4 分钟;Cloudflare 侧两次都完整下完)。
3. **p2-2 整体时限**:`core::download::font` 新增 `ATTEMPT_DEADLINE = 90 s` /
   `TOTAL_DEADLINE = 180 s`,落在本模块自己的候选循环里(不动引擎全体语义):每次尝试
   `min(单次上限, 剩余总预算)`,超时归 `Network` 档。新增守卫
   `a_trickling_connection_is_cut_by_the_deadline`(回环服务端每 100 ms 挤 1 字节、预算 300 ms)。
   顺带修掉一个真 bug:`tokio::time::timeout` 在运行时外构造会 panic(5 条既有用例当场抓到),
   现在整段包进 `block_on(async { … })`。UI 文案改成「网络不通或太慢(超时)」,不校验失败混。
4. **p2-3 说 `Source::PendingPin` 的两处**:`fonts/cjk.rs` 的模块文档已改;`ui/lang.rs::log_cjk`
   那句「D28 的下载通道还是 PendingPin」(在下载已发起之后打印,等于当面否定本功能)改成
   「已按 D28 发起下载,见 [font] cjk-download … 与设置页那一行」。全仓再扫,只剩注释里的历史说明。
5. **p2-4 假守卫**:判据抽成纯函数 `install::plan(strategy, current) -> Step`(`Skip` / `Apply` /
   `Start`),`after_probe` 只执行。旧的空转用例换成打真判据的打表用例;变异证明:删掉
   `Skip` 那一臂 ⇒ `a_started_or_finished_install_is_not_restarted` 红(已实跑)。

次要项也一并处理:`from_cache` 改成**本次是否真的命中缓存**(`CdnFetcher::was_cached()`,
`has_cached()` 降级为诊断字段 `cache_present=`);`FontSpec` 的 URL 口径收紧为「非回环必须
https」(`is_acceptable_url` + 守卫);`ui.font_cdn` 写进状态行 hint;文档里的 17.0 MB → 16.9 MB。
`download/font.rs` 拆出 `font/spec.rs` 守 400 行。

验证(这台机器上单个 800 MB 测试 exe 链接要 10 分钟,链接期间文件被 Defender 扫描,故耗时很长):
- `cargo test -p unlucky-core --all-targets` → **1188 passed / 0 failed**(16 个 target,含
  `setting` 那个 115 条、上一轮红 4 条的 integration target)
- `cargo test -p unlucky-i18n` → 31 passed / 0 failed
- `cargo test -p unlucky-app-iced --lib` → 685 passed / 0 failed
- `cargo clippy -p unlucky-i18n -p unlucky-core -p unlucky-app-iced --all-targets -- -D warnings` → 干净
- `cargo fmt --all -- --check` → 干净;全仓受检文件行数最大 388(<400)
This commit is contained in:
AskaEth committed 2026-11-08 14:38:23 +08:00
1 parent 449ded2cb7
commit 875b792206
17 files changed
+512 -170

No files matched your search

+106
View File
@@ -0,0 +1,106 @@
//! 「下什么、放哪」:字体 pin 的**值**表示([`FontSpec`])与缓存落点([`cache_path`])。
//!
//! 从 `font.rs` 拆出只为守 400 行硬线(M168 复审 r1 加了整体时限与 URL 口径之后越线)。
//! 判据(哪些 URL 可用)、复验、落盘编排都在 [`super`] 那边;这里只有数据结构与路径规则。
use std::path::{Path, PathBuf};
use super::{FontFetchError, MAX_FONT_BYTES};
/// 一套字体的「在哪拿、拿到什么」:URL 候选(首选 + 备用镜像)+ 期望 sha256 + 期望字节数。
///
/// 与 `unlucky_i18n::font::Source::Pinned` 是同一份事实的两种表示:i18n 那份是**常量**(零依赖
/// crate 里写死),本结构是**值**(可被设置改写、可在测试里注入假 URL)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FontSpec {
/// URL 候选,按顺序尝试(首选 CDN 挂了才试备用镜像)。
pub urls: Vec<String>,
/// 期望 sha256(小写十六进制、64 位)。
pub sha256: String,
/// 期望字节数(进度预算 + 大小上限的判据)。
pub bytes: u64,
}
impl FontSpec {
/// 构造并做基本自洽检查(URL 非空 / 哈希形态 / 大小上限)。
///
/// # Errors
/// pin 本身不合法时返回 [`FontFetchError::Integrity`](**不发请求**)。
pub fn new(
urls: impl IntoIterator<Item = impl Into<String>>,
sha256: impl Into<String>,
bytes: u64,
) -> Result<Self, FontFetchError> {
let spec = Self {
urls: urls.into_iter().map(Into::into).collect(),
sha256: sha256.into().trim().to_ascii_lowercase(),
bytes,
};
spec.check()?;
Ok(spec)
}
/// pin 是否自洽(`super::fetch_blocking*` 与构造器都调它,故 `pub(super)`)。
pub(super) fn check(&self) -> Result<(), FontFetchError> {
if self.urls.is_empty() {
return Err(FontFetchError::Integrity("字体 pin 没有 URL".to_owned()));
}
if let Some(bad) = self.urls.iter().find(|url| !is_acceptable_url(url)) {
return Err(FontFetchError::Integrity(format!(
"字体 pin 只接受 https(http 仅限回环地址,给单测用):{bad}"
)));
}
if self.sha256.len() != 64 || !self.sha256.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(FontFetchError::Integrity(format!(
"字体 pin 的 sha256 不是 64 位十六进制:{}",
self.sha256
)));
}
if self.bytes == 0 || self.bytes > MAX_FONT_BYTES {
return Err(FontFetchError::Integrity(format!(
"字体 pin 的字节数 {} 不在 (0, {MAX_FONT_BYTES}] 内",
self.bytes
)));
}
Ok(())
}
/// 缓存文件名的哈希段(前 16 位:够区分,又不至于让路径长得没法看)。
#[must_use]
fn cache_key(&self) -> &str {
let end = self.sha256.len().min(16);
&self.sha256[..end]
}
}
/// URL 是否可用:**https 一律可以;http 只给回环地址**(单测的回环 HTTP 服务要用)。
///
/// 为什么这条要写进校验而不是只写进注释:字体是从网络下来、直接注册进渲染器的东西。明文 http
/// 在 sha256 钉死的前提下不会导致「内容被换」,但会把「从哪拿」暴露给路上的任何人(可被替换成
/// 任意大文件来耗流量/磁盘,虽然会被大小上限与哈希挡住)。契约写成「非回环必须 https」,
/// 注释与实现就是一个口径。
fn is_acceptable_url(url: &str) -> bool {
if url.starts_with("https://") {
return true;
}
let Some(rest) = url.strip_prefix("http://") else {
return false;
};
let host_port = rest.split('/').next().unwrap_or_default();
let host = host_port
.rsplit_once(':')
.filter(|(_, port)| port.chars().all(|c| c.is_ascii_digit()))
.map_or(host_port, |(host, _)| host);
let host = host.trim_start_matches('[').trim_end_matches(']');
matches!(host, "127.0.0.1" | "localhost" | "::1") || host.starts_with("127.")
}
/// 缓存文件落点:`<数据根>/fonts/cjk-noto-sans-sc-<sha256 前 16 位>.ttf`。
///
/// 文件名带哈希段是刻意的:pin 一改(换字体 / 换子集),新文件自然换名,旧文件不会冒充命中。
#[must_use]
pub fn cache_path(cache_root: &Path, spec: &FontSpec) -> PathBuf {
cache_root
.join("fonts")
.join(format!("cjk-noto-sans-sc-{}.ttf", spec.cache_key()))
}
+108
View File
@@ -223,6 +223,114 @@ fn an_html_error_page_is_not_a_font() {
std::fs::remove_dir_all(&dir).ok();
}
/// 爬行服务端:回完头之后每 100 ms 只挤 1 字节,**永不结束**。
///
/// 这正是复审 P2-2 实测到的那条路径(IPv6 边缘 ≈67 KB/s,60 s 只到 4 MB):引擎的
/// `read_timeout` 是 30 s **空闲**超时,字节一直在动 ⇒ 引擎自己永远不会判它失败。只有我们
/// 那一层的整体时限能打断它。离线(回环)可跑。
fn serve_trickle() -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("绑定回环端口");
let addr = listener.local_addr().expect("取端口");
std::thread::spawn(move || {
let Ok((mut socket, _)) = listener.accept() else {
return;
};
let _ = socket.set_read_timeout(Some(Duration::from_secs(5)));
// 读掉请求头(不回也行,但要先把请求收干净,否则某些栈会说 RST)。
let mut seen = Vec::new();
loop {
let mut chunk = [0u8; 1024];
match socket.read(&mut chunk) {
Ok(0) | Err(_) => return,
Ok(read) => seen.extend_from_slice(&chunk[..read]),
}
if seen.windows(4).any(|window| {
window
== b"
"
}) {
break;
}
}
// 声明一个够大的长度,然后开始「爬」:每 100 ms 一个字节。
if socket
.write_all(
b"HTTP/1.1 200 OK
Content-Type: font/ttf
Content-Length: 1000000
",
)
.is_err()
{
return;
}
for _ in 0..600 {
if socket.write_all(&[0u8]).is_err() {
return;
}
std::thread::sleep(Duration::from_millis(100));
}
});
format!("http://{addr}/cjk.ttf")
}
/// 守卫(M168 复审 P2-2):**只爬不死的连接由整体时限打断**,不许让界面干等。
///
/// 抓得住:把 `tokio::time::timeout` 摘掉(只剩引擎的 30 s **空闲**超时)——本用例会一直
/// 跑在「每 100 ms 一个字节」的连接上,`started.elapsed() < 4 s` 那条断言直接红(实际会挂到
/// 测试框架超时)。也抓得住「超时被归成校验失败」:`class()` 必须还是 `network`。
/// 抓不住:90 s/180 s 两个默认值在真网络下够不够(那要真 CDN,见报告「未验」)。
#[test]
fn a_trickling_connection_is_cut_by_the_deadline() {
let dir = root("deadline");
let body = fake_font(b"not what the server sends");
let spec = FontSpec::new(vec![serve_trickle()], 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("爬行连接必须被时限打断");
assert_eq!(error.class(), "network", "超时属于网络档:{error}");
assert!(
error.message().contains("超时"),
"文案要说得清是超时:{error}"
);
assert!(
started.elapsed() < Duration::from_secs(4),
"不该等到引擎的空闲超时:{:?}",
started.elapsed()
);
assert!(!cache_path(&dir, &spec).exists(), "超时不许落盘");
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() {