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 必红(已实测)。
This commit is contained in:
AskaEth committed 2026-11-13 06:26:18 +08:00
1 parent 6e1d5fa82e
commit 3ade2d14a8
13 files changed
+1428 -359

No files matched your search

+242
View File
@@ -0,0 +1,242 @@
//! 分块路径的续传判定(`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);
}