Files
Unlucky-Launcher/crates/core/tests/download/resume/chunk.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

243 lines
7.7 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.
//! 分块路径的续传判定(`chunk_resume`,M293)。
//!
//! 分块路径不能复用单连接那套「长度 = 已下」的判定:它的中间文件被 `set_len(total)` 预分配过,
//! 长度恒等于总长(`resume::decide` 会判成 `Complete`,然后整文件校验把一份没下完的文件判死)。
//! 这里钉住的是**另一套条件**:URL / 总长 / 摘要 / 校验器全部对上,而且每条记录都要精确命中
//! 本次分块规划里的一段;过了判定还要能按记录重算出同样的摘要(`verify_records`),
//! 否则一律从头再来。
use unlucky_core::download::chunk_resume::{
decide as chunk_decide, remaining, verify_records, ChunkDecision,
};
use unlucky_core::download::chunked::{plan, ByteRange};
use unlucky_core::download::resume::{ChunkRecord, RestartReason, ResumeMeta};
use unlucky_core::download::types::{HashAlgo, ResumePolicy};
use unlucky_core::download::verify::digest_bytes;
use super::{meta, HASH, URL};
fn chunk_payload(len: usize) -> Vec<u8> {
(0..len).map(|index| (index % 251) as u8).collect()
}
fn record(payload: &[u8], range: ByteRange) -> ChunkRecord {
let bytes = &payload[range.start as usize..=range.end as usize];
ChunkRecord {
start: range.start,
end: range.end,
sha1: digest_bytes(HashAlgo::Sha1, bytes),
}
}
fn with_chunks(base: ResumeMeta, chunks: Vec<ChunkRecord>) -> ResumeMeta {
ResumeMeta {
chunks: Some(chunks),
..base
}
}
/// 判定「应当从头再来」,返回原因。
fn chunk_restart(
part_len: u64,
meta: Option<&ResumeMeta>,
url: &str,
total: u64,
planned: &[ByteRange],
) -> RestartReason {
match chunk_decide(
ResumePolicy::Conservative,
part_len,
meta,
url,
Some((HashAlgo::Sha1, HASH)),
total,
planned,
) {
ChunkDecision::Restart { reason } => reason,
other => panic!("应当判从头再来,实得 {other:?}"),
}
}
#[test]
fn chunk_resume_reuses_only_segments_that_match_the_plan() {
let payload = chunk_payload(1_000);
let planned = plan(1_000, 4);
let done = vec![record(&payload, planned[0]), record(&payload, planned[2])];
let meta = with_chunks(meta(URL), done.clone());
let decision = chunk_decide(
ResumePolicy::Conservative,
1_000,
Some(&meta),
URL,
Some((HashAlgo::Sha1, HASH)),
1_000,
&planned,
);
assert_eq!(
decision,
ChunkDecision::Reuse { done: done.clone() },
"身份全部对上时应复用,且只复用记录过的段"
);
assert_eq!(
remaining(&planned, &done),
vec![planned[1], planned[3]],
"本次要抓的应当是规划里没落盘的那几段"
);
// 全部落完 ⇒ 没有要抓的段(下一次尝试只做整文件校验)。
let all: Vec<ChunkRecord> = planned
.iter()
.map(|range| record(&payload, *range))
.collect();
assert!(remaining(&planned, &all).is_empty());
}
#[test]
fn chunk_resume_restart_matrix() {
let planned = plan(1_000, 4);
let good = with_chunks(
meta(URL),
vec![ChunkRecord {
start: planned[0].start,
end: planned[0].end,
sha1: HASH.to_string(),
}],
);
// 策略禁止续传。
assert_eq!(
match chunk_decide(
ResumePolicy::Never,
1_000,
Some(&good),
URL,
Some((HashAlgo::Sha1, HASH)),
1_000,
&planned
) {
ChunkDecision::Restart { reason } => reason,
other => panic!("实得 {other:?}"),
},
RestartReason::PolicyNever
);
// 没有中间文件 / 没有元数据 / 元数据里没有分块清单(单连接写的那种)。
assert_eq!(
chunk_restart(0, Some(&good), URL, 1_000, &planned),
RestartReason::NoPart
);
assert_eq!(
chunk_restart(1_000, None, URL, 1_000, &planned),
RestartReason::NoMeta
);
assert_eq!(
chunk_restart(1_000, Some(&meta(URL)), URL, 1_000, &planned),
RestartReason::NoMeta,
"没有分块清单的元数据证明不了乱序段各自到哪"
);
// 换了源 / 换了目标文件(摘要变)/ 换了大小。
assert_eq!(
chunk_restart(
1_000,
Some(&good),
"https://other.example/x.jar",
1_000,
&planned
),
RestartReason::UrlChanged
);
let changed_hash = ResumeMeta {
hash: "f".repeat(40),
..good.clone()
};
assert_eq!(
chunk_restart(1_000, Some(&changed_hash), URL, 1_000, &planned),
RestartReason::HashChanged
);
assert_eq!(
chunk_restart(1_000, Some(&good), URL, 2_000, &plan(2_000, 4)),
RestartReason::SizeChanged,
"总长变了就是另一个文件,旧段的偏移全部作废"
);
// 没有校验器:服务器无法回答「内容变了没有」。
let weak = ResumeMeta {
etag: None,
last_modified: None,
..good.clone()
};
assert_eq!(
chunk_restart(1_000, Some(&weak), URL, 1_000, &planned),
RestartReason::NoValidator
);
// 段边界漂移:半段、重复、越界都不行。
let half = with_chunks(
meta(URL),
vec![ChunkRecord {
start: 0,
end: 100,
sha1: HASH.to_string(),
}],
);
assert_eq!(
chunk_restart(1_000, Some(&half), URL, 1_000, &planned),
RestartReason::ChunkLayout,
"记录必须是规划里原样的一段"
);
let first = ChunkRecord {
start: planned[0].start,
end: planned[0].end,
sha1: HASH.to_string(),
};
let duplicated = with_chunks(meta(URL), vec![first.clone(), first]);
assert_eq!(
chunk_restart(1_000, Some(&duplicated), URL, 1_000, &planned),
RestartReason::ChunkLayout
);
// 换了连接数 ⇒ 规划变了 ⇒ 旧段对不上号(从头再来,而不是硬拼)。
let reconfigured = with_chunks(
meta(URL),
vec![ChunkRecord {
start: planned[0].start,
end: planned[0].end,
sha1: HASH.to_string(),
}],
);
assert_eq!(
chunk_restart(1_000, Some(&reconfigured), URL, 1_000, &plan(1_000, 8)),
RestartReason::ChunkLayout
);
}
#[test]
fn chunk_verify_rejects_bytes_that_do_not_match_the_record() {
let dir = std::env::temp_dir().join(format!("ull-chunk-verify-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("jdk.zip.ullpart");
let payload = chunk_payload(1_000);
let planned = plan(1_000, 4);
let records = vec![record(&payload, planned[0]), record(&payload, planned[1])];
std::fs::write(&path, &payload).expect("写中间文件失败");
assert_eq!(
verify_records(&path, &records).expect("字节与记录一致时必须通过"),
planned[0].len() + planned[1].len(),
"返回的是可复用的字节数"
);
// 盘上那一段被改过(断电半截写 / 坏盘 / 别的源写进来的字节)。
let mut broken = payload.clone();
broken[planned[1].start as usize + 3] ^= 0xff;
std::fs::write(&path, &broken).expect("写中间文件失败");
assert!(
verify_records(&path, &records).is_err(),
"摘要对不上必须判「证明不了」,不能拿旧字节当好的"
);
// 盘上根本不够长(记录说写完了,其实只有开头几个字节)。
std::fs::write(&path, &payload[..10]).expect("写中间文件失败");
assert!(verify_records(&path, &records).is_err());
let _ = std::fs::remove_dir_all(&dir);
}