Unlucky Launcher v0.0.1-alpha
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s

Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
AskaEth committed 2026-10-31 21:54:10 +08:00
commit bc41dfcf7d
517 files changed
+92844

No files matched your search

+204
View File
@@ -0,0 +1,204 @@
//! Adoptium(Temurin)JDK 的院校镜像展开。
//!
//! D13 定案:国内模式下 JDK 按健康度在 TUNA / 华为云 等镜像之间选,
//! 全部不可达才回官方。难点是**镜像路径与官方下载链接不同形**:
//!
//! 官方(api.adoptium.net 会 302 到这里):
//! `https://github.com/adoptium/temurin17-binaries/releases/download/JDK17.0.13+11/OpenJDK17U-jdk_x64_windows_hotspot_17.0.13_11.zip`
//! 清华 TUNA:
//! `https://mirrors.tuna.tsinghua.edu.cn/Adoptium/17/jdk/x64/windows/OpenJDK17U-jdk_x64_windows_hotspot_17.0.13_11.zip`
//!
//! 所以这里按产物文件名反解 `(major, flavor, arch, os, filename)`,再按各镜像的模板拼目标 URL。
//! **`flavor` 必须是 `jdk` 或 `jre`**:院校镜像的目录结构是 `/{major}/{jdk|jre}/{arch}/{os}/`,
//! JRE 请求若错拼成 `/jdk/` 会 404 打满所有镜像,最后退回官方直链(镜像加速白费)。
//! 解析失败不报错——只保留官方源,下载照常能完成。
//!
//! # 清单可用性(M33 实测,2026-10-30)
//!
//! 按 `{base}/{21}/jdk/x64/windows/<Temurin 文件名>` 逐个打点:
//!
//! | 镜像 | 结果 |
//! | --- | --- |
//! | 清华 TUNA `mirrors.tuna.tsinghua.edu.cn/Adoptium` | 200(真实下载 205 MB / 22 秒成功) |
//! | 中科大 USTC | **404**——`/adoptium/` 下只有 `deb/` `rpm/` `releases/`,没有这套目录树 |
//! | 上交 SJTU / 北外 BFSU | **404**(同样没有该目录树) |
//! | 华为云 | **假阳性**:门户对任意路径都回 `200` + 12 KB HTML(M34 实测,`/v1/repositories` 里没有 adoptium) |
//!
//! 因此 `mirrors.json` 里这四项全是 `enabled: false`:留着当「将来恢复」的登记,
//! 但不让它们每次安装都白跑一趟(健康度虽然会冷却,首轮仍要付代价)。
//! **判可用性别只看状态码**:真仓库目录页含 `<pre>`,门户兜底页没有——状态码 200 说明不了什么。
//!
//! # 4xx 不是「源坏」
//!
//! TUNA 每个 `{major}/{jdk|jre}/{arch}/{os}/` 目录只保留**最新一个补丁**(实测 21.0.12 → 404,
//! 21.0.13 → 200 且大小与 Adoptium API 一致)。所以 `NotFound` 在引擎里走
//! `HealthRegistry::record_missing`:不计失败、不进冷却,只留诊断痕迹。
use serde::{Deserialize, Serialize};
use super::mirrors::MirrorTable;
use super::types::Candidate;
const fn default_true() -> bool {
true
}
/// 一个 Adoptium 院校镜像。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdoptiumMirror {
/// 源标识(`tuna` / `ustc` / `sjtu` / `bfsu`)。
pub id: String,
/// 展示名。
pub name: String,
/// 镜像根(形如 `https://mirrors.tuna.tsinghua.edu.cn/Adoptium`)。
pub base: String,
/// 是否启用。
#[serde(default = "default_true")]
pub enabled: bool,
/// 该源只保留「最新补丁」(TUNA 实测:每个 `{major}/{jdk|jre}/{arch}/{os}/` 只有 1 个归档)。
///
/// 标记本身不改 URL 形态,只给诊断与 `java vendors` 提示用:旧补丁在这里必然 404,
/// 引擎侧把 4xx 当「正常降级」而不是「源坏」(见 `download::health::record_missing`)。
#[serde(default)]
pub latest_only: bool,
}
impl MirrorTable {
/// Adoptium 类 URL 展开成院校镜像候选(按清单顺序;健康度排序由引擎负责)。
#[must_use]
pub fn adoptium_candidates(&self, url: &str) -> Vec<Candidate> {
let Some(artifact) = parse_adoptium_artifact(url) else {
return Vec::new();
};
self.adoptium
.iter()
.filter(|mirror| mirror.enabled)
.map(|mirror| {
let base = mirror.base.trim_end_matches('/');
Candidate::new(mirror.id.clone(), artifact.mirror_url(base))
})
.collect()
}
}
/// Adoptium 产物形态:JDK 还是 JRE(决定镜像的 `{major}/jdk|jre/` 路径段)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JdkFlavor {
/// 完整开发包(`OpenJDK17U-jdk_...`)。
Jdk,
/// 运行时(`OpenJDK17U-jre_...`)。
Jre,
}
impl JdkFlavor {
/// 镜像路径里的段名。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Jdk => "jdk",
Self::Jre => "jre",
}
}
/// 从产物文件名判形态:`OpenJDK17U-jdk_` / `OpenJDK17U-jre_` / `*-jre-17…` 兜底。
#[must_use]
fn from_filename(filename: &str) -> Self {
if filename.contains("-jre") || filename.contains("_jre") {
Self::Jre
} else {
Self::Jdk
}
}
}
/// 解析出来的 Adoptium 产物。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdoptiumArtifact {
/// 大版本号(`17` / `21`)。
pub major: String,
/// JDK 还是 JRE。
pub flavor: JdkFlavor,
/// CPU 架构(`x64` / `aarch64` / …)。
pub arch: String,
/// 目标系统(`windows` / `linux` / `mac` / …)。
pub os: String,
/// 产物文件名(含扩展名)。
pub filename: String,
}
impl AdoptiumArtifact {
/// 拼某个镜像根下的下载地址:`{base}/{major}/{jdk|jre}/{arch}/{os}/{filename}`。
#[must_use]
pub fn mirror_url(&self, base: &str) -> String {
format!(
"{}/{}/{}/{}/{}/{}",
base.trim_end_matches('/'),
self.major,
self.flavor.as_str(),
self.arch,
self.os,
self.filename
)
}
}
/// 解析 Adoptium 产物名。
///
/// 输入形如 `https://github.com/adoptium/temurin17-binaries/releases/download/JDK17.0.13%2B11/OpenJDK17U-jdk_x64_windows_hotspot_17.0.13_11.zip`。
/// 解析失败返回 `None`(此时只保留官方源,不影响下载)。
#[must_use]
pub fn parse_adoptium_artifact(url: &str) -> Option<AdoptiumArtifact> {
let filename = url.rsplit('/').next()?;
if filename.is_empty()
|| (!filename.contains("OpenJDK") && !filename.contains("jdk") && !filename.contains("jre"))
{
return None;
}
let mut major: Option<String> = None;
let mut arch: Option<&str> = None;
let mut os: Option<&str> = None;
for token in filename.split('_') {
if let Some(stripped) = token.strip_prefix("OpenJDK") {
let digits: String = stripped.chars().take_while(char::is_ascii_digit).collect();
if !digits.is_empty() {
major = Some(digits);
}
}
arch = arch.or(match token {
"x64" => Some("x64"),
"x86-32" | "x86" => Some("x86-32"),
"aarch64" => Some("aarch64"),
"arm" => Some("arm"),
"ppc64le" => Some("ppc64le"),
"s390x" => Some("s390x"),
_ => None,
});
os = os.or(match token {
"windows" => Some("windows"),
"linux" => Some("linux"),
"mac" => Some("mac"),
"alpine-linux" => Some("alpine-linux"),
"aix" => Some("aix"),
_ => None,
});
}
let major = match major {
Some(major) => major,
None => {
// 兜底形态:`jdk-17.0.13+11_...` 之类。
let rest = filename.split("jdk-").nth(1)?;
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
if digits.is_empty() {
return None;
}
digits
}
};
Some(AdoptiumArtifact {
major,
flavor: JdkFlavor::from_filename(filename),
arch: arch?.to_string(),
os: os?.to_string(),
filename: filename.to_string(),
})
}
+384
View File
@@ -0,0 +1,384 @@
//! 原子落盘:临时文件守卫 + `tmp → rename`。
//!
//! 设计文档 §4 的 HMCL 坑之一:没有「先校验后落盘」,崩溃/取消会留下「看起来存在但内容坏了」
//! 的文件,之后所有「存在即跳过」的判断全部失效。这里的规矩:
//!
//! * 下载永远写进同级中间文件,校验通过才 `rename` 到目标路径;
//! * `PartFile` 在 `Drop` 时自动清理(除非已 `commit`),取消/early-return 都不会留垃圾;
//! * 小文件(索引、健康度)走 `write_atomic`,同样先写临时文件再 `rename`。
//!
//! # 中间文件的身份
//!
//! 中间文件名是 `<name>.ullpart-<session>-<seq>`:`session` 是进程号,`seq` 是进程内
//! 自增序号(一个 `PartFile` 一个)。旧版命名只由目标路径决定(`<name>.ullpart`),
//! 同一数据根同时跑两个启动器进程时两边共用一个中间文件:先提交的一方 `rename` 走,
//! 另一方就拿到 `os error 2`(M35 真机:两个 `ull install` 并行装同一数据根,
//! 各失败 819 / 1116 个资源)。
//!
//! 单进程语义(幂等 / 断点续传 / `current_bytes` 跳过判定)刻意保持不变:
//!
//! * [`PartFile::for_dest`] 先在目标目录里找这个目标的旧中间文件并**收养**(本进程的、
//! 旧版无主的、以及外进程但静置超过 [`ADOPT_MIN_AGE`] 的),改名成本次路径;后续
//! 续传判定完全照旧,跨进程/跨版本中断的下载仍然续得上。
//! * 孤儿清理只碰两类:本会话留下又没人在用的副本,和无主(旧版命名)且超过
//! [`ORPHAN_MAX_AGE`] 的残留。本进程正在用的路径登记在活跃表里一律让开;外进程的
//! 中间文件除了「静置超过 [`ORPHAN_MAX_AGE`] 且同一个目标」的,一概不删——没有进程
//! 存活性探测就无法证明那边已经死了,宁可再生点垃圾也不重犯 `os error 2`。
use std::collections::HashSet;
use std::ffi::OsString;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, SystemTime};
/// 下载中间文件后缀(中间文件家族的前缀:`<name>.ullpart` 或 `<name>.ullpart-<session>-<seq>`)。
pub const PART_SUFFIX: &str = ".ullpart";
/// 续传元数据后缀(挂在中间文件路径后面)。
pub const META_SUFFIX: &str = ".ullmeta.json";
/// 收养阈值:别的进程留下的中间文件静置这么久才敢接管。
///
/// 活着的下载每读到一个块就写一次(读超时默认 30s 就报错退出),所以「10 分钟没写」
/// 基本等价于「那边已经死了」。
pub const ADOPT_MIN_AGE: Duration = Duration::from_secs(10 * 60);
/// 孤儿阈值:无主(旧版命名)中间文件超过这个岁数才清。
pub const ORPHAN_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
/// 同进程内中间文件/临时文件的去重序号。
static SEQ: AtomicU64 = AtomicU64::new(0);
fn sibling(dest: &Path, suffix: &str) -> PathBuf {
let mut name = dest.file_name().map_or_else(OsString::new, OsString::from);
name.push(suffix);
match dest.parent() {
Some(parent) => parent.join(name),
None => PathBuf::from(name),
}
}
/// 本进程序号(中间文件与 `write_atomic` 的临时文件共用)。
fn next_seq() -> u64 {
SEQ.fetch_add(1, Ordering::Relaxed)
}
/// 本进程此刻仍在用的中间文件路径(清理与收养都必须让开)。
fn live_parts() -> &'static Mutex<HashSet<PathBuf>> {
static LIVE: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
LIVE.get_or_init(|| Mutex::new(HashSet::new()))
}
fn mark_live(path: &Path, live: bool) {
let mut set = live_parts()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if live {
set.insert(path.to_path_buf());
} else {
set.remove(path);
}
}
fn is_live(path: &Path) -> bool {
live_parts()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.contains(path)
}
/// 中间文件属主(从文件名解析)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Owner {
/// 旧版命名:没有进程标识(加标识之前留下的续传材料)。
Legacy,
/// 某个进程(会话)留下的中间文件。
Session(u32),
}
fn strip_meta(name: &str) -> &str {
name.strip_suffix(META_SUFFIX).unwrap_or(name)
}
/// 名字里指向的中间文件属主;不是我们认识的名字形状 → `None`(一概不碰)。
fn owner_of(file_name: &str) -> Option<Owner> {
let base = strip_meta(file_name);
let index = base.rfind(PART_SUFFIX)?;
if index == 0 {
return None;
}
let tail = &base[index + PART_SUFFIX.len()..];
if tail.is_empty() {
return Some(Owner::Legacy);
}
let mut pieces = tail.strip_prefix('-')?.split('-');
let session = pieces.next()?.parse::<u32>().ok()?;
pieces.next()?.parse::<u64>().ok()?;
if pieces.next().is_some() {
return None;
}
Some(Owner::Session(session))
}
/// 本次会话的中间文件路径:`<name>.ullpart-<session>-<seq>`。
fn part_path(dest: &Path, session: u32, seq: u64) -> PathBuf {
let mut name = dest.file_name().map_or_else(OsString::new, OsString::from);
name.push(format!("{PART_SUFFIX}-{session}-{seq}"));
match dest.parent() {
Some(parent) => parent.join(name),
None => PathBuf::from(name),
}
}
/// 文件距上次修改多久(取不到就当刚写过:清理与收养一律保守)。
fn age_of(path: &Path) -> Duration {
fs::metadata(path)
.and_then(|meta| meta.modified())
.ok()
.and_then(|modified| SystemTime::now().duration_since(modified).ok())
.unwrap_or_default()
}
/// 删掉一个中间文件连同它挂的续传元数据。
fn remove_family(part: &Path) {
let _ = fs::remove_file(part);
let _ = fs::remove_file(sibling(part, META_SUFFIX));
}
/// 目录扫描的三类结果。
#[derive(Default)]
struct Scan {
/// 可收养的旧中间文件(最年轻的那个)。
adopted: Option<PathBuf>,
/// 本目标的全部中间文件副本(含被收养者)。
leftovers: Vec<(PathBuf, Owner, Duration)>,
/// 无主且超龄的孤儿。
orphans: Vec<PathBuf>,
}
fn scan(dest: &Path, session: u32) -> Scan {
let mut scan = Scan::default();
let Some(dir) = dest
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
else {
return scan;
};
let Some(dest_name) = dest.file_name() else {
return scan;
};
let family = format!("{}{PART_SUFFIX}", dest_name.to_string_lossy());
let Ok(entries) = fs::read_dir(dir) else {
return scan;
};
let mut newest: Option<Duration> = None;
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if name.ends_with(META_SUFFIX) {
continue; // 元数据跟着它挂的中间文件一起处理
}
let Some(owner) = owner_of(&name) else {
continue;
};
let file = entry.path();
if !file.is_file() {
continue;
}
let age = age_of(&file);
if !name.starts_with(&family) {
if owner == Owner::Legacy && age >= ORPHAN_MAX_AGE {
scan.orphans.push(file);
}
continue;
}
let adoptable = match owner {
// 本会话的旧副本:没人在用就能接管(保留「同一进程内重跑可续传」)。
Owner::Session(owner_session) if owner_session == session => !is_live(&file),
// 外进程的:静置够久才敢动手,否则可能正在被写。
Owner::Session(_) => age >= ADOPT_MIN_AGE,
Owner::Legacy => true,
};
if adoptable && newest.is_none_or(|best| age < best) {
newest = Some(age);
scan.adopted = Some(file.clone());
}
scan.leftovers.push((file, owner, age));
}
scan
}
/// 为本次下载准备中间文件:收养可续传的旧文件,清掉本会话多余副本与无主孤儿。
///
/// 全程尽力而为:清理失败绝不影响下载本身。
fn prepare(dest: &Path, path: &Path, session: u32) {
let scan = scan(dest, session);
if let Some(found) = scan.adopted {
// 同一目录内改名,跨进程也是原子的:谁先动手谁拿到这份续传材料。
if found != *path && fs::rename(&found, path).is_ok() {
let meta = sibling(&found, META_SUFFIX);
if meta.is_file() {
let _ = fs::rename(&meta, sibling(path, META_SUFFIX));
}
}
}
for (leftover, owner, age) in scan.leftovers {
if leftover == *path || is_live(&leftover) {
continue;
}
// 本会话落败的副本留着没意义;外进程的只有超龄才敢清。
if owner == Owner::Session(session) || age >= ORPHAN_MAX_AGE {
remove_family(&leftover);
}
}
for orphan in scan.orphans {
remove_family(&orphan);
}
}
/// 下载中间文件的守卫。
#[derive(Debug)]
pub struct PartFile {
path: PathBuf,
committed: bool,
preserved: bool,
}
impl PartFile {
/// 为目标路径派生中间文件(名字带进程标识,并顺手收养旧的续传材料)。
#[must_use]
pub fn for_dest(dest: &Path) -> Self {
let session = std::process::id();
let path = part_path(dest, session, next_seq());
mark_live(&path, true);
prepare(dest, &path, session);
Self {
path,
committed: false,
preserved: false,
}
}
/// 中间文件路径。
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
/// 续传元数据路径。
#[must_use]
pub fn meta_path(&self) -> PathBuf {
sibling(&self.path, META_SUFFIX)
}
/// 中间文件是否存在。
#[must_use]
pub fn exists(&self) -> bool {
self.path.is_file()
}
/// 已写入的字节数(不存在时为 0)。
#[must_use]
pub fn len(&self) -> u64 {
fs::metadata(&self.path).map_or(0, |meta| meta.len())
}
/// 是否为空(续传判定要区分「文件不存在」和「文件存在但 0 字节」)。
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// 以追加方式打开(续传)。
pub fn open_append(&self) -> io::Result<File> {
ensure_parent(&self.path)?;
OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
}
/// 以截断方式打开(从头下载)。
pub fn open_truncate(&self) -> io::Result<File> {
ensure_parent(&self.path)?;
OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&self.path)
}
/// 校验通过后提交:把中间文件 `rename` 到目标路径(同一目录,跨文件系统不会发生)。
pub fn commit(mut self, dest: &Path) -> io::Result<()> {
ensure_parent(dest)?;
fs::rename(&self.path, dest)?;
self.committed = true;
// rename 后中间文件已不存在,元数据是纯冗余,顺手清掉。
let _ = fs::remove_file(self.meta_path());
Ok(())
}
/// 主动丢弃中间文件(换源/校验失败)。
pub fn discard(&mut self) {
remove_family(&self.path);
}
/// 确保中间文件的父目录存在(异步版)。
///
/// 单连接路径由 [`Self::open_append`] / [`Self::open_truncate`] 顺手建目录,
/// 但**分块路径**是先 `tokio::fs::OpenOptions` 预分配整文件、再并发写段——
/// 它不走那两个入口,所以得显式补这一步,否则「下到还不存在的目录」会全部源失败
/// (现场故障:`data/java-managed/.cache/` 尚未创建时 6 个候选源瞬间全挂)。
pub(crate) async fn ensure_parent_async(&self) -> io::Result<()> {
let Some(parent) = self.path.parent() else {
return Ok(());
};
if parent.as_os_str().is_empty() {
return Ok(());
}
tokio::fs::create_dir_all(parent).await
}
/// 保留中间文件(跨会话续传用):`Drop` 时不再清理。
pub fn preserve(&mut self) {
self.preserved = true;
}
}
impl Drop for PartFile {
fn drop(&mut self) {
if !self.committed && !self.preserved {
remove_family(&self.path);
}
mark_live(&self.path, false);
}
}
/// 原子写小文件:`<path>.tmp-<session>-<seq>` → `rename(path)`。
///
/// 临时名同样带进程标识:两个进程同时写同一份小文件(健康度、续传元数据)时
/// 不会互相把对方的临时文件 `rename` 走。
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
ensure_parent(path)?;
let tmp = sibling(path, &format!(".tmp-{}-{}", std::process::id(), next_seq()));
{
let mut file = File::create(&tmp)?;
file.write_all(bytes)?;
file.flush()?;
file.sync_all()?;
}
fs::rename(&tmp, path).inspect_err(|_| {
let _ = fs::remove_file(&tmp);
})
}
fn ensure_parent(path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent)?;
}
}
Ok(())
}
+120
View File
@@ -0,0 +1,120 @@
//! 指数退避 + 抖动。
//!
//! 修 HMCL 的坑(analyse/03 §7.6 第 5 点):它的重试循环里没有 sleep,5xx 会变成
//! 「6 次瞬时连打」,把弱网镜像直接压垮。这里给出 1s / 2s / 4s… 上限 30s,
//! ±30% 抖动打散多实例/多文件的同步重试风暴。
//!
//! 抖动源是自带的 xorshift64*,不引 `rand`(体积 + Win7 依赖面)。
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
/// 退避策略(可序列化进设置)。
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct BackoffPolicy {
/// 首次退避(毫秒)。
pub base_ms: u64,
/// 单次退避上限(毫秒)。
pub max_ms: u64,
/// 抖动比例(0.0~1.0,0.3 = ±30%)。
pub jitter_ratio: f64,
/// 每个源的尝试次数上限。
pub max_attempts: u32,
}
impl BackoffPolicy {
/// 不重试(一次失败立刻换源)。
#[must_use]
pub const fn none() -> Self {
Self {
base_ms: 0,
max_ms: 0,
jitter_ratio: 0.0,
max_attempts: 1,
}
}
/// 第 `attempt` 次重试前的**无抖动**等待时间(`attempt` 从 0 起)。
#[must_use]
pub fn delay(&self, attempt: u32) -> Duration {
let factor = 1u64.checked_shl(attempt.min(32)).unwrap_or(u64::MAX).max(1);
Duration::from_millis(self.base_ms.saturating_mul(factor).min(self.max_ms))
}
/// 第 `attempt` 次重试前的等待时间,`rng` 为 `[0,1)` 的抖动量(可复现,便于单测)。
///
/// 结果永不超过 `max_ms`,且永不低于 `base_ms`(当 `base_ms <= max_ms` 时)。
#[must_use]
pub fn delay_jittered(&self, attempt: u32, rng: f64) -> Duration {
let base_ms = self.delay(attempt).as_millis() as f64;
let ratio = self.jitter_ratio.clamp(0.0, 1.0);
let factor = 1.0 + ratio * (2.0 * rng.clamp(0.0, 1.0) - 1.0);
let ceiling = (self.max_ms as f64).max(base_ms);
let jittered_ms = (base_ms * factor).clamp(0.0, ceiling);
Duration::from_millis(jittered_ms as u64)
}
/// 用抖动源取一次等待时间。
#[must_use]
pub fn next_delay(&self, attempt: u32, jitter: &mut Jitter) -> Duration {
self.delay_jittered(attempt, jitter.next_fraction())
}
}
impl Default for BackoffPolicy {
fn default() -> Self {
Self {
base_ms: 1_000,
max_ms: 30_000,
jitter_ratio: 0.3,
max_attempts: 3,
}
}
}
/// xorshift64* 抖动源:确定性、无外部依赖。
#[derive(Debug, Clone)]
pub struct Jitter {
state: u64,
}
impl Jitter {
/// 固定种子构造(同种子的序列可复现 → 单测友好)。
#[must_use]
pub const fn new(seed: u64) -> Self {
Self {
state: if seed == 0 {
0x9E37_79B9_7F4A_7C15
} else {
seed
},
}
}
/// 下一个 64 位随机数。
pub fn next_u64(&mut self) -> u64 {
let mut x = self.state;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.state = x;
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
/// 下一个 `[0,1)` 的浮点。
pub fn next_fraction(&mut self) -> f64 {
// 取高 53 位映射到 [0,1),与 f64 的尾数精度一致。
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
}
}
impl Default for Jitter {
fn default() -> Self {
let seed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0x1234_5678_9ABC_DEF0);
Self::new(seed)
}
}
+55
View File
@@ -0,0 +1,55 @@
//! 同步桥:让**非 async 宿主**驱动异步下载引擎。
//!
//! `core::download` 的引擎是异步的(tokio + reqwest),GUI 侧自带运行时;
//! 但 headless 消费者(`crates/cli` 的 `ull`)不依赖 tokio,也不该为一次安装
//! 把整个异步运行时依赖树拉进 CLI 的 manifest(D18:CLI 依赖树保持干净)。
//!
//! 因此把「建一个短命运行时 + 跑完整段 future」这两件事收在 core 里:
//!
//! ```ignore
//! let outcome = unlucky_core::download::blocking::block_on(async { /* 规划 + 下载 */ });
//! ```
//!
//! 约定:**整段**安装流程包在一次 `block_on` 里(同一个运行时),不要每次下载都建一个——
//! reqwest 的连接池绑定在创建它的运行时上,跨运行时会退化甚至报「dispatch task is gone」。
//!
//! 这是给「单线程 CLI」用的逃生口,不是通用设施:app 层请直接用原生异步。
use std::future::Future;
/// 在一个临时 tokio 多线程运行时上跑完 `future`。
///
/// 运行时随调用结束销毁,因此适合「一条命令一次阻塞」的 CLI 生命周期。
///
/// # Panics
///
/// 运行时建不起来(系统资源耗尽)时 panic:此时进程已无法继续,早点暴露比静默降级好。
/// 在已有 tokio 运行时上下文里调用同样会 panic(tokio 不允许嵌套 `block_on`)。
pub fn block_on<F: Future>(future: F) -> F::Output {
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_name("ull-blocking")
.enable_all()
.build()
.expect("创建 tokio 运行时失败(内存/线程资源不足)");
runtime.block_on(future)
}
#[cfg(test)]
mod tests {
use super::block_on;
#[test]
fn drives_a_plain_future_to_completion() {
assert_eq!(block_on(async { 1 + 1 }), 2);
}
#[test]
fn drives_tokio_timers() {
let start = std::time::Instant::now();
block_on(async {
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
});
assert!(start.elapsed() >= std::time::Duration::from_millis(5));
}
}
+138
View File
@@ -0,0 +1,138 @@
//! 内存下载:版本清单 / 版本 JSON / 资源索引这类「小文本」资源。
//!
//! 与文件下载共用候选源、退避与健康度逻辑,但不续传、不分块——它们要么很小,
//! 要么强时效(`ETag` 语义比续传更合适)。
use std::time::Instant;
use super::cache::now_ms;
use super::engine::{millis, DownloadEngine};
use super::error::{DownloadError, DownloadErrorKind};
use super::http;
use super::progress::ProgressEvent;
use super::types::{Candidate, DownloadId, DownloadRequest};
use super::verify;
/// 内存下载的上限(manifest 类资源不该有几十 MB;防御恶意镜像把人撑爆)。
pub const MAX_IN_MEMORY_BYTES: u64 = 64 * 1024 * 1024;
impl DownloadEngine {
/// 内存下载(版本清单 / 版本 JSON / 索引等小文本资源)。
pub async fn download_bytes(&self, req: DownloadRequest) -> Result<Vec<u8>, DownloadError> {
let started = Instant::now();
let id = DownloadId::next();
req.validate()?;
let plan = self.plan(&req, now_ms());
self.announce(&id, &req, plan.len());
if plan.is_empty() {
return Err(DownloadError::new(
DownloadErrorKind::InvalidRequest,
format!("没有可用候选源:{}", req.url),
));
}
let _permit = self.acquire().await?;
let mut suppressed: Vec<String> = Vec::new();
for candidate in &plan {
// 与文件下载同一套规则:冷却中/刚出冷却的源只给一次探测机会。
let max_attempts = self.attempts_for(&candidate.source, req.kind, now_ms());
for attempt in 0..max_attempts {
match self.fetch_bytes(&req, candidate).await {
Ok(bytes) => {
self.health.record_success(
&candidate.source,
req.kind,
started.elapsed(),
now_ms(),
);
self.emit(ProgressEvent::Completed {
id,
bytes: bytes.len() as u64,
from_cache: false,
source: candidate.source.clone(),
elapsed_ms: millis(started.elapsed()),
});
self.flush_health();
return Ok(bytes);
}
Err(err) => {
self.health.record_failure(
&candidate.source,
req.kind,
&err.message,
now_ms(),
);
let retry = err.kind.is_retryable() && attempt + 1 < max_attempts;
let delay = self.next_delay(attempt);
self.emit(ProgressEvent::SourceFailed {
id,
source: candidate.source.clone(),
reason: err.message.clone(),
attempt,
will_retry: retry,
retry_in_ms: if retry { millis(delay) } else { 0 },
});
if !retry {
suppressed.push(format!("{}: {}", candidate.source, err.message));
break;
}
tokio::time::sleep(delay).await;
}
}
}
}
self.flush_health();
Err(DownloadError::new(
DownloadErrorKind::AllSourcesFailed,
format!("所有候选源都取不到文本资源:{}", req.url),
)
.with_suppressed(suppressed))
}
async fn fetch_bytes(
&self,
req: &DownloadRequest,
candidate: &Candidate,
) -> Result<Vec<u8>, DownloadError> {
let response = self
.http
.get(&candidate.url)
.send()
.await
.map_err(|err| http::classify_reqwest(&err, &candidate.url))?;
let status = response.status();
if !status.is_success() {
return Err(http::classify_status(status.as_u16(), &candidate.url)
.with_source(candidate.source.as_str()));
}
if let Some(len) = http::content_length(response.headers()) {
if len > MAX_IN_MEMORY_BYTES {
return Err(DownloadError::new(
DownloadErrorKind::InvalidRequest,
format!(
"文本资源过大({len} 字节),拒绝载入内存:{}",
candidate.url
),
)
.with_source(candidate.source.as_str()));
}
}
let body = response
.bytes()
.await
.map_err(|err| http::classify_reqwest(&err, &candidate.url))?;
if let Some(integrity) = &req.integrity {
let actual = verify::digest_bytes(integrity.algo, &body);
if !verify::matches(&integrity.hex, &actual) {
return Err(DownloadError::new(
DownloadErrorKind::Integrity,
format!("校验不符:期望 {} 实得 {actual}", integrity.hex),
)
.with_source(candidate.source.as_str()));
}
}
Ok(body.to_vec())
}
}
+285
View File
@@ -0,0 +1,285 @@
//! 内容寻址缓存:两级分片 + 「记录信任」命中快路径。
//!
//! 布局承设计文档 `04 §2` 与 analyse/03 §5.7:`<cache>/sha1/<hash[0..2]>/<hash>`(两级分片,
//! 避开 NTFS 单目录几十万条目塌陷)。
//!
//! 修 HMCL 的坑(analyse/03 §8.2 第 7 点):它每次命中都重算整个文件的 SHA-1,
//! 3000 个资源对象意味着启动期几百 MB 的无谓读盘。这里写入时记录 `{hash, size, mtime}`,
//! **此后信任记录**:size/mtime 都没变就直接命中;只有元数据变了才重算一次摘要并刷新记录。
use std::collections::BTreeMap;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use super::atomic::write_atomic;
use super::types::{HashAlgo, Integrity};
use super::verify;
/// 索引文件名(放在缓存根下)。
pub const INDEX_FILE: &str = "index.json";
/// 索引格式版本。
pub const INDEX_VERSION: u32 = 1;
/// 一条缓存记录。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CacheRecord {
/// 算法。
pub algo: HashAlgo,
/// 摘要(小写十六进制)。
pub hash: String,
/// 字节数。
pub size: u64,
/// 文件修改时间(unix 毫秒)——记录信任的锚点。
pub mtime_ms: u64,
/// 写入缓存时间。
pub cached_at_ms: u64,
/// 最近一次真正校验的时间(0 = 从未校验,走记录信任)。
pub last_verified_ms: u64,
}
/// 缓存统计(诊断页)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct CacheStats {
/// 条目数。
pub entries: usize,
/// 总字节数。
pub total_bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CacheIndex {
version: u32,
#[serde(default)]
entries: BTreeMap<String, CacheRecord>,
}
impl Default for CacheIndex {
fn default() -> Self {
Self {
version: INDEX_VERSION,
entries: BTreeMap::new(),
}
}
}
/// 内容寻址缓存。
#[derive(Debug)]
pub struct ContentCache {
root: PathBuf,
index: Mutex<CacheIndex>,
}
impl ContentCache {
/// 打开(或初始化)缓存根目录;索引损坏时从空表重建(缓存随时可丢,不阻断启动)。
#[must_use]
pub fn open(root: impl Into<PathBuf>) -> Self {
let root = root.into();
let index_path = root.join(INDEX_FILE);
let index = std::fs::read(&index_path)
.ok()
.and_then(|bytes| serde_json::from_slice::<CacheIndex>(&bytes).ok())
.unwrap_or_default();
Self {
root,
index: Mutex::new(index),
}
}
/// 缓存根目录。
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
/// 某个摘要对应的缓存路径(两级分片)。
#[must_use]
pub fn path_for(&self, algo: HashAlgo, hash: &str) -> PathBuf {
let shard = hash.get(..2).unwrap_or(hash);
self.root.join(algo.as_str()).join(shard).join(hash)
}
fn lock(&self) -> std::sync::MutexGuard<'_, CacheIndex> {
self.index
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn key(algo: HashAlgo, hash: &str) -> String {
format!("{}:{hash}", algo.as_str())
}
/// 命中查询(记录信任:size + mtime 未变则**不重算**摘要)。
#[must_use]
pub fn lookup(&self, integrity: &Integrity, now_ms: u64) -> Option<PathBuf> {
let path = self.path_for(integrity.algo, &integrity.hex);
let record = self
.lock()
.entries
.get(&Self::key(integrity.algo, &integrity.hex))
.cloned()?;
let Ok(meta) = std::fs::metadata(&path) else {
// 文件被删:记录作废,避免每次命中都白查一次磁盘。
self.forget(integrity);
return None;
};
if !meta.is_file() || meta.len() != record.size {
// 文件被删/被截断:记录作废。
self.forget(integrity);
return None;
}
let mtime_ms = mtime_ms(&meta);
if mtime_ms == record.mtime_ms {
return Some(path);
}
// 元数据变了(外部动过文件):重算一次摘要再决定。
let Ok(actual) = verify::digest_file(&path, integrity.algo) else {
self.forget(integrity);
return None;
};
if !verify::matches(&integrity.hex, &actual) {
self.forget(integrity);
return None;
}
self.refresh_record(integrity, &meta, now_ms);
Some(path)
}
/// 强制校验的命中查询(抽样/定期后台校验用,慢但可信)。
#[must_use]
pub fn lookup_verified(&self, integrity: &Integrity, now_ms: u64) -> Option<PathBuf> {
let path = self.path_for(integrity.algo, &integrity.hex);
let meta = std::fs::metadata(&path).ok()?;
if !meta.is_file() {
self.forget(integrity);
return None;
}
let actual = verify::digest_file(&path, integrity.algo).ok()?;
if !verify::matches(&integrity.hex, &actual) {
self.forget(integrity);
return None;
}
self.refresh_record(integrity, &meta, now_ms);
Some(path)
}
/// 把已校验通过的文件放进缓存(硬链接优先,跨卷退化为复制)。
pub fn insert(&self, source: &Path, integrity: &Integrity, now_ms: u64) -> io::Result<PathBuf> {
let target = self.path_for(integrity.algo, &integrity.hex);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
if !target.is_file() {
link_or_copy(source, &target)?;
}
self.register(&target, integrity, now_ms)?;
Ok(target)
}
/// 登记一个已在缓存位的文件(`assets` 已在游戏目录里的补登记场景)。
pub fn register(&self, path: &Path, integrity: &Integrity, now_ms: u64) -> io::Result<()> {
let meta = std::fs::metadata(path)?;
let record = CacheRecord {
algo: integrity.algo,
hash: integrity.hex.clone(),
size: meta.len(),
mtime_ms: mtime_ms(&meta),
cached_at_ms: now_ms,
last_verified_ms: now_ms,
};
self.lock()
.entries
.insert(Self::key(integrity.algo, &integrity.hex), record);
Ok(())
}
/// 删除一条缓存(含文件)。
pub fn remove(&self, integrity: &Integrity) -> io::Result<()> {
let path = self.path_for(integrity.algo, &integrity.hex);
self.forget(integrity);
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err),
}
}
fn forget(&self, integrity: &Integrity) {
self.lock()
.entries
.remove(&Self::key(integrity.algo, &integrity.hex));
}
fn refresh_record(&self, integrity: &Integrity, meta: &std::fs::Metadata, now_ms: u64) {
if let Some(record) = self
.lock()
.entries
.get_mut(&Self::key(integrity.algo, &integrity.hex))
{
record.size = meta.len();
record.mtime_ms = mtime_ms(meta);
record.last_verified_ms = now_ms;
}
}
/// 统计。
#[must_use]
pub fn stats(&self) -> CacheStats {
let index = self.lock();
CacheStats {
entries: index.entries.len(),
total_bytes: index.entries.values().map(|record| record.size).sum(),
}
}
/// 索引条目数(`stats` 的便捷版本)。
#[must_use]
pub fn len(&self) -> usize {
self.lock().entries.len()
}
/// 索引是否为空。
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// 原子持久化索引(`cache/index.json`)。
pub fn persist(&self) -> io::Result<()> {
let json = {
let index = self.lock();
serde_json::to_vec_pretty(&*index).map_err(io::Error::other)?
};
write_atomic(&self.root.join(INDEX_FILE), &json)
}
}
/// 硬链接优先,失败退化为复制(跨卷/权限不足时)。
fn link_or_copy(source: &Path, target: &Path) -> io::Result<()> {
match std::fs::hard_link(source, target) {
Ok(()) => Ok(()),
Err(_) => std::fs::copy(source, target).map(|_| ()),
}
}
fn mtime_ms(meta: &std::fs::Metadata) -> u64 {
meta.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map_or(0, |since| {
u64::try_from(since.as_millis()).unwrap_or(u64::MAX)
})
}
/// 当前 unix 毫秒(引擎统一取一次再往下传,避免测试里到处读时钟)。
#[must_use]
pub fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |since| {
u64::try_from(since.as_millis()).unwrap_or(u64::MAX)
})
}
+296
View File
@@ -0,0 +1,296 @@
//! 大文件多连接分块下载:分块规划、`Content-Range` 解析、Range 能力判定与单块抓取。
//!
//! HMCL 只做「单文件单连接 + 全局并发闸门」(analyse/03 §5.5),大 jar 会抢占许可并形成
//! 头阻塞。这里对「大文件 + 支持 Range」的资源开 N 条连接并行取段(默认 N=4),
//! 每条连接写进同一个**预分配**中间文件的不同偏移;服务器不支持 Range(返回 200 而不是 206)
//! 时由引擎自动退化为单连接。
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use super::error::{DownloadError, DownloadErrorKind};
use super::http;
use super::types::ResourceKind;
/// 默认分块连接数(设计文档 Q19 定案 N=4)。
pub const DEFAULT_CONNECTIONS: usize = 4;
/// 分块连接数上限(再多收益递减且容易被镜像限流)。
pub const MAX_CONNECTIONS: usize = 16;
/// 默认开启分块的最小文件大小(8 MiB)。
pub const DEFAULT_MIN_SIZE: u64 = 8 * 1024 * 1024;
/// 分块下载配置。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ChunkedConfig {
/// 总开关(设置项)。
pub enabled: bool,
/// 并行连接数。
pub connections: usize,
/// 小于该大小不做分块。
pub min_size: u64,
}
impl Default for ChunkedConfig {
fn default() -> Self {
Self {
enabled: true,
connections: DEFAULT_CONNECTIONS,
min_size: DEFAULT_MIN_SIZE,
}
}
}
impl ChunkedConfig {
/// 关闭分块。
#[must_use]
pub const fn disabled() -> Self {
Self {
enabled: false,
connections: 1,
min_size: DEFAULT_MIN_SIZE,
}
}
/// 该资源当前是否走分块。
#[must_use]
pub fn applies_to(&self, kind: ResourceKind, size: Option<u64>) -> bool {
self.enabled
&& self.connections > 1
&& kind.prefers_chunked()
&& size.is_some_and(|size| size >= self.min_size)
}
/// 实际生效的连接数(夹在 `1..=MAX_CONNECTIONS`)。
#[must_use]
pub fn effective_connections(&self) -> usize {
self.connections.clamp(1, MAX_CONNECTIONS)
}
}
/// 闭区间字节段(HTTP Range 的语义是闭区间,这里保持一致免得 +1/-1 到处飞)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ByteRange {
/// 起始偏移(含)。
pub start: u64,
/// 结束偏移(**含**)。
pub end: u64,
}
impl ByteRange {
/// 构造。
#[must_use]
pub const fn new(start: u64, end: u64) -> Self {
Self { start, end }
}
/// 段长度(字节)。
#[must_use]
pub const fn len(&self) -> u64 {
if self.end < self.start {
0
} else {
self.end - self.start + 1
}
}
/// 空段。
#[must_use]
pub const fn is_empty(&self) -> bool {
self.end < self.start
}
/// `Range` 头的值。
#[must_use]
pub fn header_value(&self) -> String {
format!("bytes={}-{}", self.start, self.end)
}
}
/// 把 `total` 均分成不超过 `connections` 段(纯函数,单测重点)。
///
/// 保证:段序列连续、无重叠、并集恰为 `[0, total)`;段数 = `min(connections, total)`。
#[must_use]
pub fn plan(total: u64, connections: usize) -> Vec<ByteRange> {
if total == 0 {
return Vec::new();
}
let parts = connections.clamp(1, MAX_CONNECTIONS) as u64;
let count = parts.min(total);
let base = total / count;
let remainder = total % count;
let mut ranges = Vec::with_capacity(count as usize);
let mut offset = 0_u64;
for index in 0..count {
// 余数摊到前几段,避免最后一段特别大。
let extra = u64::from(index < remainder);
let len = base + extra;
if len == 0 {
continue;
}
ranges.push(ByteRange::new(offset, offset + len - 1));
offset += len;
}
ranges
}
/// `Content-Range: bytes 0-99/1000` 的解析结果。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ContentRange {
/// 起始。
pub start: u64,
/// 结束(含)。
pub end: u64,
/// 总长度(`*` 时为 `None`)。
pub total: Option<u64>,
}
/// 解析 `Content-Range`(畸形一律 `None`,调用方退化处理)。
#[must_use]
pub fn parse_content_range(value: &str) -> Option<ContentRange> {
let rest = value.trim().strip_prefix("bytes")?.trim_start();
let (range, total) = rest.split_once('/')?;
let (start, end) = range.split_once('-')?;
let start: u64 = start.trim().parse().ok()?;
let end: u64 = end.trim().parse().ok()?;
if end < start {
return None;
}
let total = match total.trim() {
"*" => None,
other => Some(other.parse().ok()?),
};
Some(ContentRange { start, end, total })
}
/// 服务器对 Range 的支持度。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RangeSupport {
/// 支持(`Accept-Ranges: bytes` 或正常返回 206)。
Supported,
/// 明确不支持。
Unsupported,
/// 未知(既没头也没探测)。
Unknown,
}
/// 判定 Range 支持度。
#[must_use]
pub fn range_support(accept_ranges: Option<&str>, status: u16) -> RangeSupport {
if status == 206 {
return RangeSupport::Supported;
}
match accept_ranges {
Some(value) if value.to_ascii_lowercase().contains("bytes") => RangeSupport::Supported,
Some(value) if value.to_ascii_lowercase().contains("none") => RangeSupport::Unsupported,
Some(_) => RangeSupport::Unsupported,
None => RangeSupport::Unknown,
}
}
/// 206 响应的 `Content-Range` 是否与请求段一致。
///
/// * 缺头 → 宽松放过(个别服务器省略该头,后面的长度校验仍能兜底);
/// * 有头 → 起止必须严格等于请求段,畸形头一律判不符。
///
/// 这条防御与单连接路径(`transfer.rs` 里比对 `content_range.start != offset`)对齐:
/// 只在「长度恰好」时坏文件才会溜过长度校验,起止比对把这条路堵死。
#[must_use]
pub fn content_range_matches(requested: ByteRange, value: Option<&str>) -> bool {
let Some(value) = value else {
return true;
};
parse_content_range(value)
.is_some_and(|parsed| parsed.start == requested.start && parsed.end == requested.end)
}
/// 抓一个段,写到预分配文件的 `range.start` 偏移处,并把字节数累加进 `progress`。
///
/// 服务器若对带 `Range` 的请求返回 200(无视 Range),立刻返回 `RangeUnsupported`
/// —— 引擎据此退化单连接,而不是把整份文件重复写 N 遍。
pub async fn fetch_chunk(
client: &reqwest::Client,
url: &str,
range: ByteRange,
part: &Path,
read_timeout: Duration,
progress: &AtomicU64,
) -> Result<u64, DownloadError> {
use tokio::io::{AsyncSeekExt, AsyncWriteExt};
let request = client
.get(url)
.header(reqwest::header::RANGE, range.header_value());
let response = request
.send()
.await
.map_err(|err| http::classify_reqwest(&err, url))?;
let status = response.status().as_u16();
if status == 200 {
return Err(DownloadError::new(
DownloadErrorKind::RangeUnsupported,
format!("服务器忽略 Range 请求({url})"),
));
}
if status != 206 {
return Err(http::classify_status(status, url));
}
// 服务器可能「回 206 但段不对」(CDN 缓存错位、Range 实现有 bug)。
// 判不符时按 RangeUnsupported 处理:引擎会丢弃中间文件、退化单连接重下,
// 这比在同一条坏路径上重试更划算。
let content_range = http::header_str(response.headers(), reqwest::header::CONTENT_RANGE);
if !content_range_matches(range, content_range) {
return Err(DownloadError::new(
DownloadErrorKind::RangeUnsupported,
format!(
"Content-Range 与请求段不符({url}):请求 {},响应 {}",
range.header_value(),
content_range.unwrap_or("<缺失>")
),
));
}
let mut file = tokio::fs::OpenOptions::new()
.write(true)
.open(part)
.await
.map_err(|err| io_error(&err, "打开分块中间文件失败"))?;
file.seek(std::io::SeekFrom::Start(range.start))
.await
.map_err(|err| io_error(&err, "定位分块偏移失败"))?;
let mut stream = response.bytes_stream();
let mut written = 0_u64;
loop {
let next = tokio::time::timeout(read_timeout, futures_util::StreamExt::next(&mut stream))
.await
.map_err(|_| {
DownloadError::new(DownloadErrorKind::Timeout, format!("分块读取超时({url})"))
})?;
let Some(chunk) = next else { break };
let chunk = chunk.map_err(|err| http::classify_reqwest(&err, url))?;
file.write_all(&chunk)
.await
.map_err(|err| io_error(&err, "写入分块失败"))?;
let len = chunk.len() as u64;
written += len;
progress.fetch_add(len, Ordering::Relaxed);
}
file.flush()
.await
.map_err(|err| io_error(&err, "刷新分块失败"))?;
if written != range.len() {
return Err(DownloadError::new(
DownloadErrorKind::Network,
format!("分块长度不符:期望 {} 实得 {written}({url})", range.len()),
));
}
Ok(written)
}
fn io_error(err: &std::io::Error, context: &str) -> DownloadError {
DownloadError::new(DownloadErrorKind::Io, format!("{context}: {err}"))
}
+46
View File
@@ -0,0 +1,46 @@
//! 引擎配置:设置层(core::setting)直接映射到这里。
use std::time::Duration;
use super::backoff::BackoffPolicy;
use super::chunked::ChunkedConfig;
use super::types::ResumePolicy;
/// 引擎配置。
#[derive(Debug, Clone)]
pub struct EngineConfig {
/// 全局并发许可数(tokio `Semaphore`),默认 64。
pub max_concurrency: usize,
/// 每个源的尝试次数上限,默认 3。
pub max_attempts_per_source: u32,
/// 连接超时,默认 10s。
pub connect_timeout: Duration,
/// 单次读超时(读空闲判定,不是整体超时),默认 30s。
pub read_timeout: Duration,
/// User-Agent。
pub user_agent: String,
/// 进度事件节流间隔,默认 250ms。
pub progress_throttle: Duration,
/// 退避策略。
pub backoff: BackoffPolicy,
/// 分块下载配置。
pub chunked: ChunkedConfig,
/// 默认续传策略(请求级 `Never` 优先级更高)。
pub resume: ResumePolicy,
}
impl Default for EngineConfig {
fn default() -> Self {
Self {
max_concurrency: 64,
max_attempts_per_source: 3,
connect_timeout: Duration::from_secs(10),
read_timeout: Duration::from_secs(30),
user_agent: concat!("UnluckyLauncher/", env!("CARGO_PKG_VERSION")).to_string(),
progress_throttle: Duration::from_millis(250),
backoff: BackoffPolicy::default(),
chunked: ChunkedConfig::default(),
resume: ResumePolicy::Conservative,
}
}
}
+397
View File
@@ -0,0 +1,397 @@
//! 下载引擎:编排层。
//!
//! 一次 `download()` 的固定剧情:
//!
//! ```text
//! 校验请求 → 内容寻址缓存直取(零网络)
//! → 候选规划(镜像表展开 + 健康度排序)
//! → Semaphore 限流
//! → 候选源外循环(换源),每源内循环(指数退避 + 抖动)
//! → 续传判定(保守)→ 单连接 / 多连接分块 → 流式校验 → 原子落盘
//! → 失败则保留中间文件供下次续传
//! ```
//!
//! 最后一步保留的是**本会话命名的**中间文件(`<name>.ullpart-<session>-<seq>`,不再固定
//! 叫 `<name>.ullpart`):下次(甚至下次启动、重启后再下同一个文件)由 `PartFile::for_dest`
//! 在目标目录里认领它并改名成新的会话名,续传判定照旧。详细身份规则见 `atomic.rs` 模块头的
//! 「中间文件的身份」。
//!
//! 具体传输细节在 `transfer.rs`,错误分类在 `http.rs`,本文件只负责顺序与策略。
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Semaphore;
use super::atomic::PartFile;
use super::backoff::Jitter;
use super::cache::{now_ms, ContentCache};
use super::config::EngineConfig;
use super::error::{DownloadError, DownloadErrorKind};
use super::health::HealthRegistry;
use super::http;
use super::mirrors::MirrorTable;
use super::progress::{ProgressEmitter, ProgressEvent};
use super::region::RegionProfile;
use super::types::{
Candidate, DownloadId, DownloadOutcome, DownloadRequest, ResourceKind, ResumePolicy,
SOURCE_OFFICIAL,
};
/// 一次成功尝试的产物描述。
pub(crate) struct AttemptOk {
/// 文件总字节数。
pub bytes: u64,
/// 续传起点(0 = 从头下)。
pub resumed_from: u64,
}
/// 下载引擎(`Arc` 后全局共享;内部无状态以外的可变数据只有抖动源)。
#[derive(Debug)]
pub struct DownloadEngine {
pub(crate) http: reqwest::Client,
pub(crate) cfg: EngineConfig,
pub(crate) progress: ProgressEmitter,
pub(crate) health: Arc<HealthRegistry>,
pub(crate) cache: Arc<ContentCache>,
pub(crate) jitter: std::sync::Mutex<Jitter>,
mirrors: Arc<MirrorTable>,
region: Arc<RegionProfile>,
sem: Arc<Semaphore>,
}
impl DownloadEngine {
/// 构造引擎(用 `with_progress` 挂事件流)。
#[must_use]
pub fn new(
cfg: EngineConfig,
mirrors: Arc<MirrorTable>,
region: Arc<RegionProfile>,
health: Arc<HealthRegistry>,
cache: Arc<ContentCache>,
) -> Self {
let http = http::build_client(&cfg).unwrap_or_else(|_| reqwest::Client::new());
let sem = Arc::new(Semaphore::new(cfg.max_concurrency.max(1)));
let seed = now_ms() ^ (u64::from(cfg.max_concurrency as u32) << 32);
Self {
http,
cfg,
progress: ProgressEmitter::silent(),
health,
cache,
jitter: std::sync::Mutex::new(Jitter::new(seed)),
mirrors,
region,
sem,
}
}
/// 挂上进度事件发射器。
#[must_use]
pub fn with_progress(mut self, progress: ProgressEmitter) -> Self {
self.progress = progress;
self
}
/// 配置。
#[must_use]
pub fn config(&self) -> &EngineConfig {
&self.cfg
}
/// HTTP 客户端(同一条连接池与超时配置)。
///
/// 给「一锤子查询」用——例如 Adoptium 的版本查询(`core::game::java::api`):
/// 没必要为了一个 GET 再造一个 client,代理/超时/证书栈都在这里统一。
#[must_use]
pub fn client(&self) -> &reqwest::Client {
&self.http
}
/// 当前地区档案(设置页展示)。
#[must_use]
pub fn region(&self) -> &RegionProfile {
&self.region
}
/// 镜像表(`game::java` 的 Oracle / MS / Zulu 分发表在这里查)。
#[must_use]
pub fn mirrors(&self) -> &MirrorTable {
&self.mirrors
}
/// 健康度注册表(诊断页)。
#[must_use]
pub fn health(&self) -> &HealthRegistry {
&self.health
}
/// 内容寻址缓存。
#[must_use]
pub fn cache(&self) -> &ContentCache {
&self.cache
}
/// 候选规划:镜像展开 → 官方位置保持不变 → 其余按健康度排序。
#[must_use]
pub fn plan(&self, req: &DownloadRequest, now_ms: u64) -> Vec<Candidate> {
let expanded = self.mirrors.expand(&req.url, req.kind, &self.region);
let official_first = expanded
.first()
.is_none_or(|candidate| candidate.source == SOURCE_OFFICIAL);
let mut official = Vec::new();
let mut mirrors = Vec::new();
for candidate in expanded {
if candidate.source == SOURCE_OFFICIAL {
official.push(candidate);
} else {
mirrors.push(candidate);
}
}
let ranked = self.health.rank(&mirrors, req.kind, now_ms);
if official_first {
official.extend(ranked);
official
} else {
let mut out = ranked;
out.extend(official);
out
}
}
/// 单文件下载。
pub async fn download(
&self,
mut req: DownloadRequest,
) -> Result<DownloadOutcome, DownloadError> {
let started = Instant::now();
let id = DownloadId::next();
req.validate()?;
if self.cfg.resume == ResumePolicy::Never {
req.resume = ResumePolicy::Never;
}
if let Some(integrity) = req.integrity.clone() {
if let Some(cached) = self.cache.lookup(&integrity, now_ms()) {
let bytes = materialize(&cached, &req.dest).map_err(|err| {
DownloadError::new(
DownloadErrorKind::Io,
format!("缓存直取失败({}):{err}", req.dest.display()),
)
})?;
self.emit(ProgressEvent::Completed {
id,
bytes,
from_cache: true,
source: "cache".to_string(),
elapsed_ms: millis(started.elapsed()),
});
return Ok(DownloadOutcome {
id,
path: req.dest,
bytes,
from_cache: true,
source: "cache".to_string(),
resumed_from: 0,
elapsed: started.elapsed(),
});
}
}
let plan = self.plan(&req, now_ms());
self.announce(&id, &req, plan.len());
if plan.is_empty() {
return Err(DownloadError::new(
DownloadErrorKind::InvalidRequest,
format!("没有可用候选源:{}", req.url),
));
}
let _permit = self.acquire().await?;
let mut part = PartFile::for_dest(&req.dest);
let mut suppressed: Vec<String> = Vec::new();
let mut previous: Option<String> = None;
for candidate in &plan {
if let Some(from) = previous.take() {
self.emit(ProgressEvent::SourceSwitched {
id,
from,
to: candidate.source.clone(),
});
}
// 冷却中 / 刚出冷却的源只给一次机会(那就是「恢复探测」)。
let max_attempts = self.attempts_for(&candidate.source, req.kind, now_ms());
for attempt in 0..max_attempts {
let attempt_started = Instant::now();
let outcome = self.attempt(id, &req, candidate, &mut part).await;
match outcome {
Ok(ok) => {
self.health.record_success(
&candidate.source,
req.kind,
attempt_started.elapsed(),
now_ms(),
);
let outcome = self.finish(&req, part, id, candidate, ok, started)?;
self.flush_health();
return Ok(outcome);
}
Err(err) => {
// 4xx(NotFound)不算「源坏」:院校镜像只留最新补丁,请求旧补丁必然 404,
// 记成失败会把唯一可用镜像冷却掉(M33/M34 实测:TUNA 每个目录只保 1 个归档)。
if err.kind == DownloadErrorKind::NotFound {
self.health.record_missing(
&candidate.source,
req.kind,
&err.message,
now_ms(),
);
} else {
self.health.record_failure(
&candidate.source,
req.kind,
&err.message,
now_ms(),
);
}
let retry = err.kind.is_retryable() && attempt + 1 < max_attempts;
let delay = self.next_delay(attempt);
self.emit(ProgressEvent::SourceFailed {
id,
source: candidate.source.clone(),
reason: err.message.clone(),
attempt,
will_retry: retry,
retry_in_ms: if retry { millis(delay) } else { 0 },
});
if !retry {
suppressed.push(format!("{}: {}", candidate.source, err.message));
break;
}
tokio::time::sleep(delay).await;
}
}
}
previous = Some(candidate.source.clone());
}
// 全部候选失败:保留中间文件与元数据,下次(甚至下次启动)可续传。
part.preserve();
self.flush_health();
Err(DownloadError::new(
DownloadErrorKind::AllSourcesFailed,
format!("{} 个候选源全部失败:{}", plan.len(), req.url),
)
.with_suppressed(suppressed))
}
fn finish(
&self,
req: &DownloadRequest,
part: PartFile,
id: DownloadId,
candidate: &Candidate,
ok: AttemptOk,
started: Instant,
) -> Result<DownloadOutcome, DownloadError> {
if let Some(integrity) = &req.integrity {
// 缓存写入失败不影响交付(缓存纯属加速)。
let _ = self.cache.insert(part.path(), integrity, now_ms());
let _ = self.cache.persist();
}
part.commit(&req.dest).map_err(|err| {
DownloadError::new(
DownloadErrorKind::Io,
format!("原子落盘失败({}):{err}", req.dest.display()),
)
})?;
let elapsed = started.elapsed();
self.emit(ProgressEvent::Completed {
id,
bytes: ok.bytes,
from_cache: false,
source: candidate.source.clone(),
elapsed_ms: millis(elapsed),
});
Ok(DownloadOutcome {
id,
path: req.dest.clone(),
bytes: ok.bytes,
from_cache: false,
source: candidate.source.clone(),
resumed_from: ok.resumed_from,
elapsed,
})
}
pub(crate) fn announce(&self, id: &DownloadId, req: &DownloadRequest, candidates: usize) {
self.emit(ProgressEvent::Started {
id: *id,
kind: req.kind,
url: req.url.clone(),
dest: req.dest.display().to_string(),
candidates,
total_bytes: req.expected_size,
});
}
pub(crate) async fn acquire(&self) -> Result<tokio::sync::OwnedSemaphorePermit, DownloadError> {
Arc::clone(&self.sem).acquire_owned().await.map_err(|_| {
DownloadError::new(DownloadErrorKind::Io, "并发闸门已关闭(引擎正在销毁)")
})
}
/// 该源此刻给几次尝试:
///
/// * 冷却中 → 1 次(这次就是「恢复探测」,失败会重新冷却);
/// * 刚出冷却、还没洗白 → 也只给 1 次(`is_probe()`,别让死源每次到期都吃满 3 次);
/// * 正常 → 配置的 `max_attempts_per_source`。
pub(crate) fn attempts_for(&self, source: &str, kind: ResourceKind, now_ms: u64) -> u32 {
let health = self.health.health_of(source, kind);
if health.is_cooling_down(now_ms) || health.is_probe(now_ms) {
1
} else {
self.cfg.max_attempts_per_source.max(1)
}
}
/// 一次下载收尾时把健康度节流落盘(对齐 `cache.persist()` 的位置,但带节流)。
pub(crate) fn flush_health(&self) {
let _ = self.health.flush(now_ms());
}
pub(crate) fn next_delay(&self, attempt: u32) -> Duration {
let mut jitter = self
.jitter
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
self.cfg.backoff.next_delay(attempt, &mut jitter)
}
pub(crate) fn emit(&self, event: ProgressEvent) {
self.progress.emit(event);
}
}
/// 缓存命中时把文件变成工作副本(硬链接优先,跨卷退化为复制)。
fn materialize(source: &Path, dest: &Path) -> std::io::Result<u64> {
if let Some(parent) = dest.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
if dest.exists() {
let _ = std::fs::remove_file(dest);
}
if std::fs::hard_link(source, dest).is_err() {
std::fs::copy(source, dest)?;
}
Ok(std::fs::metadata(dest)?.len())
}
pub(crate) fn millis(duration: Duration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}
+125
View File
@@ -0,0 +1,125 @@
//! 下载错误:三态分类(同源重试 / 立刻换源 / 整体失败)+ 失败摘要。
//!
//! core 不引 `thiserror`(P0 阶段少一个依赖),手写 `Display`/`Error` 足够。
use std::fmt;
use serde::{Deserialize, Serialize};
/// 错误分类:决定「同源重试」「立刻换源」还是「整体失败」。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DownloadErrorKind {
/// 请求本身不合法(不重试)。
InvalidRequest,
/// 404/410/403:该源没有这个资源,立刻换源。
NotFound,
/// 5xx / 429:同源退避重试。
ServerError,
/// 超时:同源退避重试,多次后换源。
Timeout,
/// 连接/传输层错误。
Network,
/// 校验不符:同源重试一次后换源(可能是镜像内容不一致)。
Integrity,
/// 服务器不支持 Range(内部用于退化单连接,不向上暴露为失败)。
RangeUnsupported,
/// 本地 IO 错误(不重试)。
Io,
/// 所有候选源都失败。
AllSourcesFailed,
}
impl DownloadErrorKind {
/// 稳定字符串 id。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::InvalidRequest => "invalid_request",
Self::NotFound => "not_found",
Self::ServerError => "server_error",
Self::Timeout => "timeout",
Self::Network => "network",
Self::Integrity => "integrity",
Self::RangeUnsupported => "range_unsupported",
Self::Io => "io",
Self::AllSourcesFailed => "all_sources_failed",
}
}
/// 是否值得在同源上退避重试。
#[must_use]
pub const fn is_retryable(self) -> bool {
matches!(
self,
Self::ServerError | Self::Timeout | Self::Network | Self::Integrity
)
}
}
/// 下载错误。
#[derive(Debug, Clone)]
pub struct DownloadError {
/// 分类。
pub kind: DownloadErrorKind,
/// 出错的源(整体失败时为 `None`)。
pub source: Option<String>,
/// 人类可读描述。
pub message: String,
/// 已经尝试的次数(含本次)。
pub attempts: usize,
/// 其它候选源的失败摘要,便于诊断「到底哪一层挂了」。
pub suppressed: Vec<String>,
}
impl DownloadError {
/// 构造错误。
#[must_use]
pub fn new(kind: DownloadErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
source: None,
message: message.into(),
attempts: 0,
suppressed: Vec::new(),
}
}
/// 标注出错的源。
#[must_use]
pub fn with_source(mut self, source: impl Into<String>) -> Self {
self.source = Some(source.into());
self
}
/// 标注尝试次数。
#[must_use]
pub fn with_attempts(mut self, attempts: usize) -> Self {
self.attempts = attempts;
self
}
/// 汇总其它源的失败摘要。
#[must_use]
pub fn with_suppressed(mut self, suppressed: Vec<String>) -> Self {
self.suppressed = suppressed;
self
}
}
impl fmt::Display for DownloadError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.source {
Some(source) => write!(
f,
"[{}] {}(源 {})",
self.kind.as_str(),
self.message,
source
),
None => write!(f, "[{}] {}", self.kind.as_str(), self.message),
}
}
}
impl std::error::Error for DownloadError {}
+399
View File
@@ -0,0 +1,399 @@
//! 源健康度记忆:`源 × 资源类型` 的 EMA RTT / 失败率 / 连续失败。
//!
//! HMCL 没有这一层(analyse/03 §8.4 的「下载快」一栏是纯并发 + 缓存),于是同一台机器上
//! 一个常年 502 的镜像会被每个文件重试一遍。这里的规则:
//!
//! * 连续 **3** 次失败 → 冷却 **10 分钟**,期间该源排到最后(仍保留为兜底候选);
//! * 冷却结束后自动进入**恢复探测**:下一次尝试就是一次探测,成功即清零回队,失败则重新冷却;
//! * 记录持久化到 `cache/health.json`(原子写),进程重启后仍然生效:
//! **冷却状态迁移时立刻落盘**(进入/退出冷却这种罕见事件),其余变化标脏,
//! 由引擎在一次 `download()` / `download_bytes()` 收尾时 `flush()` 节流落盘,
//! 避免「批量下 3000 个资源 = 3000 次原子写」;
//! * 排序打分 = EMA RTT + 失败率惩罚 + 连续失败惩罚,冷却中的源加一个大惩罚。
use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use super::health_store;
use super::types::{Candidate, ResourceKind, SOURCE_OFFICIAL};
/// 连续失败多少次进入冷却。
pub const FAILURE_THRESHOLD: u32 = 3;
/// 冷却时长(毫秒):连续失败后的冷板凳时间。
pub const COOLDOWN_MS: u64 = 10 * 60 * 1000;
/// EMA 的平滑系数(0.3 意味着新样本占 30%)。
pub const EMA_ALPHA: f64 = 0.3;
/// 冷板凳/未知源的基础打分。
pub const SCORE_UNKNOWN: f64 = 400.0;
/// 落盘节流:两次 flush 之间的最小间隔(毫秒)。
pub const SAVE_THROTTLE_MS: u64 = 5_000;
/// 冷却中的惩罚项。
pub const SCORE_COOLDOWN_PENALTY: f64 = 1_000.0;
/// 健康度的记忆键:一个源在一种资源类型上的表现是独立的。
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct HealthKey {
/// 源标识(`official` / `bmclapi` / `tuna` …)。
pub source: String,
/// 资源类型。
pub kind: ResourceKind,
}
impl HealthKey {
/// 构造键。
#[must_use]
pub fn new(source: impl Into<String>, kind: ResourceKind) -> Self {
Self {
source: source.into(),
kind,
}
}
}
/// 单个 `源 × 资源类型` 的健康记录。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SourceHealth {
/// 成功请求的 RTT 指数移动平均(毫秒);无样本时为 0。
pub ema_rtt_ms: f64,
/// 参与 EMA 的样本数。
pub samples: u64,
/// 累计成功次数。
pub successes: u64,
/// 累计失败次数。
pub failures: u64,
/// 累计「该源没有这个资源」(4xx)。**不计失败、不进冷却**——院校镜像通常只留最新补丁,
/// 请求旧补丁必然 404,把这种情况算成「源坏」会在一次安装里把唯一可用镜像冷却掉。
#[serde(default)]
pub not_found: u64,
/// 连续失败次数。
pub consecutive_failures: u32,
/// 冷却截止时间(unix 毫秒,0 = 未冷却)。
pub cooldown_until_ms: u64,
/// 最近一次失败原因(诊断页用)。
pub last_error: Option<String>,
/// 最后更新时间(unix 毫秒)。
pub updated_at_ms: u64,
}
impl Default for SourceHealth {
fn default() -> Self {
Self {
ema_rtt_ms: 0.0,
samples: 0,
successes: 0,
failures: 0,
not_found: 0,
consecutive_failures: 0,
cooldown_until_ms: 0,
last_error: None,
updated_at_ms: 0,
}
}
}
impl SourceHealth {
/// 记录一次成功:重置连续失败与冷却,更新 EMA RTT。
pub fn record_success(&mut self, rtt_ms: u64, now_ms: u64) {
self.successes += 1;
self.consecutive_failures = 0;
self.cooldown_until_ms = 0;
self.last_error = None;
self.updated_at_ms = now_ms;
let rtt = rtt_ms as f64;
self.ema_rtt_ms = if self.samples == 0 {
rtt
} else {
EMA_ALPHA * rtt + (1.0 - EMA_ALPHA) * self.ema_rtt_ms
};
self.samples += 1;
}
/// 记录一次失败:达到阈值即进入冷却。
pub fn record_failure(&mut self, reason: &str, now_ms: u64) {
self.failures += 1;
self.consecutive_failures = self.consecutive_failures.saturating_add(1);
self.updated_at_ms = now_ms;
self.last_error = Some(reason.to_string());
if self.consecutive_failures >= FAILURE_THRESHOLD {
self.cooldown_until_ms = now_ms.saturating_add(COOLDOWN_MS);
}
}
/// 记录一次「该源没有这个资源」(HTTP 4xx):只留诊断痕迹,不动失败率与冷却。
pub fn record_missing(&mut self, reason: &str, now_ms: u64) {
self.not_found = self.not_found.saturating_add(1);
self.updated_at_ms = now_ms;
self.last_error = Some(reason.to_string());
}
/// 是否处于冷却期。
#[must_use]
pub fn is_cooling_down(&self, now_ms: u64) -> bool {
self.cooldown_until_ms > now_ms
}
/// 上次冷却是否已到期(此时这次尝试就是「恢复探测」)。
#[must_use]
pub fn is_probe(&self, now_ms: u64) -> bool {
self.cooldown_until_ms != 0 && self.cooldown_until_ms <= now_ms
}
/// 失败率(0.0~1.0)。
#[must_use]
pub fn failure_rate(&self) -> f64 {
let total = self.successes + self.failures;
if total == 0 {
0.0
} else {
self.failures as f64 / total as f64
}
}
/// 排序打分(越小越优先)。
#[must_use]
pub fn score(&self, now_ms: u64) -> f64 {
if self.is_cooling_down(now_ms) {
return SCORE_COOLDOWN_PENALTY + self.ema_rtt_ms;
}
if self.samples == 0 && self.failures == 0 {
return SCORE_UNKNOWN;
}
self.ema_rtt_ms + self.failure_rate() * 500.0 + f64::from(self.consecutive_failures) * 50.0
}
}
/// 健康度注册表(进程内共享,`Arc` 后传给引擎)。
#[derive(Debug)]
pub struct HealthRegistry {
path: Option<PathBuf>,
entries: Mutex<HashMap<HealthKey, SourceHealth>>,
dirty: AtomicBool,
last_save_ms: AtomicU64,
}
impl Default for HealthRegistry {
fn default() -> Self {
Self::in_memory()
}
}
impl HealthRegistry {
/// 纯内存实例(测试用 / 用户关掉健康度记忆时)。
#[must_use]
pub fn in_memory() -> Self {
Self {
path: None,
entries: Mutex::new(HashMap::new()),
dirty: AtomicBool::new(false),
last_save_ms: AtomicU64::new(0),
}
}
/// 打开持久化文件(不存在则从空表开始;损坏/超龄记录则忽略)。
#[must_use]
pub fn open(path: impl Into<PathBuf>, max_age_ms: u64, now_ms: u64) -> Self {
let path = path.into();
let entries = health_store::load(&path, max_age_ms, now_ms);
Self {
path: Some(path),
entries: Mutex::new(entries),
dirty: AtomicBool::new(false),
last_save_ms: AtomicU64::new(now_ms),
}
}
/// 按 `cache/` 根目录打开(**生产接线用这一条**):文件固定在 `<cache>/health.json`,
/// 保留期用默认值。
#[must_use]
pub fn open_in(cache_root: impl AsRef<Path>, now_ms: u64) -> Self {
Self::open(
cache_root.as_ref().join(health_store::FILE_NAME),
health_store::MAX_AGE_MS,
now_ms,
)
}
/// 持久化文件路径(诊断页展示用)。
#[must_use]
pub fn path(&self) -> Option<&Path> {
self.path.as_deref()
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<HealthKey, SourceHealth>> {
self.entries
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// 读取某个键的记录(不存在则返回默认值)。
#[must_use]
pub fn health_of(&self, source: &str, kind: ResourceKind) -> SourceHealth {
self.lock()
.get(&HealthKey::new(source, kind))
.cloned()
.unwrap_or_default()
}
/// 记录成功;返回冷却状态是否发生变化(变化即立刻落盘)。
pub fn record_success(
&self,
source: &str,
kind: ResourceKind,
rtt: Duration,
now_ms: u64,
) -> bool {
let changed = {
let mut entries = self.lock();
let health = entries.entry(HealthKey::new(source, kind)).or_default();
let before = health.cooldown_until_ms;
health.record_success(rtt.as_millis() as u64, now_ms);
before != health.cooldown_until_ms
};
self.after_record(changed);
changed
}
/// 记录失败;返回冷却状态是否发生变化(第 3 次连续失败会进入冷却 → 立刻落盘)。
pub fn record_failure(
&self,
source: &str,
kind: ResourceKind,
reason: &str,
now_ms: u64,
) -> bool {
let changed = {
let mut entries = self.lock();
let health = entries.entry(HealthKey::new(source, kind)).or_default();
let before = health.cooldown_until_ms;
health.record_failure(reason, now_ms);
before != health.cooldown_until_ms
};
self.after_record(changed);
changed
}
/// 记录「该源没有这个资源」(4xx):不计失败、不进冷却,只更新诊断字段。
pub fn record_missing(&self, source: &str, kind: ResourceKind, reason: &str, now_ms: u64) {
{
let mut entries = self.lock();
let health = entries.entry(HealthKey::new(source, kind)).or_default();
health.record_missing(reason, now_ms);
}
self.after_record(false);
}
/// 记录后:标脏,冷却状态迁移则立刻落盘(这种事件很少,不会打爆磁盘)。
fn after_record(&self, cooldown_changed: bool) {
self.dirty.store(true, Ordering::Relaxed);
if cooldown_changed {
let _ = self.save();
}
}
/// 该源当前是否在冷却期。
#[must_use]
pub fn is_cooling_down(&self, source: &str, kind: ResourceKind, now_ms: u64) -> bool {
self.health_of(source, kind).is_cooling_down(now_ms)
}
/// 按健康度重排候选序列:健康好的在前,冷却中的排到最后(仍保留为兜底)。
#[must_use]
pub fn rank(
&self,
candidates: &[Candidate],
kind: ResourceKind,
now_ms: u64,
) -> Vec<Candidate> {
let entries = self.lock();
let mut scored: Vec<(f64, usize, Candidate)> = candidates
.iter()
.enumerate()
.map(|(index, candidate)| {
let score = entries
.get(&HealthKey::new(candidate.source.clone(), kind))
.map_or(SCORE_UNKNOWN, |health| health.score(now_ms));
(score, index, candidate.clone())
})
.collect();
scored.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
scored
.into_iter()
.map(|(_, _, candidate)| candidate)
.collect()
}
/// 快照(诊断页/测试用),按源名排序保证稳定。
#[must_use]
pub fn snapshot(&self) -> Vec<(HealthKey, SourceHealth)> {
let entries = self.lock();
let mut rows: Vec<(HealthKey, SourceHealth)> = entries
.iter()
.map(|(key, health)| (key.clone(), health.clone()))
.collect();
rows.sort_by(|a, b| {
a.0.source
.cmp(&b.0.source)
.then(a.0.kind.as_str().cmp(b.0.kind.as_str()))
});
rows
}
/// 清空(设置页「重置下载源健康度」)。
pub fn clear(&self) {
self.lock().clear();
}
/// 无条件原子持久化到 `cache/health.json`(纯内存实例上是空操作)。
pub fn save(&self) -> io::Result<()> {
let Some(path) = self.path.clone() else {
self.dirty.store(false, Ordering::Relaxed);
return Ok(());
};
health_store::store(&path, &self.lock())?;
self.dirty.store(false, Ordering::Relaxed);
self.last_save_ms.store(now_wall_ms(), Ordering::Relaxed);
Ok(())
}
/// 下载收尾时的节流落盘:只有「脏」且距上次落盘超过 [`SAVE_THROTTLE_MS`] 才写。
///
/// 引擎在每个 `download()` / `download_bytes()` 结束时调它——批量下载因此最多
/// 每 5 秒写一次盘,而不是每个文件一次。
pub fn flush(&self, now_ms: u64) -> io::Result<()> {
if self.path.is_none() || !self.dirty.load(Ordering::Relaxed) {
return Ok(());
}
let last = self.last_save_ms.load(Ordering::Relaxed);
if now_ms.saturating_sub(last) < SAVE_THROTTLE_MS {
return Ok(());
}
self.save()
}
/// 是否有未落盘的改动(诊断页/测试用)。
#[must_use]
pub fn is_dirty(&self) -> bool {
self.dirty.load(Ordering::Relaxed)
}
}
/// 官方源的空健康记录便捷构造(game 层拼候选时常用)。
#[must_use]
pub fn official_key(kind: ResourceKind) -> HealthKey {
HealthKey::new(SOURCE_OFFICIAL, kind)
}
/// 记录落盘时刻用的墙钟(不为它引入时钟抽象,测试走显式 `now_ms` 参数)。
fn now_wall_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |since| {
u64::try_from(since.as_millis()).unwrap_or(u64::MAX)
})
}
+80
View File
@@ -0,0 +1,80 @@
//! 健康度的落盘格式与读盘/写盘(`cache/health.json`)。
//!
//! 与 `health.rs` 分开的原因很实际:序列化格式与文件 IO 的细节会随版本增长(字段增删、
//! 迁移、按源分文件…),把这些从「打分/冷却」的纯逻辑里挪出来,两边都好读好改。
use std::collections::HashMap;
use std::io;
use std::path::Path;
use serde::{Deserialize, Serialize};
use super::atomic::write_atomic;
use super::health::{HealthKey, SourceHealth};
use super::types::ResourceKind;
/// 健康度文件名(放在 `cache/` 下,与 `index.json` 同级)。
pub const FILE_NAME: &str = "health.json";
/// 记录保留期:超过这个时间没更新过的源直接丢掉(缓存目录可能被放很久)。
pub const MAX_AGE_MS: u64 = 7 * 24 * 60 * 60 * 1000;
/// 持久化的条目形态(`HealthKey` 拍平,便于 JSON 阅读)。
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PersistedEntry {
source: String,
kind: ResourceKind,
health: SourceHealth,
}
/// 持久化文件的容器。
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PersistedFile {
version: u32,
#[serde(default)]
entries: Vec<PersistedEntry>,
}
/// 读盘:不存在/损坏/超龄都只丢掉对应记录,绝不阻断启动。
pub(crate) fn load(path: &Path, max_age_ms: u64, now_ms: u64) -> HashMap<HealthKey, SourceHealth> {
let mut entries = HashMap::new();
let Ok(bytes) = std::fs::read(path) else {
return entries;
};
match serde_json::from_slice::<PersistedFile>(&bytes) {
Ok(file) => {
for entry in file.entries {
if entry.health.updated_at_ms.saturating_add(max_age_ms) >= now_ms {
entries.insert(HealthKey::new(entry.source, entry.kind), entry.health);
}
}
}
Err(err) => warn(&format!("健康度文件解析失败,按空表处理:{err}")),
}
entries
}
/// 写盘:原子写(`tmp → rename`),失败由调用方决定是记日志还是忽略。
pub(crate) fn store(path: &Path, entries: &HashMap<HealthKey, SourceHealth>) -> io::Result<()> {
let file = PersistedFile {
version: 1,
entries: entries
.iter()
.map(|(key, health)| PersistedEntry {
source: key.source.clone(),
kind: key.kind,
health: health.clone(),
})
.collect(),
};
let json = serde_json::to_vec_pretty(&file).map_err(io::Error::other)?;
write_atomic(path, &json)
}
/// core 不引 tracing(P0 阶段日志由 app 层接管),这里只做一次静默的 stderr 兜底。
fn warn(message: &str) {
if cfg!(debug_assertions) {
eprintln!("[ull::download] {message}");
} else {
let _ = message;
}
}
+183
View File
@@ -0,0 +1,183 @@
//! HTTP 薄封装:客户端构造、错误分类、响应头读取。
//!
//! 关键取舍:
//! * **`Accept-Encoding: identity`** —— 下载要按字节对齐做 Range/校验,gzip 会让
//! `Content-Length` 与实际写入量打架(HMCL 为此专门写了按解压后偏移计数的
//! `HttpResumeContext`,analyse/03 §5.4),我们直接从协议层绕开这个问题;
//! * 重定向上限 20 跳(承 HMCL),跳转后仍按最终 URL 做 host 校验由调用方负责;
//! * 错误分类只做「同源重试 / 立刻换源 / 整体失败」三态,不做细颗粒映射。
use reqwest::header::{
HeaderMap, HeaderName, ACCEPT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, ETAG, LAST_MODIFIED,
};
use reqwest::redirect::Policy;
use super::config::EngineConfig;
use super::error::{DownloadError, DownloadErrorKind};
/// 重定向上限(承 HMCL 的 20 跳)。
pub const MAX_REDIRECTS: usize = 20;
/// 构造共享的 `reqwest::Client`(连接池、超时、重定向策略一次定好)。
pub fn build_client(cfg: &EngineConfig) -> Result<reqwest::Client, DownloadError> {
let mut headers = HeaderMap::new();
headers.insert(
ACCEPT_ENCODING,
reqwest::header::HeaderValue::from_static("identity"),
);
reqwest::Client::builder()
.user_agent(cfg.user_agent.clone())
.connect_timeout(cfg.connect_timeout)
.redirect(Policy::limited(MAX_REDIRECTS))
.default_headers(headers)
.build()
.map_err(|err| {
DownloadError::new(
DownloadErrorKind::Network,
format!("构造 HTTP 客户端失败:{err}"),
)
})
}
/// 把 `reqwest::Error` 映射成我们的错误分类。
#[must_use]
pub fn classify_reqwest(err: &reqwest::Error, url: &str) -> DownloadError {
let kind = if err.is_timeout() {
DownloadErrorKind::Timeout
} else if let Some(status) = err.status() {
return classify_status(status.as_u16(), url);
} else {
DownloadErrorKind::Network
};
DownloadError::new(kind, format!("{err}")).with_source(url)
}
/// HTTP 状态码 → 错误分类。
#[must_use]
pub fn classify_status(status: u16, url: &str) -> DownloadError {
let kind = match status {
404 | 410 => DownloadErrorKind::NotFound,
// 403/401 对镜像来说基本等于「这条路不能走」,直接换源比退避更有用。
401 | 403 => DownloadErrorKind::NotFound,
408 => DownloadErrorKind::Timeout,
416 => DownloadErrorKind::RangeUnsupported,
429 => DownloadErrorKind::ServerError,
500..=599 => DownloadErrorKind::ServerError,
_ => DownloadErrorKind::ServerError,
};
DownloadError::new(kind, format!("HTTP {status}")).with_source(url)
}
/// 读一个头的字符串值。
#[must_use]
pub fn header_str(headers: &HeaderMap, name: HeaderName) -> Option<&str> {
headers.get(name).and_then(|value| value.to_str().ok())
}
/// `Content-Length`。
#[must_use]
pub fn content_length(headers: &HeaderMap) -> Option<u64> {
header_str(headers, CONTENT_LENGTH).and_then(|value| value.trim().parse().ok())
}
/// `ETag`(续传的强校验器)。
#[must_use]
pub fn etag(headers: &HeaderMap) -> Option<String> {
header_str(headers, ETAG).map(str::to_string)
}
/// `Last-Modified`(弱校验器,ETag 缺失时的兜底)。
#[must_use]
pub fn last_modified(headers: &HeaderMap) -> Option<String> {
header_str(headers, LAST_MODIFIED).map(str::to_string)
}
/// `Content-Type`。
#[must_use]
pub fn content_type(headers: &HeaderMap) -> Option<&str> {
header_str(headers, CONTENT_TYPE)
}
/// 判断一个 2xx 响应是不是「伪装的档案」(镜像门户的假阳性兜底页)。
///
/// 出处:research/06 §0.2 / §5.3 第 2 条——`mirrors.huaweicloud.com` 对**任意不存在的路径**
/// 都回 `200 + 12 434 字节 Angular 门户 HTML`,只看状态码会把死源当活源。
///
/// 两条判据(都只在证据充分时才拒):
/// 1. `Content-Type` 含 `html` → 一定是门户页,不是归档;
/// 2. 已知期望大小、且是整文件请求(`offset == 0`)、响应带 `Content-Length` 却对不上
/// → 长度对拍失败,同样不是我们要的那个文件。
///
/// 返回 `Some(原因)` 时调用方应当**换源**(归到 `NotFound` 类:不算源坏、不进冷却)。
#[must_use]
pub fn html_mirror_reason(
headers: &HeaderMap,
expected_size: Option<u64>,
offset: u64,
) -> Option<&'static str> {
if content_type(headers).is_some_and(|value| value.to_ascii_lowercase().contains("html")) {
return Some("源返回 HTML(镜像门户兜底页,假阳性:该路径没有真实归档)");
}
if offset == 0 {
if let (Some(expected), Some(actual)) = (expected_size, content_length(headers)) {
if expected > 0 && actual != expected {
return Some("源返回的 Content-Length 与期望大小不符(疑似门户页/截断响应)");
}
}
}
None
}
#[cfg(test)]
mod tests {
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_LENGTH, CONTENT_TYPE};
use super::{html_mirror_reason, MAX_REDIRECTS};
fn headers(content_type: Option<&str>, length: Option<u64>) -> HeaderMap {
let mut map = HeaderMap::new();
if let Some(value) = content_type {
map.insert(CONTENT_TYPE, HeaderValue::from_str(value).expect("头值"));
}
if let Some(value) = length {
map.insert(CONTENT_LENGTH, HeaderValue::from(value));
}
map
}
#[test]
fn html_portal_page_is_rejected_even_with_200() {
// 华为云实测:任意不存在的路径 → 200 + text/html + 12434 字节。
let map = headers(Some("text/html;charset=UTF-8"), Some(12_434));
assert_eq!(
html_mirror_reason(&map, Some(201_328_186), 0),
Some("源返回 HTML(镜像门户兜底页,假阳性:该路径没有真实归档)")
);
// 只有 Content-Type 也能判(没有期望大小时)。
assert!(html_mirror_reason(&map, None, 0).is_some());
}
#[test]
fn real_archive_matching_the_expected_size_passes() {
let map = headers(Some("application/zip"), Some(201_328_186));
assert_eq!(html_mirror_reason(&map, Some(201_328_186), 0), None);
// 镜像没给 Content-Type 也照放行(有 sha256 兜底)。
let bare = headers(None, Some(201_328_186));
assert_eq!(html_mirror_reason(&bare, Some(201_328_186), 0), None);
}
#[test]
fn mismatched_length_is_rejected_but_resumed_reads_are_left_alone() {
let map = headers(Some("application/octet-stream"), Some(973_920));
assert!(html_mirror_reason(&map, Some(205_421_281), 0).is_some());
// 续传片段当然比全量小:不能按整文件口径对拍。
assert_eq!(html_mirror_reason(&map, Some(205_421_281), 973_920), None);
// 未知期望大小 → 不做长度判定。
assert_eq!(html_mirror_reason(&map, None, 0), None);
}
#[test]
fn redirect_cap_is_still_twenty() {
assert_eq!(MAX_REDIRECTS, 20);
}
}
+178
View File
@@ -0,0 +1,178 @@
//! JDK 分发源表(`mirrors.json` v3):Oracle OpenJDK / Microsoft OpenJDK / Azul Zulu。
//!
//! v2 只有 `adoptium`(Temurin)一张表,因为当时只做一家发行版。D23 定下三家 + 第四备选后,
//! 三家的分发形态各不相同,表结构也就各有一套(research/06 §5.2 条目样例照抄):
//!
//! | 发行版 | 枚举方式 | 下载形态 |
//! | --- | --- | --- |
//! | Oracle OpenJDK | Apache 目录页(华为云 / 南大,与 `download.java.net` 同构) | `{base}/{ver}/{filename}`,hash 段不可推导 |
//! | Microsoft OpenJDK | 内置版本快照(官方只有人读 HTML 表) | `aka.ms` 模板 301 → VS CDN,校验走成对的 `.sha256sum.txt` |
//! | Zulu | 免登录 JSON API(两跳:列表 → 详情) | `download_url` 直出,只信 `sha256_hash` |
//!
//! 表本身只是「数据」:怎么用由 `game::java` 的 `oracle` / `microsoft` / `zulu` 决定。
//! v3 兼容读 v2:新表全部 `#[serde(default)]`,老文件照常解析(新表为空,回退链自然退化成只有 Temurin)。
use serde::{Deserialize, Serialize};
use super::mirrors::MirrorTable;
const fn default_true() -> bool {
true
}
/// 一个 Oracle OpenJDK 镜像(`download.java.net/openjdk` 的忠实镜像)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OracleMirror {
/// 源标识(`huawei` / `nju`)。
pub id: String,
/// 展示名。
pub name: String,
/// 目录根(形如 `https://mirrors.huaweicloud.com/openjdk`)。
pub base: String,
/// 是否启用。
#[serde(default = "default_true")]
pub enabled: bool,
/// 实测吞吐(展示用,不参与决策)。
#[serde(default)]
pub speed: String,
/// 备注(展示用)。
#[serde(default)]
pub note: String,
}
impl OracleMirror {
/// 目录根(去掉尾斜杠,拼路径时不会再出双斜杠)。
#[must_use]
pub fn base(&self) -> &str {
self.base.trim_end_matches('/')
}
}
/// Microsoft OpenJDK 的分发表:官方模板 + 内置版本快照。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MsOpenJdkTable {
/// 人读清单页(刷新快照时抓的页面,仅供排查)。
#[serde(default)]
pub list: String,
/// 下载模板:`microsoft-jdk-{version}-{os}-{arch}.zip`。
pub template: String,
/// 校验模板:同一文件名 + `.sha256sum.txt`。
pub sha256_template: String,
/// 官方在发的 GA 大版本(没有 8——微软已停发,官方页面让去用 Temurin 8)。
#[serde(default)]
pub majors: Vec<u32>,
/// 国内镜像(实测为空数组:三家云都没有 MS OpenJDK)。
#[serde(default)]
pub domestic_mirrors: Vec<String>,
/// 实测吞吐(展示用)。
#[serde(default)]
pub measured_speed: String,
}
/// Zulu(Azul)的元数据表。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZuluTable {
/// 包列表 API 根。
pub api: String,
/// 包详情 API(`{uuid}` 占位)——`latest=true` 的响应不含 sha256,必须再取一次。
pub detail: String,
/// CDN host(`cdn.azul.com`,展示用)。
#[serde(default)]
pub cdn_host: String,
/// 实测吞吐(展示用)。
#[serde(default)]
pub measured_speed: String,
/// `size` 字段是否可与真实字节数对拍(实测 `false`,差 133 B)。
#[serde(default)]
pub size_field_reliable: bool,
}
impl MirrorTable {
/// 启用的 Oracle OpenJDK 镜像(清单顺序 = 默认尝试顺序)。
pub fn oracle_mirrors(&self) -> impl Iterator<Item = &OracleMirror> {
self.oracle_openjdk.iter().filter(|mirror| mirror.enabled)
}
/// 按 id 找 Oracle 镜像(诊断 / `java vendors` 用;停用的也能查到)。
#[must_use]
pub fn oracle_mirror(&self, id: &str) -> Option<&OracleMirror> {
self.oracle_openjdk
.iter()
.find(|mirror| mirror.id.eq_ignore_ascii_case(id))
}
/// Microsoft OpenJDK 分发表(v2 清单没有 → `None`)。
#[must_use]
pub fn microsoft_openjdk(&self) -> Option<&MsOpenJdkTable> {
self.microsoft_openjdk.as_ref()
}
/// Zulu 元数据表。
#[must_use]
pub fn zulu(&self) -> Option<&ZuluTable> {
self.zulu.as_ref()
}
}
#[cfg(test)]
mod tests {
use super::MirrorTable;
#[test]
fn builtin_table_is_v3_with_all_four_source_families() {
let table = MirrorTable::builtin();
assert_eq!(table.version, 3);
// Temurin:只剩 TUNA 一家启用,且带 latest_only 标记。
let enabled: Vec<&str> = table
.adoptium
.iter()
.filter(|mirror| mirror.enabled)
.map(|mirror| mirror.id.as_str())
.collect();
assert_eq!(enabled, vec!["tuna"]);
assert!(table
.adoptium
.iter()
.filter(|m| m.enabled)
.all(|m| m.latest_only));
// 停用的登记项要在(将来恢复时不需要重写清单)。
for id in ["ustc", "sjtu", "bfsu", "nju", "zju", "163"] {
assert!(
table.adoptium.iter().any(|mirror| mirror.id == id),
"{id} 应登记为 disabled"
);
}
assert!(table.oracle_mirrors().any(|mirror| mirror.id == "huawei"));
let microsoft = table.microsoft_openjdk().expect("v3 应带 MS 表");
assert_eq!(microsoft.majors, vec![25, 21, 17, 11]);
assert!(microsoft.template.contains("{version}"));
assert!(microsoft.sha256_template.ends_with(".sha256sum.txt"));
let zulu = table.zulu().expect("v3 应带 Zulu 表");
assert!(zulu.detail.contains("{uuid}"));
assert!(!zulu.size_field_reliable, "Zulu 的 size 字段不可对拍");
}
#[test]
fn v2_table_still_parses() {
// v2 清单(没有三张新表)必须照常解析,回退链退化为「只有 Temurin」。
let v2 = r#"{
"version": 2,
"adoptium": [
{ "id": "tuna", "name": "清华 TUNA",
"base": "https://mirrors.tuna.tsinghua.edu.cn/Adoptium", "enabled": true }
],
"gh_proxies": [],
"rules": []
}"#;
let table = MirrorTable::parse(v2).expect("v2 应兼容");
assert_eq!(table.version, 2);
assert_eq!(table.adoptium.len(), 1);
assert!(
!table.adoptium[0].latest_only,
"v2 没有 latest_only → 默认 false"
);
assert!(table.microsoft_openjdk().is_none());
assert!(table.zulu().is_none());
assert_eq!(table.oracle_mirrors().count(), 0);
}
}
+169
View File
@@ -0,0 +1,169 @@
{
"version": 3,
"notes": [
"v3(D23/M36):adoptium 表收敛到清华 TUNA 一家(其余院校实测 404,登记保留但 enabled=false);新增 oracle_openjdk / microsoft_openjdk / zulu 三张表,供 game::java 的三发行版回退链使用。",
"华为云 / 腾讯云 / 阿里云没有 Adoptium 镜像:华为云门户对任意不存在的路径都回 200 + 12KB HTML(假阳性,M34 实测),腾讯云 /v1/source.js 与华为云 /v1/repositories 的权威清单里都没有 JDK 项。据此不再登记 adoptium.huaweicloud。",
"oracle_openjdk 的目录与文件名与 download.java.net 完全同构,但 hash 段(f2283984656d49d69e91c558476027ac)不可推导——候选 URL 只能来自对镜像目录页的解析,禁止拼装。",
"microsoft_openjdk 没有国内镜像,也没有 GitHub 资产(api.github.com 的 microsoft/openjdk releases 里 assets 全空);唯一渠道是 aka.ms 模板 301 → download.visualstudio.microsoft.com(国内约 400 KB/s)。",
"zulu 的 API size 字段与真实字节数差 133 B,只能用于展示;校验一律用 sha256_hash。"
],
"gh_proxies": [
{
"id": "gh-proxy",
"name": "gh-proxy.com",
"prefix": "https://gh-proxy.com/",
"enabled": true
}
],
"adoptium": [
{
"id": "tuna",
"name": "清华 TUNA",
"base": "https://mirrors.tuna.tsinghua.edu.cn/Adoptium",
"enabled": true,
"latest_only": true
},
{
"id": "ustc",
"name": "中科大 USTC(无 Adoptium 目录树:只有 deb/rpm/releases,实测 404)",
"base": "https://mirrors.ustc.edu.cn/adoptium",
"enabled": false
},
{
"id": "sjtu",
"name": "上交 SJTU(无 Adoptium 目录树,实测 404)",
"base": "https://mirror.sjtu.edu.cn/adoptium",
"enabled": false
},
{
"id": "bfsu",
"name": "北外 BFSU(无 Adoptium 目录树,实测 404)",
"base": "https://mirrors.bfsu.edu.cn/Adoptium",
"enabled": false
},
{
"id": "nju",
"name": "南大 NJU(adoptium 路径实测 404;/openjdk/ 走 oracle_openjdk 表)",
"base": "https://mirror.nju.edu.cn/adoptium",
"enabled": false
},
{
"id": "zju",
"name": "浙大 ZJU(无 Adoptium 目录树,实测 404 搜索页)",
"base": "https://mirrors.zju.edu.cn/adoptium",
"enabled": false
},
{
"id": "163",
"name": "网易 163(无 Adoptium 目录树,实测 404 且 18 秒才回)",
"base": "https://mirrors.163.com/adoptium",
"enabled": false
}
],
"oracle_openjdk": [
{
"id": "huawei",
"name": "华为云",
"base": "https://mirrors.huaweicloud.com/openjdk",
"enabled": true,
"speed": "11.5MB/s",
"note": "逐字节忠实镜像(21.0.2 = 201328186 B 与官方一致);目录页是 Apache 列表,带 <pre>;归档旁有 .sha256"
},
{
"id": "nju",
"name": "南大 NJU",
"base": "https://mirror.nju.edu.cn/openjdk",
"enabled": true,
"speed": "8.3MB/s",
"note": "需先吃一次 bcheck cookie(裸 GET 是 302 回自己);健康度会把它排在华为云之后,失败即降级"
}
],
"microsoft_openjdk": {
"list": "https://learn.microsoft.com/java/openjdk/download",
"template": "https://aka.ms/download-jdk/microsoft-jdk-{version}-{os}-{arch}.zip",
"sha256_template": "https://aka.ms/download-jdk/microsoft-jdk-{version}-{os}-{arch}.zip.sha256sum.txt",
"majors": [25, 21, 17, 11],
"domestic_mirrors": [],
"measured_speed": "398KB/s"
},
"zulu": {
"api": "https://api.azul.com/metadata/v1/zulu/packages/",
"detail": "https://api.azul.com/metadata/v1/zulu/packages/{uuid}",
"cdn_host": "cdn.azul.com",
"measured_speed": "3.3MB/s",
"size_field_reliable": false
},
"rules": [
{
"id": "bmclapi-launchermeta",
"from": "https://launchermeta.mojang.com",
"to": "https://bmclapi2.bangbang93.com"
},
{
"id": "bmclapi-piston-meta",
"from": "https://piston-meta.mojang.com",
"to": "https://bmclapi2.bangbang93.com"
},
{
"id": "bmclapi-piston-data",
"from": "https://piston-data.mojang.com",
"to": "https://bmclapi2.bangbang93.com"
},
{
"id": "bmclapi-launcher",
"from": "https://launcher.mojang.com",
"to": "https://bmclapi2.bangbang93.com"
},
{
"id": "bmclapi-libraries",
"from": "https://libraries.minecraft.net",
"to": "https://bmclapi2.bangbang93.com"
},
{
"id": "bmclapi-assets-objects",
"from": "https://resources.download.minecraft.net",
"to": "https://bmclapi2.bangbang93.com",
"inject": "/assets"
},
{
"id": "bmclapi-forge-maven",
"from": "https://files.minecraftforge.net/maven",
"to": "https://bmclapi2.bangbang93.com/maven"
},
{
"id": "bmclapi-forge-maven-net",
"from": "https://maven.minecraftforge.net",
"to": "https://bmclapi2.bangbang93.com/maven"
},
{
"id": "bmclapi-neoforge",
"from": "https://maven.neoforged.net/releases",
"to": "https://bmclapi2.bangbang93.com/maven"
},
{
"id": "bmclapi-fabric-meta",
"from": "https://meta.fabricmc.net",
"to": "https://bmclapi2.bangbang93.com/fabric-meta"
},
{
"id": "bmclapi-fabric-maven",
"from": "https://maven.fabricmc.net",
"to": "https://bmclapi2.bangbang93.com/maven"
},
{
"id": "bmclapi-quilt-meta",
"from": "https://meta.quiltmc.org",
"to": "https://bmclapi2.bangbang93.com/quilt-meta"
},
{
"id": "bmclapi-liteloader",
"from": "https://dl.liteloader.com/versions",
"to": "https://bmclapi2.bangbang93.com/maven"
},
{
"id": "bmclapi-authlib-injector",
"from": "https://authlib-injector.yushi.moe",
"to": "https://bmclapi2.bangbang93.com/mirrors/authlib-injector"
}
]
}
+386
View File
@@ -0,0 +1,386 @@
//! 镜像清单与 URL 前缀重写。
//!
//! 内置清单 `mirrors.json` 随源码嵌入(`include_str!`),发行后可由设置层追加自定义规则。
//! 重写语义承 analyse/03 §5.2 的「前缀替换而非全量重写」——保留子路径,因此 `/libraries`、
//! `/maven`、`/fabric-meta` 这类子路径天然兼容;`inject` 用于补上路径形状不同的场景
//! (资源对象:官方 `<xx>/<hash>`,BMCLAPI `/assets/<xx>/<hash>`)。
//!
//! gh-proxy 规则(用户定案):**只有国内模式 + GitHub host** 才套前缀,其它资源不受影响。
use serde::{Deserialize, Serialize};
pub use super::adoptium::AdoptiumMirror;
use super::jdk_sources::{MsOpenJdkTable, OracleMirror, ZuluTable};
use super::region::{RegionMode, RegionProfile};
use super::types::{Candidate, ResourceKind, SOURCE_OFFICIAL};
/// gh-proxy 候选的源标识。
pub const SOURCE_GH_PROXY: &str = "gh_proxy";
const fn default_true() -> bool {
true
}
/// 一条前缀重写规则。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrefixRule {
/// 规则 id(日志/诊断用)。
pub id: String,
/// 匹配的 URL 前缀(含 scheme)。
pub from: String,
/// 替换成的前缀。
pub to: String,
/// 插在 `to` 与剩余路径之间的内容(默认空)。
#[serde(default)]
pub inject: String,
/// 「仅加速不替代」:镜像优先模式下仍排在官方之后。
#[serde(default)]
pub fallback: bool,
/// 只对这些资源类型生效(空 = 全部)。
#[serde(default)]
pub kinds: Vec<ResourceKind>,
/// 是否启用(设置层可关)。
#[serde(default = "default_true")]
pub enabled: bool,
}
impl PrefixRule {
/// 构造一条最简规则。
#[must_use]
pub fn new(id: impl Into<String>, from: impl Into<String>, to: impl Into<String>) -> Self {
Self {
id: id.into(),
from: from.into(),
to: to.into(),
inject: String::new(),
fallback: false,
kinds: Vec::new(),
enabled: true,
}
}
/// 设一个「仅加速」标记。
#[must_use]
pub fn fallback(mut self) -> Self {
self.fallback = true;
self
}
/// 设置注入片段。
#[must_use]
pub fn with_inject(mut self, inject: impl Into<String>) -> Self {
self.inject = inject.into();
self
}
/// 是否命中该 URL(前缀匹配 + 资源类型过滤 + 启用位)。
#[must_use]
pub fn matches(&self, url: &str, kind: ResourceKind) -> bool {
self.enabled
&& url.starts_with(&self.from)
&& (self.kinds.is_empty() || self.kinds.contains(&kind))
}
/// 生成镜像候选。
#[must_use]
pub fn apply(&self, url: &str) -> Option<Candidate> {
if !url.starts_with(&self.from) {
return None;
}
let rest = &url[self.from.len()..];
let rewritten = format!("{}{}{}", self.to, self.inject, rest);
Some(Candidate::new(source_id_for(&rewritten), rewritten))
}
}
/// 一个 gh-proxy 类前缀。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GhProxy {
/// 源标识。
pub id: String,
/// 展示名。
pub name: String,
/// 前缀(形如 `https://gh-proxy.com/`)。
pub prefix: String,
/// 是否启用。
#[serde(default = "default_true")]
pub enabled: bool,
}
/// 镜像表:内置规则 + 用户自定义规则。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MirrorTable {
/// 清单版本(便于诊断页显示「镜像表是哪一版」)。
pub version: u32,
/// 清单自带的说明(假阳性成因、不可推导项等,给运维看的原地注释)。
#[serde(default)]
pub notes: Vec<String>,
/// 前缀重写规则(前面的优先)。
#[serde(default)]
pub rules: Vec<PrefixRule>,
/// Adoptium 院校镜像。
#[serde(default)]
pub adoptium: Vec<AdoptiumMirror>,
/// Oracle OpenJDK 镜像(v3 新增)。
#[serde(default)]
pub oracle_openjdk: Vec<OracleMirror>,
/// Microsoft OpenJDK 分发模板(v3 新增)。
#[serde(default)]
pub microsoft_openjdk: Option<MsOpenJdkTable>,
/// Zulu 元数据(v3 新增)。
#[serde(default)]
pub zulu: Option<ZuluTable>,
/// gh-proxy 类前缀。
#[serde(default)]
pub gh_proxies: Vec<GhProxy>,
}
impl Default for MirrorTable {
fn default() -> Self {
Self::builtin()
}
}
impl MirrorTable {
/// 内置清单(编译期嵌入)。
#[must_use]
pub fn builtin() -> Self {
Self::parse(include_str!("mirrors.json")).unwrap_or_else(|err| {
debug_note(&format!("内置 mirrors.json 解析失败(不应发生):{err}"));
Self {
version: 0,
notes: Vec::new(),
rules: Vec::new(),
adoptium: Vec::new(),
oracle_openjdk: Vec::new(),
microsoft_openjdk: None,
zulu: None,
gh_proxies: Vec::new(),
}
})
}
/// 从 JSON 解析。
pub fn parse(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
/// 序列化成 JSON(诊断页/用户自定义文件回写用)。
pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
/// 追加用户自定义规则(插到最前,优先于内置规则)。
pub fn add_custom_rule(&mut self, rule: PrefixRule) {
self.rules.insert(0, rule);
}
/// 覆盖 gh-proxy 前缀(设置项);传 `None` 表示不默认启用。
pub fn set_gh_proxy(&mut self, prefix: Option<&str>) {
match prefix {
Some(prefix) => {
if let Some(proxy) = self.gh_proxies.first_mut() {
proxy.prefix = prefix.to_string();
proxy.enabled = true;
} else {
self.gh_proxies.push(GhProxy {
id: "gh-proxy".to_string(),
name: "自定义 gh-proxy".to_string(),
prefix: prefix.to_string(),
enabled: true,
});
}
}
None => {
for proxy in &mut self.gh_proxies {
proxy.enabled = false;
}
}
}
}
/// 找到第一条命中的前缀规则。
#[must_use]
pub fn prefix_rule_for(&self, url: &str, kind: ResourceKind) -> Option<&PrefixRule> {
self.rules.iter().find(|rule| rule.matches(url, kind))
}
/// 清单里第一个启用的 gh-proxy 前缀(默认值来源;设置层用它初始化地区档案)。
#[must_use]
pub fn default_gh_proxy(&self) -> Option<&str> {
self.gh_proxies
.iter()
.find(|proxy| proxy.enabled && !proxy.prefix.is_empty())
.map(|proxy| proxy.prefix.as_str())
}
/// GitHub host 且允许时套 gh-proxy 前缀。
///
/// **生效前缀的唯一真相源是 `region.gh_proxy`**(设置层/地区判定写入,`None` = 用户关闭)。
/// 表格里的 `gh_proxies` 只是「已知可用前缀」清单,配合 `default_gh_proxy()` 给设置做默认值。
#[must_use]
pub fn gh_proxy_candidates(&self, url: &str, region: &RegionProfile) -> Vec<Candidate> {
if region.mode != RegionMode::MirrorFirst {
return Vec::new();
}
let Some(prefix) = region
.gh_proxy
.as_deref()
.map(str::trim)
.filter(|prefix| !prefix.is_empty())
else {
return Vec::new();
};
let Some(host) = host_of(url) else {
return Vec::new();
};
if !is_github_host(host) {
return Vec::new();
}
vec![Candidate::new(SOURCE_GH_PROXY, format!("{prefix}{url}"))]
}
/// 展开候选序列:镜像优先模式下镜像在前、官方兜底;直连模式下官方在前、镜像兜底。
#[must_use]
pub fn expand(&self, url: &str, kind: ResourceKind, region: &RegionProfile) -> Vec<Candidate> {
let official = Candidate::new(direct_source_id(url), url);
let mut mirrors: Vec<Candidate> = Vec::new();
let mut official_first = false;
for candidate in self.adoptium_candidates(url) {
push_unique(&mut mirrors, url, candidate);
}
if let Some(rule) = self.prefix_rule_for(url, kind) {
if let Some(candidate) = rule.apply(url) {
// 「仅加速不替代」的规则在镜像优先模式下也排官方之后。
official_first = rule.fallback;
push_unique(&mut mirrors, url, candidate);
}
}
for candidate in self.gh_proxy_candidates(url, region) {
push_unique(&mut mirrors, url, candidate);
}
if region.mode == RegionMode::MirrorFirst && !official_first {
mirrors.push(official);
mirrors
} else {
let mut out = Vec::with_capacity(mirrors.len() + 1);
out.push(official);
out.append(&mut mirrors);
out
}
}
}
fn push_unique(list: &mut Vec<Candidate>, official_url: &str, candidate: Candidate) {
if candidate.url == official_url || list.iter().any(|known| known.url == candidate.url) {
return;
}
list.push(candidate);
}
/// 直连候选的源标识。
///
/// 绝大多数资源是 Mojang/CDN 的「官方源」,标 `official`;但 JDK 的四家发行版里,
/// Oracle(国内镜像)/ MS(VS CDN)/ Zulu(Azul CDN)的**直连 URL 本身就是某个厂商的 CDN**,
/// 标成 `official` 会让用户和健康度记忆都看错(「来源 official」其实来自华为云)。
/// 所以:认得的厂商/镜像 host 用它的稳定 id,其余一律 `official`。
#[must_use]
pub fn direct_source_id(url: &str) -> String {
let id = source_id_for(url);
if MIRROR_SOURCE_IDS.contains(&id.as_str()) {
id
} else {
SOURCE_OFFICIAL.to_string()
}
}
/// 会被 `direct_source_id` 认出来的厂商/镜像源 id(都是「非官方 CDN/镜像」那一类)。
pub const MIRROR_SOURCE_IDS: &[&str] = &[
"bmclapi",
"tuna",
"ustc",
"sjtu",
"bfsu",
"nju",
"huawei",
"azul",
"microsoft",
SOURCE_GH_PROXY,
];
/// 从 URL 里抠出 host(无 `url` 依赖)。
#[must_use]
pub fn host_of(url: &str) -> Option<&str> {
let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
let authority = rest.split(['/', '?', '#']).next()?;
let host_port = authority.rsplit('@').next()?;
let host = host_port.split(':').next()?;
if host.is_empty() {
None
} else {
Some(host)
}
}
/// 是否是 GitHub 系 host(gh-proxy 只对这些套前缀)。
#[must_use]
pub fn is_github_host(host: &str) -> bool {
matches!(
host,
"github.com"
| "raw.githubusercontent.com"
| "objects.githubusercontent.com"
| "codeload.github.com"
| "gist.github.com"
| "gist.githubusercontent.com"
) || host.ends_with(".githubusercontent.com")
}
/// URL 的源标识(健康度按此归并;不知名 host 直接用 host)。
#[must_use]
pub fn source_id_for(url: &str) -> String {
let Some(host) = host_of(url) else {
return SOURCE_OFFICIAL.to_string();
};
if host.ends_with("bangbang93.com") {
return "bmclapi".to_string();
}
if host.contains("tuna.tsinghua.edu.cn") {
return "tuna".to_string();
}
if host.contains("ustc.edu.cn") {
return "ustc".to_string();
}
if host.contains("sjtu.edu.cn") {
return "sjtu".to_string();
}
if host.contains("bfsu.edu.cn") {
return "bfsu".to_string();
}
if host.contains("nju.edu.cn") {
return "nju".to_string();
}
if host.contains("huaweicloud.com") {
return "huawei".to_string();
}
if host.contains("azul.com") {
return "azul".to_string();
}
if host.contains("visualstudio.microsoft.com") || host == "aka.ms" {
return "microsoft".to_string();
}
if host.contains("gh-proxy") {
return SOURCE_GH_PROXY.to_string();
}
host.to_string()
}
fn debug_note(message: &str) {
if cfg!(debug_assertions) {
eprintln!("[ull::download] {message}");
} else {
let _ = message;
}
}
+90
View File
@@ -0,0 +1,90 @@
//! 下载引擎:多源健康度、指数退避、分层下载(大文件分块)、边下边算、原子落盘。
//!
//! 设计定稿:`DevDocs/PlanDoc/05-download-and-mirrors.md`(决策 D7)。
//! 镜像清单随更新可刷新(BMCLAPI / TUNA / USTC / SJTU / gh-proxy),
//! 地区自动判定 = 时区 + 语言 + 网络探测组合信号,可手动覆盖。
//!
//! 与其它模块的契约:`DownloadRequest` / 进度事件的类型需与 game、agent 对齐
//! (`DevDocs/SubAgentsTalk/README.md` 的对齐点)。
//!
//! # 公共面
//!
//! 调用方只需要三件套:构造 [`DownloadRequest`] → [`DownloadEngine::download`] →
//! 消费 [`ProgressEvent`]。「用哪个镜像、要不要续传、开几条连接、失败重试几次」
//! 全部由引擎内部决定,调用方既不拼镜像 URL 也不写重试循环。
//!
//! 其余构件(镜像表 / 地区档案 / 健康度 / 内容寻址缓存 / 续传元数据)都作为
//! 可注入对象暴露出来:设置层要把它们接进 schema,诊断页要直接读它们的状态,
//! 测试要能把它们换成确定性的假件。
//!
//! # 分层
//!
//! ```text
//! engine 编排:缓存直取 → 候选规划 → 限流 → 候选外循环 / 退避内循环
//! transfer 单连接尝试(续传 + 流式校验)
//! transfer_chunk 多连接分块编排
//! chunked 分块规划 / Range 语义 / 单块抓取
//! mirrors 镜像清单 + URL 前缀重写 adoptium JDK 院校镜像
//! region 时区 + 语言 + 探测 → 直连/镜像优先
//! health 源 × 资源类型的健康度记忆
//! cache 内容寻址缓存(记录信任) atomic 落盘守卫 / 原子写
//! resume 保守续传判定 verify 流式摘要
//! progress 事件流 error 错误分类
//! ```
//!
//! 落盘守卫([`PartFile`])中间文件名是 `<name>.ullpart-<session>-<seq>`:`session` 是
//! 进程号,`seq` 是进程内序号。同一数据根跑两个启动器进程时不会互相搬走对方的中间文件
//! (M35 真机:并行两次 `ull install` 各失败数百个资源,`os error 2`);旧续传材料由
//! `PartFile::for_dest` 收养,单进程语义不变。
//!
//! 依赖纪律:本模块只用 `reqwest` / `tokio` / `futures-util` / `sha1` / `sha2` /
//! `serde` / `serde_json`,不触碰任何 UI 框架(`crates/core` 的硬约束)。
// 对外可用的构件(设置层 / 诊断页 / 其它模块直接消费)。
pub mod adoptium;
pub mod backoff;
pub mod blocking;
pub mod cache;
pub mod chunked;
pub mod config;
pub mod engine;
pub mod error;
pub mod health;
pub mod jdk_sources;
pub mod mirrors;
pub mod progress;
pub mod region;
pub mod region_system;
pub mod resume;
pub mod types;
pub mod verify;
// 内部实现细节:只在 download 模块内(含 crate 内)可见,不承诺兼容性。
pub(crate) mod atomic;
pub(crate) mod bytes;
pub(crate) mod health_store;
pub(crate) mod http;
pub(crate) mod transfer;
pub(crate) mod transfer_chunk;
pub use self::adoptium::{AdoptiumArtifact, JdkFlavor};
pub use self::atomic::{PartFile, ADOPT_MIN_AGE, META_SUFFIX, ORPHAN_MAX_AGE, PART_SUFFIX};
pub use self::backoff::{BackoffPolicy, Jitter};
pub use self::blocking::block_on;
pub use self::cache::{CacheRecord, CacheStats, ContentCache};
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::health::{HealthKey, HealthRegistry, SourceHealth};
pub use self::jdk_sources::{MsOpenJdkTable, OracleMirror, ZuluTable};
pub use self::mirrors::{direct_source_id, AdoptiumMirror, GhProxy, MirrorTable, PrefixRule};
pub use self::progress::{ProgressEmitter, ProgressEvent, ProgressThrottle};
pub use self::region::{RegionMode, RegionProfile, RegionTrigger, Signals};
pub use self::region_system::detect_signals;
pub use self::resume::{RestartReason, ResumeDecision, ResumeMeta};
pub use self::types::{
Candidate, DownloadId, DownloadOutcome, DownloadRequest, HashAlgo, Integrity, ResourceKind,
ResumePolicy, SOURCE_OFFICIAL,
};
pub use self::verify::Hasher;
+197
View File
@@ -0,0 +1,197 @@
//! 进度事件与发射器。
//!
//! 事件是「字节级 + 文件级」双层的载体(设计文档 §5):文件级由 `Started/Completed/Failed`
//! 表达,字节级由节流后的 `Progress` 表达。`total_bytes` 未知时给 `None`——不做假进度。
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
use super::error::DownloadErrorKind;
use super::types::{DownloadId, HashAlgo, ResourceKind};
/// 一条下载进度事件。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum ProgressEvent {
/// 开始一次下载(含候选数量与总字节预算)。
Started {
/// 关联 id。
id: DownloadId,
/// 资源类型。
kind: ResourceKind,
/// 逻辑 URL。
url: String,
/// 落盘路径。
dest: String,
/// 候选源数量。
candidates: usize,
/// 总字节数(未知为 `None`)。
total_bytes: Option<u64>,
},
/// 字节级进度(节流后)。
Progress {
/// 关联 id。
id: DownloadId,
/// 已下载字节。
downloaded: u64,
/// 总字节数(未知为 `None`)。
total_bytes: Option<u64>,
},
/// 某个源失败(单次尝试)。
SourceFailed {
/// 关联 id。
id: DownloadId,
/// 源标识。
source: String,
/// 失败原因(短描述)。
reason: String,
/// 第几次尝试(从 0 起)。
attempt: u32,
/// 是否还会在同源重试。
will_retry: bool,
/// 下次重试的等待毫秒数。
retry_in_ms: u64,
},
/// 切换到下一个候选源。
SourceSwitched {
/// 关联 id。
id: DownloadId,
/// 原源。
from: String,
/// 新源。
to: String,
},
/// 进入校验阶段。
Verifying {
/// 关联 id。
id: DownloadId,
/// 算法。
algo: HashAlgo,
/// 期望摘要前缀(8 位,够定位又不太啰嗦)。
expected_prefix: String,
},
/// 下载成功。
Completed {
/// 关联 id。
id: DownloadId,
/// 字节数。
bytes: u64,
/// 是否命中内容寻址缓存。
from_cache: bool,
/// 成功的源。
source: String,
/// 耗时毫秒。
elapsed_ms: u64,
},
/// 下载失败(所有候选源都失败或不可重试)。
Failed {
/// 关联 id。
id: DownloadId,
/// 错误分类。
kind: DownloadErrorKind,
/// 错误描述。
message: String,
},
}
impl ProgressEvent {
/// 事件归属的下载 id。
#[must_use]
pub const fn id(&self) -> DownloadId {
match self {
Self::Started { id, .. }
| Self::Progress { id, .. }
| Self::SourceFailed { id, .. }
| Self::SourceSwitched { id, .. }
| Self::Verifying { id, .. }
| Self::Completed { id, .. }
| Self::Failed { id, .. } => *id,
}
}
}
/// 进度发射器:没有接收端时退化为空操作(core 不关心谁在听)。
#[derive(Debug, Clone, Default)]
pub struct ProgressEmitter {
tx: Option<UnboundedSender<ProgressEvent>>,
}
impl ProgressEmitter {
/// 静默发射器(测试、后台静默下载用)。
#[must_use]
pub const fn silent() -> Self {
Self { tx: None }
}
/// 建一对(发射器, 接收端);接收端由 app 层转成 IPC DTO。
#[must_use]
pub fn channel() -> (Self, UnboundedReceiver<ProgressEvent>) {
let (tx, rx) = mpsc::unbounded_channel();
(Self { tx: Some(tx) }, rx)
}
/// 发一条事件;接收端已关闭则静默丢弃。
pub fn emit(&self, event: ProgressEvent) {
if let Some(tx) = &self.tx {
let _ = tx.send(event);
}
}
/// 是否没有接收端。
#[must_use]
pub const fn is_silent(&self) -> bool {
self.tx.is_none()
}
}
/// 进度节流器:距上次发射不足 `interval` 且未完成时压掉本次事件。
#[derive(Debug, Clone)]
pub struct ProgressThrottle {
interval: Duration,
last_emit: Option<Instant>,
}
impl ProgressThrottle {
/// 构造节流器。
#[must_use]
pub const fn new(interval: Duration) -> Self {
Self {
interval,
last_emit: None,
}
}
/// 节流间隔。
#[must_use]
pub const fn interval(&self) -> Duration {
self.interval
}
/// 是否应该发射本次进度(`finished = true` 时无条件放行)。
pub fn should_emit(&mut self, now: Instant, finished: bool) -> bool {
if finished {
self.last_emit = Some(now);
return true;
}
match self.last_emit {
Some(last) if now.duration_since(last) < self.interval => false,
_ => {
self.last_emit = Some(now);
true
}
}
}
/// 复位(换源/重试时重新计时)。
pub fn reset(&mut self) {
self.last_emit = None;
}
}
impl Default for ProgressThrottle {
fn default() -> Self {
Self::new(Duration::from_millis(250))
}
}
+343
View File
@@ -0,0 +1,343 @@
//! 地区自动判定:时区 + 系统语言 + 网络探测 → 直连 / 镜像优先。
//!
//! 设计文档 05 §3.2(用户 Q19 定案):
//!
//! * 信号①时区 ∈ 大中华区列表,或 信号②系统语言 zh-* → 「大中华区候选」;
//! * 信号③网络探测(google/baidu,各 3s 超时,结果缓存 24h,可关);
//! * 判定:大中华区 && google 不可达 → **镜像优先**(并启用 gh-proxy);
//! google 可达(已有全局代理)→ **直连**(尊重用户代理);非大中华区 → 直连;
//! * 探测被关闭时,中文环境默认镜像优先(用户拍板的默认值)。
//!
//! 判定结果可由用户在设置里钉死(`RegionTrigger::UserPinned`),此时不参与自动重判。
use std::path::Path;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use super::atomic::write_atomic;
/// 大中华区时区(IANA 名)。
pub const GREATER_CHINA_TIMEZONES: &[&str] = &[
"Asia/Shanghai",
"Asia/Urumqi",
"Asia/Chongqing",
"Asia/Harbin",
"Asia/Hong_Kong",
"Asia/Macau",
"Asia/Taipei",
];
/// 中文语言前缀(大小写不敏感的前缀匹配)。
pub const GREATER_CHINA_LANGUAGE_PREFIXES: &[&str] = &["zh", "wuu", "yue", "lzh"];
/// 默认 gh-proxy 前缀(2026-10 实测可用;设置可改,`None` 表示不默认启用)。
pub const DEFAULT_GH_PROXY: &str = "https://gh-proxy.com/";
/// 探测超时(设计文档定死 3s)。
pub const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
/// 自动判定的有效期(24h)。
pub const DECISION_TTL_MS: u64 = 24 * 60 * 60 * 1000;
/// google 可达性探测点(204 短响应)。
pub const GOOGLE_PROBE_URL: &str = "https://www.gstatic.com/generate_204";
/// 百度可达性探测点。
pub const BAIDU_PROBE_URL: &str = "https://www.baidu.com/robots.txt";
/// 源选择模式。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RegionMode {
/// 直连:官方在前,镜像只做兜底。
Direct,
/// 镜像优先:镜像在前,官方兜底;GitHub 资源套 gh-proxy。
MirrorFirst,
}
impl RegionMode {
/// 稳定字符串 id。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Direct => "direct",
Self::MirrorFirst => "mirror_first",
}
}
}
/// 判定来源:自动 or 用户钉死。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RegionTrigger {
/// 自动判定(24h 后可重判)。
Auto,
/// 用户手动钉死(不参与自动重判)。
UserPinned,
}
/// 地区档案:引擎唯一需要知道的「用哪套源顺序」。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RegionProfile {
/// 直连 / 镜像优先。
pub mode: RegionMode,
/// 判定来源。
pub trigger: RegionTrigger,
/// 是否落在「大中华区候选」。
pub greater_china: bool,
/// gh-proxy 前缀(`None` = 不启用)。
pub gh_proxy: Option<String>,
/// 是否做过网络探测(设置里可关)。
pub probes_enabled: bool,
/// 判定时间(unix 毫秒)。
pub decided_at_ms: u64,
}
impl Default for RegionProfile {
fn default() -> Self {
Self::direct(0)
}
}
impl RegionProfile {
/// 显式直连。
#[must_use]
pub fn direct(now_ms: u64) -> Self {
Self {
mode: RegionMode::Direct,
trigger: RegionTrigger::Auto,
greater_china: false,
gh_proxy: None,
probes_enabled: false,
decided_at_ms: now_ms,
}
}
/// 镜像优先(并启用默认 gh-proxy)。
#[must_use]
pub fn mirror_first(now_ms: u64) -> Self {
Self {
mode: RegionMode::MirrorFirst,
trigger: RegionTrigger::Auto,
greater_china: true,
gh_proxy: Some(DEFAULT_GH_PROXY.to_string()),
probes_enabled: false,
decided_at_ms: now_ms,
}
}
/// 用户钉死某个模式(设置页「直连 / 镜像优先」)。
#[must_use]
pub fn pinned(mode: RegionMode, now_ms: u64) -> Self {
let mut profile = match mode {
RegionMode::Direct => Self::direct(now_ms),
RegionMode::MirrorFirst => Self::mirror_first(now_ms),
};
profile.trigger = RegionTrigger::UserPinned;
profile
}
/// gh-proxy 是否真的会生效(镜像优先 + 配了前缀)。
#[must_use]
pub fn gh_proxy_enabled(&self) -> bool {
self.mode == RegionMode::MirrorFirst
&& self
.gh_proxy
.as_deref()
.is_some_and(|prefix| !prefix.is_empty())
}
/// 是否按「国内」口径选源(镜像优先,或判定落在大中华区)。
///
/// 给 JDK 发行版默认优先级用(D23:国内 Temurin→Oracle→MS,国外 Temurin→MS→Oracle):
/// 用户把模式钉成直连但机器仍在国内时,仍然应该吃国内镜像更快的那个顺序。
#[must_use]
pub fn is_domestic(&self) -> bool {
self.mode == RegionMode::MirrorFirst || self.greater_china
}
/// 覆盖 gh-proxy 前缀。
#[must_use]
pub fn with_gh_proxy(mut self, prefix: Option<&str>) -> Self {
self.gh_proxy = prefix
.map(str::trim)
.filter(|prefix| !prefix.is_empty())
.map(str::to_string);
self
}
/// 标记是否允许网络探测。
#[must_use]
pub fn with_probes(mut self, enabled: bool) -> Self {
self.probes_enabled = enabled;
self
}
/// 自动判定是否已过期(用户钉死永不过期)。
#[must_use]
pub fn is_stale(&self, now_ms: u64) -> bool {
self.trigger == RegionTrigger::Auto
&& now_ms.saturating_sub(self.decided_at_ms) > DECISION_TTL_MS
}
/// 设置页展示用的中文标签。
#[must_use]
pub const fn label(&self) -> &'static str {
match self.mode {
RegionMode::Direct => "直连",
RegionMode::MirrorFirst => "镜像优先",
}
}
}
/// 判定输入信号。`None` 表示「未知/未探测」。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Signals {
/// 时区名(IANA)。
pub timezone: Option<String>,
/// 系统语言列表(BCP-47 或 POSIX locale,取首个非空即可)。
pub languages: Vec<String>,
/// google 可达性(`None` = 未探测/探测被关)。
pub google_reachable: Option<bool>,
/// baidu 可达性(诊断信息,暂不参与判定)。
pub baidu_reachable: Option<bool>,
}
impl Signals {
/// 从环境变量读(尽力而为:Windows 上通常没有 `TZ`,此时由 platform 层补精确值)。
#[must_use]
pub fn from_env() -> Self {
let timezone = std::env::var("TZ").ok().filter(|tz| !tz.trim().is_empty());
let mut languages = Vec::new();
for key in ["LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"] {
if let Ok(value) = std::env::var(key) {
for part in value.split([':', ';']) {
let part = part.trim();
if !part.is_empty() && !languages.iter().any(|known: &String| known == part) {
languages.push(part.to_string());
}
}
}
}
Self {
timezone,
languages,
google_reachable: None,
baidu_reachable: None,
}
}
/// 时区是否落在大中华区。
#[must_use]
pub fn timezone_in_greater_china(&self) -> bool {
self.timezone.as_deref().is_some_and(|tz| {
GREATER_CHINA_TIMEZONES
.iter()
.any(|known| known.eq_ignore_ascii_case(tz.trim()))
})
}
/// 系统语言是否是中文(含 `zh_CN.UTF-8` 这类 POSIX 形态)。
#[must_use]
pub fn language_in_greater_china(&self) -> bool {
self.languages.iter().any(|language| {
let lower = language.to_ascii_lowercase().replace('_', "-");
GREATER_CHINA_LANGUAGE_PREFIXES
.iter()
.any(|prefix| lower.starts_with(prefix))
})
}
/// 是否是大中华区候选(信号① 或 信号②)。
#[must_use]
pub fn is_greater_china(&self) -> bool {
self.timezone_in_greater_china() || self.language_in_greater_china()
}
}
/// 纯判定函数(不发请求 → 好测)。
#[must_use]
pub fn decide_from(signals: &Signals, now_ms: u64) -> RegionProfile {
let greater_china = signals.is_greater_china();
let mode = match (greater_china, signals.google_reachable) {
// 非大中华区:一律直连。
(false, _) => RegionMode::Direct,
// 大中华区但 google 可达:用户已有全局代理,尊重直连。
(true, Some(true)) => RegionMode::Direct,
// 大中华区且 google 不可达:镜像优先。
(true, Some(false)) => RegionMode::MirrorFirst,
// 探测被关:中文环境默认镜像优先(用户拍板)。
(true, None) => RegionMode::MirrorFirst,
};
let mut profile = match mode {
RegionMode::Direct => RegionProfile::direct(now_ms),
RegionMode::MirrorFirst => RegionProfile::mirror_first(now_ms),
};
profile.greater_china = greater_china;
profile.probes_enabled = signals.google_reachable.is_some();
if mode == RegionMode::Direct {
profile.gh_proxy = None;
}
profile
}
/// 单点可达性探测(3s 超时,任何错误都算不可达)。
pub async fn probe_reachable(client: &reqwest::Client, url: &str) -> bool {
let request = client.get(url).header(reqwest::header::RANGE, "bytes=0-0");
match tokio::time::timeout(PROBE_TIMEOUT, request.send()).await {
Ok(Ok(response)) => {
let status = response.status();
status.is_success() || status.is_redirection() || status.as_u16() == 204
}
_ => false,
}
}
/// 并发探测 google 与 baidu(两个都 3s 上限,总耗时约 3s)。
pub async fn probe_signals(client: &reqwest::Client) -> (Option<bool>, Option<bool>) {
let (google, baidu) = tokio::join!(
probe_reachable(client, GOOGLE_PROBE_URL),
probe_reachable(client, BAIDU_PROBE_URL)
);
(Some(google), Some(baidu))
}
/// 完整判定:读信号 →(可选)探测 → 决定。
pub async fn detect(client: &reqwest::Client, probes_enabled: bool, now_ms: u64) -> RegionProfile {
let mut signals = Signals::from_env();
if probes_enabled {
let (google, baidu) = probe_signals(client).await;
signals.google_reachable = google;
signals.baidu_reachable = baidu;
}
decide_from(&signals, now_ms)
}
/// 判定结果的落盘形态。
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PersistedProfile {
version: u32,
profile: RegionProfile,
}
/// 读 24h 缓存(过期或损坏返回 `None`)。
#[must_use]
pub fn load_cached(path: &Path, now_ms: u64) -> Option<RegionProfile> {
let bytes = std::fs::read(path).ok()?;
let persisted: PersistedProfile = serde_json::from_slice(&bytes).ok()?;
if persisted.profile.is_stale(now_ms) {
return None;
}
Some(persisted.profile)
}
/// 写 24h 缓存(原子写)。
pub fn store_cached(path: &Path, profile: &RegionProfile) -> std::io::Result<()> {
let persisted = PersistedProfile {
version: 1,
profile: profile.clone(),
};
let json = serde_json::to_vec_pretty(&persisted).map_err(std::io::Error::other)?;
write_atomic(path, &json)
}
+158
View File
@@ -0,0 +1,158 @@
//! 地区判定的**系统级**信号补充:环境变量之外的那一半(D23「国内用户默认都走镜像」)。
//!
//! 为什么需要它:`Signals::from_env` 只读 `TZ` / `LANG` / `LC_*`。Windows 上这三个通常**全是空的**
//! ——中文版 Windows 既不设 `TZ` 也不设 `LANG`,于是「国内机器」被误判成国外,
//! D23 定下的国内默认序(Temurin→Oracle→MS)就拿不到了。本机实测就是这个现象。
//!
//! 补法(只在环境变量没给出答案时才做,且读不到就静默跳过):
//!
//! | 平台 | 时区 | 语言 |
//! | --- | --- | --- |
//! | Windows | `tzutil /g` → `China Standard Time` | `reg query` 的 `LocaleName` → `zh-CN` |
//! | 其它 | 不补(Unix 一般都有 `LANG`,`/etc/localtime` 解析成本不划算) | 同左 |
//!
//! 两个纪律:①**不引依赖**——`tzutil` / `reg` 都是系统自带,`discover.rs` 早就有 `reg query`
//! 的先例;②**不做网络探测**——探测(google 可达性)仍由调用方决定开不开。
use std::process::Command;
use super::region::{Signals, GREATER_CHINA_TIMEZONES};
/// Windows 时区显示名里能判成「大中华区」的关键词(`tzutil /g` 的输出形态)。
const GREATER_CHINA_ZONE_HINTS: &[&str] = &[
"China Standard Time",
"Taipei Standard Time",
"Hong Kong Standard Time",
"Ulaanbaatar",
];
/// 系统级信号(环境变量之外;读不到就是空信号)。
#[must_use]
pub fn system_signals() -> Signals {
let mut signals = Signals::default();
if !cfg!(windows) {
return signals;
}
if let Some(zone) = windows_timezone() {
if zone_in_greater_china(&zone) {
// 折成 IANA 名,和 `Signals::from_env` 的取值空间保持一致。
signals.timezone = Some("Asia/Shanghai".to_string());
}
}
if let Some(locale) = windows_locale() {
signals.languages.push(locale);
}
signals
}
/// 环境变量信号 + 系统级信号(系统级只补空缺,不覆盖环境变量)。
#[must_use]
pub fn detect_signals() -> Signals {
let mut signals = Signals::from_env();
let system = system_signals();
if signals.timezone.is_none() {
signals.timezone = system.timezone;
}
for language in system.languages {
if !signals.languages.contains(&language) {
signals.languages.push(language);
}
}
signals
}
/// `tzutil /g` 的输出(失败为 `None`)。
#[must_use]
pub fn windows_timezone() -> Option<String> {
let output = Command::new("tzutil").arg("/g").output().ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!text.is_empty()).then_some(text)
}
/// 系统的 locale 名(`HKCU\Control Panel\International` 的 `LocaleName`)。
#[must_use]
pub fn windows_locale() -> Option<String> {
let output = Command::new("reg")
.args([
"query",
"HKCU\\Control Panel\\International",
"/v",
"LocaleName",
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
parse_reg_locale_name(&stdout)
}
/// 从 `reg query` 的输出里取 `LocaleName` 的值(` LocaleName REG_SZ zh-CN`)。
#[must_use]
pub fn parse_reg_locale_name(stdout: &str) -> Option<String> {
for line in stdout.lines() {
let line = line.trim();
if !line.starts_with("LocaleName") {
continue;
}
let value = line.rsplit("REG_SZ").next()?.trim();
if !value.is_empty() {
return Some(value.to_string());
}
}
None
}
/// Windows 时区显示名是否落在大中华区。
#[must_use]
pub fn zone_in_greater_china(zone: &str) -> bool {
let zone = zone.trim();
GREATER_CHINA_ZONE_HINTS
.iter()
.any(|hint| zone.eq_ignore_ascii_case(hint))
|| GREATER_CHINA_TIMEZONES
.iter()
.any(|known| known.eq_ignore_ascii_case(zone))
}
#[cfg(test)]
mod tests {
use super::{detect_signals, parse_reg_locale_name, zone_in_greater_china};
#[test]
fn windows_zone_names_map_to_the_greater_china_list() {
assert!(zone_in_greater_china("China Standard Time"));
assert!(zone_in_greater_china("Taipei Standard Time"));
assert!(zone_in_greater_china("Asia/Shanghai"), "IANA 名同样要认");
assert!(!zone_in_greater_china("Pacific Standard Time"));
assert!(!zone_in_greater_china(""));
}
#[test]
fn reg_output_yields_the_locale_name() {
// 真机 `reg query "HKCU\Control Panel\International" /v LocaleName` 的形态。
let stdout = "\r\nHKEY_CURRENT_USER\\Control Panel\\International\r\n \
LocaleName REG_SZ zh-CN\r\n\r\n";
assert_eq!(parse_reg_locale_name(stdout).as_deref(), Some("zh-CN"));
assert_eq!(
parse_reg_locale_name(" LocaleName REG_SZ \r\n"),
None
);
assert_eq!(parse_reg_locale_name("没有这一行"), None);
}
#[test]
fn detect_layers_system_signals_under_the_environment() {
// 环境变量优先:这里能断言的只有「不 panic 且返回一个 Signals」,
// 具体取值取决于跑测试的机器(CI 上没有 tzutil/reg 也要安静退化)。
let signals = detect_signals();
// 无论哪种情况,都不该凭空造出「大中华区」以外的时区值。
if let Some(zone) = &signals.timezone {
assert!(!zone.trim().is_empty());
}
}
}
+235
View File
@@ -0,0 +1,235 @@
//! 断点续传的**保守判定**。
//!
//! 承 analyse/03 §5.4 与设计文档 §4 最后一行:「拒绝『能续传但会坏』场景」是 HMCL 做对的事。
//! 我们把它写成纯函数,判定条件全部显式:
//!
//! 1. 策略不是 `Never`;
//! 2. 中间文件存在且长度 > 0;
//! 3. 有上一次的元数据(URL、摘要、总长、校验器);
//! 4. URL 与期望摘要都没变;
//! 5. 总长已知且 `0 < 已下 < 总长`;
//! 6. 有 ETag 或 Last-Modified 作为校验器(否则服务器换了内容我们也发现不了)。
//!
//! 任何一条不满足就**从头下**——重下 100MB 也比交付一个坏文件便宜。
//! 另外单独处理 416(`Requested Range Not Satisfiable`):已下满则直接进入校验,
//! 否则丢弃中间文件重来。
use std::io;
use std::path::Path;
use serde::{Deserialize, Serialize};
use super::atomic::write_atomic;
use super::types::{HashAlgo, ResumePolicy};
/// 上一次下载留下的续传元数据。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResumeMeta {
/// 当时请求的 URL(镜像可能换掉,URL 变了就不续传)。
pub url: String,
/// 响应 `ETag`。
pub etag: Option<String>,
/// 响应 `Last-Modified`。
pub last_modified: Option<String>,
/// 响应告知的总长度。
pub total: Option<u64>,
/// 摘要算法。
pub algo: HashAlgo,
/// 期望摘要。
pub hash: String,
/// 保存时间(unix 毫秒)。
pub saved_at_ms: u64,
}
impl ResumeMeta {
/// 从磁盘读(不存在/损坏 → `None`)。
#[must_use]
pub fn load(path: &Path) -> Option<Self> {
let bytes = std::fs::read(path).ok()?;
serde_json::from_slice(&bytes).ok()
}
/// 原子写。
pub fn save(&self, path: &Path) -> io::Result<()> {
let json = serde_json::to_vec_pretty(self).map_err(io::Error::other)?;
write_atomic(path, &json)
}
/// 删除(校验通过或放弃续传时)。
pub fn remove(path: &Path) {
let _ = std::fs::remove_file(path);
}
/// 本记录可用的校验器。
#[must_use]
pub fn validator(&self) -> Validator {
Validator {
etag: self.etag.clone(),
last_modified: self.last_modified.clone(),
}
}
}
/// 续传用的校验器(`If-Range` 头的取值来源)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Validator {
/// 强校验器。
pub etag: Option<String>,
/// 弱校验器。
pub last_modified: Option<String>,
}
impl Validator {
/// `If-Range` 头的值(ETag 优先,其次 Last-Modified)。
#[must_use]
pub fn if_range_value(&self) -> Option<&str> {
self.etag.as_deref().or(self.last_modified.as_deref())
}
}
/// 不复续传的原因(日志与诊断页用)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RestartReason {
/// 策略要求从不续传。
PolicyNever,
/// 没有中间文件。
NoPart,
/// 中间文件是 0 字节。
EmptyPart,
/// 没有元数据(无法证明中间文件是同一次下载的产物)。
NoMeta,
/// URL 变了(换源后偏移不可信)。
UrlChanged,
/// 期望摘要变了(目标文件已经不是同一个东西)。
HashChanged,
/// 总长未知(无法判断是否已下满)。
TotalUnknown,
/// 已下字节超过总长(中间文件被污染)。
Oversize,
/// 没有 ETag/Last-Modified,无法证明远端内容没变。
NoValidator,
/// 服务器 416 且未下满。
RangeUnsatisfiable,
}
impl RestartReason {
/// 稳定字符串 id。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::PolicyNever => "policy_never",
Self::NoPart => "no_part",
Self::EmptyPart => "empty_part",
Self::NoMeta => "no_meta",
Self::UrlChanged => "url_changed",
Self::HashChanged => "hash_changed",
Self::TotalUnknown => "total_unknown",
Self::Oversize => "oversize",
Self::NoValidator => "no_validator",
Self::RangeUnsatisfiable => "range_unsatisfiable",
}
}
}
/// 续传决策。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResumeDecision {
/// 可以续传:从 `offset` 起发 `Range` + `If-Range`。
Resume {
/// 已下字节数。
offset: u64,
/// 校验器。
validator: Validator,
},
/// 从头下载。
Restart {
/// 原因。
reason: RestartReason,
},
/// 中间文件已经是完整长度:跳过传输,直接校验。
Complete {
/// 文件长度。
size: u64,
},
}
impl ResumeDecision {
/// 是否要丢掉中间文件(`Complete` 不能丢)。
#[must_use]
pub const fn should_drop_part(&self) -> bool {
matches!(self, Self::Restart { .. })
}
}
/// 判定是否续传(纯函数)。
#[must_use]
pub fn decide(
policy: ResumePolicy,
part_len: u64,
meta: Option<&ResumeMeta>,
url: &str,
integrity: Option<(HashAlgo, &str)>,
expected_size: Option<u64>,
) -> ResumeDecision {
if policy == ResumePolicy::Never {
return ResumeDecision::Restart {
reason: RestartReason::PolicyNever,
};
}
if part_len == 0 {
return ResumeDecision::Restart {
reason: RestartReason::EmptyPart,
};
}
let Some(meta) = meta else {
return ResumeDecision::Restart {
reason: RestartReason::NoMeta,
};
};
if meta.url != url {
return ResumeDecision::Restart {
reason: RestartReason::UrlChanged,
};
}
if let Some((algo, hash)) = integrity {
if meta.algo != algo || !meta.hash.eq_ignore_ascii_case(hash) {
return ResumeDecision::Restart {
reason: RestartReason::HashChanged,
};
}
}
let Some(total) = meta.total.or(expected_size) else {
return ResumeDecision::Restart {
reason: RestartReason::TotalUnknown,
};
};
if part_len > total {
return ResumeDecision::Restart {
reason: RestartReason::Oversize,
};
}
if part_len == total {
return ResumeDecision::Complete { size: total };
}
let validator = meta.validator();
if validator.if_range_value().is_none() {
return ResumeDecision::Restart {
reason: RestartReason::NoValidator,
};
}
ResumeDecision::Resume {
offset: part_len,
validator,
}
}
/// 收到 416 后的决策。
#[must_use]
pub fn on_range_unsatisfiable(part_len: u64, total: Option<u64>) -> ResumeDecision {
match total {
Some(total) if part_len == total => ResumeDecision::Complete { size: total },
_ => ResumeDecision::Restart {
reason: RestartReason::RangeUnsatisfiable,
},
}
}
+305
View File
@@ -0,0 +1,305 @@
//! 传输细节:单连接(含续传)+ 多连接分块,产出「已校验的完整中间文件」。
//!
//! 本文件只管「一个候选源的一次尝试」——换源、退避、限流在 `engine.rs`。
use std::time::Instant;
use tokio::io::AsyncWriteExt;
use super::atomic::PartFile;
use super::cache::now_ms;
use super::chunked;
use super::engine::{AttemptOk, DownloadEngine};
use super::error::{DownloadError, DownloadErrorKind};
use super::http;
use super::progress::{ProgressEvent, ProgressThrottle};
use super::resume::{self, RestartReason, ResumeDecision, ResumeMeta};
use super::types::{Candidate, DownloadId, DownloadRequest, HashAlgo};
use super::verify;
/// 续传起点(无续传时全为默认值)。
struct ResumeCut {
/// 已下字节数。
offset: u64,
/// 续传起点(事件字段)。
resumed_from: u64,
/// `If-Range` 的取值。
if_range: Option<String>,
}
impl ResumeCut {
/// 从头下载。
const fn none() -> Self {
Self {
offset: 0,
resumed_from: 0,
if_range: None,
}
}
}
impl DownloadEngine {
/// 一次尝试:续传判定 → 分块 or 单连接 → 流式校验。
pub(crate) async fn attempt(
&self,
id: DownloadId,
req: &DownloadRequest,
candidate: &Candidate,
part: &mut PartFile,
) -> Result<AttemptOk, DownloadError> {
let integrity = req.integrity.as_ref();
let meta_path = part.meta_path();
let previous = ResumeMeta::load(&meta_path);
// 「没有中间文件」和「有但 0 字节」是两种不同的原因,分开报出来便于诊断。
let decision = if !part.exists() {
ResumeDecision::Restart {
reason: RestartReason::NoPart,
}
} else if part.is_empty() {
ResumeDecision::Restart {
reason: RestartReason::EmptyPart,
}
} else {
resume::decide(
req.resume,
part.len(),
previous.as_ref(),
&candidate.url,
integrity.map(|item| (item.algo, item.hex.as_str())),
req.expected_size,
)
};
let mut cut = ResumeCut::none();
match decision {
ResumeDecision::Complete { size } => {
self.verify_part(part, integrity, id)?;
return Ok(AttemptOk {
bytes: size,
resumed_from: size,
});
}
ResumeDecision::Resume { offset, validator } => {
if let Some(value) = validator.if_range_value() {
cut = ResumeCut {
offset,
resumed_from: offset,
if_range: Some(value.to_string()),
};
}
}
ResumeDecision::Restart { .. } => {}
}
if cut.offset == 0 && self.cfg.chunked.applies_to(req.kind, req.expected_size) {
match self
.attempt_chunked(id, req, candidate, part, req.expected_size)
.await
{
Ok(ok) => return Ok(ok),
// Range 不支持/切不出足够段:静默退化为单连接(不算源失败)。
Err(err) if err.kind == DownloadErrorKind::RangeUnsupported => {}
Err(err) => return Err(err),
}
}
self.attempt_single(id, req, candidate, part, cut).await
}
async fn attempt_single(
&self,
id: DownloadId,
req: &DownloadRequest,
candidate: &Candidate,
part: &mut PartFile,
cut: ResumeCut,
) -> Result<AttemptOk, DownloadError> {
let integrity = req.integrity.as_ref();
let mut offset = cut.offset;
let mut resumed_from = cut.resumed_from;
let mut request = self.http.get(&candidate.url);
if offset > 0 {
match cut.if_range.as_deref() {
Some(value) => {
request = request
.header(reqwest::header::RANGE, format!("bytes={offset}-"))
.header(reqwest::header::IF_RANGE, value);
}
// 没有校验器就不能保证远端内容没变,直接从头下。
None => {
offset = 0;
resumed_from = 0;
}
}
}
let response = request.send().await.map_err(|err| {
http::classify_reqwest(&err, &candidate.url).with_source(candidate.source.as_str())
})?;
let status = response.status();
if status.as_u16() == 416 {
return match resume::on_range_unsatisfiable(part.len(), req.expected_size) {
ResumeDecision::Complete { size } => {
self.verify_part(part, integrity, id)?;
Ok(AttemptOk {
bytes: size,
resumed_from: size,
})
}
_ => {
part.discard();
Err(DownloadError::new(
DownloadErrorKind::RangeUnsupported,
"416:丢弃中间文件重下",
)
.with_source(candidate.source.as_str()))
}
};
}
if !status.is_success() {
return Err(http::classify_status(status.as_u16(), &candidate.url)
.with_source(candidate.source.as_str()));
}
// 2xx 不等于「拿到归档」:镜像门户对任意路径都回 200 + HTML(华为云实测)。
// 归到 NotFound(换源,不算源坏不进冷却),否则假阳性源会一直挡在队首。
if let Some(reason) =
http::html_mirror_reason(response.headers(), req.expected_size, offset)
{
return Err(DownloadError::new(DownloadErrorKind::NotFound, reason)
.with_source(candidate.source.as_str()));
}
// 服务器无视了 Range(200 而非 206)→ 从头写。
if offset > 0 && status.as_u16() != 206 {
offset = 0;
resumed_from = 0;
}
let content_range = http::header_str(response.headers(), reqwest::header::CONTENT_RANGE)
.and_then(chunked::parse_content_range);
let etag = http::etag(response.headers());
let last_modified = http::last_modified(response.headers());
let content_length = http::content_length(response.headers());
let mut total = req.expected_size;
if let Some(content_range) = content_range {
if content_range.start != offset {
// 服务器给的起点和我们以为的不一样:偏移不可信,从头来。
offset = 0;
resumed_from = 0;
}
total = content_range.total.or(total);
}
total = total.or_else(|| content_length.map(|len| len + offset));
// 记下续传元数据(跨进程续传的凭据)。
let meta = ResumeMeta {
url: candidate.url.clone(),
etag,
last_modified,
total,
algo: integrity.map_or(HashAlgo::Sha1, |item| item.algo),
hash: integrity.map_or_else(String::new, |item| item.hex.clone()),
saved_at_ms: now_ms(),
};
let _ = meta.save(&part.meta_path());
let std_file = if offset > 0 {
part.open_append()
} else {
part.open_truncate()
}
.map_err(|err| io_err("打开中间文件失败", &err))?;
let mut file = tokio::fs::File::from_std(std_file);
let mut hasher = integrity.map(|item| verify::Hasher::new(item.algo));
if offset > 0 {
if let Some(hasher) = hasher.as_mut() {
verify::update_hasher_from_file(hasher, part.path(), offset)
.map_err(|err| io_err("读取续传前缀失败", &err))?;
}
}
let mut throttle = ProgressThrottle::new(self.cfg.progress_throttle);
let mut written = offset;
let mut stream = response.bytes_stream();
loop {
let next = tokio::time::timeout(
self.cfg.read_timeout,
futures_util::StreamExt::next(&mut stream),
)
.await
.map_err(|_| {
DownloadError::new(
DownloadErrorKind::Timeout,
format!("读取超时({})", candidate.url),
)
.with_source(candidate.source.as_str())
})?;
let Some(chunk) = next else { break };
let chunk = chunk.map_err(|err| http::classify_reqwest(&err, &candidate.url))?;
if let Some(hasher) = hasher.as_mut() {
hasher.update(&chunk);
}
file.write_all(&chunk)
.await
.map_err(|err| io_err("写入中间文件失败", &err))?;
written += chunk.len() as u64;
if throttle.should_emit(Instant::now(), false) {
self.emit(ProgressEvent::Progress {
id,
downloaded: written,
total_bytes: total,
});
}
}
file.flush()
.await
.map_err(|err| io_err("刷新中间文件失败", &err))?;
// Windows 上打开中的文件不能被 rename,必须先关。
drop(file);
if let Some(total) = total {
if written != total {
return Err(DownloadError::new(
DownloadErrorKind::Network,
format!("长度不符:期望 {total} 实得 {written}({})", candidate.url),
)
.with_source(candidate.source.as_str()));
}
}
if let (Some(integrity), Some(hasher)) = (integrity, hasher) {
self.emit(ProgressEvent::Verifying {
id,
algo: integrity.algo,
expected_prefix: integrity.short_prefix().to_string(),
});
let actual = hasher.finish();
if !verify::matches(&integrity.hex, &actual) {
part.discard();
return Err(DownloadError::new(
DownloadErrorKind::Integrity,
format!("校验不符:期望 {} 实得 {actual}", integrity.hex),
)
.with_source(candidate.source.as_str()));
}
}
self.emit(ProgressEvent::Progress {
id,
downloaded: written,
total_bytes: total,
});
Ok(AttemptOk {
bytes: written,
resumed_from,
})
}
}
fn io_err(context: &str, err: &std::io::Error) -> DownloadError {
DownloadError::new(DownloadErrorKind::Io, format!("{context}: {err}"))
}
+163
View File
@@ -0,0 +1,163 @@
//! 大文件多连接分块的编排与校验。
//!
//! 分块本身由 `chunked.rs` 负责(纯规划 + 单块抓取),这里负责并发、进度上报、
//! 失败清理与「整文件重算一次摘要」(分块是乱序写,只能整文件校验)。
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use super::atomic::PartFile;
use super::chunked;
use super::engine::{AttemptOk, DownloadEngine};
use super::error::{DownloadError, DownloadErrorKind};
use super::progress::ProgressEvent;
use super::types::{Candidate, DownloadId, DownloadRequest, Integrity};
use super::verify;
impl DownloadEngine {
pub(crate) async fn attempt_chunked(
&self,
id: DownloadId,
req: &DownloadRequest,
candidate: &Candidate,
part: &mut PartFile,
total: Option<u64>,
) -> Result<AttemptOk, DownloadError> {
let Some(total) = total.filter(|total| *total > 0) else {
return Err(DownloadError::new(
DownloadErrorKind::RangeUnsupported,
"总长未知,无法分块",
));
};
let ranges = chunked::plan(total, self.cfg.chunked.effective_connections());
if ranges.len() < 2 {
return Err(DownloadError::new(
DownloadErrorKind::RangeUnsupported,
"可切分段不足,退化为单连接",
));
}
part.discard();
part.ensure_parent_async()
.await
.map_err(|err| io_err("创建下载目录失败", &err))?;
{
let file = tokio::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(part.path())
.await
.map_err(|err| io_err("预分配中间文件失败", &err))?;
file.set_len(total)
.await
.map_err(|err| io_err("预分配长度失败", &err))?;
}
let counter = Arc::new(AtomicU64::new(0));
let reporter = self.spawn_reporter(id, Arc::clone(&counter), total);
let mut set = tokio::task::JoinSet::new();
for range in ranges {
let client = self.http.clone();
let url = candidate.url.clone();
let path = part.path().to_path_buf();
let read_timeout = self.cfg.read_timeout;
let progress = Arc::clone(&counter);
set.spawn(async move {
chunked::fetch_chunk(&client, &url, range, &path, read_timeout, &progress).await
});
}
let mut failure: Option<DownloadError> = None;
while let Some(joined) = set.join_next().await {
match joined {
Ok(Ok(_)) => {}
Ok(Err(err)) => {
failure = Some(err.with_source(candidate.source.as_str()));
set.abort_all();
break;
}
Err(err) => {
failure = Some(DownloadError::new(
DownloadErrorKind::Network,
format!("分块任务异常:{err}"),
));
set.abort_all();
break;
}
}
}
reporter.abort();
if let Some(err) = failure {
part.discard();
return Err(err);
}
// 分块是乱序写,只能整文件重算一次摘要。
self.verify_part(part, req.integrity.as_ref(), id)?;
self.emit(ProgressEvent::Progress {
id,
downloaded: total,
total_bytes: Some(total),
});
Ok(AttemptOk {
bytes: total,
resumed_from: 0,
})
}
fn spawn_reporter(
&self,
id: DownloadId,
counter: Arc<AtomicU64>,
total: u64,
) -> tokio::task::JoinHandle<()> {
let emitter = self.progress.clone();
let interval = self.cfg.progress_throttle.max(Duration::from_millis(50));
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
loop {
ticker.tick().await;
let downloaded = counter.load(Ordering::Relaxed).min(total);
emitter.emit(ProgressEvent::Progress {
id,
downloaded,
total_bytes: Some(total),
});
}
})
}
pub(crate) fn verify_part(
&self,
part: &mut PartFile,
integrity: Option<&Integrity>,
id: DownloadId,
) -> Result<(), DownloadError> {
let Some(integrity) = integrity else {
return Ok(());
};
self.emit(ProgressEvent::Verifying {
id,
algo: integrity.algo,
expected_prefix: integrity.short_prefix().to_string(),
});
let actual = verify::digest_file(part.path(), integrity.algo)
.map_err(|err| io_err("读取中间文件失败", &err))?;
if verify::matches(&integrity.hex, &actual) {
return Ok(());
}
part.discard();
Err(DownloadError::new(
DownloadErrorKind::Integrity,
format!("校验不符:期望 {} 实得 {actual}", integrity.hex),
))
}
}
fn io_err(context: &str, err: &std::io::Error) -> DownloadError {
DownloadError::new(DownloadErrorKind::Io, format!("{context}: {err}"))
}
+307
View File
@@ -0,0 +1,307 @@
//! 下载引擎的请求/结果类型:资源类型、摘要、请求、候选源、结果。
//!
//! 调用方(game / launch / agent)只需要描述「逻辑资源 + 落盘路径 + 期望校验」,
//! 镜像展开、地区模式、健康度排序、重试、续传、分块全部由引擎内部完成。
use std::fmt;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use serde::{Deserialize, Serialize};
/// 资源类型。
///
/// 同时用作三件事:健康度统计的第二维度(`源 × 资源类型`)、分块下载的准入条件、
/// 进度事件的分类标签。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResourceKind {
/// 资源对象(`assets/objects/<xx>/<hash>`),海量小文件。
Asset,
/// 库文件(MB 级)。
Library,
/// 客户端 jar(MB~几十 MB)。
ClientJar,
/// 版本清单 / 版本 JSON(强时效,短 TTL)。
VersionManifest,
/// 资源索引 `assets/indexes/<id>.json`。
AssetIndex,
/// JDK 运行时(~180MB),分块下载的主力场景。
JavaRuntime,
/// GitHub release 产物(自更新 / mod)。
GitHubRelease,
/// mod 文件(CurseForge / Modrinth)。
ModFile,
/// 其它。
Other,
}
impl ResourceKind {
/// 稳定字符串 id(持久化与日志用,**不要改名**)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Asset => "asset",
Self::Library => "library",
Self::ClientJar => "client_jar",
Self::VersionManifest => "version_manifest",
Self::AssetIndex => "asset_index",
Self::JavaRuntime => "java_runtime",
Self::GitHubRelease => "github_release",
Self::ModFile => "mod_file",
Self::Other => "other",
}
}
/// 是否默认走多连接分块下载(大文件)。
#[must_use]
pub const fn prefers_chunked(self) -> bool {
matches!(self, Self::JavaRuntime | Self::ModFile)
}
/// 是否属于「小文件」:小文件不开分块(续传开销大于收益)。
#[must_use]
pub const fn is_small(self) -> bool {
matches!(
self,
Self::Asset | Self::VersionManifest | Self::AssetIndex | Self::Other
)
}
}
/// 摘要算法。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HashAlgo {
/// SHA-1(Mojang 全线使用)。
Sha1,
/// SHA-256(自更新产物与部分镜像)。
Sha256,
}
impl HashAlgo {
/// 稳定字符串 id(缓存目录名与索引键用)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Sha1 => "sha1",
Self::Sha256 => "sha256",
}
}
/// 十六进制摘要的字符数。
#[must_use]
pub const fn hex_len(self) -> usize {
match self {
Self::Sha1 => 40,
Self::Sha256 => 64,
}
}
}
/// 期望校验值。构造时自动归一化为小写十六进制。
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Integrity {
/// 算法。
pub algo: HashAlgo,
/// 小写十六进制摘要。
pub hex: String,
}
impl Integrity {
/// 构造 SHA-1 校验值。
#[must_use]
pub fn sha1(hex: impl Into<String>) -> Self {
Self {
algo: HashAlgo::Sha1,
hex: hex.into().trim().to_ascii_lowercase(),
}
}
/// 构造 SHA-256 校验值。
#[must_use]
pub fn sha256(hex: impl Into<String>) -> Self {
Self {
algo: HashAlgo::Sha256,
hex: hex.into().trim().to_ascii_lowercase(),
}
}
/// 长度与字符集是否合法(不合法时调用方应显式传 `None`,别让引擎白跑一趟)。
#[must_use]
pub fn is_well_formed(&self) -> bool {
self.hex.len() == self.algo.hex_len() && self.hex.bytes().all(|b| b.is_ascii_hexdigit())
}
/// 给日志/事件用的短前缀(前 8 位)。
#[must_use]
pub fn short_prefix(&self) -> &str {
let end = self.hex.len().min(8);
&self.hex[..end]
}
}
/// 断点续传策略。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResumePolicy {
/// 保守判定(默认):长度、ETag/Last-Modified 都对得上才续传(承 HMCL `HttpResumeContext`)。
#[default]
Conservative,
/// 从不续传(强时效资源、或镜像对 Range 支持可疑时)。
Never,
}
/// 单次下载的关联 id(进度事件靠它归并)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct DownloadId(pub u64);
impl DownloadId {
/// 进程内单调递增的 id。
#[must_use]
pub fn next() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(1);
Self(NEXT.fetch_add(1, Ordering::Relaxed))
}
/// 原始数值。
#[must_use]
pub const fn value(self) -> u64 {
self.0
}
}
impl fmt::Display for DownloadId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "#{}", self.0)
}
}
/// 一个下载请求:逻辑资源描述,不含任何镜像/重试策略。
#[derive(Debug, Clone)]
pub struct DownloadRequest {
/// 资源类型。
pub kind: ResourceKind,
/// **逻辑 URL(官方源)**。镜像按镜像表 + 地区模式展开成候选序列。
pub url: String,
/// 最终落盘路径(父目录自动创建)。
pub dest: PathBuf,
/// 期望校验值;`None` 表示只能靠长度兜底(不推荐,清单类资源必须带)。
pub integrity: Option<Integrity>,
/// 期望大小(进度预算 + 分块规划)。
pub expected_size: Option<u64>,
/// 续传策略。
pub resume: ResumePolicy,
}
impl DownloadRequest {
/// 构造请求(默认保守续传、无校验)。
#[must_use]
pub fn new(kind: ResourceKind, url: impl Into<String>, dest: impl Into<PathBuf>) -> Self {
Self {
kind,
url: url.into(),
dest: dest.into(),
integrity: None,
expected_size: None,
resume: ResumePolicy::Conservative,
}
}
/// 设置 SHA-1 校验。
#[must_use]
pub fn with_sha1(mut self, hex: impl Into<String>) -> Self {
self.integrity = Some(Integrity::sha1(hex));
self
}
/// 设置 SHA-256 校验。
#[must_use]
pub fn with_sha256(mut self, hex: impl Into<String>) -> Self {
self.integrity = Some(Integrity::sha256(hex));
self
}
/// 设置期望大小。
#[must_use]
pub fn with_size(mut self, size: u64) -> Self {
self.expected_size = Some(size);
self
}
/// 设置续传策略。
#[must_use]
pub fn with_resume(mut self, resume: ResumePolicy) -> Self {
self.resume = resume;
self
}
/// 请求本身是否自洽(运行期挡住明显错误的请求)。
pub fn validate(&self) -> Result<(), super::error::DownloadError> {
use super::error::{DownloadError, DownloadErrorKind};
if self.url.is_empty() {
return Err(DownloadError::new(
DownloadErrorKind::InvalidRequest,
"url 为空",
));
}
if !(self.url.starts_with("http://") || self.url.starts_with("https://")) {
return Err(DownloadError::new(
DownloadErrorKind::InvalidRequest,
format!("仅支持 http(s) URL:{}", self.url),
));
}
if let Some(integrity) = &self.integrity {
if !integrity.is_well_formed() {
return Err(DownloadError::new(
DownloadErrorKind::InvalidRequest,
format!("校验值不合法:{}", integrity.hex),
));
}
}
Ok(())
}
}
/// 一个候选源(镜像展开产物)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Candidate {
/// 真正要请求的 URL。
pub url: String,
/// 源标识(`official` / `bmclapi` / `tuna` / `gh_proxy` …),健康度按此归并。
pub source: String,
}
impl Candidate {
/// 构造候选。
#[must_use]
pub fn new(source: impl Into<String>, url: impl Into<String>) -> Self {
Self {
source: source.into(),
url: url.into(),
}
}
}
/// 官方源候选的固定 `source` 标识。
pub const SOURCE_OFFICIAL: &str = "official";
/// 下载成功的结果。
#[derive(Debug, Clone)]
pub struct DownloadOutcome {
/// 关联 id。
pub id: DownloadId,
/// 落盘路径。
pub path: PathBuf,
/// 实际字节数(续传时含已存在的部分)。
pub bytes: u64,
/// 是否零网络命中内容寻址缓存。
pub from_cache: bool,
/// 实际成功的源。
pub source: String,
/// 续传起点(0 = 从头下载)。
pub resumed_from: u64,
/// 耗时。
pub elapsed: Duration,
}
+123
View File
@@ -0,0 +1,123 @@
//! 流式摘要:边下边算,不额外读一遍盘。
//!
//! 承 analyse/03 §5.6 的「先校验后落盘」:`MessageDigest` 在写流的同时喂数据,
//! 流结束时比对,不符就丢掉中间文件。
use std::fs::File;
use std::io::{self, Read};
use std::path::Path;
use sha1::Digest;
use super::types::HashAlgo;
/// 小写十六进制编码(不引 `hex` 依赖)。
#[must_use]
pub fn to_hex(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(char::from(HEX[usize::from(byte >> 4)]));
out.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
out
}
/// 增量摘要器。
#[derive(Debug)]
pub enum Hasher {
/// SHA-1。
Sha1(sha1::Sha1),
/// SHA-256。
Sha256(sha2::Sha256),
}
impl Hasher {
/// 按算法新建。
#[must_use]
pub fn new(algo: HashAlgo) -> Self {
match algo {
HashAlgo::Sha1 => Self::Sha1(sha1::Sha1::new()),
HashAlgo::Sha256 => Self::Sha256(sha2::Sha256::new()),
}
}
/// 算法。
#[must_use]
pub const fn algo(&self) -> HashAlgo {
match self {
Self::Sha1(_) => HashAlgo::Sha1,
Self::Sha256(_) => HashAlgo::Sha256,
}
}
/// 喂一段数据。
pub fn update(&mut self, bytes: &[u8]) {
match self {
Self::Sha1(hasher) => Digest::update(hasher, bytes),
Self::Sha256(hasher) => Digest::update(hasher, bytes),
}
}
/// 收尾成小写十六进制。
#[must_use]
pub fn finish(self) -> String {
match self {
Self::Sha1(hasher) => to_hex(&hasher.finalize()),
Self::Sha256(hasher) => to_hex(&hasher.finalize()),
}
}
}
/// 一次性算内存里的摘要。
#[must_use]
pub fn digest_bytes(algo: HashAlgo, bytes: &[u8]) -> String {
let mut hasher = Hasher::new(algo);
hasher.update(bytes);
hasher.finish()
}
/// 算整个文件的摘要(`cache` 的抽样校验与 `register` 路径用)。
pub fn digest_file(path: &Path, algo: HashAlgo) -> io::Result<String> {
let mut hasher = Hasher::new(algo);
let mut buffer = vec![0_u8; 64 * 1024];
let mut file = File::open(path)?;
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(hasher.finish())
}
/// 把文件前 `limit` 字节喂进已有摘要器(续传时让「边下边算」接得上)。
pub fn update_hasher_from_file(hasher: &mut Hasher, path: &Path, limit: u64) -> io::Result<()> {
if limit == 0 {
return Ok(());
}
let mut buffer = vec![0_u8; 64 * 1024];
let mut reader = File::open(path)?.take(limit);
loop {
let read = reader.read(&mut buffer)?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(())
}
/// 对已有文件的前 `prefix_len` 字节补算摘要(诊断/抽样校验用)。
pub fn digest_prefix(path: &Path, algo: HashAlgo, prefix_len: u64) -> io::Result<String> {
let mut hasher = Hasher::new(algo);
update_hasher_from_file(&mut hasher, path, prefix_len)?;
Ok(hasher.finish())
}
/// 摘要比较(大小写不敏感,避免上游给了大写 hex 就白跑一趟)。
#[must_use]
pub fn matches(expected_hex: &str, actual_hex: &str) -> bool {
expected_hex.len() == actual_hex.len() && expected_hex.eq_ignore_ascii_case(actual_hex)
}