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,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;
|
||||
@@ -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)))
|
||||
);
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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 必须过校验"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user