feat(m168): 中文字体 CDN 下载真接线(D28 后半)

M167 调研的结论是「中文字体 CDN 下载至今未实装」:`i18n::font` 只有 `Fetcher` trait 与
假实现,`needs-download` 分支什么也不做。这条把它接成一条真链路。

判据 → 下载 → 注册(一条链,四个 crate 各管一段):

* `unlucky_i18n::font`(零依赖):`Source::Pinned { urls, sha256, bytes }` 把「下什么」
  钉死——google/fonts 的 `NotoSansSC[wght].ttf` 钉到 commit `2894aab…`(不是 `@main`),
  17 772 300 B / sha256 `a3041811…`。可变字体一份覆盖 wght 100–900,省掉三份静态子集的 25 MiB。
  `FetchError` 补第三档 `Disk`(网络 / 校验 / 磁盘,UI 按类别选词)。
* `unlucky_core::download::font`(新):缓存命中 → 共享引擎 `download_bytes` → 复验(sha256 +
  sfnt 魔数 + 32 MiB 上限)→ 原子落盘。**复验在写盘之前**,所以「哈希不符」时盘上什么都没有;
  缓存按 `<sha256 前 16 位>` 命名,pin 一换自动换名。
* `unlucky_app_iced::fonts::{cdn,install}`(新):`CdnFetcher` 把 pin 接进 core(`ui.font_cdn`
  可覆盖 URL,哈希不换);`install` 是状态机 + 后台线程,只有 `NeedsDownload` 才起线程
  (英文 / 系统已有 / 探测不出一律零网络)。注册走 `iced_graphics::text::FontSystem::load_font`,
  族名从字体库读回来核对。失败只降级(继续用系统字体),不阻塞、不崩。
* 接入两条路径:`fonts::resolve_with`(启动)与 `fonts::cjk_for`(设置页切语言)都调
  `install::after_probe`;设置页 General 组多一行状态(下载中 / 完成 / 失败 / 无需下载 + 三类失败
  原因),文案进 catalog 金表(链尾追加,既有顺序不动);`App::advance` 每秒比对状态快照后重建
  视图模型,所以那一行会自己刷新。

配置:新增 `ui.font_cdn`(`keys/font.rs`,schema 1.7.0),空 = 内置 jsDelivr 镜像(含备用边缘)。

守卫(三条都能变红,变异证明见报告):core 侧 8 条(缓存不联网 / 哈希不符拒落盘 / HTML 不当字体
/ 坏缓存自愈 / 上限 / pin 校验),app 侧 6 条(三种闲置策略零调用、失败不注册、类别不串、幂等、
截图钉子、单行日志),页面侧 1 条(四态 × 两语言无缺译、不写假百分比)。

测试:`cargo test -p unlucky-i18n --lib` 31 绿;`-p unlucky-core --lib` 622 绿;
`-p unlucky-app-iced --lib` 684 绿;`cargo fmt --check` 干净。截图三张见报告。
This commit is contained in:
AskaEth committed 2026-11-08 14:38:23 +08:00
1 parent 2a004a5754
commit 449ded2cb7
22 files changed
+2191 -184

No files matched your search

+280
View File
@@ -0,0 +1,280 @@
//! `download::font` 的守卫(三条都能变红,变异证明见 M168 报告)。
//!
//! ## 离线纪律
//!
//! 服务端是 `127.0.0.1` 上的回环监听器,把**内存里的假字体字节**当响应体发出去:不碰任何
//! 外网站点,跑 `cargo test` 不需要网络。真 CDN 的字节有没有变、17 MiB 要下多久,这些**不在**
//! 本文件的能力范围内(报告里写明)。
use std::io::{Read as _, Write as _};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use super::super::types::HashAlgo;
use super::*;
/// 每个用例一个独立数据根(共享引擎的登记处是进程级的,键撞了会互相干扰)。
fn root(tag: &str) -> PathBuf {
static SEQ: AtomicUsize = AtomicUsize::new(0);
let seq = SEQ.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("ull-font-{tag}-{}-{seq}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建数据根");
dir
}
/// 一段「像字体」的字节:sfnt 头 + 一张表记录 + 载荷。
///
/// 为什么造它而不是塞随机字节:`looks_like_sfnt` 是落盘前的一道闸,测试若拿 HTML 当正常内容,
/// 「下载成功」这条路径就永远走不到。
fn fake_font(payload: &[u8]) -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(b"\x00\x01\x00\x00"); // sfnt 版本
bytes.extend_from_slice(&1u16.to_be_bytes()); // numTables
bytes.extend_from_slice(&[0u8; 6]); // searchRange / entrySelector / rangeShift
bytes.extend_from_slice(b"TEST"); // 表名
bytes.extend_from_slice(&0u32.to_be_bytes()); // checksum
bytes.extend_from_slice(&28u32.to_be_bytes()); // offset
bytes.extend_from_slice(&(payload.len() as u32).to_be_bytes());
bytes.extend_from_slice(payload);
bytes
}
fn sha256(bytes: &[u8]) -> String {
verify::digest_bytes(HashAlgo::Sha256, bytes)
}
/// 回环 HTTP 服务:不管请求什么,都回同一份字节;记下处理了几条请求。
///
/// 只讲 HTTP/1.1 keep-alive,够用来判「第二次调用有没有再发请求」。
fn serve(body: Vec<u8>, expected_requests: usize) -> (String, Arc<AtomicUsize>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("绑定回环端口");
let addr = listener.local_addr().expect("取端口");
let served = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&served);
std::thread::spawn(move || {
let mut requests = 0usize;
while requests < expected_requests {
let Ok((mut socket, _)) = listener.accept() else {
break;
};
let _ = socket.set_read_timeout(Some(Duration::from_secs(10)));
let mut seen = Vec::new();
loop {
let mut chunk = [0u8; 1024];
match socket.read(&mut chunk) {
Ok(0) | Err(_) => break,
Ok(read) => seen.extend_from_slice(&chunk[..read]),
}
if !seen.windows(4).any(|window| window == b"\r\n\r\n") {
continue;
}
requests += 1;
counter.store(requests, Ordering::SeqCst);
let head = format!(
"HTTP/1.1 200 OK\r\nContent-Type: font/ttf\r\nContent-Length: {}\r\n\r\n",
body.len()
);
if socket.write_all(head.as_bytes()).is_err() || socket.write_all(&body).is_err() {
break;
}
seen.clear();
if requests >= expected_requests {
return;
}
}
}
});
(format!("http://{addr}/cjk.ttf"), served)
}
/// 一个**不会**有人监听的地址:证明「没联网」比证明「联网成功」更有力。
fn dead_url() -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("占一个端口");
let addr = listener.local_addr().expect("取端口");
drop(listener);
format!("http://{addr}/cjk.ttf")
}
fn spec_for(urls: Vec<String>, bytes: &[u8]) -> FontSpec {
FontSpec::new(urls, sha256(bytes), bytes.len() as u64).expect("pin 应自洽")
}
/// 守卫 ③:**缓存命中不联网**。
///
/// 抓得住:把 `fetch_blocking` 开头的缓存分支删掉(或改成「只 stat 不校验」以外的任何短路取消)
/// —— 那样 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 不符时拒绝落盘**(正式路径上什么都不许留下)。
///
/// 抓得住:把复验换成「信引擎的」或把中间文件直接当成品换名——本用例会在 `cache_path` 存在
/// 与「错误类别」两处同时红。
/// 抓不住:引擎自身的流式校验(那是 `transfer` 的守卫)。
#[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();
}
/// 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)))
);
}