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

+333
View File
@@ -0,0 +1,333 @@
//! OFL 中文字体的取回(D28 后半):**缓存命中 → 共享引擎下载 → 复验 → 原子落盘**。
//!
//! ## 一段话
//!
//! `unlucky_i18n::font` 只做判据与「该下载什么」(URL 候选 + sha256 + 字节数),真正把字节弄到
//! 盘上的活在这里:
//!
//! ```text
//! <数据根>/fonts/cjk-noto-sans-sc-<sha256 前 16 位>.ttf
//! 已有且哈希 + 魔数都对 ⇒ 直接返回(零网络)
//! 否则 ⇒ 共享引擎的 download_bytes(进内存)
//! ⇒ 自己再算一遍 sha256、再看一眼是不是 sfnt、再看大小上限
//! ⇒ 全过才 write_atomic 落盘 ⇒ 返回字节
//! ```
//!
//! ## 为什么用「内存下载」而不是「边下边写文件」
//!
//! 一张 17 MiB 的字体本来就要整份进内存(注册进字体系统要 `Vec<u8>`),而**先复验再落盘**让
//! 「哈希不符」这条判据简单到不可能出错:盘上要么没有这个文件,要么它是校验过的。文件下载路径
//! (`DownloadEngine::download`)会先写 `<dest>.ullpart-*` 再换名,中间态与「拒绝落盘」混在一起,
//! 对这一份内容寻址的小资源没有收益。
//!
//! ## 三条判据(都有守卫,见 `tests.rs`)
//!
//! 1. **缓存命中不联网**:命中就 `from_cache`,一条 HTTP 都不发(同进程第二次、二次启动都一样);
//! 哈希不符的旧文件会被删掉重下(自愈),不冒充命中、也不永远挡路。
//! 2. **sha256 不符拒绝落盘**:复验在任何写盘动作**之前**;不符 ⇒ 正式路径上什么都没有。
//! 3. **大小上限**:[`MAX_FONT_BYTES`]。pin 声称超过上限的直接拒(不发请求);实得字节超上限的
//! 也拒。挡的是「CDN 被换成一个大文件」这类事故,不是省流量。
//!
//! ## 失败分类为什么是三类
//!
//! 上层(设置页那一行)只需决定「说什么话」:网络类可重试、校验类是内容不对、磁盘类得腾空间。
//! 再多分类对用户没有区别。
//!
//! ## 与 M165 共享引擎的关系
//!
//! **不另写 HTTP 通路**:请求一律走 [`super::shared::engine_for`] 那个进程级引擎(连接池 / 限流 /
//! 健康度 / 候选源都复用)。本模块只额外提供「字体这个资源特有的三件事」:内容寻址的缓存文件名、
//! 复验、大小上限。
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use super::atomic::write_atomic;
use super::blocking::block_on;
use super::error::DownloadErrorKind;
use super::shared;
use super::types::{DownloadRequest, HashAlgo, ResourceKind};
use super::verify;
/// 一套字体允许的最大字节数(32 MiB)。
///
/// 实测参考:Noto Sans SC 可变字体(wght 100–900,整个简体字符集)17.0 MiB;再大的「字体」基本
/// 可以判断不是给 UI 用的字体。上限的用处不是省流量,是**挡住被换掉的 CDN 把几十上百 MB 的东西
/// 写进数据根**。
pub const MAX_FONT_BYTES: u64 = 32 * 1024 * 1024;
/// 字体取回的失败分类(三层,见模块文档「失败分类为什么是三类」)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FontFetchError {
/// 网络层:连不上 / 超时 / 非 2xx / 所有候选源都失败。
Network(String),
/// 内容层:sha256 不符 / 不是字体文件 / 超出大小上限。
Integrity(String),
/// 本地磁盘层:建目录、写文件、换名失败。
Disk(String),
}
impl FontFetchError {
/// 稳定类别名(`network` / `integrity` / `disk`;日志与 i18n 金表按它选词,**不要改名**)。
#[must_use]
pub const fn class(&self) -> &'static str {
match self {
Self::Network(_) => "network",
Self::Integrity(_) => "integrity",
Self::Disk(_) => "disk",
}
}
/// 人类可读描述(进日志的一句)。
#[must_use]
pub fn message(&self) -> &str {
match self {
Self::Network(message) | Self::Integrity(message) | Self::Disk(message) => message,
}
}
}
impl std::fmt::Display for FontFetchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[{}] {}", self.class(), self.message())
}
}
impl std::error::Error for FontFetchError {}
impl From<super::error::DownloadError> for FontFetchError {
fn from(error: super::error::DownloadError) -> Self {
// 用叶子成因:`download_bytes` 的整体失败会带上它(全 404 → NotFound,出现网络错 → Network,
// 校验不符 → Integrity)。少了叶子这一层,哈希不符会被读成「没联网」。
let message = error.to_string();
match error.effective_kind() {
DownloadErrorKind::Io => Self::Disk(message),
DownloadErrorKind::Integrity | DownloadErrorKind::InvalidRequest => {
Self::Integrity(message)
}
_ => Self::Network(message),
}
}
}
/// 一套字体的「在哪拿、拿到什么」: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 是否自洽。
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| !(url.starts_with("https://") || url.starts_with("http://")))
{
return Err(FontFetchError::Integrity(format!(
"字体 pin 只接受 http(s) URL:{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]
}
}
/// 一次取回的产物。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FontFetch {
/// 字体字节(交给调用方注册进字体系统)。
pub bytes: Vec<u8>,
/// 是否零网络命中缓存。
pub from_cache: bool,
/// 成功的源标识(`cache` / 引擎给的候选源名)。
pub source: String,
/// 耗时。
pub elapsed: Duration,
}
/// 缓存文件落点:`<数据根>/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()))
}
/// 取一套字体(**阻塞**:内部走进程共享运行时)。
///
/// 顺序:pin 自洽检查 → 缓存复验 → 逐个 URL 走共享引擎 → 复验 + 魔数 + 上限 → 原子落盘。
///
/// # Errors
/// 见 [`FontFetchError`]。失败**不是**致命错误:调用方应当降级回系统字体 / 英文界面。
pub fn fetch_blocking(cache_root: &Path, spec: &FontSpec) -> Result<FontFetch, FontFetchError> {
let started = Instant::now();
spec.check()?;
let final_path = cache_path(cache_root, spec);
if let Some(bytes) = read_verified(&final_path, spec)? {
return Ok(FontFetch {
bytes,
from_cache: true,
source: "cache".to_owned(),
elapsed: started.elapsed(),
});
}
let engine = shared::engine_for(cache_root);
let mut network_error: Option<FontFetchError> = None;
let mut integrity_error: Option<FontFetchError> = None;
for url in &spec.urls {
let request = DownloadRequest::new(ResourceKind::Other, url.clone(), &final_path)
.with_sha256(&spec.sha256)
.with_size(spec.bytes);
match block_on(engine.download_bytes(request)) {
Ok(bytes) => {
if bytes.len() as u64 > MAX_FONT_BYTES {
integrity_error = Some(FontFetchError::Integrity(format!(
"字体超过大小上限:{} > {MAX_FONT_BYTES}",
bytes.len()
)));
continue;
}
// 引擎的流式校验与这一道是**两处独立**的判据:这里同时看哈希与魔数,且都在写盘之前。
let actual = verify::digest_bytes(HashAlgo::Sha256, &bytes);
if !verify::matches(&spec.sha256, &actual) {
integrity_error = Some(FontFetchError::Integrity(format!(
"字体 sha256 不符:期望 {} 实得 {actual}({url})",
spec.sha256
)));
continue;
}
if !looks_like_sfnt(&bytes) {
integrity_error = Some(FontFetchError::Integrity(format!(
"取回的不是字体文件(sfnt 魔数不符):{url}"
)));
continue;
}
write_atomic(&final_path, &bytes).map_err(|error| {
FontFetchError::Disk(format!(
"字体落盘失败({}):{error}",
final_path.display()
))
})?;
return Ok(FontFetch {
bytes,
from_cache: false,
source: url.clone(),
elapsed: started.elapsed(),
});
}
Err(error) => {
let mapped = FontFetchError::from(error);
match mapped {
FontFetchError::Integrity(_) => integrity_error = Some(mapped),
_ => network_error = Some(mapped),
}
}
}
}
// 报错时优先报「内容不对」:那说明源给了不该给的东西,比「连不上」更需要人看一眼。
Err(integrity_error
.or(network_error)
.unwrap_or_else(|| FontFetchError::Network("字体 pin 没有可用 URL".to_owned())))
}
/// 读缓存并复验(`Ok(None)` = 不存在或校验不过,**且坏文件已被删掉**)。
fn read_verified(path: &Path, spec: &FontSpec) -> Result<Option<Vec<u8>>, FontFetchError> {
let bytes = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(FontFetchError::Disk(format!(
"读字体缓存失败({}):{error}",
path.display()
)))
}
};
let actual = verify::digest_bytes(HashAlgo::Sha256, &bytes);
if !verify::matches(&spec.sha256, &actual) || !looks_like_sfnt(&bytes) {
// 坏缓存自愈:删掉,交给下载路径重来(不把坏文件当命中,也不让它一直挡路)。
let _ = std::fs::remove_file(path);
return Ok(None);
}
Ok(Some(bytes))
}
/// 是不是一个 sfnt 字体文件(TTF / OTF / TTC)。
///
/// 只认魔数 + 表目录读得出来:够挡住「CDN 回了一个 HTML 错误页」这类事故,**不**做字形覆盖校验
/// (那要 `ttf-parser`,core 的依赖纪律不允许为一个判据引它)。
#[must_use]
pub fn looks_like_sfnt(bytes: &[u8]) -> bool {
if bytes.len() < 12 {
return false;
}
let tag = &bytes[..4];
if tag == b"ttcf" {
// 字体集合:只要长度说得过去就收(成员表的细节不值得在这里解析)。
return bytes.len() >= 16;
}
if !matches!(tag, b"\x00\x01\x00\x00" | b"OTTO" | b"true") {
return false;
}
let tables = u16::from_be_bytes([bytes[4], bytes[5]]);
tables > 0 && bytes.len() >= 12 + usize::from(tables) * 16
}
#[cfg(test)]
#[path = "font/tests.rs"]
mod tests;
+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)))
);
}
+4
View File
@@ -53,6 +53,7 @@ pub mod chunked;
pub mod config;
pub mod engine;
pub mod error;
pub mod font;
pub mod health;
pub mod jdk_sources;
pub mod mirrors;
@@ -81,6 +82,9 @@ pub use self::chunked::{ByteRange, ChunkedConfig, RangeSupport};
pub use self::config::EngineConfig;
pub use self::engine::DownloadEngine;
pub use self::error::{DownloadError, DownloadErrorKind};
pub use self::font::{
cache_path, fetch_blocking, FontFetch, FontFetchError, FontSpec, MAX_FONT_BYTES,
};
pub use self::health::{HealthKey, HealthRegistry, SourceHealth};
pub use self::jdk_sources::{MsOpenJdkTable, OracleMirror, ZuluTable};
pub use self::mirrors::{direct_source_id, AdoptiumMirror, GhProxy, MirrorTable, PrefixRule};
+10 -9
View File
@@ -33,12 +33,12 @@ pub const LOG_LEVELS: &[&str] = &["error", "warn", "info", "debug", "trace"];
pub const INSTANCE_MODES: &[&str] = &["isolated", "shared"];
/// `java.installRoot` 未设置时的哨兵(空串 = 按数据目录模式派生落点)。
pub const JAVA_INSTALL_ROOT_UNSET: &str = "";
/// `java.vendorPriority` 的允许值(D23:四家发行版,顺序即优先级)。
/// 与 `game::java::vendor::VENDOR_IDS` 必须同一份集合(setting 层不依赖 game 层),
/// 一致性由 `tests/setting/java_install_keys.rs` 的契约测试固定。
/// `java.vendorPriority` 的允许值(D23:四家发行版,顺序即优先级)。与 `game::java::vendor::VENDOR_IDS`
/// 必须同一份集合(setting 层不依赖 game 层),一致性由 `tests/setting/java_install_keys.rs` 守。
pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu", "termux"];
/// 自更新两键(M161;拆到自己的文件守 400 行)。
/// 拆到各自文件的键域:中文字体 CDN(M168)/ 自更新(M161)。主表各留一行登记。
pub mod font;
pub mod update;
/// launcher.json 当前 schema 版本。
@@ -54,17 +54,17 @@ pub mod update;
/// `1.5.0` 登记 M116 的窗口圆角策略(`appearance.window_corner`,出厂 `system`):纯增量。
/// `1.6.0` 登记 M161 的「自更新」两键(见 [`update`]):纯增量但带一步数据迁移
/// (此前住在 `cache/update/prefs.json`,迁移见 [`super::update_prefs`])。
/// `1.7.0` 登记 M168 的中文字体下载地址覆盖键(`ui.font_cdn`,见 [`font`]):纯增量、无迁移。
///
/// **旧读新(方向相反,措辞必须精确)**:1.4.0 的客户端读 1.5.0 的文件时,
/// [`super::schema::SchemaCompat::check_url`] 走 `found.minor > expected.minor` →
/// `ReadOnlyPreserveSchema` → [`super::schema::SchemaCompat::access`] → `FileAccess::ReadOnly`。
/// 也就是说**整份配置对它只读**(值照读、`store.set` 一律 `SettingError::ReadOnly`、
/// `save` 跳过),不是「只是少一档」。这是 minor 提升的既定代价——也正是这条键必须推 minor
/// 而不是偷偷塞进 1.4.0 的原因:回退到旧版本会出现「设置改不动」,而原因只在
/// `$schema` 那一行里,不看版本号无从排查。
/// 也就是说**整份配置对它只读**(值照读、`store.set` 一律 `SettingError::ReadOnly`、`save` 跳过),
/// 不是「只是少一档」。这是 minor 提升的既定代价——也正是新键必须推 minor 而不是偷偷塞进旧版本的
/// 原因:回退到旧版本会出现「设置改不动」,而原因只在 `$schema` 那一行里,不看版本号无从排查。
#[must_use]
pub fn launcher_version() -> SchemaVersion {
SchemaVersion::new(1, 6, 0)
SchemaVersion::new(1, 7, 0)
}
/// `config/launcher.json` 的 schema。
@@ -396,4 +396,5 @@ pub fn launcher_schema() -> SettingSchema {
"启动器日志级别",
))
.with_all(update::specs())
.with(font::cdn_spec())
}
+59
View File
@@ -0,0 +1,59 @@
//! 中文字体下载地址的覆盖键(M168:D28 后半「下载开源中文字体」)。
//!
//! 从 `keys.rs` 拆出(那边贴着 400 行硬线):主表只留一行 `.with(font::cdn_spec())`。
//!
//! ## 为什么需要这个键
//!
//! 内置 pin(`unlucky_i18n::font::NOTO_SANS_SC`)把 URL 钉到了 jsDelivr 的一个 git commit 上
//! ——那是**一个**出口。用户所在网络如果连不上它(或想走自建镜像 / 代理),要有办法换,否则
//! 「自动下载中文字体」在某些网络上等于不存在。
//!
//! ## 为什么只换 URL,不换哈希
//!
//! 字体是**注册进渲染器的可执行内容**,不是一张图。这个键只允许改「从哪拿」,`sha256` 与字节数
//! 仍取内置那一份:镜像给了别的内容 ⇒ 校验当场拒 ⇒ 降级回系统字体,并在设置页那一行说「镜像
//! 内容校验不通过」。**宁可不下载,也不把没校验过的东西注册成字体。**
//!
//! 想让用户能换「整套字体」(换族、换子集)就得连哈希一起进配置——那是另一件事:现在的 pin 是
//! 代码里的常量,改它要发版。理由是此刻只有一套官方子集,多条可写路径只会多一个「校验值和字体
//! 对不上」的支持面。
use crate::setting::spec::{AiPolicy, SettingSpec};
/// `ui.font_cdn` 的登记项(走 [`SettingSpec::text`]:空串 = 用内置镜像)。
#[must_use]
pub fn cdn_spec() -> SettingSpec {
SettingSpec::text(
"ui.font_cdn",
"",
512,
AiPolicy::audited(),
"中文字体下载地址(空 = 内置国内可达镜像;填整条 https URL,内容必须与内置 sha256 一致)",
)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// 默认值是空串(= 用内置镜像),且不是 `Path` 类型——空串对 `Path` 会被 schema 拒。
///
/// 抓得住:有人把键改成 `SettingSpec::path(...)`(空串过不了校验 ⇒ 出厂配置自己就非法)。
#[test]
fn the_cdn_key_defaults_to_the_builtin_mirror() {
let spec = cdn_spec();
assert_eq!(spec.key, "ui.font_cdn");
assert_eq!(spec.default, json!(""));
assert!(
spec.kind.check(&json!("")).is_ok(),
"空串(= 内置镜像)必须过校验"
);
assert!(
spec.kind
.check(&json!("https://mirror.example/NotoSansSC.ttf"))
.is_ok(),
"一条 https URL 必须过校验"
);
}
}