台账 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 必红(已实测)。
229 lines
5.8 KiB
Rust
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);
|
|
}
|