fix(m162-r1): 叶子成因穿出引擎 + 安装路径切 core 在线源 + 详情迟到结果闸门
P1-1(p1-1items 的根因): - download/error.rs:`DownloadError` 增 `leaf: Option<DownloadErrorKind>` 与 `effective_kind()`;`leaf_of()` 汇总规则只此一处:全 404 ⇒ NotFound、有 Network/Timeout ⇒ Network、全 5xx ⇒ ServerError、混合按「网络错 > 5xx > 404」 取最可诊断的那个(**不从 suppressed 文本猜状态码**)。 - download/bytes.rs:候选源循环收集逐源 kind,收尾时把叶子挂到 AllSourcesFailed 上。 - online/transport.rs:`From<DownloadError>` 改用 `effective_kind()`,于是 NotFound/Server/Integrity 在生产路径可达(项目被删/id 拼错/限速不再画成「未联网」)。 - import/fetch.rs:新增 `FetchErrorKind::Offline`(真正的「没联网」)并与 Network 分开; `From<DownloadError>`/`From<OnlineError>` 都按叶子/大类映射。 - import/complete.rs:补全只在 `Offline` 时早停;404(真实 mrpack 大多托管在 CF CDN, Modrinth 必然 404)与 5xx 只影响那一条,不再掐掉整轮;注记「因未联网提前停止」因此 只会在真断网时出现。 P2-1:presenter 安装路径不再自己拼 Modrinth URL / 自己解析 JSON——改用 `core::addon::online::Modrinth::versions` + `primary_file()` + `VersionFile::sha1/sha512`; `HostTask::InstallDone.result` 改成 `Result<String, ContentFailure>`, `InstallProgressView` 增 `failure_kind`(离线 ≠ 查无此项)。 P2-2:`app/update/content.rs` 把被详情方法抢走的「装入闸门」注释还回 `content_install_request`,详情方法只留自己那一行。 P2-3:`ContentState::apply_detail/fail_detail` 带上 project(事件与 HostTask 都携带请求时的 项目 id),迟到结果一律丢弃;`request_detail` 记下在飞项目,`detail_pending` 不再被迟到 结果清掉。 顺手项:`usable_hash` 让形状非法的那一档摘要被**丢弃**(sha512 仍校验,不再整条跳过; 两档都不可用才拒绝);`digest_file` 的 IO 失败先删落盘文件再返回。i18n 的 ContentOfflineBody 改成不暗示「有缓存」的措辞(golden 同步)。 用例:download/error.rs 叶子四档 + effective_kind;online/tests/classify.rs 5 条 (AllSourcesFailed+leaf 404 ⇒ NotFound、无叶子 ⇒ NotConnected 保守、叶子一一映射、 单源自身 kind、导入侧 404≠Offline);import_online 增「404 不停」「真离线才停」 (共 7 条);download 增「sha1 形状非法仍用 sha512 校验成功」;presenter 增详情迟到闸门。
This commit is contained in:
1 parent
81278f55af
commit
b394316df4
30 files changed
+658
-172
No files matched your search
@@ -122,8 +122,17 @@ pub async fn download_verified(
|
||||
if url.trim().is_empty() {
|
||||
return Err(OnlineError::unsupported("没有可用下载地址"));
|
||||
}
|
||||
let sha1 = check_hash(sha1, HashAlgorithm::Sha1)?;
|
||||
let sha512 = check_hash(sha512, HashAlgorithm::Sha512)?;
|
||||
// 两档摘要**各自**过形状检查:坏掉的那一档丢掉,用另一档校验(mrpack 的权威摘要是
|
||||
// sha512,CLI/整合包都可能给出一个人写坏的 sha1——为此整条不装是过分惩罚)。
|
||||
// 但如果**声明过**摘要却一档都用不上,就必须失败:那是「没有任何校验」的伪装。
|
||||
let declared = sha1.is_some() || sha512.is_some();
|
||||
let sha1 = usable_hash(sha1, HashAlgorithm::Sha1);
|
||||
let sha512 = usable_hash(sha512, HashAlgorithm::Sha512);
|
||||
if declared && sha1.is_none() && sha512.is_none() {
|
||||
return Err(OnlineError::unsupported(
|
||||
"声明的 sha1/sha512 都不是合法摘要,无法校验(拒绝在「没有校验」的情况下落盘)",
|
||||
));
|
||||
}
|
||||
if let Some(parent) = dest.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|error| {
|
||||
OnlineError::new(
|
||||
@@ -165,7 +174,14 @@ pub async fn download_verified(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let digest = digest_file(dest)?;
|
||||
let digest = match digest_file(dest) {
|
||||
Ok(digest) => digest,
|
||||
Err(error) => {
|
||||
// 与其它失败路径一致:读不出来就不许把文件留在盘上。
|
||||
remove_quiet(dest);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if let Some(expected) = sha1 {
|
||||
if !equals_hex(expected, &digest.sha1) {
|
||||
last = Some(OnlineError::integrity(format!(
|
||||
@@ -239,19 +255,15 @@ pub fn digest_file(path: &Path) -> Result<Digest, OnlineError> {
|
||||
|
||||
/// 摘要形状合法吗(长度 + 十六进制)。
|
||||
///
|
||||
/// 返回的是调用方原来那段文本(不复制、不归一),大小写不敏感的比较交给 [`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()
|
||||
))),
|
||||
}
|
||||
/// 这一档摘要能用吗:形状合法就原样返回(不复制、不归一,大小写不敏感比较交给
|
||||
/// [`equals_hex`]),形状不合法就当「没给」。
|
||||
///
|
||||
/// 为什么丢掉而不是报错:`sha1` 与 `sha512` 是同一个文件的两档校验,**任一**都能证明内容;
|
||||
/// 一档写坏不该让整条装不上(r1 顺手项)。两档都不可用时由 `download_verified` 统一拒绝。
|
||||
fn usable_hash(raw: Option<&str>, algorithm: HashAlgorithm) -> Option<&str> {
|
||||
raw.map(str::trim)
|
||||
.filter(|hex| !hex.is_empty())
|
||||
.filter(|hex| algorithm.accepts(hex))
|
||||
}
|
||||
|
||||
/// 十六进制比较(大小写不敏感:声明侧可能大写)。
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! 引擎错误 → 在线内容源错误分类(r1 P1-1 的守卫)。
|
||||
//!
|
||||
//! 这一族的价值全在「经过真的 `From<DownloadError>`」:直接给 `OnlineError` 造一个
|
||||
//! `NotFound` 什么也证明不了——本轮修的是**引擎把叶子成因折叠掉**之后上层的映射,
|
||||
//! 所以输入必须是引擎真会产出的那种错误(`AllSourcesFailed` + `leaf`)。
|
||||
|
||||
use crate::addon::online::{OnlineError, OnlineErrorKind};
|
||||
use crate::download::{DownloadError, DownloadErrorKind};
|
||||
|
||||
#[test]
|
||||
fn an_all_sources_failed_with_a_404_leaf_is_not_found() {
|
||||
// 引擎在「所有候选源都回 404」时的真实形状(`download::bytes` 的收尾)。
|
||||
let error = DownloadError::new(
|
||||
DownloadErrorKind::AllSourcesFailed,
|
||||
"所有候选源都取不到文本资源:https://api.modrinth.com/v2/version_file/...",
|
||||
)
|
||||
.with_suppressed(vec!["official: HTTP 404".to_string()])
|
||||
.with_leaf(DownloadErrorKind::NotFound);
|
||||
let online: OnlineError = error.into();
|
||||
assert_eq!(
|
||||
online.kind(),
|
||||
OnlineErrorKind::NotFound,
|
||||
"项目不存在 / id 拼错必须报 NotFound,不能画成「未联网」"
|
||||
);
|
||||
assert!(!online.is_offline());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_all_sources_failed_without_a_leaf_stays_conservatively_offline() {
|
||||
let online: OnlineError =
|
||||
DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部候选源都失败").into();
|
||||
assert_eq!(online.kind(), OnlineErrorKind::NotConnected);
|
||||
assert!(online.is_offline());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_kinds_map_one_to_one() {
|
||||
let cases = [
|
||||
(DownloadErrorKind::NotFound, OnlineErrorKind::NotFound),
|
||||
(DownloadErrorKind::ServerError, OnlineErrorKind::Server),
|
||||
(DownloadErrorKind::Timeout, OnlineErrorKind::NotConnected),
|
||||
(DownloadErrorKind::Network, OnlineErrorKind::NotConnected),
|
||||
(DownloadErrorKind::Integrity, OnlineErrorKind::Integrity),
|
||||
(DownloadErrorKind::Io, OnlineErrorKind::Io),
|
||||
(
|
||||
DownloadErrorKind::InvalidRequest,
|
||||
OnlineErrorKind::Unsupported,
|
||||
),
|
||||
(
|
||||
DownloadErrorKind::RangeUnsupported,
|
||||
OnlineErrorKind::Malformed,
|
||||
),
|
||||
];
|
||||
for (leaf, expected) in cases {
|
||||
let error =
|
||||
DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败").with_leaf(leaf);
|
||||
let online: OnlineError = error.into();
|
||||
assert_eq!(online.kind(), expected, "叶子 {} 映射错了", leaf.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_source_failure_uses_its_own_kind() {
|
||||
// 单源直失败(没有 AllSourcesFailed 这一层):不该看 leaf,本来就没有。
|
||||
let online: OnlineError = DownloadError::new(DownloadErrorKind::NotFound, "HTTP 404").into();
|
||||
assert_eq!(online.kind(), OnlineErrorKind::NotFound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_404_leaf_is_not_treated_as_offline_by_the_importer_too() {
|
||||
// 导入侧(补全)用的是另一套分类:这里只钉「404 与没联网不同类」。
|
||||
use crate::addon::import::FetchErrorKind;
|
||||
let not_found: crate::addon::import::FetchError =
|
||||
DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败")
|
||||
.with_leaf(DownloadErrorKind::NotFound)
|
||||
.into();
|
||||
assert_eq!(not_found.kind(), FetchErrorKind::NotFound);
|
||||
assert!(not_found.kind().is_tolerable());
|
||||
|
||||
let offline: crate::addon::import::FetchError =
|
||||
DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败")
|
||||
.with_leaf(DownloadErrorKind::Network)
|
||||
.into();
|
||||
assert_eq!(offline.kind(), FetchErrorKind::Offline);
|
||||
assert!(
|
||||
offline.kind().is_tolerable(),
|
||||
"没联网仍然是「这条文件搞不到」,实例照装"
|
||||
);
|
||||
}
|
||||
@@ -155,6 +155,26 @@ fn a_malformed_declared_hash_never_touches_the_disk_or_the_network() {
|
||||
assert!(!dest.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_sha1_is_dropped_and_the_sha512_still_guards_the_file() {
|
||||
// r1 顺手项:sha1 形状写坏不该让整条装不上——sha512 才是 mrpack 的权威摘要。
|
||||
let root = TempRoot::new("online-bad-sha1-good-sha512");
|
||||
let dest = root.join("sodium.jar");
|
||||
let fetcher = FakeArtifact::new(BYTES);
|
||||
let (_, sha512) = spec();
|
||||
let done = block_on(download_verified(
|
||||
&fetcher,
|
||||
"https://cdn.modrinth.com/data/AANobbMI/sodium.jar",
|
||||
Some("not-a-sha1-at-all"),
|
||||
Some(&sha512),
|
||||
Some(BYTES.len() as u64),
|
||||
&dest,
|
||||
))
|
||||
.expect("sha512 合法就该能校验并装下");
|
||||
assert_eq!(done.digest.sha512, sha512);
|
||||
assert!(dest.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_url_is_unsupported() {
|
||||
let root = TempRoot::new("online-no-url");
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! M162 报告的「没验的部分」里。
|
||||
|
||||
mod api;
|
||||
mod classify;
|
||||
mod complete;
|
||||
mod download;
|
||||
mod fixtures;
|
||||
|
||||
@@ -139,21 +139,40 @@ impl std::error::Error for OnlineError {}
|
||||
|
||||
/// `DownloadEngine` 的错误 → 本模块的错误分类。
|
||||
///
|
||||
/// **分类口径**:连接类/超时/所有候选源失败都是「没联网」(用户能做的只有检查网络);
|
||||
/// 5xx/429 是「站点那边的问题」,与没网不同,但都值得重试一次。
|
||||
/// # 为什么读 `effective_kind()` 而不是 `kind`
|
||||
///
|
||||
/// 引擎的候选源循环结束时只留下一条 [`DownloadErrorKind::AllSourcesFailed`],404 / 429 / 5xx /
|
||||
/// 超时这些**叶子**成因会被折叠掉(`crates/core/src/download/bytes.rs` 的收尾)。
|
||||
/// 如果直接按 `kind` 判定,`NotFound` / `Server` / `Integrity` 三档在本模块的生产路径上
|
||||
/// **永远不可达**:项目被删、id 拼错、被限速都会画成「未联网」(r1 P1-1 后果 A)。
|
||||
///
|
||||
/// 引擎现在会把汇总出来的叶子 kind 挂在 `DownloadError::leaf` 上,这里按叶子映射;
|
||||
/// 没有叶子(单源直失败 / 手工构造)就按自身 kind。**不从 `suppressed` 文本里猜状态码。**
|
||||
///
|
||||
/// # 分类口径
|
||||
///
|
||||
/// | 引擎侧 | 本模块 | 上层该怎么说 |
|
||||
/// |--------|--------|--------------|
|
||||
/// | `NotFound`(404/410/403,全源都是) | [`OnlineErrorKind::NotFound`] | 「查无此项 / 换个关键词」 |
|
||||
/// | `ServerError`(5xx / 429) | [`OnlineErrorKind::Server`] | 「站点那边出错了」 |
|
||||
/// | `Network` / `Timeout` | [`OnlineErrorKind::NotConnected`] | 「未联网,检查网络」 |
|
||||
/// | `AllSourcesFailed`(没有叶子) | [`OnlineErrorKind::NotConnected`] | 同上(保守) |
|
||||
/// | `Integrity` | [`OnlineErrorKind::Integrity`] | 「内容不对」 |
|
||||
impl From<DownloadError> for OnlineError {
|
||||
fn from(error: DownloadError) -> Self {
|
||||
let kind = match error.kind {
|
||||
let kind = match error.effective_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::Timeout | DownloadErrorKind::Network => {
|
||||
OnlineErrorKind::NotConnected
|
||||
}
|
||||
DownloadErrorKind::Io => OnlineErrorKind::Io,
|
||||
DownloadErrorKind::Integrity => OnlineErrorKind::Integrity,
|
||||
DownloadErrorKind::RangeUnsupported => OnlineErrorKind::Malformed,
|
||||
DownloadErrorKind::InvalidRequest => OnlineErrorKind::Unsupported,
|
||||
// 没有叶子 ⇒ 分不清「没网」与「站点挂了」,按可重试的「没联网」保守处理:
|
||||
// 上层至少会提示「检查网络后重试」,而不是宣称「没有匹配的内容」。
|
||||
DownloadErrorKind::AllSourcesFailed => OnlineErrorKind::NotConnected,
|
||||
};
|
||||
Self::new(kind, error.message)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user