Files
Unlucky-Launcher/crates/core/src/download/resume.rs
T
AskaEth bc41dfcf7d
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
Unlucky Launcher v0.0.1-alpha
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
2026-10-31 21:54:10 +08:00

236 lines
6.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 断点续传的**保守判定**。
//!
//! 承 analyse/03 §5.4 与设计文档 §4 最后一行:「拒绝『能续传但会坏』场景」是 HMCL 做对的事。
//! 我们把它写成纯函数,判定条件全部显式:
//!
//! 1. 策略不是 `Never`;
//! 2. 中间文件存在且长度 > 0;
//! 3. 有上一次的元数据(URL、摘要、总长、校验器);
//! 4. URL 与期望摘要都没变;
//! 5. 总长已知且 `0 < 已下 < 总长`;
//! 6. 有 ETag 或 Last-Modified 作为校验器(否则服务器换了内容我们也发现不了)。
//!
//! 任何一条不满足就**从头下**——重下 100MB 也比交付一个坏文件便宜。
//! 另外单独处理 416(`Requested Range Not Satisfiable`):已下满则直接进入校验,
//! 否则丢弃中间文件重来。
use std::io;
use std::path::Path;
use serde::{Deserialize, Serialize};
use super::atomic::write_atomic;
use super::types::{HashAlgo, ResumePolicy};
/// 上一次下载留下的续传元数据。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResumeMeta {
/// 当时请求的 URL(镜像可能换掉,URL 变了就不续传)。
pub url: String,
/// 响应 `ETag`。
pub etag: Option<String>,
/// 响应 `Last-Modified`。
pub last_modified: Option<String>,
/// 响应告知的总长度。
pub total: Option<u64>,
/// 摘要算法。
pub algo: HashAlgo,
/// 期望摘要。
pub hash: String,
/// 保存时间(unix 毫秒)。
pub saved_at_ms: u64,
}
impl ResumeMeta {
/// 从磁盘读(不存在/损坏 → `None`)。
#[must_use]
pub fn load(path: &Path) -> Option<Self> {
let bytes = std::fs::read(path).ok()?;
serde_json::from_slice(&bytes).ok()
}
/// 原子写。
pub fn save(&self, path: &Path) -> io::Result<()> {
let json = serde_json::to_vec_pretty(self).map_err(io::Error::other)?;
write_atomic(path, &json)
}
/// 删除(校验通过或放弃续传时)。
pub fn remove(path: &Path) {
let _ = std::fs::remove_file(path);
}
/// 本记录可用的校验器。
#[must_use]
pub fn validator(&self) -> Validator {
Validator {
etag: self.etag.clone(),
last_modified: self.last_modified.clone(),
}
}
}
/// 续传用的校验器(`If-Range` 头的取值来源)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Validator {
/// 强校验器。
pub etag: Option<String>,
/// 弱校验器。
pub last_modified: Option<String>,
}
impl Validator {
/// `If-Range` 头的值(ETag 优先,其次 Last-Modified)。
#[must_use]
pub fn if_range_value(&self) -> Option<&str> {
self.etag.as_deref().or(self.last_modified.as_deref())
}
}
/// 不复续传的原因(日志与诊断页用)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RestartReason {
/// 策略要求从不续传。
PolicyNever,
/// 没有中间文件。
NoPart,
/// 中间文件是 0 字节。
EmptyPart,
/// 没有元数据(无法证明中间文件是同一次下载的产物)。
NoMeta,
/// URL 变了(换源后偏移不可信)。
UrlChanged,
/// 期望摘要变了(目标文件已经不是同一个东西)。
HashChanged,
/// 总长未知(无法判断是否已下满)。
TotalUnknown,
/// 已下字节超过总长(中间文件被污染)。
Oversize,
/// 没有 ETag/Last-Modified,无法证明远端内容没变。
NoValidator,
/// 服务器 416 且未下满。
RangeUnsatisfiable,
}
impl RestartReason {
/// 稳定字符串 id。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::PolicyNever => "policy_never",
Self::NoPart => "no_part",
Self::EmptyPart => "empty_part",
Self::NoMeta => "no_meta",
Self::UrlChanged => "url_changed",
Self::HashChanged => "hash_changed",
Self::TotalUnknown => "total_unknown",
Self::Oversize => "oversize",
Self::NoValidator => "no_validator",
Self::RangeUnsatisfiable => "range_unsatisfiable",
}
}
}
/// 续传决策。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResumeDecision {
/// 可以续传:从 `offset` 起发 `Range` + `If-Range`。
Resume {
/// 已下字节数。
offset: u64,
/// 校验器。
validator: Validator,
},
/// 从头下载。
Restart {
/// 原因。
reason: RestartReason,
},
/// 中间文件已经是完整长度:跳过传输,直接校验。
Complete {
/// 文件长度。
size: u64,
},
}
impl ResumeDecision {
/// 是否要丢掉中间文件(`Complete` 不能丢)。
#[must_use]
pub const fn should_drop_part(&self) -> bool {
matches!(self, Self::Restart { .. })
}
}
/// 判定是否续传(纯函数)。
#[must_use]
pub fn decide(
policy: ResumePolicy,
part_len: u64,
meta: Option<&ResumeMeta>,
url: &str,
integrity: Option<(HashAlgo, &str)>,
expected_size: Option<u64>,
) -> ResumeDecision {
if policy == ResumePolicy::Never {
return ResumeDecision::Restart {
reason: RestartReason::PolicyNever,
};
}
if part_len == 0 {
return ResumeDecision::Restart {
reason: RestartReason::EmptyPart,
};
}
let Some(meta) = meta else {
return ResumeDecision::Restart {
reason: RestartReason::NoMeta,
};
};
if meta.url != url {
return ResumeDecision::Restart {
reason: RestartReason::UrlChanged,
};
}
if let Some((algo, hash)) = integrity {
if meta.algo != algo || !meta.hash.eq_ignore_ascii_case(hash) {
return ResumeDecision::Restart {
reason: RestartReason::HashChanged,
};
}
}
let Some(total) = meta.total.or(expected_size) else {
return ResumeDecision::Restart {
reason: RestartReason::TotalUnknown,
};
};
if part_len > total {
return ResumeDecision::Restart {
reason: RestartReason::Oversize,
};
}
if part_len == total {
return ResumeDecision::Complete { size: total };
}
let validator = meta.validator();
if validator.if_range_value().is_none() {
return ResumeDecision::Restart {
reason: RestartReason::NoValidator,
};
}
ResumeDecision::Resume {
offset: part_len,
validator,
}
}
/// 收到 416 后的决策。
#[must_use]
pub fn on_range_unsatisfiable(part_len: u64, total: Option<u64>) -> ResumeDecision {
match total {
Some(total) if part_len == total => ResumeDecision::Complete { size: total },
_ => ResumeDecision::Restart {
reason: RestartReason::RangeUnsatisfiable,
},
}
}