fix(m162-r2): 金表沿用主线的拆分边界 + phase/pipeline 回到 400 行内

金表(rebase 到 M161 后重写这一段):**采用 main 的拆分边界**——`golden/tail.rs`
(`InstancesLaunchReset` 之后、`PackTargetNew` 之前)+ `golden/tail2.rs`
(`PackInstanceName` 之后),不再保留本分支原来那套 205/221 的切法。
本分支新增的 6 条 key(`ContentOffline{Title,Body}`、`ContentDetail{Fetching,FetchFailed,
VersionCount,VersionLine}`)按 `Key::ALL` 的顺序并入 main 的 `tail.rs`;
`golden.rs` 的 `rows()` 保持 `GOLDEN + tail + tail2` 三段串联。(`len()` 与内容同源的修正
在下一个提交里。)

其余两处(本来就是我推过线的,现在回到红线内):
- `import/phase.rs` 397 → **400**:只动注释(`Resolved` 的两个摘要字段共用一句、
  `resolve`/`settle` 的文档各并一行),代码语义零改动。
- `import/pipeline.rs` 399 → **400**:把补全注记(14 行)搬到 `import/complete.rs` 的
  `Completion::note(lead)` 旁边,管线只剩一行 `complete_and_note(...)`;
  `scan_pack_online` 也改用同一个 `note("预检前")`——注记文案由两处变一处。

同时删除与 `import/complete.rs` 重复的 `online::complete::{complete_files, CompletionSummary}`
(生产零调用方),只留生产在用的 `identity_of`。
This commit is contained in:
AskaEth committed 2026-11-07 17:37:13 +08:00
1 parent b394316df4
commit ecd24f0608
12 files changed
+249 -262

No files matched your search

+81 -87
View File
@@ -1,89 +1,29 @@
//! 远端条目补全:**mrpack 的 `files[]` 只有 `path`+`downloads`+`hashes` 时,按摘要反查补 sha1**。
//! 按摘要认文件:[`identity_of`] 判断「站点返回的这个版本里,哪个文件才是我们要的那个」。
//!
//! # 补的是什么(以及为什么不许猜)
//! # 为什么只留这一件
//!
//! mrpack 规范里 `files[].hashes.sha512` 必填、`sha1` 可选。少了 `sha1` 的条目在 M160 的预检里
//! 判不了「会不会覆盖用户文件」(判据是 SHA-1),只能记进 `ImportScan::unknown_remote`。
//! 现在按 `sha512` 去 Modrinth 反查:查得到就把**站点给的 sha1**填回去(那是同一个文件在同一
//! 站点上的官方摘要,不是本地推算),查不到就**保持缺**、照旧计数——绝不「猜一个路径填上」,
//! 也绝不拿 sha512 冒充 sha1。
//! 生产上的补全循环只有**一处**:`crate::addon::import::complete`(它走 `PackFetcher`,
//! 因此能区分「站点不认得这个摘要(404)」与「没联网(Offline)」,这决定了要不要提前收工)。
//! 这里曾经还有一份 `complete_files` 循环,r2 复审指出「看着像生产代码其实没人调、两套循环
//! 迟早漂移」⇒ 已删除。本文件只留**判定**,两个调用点共用:
//!
//! # 一致性检查(反查结果必须能对上)
//! | 调用方 | 用它做什么 |
//! |--------|------------|
//! | `addon::import::fetch`(生产补全) | 按 sha512 反查回来的版本 → 取出那份文件的官方 sha1/大小/直链 |
//! | `online::complete` 的用例 | 钉住「对不上就不许用」 |
//!
//! 站点返回的版本里可能不止一个文件(`primary` 之外还有 `sources.jar`)。所以只在
//! **`hashes.sha512` 与请求值逐字相同**的那个文件上取 `sha1`;一个都对不上就当「补不出」
//! ——宁可老实计数,也不把别的东西的摘要装进来。
//! # 一致性比「有值」重要
//!
//! # 离线时立刻停
//!
//! 无网时逐条重试是拿 N 次超时惩罚用户。第一条网络类失败就把 [`CompletionSummary::offline`]
//! 置位并停止请求,剩下的条目一律算「补不出」(预检的注记本来就会说「有 N 条判不了」)。
//! 站点返回的版本里可能有多个文件(`primary` 之外还有 `sources.jar`)。只在
//! **`hashes.sha512` 与请求值逐字相同**的那条上取 `sha1`;一个都对不上就返回 `None`
//! ——宁可让上游记「补不出」,也不把别的东西的摘要装进来。
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 与请求值相同(站点数据不一致 / 版本被换过)。
/// `None` = 这个版本里没有任何文件的 sha512 与请求值相同(站点数据不一致 / 版本被换过),
/// 或者对上的那条没有 sha1 可用。
#[must_use]
pub fn identity_of(version: &Version, sha512: &str) -> Option<Identity> {
let file = version.files.iter().find(|file| {
@@ -109,17 +49,71 @@ pub struct Identity {
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());
#[cfg(test)]
mod tests {
use super::identity_of;
use crate::addon::online::model::{Version, VersionFile};
fn version(files: Vec<VersionFile>) -> Version {
Version {
id: "v1".to_string(),
project_id: "p1".to_string(),
files,
..Version::default()
}
}
fn file(sha1: Option<&str>, sha512: &str, primary: bool) -> VersionFile {
let mut file = VersionFile {
url: "https://cdn.modrinth.com/data/p1/v1/a.jar".to_string(),
filename: "a.jar".to_string(),
size: 2048,
primary,
..VersionFile::default()
};
file.hashes.insert("sha512".to_string(), sha512.to_string());
if let Some(sha1) = sha1 {
file.hashes.insert("sha1".to_string(), sha1.to_string());
}
file
}
#[test]
fn the_matching_file_wins_even_when_it_is_not_the_primary() {
let wanted = "a".repeat(128);
let other = "b".repeat(128);
let version = version(vec![
file(Some(&"1".repeat(40)), &other, true),
file(Some(&"2".repeat(40)), &wanted, false),
]);
let identity = identity_of(&version, &wanted).expect("摘要对得上,必须给出身份");
assert_eq!(identity.sha1, "2".repeat(40));
assert_eq!(identity.size, Some(2048));
assert!(identity.url.is_some());
}
#[test]
fn a_version_without_that_hash_yields_nothing() {
let version = version(vec![file(Some(&"1".repeat(40)), &"b".repeat(128), true)]);
assert_eq!(identity_of(&version, &"c".repeat(128)), None);
}
#[test]
fn a_matching_file_without_a_sha1_is_not_usable() {
// 站点只给了 sha512:这时没有可填的 sha1,宁可「补不出」也不许拿 sha512 冒充。
let wanted = "a".repeat(128);
let version = version(vec![file(None, &wanted, true)]);
assert_eq!(identity_of(&version, &wanted), None);
}
#[test]
fn hex_case_does_not_matter() {
let wanted = "AbCd".repeat(32);
let version = version(vec![file(
Some(&"1".repeat(40)),
&wanted.to_ascii_lowercase(),
true,
)]);
assert!(identity_of(&version, &wanted).is_some());
}
}
+5 -3
View File
@@ -30,8 +30,10 @@
//! # 离线不是「空结果」
//!
//! 无网时上层必须能说清「未联网」而不是「没有匹配的内容」,所以错误是枚举不是字符串:
//! 网络类失败一律映射成 [`OnlineErrorKind::NotConnected`](见 [`OnlineError`]),
//! presenter 再把它翻成 DTO 的 `ContentFailureKind::Offline`。
//! 引擎把叶子成因(404 / 5xx / 网络)放在 `DownloadError::leaf` 上,[`OnlineError`] 按它映射
//! 成 `NotFound` / `Server` / `NotConnected`(见 `transport::From<DownloadError>` 的映射表),
//! presenter 再翻成 DTO 的 `ContentFailureKind::{Offline,NotFound,Server}`。
//! 没有叶子时保守归到 `NotConnected`——宁可说「检查网络」,也不声称「没有匹配的内容」。
//!
//! # 模块
//!
@@ -49,7 +51,7 @@ pub mod model;
pub mod modrinth;
pub mod transport;
pub use complete::{complete_files, CompletionSummary};
pub use complete::identity_of;
pub use download::{
digest_file, download_verified, ArtifactFetcher, Digest, Downloaded, EngineArtifactFetcher,
FetchArgs, RETRY_ATTEMPTS,
+35
View File
@@ -168,3 +168,38 @@ fn a_broken_payload_is_malformed_not_empty() {
let error = block_on(client.search(&query("sodium"))).expect_err("形状不对必须报错");
assert_eq!(error.kind(), OnlineErrorKind::Malformed);
}
// ------------------------------------------------ 按摘要反查(补全路径的那两个端点行为)
#[test]
fn version_file_lookup_parses_the_recorded_payload() {
let transport = FixtureTransport::new().with(
&format!("{MODRINTH_API}/version_file/"),
fixtures::VERSION_FILE_A,
);
let client = Modrinth::new(&transport);
let version = block_on(client.version_by_hash(HashAlgorithm::Sha512, fixtures::LOOKUP_SHA512))
.expect("录制响应应当解析成功");
assert_eq!(version.id, "ver-a");
assert_eq!(version.game_versions, vec!["1.20.1".to_string()]);
// 身份判定只认「sha512 逐字相同」的那条:同版本里的 sources.jar 不许被当成它
let identity = crate::addon::online::complete::identity_of(&version, fixtures::LOOKUP_SHA512)
.expect("摘要对得上就该给出身份");
assert_eq!(identity.sha1, fixtures::LOOKUP_SHA1);
assert_eq!(identity.size, Some(4096));
let other = crate::addon::online::complete::identity_of(&version, fixtures::OTHER_SHA512)
.expect("另一个文件也认得出来");
assert_ne!(other.sha1, fixtures::LOOKUP_SHA1, "两档摘要不许串");
}
#[test]
fn a_hash_the_source_does_not_know_is_not_found_and_not_offline() {
// 真实 mrpack 大多托管在 CurseForge CDN:Modrinth 对这个 sha512 必然 404。
let transport = FixtureTransport::new().missing(&format!("{MODRINTH_API}/version_file/"));
let client = Modrinth::new(&transport);
let error = block_on(client.version_by_hash(HashAlgorithm::Sha512, &"a1".repeat(64)))
.expect_err("站点不认得就该报错");
assert_eq!(error.kind(), OnlineErrorKind::NotFound);
assert!(!error.is_offline(), "404 不是「没联网」");
}
@@ -1,135 +0,0 @@
//! 远端条目补全:能补就补、补不出照旧如实计数、离线立刻停。
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()
]
);
}
@@ -8,7 +8,6 @@
mod api;
mod classify;
mod complete;
mod download;
mod fixtures;
mod support;