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

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

No files matched your search

+183
View File
@@ -0,0 +1,183 @@
//! HTTP 薄封装:客户端构造、错误分类、响应头读取。
//!
//! 关键取舍:
//! * **`Accept-Encoding: identity`** —— 下载要按字节对齐做 Range/校验,gzip 会让
//! `Content-Length` 与实际写入量打架(HMCL 为此专门写了按解压后偏移计数的
//! `HttpResumeContext`,analyse/03 §5.4),我们直接从协议层绕开这个问题;
//! * 重定向上限 20 跳(承 HMCL),跳转后仍按最终 URL 做 host 校验由调用方负责;
//! * 错误分类只做「同源重试 / 立刻换源 / 整体失败」三态,不做细颗粒映射。
use reqwest::header::{
HeaderMap, HeaderName, ACCEPT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, ETAG, LAST_MODIFIED,
};
use reqwest::redirect::Policy;
use super::config::EngineConfig;
use super::error::{DownloadError, DownloadErrorKind};
/// 重定向上限(承 HMCL 的 20 跳)。
pub const MAX_REDIRECTS: usize = 20;
/// 构造共享的 `reqwest::Client`(连接池、超时、重定向策略一次定好)。
pub fn build_client(cfg: &EngineConfig) -> Result<reqwest::Client, DownloadError> {
let mut headers = HeaderMap::new();
headers.insert(
ACCEPT_ENCODING,
reqwest::header::HeaderValue::from_static("identity"),
);
reqwest::Client::builder()
.user_agent(cfg.user_agent.clone())
.connect_timeout(cfg.connect_timeout)
.redirect(Policy::limited(MAX_REDIRECTS))
.default_headers(headers)
.build()
.map_err(|err| {
DownloadError::new(
DownloadErrorKind::Network,
format!("构造 HTTP 客户端失败:{err}"),
)
})
}
/// 把 `reqwest::Error` 映射成我们的错误分类。
#[must_use]
pub fn classify_reqwest(err: &reqwest::Error, url: &str) -> DownloadError {
let kind = if err.is_timeout() {
DownloadErrorKind::Timeout
} else if let Some(status) = err.status() {
return classify_status(status.as_u16(), url);
} else {
DownloadErrorKind::Network
};
DownloadError::new(kind, format!("{err}")).with_source(url)
}
/// HTTP 状态码 → 错误分类。
#[must_use]
pub fn classify_status(status: u16, url: &str) -> DownloadError {
let kind = match status {
404 | 410 => DownloadErrorKind::NotFound,
// 403/401 对镜像来说基本等于「这条路不能走」,直接换源比退避更有用。
401 | 403 => DownloadErrorKind::NotFound,
408 => DownloadErrorKind::Timeout,
416 => DownloadErrorKind::RangeUnsupported,
429 => DownloadErrorKind::ServerError,
500..=599 => DownloadErrorKind::ServerError,
_ => DownloadErrorKind::ServerError,
};
DownloadError::new(kind, format!("HTTP {status}")).with_source(url)
}
/// 读一个头的字符串值。
#[must_use]
pub fn header_str(headers: &HeaderMap, name: HeaderName) -> Option<&str> {
headers.get(name).and_then(|value| value.to_str().ok())
}
/// `Content-Length`。
#[must_use]
pub fn content_length(headers: &HeaderMap) -> Option<u64> {
header_str(headers, CONTENT_LENGTH).and_then(|value| value.trim().parse().ok())
}
/// `ETag`(续传的强校验器)。
#[must_use]
pub fn etag(headers: &HeaderMap) -> Option<String> {
header_str(headers, ETAG).map(str::to_string)
}
/// `Last-Modified`(弱校验器,ETag 缺失时的兜底)。
#[must_use]
pub fn last_modified(headers: &HeaderMap) -> Option<String> {
header_str(headers, LAST_MODIFIED).map(str::to_string)
}
/// `Content-Type`。
#[must_use]
pub fn content_type(headers: &HeaderMap) -> Option<&str> {
header_str(headers, CONTENT_TYPE)
}
/// 判断一个 2xx 响应是不是「伪装的档案」(镜像门户的假阳性兜底页)。
///
/// 出处:research/06 §0.2 / §5.3 第 2 条——`mirrors.huaweicloud.com` 对**任意不存在的路径**
/// 都回 `200 + 12 434 字节 Angular 门户 HTML`,只看状态码会把死源当活源。
///
/// 两条判据(都只在证据充分时才拒):
/// 1. `Content-Type` 含 `html` → 一定是门户页,不是归档;
/// 2. 已知期望大小、且是整文件请求(`offset == 0`)、响应带 `Content-Length` 却对不上
/// → 长度对拍失败,同样不是我们要的那个文件。
///
/// 返回 `Some(原因)` 时调用方应当**换源**(归到 `NotFound` 类:不算源坏、不进冷却)。
#[must_use]
pub fn html_mirror_reason(
headers: &HeaderMap,
expected_size: Option<u64>,
offset: u64,
) -> Option<&'static str> {
if content_type(headers).is_some_and(|value| value.to_ascii_lowercase().contains("html")) {
return Some("源返回 HTML(镜像门户兜底页,假阳性:该路径没有真实归档)");
}
if offset == 0 {
if let (Some(expected), Some(actual)) = (expected_size, content_length(headers)) {
if expected > 0 && actual != expected {
return Some("源返回的 Content-Length 与期望大小不符(疑似门户页/截断响应)");
}
}
}
None
}
#[cfg(test)]
mod tests {
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_LENGTH, CONTENT_TYPE};
use super::{html_mirror_reason, MAX_REDIRECTS};
fn headers(content_type: Option<&str>, length: Option<u64>) -> HeaderMap {
let mut map = HeaderMap::new();
if let Some(value) = content_type {
map.insert(CONTENT_TYPE, HeaderValue::from_str(value).expect("头值"));
}
if let Some(value) = length {
map.insert(CONTENT_LENGTH, HeaderValue::from(value));
}
map
}
#[test]
fn html_portal_page_is_rejected_even_with_200() {
// 华为云实测:任意不存在的路径 → 200 + text/html + 12434 字节。
let map = headers(Some("text/html;charset=UTF-8"), Some(12_434));
assert_eq!(
html_mirror_reason(&map, Some(201_328_186), 0),
Some("源返回 HTML(镜像门户兜底页,假阳性:该路径没有真实归档)")
);
// 只有 Content-Type 也能判(没有期望大小时)。
assert!(html_mirror_reason(&map, None, 0).is_some());
}
#[test]
fn real_archive_matching_the_expected_size_passes() {
let map = headers(Some("application/zip"), Some(201_328_186));
assert_eq!(html_mirror_reason(&map, Some(201_328_186), 0), None);
// 镜像没给 Content-Type 也照放行(有 sha256 兜底)。
let bare = headers(None, Some(201_328_186));
assert_eq!(html_mirror_reason(&bare, Some(201_328_186), 0), None);
}
#[test]
fn mismatched_length_is_rejected_but_resumed_reads_are_left_alone() {
let map = headers(Some("application/octet-stream"), Some(973_920));
assert!(html_mirror_reason(&map, Some(205_421_281), 0).is_some());
// 续传片段当然比全量小:不能按整文件口径对拍。
assert_eq!(html_mirror_reason(&map, Some(205_421_281), 973_920), None);
// 未知期望大小 → 不做长度判定。
assert_eq!(html_mirror_reason(&map, None, 0), None);
}
#[test]
fn redirect_cap_is_still_twenty() {
assert_eq!(MAX_REDIRECTS, 20);
}
}