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

@@ -0,0 +1,287 @@
//! 分块路径的续传(M293 / 台账 P21 第 2 条 + P19(b)):真跑两次尝试,**数服务端吐了多少字节**。
//!
//! 判据刻意不用「最终 hash 对」:hash 对只说明拼出来的文件没错,抓不住「其实整份又下了一遍」——
//! 那正是 P21 那条债的形态(188MB 的 JDK 一次失败就从 0 重下)。所以每个用例都用夹具统计
//! 服务端实际发出的正文字节数([`MockServer::bytes_sent`]):
//!
//! * `resumes_the_finished_chunks_after_a_partial_failure`:第一次在 3/4 处失败、第二次续传完成
//! ⇒ 两次总字节必须 **< 1.5×文件大小**。变异:把 `attempt_chunked` 改回「进来先 `discard()`、
//! 失败也 `discard()`」⇒ 第二次必然整份重下,该断言红(1.75×)。
//! * `without_resume_the_same_failure_downloads_again`:同一份夹具、同一个失败点,只把续传策略
//! 换成 `Never` ⇒ 服务端确实吐出 ~1.75×。这条是上一条的**可执行变异证明**:`< 1.5×` 这个阈值
//! 真的把「续传」和「全重下」分开了,不是随便定的一个宽阈值。
//! * `corrupted_old_chunks_are_never_stitched_in`:盘上的旧段被改过一个字节 ⇒ 一段都不许复用
//! (摘要自证不过就整份丢掉从头再来),服务端要重新吐全部字节。
//! * `changed_size_invalidates_old_chunk_layout`:总长换了(同一个目标文件,大小变了)⇒ 旧段一段
//! 都不许用。这条钉的是「旧元数据不得被误用」。
use std::sync::Arc;
use std::time::Duration;
use unlucky_core::download::backoff::BackoffPolicy;
use unlucky_core::download::cache::ContentCache;
use unlucky_core::download::chunked::{plan, ByteRange, ChunkedConfig};
use unlucky_core::download::config::EngineConfig;
use unlucky_core::download::engine::DownloadEngine;
use unlucky_core::download::health::HealthRegistry;
use unlucky_core::download::mirrors::MirrorTable;
use unlucky_core::download::region::RegionProfile;
use unlucky_core::download::resume::{ChunkRecord, ResumeMeta};
use unlucky_core::download::types::{DownloadRequest, HashAlgo, ResourceKind, ResumePolicy};
use unlucky_core::download::verify::digest_bytes;
use super::support::{sha1_hex, temp_dir, MockResponse, MockServer};
/// 4 段 × 10 KiB。
const SIZE: usize = 40 * 1024;
/// 卡住的那一段吐这么多字节就停(这部分字节落盘了但不会被记账)。
const STALL_TAIL: usize = 1024;
fn payload() -> Vec<u8> {
(0..SIZE).map(|index| (index % 251) as u8).collect()
}
/// 一次尝试允许 2 次(第一次故意失败、第二次续传);读空闲 300ms:卡住的那一段自己报错,
/// 那时其余三段早就落完并被回收了(作业级的无进度窗口是 10s,不会抢在它前面)。
fn config() -> EngineConfig {
EngineConfig {
max_attempts_per_source: 2,
backoff: BackoffPolicy::none(),
connect_timeout: Duration::from_secs(2),
response_timeout: Duration::from_secs(2),
read_timeout: Duration::from_millis(300),
chunk_stall_timeout: Duration::from_secs(10),
chunk_job_floor: Duration::from_secs(10),
verify_timeout: Duration::from_secs(10),
chunked: ChunkedConfig {
enabled: true,
connections: 4,
min_size: 1024,
},
..EngineConfig::default()
}
}
fn engine(dir: &std::path::Path) -> DownloadEngine {
DownloadEngine::new(
config(),
Arc::new(MirrorTable::builtin()),
Arc::new(RegionProfile::direct(0)),
Arc::new(HealthRegistry::in_memory()),
Arc::new(ContentCache::open(dir.join("cache"))),
)
}
fn request(url: &str, dest: &std::path::Path, payload: &[u8]) -> DownloadRequest {
DownloadRequest::new(ResourceKind::JavaRuntime, url, dest)
.with_sha1(sha1_hex(payload))
.with_size(payload.len() as u64)
}
/// 第一次尝试的脚本:4 段里 3 段正常落完,第 4 个连接拿到「吐一点就卡住」的那份。
fn partial_script(payload: &[u8]) -> Vec<MockResponse> {
let mut script: Vec<MockResponse> =
(0..3).map(|_| MockResponse::ok(payload.to_vec())).collect();
script.push(MockResponse::ok(payload.to_vec()).stalling_after(STALL_TAIL));
script
}
fn record_of(payload: &[u8], range: ByteRange) -> ChunkRecord {
ChunkRecord {
start: range.start,
end: range.end,
sha1: digest_bytes(
HashAlgo::Sha1,
&payload[range.start as usize..=range.end as usize],
),
}
}
/// 分块路径的元数据(`chunks: Some(..)` 是「这个中间文件属于分块路径」的标记)。
fn chunk_meta(url: &str, total: u64, hash: &str, chunks: Vec<ChunkRecord>) -> ResumeMeta {
ResumeMeta {
url: url.to_string(),
etag: Some("\"v1\"".to_string()),
last_modified: None,
total: Some(total),
algo: HashAlgo::Sha1,
hash: hash.to_string(),
saved_at_ms: 1,
chunks: Some(chunks),
}
}
/// 3/4 段落完就失败,第二次续传完成 ⇒ 服务端总共只该吐 ≈1.0×,而不是 ≈1.75×。
#[tokio::test]
async fn resumes_the_finished_chunks_after_a_partial_failure() {
let payload = payload();
let size = payload.len() as u64;
let mut script = partial_script(&payload);
// 第二次尝试只该有 1 段要补。脚本仍给足 4 条:万一实现退化成「从头下」,
// 那一次也要能下完——这样红的就是**字节判据**本身,而不是「脚本用尽 ⇒ 500」。
script.extend((0..4).map(|_| MockResponse::ok(payload.clone())));
let server = MockServer::start(script).await;
let url = server.url("/jdk.zip");
let dir = temp_dir("chunked-resume");
let engine = engine(&dir);
let dest = dir.join("jdk.zip");
let outcome = engine
.download(request(&url, &dest, &payload))
.await
.expect("续传后应当成功");
assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload);
// 主判据:服务端总共吐了多少字节(「最终 hash 对」抓不到「其实全重下了」)。
let sent = server.bytes_sent();
assert!(
sent < size * 3 / 2,
"两次尝试总共只该吐 ≈1.0× 字节,实得 {sent}(1.5×{size} = {}):\
超过它说明第二次又把整份下了一遍",
size * 3 / 2
);
assert_eq!(server.hits(), 5, "第一次 4 段 + 第二次补 1 段");
assert_eq!(
outcome.resumed_from,
size * 3 / 4,
"3/4 的段应当从磁盘复用(剩下 1 段重抓)"
);
server.stop();
}
/// 上一条的变异证明:同一个失败点,只把续传关掉 ⇒ 两次尝试一共吐 ~1.75×。
///
/// 把实现改回「失败就 discard、下次从头下」时,上一条的 `< 1.5×` 会落到本用例的区间 ⇒ 必红。
#[tokio::test]
async fn without_resume_the_same_failure_downloads_again() {
let payload = payload();
let size = payload.len() as u64;
let mut script = partial_script(&payload);
// 第二次尝试要是从头下:4 段全部重抓。
script.extend((0..4).map(|_| MockResponse::ok(payload.clone())));
let server = MockServer::start(script).await;
let url = server.url("/jdk.zip");
let dir = temp_dir("chunked-noresume");
let engine = engine(&dir);
let dest = dir.join("jdk.zip");
let outcome = engine
.download(request(&url, &dest, &payload).with_resume(ResumePolicy::Never))
.await
.expect("不续传也要能下完");
assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload);
assert_eq!(outcome.resumed_from, 0, "策略 Never ⇒ 一个字节都不复用");
let sent = server.bytes_sent();
assert!(
sent > size * 3 / 2,
"不续传时服务端要吐出两遍的字节(实测 {sent},应当 > {})——\
这正是 P21 那条债的形态",
size * 3 / 2
);
assert_eq!(server.hits(), 8, "第一次 4 段 + 第二次整份 4 段");
server.stop();
}
/// 盘上的旧段被改过:不许把「记录里说好的字节」当成「盘上现在就是好的」直接拼进结果。
#[tokio::test]
async fn corrupted_old_chunks_are_never_stitched_in() {
let payload = payload();
let size = payload.len() as u64;
let mut script: Vec<MockResponse> = (0..4).map(|_| MockResponse::ok(payload.clone())).collect();
// 万一实现真去复用坏段,它还要再整份重下一遍才有机会成功(8 条够两种世界都下完)。
script.extend((0..4).map(|_| MockResponse::ok(payload.clone())));
let server = MockServer::start(script).await;
let url = server.url("/jdk.zip");
let dir = temp_dir("chunked-broken-part");
let engine = engine(&dir);
let dest = dir.join("jdk.zip");
let ranges = plan(size, 4);
// 旧版无主命名的中间文件(`PartFile::for_dest` 会收养它)。
let part = dir.join("jdk.zip.ullpart");
let meta_path = dir.join("jdk.zip.ullpart.ullmeta.json");
let mut on_disk = payload.clone();
on_disk[ranges[1].start as usize + 5] ^= 0xff;
std::fs::write(&part, &on_disk).expect("写旧中间文件失败");
// 元数据声称前 3 段都落完了(记的是**原始**字节的摘要),只有第 4 段没记。
chunk_meta(
&url,
size,
&sha1_hex(&payload),
ranges[..3]
.iter()
.map(|range| record_of(&payload, *range))
.collect(),
)
.save(&meta_path)
.expect("写旧元数据失败");
let outcome = engine
.download(request(&url, &dest, &payload))
.await
.expect("坏旧段被丢掉后应当重下成功");
assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload);
// 主判据:坏段自证不过 ⇒ 整份丢掉从头来,服务端只该吐这一份文件的字节。
assert_eq!(
server.bytes_sent(),
size,
"全部字节都要重新抓一遍(复用坏字节 = 交付坏文件)"
);
assert_eq!(server.hits(), 4, "从头下 = 4 个分块请求");
assert_eq!(
outcome.resumed_from, 0,
"任何一段对不上就整份丢掉,一段都不复用"
);
server.stop();
}
/// 同一个目标文件换了大小:旧的 2 段元数据**恰好**对得上新规划的前两段,只有「总长变了」能拦住它。
#[tokio::test]
async fn changed_size_invalidates_old_chunk_layout() {
let payload = payload();
let size = payload.len() as u64;
let mut script: Vec<MockResponse> = (0..4).map(|_| MockResponse::ok(payload.clone())).collect();
// 真去复用旧段时,它还要再整份重下一遍才有机会成功(8 条够两种世界都下完)。
script.extend((0..4).map(|_| MockResponse::ok(payload.clone())));
let server = MockServer::start(script).await;
let url = server.url("/jdk.zip");
let dir = temp_dir("chunked-size-change");
let engine = engine(&dir);
let dest = dir.join("jdk.zip");
// 旧中间文件 = 上一次「同一个目标、一半大小」的下载残留(内容是**另一个版本**的字节)。
let old_size = SIZE / 2;
let old_bytes = vec![0xEE_u8; old_size];
let part = dir.join("jdk.zip.ullpart");
let meta_path = dir.join("jdk.zip.ullpart.ullmeta.json");
std::fs::write(&part, &old_bytes).expect("写旧中间文件失败");
// 2 段 × 10KiB:段区间与新规划的 [0-10239] / [10240-20479] 完全重合,所以
// 「段对不上号」这条拦不住它,只有总长比较能拦(变异:删掉总长比较 ⇒ 本用例红:
// 复用 20480 字节 + 整文件校验失败 ⇒ 白下一次 20480 字节)。
chunk_meta(
&url,
old_size as u64,
&sha1_hex(&payload),
plan(old_size as u64, 2)
.iter()
.map(|range| record_of(&old_bytes, *range))
.collect(),
)
.save(&meta_path)
.expect("写旧元数据失败");
let outcome = engine
.download(request(&url, &dest, &payload))
.await
.expect("大小变了以后必须从头重下成功");
assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload);
// 主判据:旧文件的那 20480 字节一个都不许用(误用 ⇒ 这次会吐出 1.5×)。
assert_eq!(
server.bytes_sent(),
size,
"旧的那 20480 字节一个都不许用:只允许下这次的 {size} 字节"
);
assert_eq!(outcome.resumed_from, 0, "总长变了 ⇒ 旧段偏移全部作废");
server.stop();
}
@@ -377,3 +377,7 @@ async fn rejects_lying_content_range_and_falls_back() {
/// 分块作业的两道失速判据(无进度 / 总期限):拆到子模块守 400 行硬线。
#[path = "chunked/timeout.rs"]
mod timeout;
/// 分块路径的续传(失败不重下、旧字节要自证):同族用例单独一份。
#[path = "chunked/resume.rs"]
mod resume;
@@ -278,6 +278,7 @@ async fn resumes_from_existing_part_file() {
algo: HashAlgo::Sha1,
hash: sha1_hex(&payload),
saved_at_ms: 1,
chunks: None,
}
.save(&meta_path)
.expect("写续传元数据失败");
+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);
}
@@ -5,6 +5,10 @@ use unlucky_core::download::resume::{
};
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";
@@ -17,6 +21,7 @@ fn meta(url: &str) -> ResumeMeta {
algo: HashAlgo::Sha1,
hash: HASH.to_string(),
saved_at_ms: 1,
chunks: None,
}
}
+6 -331
View File
@@ -1,17 +1,17 @@
//! 测试支撑:临时目录、摘要工具、进程内极简 HTTP 服务器(零额外依赖)。
//!
//! 为什么自己写 mock server 而不用 wiremock:P0 阶段控制依赖面(Win7 Tier-3 构建 +
//! 单文件体积),一个 60 行的 `TcpListener` 足够表达「成功 / 500 / 校验不符 / 无视 Range」。
//! 单文件体积),一个 `TcpListener` + 脚本队列足够表达「成功 / 500 / 校验不符 / 无视 Range」。
//! 服务器本体拆在 [`server`] 里(守 400 行硬线),这里只做再导出,调用方路径不变。
#![allow(dead_code)]
use std::net::SocketAddr;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
mod server;
#[allow(unused_imports)]
pub use self::server::{MockResponse, MockServer};
/// 断言用的小工具:唯一临时目录(进程内计数 + 时间戳,避免并行测试互相踩)。
pub fn temp_dir(tag: &str) -> std::path::PathBuf {
@@ -61,328 +61,3 @@ pub fn age_file(path: &std::path::Path, age: std::time::Duration) {
.set_times(times)
.expect("改 mtime 失败");
}
/// mock 服务器给一次请求的应答。
#[derive(Debug, Clone)]
pub struct MockResponse {
/// HTTP 状态码。
pub status: u16,
/// 响应体。
pub body: Vec<u8>,
/// 是否声明 `Accept-Ranges: bytes` 并在收到 `Range` 时真的切段。
pub range_support: bool,
/// `ETag`(续传测试用)。
pub etag: Option<String>,
/// 返回 206 时故意把 `Content-Range` 写成从 0 开始(模拟段错位的坏服务器)。
pub lie_content_range: bool,
/// 读完请求头后**一个字节都不写**(服务端接了连接但永不回话)。
///
/// 这是「无界 `await`」唯一能表达成用例的形态:读方不设上界就永远不返回。
pub silent: bool,
/// 写出这么多字节正文后就**停住不再写**(中途卡住),连接保持打开。
pub stall_at: Option<usize>,
/// 正文实际只发这么多字节(头部仍声称全量 = 短包)。
pub send_len: Option<usize>,
/// 正文之后**多**发这么多字节,且故意不发 `Content-Length`(超长包:HTTP/1.1 靠关连接分帧)。
pub trailing_extra: Option<usize>,
/// 正文按 `piece` 字节一片、片间睡 `gap` 滴流(慢但持续,用来证明超时不会误杀它)。
pub drip: Option<(usize, Duration)>,
}
/// 夹具「挂住」的时长:远大于用例自己的上界,保证读方只能靠超时脱身。
const HOLD: Duration = Duration::from_secs(30);
impl MockResponse {
/// 200 + 完整内容(支持 Range)。
pub fn ok(body: impl Into<Vec<u8>>) -> Self {
Self {
status: 200,
body: body.into(),
range_support: true,
etag: Some("\"v1\"".to_string()),
lie_content_range: false,
silent: false,
stall_at: None,
send_len: None,
trailing_extra: None,
drip: None,
}
}
/// 固定状态码 + 空体。
pub fn status(status: u16) -> Self {
Self {
status,
body: Vec::new(),
range_support: false,
etag: None,
lie_content_range: false,
silent: false,
stall_at: None,
send_len: None,
trailing_extra: None,
drip: None,
}
}
/// 200 但**永不回话**:读完请求头就挂住,一个字都不写。
pub fn silent() -> Self {
Self {
silent: true,
..Self::ok(Vec::new())
}
}
/// 写完 `bytes` 字节正文后停住(中途卡住,连接不断)。
pub fn stalling_after(mut self, bytes: usize) -> Self {
self.stall_at = Some(bytes);
self
}
/// 头部声称全量、正文只发 `sent` 字节(短包,客户端读不满)。
pub fn short_body(mut self, sent: usize) -> Self {
self.send_len = Some(sent);
self
}
/// 正文之后多发 `extra` 字节且不发 `Content-Length`(超长包)。
pub fn oversize_body(mut self, extra: usize) -> Self {
self.trailing_extra = Some(extra);
self
}
/// 正文按 `piece` 字节一片、片间 `gap` 滴流(慢但持续)。
pub fn dripping(mut self, piece: usize, gap: Duration) -> Self {
self.drip = Some((piece, gap));
self
}
/// 声明不支持 Range(模拟部分镜像)。
pub fn without_range(mut self) -> Self {
self.range_support = false;
self
}
/// 206 但 `Content-Range` 谎报(模拟段错位的坏 CDN)。
pub fn with_wrong_content_range(mut self) -> Self {
self.lie_content_range = true;
self
}
}
/// 进程内极简 HTTP/1.1 服务器:按脚本顺序返回响应,脚本用尽后一律 500。
pub struct MockServer {
addr: SocketAddr,
hits: Arc<AtomicUsize>,
handle: tokio::task::JoinHandle<()>,
}
impl MockServer {
/// 启动服务器(脚本是「每次请求一个响应」的队列)。
pub async fn start(script: Vec<MockResponse>) -> Self {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("绑定端口失败");
let addr = listener.local_addr().expect("取本地地址失败");
let hits = Arc::new(AtomicUsize::new(0));
let script = Arc::new(std::sync::Mutex::new(script));
let counter = Arc::clone(&hits);
let handle = tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
break;
};
let script = Arc::clone(&script);
let counter = Arc::clone(&counter);
tokio::spawn(async move {
let index = counter.fetch_add(1, Ordering::Relaxed);
let response = {
let script = script.lock().unwrap_or_else(|err| err.into_inner());
script.get(index).cloned()
};
let response = response.unwrap_or_else(|| MockResponse::status(500));
let _ = serve(stream, response).await;
});
}
});
Self { addr, hits, handle }
}
/// 第 `index` 个请求该用的 URL(换成不同路径可模拟「不同源」)。
pub fn url(&self, path: &str) -> String {
format!("http://{}{}", self.addr, path)
}
/// 换 host 拼 URL(用 `localhost` vs `127.0.0.1` 造出两个不同源)。
pub fn url_with(&self, host: &str, path: &str) -> String {
format!("http://{host}:{}{}", self.addr.port(), path)
}
/// 端口号。
pub fn port(&self) -> u16 {
self.addr.port()
}
/// 已收到的请求数。
pub fn hits(&self) -> usize {
self.hits.load(Ordering::Relaxed)
}
/// 关停。
pub fn stop(self) {
self.handle.abort();
}
}
async fn serve(mut stream: TcpStream, response: MockResponse) -> std::io::Result<()> {
let mut buffer = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
let read = stream.read(&mut chunk).await?;
if read == 0 {
break;
}
buffer.extend_from_slice(&chunk[..read]);
if buffer.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
let request = String::from_utf8_lossy(&buffer).to_ascii_lowercase();
let range = request
.lines()
.find_map(|line| line.strip_prefix("range: bytes="))
.map(str::trim)
.and_then(parse_range_header);
if response.silent {
// 接了连接、读完请求头、一个字都不回:读方不设上界就永远挂在这里。
tokio::time::sleep(HOLD).await;
return Ok(());
}
let total = response.body.len() as u64;
// 超长包不能发 `Content-Length`,否则读方按声明截断、永远看不到多余字节。
let with_length = response.trailing_extra.is_none();
let (status, headers, body) = match (range, response.range_support) {
(Some((start, requested_end)), true) if start < total => {
// 尊重请求里的结束偏移(分块下载必须逐段精确,否则组装后长度对不上)。
let end = requested_end.unwrap_or(total - 1).min(total - 1);
let slice = response.body[start as usize..=end as usize].to_vec();
// 谎报模式:body 仍是正确的那一段,但头部声称从 0 开始(长度恰好 → 只有
// 严格比对 Content-Range 才抓得住)。
let (claimed_start, claimed_end) = if response.lie_content_range {
(0, slice.len().saturating_sub(1) as u64)
} else {
(start, end)
};
let mut headers = vec![
format!("Content-Range: bytes {claimed_start}-{claimed_end}/{total}"),
"Accept-Ranges: bytes".to_string(),
];
if with_length {
headers.push(format!("Content-Length: {}", slice.len()));
}
(206, headers, slice)
}
(Some(_), true) => (
416,
vec![
format!("Content-Range: bytes */{total}"),
"Content-Length: 0".to_string(),
],
Vec::new(),
),
_ => {
let mut headers = Vec::new();
if with_length {
headers.push(format!("Content-Length: {total}"));
}
if response.range_support {
headers.push("Accept-Ranges: bytes".to_string());
}
(response.status, headers, response.body.clone())
}
};
let mut head = format!("HTTP/1.1 {status} {}\r\n", reason(status));
if let Some(etag) = &response.etag {
head.push_str(&format!("ETag: {etag}\r\n"));
}
for header in headers {
head.push_str(&header);
head.push_str("\r\n");
}
head.push_str("Connection: close\r\n\r\n");
stream.write_all(head.as_bytes()).await?;
write_body(&mut stream, &body, &response).await?;
stream.flush().await?;
Ok(())
}
/// 按夹具的口径写正文:正常 / 卡在中途 / 短包 / 超长尾巴 / 滴流。
async fn write_body(
stream: &mut TcpStream,
body: &[u8],
response: &MockResponse,
) -> std::io::Result<()> {
if let Some(stall_at) = response.stall_at {
// 头部 + 这么多字节已发出,之后一个字不再写(连接不断):读方只能靠超时脱身。
let sent = stall_at.min(body.len());
stream.write_all(&body[..sent]).await?;
stream.flush().await?;
tokio::time::sleep(HOLD).await;
return Ok(());
}
let limit = response.send_len.unwrap_or(body.len()).min(body.len());
match response.drip {
Some((piece, gap)) => {
let piece = piece.max(1);
let mut offset = 0;
while offset < limit {
let end = (offset + piece).min(limit);
stream.write_all(&body[offset..end]).await?;
stream.flush().await?;
offset = end;
if offset < limit {
tokio::time::sleep(gap).await;
}
}
}
None => {
stream.write_all(&body[..limit]).await?;
stream.flush().await?;
}
}
if let Some(extra) = response.trailing_extra {
let filler = vec![0_u8; extra];
stream.write_all(&filler).await?;
stream.flush().await?;
}
Ok(())
}
/// 解析 `Range: bytes=<start>-<end>`(`end` 可缺省 = 到文件末尾)。
fn parse_range_header(value: &str) -> Option<(u64, Option<u64>)> {
let (start, end) = value.split_once('-')?;
let start = start.trim().parse::<u64>().ok()?;
let end = end.trim();
let end = if end.is_empty() {
None
} else {
Some(end.parse::<u64>().ok()?)
};
Some((start, end))
}
fn reason(status: u16) -> &'static str {
match status {
200 => "OK",
206 => "Partial Content",
404 => "Not Found",
416 => "Range Not Satisfiable",
500 => "Internal Server Error",
503 => "Service Unavailable",
_ => "Status",
}
}
@@ -0,0 +1,368 @@
//! 进程内极简 HTTP/1.1 夹具服务器(零额外依赖)。
//!
//! 为什么自己写而不用 wiremock:P0 阶段控制依赖面(Win7 Tier-3 构建 + 单文件体积),一个
//! `TcpListener` + 脚本队列足够表达「成功 / 500 / 校验不符 / 无视 Range / 中途卡住」。
//!
//! 正文写出的字节数记在 [`MockServer::bytes_sent`] 里:续传用例的判据是「服务端到底吐了
//! 多少字节」,只看最终 hash 抓不到「其实整份又下了一遍」(见 `chunked/resume.rs`)。
//!
//! 从 `support/mod.rs` 拆出只为守 400 行硬线(M293)。
use std::net::SocketAddr;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
/// mock 服务器给一次请求的应答。
#[derive(Debug, Clone)]
pub struct MockResponse {
/// HTTP 状态码。
pub status: u16,
/// 响应体。
pub body: Vec<u8>,
/// 是否声明 `Accept-Ranges: bytes` 并在收到 `Range` 时真的切段。
pub range_support: bool,
/// `ETag`(续传测试用)。
pub etag: Option<String>,
/// 返回 206 时故意把 `Content-Range` 写成从 0 开始(模拟段错位的坏服务器)。
pub lie_content_range: bool,
/// 读完请求头后**一个字节都不写**(服务端接了连接但永不回话)。
///
/// 这是「无界 `await`」唯一能表达成用例的形态:读方不设上界就永远不返回。
pub silent: bool,
/// 写出这么多字节正文后就**停住不再写**(中途卡住),连接保持打开。
pub stall_at: Option<usize>,
/// 正文实际只发这么多字节(头部仍声称全量 = 短包)。
pub send_len: Option<usize>,
/// 正文之后**多**发这么多字节,且故意不发 `Content-Length`(超长包:HTTP/1.1 靠关连接分帧)。
pub trailing_extra: Option<usize>,
/// 正文按 `piece` 字节一片、片间睡 `gap` 滴流(慢但持续,用来证明超时不会误杀它)。
pub drip: Option<(usize, Duration)>,
}
/// 夹具「挂住」的时长:远大于用例自己的上界,保证读方只能靠超时脱身。
const HOLD: Duration = Duration::from_secs(30);
impl MockResponse {
/// 200 + 完整内容(支持 Range)。
pub fn ok(body: impl Into<Vec<u8>>) -> Self {
Self {
status: 200,
body: body.into(),
range_support: true,
etag: Some("\"v1\"".to_string()),
lie_content_range: false,
silent: false,
stall_at: None,
send_len: None,
trailing_extra: None,
drip: None,
}
}
/// 固定状态码 + 空体。
pub fn status(status: u16) -> Self {
Self {
status,
body: Vec::new(),
range_support: false,
etag: None,
lie_content_range: false,
silent: false,
stall_at: None,
send_len: None,
trailing_extra: None,
drip: None,
}
}
/// 200 但**永不回话**:读完请求头就挂住,一个字都不写。
pub fn silent() -> Self {
Self {
silent: true,
..Self::ok(Vec::new())
}
}
/// 写完 `bytes` 字节正文后停住(中途卡住,连接不断)。
pub fn stalling_after(mut self, bytes: usize) -> Self {
self.stall_at = Some(bytes);
self
}
/// 头部声称全量、正文只发 `sent` 字节(短包,客户端读不满)。
pub fn short_body(mut self, sent: usize) -> Self {
self.send_len = Some(sent);
self
}
/// 正文之后多发 `extra` 字节且不发 `Content-Length`(超长包)。
pub fn oversize_body(mut self, extra: usize) -> Self {
self.trailing_extra = Some(extra);
self
}
/// 正文按 `piece` 字节一片、片间 `gap` 滴流(慢但持续)。
pub fn dripping(mut self, piece: usize, gap: Duration) -> Self {
self.drip = Some((piece, gap));
self
}
/// 声明不支持 Range(模拟部分镜像)。
pub fn without_range(mut self) -> Self {
self.range_support = false;
self
}
/// 206 但 `Content-Range` 谎报(模拟段错位的坏 CDN)。
pub fn with_wrong_content_range(mut self) -> Self {
self.lie_content_range = true;
self
}
}
/// 进程内极简 HTTP/1.1 服务器:按脚本顺序返回响应,脚本用尽后一律 500。
pub struct MockServer {
addr: SocketAddr,
hits: Arc<AtomicUsize>,
bytes: Arc<std::sync::atomic::AtomicU64>,
handle: tokio::task::JoinHandle<()>,
}
impl MockServer {
/// 启动服务器(脚本是「每次请求一个响应」的队列)。
pub async fn start(script: Vec<MockResponse>) -> Self {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("绑定端口失败");
let addr = listener.local_addr().expect("取本地地址失败");
let hits = Arc::new(AtomicUsize::new(0));
let bytes = Arc::new(std::sync::atomic::AtomicU64::new(0));
let script = Arc::new(std::sync::Mutex::new(script));
let counter = Arc::clone(&hits);
let body_bytes = Arc::clone(&bytes);
let handle = tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
break;
};
let script = Arc::clone(&script);
let counter = Arc::clone(&counter);
let bytes = Arc::clone(&body_bytes);
tokio::spawn(async move {
let index = counter.fetch_add(1, Ordering::Relaxed);
let response = {
let script = script.lock().unwrap_or_else(|err| err.into_inner());
script.get(index).cloned()
};
let response = response.unwrap_or_else(|| MockResponse::status(500));
let _ = serve(stream, response, &bytes).await;
});
}
});
Self {
addr,
hits,
bytes,
handle,
}
}
/// 第 `index` 个请求该用的 URL(换成不同路径可模拟「不同源」)。
pub fn url(&self, path: &str) -> String {
format!("http://{}{}", self.addr, path)
}
/// 换 host 拼 URL(用 `localhost` vs `127.0.0.1` 造出两个不同源)。
pub fn url_with(&self, host: &str, path: &str) -> String {
format!("http://{host}:{}{}", self.addr.port(), path)
}
/// 端口号。
pub fn port(&self) -> u16 {
self.addr.port()
}
/// 已收到的请求数。
pub fn hits(&self) -> usize {
self.hits.load(Ordering::Relaxed)
}
/// 已发出的**正文字节**总数(不含响应头)。
///
/// 续传用例的判据靠它:「最终 hash 对」证明不了没白下字节,只有统计服务端真正吐了多少
/// 才知道是不是又整份下了一遍(见 `chunked/resume.rs`)。
pub fn bytes_sent(&self) -> u64 {
self.bytes.load(Ordering::Relaxed)
}
/// 关停。
pub fn stop(self) {
self.handle.abort();
}
}
async fn serve(
mut stream: TcpStream,
response: MockResponse,
sent: &std::sync::atomic::AtomicU64,
) -> std::io::Result<()> {
let mut buffer = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
let read = stream.read(&mut chunk).await?;
if read == 0 {
break;
}
buffer.extend_from_slice(&chunk[..read]);
if buffer.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
let request = String::from_utf8_lossy(&buffer).to_ascii_lowercase();
let range = request
.lines()
.find_map(|line| line.strip_prefix("range: bytes="))
.map(str::trim)
.and_then(parse_range_header);
if response.silent {
// 接了连接、读完请求头、一个字都不回:读方不设上界就永远挂在这里。
tokio::time::sleep(HOLD).await;
return Ok(());
}
let total = response.body.len() as u64;
// 超长包不能发 `Content-Length`,否则读方按声明截断、永远看不到多余字节。
let with_length = response.trailing_extra.is_none();
let (status, headers, body) = match (range, response.range_support) {
(Some((start, requested_end)), true) if start < total => {
// 尊重请求里的结束偏移(分块下载必须逐段精确,否则组装后长度对不上)。
let end = requested_end.unwrap_or(total - 1).min(total - 1);
let slice = response.body[start as usize..=end as usize].to_vec();
// 谎报模式:body 仍是正确的那一段,但头部声称从 0 开始(长度恰好 → 只有
// 严格比对 Content-Range 才抓得住)。
let (claimed_start, claimed_end) = if response.lie_content_range {
(0, slice.len().saturating_sub(1) as u64)
} else {
(start, end)
};
let mut headers = vec![
format!("Content-Range: bytes {claimed_start}-{claimed_end}/{total}"),
"Accept-Ranges: bytes".to_string(),
];
if with_length {
headers.push(format!("Content-Length: {}", slice.len()));
}
(206, headers, slice)
}
(Some(_), true) => (
416,
vec![
format!("Content-Range: bytes */{total}"),
"Content-Length: 0".to_string(),
],
Vec::new(),
),
_ => {
let mut headers = Vec::new();
if with_length {
headers.push(format!("Content-Length: {total}"));
}
if response.range_support {
headers.push("Accept-Ranges: bytes".to_string());
}
(response.status, headers, response.body.clone())
}
};
let mut head = format!("HTTP/1.1 {status} {}\r\n", reason(status));
if let Some(etag) = &response.etag {
head.push_str(&format!("ETag: {etag}\r\n"));
}
for header in headers {
head.push_str(&header);
head.push_str("\r\n");
}
head.push_str("Connection: close\r\n\r\n");
stream.write_all(head.as_bytes()).await?;
write_body(&mut stream, &body, &response, sent).await?;
stream.flush().await?;
Ok(())
}
/// 按夹具的口径写正文:正常 / 卡在中途 / 短包 / 超长尾巴 / 滴流。发出的正文逐块计数。
async fn write_body(
stream: &mut TcpStream,
body: &[u8],
response: &MockResponse,
sent: &std::sync::atomic::AtomicU64,
) -> std::io::Result<()> {
if let Some(stall_at) = response.stall_at {
// 头部 + 这么多字节已发出,之后一个字不再写(连接不断):读方只能靠超时脱身。
let sent_len = stall_at.min(body.len());
stream.write_all(&body[..sent_len]).await?;
stream.flush().await?;
sent.fetch_add(sent_len as u64, Ordering::Relaxed);
tokio::time::sleep(HOLD).await;
return Ok(());
}
let limit = response.send_len.unwrap_or(body.len()).min(body.len());
match response.drip {
Some((piece, gap)) => {
let piece = piece.max(1);
let mut offset = 0;
while offset < limit {
let end = (offset + piece).min(limit);
stream.write_all(&body[offset..end]).await?;
stream.flush().await?;
sent.fetch_add((end - offset) as u64, Ordering::Relaxed);
offset = end;
if offset < limit {
tokio::time::sleep(gap).await;
}
}
}
None => {
stream.write_all(&body[..limit]).await?;
stream.flush().await?;
sent.fetch_add(limit as u64, Ordering::Relaxed);
}
}
if let Some(extra) = response.trailing_extra {
let filler = vec![0_u8; extra];
stream.write_all(&filler).await?;
stream.flush().await?;
sent.fetch_add(extra as u64, Ordering::Relaxed);
}
Ok(())
}
/// 解析 `Range: bytes=<start>-<end>`(`end` 可缺省 = 到文件末尾)。
fn parse_range_header(value: &str) -> Option<(u64, Option<u64>)> {
let (start, end) = value.split_once('-')?;
let start = start.trim().parse::<u64>().ok()?;
let end = end.trim();
let end = if end.is_empty() {
None
} else {
Some(end.parse::<u64>().ok()?)
};
Some((start, end))
}
fn reason(status: u16) -> &'static str {
match status {
200 => "OK",
206 => "Partial Content",
404 => "Not Found",
416 => "Range Not Satisfiable",
500 => "Internal Server Error",
503 => "Service Unavailable",
_ => "Status",
}
}