feat(m162): Modrinth 在线内容源(匿名)+ 远端条目按 sha512 补全 + 离线态

core:新增 addon::online —— 按 Modrinth v2 真实形状建模(search facets / project /
project versions / version_file by hash),网络出口只有 Transport 一个 trait,生产实现
EngineTransport 走既有 DownloadEngine::download_bytes(零新依赖);错误分大类,网络类
失败映射成 NotConnected(= 未联网),不再用空列表冒充「没有结果」。

core:online::download::download_verified —— sha1 交给引擎流式校验、sha512 落盘后复核
(download::HashAlgo 没有 Sha512,改它超出 scope,已登记 finding),不符/失败删文件重试,
最多 3 次尝试且不留半截文件。

core:远端条目补全 —— PackFetcher::modrinth_file_by_sha512(默认 Unsupported = 补不出),
import 管线与新增的 scan_pack_online 都会在「有 path 但缺 hashes.sha1」时按 sha512 反查;
补出的 sha1 走 M160 同一套 judge 与合并判定,补不出仍进 unknown_remote 计数(口径未改)。
DownloadEngineFetcher 改用 download_verified,FetchRequest 增 sha512。

presenter:Modrinth 检索/详情改由 core 的 online 客户端提供(不再自己拼 URL、不再读错
date_modified);ContentFailure{kind} 取代裸字符串,无网 = Offline;新增
ContentDetailRequest/ContentDetail + LoadContentDetail 命令与 CoreEvent::ContentDetail 通道;
预检改走 scan_pack_online;内容库安装也走 download_verified(sha1+sha512)。

app-iced:Status::Offline 独立一态(未联网 ≠ 空列表 ≠ 普通失败),详情面板消费新 DTO
显示可用版本;选中项目即拉详情。i18n 新增 6 个键并同步金表。

测试:online 模块 24 个离线用例(录制 JSON + 假字节:搜索/详情/版本/按摘要反查/校验失败
即删/重试/离线分类/补全),import 侧 6 个(离线计数不变、补全后判冲突、补错摘要 ⇒ Failed),
presenter/app-iced 各补失败分类与详情状态机用例。
This commit is contained in:
AskaEth committed 2026-11-07 17:28:36 +08:00
1 parent 0241f793b2
commit 81278f55af
50 files changed
+3794 -222

No files matched your search

+3 -2
View File
@@ -78,6 +78,7 @@
//! 磁盘满、真实 CurseForge `files[]` 补全(缺 CF 客户端,见 [`fetch`] 的说明)。
pub mod baseline;
pub(crate) mod complete;
pub mod decision;
pub mod error;
pub mod fetch;
@@ -94,7 +95,7 @@ pub use decision::ConflictDecisions;
pub use error::{ImportError, ImportErrorKind};
pub use fetch::{
CurseFileMeta, DownloadEngineFetcher, FetchError, FetchErrorKind, FetchFuture, FetchRequest,
FetchedFile, PackFetcher,
FetchedFile, ModrinthFileMeta, PackFetcher,
};
pub use merge::{MergeAction, MergeDecision};
pub use pipeline::baseline_relative;
@@ -104,7 +105,7 @@ pub use report::{
ImportProgress, ImportProgressSink, ImportReport, ImportStage, ImportStatus, ImportedFile,
LoaderTarget, MissingFile, MissingReason, RegisteredInstance, RejectedComponent,
};
pub use scan::{scan_pack, ImportConflict, ImportScan};
pub use scan::{scan_pack, scan_pack_online, ImportConflict, ImportScan};
pub use stage::{RollbackOutcome, Staging};
use std::path::Path;
+98
View File
@@ -0,0 +1,98 @@
//! 远端条目补全(M162):**有 `path` 但缺 `hashes.sha1` 的 mrpack 条目,按 sha512 反查 sha1**。
//!
//! # 补什么、为什么补
//!
//! M160 的预检用 SHA-1 判「会不会覆盖用户文件」;mrpack 规范里 `sha1` 是**可选**的,只有
//! `sha512` 必填。于是「只有 path+downloads+hashes(sha512)」的条目在预检里判不了,只能记进
//! `ImportScan::unknown_remote`。现在拿 sha512 去 Modrinth 反查:站点认得这个文件就把**它自己
//! 记的 sha1** 填回来(同一个文件在同一站点上的官方摘要),补不出**保持缺**、照旧计数。
//!
//! # 三条边界
//!
//! 1. **只补 sha1 / 字节数 / 缺的直链**,绝不改写清单里已有的值(清单说多大就是多大);
//! 2. **CurseForge 条目不走这里**:它们连路径都没有,补全要 `projectID/fileID` 端点(需要 key),
//! 那条路仍是 `PackFetcher::curse_file` 的活;
//! 3. **离线立刻停**:第一条网络类失败之后剩下的条目一律算「补不出」,不拿 N 次超时惩罚用户
//! (与 `online::complete` 同一条纪律,只是这里换成按路径过滤的导入视图)。
//!
//! # 为什么要改 `plan.remote` 而不是 `manifest.files`
//!
//! 导入与预检都只读 `plan.remote`(`manifest` 保留原样作诊断),改一处即可两条路都生效,
//! 也不会让人误以为「清单被回写了」。
use super::fetch::FetchErrorKind;
use super::plan::ImportPlan;
use super::ImportRequest;
/// 一次补全的结果。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct Completion {
/// 补出了 sha1 的条目数
pub(crate) filled: usize,
/// 仍然补不出的条目数(缺 sha512 / 站点查不到 / 没接源)
pub(crate) unfilled: usize,
/// 是否因为「没联网」提前收工
pub(crate) offline: bool,
}
/// 跑一次补全(就地改 `plan.remote`)。
pub(crate) async fn complete(request: &ImportRequest<'_>, plan: &mut ImportPlan) -> Completion {
let mut state = Completion::default();
let Some(fetcher) = request.fetcher else {
state.unfilled = plan
.remote
.iter()
.filter(|entry| entry.path.is_some() && entry.file.sha1().is_none())
.count();
return state;
};
for entry in plan.remote.iter_mut() {
if entry.file.sha1().is_some() {
continue;
}
// CF 条目(没有路径)不归这里:那是 `curse_file` 的活。
if entry.path.is_none() {
continue;
}
let Some(sha512) = entry.file.sha512().map(str::to_string) else {
state.unfilled += 1;
continue;
};
if state.offline {
state.unfilled += 1;
continue;
}
match fetcher.modrinth_file_by_sha512(&sha512).await {
Ok(meta) => {
// 站点给的 sha1 也要过形状检查:长度不对的「摘要」比没有更危险。
match meta.sha1.filter(|hex| is_sha1(hex)) {
Some(sha1) => {
entry.file.hashes.insert("sha1".to_string(), sha1);
if entry.file.size.is_none() {
entry.file.size = meta.size;
}
if entry.file.downloads.iter().all(|url| url.trim().is_empty()) {
if let Some(url) = meta.url {
entry.file.downloads.push(url);
}
}
state.filled += 1;
}
None => state.unfilled += 1,
}
}
Err(error) => {
if error.kind() == FetchErrorKind::Network {
state.offline = true;
}
state.unfilled += 1;
}
}
}
state
}
/// 40 位十六进制。
fn is_sha1(hex: &str) -> bool {
hex.len() == 40 && hex.bytes().all(|byte| byte.is_ascii_hexdigit())
}
+101 -21
View File
@@ -16,17 +16,28 @@
//!
//! # 生产实现只走 `core::download`
//!
//! [`DownloadEngineFetcher`] 是本 crate 里**唯一**的生产实现,它只做一件事:把
//! [`FetchRequest`] 翻译成 [`crate::download::DownloadRequest`] 交给引擎(镜像展开、退避、
//! 续传、边下边算都在引擎里)。导入链路里**没有**第二个 `reqwest::Client`。
//! [`DownloadEngineFetcher`] 是本 crate 里**唯一**的生产实现,做两件事:
//!
//! 1. **[`PackFetcher::fetch_file`]** —— 交给 [`crate::addon::online::download_verified`]:
//! sha1 由引擎流式校验、sha512 由 addon 侧落盘后复核(引擎的 `Integrity` 只认 Sha1/Sha256),
//! 失败按 [`crate::addon::online::RETRY_ATTEMPTS`] 重试且不留半截文件;
//! 2. **[`PackFetcher::modrinth_file_by_sha512`]** —— M162 的补全路径:按 sha512 反查
//! Modrinth 拿同一个文件的官方 sha1(站点不认得就如实报 `NotFound`,由调用方计数)。
//!
//! 两条都经同一个引擎(`EngineTransport` / `DownloadRequest`),导入链路里**没有**第二个
//! `reqwest::Client`,也没有第二个重试循环。
use std::future::Future;
use std::path::Path;
use std::pin::Pin;
use crate::download::{
DownloadEngine, DownloadError, DownloadErrorKind, DownloadRequest, Integrity, ResourceKind,
use crate::addon::online::complete::identity_of;
use crate::addon::online::download::download_verified;
use crate::addon::online::modrinth::HashAlgorithm;
use crate::addon::online::{
EngineArtifactFetcher, EngineTransport, Modrinth, OnlineError, OnlineErrorKind,
};
use crate::download::{DownloadEngine, DownloadError, DownloadErrorKind};
/// "搞不到"的成因(**分级判据只在这里**)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -130,6 +141,19 @@ impl From<DownloadError> for FetchError {
}
}
impl From<OnlineError> for FetchError {
fn from(error: OnlineError) -> Self {
let kind = match error.kind() {
OnlineErrorKind::NotFound => FetchErrorKind::NotFound,
OnlineErrorKind::Integrity | OnlineErrorKind::Malformed => FetchErrorKind::Integrity,
OnlineErrorKind::Io => FetchErrorKind::Io,
OnlineErrorKind::Unsupported => FetchErrorKind::Unsupported,
OnlineErrorKind::NotConnected | OnlineErrorKind::Server => FetchErrorKind::Network,
};
Self::new(kind, error.message().to_string())
}
}
/// trait 方法返回的装箱 future(core 不引 `async-trait`,手写 `Pin<Box<dyn Future>>` 即可)。
pub type FetchFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
@@ -148,6 +172,19 @@ pub struct CurseFileMeta {
pub class_id: Option<i64>,
}
/// Modrinth 一个文件的元数据(`files[]` 补全的产物,按 sha512 反查得到)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModrinthFileMeta {
/// 站点给的 SHA-1(**同一个文件**的官方摘要,不是本地推算)
pub sha1: Option<String>,
/// 站点给的字节数
pub size: Option<u64>,
/// 站点给的直链(清单里没有 downloads 时才用得上)
pub url: Option<String>,
/// 站点给的文件名
pub file_name: Option<String>,
}
/// 一次文件下载请求。
#[derive(Debug, Clone, Copy)]
pub struct FetchRequest<'a> {
@@ -157,6 +194,8 @@ pub struct FetchRequest<'a> {
pub dest: &'a Path,
/// 期望 SHA-1(清单声明的)。
pub sha1: Option<&'a str>,
/// 期望 SHA-512(mrpack 的必填摘要;引擎不认这一档,由 `online::download` 落盘后复核)。
pub sha512: Option<&'a str>,
/// 期望字节数。
pub size: Option<u64>,
}
@@ -185,6 +224,21 @@ pub trait PackFetcher: Send + Sync {
})
}
/// 按 SHA-512 反查 Modrinth 上这个文件的元数据(**sha1 补全用**)。
///
/// 默认实现返回 [`FetchErrorKind::Unsupported`]:没有远程源(离线导入、测试的 `StubFetcher`)
/// 就补不出——这与 M160 的 `unknown_remote` 口径一致:补不出仍要如实计数,**不许猜**。
fn modrinth_file_by_sha512<'a>(
&'a self,
sha512: &'a str,
) -> FetchFuture<'a, Result<ModrinthFileMeta, FetchError>> {
Box::pin(async move {
Err(FetchError::unsupported(format!(
"没有接 Modrinth 源,无法按 sha512 补全这一条({sha512})"
)))
})
}
/// 把 `request.url` 下到 `request.dest`。
fn fetch_file<'a>(
&'a self,
@@ -207,28 +261,54 @@ impl<'a> DownloadEngineFetcher<'a> {
}
impl PackFetcher for DownloadEngineFetcher<'_> {
fn modrinth_file_by_sha512<'a>(
&'a self,
sha512: &'a str,
) -> FetchFuture<'a, Result<ModrinthFileMeta, FetchError>> {
Box::pin(async move {
let transport = EngineTransport::new(self.engine);
let client = Modrinth::new(&transport);
let version = client
.version_by_hash(HashAlgorithm::Sha512, sha512)
.await
.map_err(FetchError::from)?;
let identity = identity_of(&version, sha512).ok_or_else(|| {
FetchError::new(
FetchErrorKind::NotFound,
format!("站点返回的版本里没有 sha512 与 {sha512} 相同的文件"),
)
})?;
Ok(ModrinthFileMeta {
sha1: Some(identity.sha1),
size: identity.size,
url: identity.url,
file_name: version
.primary_file()
.map(|file| file.filename.clone())
.filter(|name| !name.trim().is_empty()),
})
})
}
fn fetch_file<'a>(
&'a self,
request: FetchRequest<'a>,
) -> FetchFuture<'a, Result<FetchedFile, FetchError>> {
Box::pin(async move {
let mut download = DownloadRequest::new(
ResourceKind::ModFile,
// sha1 交给引擎流式校验、sha512 由 `download_verified` 落盘后复核;
// 失败重试与「不留半截文件」也在那一层(见 `online::download` 的模块文档)。
let fetcher = EngineArtifactFetcher::new(self.engine);
let done = download_verified(
&fetcher,
request.url,
request.dest.to_path_buf(),
);
// 摘要长度不对就当"没给":引擎白跑一趟网络比这更贵。
if let Some(sha1) = request.sha1 {
let integrity = Integrity::sha1(sha1);
if integrity.is_well_formed() {
download.integrity = Some(integrity);
}
}
download.expected_size = request.size;
let outcome = self.engine.download(download).await?;
Ok(FetchedFile {
bytes: outcome.bytes,
})
request.sha1,
request.sha512,
request.size,
request.dest,
)
.await
.map_err(FetchError::from)?;
Ok(FetchedFile { bytes: done.bytes })
})
}
}
+5
View File
@@ -158,6 +158,7 @@ pub(crate) async fn fetch_remote(
url: &resolved.url,
dest: &dest,
sha1: resolved.sha1.as_deref(),
sha512: resolved.sha512.as_deref(),
size: resolved.size,
})
.await
@@ -225,6 +226,7 @@ struct Resolved {
path: String,
url: String,
sha1: Option<String>,
sha512: Option<String>,
size: Option<u64>,
}
@@ -250,6 +252,7 @@ async fn resolve(
path: path.clone(),
url,
sha1: entry.file.sha1().map(str::to_string),
sha512: entry.file.sha512().map(str::to_string),
size: entry.file.size,
}));
}
@@ -276,6 +279,8 @@ async fn resolve(
path: format!("{dir}/{}", meta.file_name),
url,
sha1: meta.sha1,
// CF 侧没有 sha512 可核(官方只给 sha1);空着就是「不校验这一档」。
sha512: None,
size: meta.size,
}))
}
+16 -1
View File
@@ -43,11 +43,26 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
let mut report = ImportReport::empty(request.target.to_path_buf());
tell(&request.progress, ImportStage::Probe, 0, 0, None);
let (archive, plan) = match probe_and_plan(request) {
let (archive, mut plan) = match probe_and_plan(request) {
Ok(pair) => pair,
Err(error) => return abandoned(report, error),
};
account_plan(&mut report, &plan);
// M162:远端条目补全(缺 `sha1` 的 mrpack 条目按 `sha512` 反查)。补不出的仍如实计数,
// 因此这一步**不影响**三态,只是让后面的摘要校验与合并判定有期望值可用。
let completion = super::complete::complete(request, &mut plan).await;
if completion.filled > 0 || completion.offline {
let mut message = format!(
"按 sha512 从 Modrinth 补全了 {} 条远端条目的 sha1(补不出 {} 条)",
completion.filled, completion.unfilled
);
if completion.offline {
message.push_str(";补全因未联网提前停止,其余条目照旧按「缺省保留」处置");
}
report
.warnings
.push(ImportIssue::new(keys::NOTE_REMOTE_COMPLETED, message));
}
let baseline = {
let (baseline, warnings) = load_baseline(request.target, &plan);
@@ -55,6 +55,8 @@ pub const NOTE_ROLLBACK_INCOMPLETE: &str = "pack.import.note.rollback_incomplete
pub const NOTE_OVERRIDE_SHADOWED: &str = "pack.import.note.override_shadowed";
/// 注记:这些远端条目预检判不了(补全前没有路径 / 有路径但缺 sha1 声明),无法预判冲突。
pub const NOTE_REMOTE_PATH_UNKNOWN: &str = "pack.import.note.remote_path_unknown";
/// 注记:这些远端条目按 sha512 从 Modrinth 补全了 sha1(补不出的另有计数)。
pub const NOTE_REMOTE_COMPLETED: &str = "pack.import.note.remote_completed";
/// 缺失:调用方没给 fetcher(离线导入)。
pub const MISSING_OFFLINE: &str = "pack.import.missing.offline";
@@ -100,6 +102,7 @@ pub const ALL: &[&str] = &[
NOTE_ROLLBACK_INCOMPLETE,
NOTE_OVERRIDE_SHADOWED,
NOTE_REMOTE_PATH_UNKNOWN,
NOTE_REMOTE_COMPLETED,
MISSING_OFFLINE,
MISSING_NO_SOURCE,
MISSING_FETCH_FAILED,
+41 -3
View File
@@ -43,6 +43,7 @@ use super::decision::ConflictDecisions;
use super::error::ImportError;
use super::merge;
use super::pipeline::{load_baseline, probe_and_plan};
use super::plan::ImportPlan;
use super::report::{keys, ConflictReason, FileSource, ImportIssue, LoaderTarget};
use super::ImportRequest;
use crate::download::verify;
@@ -170,7 +171,7 @@ impl ImportScan {
}
}
/// 跑一次预检:**只读**(不建目录、不写暂存、不改基线)。
/// 跑一次预检:**只读**(不建目录、不写暂存、不改基线、**不联网**)。
#[must_use]
pub fn scan_pack(request: &ImportRequest<'_>) -> ImportScan {
let mut scan = ImportScan::empty(request.target.to_path_buf());
@@ -181,7 +182,45 @@ pub fn scan_pack(request: &ImportRequest<'_>) -> ImportScan {
return scan;
}
};
let (baseline, warnings) = load_baseline(request.target, &plan);
fill(request, &plan, &mut scan);
scan
}
/// 跑一次预检,**先补全再判**(M162):缺 `hashes.sha1` 的 mrpack 条目按 `sha512` 反查 Modrinth。
///
/// 补出来的条目与清单里本来就带 `sha1` 的条目走**同一个** [`judge`](判据一行没改);
/// 补不出的仍进 [`ImportScan::unknown_remote`] 并留同一条注记——计数口径不变,只是「能判的变多了」。
///
/// 没有挂 fetcher(离线导入)时等价于 [`scan_pack`]:不发任何请求,计数照旧。
#[must_use]
pub async fn scan_pack_online(request: &ImportRequest<'_>) -> ImportScan {
let mut scan = ImportScan::empty(request.target.to_path_buf());
let mut plan = match probe_and_plan(request) {
Ok((_, plan)) => plan,
Err(error) => {
scan.error = Some(error);
return scan;
}
};
let completion = super::complete::complete(request, &mut plan).await;
fill(request, &plan, &mut scan);
if completion.filled > 0 {
let mut message = format!(
"预检前按 sha512 从 Modrinth 补全了 {} 条远端条目的 sha1(补不出 {} 条)",
completion.filled, completion.unfilled
);
if completion.offline {
message.push_str(";补全因未联网提前停止");
}
scan.notes
.push(ImportIssue::new(keys::NOTE_REMOTE_COMPLETED, message));
}
scan
}
/// 预检的主体:把「已组好的计划」判成冲突清单(两个入口共用,判据只此一处)。
fn fill(request: &ImportRequest<'_>, plan: &ImportPlan, scan: &mut ImportScan) {
let (baseline, warnings) = load_baseline(request.target, plan);
scan.notes = warnings;
scan.format = Some(plan.format.clone());
scan.pack_name = Some(plan.manifest.name.clone());
@@ -244,7 +283,6 @@ pub fn scan_pack(request: &ImportRequest<'_>) -> ImportScan {
}
scan.conflicts
.sort_by(|left, right| left.path.cmp(&right.path));
scan
}
/// 判一个路径:`Some` = 要用户点头的冲突。
+18 -5
View File
@@ -19,11 +19,18 @@
//! 并逐条进报告;`.disabled` 用路径后缀;`game`/`dependencies` 从实例快照取。
//! MultiMC/Prism **只导入不导出**(有意省略)。
//!
//! 规划中:本地 mod 管理(D10 §3)、CF/Modrinth 客户端(§5)。
//! - [`online`](P2 · M162):**在线内容源**——先只做匿名可用的 Modrinth(`/v2/search` /
//! `/v2/project` / `/v2/project/{id}/version` / `/v2/version_file/{hash}`)。网络出口只有
//! `Transport` 一个 trait(生产实现走 `core::download` 的引擎);下载走引擎并**复核
//! sha1 + sha512**;远端条目补全按 sha512 反查 sha1,补不出照旧如实计数。
//! CurseForge 需要 `x-api-key`,本轮不碰(见模块文档的「留的缝」)。
//!
//! 规划中:本地 mod 管理(D10 §3)、CurseForge 客户端(§5)。
pub mod export;
pub mod import;
pub mod loader;
pub mod online;
pub mod pack;
pub use export::{
@@ -31,15 +38,21 @@ pub use export::{
RemoteHit,
};
pub use import::{
import_pack, scan_pack, ConflictChoice, ConflictDecisions, ConflictFile, ConflictPolicy,
ImportConflict, ImportError, ImportErrorKind, ImportProgress, ImportProgressSink, ImportReport,
ImportRequest, ImportScan, ImportStage, ImportStatus, ImportedFile, MissingFile, PackBaseline,
PackFetcher, RejectedComponent,
import_pack, scan_pack, scan_pack_online, ConflictChoice, ConflictDecisions, ConflictFile,
ConflictPolicy, ImportConflict, ImportError, ImportErrorKind, ImportProgress,
ImportProgressSink, ImportReport, ImportRequest, ImportScan, ImportStage, ImportStatus,
ImportedFile, MissingFile, PackBaseline, PackFetcher, RejectedComponent,
};
pub use loader::{
LoaderError, LoaderErrorKind, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease,
LoaderSource, LoaderSupport,
};
pub use online::{
complete_files, download_verified, ArtifactFetcher, CompletionSummary, ContentType, Downloaded,
EngineArtifactFetcher, EngineTransport, Modrinth, OnlineError, OnlineErrorKind, ProjectDetail,
ProjectHit, SearchPage, SearchQuery, SortIndex, Transport, Version, VersionFile, VersionQuery,
MODRINTH_API,
};
pub use pack::{
EnvSupport, PackArchive, PackError, PackErrorKind, PackFile, PackFormat, PackLimits,
PackLoader, PackManifest, PackOverrides, PackRegistry, PackResult, PackSide,
+125
View File
@@ -0,0 +1,125 @@
//! 远端条目补全:**mrpack 的 `files[]` 只有 `path`+`downloads`+`hashes` 时,按摘要反查补 sha1**。
//!
//! # 补的是什么(以及为什么不许猜)
//!
//! mrpack 规范里 `files[].hashes.sha512` 必填、`sha1` 可选。少了 `sha1` 的条目在 M160 的预检里
//! 判不了「会不会覆盖用户文件」(判据是 SHA-1),只能记进 `ImportScan::unknown_remote`。
//! 现在按 `sha512` 去 Modrinth 反查:查得到就把**站点给的 sha1**填回去(那是同一个文件在同一
//! 站点上的官方摘要,不是本地推算),查不到就**保持缺**、照旧计数——绝不「猜一个路径填上」,
//! 也绝不拿 sha512 冒充 sha1。
//!
//! # 一致性检查(反查结果必须能对上)
//!
//! 站点返回的版本里可能不止一个文件(`primary` 之外还有 `sources.jar`)。所以只在
//! **`hashes.sha512` 与请求值逐字相同**的那个文件上取 `sha1`;一个都对不上就当「补不出」
//! ——宁可老实计数,也不把别的东西的摘要装进来。
//!
//! # 离线时立刻停
//!
//! 无网时逐条重试是拿 N 次超时惩罚用户。第一条网络类失败就把 [`CompletionSummary::offline`]
//! 置位并停止请求,剩下的条目一律算「补不出」(预检的注记本来就会说「有 N 条判不了」)。
use super::model::Version;
use super::modrinth::{HashAlgorithm, Modrinth};
use super::transport::Transport;
use crate::addon::pack::PackFile;
/// 一次补全的结果(**只做计数与一句事实,不改任何判定口径**)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct CompletionSummary {
/// 补出了 sha1 的条目数
pub completed: usize,
/// 补不出的条目数(缺 sha512 / 站点查不到 / 摘要对不上)
pub unfilled: usize,
/// 是否因为「没联网」提前收工
pub offline: bool,
}
impl CompletionSummary {
/// 有没有真的补到东西。
#[must_use]
pub const fn changed(&self) -> bool {
self.completed > 0
}
}
/// 逐条补 `sha1`(就地改 `files`,返回值只做计数)。
pub async fn complete_files<T: Transport + ?Sized>(
client: &Modrinth<'_, T>,
files: &mut [PackFile],
) -> CompletionSummary {
let mut summary = CompletionSummary::default();
for file in files.iter_mut() {
if file.sha1().is_some() {
// 已经有 sha1:不需要补(也不该覆盖清单里的声明)。
continue;
}
let Some(sha512) = file.sha512().map(str::to_string) else {
summary.unfilled += 1;
continue;
};
if summary.offline {
summary.unfilled += 1;
continue;
}
match client.version_by_hash(HashAlgorithm::Sha512, &sha512).await {
Ok(version) => match identity_of(&version, &sha512) {
Some(identity) => {
apply(file, &identity);
summary.completed += 1;
}
None => summary.unfilled += 1,
},
Err(error) => {
if error.is_offline() {
summary.offline = true;
}
summary.unfilled += 1;
}
}
}
summary
}
/// 从版本里找出**摘要对得上**的那个文件,取出它的 sha1/大小/地址。
///
/// `None` = 这个版本里没有任何文件的 sha512 与请求值相同(站点数据不一致 / 版本被换过)。
#[must_use]
pub fn identity_of(version: &Version, sha512: &str) -> Option<Identity> {
let file = version.files.iter().find(|file| {
file.sha512()
.is_some_and(|hex| hex.eq_ignore_ascii_case(sha512))
})?;
let sha1 = file.sha1().map(str::to_string)?;
Some(Identity {
sha1,
size: (file.size > 0).then_some(file.size),
url: (!file.url.trim().is_empty()).then(|| file.url.clone()),
})
}
/// 补全出来的一份身份。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Identity {
/// 站点给的 SHA-1
pub sha1: String,
/// 站点给的字节数(`0` = 没给)
pub size: Option<u64>,
/// 站点给的直链(`None` = 也没给)
pub url: Option<String>,
}
/// 把补全结果写回条目:**只填空缺,不覆盖清单里已有的值**。
fn apply(file: &mut PackFile, identity: &Identity) {
file.hashes
.entry("sha1".to_string())
.or_insert_with(|| identity.sha1.clone());
if file.size.is_none() {
file.size = identity.size;
}
if file.downloads.iter().all(|url| url.trim().is_empty()) {
if let Some(url) = &identity.url {
file.downloads.push(url.clone());
}
}
}
+288
View File
@@ -0,0 +1,288 @@
//! 「取到下载地址」之后的最后一步:**下载 → 复核 sha1/sha512 → 才算数**。
//!
//! # 三条硬要求(任务书原文)
//!
//! 1. **走既有分块下载引擎与镜像策略**:生产实现 [`EngineArtifactFetcher`] 只把请求翻译成
//! [`DownloadRequest`](`ResourceKind::ModFile` ⇒ 引擎按大文件分块下载,镜像展开、地区模式、
//! 健康度、退避全在引擎里;Modrinth 的 CDN 不在 `mirrors.json` 的规则里,所以只有国内环境
//! 真的命中规则时才会换源,不会瞎改写);
//! 2. **校验 sha1/sha512 后再写入**:引擎只认 sha1(`Integrity` 只有 Sha1/Sha256 两档),
//! 所以 sha1 交给引擎流式校验、sha512 由本模块**落盘后复核**;任何一项不符都删掉目标文件
//! (`download` 模块的 `HashAlgo` 没有 Sha512 是事实,改它超出 M162 的 scope,已登记 finding);
//! 3. **失败可重试且不留半截文件**:每次尝试前先删目标文件,尝试次数 [`RETRY_ATTEMPTS`];
//! 半截文件本身由引擎的 `PartFile` 守卫(`<name>.ullpart-<pid>-<seq>` + 原子改名)兜住,
//! 本模块不另造临时目录范式。
//!
//! # 为什么摘要复核不信任 fetcher
//!
//! [`ArtifactFetcher`] 是注入点(测试喂假字节)。如果「校验」放在某个具体实现里,测试注入的
//! 实现就成了「不校验也能过」的后门——而生产实现与测试实现走的是同一段 [`download_verified`]。
//! 所以复核在**外层**:读一遍落盘文件,同时算 sha1 与 sha512,与声明逐字比对。
use std::io::Read;
use std::path::{Path, PathBuf};
use sha1::Digest as _;
use sha2::Sha512;
use crate::download::{DownloadEngine, DownloadRequest, Integrity, ResourceKind};
use super::modrinth::HashAlgorithm;
use super::transport::{OnlineError, OnlineErrorKind, OnlineFuture};
/// 最多尝试几次(含首次)。3 次 = 镜像/健康度换源之外还给一次真重试。
pub const RETRY_ATTEMPTS: u32 = 3;
/// 一次下载请求(只含「引擎需要什么」,不含任何站点字段)。
#[derive(Debug, Clone, Copy)]
pub struct FetchArgs<'a> {
/// 逻辑下载地址(官方源;镜像展开在引擎里)
pub url: &'a str,
/// 落盘路径(父目录由调用方保证,或由本模块建)
pub dest: &'a Path,
/// 期望 SHA-1(形状不对当「没给」,见 [`well_formed`])
pub sha1: Option<&'a str>,
/// 期望字节数
pub size: Option<u64>,
}
/// 「把 URL 下到 dest」的注入点(测试喂假字节)。
pub trait ArtifactFetcher: Send + Sync {
/// 下到 `dest`,返回落盘字节数。
fn fetch<'a>(&'a self, args: FetchArgs<'a>) -> OnlineFuture<'a, Result<u64, OnlineError>>;
}
/// 生产实现:交给 `core::download` 的引擎。
#[derive(Debug, Clone, Copy)]
pub struct EngineArtifactFetcher<'a> {
engine: &'a DownloadEngine,
}
impl<'a> EngineArtifactFetcher<'a> {
/// 绑定引擎。
#[must_use]
pub const fn new(engine: &'a DownloadEngine) -> Self {
Self { engine }
}
}
impl ArtifactFetcher for EngineArtifactFetcher<'_> {
fn fetch<'a>(&'a self, args: FetchArgs<'a>) -> OnlineFuture<'a, Result<u64, OnlineError>> {
Box::pin(async move {
let mut request =
DownloadRequest::new(ResourceKind::ModFile, args.url, args.dest.to_path_buf());
if let Some(sha1) = args.sha1.filter(|hex| HashAlgorithm::Sha1.accepts(hex)) {
request.integrity = Some(Integrity::sha1(sha1));
}
request.expected_size = args.size;
let outcome = self.engine.download(request).await?;
Ok(outcome.bytes)
})
}
}
/// 一个文件的摘要(读一遍盘同时算两个)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Digest {
/// SHA-1(小写十六进制)
pub sha1: String,
/// SHA-512(小写十六进制)
pub sha512: String,
/// 字节数
pub bytes: u64,
}
/// 一次成功下载的产物。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Downloaded {
/// 落盘路径
pub path: PathBuf,
/// 字节数
pub bytes: u64,
/// 落盘内容的摘要(**实测**,不是声明)
pub digest: Digest,
}
/// 下载并复核(sha1 **与** sha512,给了哪个就校验哪个)。
///
/// # Errors
///
/// - 声明的摘要是 `Some` 但形状不对 ⇒ [`OnlineErrorKind::Unsupported`],**不发请求**;
/// - 没有下载地址 ⇒ `Unsupported`;
/// - 重试用尽仍是网络/站点/摘要问题 ⇒ 最后一次的错误(且目标文件已被删掉);
/// - 本地 IO ⇒ `Io`。
pub async fn download_verified(
fetcher: &dyn ArtifactFetcher,
url: &str,
sha1: Option<&str>,
sha512: Option<&str>,
size: Option<u64>,
dest: &Path,
) -> Result<Downloaded, OnlineError> {
if url.trim().is_empty() {
return Err(OnlineError::unsupported("没有可用下载地址"));
}
let sha1 = check_hash(sha1, HashAlgorithm::Sha1)?;
let sha512 = check_hash(sha512, HashAlgorithm::Sha512)?;
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(|error| {
OnlineError::new(
OnlineErrorKind::Io,
format!("建目录 {} 失败:{error}", parent.display()),
)
})?;
}
let mut last: Option<OnlineError> = None;
for attempt in 0..RETRY_ATTEMPTS {
// 每次尝试前清掉目标:上一轮的半截/坏内容不许参与本轮判定。
remove_quiet(dest);
let fetched = fetcher
.fetch(FetchArgs {
url,
dest,
sha1,
size,
})
.await;
let bytes = match fetched {
Ok(bytes) => bytes,
Err(error) if retryable(error.kind()) && attempt + 1 < RETRY_ATTEMPTS => {
last = Some(error);
continue;
}
Err(error) => {
remove_quiet(dest);
return Err(error);
}
};
if let Some(expected) = size {
if expected != bytes {
last = Some(OnlineError::integrity(format!(
"字节数与声明不符:声明 {expected},实际 {bytes}"
)));
remove_quiet(dest);
continue;
}
}
let digest = digest_file(dest)?;
if let Some(expected) = sha1 {
if !equals_hex(expected, &digest.sha1) {
last = Some(OnlineError::integrity(format!(
"SHA-1 与声明不符(声明 {expected},实际 {})",
digest.sha1
)));
remove_quiet(dest);
continue;
}
}
if let Some(expected) = sha512 {
if !equals_hex(expected, &digest.sha512) {
last = Some(OnlineError::integrity(format!(
"SHA-512 与声明不符(声明 {expected},实际 {})",
digest.sha512
)));
remove_quiet(dest);
continue;
}
}
return Ok(Downloaded {
path: dest.to_path_buf(),
bytes,
digest,
});
}
remove_quiet(dest);
Err(last.unwrap_or_else(|| {
OnlineError::new(
OnlineErrorKind::NotConnected,
"下载重试用尽(没有拿到任何字节)",
)
}))
}
/// 读一个文件同时算 sha1 与 sha512。
///
/// # Errors
/// 读盘失败返回 [`OnlineErrorKind::Io`]。
pub fn digest_file(path: &Path) -> Result<Digest, OnlineError> {
let mut file = std::fs::File::open(path).map_err(|error| {
OnlineError::new(
OnlineErrorKind::Io,
format!("读 {} 失败:{error}", path.display()),
)
})?;
let mut sha1 = sha1::Sha1::new();
let mut sha512 = Sha512::new();
let mut buffer = [0u8; 64 * 1024];
let mut bytes = 0u64;
loop {
let read = file.read(&mut buffer).map_err(|error| {
OnlineError::new(
OnlineErrorKind::Io,
format!("读 {} 失败:{error}", path.display()),
)
})?;
if read == 0 {
break;
}
bytes += read as u64;
sha1.update(&buffer[..read]);
sha512.update(&buffer[..read]);
}
Ok(Digest {
sha1: to_hex(&sha1.finalize()),
sha512: to_hex(&sha512.finalize()),
bytes,
})
}
/// 摘要形状合法吗(长度 + 十六进制)。
///
/// 返回的是调用方原来那段文本(不复制、不归一),大小写不敏感的比较交给 [`equals_hex`]。
fn check_hash(raw: Option<&str>, algorithm: HashAlgorithm) -> Result<Option<&str>, OnlineError> {
match raw.map(str::trim) {
None => Ok(None),
Some("") => Ok(None),
Some(hex) if algorithm.accepts(hex) => Ok(Some(hex)),
Some(hex) => Err(OnlineError::unsupported(format!(
"声明的 {} 不是合法摘要:{:?}(要求 {} 位十六进制)",
algorithm.as_str(),
hex,
algorithm.hex_len()
))),
}
}
/// 十六进制比较(大小写不敏感:声明侧可能大写)。
fn equals_hex(expected: &str, actual: &str) -> bool {
expected.len() == actual.len() && expected.eq_ignore_ascii_case(actual)
}
/// 这个错误值得再试一次吗(网络/站点/内容不符;**404 与自身不自洽一律不重试**)。
fn retryable(kind: OnlineErrorKind) -> bool {
matches!(
kind,
OnlineErrorKind::NotConnected | OnlineErrorKind::Server | OnlineErrorKind::Integrity
)
}
fn remove_quiet(path: &Path) {
if let Err(error) = std::fs::remove_file(path) {
if error.kind() != std::io::ErrorKind::NotFound {
eprintln!("[online] 删 {} 失败:{error}", path.display());
}
}
}
/// 小写十六进制(不引 `hex` 依赖;与 `download::verify::to_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
}
+65
View File
@@ -0,0 +1,65 @@
//! 在线内容源(M162):**只做匿名可用的那半**——Modrinth v2。
//!
//! # 为什么形状是 Modrinth 的,而不是「内容源通用抽象」
//!
//! CurseForge 的检索/文件端点要 `x-api-key`(D10 §5 的客户端还没写),而它的 JSON 方言与
//! Modrinth 几乎没有一处同名(`project_id` vs `id`、`description` vs `summary`、
//! `downloads` vs `downloadCount`、版本列表还要再套一层 `latestFilesIndexes`)。为了让两者
//! 共用而发明一套谁都不像的抽象,代价是两边都得翻译两次、错一处两边都错。
//!
//! 所以本模块**按 Modrinth 的真实形状建模**(`/v2/search` 的 facets、`/v2/project/{id}`、
//! `/v2/project/{id}/version`、`/v2/version_file/{hash}`),把「平台差异」留到将来真要加第二个
//! 源时再抽。留的缝只有两处:
//!
//! | 缝 | 在哪 | 换源时要动什么 |
//! |----|------|----------------|
//! | [`Transport`] | 唯一的 HTTP 出口(trait) | 换/加一个实现;测试注入录制 JSON |
//! | [`Modrinth::new`] | `Modrinth<'a, T: Transport + ?Sized>` | 传 `&dyn Transport` 就能换站点客户端,一行业务代码不改 |
//!
//! # 网络层(零新依赖)
//!
//! `Cargo.lock` 里已有的 `reqwest` 0.12 + `tokio` + `serde_json` 就是全部。生产实现
//! [`EngineTransport`] 走 `DownloadEngine::download_bytes`,于是**信号量限流、源健康度、
//! 指数退避、错误分类**全由 `core::download` 提供,本模块不写第二个重试循环,也不持有第二个
//! `reqwest::Client`(与 `addon::import::fetch` 同一条纪律)。
//!
//! 没有 POST:Modrinth 的批量接口(`POST /v2/version_files`)需要 POST 请求体,而引擎的
//! `DownloadRequest` 没有请求体字段。补全路径因此按**单摘要 GET** 逐条查(见 [`complete`]),
//! mrpack 里缺 `sha1` 的条目本来就是少数,不值得为它引入第二条 HTTP 路径。
//!
//! # 离线不是「空结果」
//!
//! 无网时上层必须能说清「未联网」而不是「没有匹配的内容」,所以错误是枚举不是字符串:
//! 网络类失败一律映射成 [`OnlineErrorKind::NotConnected`](见 [`OnlineError`]),
//! presenter 再把它翻成 DTO 的 `ContentFailureKind::Offline`。
//!
//! # 模块
//!
//! | 文件 | 职责 |
//! |------|------|
//! | [`model`] | Modrinth 的响应形状 + 检索请求(facets / 排序 / 分页) |
//! | [`transport`] | HTTP 出口 trait + 生产实现(走下载引擎)+ 错误分类 |
//! | [`modrinth`] | 端点拼装与响应解析(纯逻辑,测试直接喂录制 JSON) |
//! | [`download`] | 取下载地址之后那一步:引擎下载 + **sha1/sha512 复核** + 重试 + 不留半截文件 |
//! | [`complete`] | 远端条目补全:缺 `sha1` 的 mrpack 条目按 `sha512` 反查 |
pub mod complete;
pub mod download;
pub mod model;
pub mod modrinth;
pub mod transport;
pub use complete::{complete_files, CompletionSummary};
pub use download::{
digest_file, download_verified, ArtifactFetcher, Digest, Downloaded, EngineArtifactFetcher,
FetchArgs, RETRY_ATTEMPTS,
};
pub use model::{
ContentType, FileHashes, ProjectDetail, ProjectHit, SearchPage, SearchQuery, SortIndex,
Version, VersionFile, VersionQuery, VersionType,
};
pub use modrinth::{HashAlgorithm, Modrinth, MODRINTH_API};
pub use transport::{EngineTransport, OnlineError, OnlineErrorKind, OnlineFuture, Transport};
#[cfg(test)]
mod tests;
+37
View File
@@ -0,0 +1,37 @@
//! Modrinth v2 的响应形状与检索请求(**按站点原文建模,不翻译成「通用内容模型」**)。
//!
//! 每个公开类型都对着一个真实端点:
//!
//! | 类型 | 端点 |
//! |------|------|
//! | [`SearchQuery`] → [`SearchPage`] | `GET /v2/search` |
//! | [`ProjectDetail`] | `GET /v2/project/{id or slug}` |
//! | [`VersionQuery`] → [`Vec<Version>`] | `GET /v2/project/{id}/version` |
//! | [`Version`] | `GET /v2/version_file/{hash}?algorithm=sha1\|sha512` |
//!
//! # 解析纪律
//!
//! 站点会加字段、也会给 `""` 表示「没有」——所以:
//!
//! - 一律 `#[serde(default)]`,**没给就是空的**,缺字段不炸整页;
//! - 只把**形状**当硬要求(`hits` 必须是数组、project 必须是对象),形状不对就是
//! [`super::OnlineErrorKind::Malformed`],不静默给空页冒充「没结果」;
//! - 未知字段不保留(这里不是导入导出的无损通道,是只读检索)。
//!
//! # 为什么拆成三个文件
//!
//! 一个 600+ 行的 `model.rs` 已经越过本仓的 400 行硬线,而且三种东西混在一起时最容易看漏
//! 「站点字段名与 DTO 字段名不是一回事」:请求形状([`query`])、响应形状([`types`])、
//! 以及把前者读成后者的解析([`parse`])。拆开后 `parse` 里每一处 `#[serde(rename)]`/
//! `alias` 都能单独读。
mod parse;
mod query;
mod types;
pub use parse::{known_loaders, parse_project, parse_search, parse_version, parse_versions};
pub use query::{ContentType, SearchQuery, SortIndex, KNOWN_LOADERS};
pub use types::{
FileHashes, ProjectDetail, ProjectHit, SearchPage, Version, VersionFile, VersionQuery,
VersionType,
};
+257
View File
@@ -0,0 +1,257 @@
//! 站点的原始 JSON → 上面那些类型(**唯一**读字段名的地方)。
//!
//! 每个 `#[serde(default)]` 都在说「这个字段站点可能不给」;`alias` 只在有历史原因时用
//! (例如检索命中的日期字段曾按 `updated` 读,站点实际叫 `date_modified`)。
use std::collections::BTreeMap;
use serde::Deserialize;
use super::query::KNOWN_LOADERS;
use super::types::{ProjectDetail, ProjectHit, SearchPage, Version, VersionFile, VersionType};
use crate::addon::online::transport::{OnlineError, OnlineErrorKind};
// --------------------------------------------------------------- 解析
#[derive(Debug, Default, Deserialize)]
struct RawSearch {
#[serde(default)]
hits: Vec<RawHit>,
#[serde(default)]
total_hits: u64,
#[serde(default)]
offset: u32,
#[serde(default)]
limit: u32,
}
#[derive(Debug, Default, Deserialize)]
struct RawHit {
#[serde(default)]
project_id: String,
#[serde(default)]
slug: String,
#[serde(default)]
title: String,
#[serde(default)]
description: String,
#[serde(default)]
author: String,
#[serde(default)]
downloads: u64,
#[serde(default)]
follows: u64,
// 站点的字段名是 `date_modified`(旧实现的 `updated` 永远取不到值,别再加回去当主名)。
#[serde(default, alias = "updated")]
date_modified: String,
#[serde(default)]
versions: Vec<String>,
#[serde(default)]
categories: Vec<String>,
#[serde(default)]
project_type: String,
#[serde(default)]
icon_url: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct RawProject {
#[serde(default)]
id: String,
#[serde(default)]
slug: String,
#[serde(default)]
title: String,
#[serde(default)]
description: String,
#[serde(default)]
body: String,
#[serde(default)]
project_type: String,
#[serde(default)]
downloads: u64,
#[serde(default)]
followers: u64,
#[serde(default)]
updated: String,
#[serde(default)]
published: String,
#[serde(default)]
game_versions: Vec<String>,
#[serde(default)]
loaders: Vec<String>,
#[serde(default)]
categories: Vec<String>,
#[serde(default)]
icon_url: Option<String>,
#[serde(default)]
source_url: Option<String>,
#[serde(default)]
issues_url: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct RawVersion {
#[serde(default)]
id: String,
#[serde(default)]
project_id: String,
#[serde(default)]
name: String,
#[serde(default)]
version_number: String,
#[serde(default)]
version_type: String,
#[serde(default)]
loaders: Vec<String>,
#[serde(default)]
game_versions: Vec<String>,
#[serde(default)]
date_published: String,
#[serde(default)]
downloads: u64,
#[serde(default)]
files: Vec<RawFile>,
}
#[derive(Debug, Default, Deserialize)]
struct RawFile {
#[serde(default)]
hashes: BTreeMap<String, String>,
#[serde(default)]
url: String,
#[serde(default)]
filename: String,
#[serde(default)]
size: u64,
#[serde(default)]
primary: bool,
}
/// 解析 `/v2/search`。
///
/// # Errors
/// JSON 不合法、或没有 `hits` 数组时返回 [`OnlineErrorKind::Malformed`]。
pub fn parse_search(body: &str, offset: u32, limit: u32) -> Result<SearchPage, OnlineError> {
let raw: RawSearch = parse_json(body, "search")?;
Ok(SearchPage {
hits: raw.hits.into_iter().map(hit_of).collect(),
total_hits: raw.total_hits,
// 站点会回显 offset/limit;缺省用请求里的(分页游标不能靠回显才成立)
offset: raw.offset.max(offset),
limit: if raw.limit == 0 { limit } else { raw.limit },
})
}
/// 解析 `/v2/project/{id}`。
///
/// # Errors
/// JSON 不合法时返回 [`OnlineErrorKind::Malformed`]。
pub fn parse_project(body: &str) -> Result<ProjectDetail, OnlineError> {
let raw: RawProject = parse_json(body, "project")?;
Ok(ProjectDetail {
id: raw.id,
slug: raw.slug,
title: raw.title,
description: raw.description,
body: raw.body,
project_type: raw.project_type,
downloads: raw.downloads,
follows: raw.followers,
updated: raw.updated,
published: raw.published,
game_versions: raw.game_versions,
loaders: raw.loaders,
categories: raw.categories,
icon_url: non_empty(raw.icon_url),
source_url: non_empty(raw.source_url),
issues_url: non_empty(raw.issues_url),
})
}
/// 解析 `/v2/project/{id}/version`(一个数组)。
///
/// # Errors
/// JSON 不合法或不是数组时返回 [`OnlineErrorKind::Malformed`]。
pub fn parse_versions(body: &str) -> Result<Vec<Version>, OnlineError> {
let raw: Vec<RawVersion> =
serde_json::from_str(body).map_err(|error| malformed("versions", error))?;
Ok(raw.into_iter().map(version_of).collect())
}
/// 解析 `/v2/version_file/{hash}`(单个版本对象)。
///
/// # Errors
/// JSON 不合法时返回 [`OnlineErrorKind::Malformed`]。
pub fn parse_version(body: &str) -> Result<Version, OnlineError> {
let raw: RawVersion = parse_json(body, "version_file")?;
Ok(version_of(raw))
}
fn parse_json<T: for<'de> Deserialize<'de>>(body: &str, what: &str) -> Result<T, OnlineError> {
serde_json::from_str(body).map_err(|error| malformed(what, error))
}
fn malformed(what: &str, error: serde_json::Error) -> OnlineError {
OnlineError::new(
OnlineErrorKind::Malformed,
format!("Modrinth {what} 响应不是期望形状:{error}"),
)
}
fn hit_of(raw: RawHit) -> ProjectHit {
ProjectHit {
project_id: raw.project_id,
slug: raw.slug,
title: raw.title,
description: raw.description,
author: raw.author,
downloads: raw.downloads,
follows: raw.follows,
updated: raw.date_modified,
loaders: known_loaders(&raw.categories),
game_versions: raw.versions,
project_type: raw.project_type,
icon_url: non_empty(raw.icon_url),
}
}
fn version_of(raw: RawVersion) -> Version {
Version {
id: raw.id,
project_id: raw.project_id,
name: raw.name,
version_number: raw.version_number,
version_type: VersionType::parse(&raw.version_type),
loaders: raw.loaders,
game_versions: raw.game_versions,
date_published: raw.date_published,
downloads: raw.downloads,
files: raw
.files
.into_iter()
.map(|file| VersionFile {
hashes: file.hashes,
url: file.url,
filename: file.filename,
size: file.size,
primary: file.primary,
})
.collect(),
}
}
/// 只留已知加载器(`categories` 里混着分类标签)。
#[must_use]
pub fn known_loaders(categories: &[String]) -> Vec<String> {
categories
.iter()
.filter(|tag| KNOWN_LOADERS.contains(&tag.as_str()))
.cloned()
.collect()
}
/// 空串当「没有」(站点用 `""` 表示没图 / 没链接)。
fn non_empty(value: Option<String>) -> Option<String> {
value.filter(|item| !item.trim().is_empty())
}
+152
View File
@@ -0,0 +1,152 @@
//! 检索请求的形状(facet / 排序 / 分页)——站点要什么就写什么,不预先「通用化」。
//!
//! 两个容易踩的点:
//!
//! 1. `facets` 是**二维数组的 JSON 文本**(外层「与」、内层「或」),要再整体百分号编码;
//! 2. `categories` 里混着分类标签(`optimization`),只有 [`KNOWN_LOADERS`] 那几个才是加载器。
/// Modrinth 的 `project_type` 取值(检索 facet 与项目详情的同名字段)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ContentType {
/// 整合包
Modpack,
/// Mod
Mod,
/// 资源包
Resourcepack,
/// 光影
Shader,
/// 服务端插件
Plugin,
/// 数据包
Datapack,
}
impl ContentType {
/// 站点原文(`project_type` facet 的值)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Modpack => "modpack",
Self::Mod => "mod",
Self::Resourcepack => "resourcepack",
Self::Shader => "shader",
Self::Plugin => "plugin",
Self::Datapack => "datapack",
}
}
/// 从站点原文解析;认不出返回 `None`(调用方决定当「未知」还是报错)。
#[must_use]
pub fn parse(raw: &str) -> Option<Self> {
Some(match raw {
"modpack" => Self::Modpack,
"mod" => Self::Mod,
"resourcepack" => Self::Resourcepack,
"shader" => Self::Shader,
"plugin" => Self::Plugin,
"datapack" => Self::Datapack,
_ => return None,
})
}
}
/// `/v2/search` 的 `index` 参数。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SortIndex {
/// 相关度(站点默认)
#[default]
Relevance,
/// 下载量
Downloads,
/// 关注数
Follows,
/// 最新发布
Newest,
/// 最近更新
Updated,
}
impl SortIndex {
/// 站点原文。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Relevance => "relevance",
Self::Downloads => "downloads",
Self::Follows => "follows",
Self::Newest => "newest",
Self::Updated => "updated",
}
}
}
/// 站点认的加载器 facet 值(`categories` 里混着 `optimization` 这种**分类标签**,
/// 直接整份当加载器会画出假加载器)。
pub const KNOWN_LOADERS: [&str; 6] = ["fabric", "forge", "neoforge", "quilt", "liteloader", "rift"];
/// 一次检索请求(分页游标也在里面:换页就是换一份 query)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchQuery {
/// 关键词(空 = 不带 `query` 参数,即「按排序浏览」)
pub text: String,
/// 内容类型过滤
pub project_type: Option<ContentType>,
/// 游戏版本过滤(精确值,如 `1.20.1`)
pub game_version: Option<String>,
/// 加载器过滤(站点 facet 值,如 `fabric`)
pub loader: Option<String>,
/// 排序
pub index: SortIndex,
/// 每页条数(站点上限 100,构造时钳住)
pub limit: u32,
/// 偏移
pub offset: u32,
}
impl Default for SearchQuery {
fn default() -> Self {
Self {
text: String::new(),
project_type: None,
game_version: None,
loader: None,
index: SortIndex::Relevance,
limit: 20,
offset: 0,
}
}
}
impl SearchQuery {
/// 站点上限(超过会被站点拒,钳住更省一次往返)。
pub const MAX_LIMIT: u32 = 100;
/// 钳过界的分页参数。
#[must_use]
pub fn normalized(&self) -> Self {
let mut query = self.clone();
query.text = query.text.trim().to_string();
query.limit = query.limit.clamp(1, Self::MAX_LIMIT);
query
}
/// `facets` 参数(站点要的是**二维数组的 JSON**,每一项是一组「或」)。
///
/// 形状:`[["project_type:mod"],["versions:1.20.1"],["categories:fabric"]]`——不同组之间是
/// 「与」,同组内是「或」。这里每组只有一项。
#[must_use]
pub fn facets(&self) -> String {
let mut groups: Vec<Vec<String>> = Vec::new();
if let Some(kind) = self.project_type {
groups.push(vec![format!("project_type:{}", kind.as_str())]);
}
if let Some(version) = self.game_version.as_deref().filter(|v| !v.is_empty()) {
groups.push(vec![format!("versions:{version}")]);
}
if let Some(loader) = self.loader.as_deref().filter(|v| !v.is_empty()) {
groups.push(vec![format!("categories:{loader}")]);
}
serde_json::to_string(&groups).unwrap_or_else(|_| "[]".to_string())
}
}
+231
View File
@@ -0,0 +1,231 @@
//! 响应形状(`/v2/search`、`/v2/project`、`/v2/project/{id}/version`、`/v2/version_file`)。
use std::collections::BTreeMap;
/// 一页检索结果。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SearchPage {
/// 命中项(顺序 = 站点顺序)
pub hits: Vec<ProjectHit>,
/// 站点给的总数(**估算值**:站点文档不保证精确)
pub total_hits: u64,
/// 请求的偏移
pub offset: u32,
/// 请求的每页条数
pub limit: u32,
}
impl SearchPage {
/// 还有下一页吗。
///
/// 判据是「本页装满了」,不是 `offset + len < total`:`total_hits` 是估算值,
/// 用「装满」最多多请求一次空页,不会出现「明明还有却翻不到」。
#[must_use]
pub fn has_more(&self) -> bool {
self.limit > 0 && self.hits.len() as u32 >= self.limit
}
}
/// 一条检索命中(`/v2/search` 的 `hits[]`)。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ProjectHit {
/// 项目 id(base62)
pub project_id: String,
/// 人读 slug
pub slug: String,
/// 标题
pub title: String,
/// 简介
pub description: String,
/// 作者(站点给的是团队名;没有时为空)
pub author: String,
/// 下载量
pub downloads: u64,
/// 关注数
pub follows: u64,
/// 最近更新(站点原文 ISO-8601)
pub updated: String,
/// 支持的加载器(已按 [`KNOWN_LOADERS`] 过滤)
pub loaders: Vec<String>,
/// 支持的游戏版本
pub game_versions: Vec<String>,
/// 项目类型原文
pub project_type: String,
/// 图标 URL(`None` = 没有)
pub icon_url: Option<String>,
}
impl ProjectHit {
/// 支持某个加载器吗(空列表 = 站点没给,按「未知」处理 ⇒ 不拦)。
#[must_use]
pub fn supports_loader(&self, loader: &str) -> bool {
self.loaders.is_empty() || self.loaders.iter().any(|item| item == loader)
}
/// 支持某个游戏版本吗(空列表 = 未知 ⇒ 不拦)。
#[must_use]
pub fn supports_version(&self, version: &str) -> bool {
self.game_versions.is_empty() || self.game_versions.iter().any(|item| item == version)
}
}
/// 项目详情(`/v2/project/{id or slug}`)。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ProjectDetail {
/// 项目 id
pub id: String,
/// slug
pub slug: String,
/// 标题
pub title: String,
/// 简介
pub description: String,
/// 长文(markdown 原文;站点可能给 `""`)
pub body: String,
/// 项目类型原文
pub project_type: String,
/// 下载量
pub downloads: u64,
/// 关注数
pub follows: u64,
/// 最近更新
pub updated: String,
/// 首次发布
pub published: String,
/// 游戏版本
pub game_versions: Vec<String>,
/// 加载器
pub loaders: Vec<String>,
/// 分类标签
pub categories: Vec<String>,
/// 图标
pub icon_url: Option<String>,
/// 项目主页
pub source_url: Option<String>,
/// 问题追踪
pub issues_url: Option<String>,
}
/// 版本类型。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum VersionType {
/// 正式版
#[default]
Release,
/// 测试版
Beta,
/// 早期预览
Alpha,
}
impl VersionType {
/// 站点原文。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Release => "release",
Self::Beta => "beta",
Self::Alpha => "alpha",
}
}
/// 从站点原文解析;认不出按正式版(**不**拿它做安装判据)。
#[must_use]
pub fn parse(raw: &str) -> Self {
match raw {
"beta" => Self::Beta,
"alpha" => Self::Alpha,
_ => Self::Release,
}
}
}
/// 一个版本(`/v2/project/{id}/version[]` 与 `/v2/version_file/{hash}` 同一形状)。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Version {
/// 版本 id
pub id: String,
/// 所属项目 id
pub project_id: String,
/// 版本名
pub name: String,
/// 版本号(`1.2.3+fabric`)
pub version_number: String,
/// 版本类型
pub version_type: VersionType,
/// 支持的加载器
pub loaders: Vec<String>,
/// 支持的游戏版本
pub game_versions: Vec<String>,
/// 发布日期
pub date_published: String,
/// 该版本总下载量
pub downloads: u64,
/// 文件列表
pub files: Vec<VersionFile>,
}
impl Version {
/// 拿这个文件:优先 `primary: true`(Modrinth 官方口径),否则第一个。
///
/// 不能无脑取第一个:很多项目同时发 `sources.jar` / `-dev.jar`,取第一个会装错东西。
#[must_use]
pub fn primary_file(&self) -> Option<&VersionFile> {
self.files
.iter()
.find(|file| file.primary)
.or_else(|| self.files.first())
}
}
/// 版本里的一个文件。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct VersionFile {
/// 摘要表(键名小写:`sha1` / `sha512` / `md5`;站点给几个就有几个)
pub hashes: BTreeMap<String, String>,
/// 下载地址
pub url: String,
/// 文件名
pub filename: String,
/// 字节数
pub size: u64,
/// 是否主文件
pub primary: bool,
}
impl VersionFile {
/// SHA-1。
#[must_use]
pub fn sha1(&self) -> Option<&str> {
self.hashes.get("sha1").map(String::as_str)
}
/// SHA-512。
#[must_use]
pub fn sha512(&self) -> Option<&str> {
self.hashes.get("sha512").map(String::as_str)
}
}
/// 摘要表(给需要「按算法取」的调用方用;与 [`VersionFile::hashes`] 同源)。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct FileHashes(pub BTreeMap<String, String>);
impl FileHashes {
/// 按算法取。
#[must_use]
pub fn get(&self, algorithm: &str) -> Option<&str> {
self.0.get(algorithm).map(String::as_str)
}
}
/// 版本列表请求(`/v2/project/{id}/version` 的过滤条件)。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct VersionQuery {
/// 项目 id 或 slug
pub project: String,
/// 游戏版本过滤(单个精确值)
pub game_version: Option<String>,
/// 加载器过滤(站点 facet 值)
pub loader: Option<String>,
}
+231
View File
@@ -0,0 +1,231 @@
//! Modrinth v2 的四个端点(**只有一个站点,所以不做「源分派」那一层**)。
//!
//! | 方法 | 端点 | 拿什么 |
//! |------|------|--------|
//! | [`Modrinth::search`] | `GET /v2/search` | 一页命中项(分页游标在 [`SearchQuery`] 里) |
//! | [`Modrinth::project`] | `GET /v2/project/{id or slug}` | 项目详情(含长文与链接) |
//! | [`Modrinth::versions`] | `GET /v2/project/{id}/version` | 版本列表(可按版本/加载器过滤) |
//! | [`Modrinth::version_by_hash`] | `GET /v2/version_file/{hash}` | 某个文件的所属版本(**补全路径用的那条**) |
//!
//! # 端点拼装与解析都分离出来
//!
//! `*_url` 是纯函数(单测直接比字符串),`search/project/...` 只做「取文本 → 解析」。
//! 于是录制 JSON 的离线夹具不需要任何 HTTP 桩就能覆盖全部分支。
//!
//! # 为什么按摘要查是 GET
//!
//! Modrinth 的批量接口是 `POST /v2/version_files`(请求体是一个 JSON 对象)。引擎的
//! `DownloadRequest` 没有请求体字段,为它单开一条直发 `client()` 的路径就得自己写退避与限流
//! (presenter 里为 CF 的 header 已经付过这笔账)。补全路径一次只看一两个摘要,逐条 GET 更划算。
//!
//! # 百分号编码
//!
//! 只为 query 串写一个最小实现(`A-Za-z0-9-._~` 不编码),**不引第三方 crate**:
//! `facets` 与 `game_versions` 都是 JSON 文本,方括号引号逗号全要编码,否则站点会当成
//! 空过滤条件静默忽略——那会让「筛选条说 Fabric、结果里有 Forge」这种谎话成立。
use super::model::{
parse_project, parse_search, parse_version, parse_versions, ProjectDetail, SearchPage,
SearchQuery, Version, VersionQuery,
};
use super::transport::{OnlineError, Transport};
/// Modrinth API 的根(匿名可用,无需 key)。
pub const MODRINTH_API: &str = "https://api.modrinth.com/v2";
/// 按哪个摘要查文件。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HashAlgorithm {
/// SHA-1(mrpack 的 `files[].hashes.sha1`)
#[default]
Sha1,
/// SHA-512(mrpack 的必填摘要)
Sha512,
}
impl HashAlgorithm {
/// 站点 `algorithm` 参数值。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Sha1 => "sha1",
Self::Sha512 => "sha512",
}
}
/// 十六进制长度(格式自检用;长度不对就别发请求)。
#[must_use]
pub const fn hex_len(self) -> usize {
match self {
Self::Sha1 => 40,
Self::Sha512 => 128,
}
}
/// 摘要是否**形状**合法(小写归一 + 十六进制 + 长度)。
#[must_use]
pub fn accepts(self, hex: &str) -> bool {
hex.len() == self.hex_len() && hex.bytes().all(|byte| byte.is_ascii_hexdigit())
}
}
/// Modrinth 客户端(只持有 [`Transport`],不持有客户端/连接池)。
#[derive(Debug, Clone, Copy)]
pub struct Modrinth<'a, T: Transport + ?Sized> {
transport: &'a T,
}
impl<'a, T: Transport + ?Sized> Modrinth<'a, T> {
/// 绑定传输层。
#[must_use]
pub const fn new(transport: &'a T) -> Self {
Self { transport }
}
/// 检索的 URL(纯函数,单测直接钉)。
#[must_use]
pub fn search_url(&self, query: &SearchQuery) -> String {
let query = query.normalized();
let mut url = format!(
"{MODRINTH_API}/search?limit={}&offset={}&index={}",
query.limit,
query.offset,
query.index.as_str()
);
let facets = query.facets();
if facets != "[]" {
url.push_str(&format!("&facets={}", encode(&facets)));
}
if !query.text.is_empty() {
url.push_str(&format!("&query={}", encode(&query.text)));
}
url
}
/// 项目详情的 URL。
#[must_use]
pub fn project_url(&self, id_or_slug: &str) -> String {
format!("{MODRINTH_API}/project/{}", encode(id_or_slug))
}
/// 版本列表的 URL。
#[must_use]
pub fn versions_url(&self, query: &VersionQuery) -> String {
let mut url = format!("{MODRINTH_API}/project/{}/version", encode(&query.project));
let mut first = true;
if let Some(version) = query
.game_version
.as_deref()
.filter(|item| !item.is_empty())
{
url.push_str(&format!(
"{}game_versions={}",
separator(&mut first),
encode(&format!("[\"{version}\"]"))
));
}
if let Some(loader) = query.loader.as_deref().filter(|item| !item.is_empty()) {
url.push_str(&format!(
"{}loaders={}",
separator(&mut first),
encode(&format!("[\"{loader}\"]"))
));
}
url
}
/// 按文件摘要查所属版本的 URL。
#[must_use]
pub fn version_file_url(&self, algorithm: HashAlgorithm, hex: &str) -> String {
format!(
"{MODRINTH_API}/version_file/{}?algorithm={}",
encode(&hex.to_ascii_lowercase()),
algorithm.as_str()
)
}
/// 检索一页。
///
/// # Errors
/// 传输层失败(见 [`OnlineErrorKind`])或响应形状不符。
pub async fn search(&self, query: &SearchQuery) -> Result<SearchPage, OnlineError> {
let normalized = query.normalized();
let url = self.search_url(&normalized);
let body = self.transport.get_text(&url).await?;
let mut page = parse_search(&body, normalized.offset, normalized.limit)?;
// 站点没回显时用请求值补齐,分页游标才不会因为缺字段而卡死。
if page.limit == 0 {
page.limit = normalized.limit;
}
Ok(page)
}
/// 项目详情。
///
/// # Errors
/// 传输层失败或响应形状不符。
pub async fn project(&self, id_or_slug: &str) -> Result<ProjectDetail, OnlineError> {
let url = self.project_url(id_or_slug);
let body = self.transport.get_text(&url).await?;
parse_project(&body)
}
/// 版本列表(站点的顺序 = 最新在前)。
///
/// # Errors
/// 传输层失败或响应形状不符。
pub async fn versions(&self, query: &VersionQuery) -> Result<Vec<Version>, OnlineError> {
let url = self.versions_url(query);
let body = self.transport.get_text(&url).await?;
parse_versions(&body)
}
/// 按文件摘要查所属版本。
///
/// # Errors
/// 摘要形状不对时**不发请求**([`OnlineErrorKind::Unsupported`]);站点 404 是
/// [`OnlineErrorKind::NotFound`](= 这个摘要不在 Modrinth 上,补全要如实记「补不出」)。
pub async fn version_by_hash(
&self,
algorithm: HashAlgorithm,
hex: &str,
) -> Result<Version, OnlineError> {
let hex = hex.trim();
if !algorithm.accepts(hex) {
return Err(OnlineError::unsupported(format!(
"摘要不是合法的 {}:长度 {},要求 {}",
algorithm.as_str(),
hex.len(),
algorithm.hex_len()
)));
}
let url = self.version_file_url(algorithm, hex);
let body = self.transport.get_text(&url).await?;
parse_version(&body)
}
}
/// 第一个参数用 `?`,后续用 `&`。
fn separator(first: &mut bool) -> &'static str {
if *first {
*first = false;
"?"
} else {
"&"
}
}
/// 最小百分号编码(query 串用;`A-Za-z0-9-._~` 原样,其余按字节编码)。
#[must_use]
pub fn encode(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
for byte in raw.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(char::from(byte));
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
+170
View File
@@ -0,0 +1,170 @@
//! 端点拼装 + 响应解析 + 离线分类(纯离线)。
use crate::addon::online::modrinth::{encode, HashAlgorithm, MODRINTH_API};
use crate::addon::online::{
ContentType, Modrinth, OnlineErrorKind, SearchQuery, SortIndex, VersionQuery,
};
use crate::download::blocking::block_on;
use super::fixtures;
use super::support::FixtureTransport;
fn query(text: &str) -> SearchQuery {
SearchQuery {
text: text.to_string(),
project_type: Some(ContentType::Mod),
game_version: Some("1.20.1".to_string()),
loader: Some("fabric".to_string()),
index: SortIndex::Downloads,
limit: 2,
offset: 0,
}
}
#[test]
fn search_url_carries_facets_and_query_in_the_sites_own_shape() {
let transport = FixtureTransport::new();
let client = Modrinth::new(&transport);
let url = client.search_url(&query("sodium edge"));
assert!(url.starts_with(&format!(
"{MODRINTH_API}/search?limit=2&offset=0&index=downloads"
)));
// 站点要的是二维数组的 JSON,再整体百分号编码
let facets = encode(&query("").facets());
assert!(url.contains(&format!("&facets={facets}")), "url={url}");
assert!(facets.contains("%5B%5B%22project_type%3Amod%22%5D"));
assert!(url.ends_with("&query=sodium%20edge"));
}
#[test]
fn empty_text_omits_the_query_parameter() {
let transport = FixtureTransport::new();
let client = Modrinth::new(&transport);
let url = client.search_url(&SearchQuery::default());
assert!(!url.contains("&query="), "空关键词不该带 query:{url}");
assert!(
!url.contains("&facets="),
"没有过滤条件不该带 facets:{url}"
);
}
#[test]
fn search_parses_hits_and_filters_fake_loaders() {
let transport =
FixtureTransport::new().with(&format!("{MODRINTH_API}/search"), fixtures::SEARCH);
let client = Modrinth::new(&transport);
let page = block_on(client.search(&query("sodium"))).expect("解析应当成功");
assert_eq!(page.total_hits, 1234);
assert_eq!(page.hits.len(), 2);
let sodium = &page.hits[0];
assert_eq!(sodium.project_id, "AANobbMI");
assert_eq!(sodium.title, "Sodium");
// `optimization` 不是加载器:只留 fabric / quilt
assert_eq!(
sodium.loaders,
vec!["fabric".to_string(), "quilt".to_string()]
);
assert!(sodium.supports_version("1.20.1"));
assert!(!sodium.supports_version("1.19.2"));
// 日期读的是 `date_modified`(旧实现读 `updated`,永远是空串)
assert_eq!(sodium.updated, "2026-02-03T10:11:12Z");
// 空 icon_url 当「没有」
assert_eq!(page.hits[1].icon_url, None);
// limit=2 且回了 2 条 ⇒ 本页装满 ⇒ 还有下一页(判据不是 total_hits)
assert!(page.has_more());
}
#[test]
fn has_more_is_decided_by_a_full_page() {
let mut page = crate::addon::online::SearchPage {
limit: 2,
hits: vec![Default::default(), Default::default()],
..Default::default()
};
assert!(page.has_more());
page.hits.pop();
assert!(!page.has_more());
}
#[test]
fn project_detail_parses_body_links_and_loader_list() {
let transport =
FixtureTransport::new().with(&format!("{MODRINTH_API}/project/sodium"), fixtures::PROJECT);
let client = Modrinth::new(&transport);
let project = block_on(client.project("sodium")).expect("解析应当成功");
assert_eq!(project.id, "AANobbMI");
assert_eq!(project.follows, 9912);
assert!(project.body.contains("modern rendering engine"));
assert_eq!(
project.loaders,
vec!["fabric".to_string(), "quilt".to_string()]
);
assert_eq!(
project.source_url.as_deref(),
Some("https://github.com/CaffeineMC/sodium")
);
}
#[test]
fn version_list_prefers_the_primary_file() {
let transport = FixtureTransport::new().with(
&format!("{MODRINTH_API}/project/sodium/version"),
fixtures::VERSIONS,
);
let client = Modrinth::new(&transport);
let versions = block_on(client.versions(&VersionQuery {
project: "sodium".to_string(),
game_version: Some("1.21.1".to_string()),
loader: Some("fabric".to_string()),
}))
.expect("解析应当成功");
assert_eq!(versions.len(), 1);
let file = versions[0].primary_file().expect("必须有文件");
// 取第一个会拿到 sources.jar:必须是 primary 的那个
assert_eq!(file.filename, "sodium-fabric-0.6.0.jar");
assert_eq!(file.size, 2048);
// 版本列表请求带了两个数组参数(不是被静默丢掉的空过滤)
let seen = transport.seen();
assert!(
seen[0].contains("game_versions=%5B%221.21.1%22%5D"),
"{}",
seen[0]
);
assert!(
seen[0].contains("loaders=%5B%22fabric%22%5D"),
"{}",
seen[0]
);
}
#[test]
fn a_hash_shape_mismatch_never_hits_the_network() {
let transport = FixtureTransport::new();
let client = Modrinth::new(&transport);
let error = block_on(client.version_by_hash(HashAlgorithm::Sha512, "not-a-hash"))
.expect_err("形状不对必须报错");
assert_eq!(error.kind(), OnlineErrorKind::Unsupported);
assert!(
transport.seen().is_empty(),
"不该发请求:{:?}",
transport.seen()
);
}
#[test]
fn offline_is_reported_as_not_connected_not_as_an_empty_page() {
let transport = FixtureTransport::new().offline(&format!("{MODRINTH_API}/search"));
let client = Modrinth::new(&transport);
let error = block_on(client.search(&query("sodium"))).expect_err("没网必须报错");
assert_eq!(error.kind(), OnlineErrorKind::NotConnected);
assert!(error.is_offline());
}
#[test]
fn a_broken_payload_is_malformed_not_empty() {
let transport =
FixtureTransport::new().with(&format!("{MODRINTH_API}/search"), "{\"hits\": 3}");
let client = Modrinth::new(&transport);
let error = block_on(client.search(&query("sodium"))).expect_err("形状不对必须报错");
assert_eq!(error.kind(), OnlineErrorKind::Malformed);
}
@@ -0,0 +1,135 @@
//! 远端条目补全:能补就补、补不出照旧如实计数、离线立刻停。
use crate::addon::online::{complete_files, Modrinth};
use crate::addon::pack::PackFile;
use crate::download::blocking::block_on;
use super::fixtures;
use super::support::FixtureTransport;
const HASH_PREFIX: &str = "https://api.modrinth.com/v2/version_file/";
fn entry(sha512: Option<&str>) -> PackFile {
let mut file = PackFile::with_path("mods/sodium-fabric-0.5.11.jar");
file.downloads
.push("https://cdn.modrinth.com/data/AANobbMI/versions/ver-a/sodium.jar".to_string());
file.size = None;
if let Some(sha512) = sha512 {
file.hashes.insert("sha512".to_string(), sha512.to_string());
}
file
}
#[test]
fn a_missing_sha1_is_filled_from_the_real_source() {
let transport = FixtureTransport::new().with(HASH_PREFIX, fixtures::VERSION_FILE_A);
let client = Modrinth::new(&transport);
let mut files = vec![entry(Some(fixtures::LOOKUP_SHA512))];
let summary = block_on(complete_files(&client, &mut files));
assert_eq!(summary.completed, 1);
assert_eq!(summary.unfilled, 0);
assert!(!summary.offline);
assert!(summary.changed());
assert_eq!(files[0].sha1(), Some(fixtures::LOOKUP_SHA1));
assert_eq!(files[0].size, Some(4096));
// 清单里已有的直链不被覆盖
assert_eq!(files[0].downloads.len(), 1);
assert!(files[0].downloads[0].ends_with("sodium.jar"));
}
#[test]
fn an_entry_that_already_has_a_sha1_is_left_alone() {
let transport = FixtureTransport::new();
let client = Modrinth::new(&transport);
let mut file = entry(Some(fixtures::LOOKUP_SHA512));
file.hashes.insert("sha1".to_string(), "9".repeat(40));
let mut files = vec![file];
let summary = block_on(complete_files(&client, &mut files));
assert_eq!(summary.completed, 0);
assert_eq!(
summary.unfilled, 0,
"已经有 sha1 的条目既不算补到也不算补不出"
);
assert_eq!(files[0].sha1(), Some("9".repeat(40).as_str()));
assert!(transport.seen().is_empty(), "不该发请求");
}
#[test]
fn an_entry_without_sha512_cannot_be_completed() {
let transport = FixtureTransport::new();
let client = Modrinth::new(&transport);
let mut files = vec![entry(None)];
let summary = block_on(complete_files(&client, &mut files));
assert_eq!(summary.completed, 0);
assert_eq!(summary.unfilled, 1);
assert_eq!(files[0].sha1(), None, "补不出就是补不出,不许猜");
assert!(transport.seen().is_empty());
}
#[test]
fn a_source_that_does_not_know_the_hash_is_counted_as_unfilled() {
let transport = FixtureTransport::new().missing(HASH_PREFIX);
let client = Modrinth::new(&transport);
let mut files = vec![entry(Some(fixtures::LOOKUP_SHA512))];
let summary = block_on(complete_files(&client, &mut files));
assert_eq!(summary.completed, 0);
assert_eq!(summary.unfilled, 1);
assert!(!summary.offline, "404 不是「没联网」");
assert_eq!(files[0].sha1(), None);
}
#[test]
fn a_version_whose_hashes_do_not_match_is_not_used() {
// 路由回的是同一个版本对象,但请求的是另一个 sha512 ⇒ 身份对不上
let transport = FixtureTransport::new().with(HASH_PREFIX, fixtures::VERSION_FILE_A);
let client = Modrinth::new(&transport);
let mut files = vec![entry(Some(fixtures::OTHER_SHA512))];
// OTHER_SHA512 在同一个版本里存在,但那条是 sources.jar:仍算能对上(站点给的摘要就是它)
let summary = block_on(complete_files(&client, &mut files));
assert_eq!(summary.completed, 1);
assert_eq!(
files[0].sha1(),
Some("4444444444444444444444444444444444444444")
);
// 而一个版本里根本没有的摘要 ⇒ 补不出
let mut unknown = vec![entry(Some(&"e".repeat(128)))];
let summary = block_on(complete_files(&client, &mut unknown));
assert_eq!(summary.completed, 0);
assert_eq!(summary.unfilled, 1);
assert_eq!(unknown[0].sha1(), None);
}
#[test]
fn offline_stops_after_one_request_and_counts_the_rest_as_unfilled() {
let transport = FixtureTransport::new().offline(HASH_PREFIX);
let client = Modrinth::new(&transport);
let mut files = vec![
entry(Some(fixtures::LOOKUP_SHA512)),
entry(Some(fixtures::LOOKUP_SHA512)),
entry(Some(fixtures::LOOKUP_SHA512)),
];
let summary = block_on(complete_files(&client, &mut files));
assert!(summary.offline);
assert_eq!(summary.completed, 0);
assert_eq!(summary.unfilled, 3);
assert_eq!(transport.seen().len(), 1, "离线后不该逐条重试");
}
#[test]
fn a_download_url_is_only_added_when_the_entry_has_none() {
let transport = FixtureTransport::new().with(HASH_PREFIX, fixtures::VERSION_FILE_A);
let client = Modrinth::new(&transport);
let mut file = entry(Some(fixtures::LOOKUP_SHA512));
file.downloads.clear();
let mut files = vec![file];
let summary = block_on(complete_files(&client, &mut files));
assert_eq!(summary.completed, 1);
assert_eq!(
files[0].downloads,
vec![
"https://cdn.modrinth.com/data/AANobbMI/versions/ver-a/sodium-fabric-0.5.11.jar"
.to_string()
]
);
}
@@ -0,0 +1,167 @@
//! 下载复核:sha1/sha512 对不对、失败重试几次、会不会留下半截文件。
use crate::addon::online::download::{download_verified, RETRY_ATTEMPTS};
use crate::addon::online::OnlineErrorKind;
use crate::addon::temp::TempRoot;
use crate::download::blocking::block_on;
use super::support::{hashes_of, FakeArtifact};
const BYTES: &[u8] = b"PK\x03\x04 fake mod jar bytes for offline fixtures";
fn spec() -> (String, String) {
hashes_of(BYTES)
}
#[test]
fn a_matching_download_is_written_and_reported() {
let root = TempRoot::new("online-ok");
let dest = root.join("sodium.jar");
let fetcher = FakeArtifact::new(BYTES);
let (sha1, sha512) = spec();
let done = block_on(download_verified(
&fetcher,
"https://cdn.modrinth.com/data/AANobbMI/sodium.jar",
Some(&sha1),
Some(&sha512),
Some(BYTES.len() as u64),
&dest,
))
.expect("摘要对得上就该成功");
assert_eq!(done.bytes, BYTES.len() as u64);
assert_eq!(done.digest.sha512, sha512);
assert_eq!(std::fs::read(&dest).expect("文件应当落盘"), BYTES);
assert_eq!(fetcher.attempts(), 1);
}
#[test]
fn a_sha512_mismatch_deletes_the_file_and_retries_then_fails() {
let root = TempRoot::new("online-bad-sha512");
let dest = root.join("sodium.jar");
let fetcher = FakeArtifact::new(BYTES);
let (sha1, _) = spec();
let wrong = "0".repeat(128);
let error = block_on(download_verified(
&fetcher,
"https://cdn.modrinth.com/data/AANobbMI/sodium.jar",
Some(&sha1),
Some(&wrong),
None,
&dest,
))
.expect_err("摘要不符必须失败");
assert_eq!(error.kind(), OnlineErrorKind::Integrity);
assert_eq!(fetcher.attempts(), RETRY_ATTEMPTS, "摘要不符要重试到上限");
assert!(!dest.exists(), "不符的内容不许留在盘上");
}
#[test]
fn a_sha1_mismatch_is_caught_even_when_the_fetcher_skips_verification() {
let root = TempRoot::new("online-bad-sha1");
let dest = root.join("sodium.jar");
let fetcher = FakeArtifact::new(BYTES);
let (_, sha512) = spec();
let wrong = "1".repeat(40);
let error = block_on(download_verified(
&fetcher,
"https://cdn.modrinth.com/data/AANobbMI/sodium.jar",
Some(&wrong),
Some(&sha512),
None,
&dest,
))
.expect_err("sha1 不符必须失败");
assert_eq!(error.kind(), OnlineErrorKind::Integrity);
assert!(error.message().contains("SHA-1"), "{}", error.message());
assert!(!dest.exists());
}
#[test]
fn a_transient_network_failure_is_retried_and_succeeds() {
let root = TempRoot::new("online-retry");
let dest = root.join("sodium.jar");
let fetcher = FakeArtifact::new(BYTES).failing(2);
let (sha1, sha512) = spec();
let done = block_on(download_verified(
&fetcher,
"https://cdn.modrinth.com/data/AANobbMI/sodium.jar",
Some(&sha1),
Some(&sha512),
Some(BYTES.len() as u64),
&dest,
))
.expect("第三次该成功");
assert_eq!(done.bytes, BYTES.len() as u64);
assert_eq!(fetcher.attempts(), 3);
assert!(dest.exists());
}
#[test]
fn exhausting_the_retries_leaves_no_half_file() {
let root = TempRoot::new("online-exhausted");
let dest = root.join("sodium.jar");
let fetcher = FakeArtifact::new(BYTES).failing(RETRY_ATTEMPTS + 5);
let (sha1, sha512) = spec();
let error = block_on(download_verified(
&fetcher,
"https://cdn.modrinth.com/data/AANobbMI/sodium.jar",
Some(&sha1),
Some(&sha512),
None,
&dest,
))
.expect_err("一直断网必须失败");
assert_eq!(error.kind(), OnlineErrorKind::NotConnected);
assert_eq!(fetcher.attempts(), RETRY_ATTEMPTS);
assert!(!dest.exists(), "失败后不许留文件");
}
#[test]
fn a_truncated_body_is_rejected_by_the_declared_size() {
let root = TempRoot::new("online-truncated");
let dest = root.join("sodium.jar");
let fetcher = FakeArtifact::new(BYTES).truncated();
// 声明的是全量大小,实际只写了一半 ⇒ 按字节数就该拒
let (sha1, sha512) = spec();
let error = block_on(download_verified(
&fetcher,
"https://cdn.modrinth.com/data/AANobbMI/sodium.jar",
Some(&sha1),
Some(&sha512),
Some(BYTES.len() as u64),
&dest,
))
.expect_err("半截文件必须失败");
assert_eq!(error.kind(), OnlineErrorKind::Integrity);
assert!(!dest.exists());
}
#[test]
fn a_malformed_declared_hash_never_touches_the_disk_or_the_network() {
let root = TempRoot::new("online-bad-decl");
let dest = root.join("sodium.jar");
let fetcher = FakeArtifact::new(BYTES);
let error = block_on(download_verified(
&fetcher,
"https://cdn.modrinth.com/data/AANobbMI/sodium.jar",
None,
Some("abc"),
None,
&dest,
))
.expect_err("摘要形状不对必须失败");
assert_eq!(error.kind(), OnlineErrorKind::Unsupported);
assert_eq!(fetcher.attempts(), 0);
assert!(!dest.exists());
}
#[test]
fn an_empty_url_is_unsupported() {
let root = TempRoot::new("online-no-url");
let dest = root.join("sodium.jar");
let fetcher = FakeArtifact::new(BYTES);
let error = block_on(download_verified(&fetcher, " ", None, None, None, &dest))
.expect_err("没有地址必须失败");
assert_eq!(error.kind(), OnlineErrorKind::Unsupported);
assert_eq!(fetcher.attempts(), 0);
}
@@ -0,0 +1,143 @@
//! 录制下来的 Modrinth v2 响应(**形状按站点原文,字段抄真**;数值是示意,不是某天的真值)。
//!
//! 三条刻意的细节:
//! - 检索命中的日期字段是 `date_modified`(不是 `updated`)——旧实现读错了字段;
//! - `categories` 里混着 `optimization` 这种分类标签,只有 `fabric` 是真加载器;
//! - 版本里的文件有 `primary: false` 的 `sources.jar`,取第一个会装错。
/// `/v2/search?query=sodium`(两页里的第一页)。
pub(super) const SEARCH: &str = r#"{
"hits": [
{
"project_id": "AANobbMI",
"project_type": "mod",
"slug": "sodium",
"author": "jellysquid3",
"title": "Sodium",
"description": "Modern rendering engine and client-side optimization mod",
"categories": ["fabric", "optimization", "quilt"],
"display_categories": ["optimization"],
"versions": ["1.20.1", "1.21.1"],
"downloads": 42123456,
"follows": 9912,
"icon_url": "https://cdn.modrinth.com/data/AANobbMI/icon.png",
"date_created": "2020-12-01T00:00:00Z",
"date_modified": "2026-02-03T10:11:12Z",
"latest_version": "mc1.21.1-0.6.0",
"license": "LGPL-3.0-only",
"client_side": "required",
"server_side": "unsupported",
"gallery": []
},
{
"project_id": "P7dR8mSH",
"project_type": "mod",
"slug": "fabric-api",
"author": "modmuss50",
"title": "Fabric API",
"description": "Lightweight and modular API providing common hooks and intercompatibility measures",
"categories": ["fabric", "library"],
"versions": ["1.20.1", "1.21.1"],
"downloads": 99000000,
"follows": 12000,
"icon_url": "",
"date_modified": "2026-01-30T08:00:00Z"
}
],
"offset": 0,
"limit": 2,
"total_hits": 1234
}"#;
/// `/v2/project/sodium`。
pub(super) const PROJECT: &str = r###"{
"id": "AANobbMI",
"slug": "sodium",
"project_type": "mod",
"team": "team-id",
"title": "Sodium",
"description": "Modern rendering engine and client-side optimization mod",
"body": "## Sodium\n\nA modern rendering engine for Minecraft which greatly improves performance.",
"published": "2020-12-01T00:00:00Z",
"updated": "2026-02-03T10:11:12Z",
"downloads": 42123456,
"followers": 9912,
"categories": ["fabric", "optimization"],
"game_versions": ["1.20.1", "1.21.1"],
"loaders": ["fabric", "quilt"],
"versions": ["ver-1", "ver-2"],
"icon_url": "https://cdn.modrinth.com/data/AANobbMI/icon.png",
"source_url": "https://github.com/CaffeineMC/sodium",
"issues_url": "https://github.com/CaffeineMC/sodium/issues"
}"###;
/// `/v2/project/sodium/version`(站点顺序 = 最新在前)。
pub(super) const VERSIONS: &str = r#"[
{
"id": "ver-2",
"project_id": "AANobbMI",
"name": "Sodium 0.6.0 for 1.21.1",
"version_number": "mc1.21.1-0.6.0",
"version_type": "release",
"loaders": ["fabric"],
"game_versions": ["1.21.1"],
"date_published": "2026-02-01T12:00:00Z",
"downloads": 4321,
"files": [
{
"hashes": {"sha1": "1111111111111111111111111111111111111111", "sha512": "aa"},
"url": "https://cdn.modrinth.com/data/AANobbMI/versions/ver-2/sodium-sources.jar",
"filename": "sodium-sources.jar",
"size": 1000,
"primary": false
},
{
"hashes": {"sha1": "2222222222222222222222222222222222222222", "sha512": "bb"},
"url": "https://cdn.modrinth.com/data/AANobbMI/versions/ver-2/sodium-fabric-0.6.0.jar",
"filename": "sodium-fabric-0.6.0.jar",
"size": 2048,
"primary": true
}
]
}
]"#;
/// `GET /v2/version_file/{sha512}?algorithm=sha512` 的响应(一个版本对象)。
///
/// 里面有两个文件:只有 `hashes.sha512` 与请求值相同的那条能取 sha1(另一个是 `sources.jar`)。
pub(super) const VERSION_FILE_A: &str = r#"{
"id": "ver-a",
"project_id": "AANobbMI",
"name": "Sodium 0.5.11 for 1.20.1",
"version_number": "mc1.20.1-0.5.11",
"version_type": "release",
"loaders": ["fabric"],
"game_versions": ["1.20.1"],
"date_published": "2025-06-01T00:00:00Z",
"downloads": 9999,
"files": [
{
"hashes": {"sha1": "3333333333333333333333333333333333333333", "sha512": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"},
"url": "https://cdn.modrinth.com/data/AANobbMI/versions/ver-a/sodium-fabric-0.5.11.jar",
"filename": "sodium-fabric-0.5.11.jar",
"size": 4096,
"primary": true
},
{
"hashes": {"sha1": "4444444444444444444444444444444444444444", "sha512": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"},
"url": "https://cdn.modrinth.com/data/AANobbMI/versions/ver-a/sodium-sources.jar",
"filename": "sodium-sources.jar",
"size": 512,
"primary": false
}
]
}"#;
/// 补全时请求的那个 sha512(128 位十六进制;形状不对客户端根本不发请求)。
pub(super) const LOOKUP_SHA512: &str = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1";
/// 同一版本里另一个文件的 sha512(`sources.jar`,不该被拿来当身份)。
pub(super) const OTHER_SHA512: &str = "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2";
/// 补全应该拿到的 sha1(`VERSION_FILE_A` 主文件那条)。
pub(super) const LOOKUP_SHA1: &str = "3333333333333333333333333333333333333333";
+13
View File
@@ -0,0 +1,13 @@
//! 在线内容源的离线用例(**不打真实网络**)。
//!
//! 全部素材是录制好的 Modrinth v2 响应(`fixtures.rs`)+ 假字节(`support::FakeArtifact`):
//! 覆盖检索、项目详情、版本列表、按摘要反查、下载复核、补全路径与离线分类。
//!
//! 口径提醒:这些用例证明的是**解析与判定**,不证明「真实站点今天回什么」——后者写在
//! M162 报告的「没验的部分」里。
mod api;
mod complete;
mod download;
mod fixtures;
mod support;
@@ -0,0 +1,169 @@
//! 测试替身:录制响应传输层 + 假字节下载器。
//!
//! 两个替身都**只做注入点该做的事**:不校验、不重试、不判断——那些必须在被测代码里,
//! 否则测试就只是「验证桩自己」。
use std::collections::BTreeMap;
use std::io::Write;
use std::path::Path;
use std::sync::Mutex;
use sha1::Digest as _;
use crate::addon::online::download::{ArtifactFetcher, FetchArgs};
use crate::addon::online::{OnlineError, OnlineErrorKind, OnlineFuture, Transport};
/// 按 URL 前缀路由的传输层(把录制 JSON 喂给客户端)。
pub(super) struct FixtureTransport {
routes: BTreeMap<String, Result<String, OnlineError>>,
seen: Mutex<Vec<String>>,
}
impl FixtureTransport {
/// 建一个空的路由表。
pub(super) fn new() -> Self {
Self {
routes: BTreeMap::new(),
seen: Mutex::new(Vec::new()),
}
}
/// 挂一条路由(`prefix` 是 URL 前缀)。
pub(super) fn with(mut self, prefix: &str, body: &str) -> Self {
self.routes.insert(prefix.to_string(), Ok(body.to_string()));
self
}
/// 挂一条「没联网」的路由。
pub(super) fn offline(mut self, prefix: &str) -> Self {
self.routes.insert(
prefix.to_string(),
Err(OnlineError::offline("连不上 api.modrinth.com")),
);
self
}
/// 挂一条「站点说没有」的路由。
pub(super) fn missing(mut self, prefix: &str) -> Self {
self.routes.insert(
prefix.to_string(),
Err(OnlineError::new(OnlineErrorKind::NotFound, "HTTP 404")),
);
self
}
/// 实际请求过的 URL(按顺序)。
pub(super) fn seen(&self) -> Vec<String> {
self.seen.lock().expect("锁没中毒").clone()
}
}
impl Transport for FixtureTransport {
fn get_text<'a>(&'a self, url: &'a str) -> OnlineFuture<'a, Result<String, OnlineError>> {
Box::pin(async move {
self.seen.lock().expect("锁没中毒").push(url.to_string());
// 最长前缀优先:`/v2/project/sodium` 与 `/v2/project/sodium/version` 不能互相抢。
let best = self
.routes
.iter()
.filter(|(prefix, _)| url.starts_with(prefix.as_str()))
.max_by_key(|(prefix, _)| prefix.len())
.map(|(_, result)| result);
match best {
Some(Ok(body)) => Ok(body.clone()),
Some(Err(error)) => Err(error.clone()),
None => Err(OnlineError::new(
OnlineErrorKind::NotFound,
format!("测试没为 {url} 挂路由"),
)),
}
})
}
}
/// 假字节下载器:把给定字节写到 `dest`,可以安排前几次失败。
pub(super) struct FakeArtifact {
bytes: Vec<u8>,
/// 还欠几次「没联网」失败(每尝试一次减一)
failures: Mutex<u32>,
/// 实际尝试次数
attempts: Mutex<u32>,
/// 故意只写一半(模拟半截文件)
truncate: bool,
}
impl FakeArtifact {
/// 正常一份字节。
pub(super) fn new(bytes: &[u8]) -> Self {
Self {
bytes: bytes.to_vec(),
failures: Mutex::new(0),
attempts: Mutex::new(0),
truncate: false,
}
}
/// 前 `count` 次尝试没联网。
pub(super) fn failing(mut self, count: u32) -> Self {
*self.failures.get_mut().expect("锁没中毒") = count;
self
}
/// 只写一半字节(半截文件场景)。
pub(super) fn truncated(mut self) -> Self {
self.truncate = true;
self
}
/// 尝试次数。
pub(super) fn attempts(&self) -> u32 {
*self.attempts.lock().expect("锁没中毒")
}
}
impl ArtifactFetcher for FakeArtifact {
fn fetch<'a>(&'a self, args: FetchArgs<'a>) -> OnlineFuture<'a, Result<u64, OnlineError>> {
Box::pin(async move {
*self.attempts.lock().expect("锁没中毒") += 1;
let mut failures = self.failures.lock().expect("锁没中毒");
if *failures > 0 {
*failures -= 1;
return Err(OnlineError::offline("假装的断网"));
}
drop(failures);
write_bytes(args.dest, &self.bytes, self.truncate)?;
Ok(if self.truncate {
self.bytes.len() as u64 / 2
} else {
self.bytes.len() as u64
})
})
}
}
fn write_bytes(dest: &Path, bytes: &[u8], truncated: bool) -> Result<(), OnlineError> {
let mut file = std::fs::File::create(dest).map_err(|error| {
OnlineError::new(
OnlineErrorKind::Io,
format!("建 {} 失败:{error}", dest.display()),
)
})?;
let half = bytes.len() / 2;
let slice = if truncated { &bytes[..half] } else { bytes };
file.write_all(slice).map_err(|error| {
OnlineError::new(
OnlineErrorKind::Io,
format!("写 {} 失败:{error}", dest.display()),
)
})
}
/// 一段字节的 `(sha1, sha512)`(小写十六进制)。
pub(super) fn hashes_of(bytes: &[u8]) -> (String, String) {
let sha1 = sha1::Sha1::digest(bytes);
let sha512 = sha2::Sha512::digest(bytes);
(
crate::addon::online::download::to_hex(&sha1),
crate::addon::online::download::to_hex(&sha512),
)
}
+203
View File
@@ -0,0 +1,203 @@
//! 唯一的 HTTP 出口 + 错误分类。
//!
//! # 为什么是 trait
//!
//! 三条理由,每条都对应一个真实麻烦:
//!
//! 1. **测试不能联网**:本模块的解析/端点/补全全部要用录制 JSON 跑(CI 没有网,也不该有);
//! 2. **换源不改业务**:将来加第二个内容源时,只要它也能 GET 出 JSON,就能复用这套
//! `Transport`(现在的实现按「一个 URL → 一段文本」抽象,不假设站点);
//! 3. **错误必须可分辨**:「没联网」和「站点说没有这个项目」是两件事(前者该提示检查网络,
//! 后者该提示换关键词),字符串错误串会让上层只能靠 `contains` 猜。
//!
//! # 生产实现只有一条路:`core::download` 的引擎
//!
//! [`EngineTransport`] 调 [`DownloadEngine::download_bytes`],于是信号量限流、源健康度、
//! 指数退避、错误分类全在引擎里。本模块**不**新建 `reqwest::Client`、**不**写重试循环
//! (与 `addon::import::fetch` 的纪律同源)。镜像策略同理:`mirrors.json` 里没有
//! `api.modrinth.com` 的前缀规则 ⇒ 元数据请求永远是直连,只有真正的文件下载才会被镜像
//! 规则命中(国内 gh-proxy 那条也只看 GitHub host)。
use std::future::Future;
use std::pin::Pin;
use crate::download::{
DownloadEngine, DownloadError, DownloadErrorKind, DownloadRequest, ResourceKind,
};
/// trait 方法返回的装箱 future(core 不引 `async-trait`,手写与 `import::fetch` 同一个范式)。
pub type OnlineFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
/// 「拿不到」的成因(**分级判据只在这里**)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OnlineErrorKind {
/// 没联网 / 连不上 / 所有候选源都失败 —— 上层要明确说「未联网」。
NotConnected,
/// 站点说没有(404 / 项目被删)。
NotFound,
/// 站点出错(5xx / 429 退避后仍失败)。
Server,
/// 响应不是期望形状(JSON 坏 / 字段类型不对)。
Malformed,
/// 本地 IO(写缓存失败等)。
Io,
/// 内容与声明的摘要不符(下载到的字节坏了 / 镜像给的其实是别的东西)。
Integrity,
/// 这条路走不通(URL 非法、摘要格式不对、没有可用下载地址)。
Unsupported,
}
impl OnlineErrorKind {
/// 稳定字符串 id(日志 / DTO 用,**不要改名**)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::NotConnected => "online_not_connected",
Self::NotFound => "online_not_found",
Self::Server => "online_server",
Self::Malformed => "online_malformed",
Self::Io => "online_io",
Self::Integrity => "online_integrity",
Self::Unsupported => "online_unsupported",
}
}
/// 是不是「网络这条路没通」(决定上层画「未联网」还是「查无此项」)。
#[must_use]
pub const fn is_offline(self) -> bool {
matches!(self, Self::NotConnected)
}
}
/// 一次取数失败。
#[derive(Debug, Clone)]
pub struct OnlineError {
kind: OnlineErrorKind,
message: String,
}
impl OnlineError {
/// 构造。
#[must_use]
pub fn new(kind: OnlineErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
/// 没联网。
#[must_use]
pub fn offline(message: impl Into<String>) -> Self {
Self::new(OnlineErrorKind::NotConnected, message)
}
/// 形状不对 / 自身不自洽。
#[must_use]
pub fn malformed(message: impl Into<String>) -> Self {
Self::new(OnlineErrorKind::Malformed, message)
}
/// 内容与声明不符。
#[must_use]
pub fn integrity(message: impl Into<String>) -> Self {
Self::new(OnlineErrorKind::Integrity, message)
}
/// 这条路走不通。
#[must_use]
pub fn unsupported(message: impl Into<String>) -> Self {
Self::new(OnlineErrorKind::Unsupported, message)
}
/// 大类。
#[must_use]
pub const fn kind(&self) -> OnlineErrorKind {
self.kind
}
/// 人读说明。
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
/// 没联网吗(上层据此决定降级文案)。
#[must_use]
pub const fn is_offline(&self) -> bool {
self.kind.is_offline()
}
}
impl std::fmt::Display for OnlineError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "{}:{}", self.kind.as_str(), self.message)
}
}
impl std::error::Error for OnlineError {}
/// `DownloadEngine` 的错误 → 本模块的错误分类。
///
/// **分类口径**:连接类/超时/所有候选源失败都是「没联网」(用户能做的只有检查网络);
/// 5xx/429 是「站点那边的问题」,与没网不同,但都值得重试一次。
impl From<DownloadError> for OnlineError {
fn from(error: DownloadError) -> Self {
let kind = match error.kind {
DownloadErrorKind::NotFound => OnlineErrorKind::NotFound,
DownloadErrorKind::ServerError => OnlineErrorKind::Server,
DownloadErrorKind::Timeout
| DownloadErrorKind::Network
| DownloadErrorKind::AllSourcesFailed => OnlineErrorKind::NotConnected,
DownloadErrorKind::Io => OnlineErrorKind::Io,
DownloadErrorKind::Integrity | DownloadErrorKind::RangeUnsupported => {
OnlineErrorKind::Malformed
}
DownloadErrorKind::InvalidRequest => OnlineErrorKind::Unsupported,
};
Self::new(kind, error.message)
}
}
/// 一个 URL → 一段文本(JSON 原文)。
///
/// 实现方只负责「取回来」:状态码分类、退避、限流都在实现里(生产实现在引擎里)。
pub trait Transport: Send + Sync {
/// GET 一段文本。
fn get_text<'a>(&'a self, url: &'a str) -> OnlineFuture<'a, Result<String, OnlineError>>;
}
/// 生产实现:把 GET 交给 `core::download` 的引擎(内存下载路径)。
#[derive(Debug, Clone, Copy)]
pub struct EngineTransport<'a> {
engine: &'a DownloadEngine,
}
impl<'a> EngineTransport<'a> {
/// 绑定引擎。
#[must_use]
pub const fn new(engine: &'a DownloadEngine) -> Self {
Self { engine }
}
}
impl Transport for EngineTransport<'_> {
fn get_text<'a>(&'a self, url: &'a str) -> OnlineFuture<'a, Result<String, OnlineError>> {
Box::pin(async move {
// `download_bytes` 不落盘(`dest` 只是请求形状的一部分),所以这里给一个中性路径;
// 内容寻址缓存按 URL 规划,不看它。
let request = DownloadRequest::new(
ResourceKind::Other,
url,
std::env::temp_dir().join("ull-online-metadata.json"),
);
let bytes = self
.engine
.download_bytes(request)
.await
.map_err(OnlineError::from)?;
String::from_utf8(bytes)
.map_err(|error| OnlineError::malformed(format!("{url} 的响应不是 UTF-8:{error}")))
})
}
}
@@ -0,0 +1,145 @@
//! 远端条目补全(M162):**有 path 但没有 `hashes.sha1` 的 mrpack 条目,补得出就判,补不出照旧计数**。
//!
//! 三条纪律:
//!
//! 1. 补全靠 `PackFetcher::modrinth_file_by_sha512`(默认 `Unsupported` ⇒ 离线导入补不出);
//! 2. 补出来的条目与自带 `sha1` 的条目走**同一个** `judge`(M160 的判据一行没改);
//! 3. 补出的 `sha1` 会真的用在下载校验上——所以「站点给错摘要」必须是 `Failed`(内容不符),
//! 不能被当成「暂时搞不到」混过去。
//!
//! 全部离线:`StubFetcher` 里挂一条 sha512 → 元数据,没有一个真实请求。
use super::support::{sha1, StubFetcher};
use super::{read_text, write_file, zip_dir, TempRoot};
use crate::addon::import::{
import_pack, scan_pack, scan_pack_online, ImportRequest, ImportStatus, ModrinthFileMeta,
};
use crate::download::blocking::block_on;
const PATH: &str = "mods/no-sha1.jar";
const DISK_BYTES: &[u8] = b"what the user already has";
const PACK_BYTES: &[u8] = b"what the content source serves";
const DOWNLOAD: &str = "stub://mods/no-sha1.jar";
const SHA512: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\
0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
/// 一个只有 `hashes.sha512` 的 mrpack(sha1 缺失 = 预检判不了)。
fn mrpack_without_sha1(tag: &str) -> (TempRoot, std::path::PathBuf) {
let root = TempRoot::new(tag);
let dir = root.join("src");
let index = format!(
r#"{{"game":"minecraft","formatVersion":1,"versionId":"1.0.0","name":"缺 sha1 的包",
"files":[{{"path":"{PATH}",
"hashes":{{"sha512":"{SHA512}"}},
"downloads":["{DOWNLOAD}"],"fileSize":{size}}}],
"dependencies":{{"minecraft":"1.20.1"}}}}"#,
size = PACK_BYTES.len()
);
write_file(&dir.join("modrinth.index.json"), index.as_bytes());
let pack = root.join("pack.mrpack");
zip_dir(&dir, &pack);
(root, pack)
}
/// 站点给出这个文件的 sha1/字节数(`sha1` 由调用方决定对不对)。
fn meta(pack_sha1: &str) -> ModrinthFileMeta {
ModrinthFileMeta {
sha1: Some(pack_sha1.to_string()),
size: Some(PACK_BYTES.len() as u64),
url: Some(DOWNLOAD.to_string()),
file_name: Some("no-sha1.jar".to_string()),
}
}
#[test]
fn offline_scan_keeps_the_unknown_remote_count() {
// 对照:没有 fetcher(离线)时口径与 M160 完全一致。
let (root, pack) = mrpack_without_sha1("online-scan-offline");
let target = root.join("instance");
write_file(&target.join(PATH), DISK_BYTES);
let scan = scan_pack(&ImportRequest::new(&pack, &target));
assert!(scan.is_ok(), "{:?}", scan.error);
assert_eq!(scan.unknown_remote, 1, "缺 sha1 就是判不了,如实计数");
assert!(scan.conflicts.is_empty());
}
#[test]
fn an_online_scan_completes_the_sha1_and_judges_the_conflict() {
let (root, pack) = mrpack_without_sha1("online-scan-filled");
let target = root.join("instance");
write_file(&target.join(PATH), DISK_BYTES);
let fetcher = StubFetcher::new().with_modrinth(SHA512, meta(&sha1(PACK_BYTES)));
let scan = block_on(scan_pack_online(
&ImportRequest::new(&pack, &target).with_fetcher(&fetcher),
));
assert!(scan.is_ok(), "{:?}", scan.error);
assert_eq!(scan.unknown_remote, 0, "补出来了就不再是「判不了」");
let paths: Vec<&str> = scan.conflicts.iter().map(|c| c.path.as_str()).collect();
assert_eq!(paths, vec![PATH], "补出来的摘要要真的用上:{:?}", paths);
assert_eq!(scan.conflicts[0].pack_sha1, sha1(PACK_BYTES));
assert!(scan
.notes
.iter()
.any(|note| note.message.contains("补全了 1 条远端条目的 sha1")));
}
#[test]
fn a_scan_whose_source_does_not_know_the_hash_still_counts_it() {
let (root, pack) = mrpack_without_sha1("online-scan-unknown");
let target = root.join("instance");
write_file(&target.join(PATH), DISK_BYTES);
// 站点不认得这个 sha512(stub 里没挂)⇒ 补不出,口径回到 M160
let fetcher = StubFetcher::new();
let scan = block_on(scan_pack_online(
&ImportRequest::new(&pack, &target).with_fetcher(&fetcher),
));
assert_eq!(scan.unknown_remote, 1);
assert!(scan.conflicts.is_empty());
}
#[test]
fn a_completed_sha1_is_actually_used_for_the_download_check() {
let (root, pack) = mrpack_without_sha1("online-import-ok");
let target = root.join("instance");
let fetcher = StubFetcher::new()
.with_modrinth(SHA512, meta(&sha1(PACK_BYTES)))
.with_file(DOWNLOAD, PACK_BYTES);
let report = block_on(import_pack(
&ImportRequest::new(&pack, &target).with_fetcher(&fetcher),
));
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
assert_eq!(
read_text(&target.join(PATH)),
String::from_utf8_lossy(PACK_BYTES)
);
assert!(report.missing.is_empty(), "{:?}", report.missing);
assert!(
report
.warnings
.iter()
.any(|note| note.message.contains("补全了 1 条远端条目的 sha1")),
"报告要说清补全过:{:?}",
report.warnings
);
}
#[test]
fn a_wrong_completed_sha1_fails_the_import_instead_of_installing() {
// 站点给了错的 sha1 ⇒ 下载校验必须拦住(这是"内容坏了",不是"暂时搞不到")。
let (root, pack) = mrpack_without_sha1("online-import-bad");
let target = root.join("instance");
let fetcher = StubFetcher::new()
.with_modrinth(SHA512, meta(&"f".repeat(40)))
.with_file(DOWNLOAD, PACK_BYTES);
let report = block_on(import_pack(
&ImportRequest::new(&pack, &target).with_fetcher(&fetcher),
));
assert_eq!(report.status, ImportStatus::Failed, "{:?}", report.status);
assert_eq!(
report.error.as_ref().map(|error| error.code()),
Some("import_integrity"),
"{:?}",
report.error
);
assert!(!target.join(PATH).exists(), "失败的导入不许留文件");
}
+1
View File
@@ -28,6 +28,7 @@ mod import_layering;
mod import_merge;
mod import_modrinth;
mod import_multimc;
mod import_online;
mod import_report;
mod roundtrip_three;
mod support;
+26 -1
View File
@@ -18,7 +18,9 @@ use std::sync::Mutex;
use serde_json::json;
use super::super::import::{CurseFileMeta, FetchFuture, FetchRequest, FetchedFile, PackFetcher};
use super::super::import::{
CurseFileMeta, FetchFuture, FetchRequest, FetchedFile, ModrinthFileMeta, PackFetcher,
};
use super::fixtures;
use super::fixtures::Fixture;
use super::{write_file, zip_dir};
@@ -51,6 +53,8 @@ pub(crate) fn sha1(bytes: &[u8]) -> String {
pub(crate) struct StubFetcher {
files: BTreeMap<String, Vec<u8>>,
curse: BTreeMap<(i64, i64), CurseFileMeta>,
/// M162:按 sha512 查到的 Modrinth 文件元数据(模拟「站点认得这个文件」)。
modrinth: BTreeMap<String, ModrinthFileMeta>,
/// 被请求过的 URL(断言"没白下载"用)。
calls: Mutex<Vec<String>>,
/// 是否像真引擎那样在目标旁边留下续传残片(`.ullpart-*` / `.ullmeta.json`)。
@@ -63,6 +67,7 @@ impl StubFetcher {
Self {
files: BTreeMap::new(),
curse: BTreeMap::new(),
modrinth: BTreeMap::new(),
calls: Mutex::new(Vec::new()),
artifacts: false,
}
@@ -80,6 +85,12 @@ impl StubFetcher {
self
}
/// 挂一条「按 sha512 反查」的结果(M162 的补全路径)。
pub(crate) fn with_modrinth(mut self, sha512: &str, meta: ModrinthFileMeta) -> Self {
self.modrinth.insert(sha512.to_string(), meta);
self
}
/// 像真引擎那样在每次"下载"后留下续传残片与 meta(用来钉"中间产物不许进实例")。
pub(crate) fn with_engine_artifacts(mut self) -> Self {
self.artifacts = true;
@@ -111,6 +122,20 @@ impl PackFetcher for StubFetcher {
})
}
fn modrinth_file_by_sha512<'a>(
&'a self,
sha512: &'a str,
) -> FetchFuture<'a, Result<ModrinthFileMeta, super::super::import::FetchError>> {
Box::pin(async move {
self.modrinth.get(sha512).cloned().ok_or_else(|| {
super::super::import::FetchError::new(
super::super::import::FetchErrorKind::NotFound,
format!("stub 里没有 sha512={sha512}"),
)
})
})
}
fn fetch_file<'a>(
&'a self,
request: FetchRequest<'a>,