Files
Unlucky-Launcher/crates/core/tests/download/resume_test.rs
T
AskaEth 3ade2d14a8 feat(download): 分块路径写续传元数据并自证旧字节(M293)
台账 P21 第 2 条 + P19(b):分块路径此前进来先 `part.discard()`、失败也 `discard()`,
188MB 的 JDK 任何一次失败都从 0 重下;M289 又给预算耗尽加了「不再重试、不再换源」的
短路,慢链路上一次晚到的失败就变成「这个文件下不来」。

写这一侧:
* `ResumeMeta` 新增 `chunks: Option<Vec<ChunkRecord>>`(段区间 + 该段 SHA-1),
  由新增的 `chunk_resume::ChunkJournal` 每成功回收一段就原子落一次盘;
* `fetch_chunk` 顺带带回段摘要、`Content-Range` 总长与 ETag/Last-Modified,
  所以元数据里同时有 total / 源 URL / 分块清单 / 校验所需信息;
* 分块失败不再丢中间文件,只有 `RangeUnsupported`(单连接接不住这种形状)才丢。

判这一侧:
* `chunk_resume::decide`(纯函数)要求策略允许、URL / 总长 / 摘要 / 校验器全对得上,
  且每条记录精确命中本次分块规划里的一段,否则从头再来;
* `chunk_resume::verify_records` 按记录重读旧段、重算摘要:任何一段对不上就整份丢掉
  (元数据本身已不可信,同批其它记录也不再被信任);
* 续传的段请求带 `If-Range`,让服务器回答「内容变了没有」;
* 整文件摘要照旧在末尾整份重算,覆盖新下 + 复用的全部字节。

单连接路径零改动:`resume::decide` 的判定一字未动,只新增
「元数据里带分块清单 ⇒ 强行判 Restart 交给分块路径」这一条,避免预分配全长文件被
单连接的 `Complete` 误判成「已下满」。

判据(都是「服务端实际吐了多少字节」,不是「最终 hash 对」):
* 3/4 处失败后续传完成:41984 字节 / 40960 文件 = 1.025×(断言 < 1.5×);
* 变异(关掉续传同样夹具):72704 = 1.775×,`< 1.5×` 必红(已实测);
* 变异(去掉旧段摘要自证):坏段被复用 ⇒ 51200 ≠ 40960 必红(已实测);
* 变异(去掉总长比较):旧大小的段被复用 ⇒ 61440 ≠ 40960 必红(已实测)。
2026-11-13 06:26:18 +08:00

229 lines
5.8 KiB
Rust

//! 断点续传保守判定的单测:矩阵覆盖 + 416 处理。
use unlucky_core::download::resume::{
decide, on_range_unsatisfiable, RestartReason, ResumeDecision, ResumeMeta, Validator,
};
use unlucky_core::download::types::{HashAlgo, ResumePolicy};
/// 分块路径的续传判定与旧字节自证(M293):拆到子模块守 400 行硬线。
#[path = "resume/chunk.rs"]
mod chunk;
const URL: &str = "https://bmclapi2.bangbang93.com/versions/1.20.1/client.jar";
const HASH: &str = "0123456789abcdef0123456789abcdef01234567";
fn meta(url: &str) -> ResumeMeta {
ResumeMeta {
url: url.to_string(),
etag: Some("\"abc\"".to_string()),
last_modified: None,
total: Some(1_000),
algo: HashAlgo::Sha1,
hash: HASH.to_string(),
saved_at_ms: 1,
chunks: None,
}
}
fn resume_ok(part_len: u64, meta: &ResumeMeta) -> ResumeDecision {
decide(
ResumePolicy::Conservative,
part_len,
Some(meta),
URL,
Some((HashAlgo::Sha1, HASH)),
Some(1_000),
)
}
#[test]
fn resumes_when_everything_matches() {
let decision = resume_ok(400, &meta(URL));
assert_eq!(
decision,
ResumeDecision::Resume {
offset: 400,
validator: Validator {
etag: Some("\"abc\"".to_string()),
last_modified: None,
},
}
);
}
#[test]
fn never_policy_and_missing_files_restart() {
let meta = meta(URL);
assert_eq!(
decide(
ResumePolicy::Never,
400,
Some(&meta),
URL,
Some((HashAlgo::Sha1, HASH)),
Some(1_000)
),
ResumeDecision::Restart {
reason: RestartReason::PolicyNever
}
);
assert_eq!(
resume_ok(0, &meta),
ResumeDecision::Restart {
reason: RestartReason::EmptyPart
}
);
// 没有元数据:无法证明中间文件是同一次下载的产物。
assert_eq!(
decide(
ResumePolicy::Conservative,
400,
None,
URL,
Some((HashAlgo::Sha1, HASH)),
Some(1_000)
),
ResumeDecision::Restart {
reason: RestartReason::NoMeta
}
);
}
#[test]
fn changed_url_or_hash_restarts() {
assert_eq!(
decide(
ResumePolicy::Conservative,
400,
Some(&meta("https://other.example/client.jar")),
URL,
Some((HashAlgo::Sha1, HASH)),
Some(1_000)
),
ResumeDecision::Restart {
reason: RestartReason::UrlChanged
}
);
assert_eq!(
decide(
ResumePolicy::Conservative,
400,
Some(&meta(URL)),
URL,
Some((HashAlgo::Sha1, "ffffffffffffffffffffffffffffffffffffffff")),
Some(1_000)
),
ResumeDecision::Restart {
reason: RestartReason::HashChanged
}
);
}
#[test]
fn unknown_total_or_oversize_restarts() {
let mut unknown = meta(URL);
unknown.total = None;
assert_eq!(
decide(
ResumePolicy::Conservative,
400,
Some(&unknown),
URL,
None,
None
),
ResumeDecision::Restart {
reason: RestartReason::TotalUnknown
}
);
assert_eq!(
resume_ok(2_000, &meta(URL)),
ResumeDecision::Restart {
reason: RestartReason::Oversize
}
);
}
#[test]
fn complete_part_skips_transfer() {
assert_eq!(
resume_ok(1_000, &meta(URL)),
ResumeDecision::Complete { size: 1_000 }
);
}
#[test]
fn missing_validator_restarts() {
let mut weak = meta(URL);
weak.etag = None;
weak.last_modified = None;
assert_eq!(
resume_ok(400, &weak),
ResumeDecision::Restart {
reason: RestartReason::NoValidator
}
);
// Last-Modified 单独也能当校验器。
weak.last_modified = Some("Wed, 21 Oct 2015 07:28:00 GMT".to_string());
assert!(matches!(
resume_ok(400, &weak),
ResumeDecision::Resume { offset: 400, .. }
));
}
#[test]
fn expected_size_can_stand_in_for_missing_total() {
let mut unknown = meta(URL);
unknown.total = None;
let decision = decide(
ResumePolicy::Conservative,
400,
Some(&unknown),
URL,
Some((HashAlgo::Sha1, HASH)),
Some(1_000),
);
assert!(matches!(
decision,
ResumeDecision::Resume { offset: 400, .. }
));
}
#[test]
fn range_not_satisfiable_handling() {
// 已下满:直接进入校验,不要白删一个好文件。
assert_eq!(
on_range_unsatisfiable(1_000, Some(1_000)),
ResumeDecision::Complete { size: 1_000 }
);
// 没下满却 416:中间文件不可信,丢掉重来。
let decision = on_range_unsatisfiable(400, Some(1_000));
assert_eq!(
decision,
ResumeDecision::Restart {
reason: RestartReason::RangeUnsatisfiable
}
);
assert!(decision.should_drop_part());
// 不允许丢掉「已完成」的中间文件。
assert!(!ResumeDecision::Complete { size: 1 }.should_drop_part());
}
#[test]
fn resume_meta_roundtrip_and_removal() {
let dir = std::env::temp_dir().join(format!("ull-resume-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("client.jar.ullpart.ullmeta.json");
let original = meta(URL);
original.save(&path).expect("写元数据失败");
assert_eq!(ResumeMeta::load(&path), Some(original));
ResumeMeta::remove(&path);
assert!(ResumeMeta::load(&path).is_none());
let _ = std::fs::remove_dir_all(&dir);
}