diff --git a/crates/core/src/addon/import/complete.rs b/crates/core/src/addon/import/complete.rs index 29b3b95..802aefb 100644 --- a/crates/core/src/addon/import/complete.rs +++ b/crates/core/src/addon/import/complete.rs @@ -12,8 +12,10 @@ //! 1. **只补 sha1 / 字节数 / 缺的直链**,绝不改写清单里已有的值(清单说多大就是多大); //! 2. **CurseForge 条目不走这里**:它们连路径都没有,补全要 `projectID/fileID` 端点(需要 key), //! 那条路仍是 `PackFetcher::curse_file` 的活; -//! 3. **离线立刻停**:第一条网络类失败之后剩下的条目一律算「补不出」,不拿 N 次超时惩罚用户 -//! (与 `online::complete` 同一条纪律,只是这里换成按路径过滤的导入视图)。 +//! 3. **只有「没联网」才立刻停**:第一条 [`FetchErrorKind::Offline`] 之后剩下的条目一律算 +//! 「补不出」,不拿 N 次超时惩罚用户;**404 / 5xx 不停**——真实 mrpack 的文件大多托管在 +//! CurseForge CDN,Modrinth 的 `version_file` 对它们必然 404,把「补不出」当「没联网」 +//! 会让「能补就补」只在第一条恰好托管在 Modrinth 时成立(r1 P1-1 后果 B)。 //! //! # 为什么要改 `plan.remote` 而不是 `manifest.files` //! @@ -22,6 +24,7 @@ use super::fetch::FetchErrorKind; use super::plan::ImportPlan; +use super::report::{keys, ImportIssue, ImportReport}; use super::ImportRequest; /// 一次补全的结果。 @@ -35,6 +38,38 @@ pub(crate) struct Completion { pub(crate) offline: bool, } +impl Completion { + /// 补全结果 → 报告注记(`None` = 既没补到、也没因断网停下,不必占一行)。 + /// + /// `lead` 是场景前缀(导入用空串、预检用「预检前」):**文案只此一处**,两个入口不许 + /// 各写一句——r1 抓到的「404 却写因未联网提前停止」正是两处文案各自演进的后果。 + /// 现在 `offline` 只由真 `Offline` 置位,这句话只会在真断网时出现。 + pub(crate) fn note(&self, lead: &str) -> Option { + if self.filled == 0 && !self.offline { + return None; + } + let mut message = format!( + "{lead}按 sha512 从 Modrinth 补全了 {} 条远端条目的 sha1(补不出 {} 条)", + self.filled, self.unfilled + ); + if self.offline { + message.push_str(";补全因未联网提前停止,其余条目照旧按「缺省保留」处置"); + } + Some(ImportIssue::new(keys::NOTE_REMOTE_COMPLETED, message)) + } +} + +/// 补全 + 把注记写进报告(导入管线唯一的入口:一行调用,文案与计数都在本模块)。 +pub(crate) async fn complete_and_note( + request: &ImportRequest<'_>, + plan: &mut ImportPlan, + report: &mut ImportReport, +) { + if let Some(note) = complete(request, plan).await.note("") { + report.warnings.push(note); + } +} + /// 跑一次补全(就地改 `plan.remote`)。 pub(crate) async fn complete(request: &ImportRequest<'_>, plan: &mut ImportPlan) -> Completion { let mut state = Completion::default(); diff --git a/crates/core/src/addon/import/phase.rs b/crates/core/src/addon/import/phase.rs index 81bd2f7..0f83f61 100644 --- a/crates/core/src/addon/import/phase.rs +++ b/crates/core/src/addon/import/phase.rs @@ -225,13 +225,12 @@ pub(crate) async fn fetch_remote( struct Resolved { path: String, url: String, - sha1: Option, + sha1: Option, // 两档摘要各自可能缺(CF 只有 sha1、mrpack 可能只有 sha512) sha512: Option, size: Option, } /// 把条目补全成"有路径、有地址"。 -/// /// `Ok(None)` = 这条补不出来(进 `missing`);`Err` = 致命(本地 IO / 内容不符)。 async fn resolve( request: &ImportRequest<'_>, @@ -278,8 +277,8 @@ async fn resolve( Ok(Some(Resolved { path: format!("{dir}/{}", meta.file_name), url, + // CF 官方只给 sha1,这一档没有可核的期望值。 sha1: meta.sha1, - // CF 侧没有 sha512 可核(官方只给 sha1);空着就是「不校验这一档」。 sha512: None, size: meta.size, })) @@ -288,8 +287,7 @@ async fn resolve( /// 合并判定 + 决策 → 实际动作。 /// /// `picked` 是这一次的处置(逐项决策或整批档,见 [`super::decision::ConflictDecisions`]): -/// 它**只对冲突生效**——`Install` / `SkipIdentical` 两个非冲突结论不受它影响,所以"用户点错 -/// 一个保留"不会把一份全新文件漏掉。 +/// 它**只对冲突生效**——`Install` / `SkipIdentical` 不受它影响,所以"用户点错一条保留"不会漏装。 fn settle( decision: merge::MergeDecision, disk_exists: bool, diff --git a/crates/core/src/addon/import/pipeline.rs b/crates/core/src/addon/import/pipeline.rs index c7c87d8..14620cd 100644 --- a/crates/core/src/addon/import/pipeline.rs +++ b/crates/core/src/addon/import/pipeline.rs @@ -48,21 +48,7 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport { 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)); - } + super::complete::complete_and_note(request, &mut plan, &mut report).await; let baseline = { let (baseline, warnings) = load_baseline(request.target, &plan); diff --git a/crates/core/src/addon/import/scan.rs b/crates/core/src/addon/import/scan.rs index 3886ce9..0ea938e 100644 --- a/crates/core/src/addon/import/scan.rs +++ b/crates/core/src/addon/import/scan.rs @@ -204,16 +204,8 @@ pub async fn scan_pack_online(request: &ImportRequest<'_>) -> ImportScan { }; 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)); + if let Some(note) = completion.note("预检前") { + scan.notes.push(note); } scan } diff --git a/crates/core/src/addon/mod.rs b/crates/core/src/addon/mod.rs index 7a4a44e..08b09e9 100644 --- a/crates/core/src/addon/mod.rs +++ b/crates/core/src/addon/mod.rs @@ -48,10 +48,9 @@ pub use loader::{ 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, + download_verified, ArtifactFetcher, 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, diff --git a/crates/core/src/addon/online/complete.rs b/crates/core/src/addon/online/complete.rs index f22a52f..98d64ae 100644 --- a/crates/core/src/addon/online/complete.rs +++ b/crates/core/src/addon/online/complete.rs @@ -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( - 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 { let file = version.files.iter().find(|file| { @@ -109,17 +49,71 @@ pub struct Identity { pub url: Option, } -/// 把补全结果写回条目:**只填空缺,不覆盖清单里已有的值**。 -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) -> 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()); + } } diff --git a/crates/core/src/addon/online/mod.rs b/crates/core/src/addon/online/mod.rs index b71bede..74b2f8a 100644 --- a/crates/core/src/addon/online/mod.rs +++ b/crates/core/src/addon/online/mod.rs @@ -30,8 +30,10 @@ //! # 离线不是「空结果」 //! //! 无网时上层必须能说清「未联网」而不是「没有匹配的内容」,所以错误是枚举不是字符串: -//! 网络类失败一律映射成 [`OnlineErrorKind::NotConnected`](见 [`OnlineError`]), -//! presenter 再把它翻成 DTO 的 `ContentFailureKind::Offline`。 +//! 引擎把叶子成因(404 / 5xx / 网络)放在 `DownloadError::leaf` 上,[`OnlineError`] 按它映射 +//! 成 `NotFound` / `Server` / `NotConnected`(见 `transport::From` 的映射表), +//! 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, diff --git a/crates/core/src/addon/online/tests/api.rs b/crates/core/src/addon/online/tests/api.rs index f5ba8ff..38ea9d5 100644 --- a/crates/core/src/addon/online/tests/api.rs +++ b/crates/core/src/addon/online/tests/api.rs @@ -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 不是「没联网」"); +} diff --git a/crates/core/src/addon/online/tests/complete.rs b/crates/core/src/addon/online/tests/complete.rs deleted file mode 100644 index 28723bf..0000000 --- a/crates/core/src/addon/online/tests/complete.rs +++ /dev/null @@ -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() - ] - ); -} diff --git a/crates/core/src/addon/online/tests/mod.rs b/crates/core/src/addon/online/tests/mod.rs index 960ea2a..f2509a1 100644 --- a/crates/core/src/addon/online/tests/mod.rs +++ b/crates/core/src/addon/online/tests/mod.rs @@ -8,7 +8,6 @@ mod api; mod classify; -mod complete; mod download; mod fixtures; mod support; diff --git a/crates/core/src/addon/tests/import_online.rs b/crates/core/src/addon/tests/import_online.rs index ced8c64..8855e10 100644 --- a/crates/core/src/addon/tests/import_online.rs +++ b/crates/core/src/addon/tests/import_online.rs @@ -220,3 +220,82 @@ fn only_a_real_offline_stops_the_completion_round() { assert_eq!(scan.unknown_remote, 2, "没联网 ⇒ 两条都判不了"); assert!(scan.conflicts.is_empty()); } + +/// 清单自带 `sha1` 时补全不许插手(也不许改写清单里的摘要)。 +fn pack_with_sha1_and_sha512(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":"两档摘要都有", + "files":[{{"path":"{PATH}", + "hashes":{{"sha1":"{sha1}","sha512":"{SHA512}"}}, + "downloads":["{DOWNLOAD}"],"fileSize":{size}}}], + "dependencies":{{"minecraft":"1.20.1"}}}}"#, + sha1 = sha1(PACK_BYTES), + 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) +} + +#[test] +fn an_entry_that_already_has_a_sha1_is_left_alone() { + let (root, pack) = pack_with_sha1_and_sha512("online-keeps-sha1"); + let target = root.join("instance"); + write_file(&target.join(PATH), DISK_BYTES); + // 站点那边挂一个**不一样**的 sha1:真去反查并写回的话,这条就会被改写。 + let fetcher = StubFetcher::new().with_modrinth(SHA512, meta(&"e".repeat(40))); + let scan = block_on(scan_pack_online( + &ImportRequest::new(&pack, &target).with_fetcher(&fetcher), + )); + assert!(scan.is_ok(), "{:?}", scan.error); + let conflict = scan.conflicts.first().expect("磁盘内容不同 ⇒ 要判出冲突"); + assert_eq!( + conflict.pack_sha1, + sha1(PACK_BYTES), + "清单里的 sha1 不许被改写" + ); + assert!( + !scan + .notes + .iter() + .any(|note| note.message.contains("补全了")), + "没有条目需要补 ⇒ 不该写「补全了 N 条」的注记" + ); +} + +/// 清单没写 `downloads`:补全给的直链要能真的用上(否则这条只会进 missing)。 +fn pack_without_downloads(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":"没有直链", + "files":[{{"path":"{PATH}","hashes":{{"sha512":"{SHA512}"}}, + "downloads":[],"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) +} + +#[test] +fn a_completed_entry_can_supply_the_missing_download_url() { + let (root, pack) = pack_without_downloads("online-url-from-source"); + 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) + ); +} diff --git a/crates/core/src/addon/tests/import_report.rs b/crates/core/src/addon/tests/import_report.rs index 97f4297..bea2a23 100644 --- a/crates/core/src/addon/tests/import_report.rs +++ b/crates/core/src/addon/tests/import_report.rs @@ -137,6 +137,9 @@ const SOURCES: &[&str] = &[ // M160:预检(冲突清单)与逐文件决策的引用也要算进这张表, // 否则 `NOTE_REMOTE_PATH_UNKNOWN` 会被判成"死常量"。 include_str!("../import/scan.rs"), + // M162:补全注记的文案搬到了 `complete.rs`(原来在 pipeline/scan 各写一份), + // 不把它算进这张表的话 `NOTE_REMOTE_COMPLETED` 会被判成死常量。 + include_str!("../import/complete.rs"), include_str!("../import/decision.rs"), include_str!("../import/stage.rs"), include_str!("../mod.rs"),