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:
1 parent
2a004a5754
commit
449ded2cb7
22 files changed
+2191
-184
No files matched your search
@@ -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)))
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user