Unlucky Launcher v0.2.0
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s

首个 iced + Material 3 Expressive 版本的源码快照。
完整历史与日常提交在 dev 分支;main 只放阶段性快照。
This commit is contained in:
AskaEth committed 2026-11-05 14:22:11 +08:00
commit 8a8e5ca0c0
1079 files changed
+208035

No files matched your search

@@ -0,0 +1,68 @@
//! 指数退避与抖动的单测(纯函数,无网络)。
use std::time::Duration;
use unlucky_core::download::backoff::{BackoffPolicy, Jitter};
#[test]
fn exponential_growth_doubles_and_caps() {
let policy = BackoffPolicy::default();
assert_eq!(policy.delay(0), Duration::from_millis(1_000));
assert_eq!(policy.delay(1), Duration::from_millis(2_000));
assert_eq!(policy.delay(2), Duration::from_millis(4_000));
assert_eq!(policy.delay(3), Duration::from_millis(8_000));
// 上限封顶,不会涨到分钟级。
assert_eq!(policy.delay(10), Duration::from_millis(30_000));
assert_eq!(policy.delay(u32::MAX), Duration::from_millis(30_000));
}
#[test]
fn no_retry_policy_waits_nothing() {
let policy = BackoffPolicy::none();
assert_eq!(policy.delay(0), Duration::ZERO);
assert_eq!(policy.max_attempts, 1);
assert_eq!(policy.delay_jittered(0, 0.5), Duration::ZERO);
}
#[test]
fn jitter_stays_within_ratio_bounds() {
let policy = BackoffPolicy::default();
// base 1000ms ± 30% → [700, 1300]
let low = policy.delay_jittered(0, 0.0);
let high = policy.delay_jittered(0, 1.0);
assert_eq!(low, Duration::from_millis(700));
assert_eq!(high, Duration::from_millis(1_300));
// 抖动永不超过 max_ms。
for rng in [0.0, 0.25, 0.5, 0.75, 1.0] {
assert!(policy.delay_jittered(9, rng) <= Duration::from_millis(30_000));
}
}
#[test]
fn jitter_without_ratio_is_deterministic() {
let policy = BackoffPolicy {
jitter_ratio: 0.0,
..BackoffPolicy::default()
};
assert_eq!(
policy.delay_jittered(2, 0.0),
policy.delay_jittered(2, 0.99)
);
}
#[test]
fn jitter_source_is_reproducible_and_in_range() {
let mut first = Jitter::new(42);
let mut second = Jitter::new(42);
for _ in 0..32 {
let a = first.next_fraction();
let b = second.next_fraction();
assert_eq!(a, b, "同种子的抖动序列必须一致");
assert!((0.0..1.0).contains(&a));
}
let mut other = Jitter::new(7);
assert_ne!(Jitter::new(1).next_u64(), other.next_u64());
// 种子 0 会被替换成固定非零种子,但仍然可用。
assert!((0.0..1.0).contains(&Jitter::new(0).next_fraction()));
}
+149
View File
@@ -0,0 +1,149 @@
//! 内容寻址缓存的单测:两级分片、记录信任(命中不重算)、损坏检测、索引持久化。
mod support;
use std::time::Duration;
use unlucky_core::download::cache::ContentCache;
use unlucky_core::download::types::{HashAlgo, Integrity};
const NOW: u64 = 5_000_000;
fn cache_dir(tag: &str) -> std::path::PathBuf {
support::temp_dir(tag)
}
#[test]
fn sharded_path_follows_layout() {
let dir = cache_dir("cache-path");
let cache = ContentCache::open(&dir);
let integrity = Integrity::sha1("abcdef0123456789abcdef0123456789abcdef01");
let path = cache.path_for(integrity.algo, &integrity.hex);
assert!(path.ends_with("sha1/ab/abcdef0123456789abcdef0123456789abcdef01"));
assert!(path.starts_with(cache.root()));
// SHA-256 单独一棵子树。
let sha256_path = cache.path_for(HashAlgo::Sha256, "00112233445566778899aabbccddeeff");
assert!(sha256_path.ends_with("sha256/00/00112233445566778899aabbccddeeff"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn insert_then_lookup_hits_without_rehashing() {
let dir = cache_dir("cache-hit");
let cache = ContentCache::open(&dir);
let payload = b"hello unlucky launcher".to_vec();
let integrity = Integrity::sha1(support::sha1_hex(&payload));
let source = dir.join("download.bin");
std::fs::write(&source, &payload).unwrap();
let cached = cache
.insert(&source, &integrity, NOW)
.expect("写入缓存失败");
assert!(cached.is_file());
assert_eq!(cache.len(), 1);
// 第二次命中:size/mtime 未变 → 记录信任,直接返回(不重算摘要)。
let hit = cache.lookup(&integrity, NOW + 10).expect("应命中缓存");
assert_eq!(hit, cached);
// 文件被删 → 记录作废。
std::fs::remove_file(&cached).unwrap();
assert!(cache.lookup(&integrity, NOW + 20).is_none());
assert_eq!(cache.len(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn truncated_cache_file_is_a_miss() {
let dir = cache_dir("cache-truncate");
let cache = ContentCache::open(&dir);
let payload = vec![7_u8; 256];
let integrity = Integrity::sha1(support::sha1_hex(&payload));
let source = dir.join("payload.bin");
std::fs::write(&source, &payload).unwrap();
let cached = cache.insert(&source, &integrity, NOW).unwrap();
// 半损坏(长度变了)必须立刻视为未命中。
std::fs::write(&cached, &payload[..128]).unwrap();
assert!(cache.lookup(&integrity, NOW + 1).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn metadata_change_forces_a_rehash() {
let dir = cache_dir("cache-rehash");
let cache = ContentCache::open(&dir);
let payload = vec![3_u8; 128];
let integrity = Integrity::sha1(support::sha1_hex(&payload));
let source = dir.join("data.bin");
std::fs::write(&source, &payload).unwrap();
let cached = cache.insert(&source, &integrity, NOW).unwrap();
// 硬链接/复制都可能让源文件与缓存同 inode,先断开再改缓存内容。
let _ = std::fs::remove_file(&source);
// 等 mtime 变化(部分文件系统时间戳精度 10ms)。
std::thread::sleep(Duration::from_millis(30));
let mut tampered = payload.clone();
tampered[0] = 9;
std::fs::write(&cached, &tampered).unwrap();
// 内容变了 → 摘要不符 → 记录作废。
assert!(cache.lookup(&integrity, NOW + 100).is_none());
assert_eq!(cache.len(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn lookup_verified_rejects_wrong_content_with_same_size() {
let dir = cache_dir("cache-verified");
let cache = ContentCache::open(&dir);
let payload = vec![1_u8; 64];
let integrity = Integrity::sha1(support::sha1_hex(&payload));
// 直接放到缓存位但内容是错的(模拟外部篡改 / 镜像写坏)。
let path = cache.path_for(integrity.algo, &integrity.hex);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, vec![2_u8; 64]).unwrap();
cache.register(&path, &integrity, NOW).unwrap();
assert!(
cache.lookup_verified(&integrity, NOW + 1).is_none(),
"强制校验必须发现内容不符"
);
// 内容对了就能通过。
std::fs::write(&path, &payload).unwrap();
cache.register(&path, &integrity, NOW).unwrap();
assert!(cache.lookup_verified(&integrity, NOW + 2).is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn index_persists_across_reopen() {
let dir = cache_dir("cache-persist");
let payload = vec![5_u8; 32];
let integrity = Integrity::sha1(support::sha1_hex(&payload));
{
let cache = ContentCache::open(&dir);
let source = dir.join("src.bin");
std::fs::write(&source, &payload).unwrap();
cache.insert(&source, &integrity, NOW).unwrap();
cache.persist().expect("持久化索引失败");
assert_eq!(cache.stats().entries, 1);
assert_eq!(cache.stats().total_bytes, 32);
}
let reopened = ContentCache::open(&dir);
assert_eq!(reopened.len(), 1);
assert!(reopened.lookup(&integrity, NOW + 1).is_some());
let _ = std::fs::remove_dir_all(&dir);
}
+375
View File
@@ -0,0 +1,375 @@
//! 分块规划与 Range 语义的单测(纯函数)+ 真跑 4 连接的端到端用例(mock 源)。
mod support;
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::{
content_range_matches, parse_content_range, plan, range_support, ByteRange, ChunkedConfig,
ContentRange, RangeSupport, MAX_CONNECTIONS,
};
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::types::{DownloadRequest, ResourceKind};
use support::{sha1_hex, temp_dir, MockResponse, MockServer};
#[test]
fn plan_partitions_whole_file_without_gaps() {
let cases = [
(1_u64, 4_usize),
(3, 4),
(100, 4),
(1000, 7),
(123_456_789, 4),
];
for (total, connections) in cases {
let ranges = plan(total, connections);
assert!(!ranges.is_empty(), "total={total} 不能切出空集");
assert_eq!(ranges[0].start, 0, "必须从 0 开始");
assert_eq!(ranges.last().unwrap().end, total - 1, "必须覆盖到末尾");
let mut next = 0_u64;
for range in &ranges {
assert_eq!(range.start, next, "段之间不能有空洞或重叠");
assert!(!range.is_empty());
next = range.end + 1;
}
assert_eq!(next, total);
assert_eq!(ranges.iter().map(ByteRange::len).sum::<u64>(), total);
}
}
#[test]
fn plan_caps_connections_and_handles_edge_cases() {
assert!(plan(0, 4).is_empty());
// 文件比连接数还小:每段 1 字节。
let tiny = plan(3, 4);
assert_eq!(tiny.len(), 3);
assert!(tiny.iter().all(|range| range.len() == 1));
// 连接数被夹到上限。
let many = plan(1_000_000, 100);
assert_eq!(many.len(), MAX_CONNECTIONS);
// 单连接。
assert_eq!(plan(120, 1), vec![ByteRange::new(0, 119)]);
}
#[test]
fn byte_range_headers_are_closed_intervals() {
let range = ByteRange::new(100, 199);
assert_eq!(range.len(), 100);
assert_eq!(range.header_value(), "bytes=100-199");
assert!(ByteRange::new(10, 9).is_empty());
assert_eq!(ByteRange::new(10, 9).len(), 0);
}
#[test]
fn parse_content_range_accepts_servers_forms() {
assert_eq!(
parse_content_range("bytes 0-99/1000"),
Some(ContentRange {
start: 0,
end: 99,
total: Some(1000)
})
);
assert_eq!(
parse_content_range(" bytes 100-199/* "),
Some(ContentRange {
start: 100,
end: 199,
total: None
})
);
// 畸形一律 None:调用方据此退化。
assert_eq!(parse_content_range("bytes 100-99/1000"), None);
assert_eq!(parse_content_range("items 0-9/10"), None);
assert_eq!(parse_content_range("bytes 0-9"), None);
assert_eq!(parse_content_range(""), None);
}
#[test]
fn range_support_matrix() {
assert_eq!(range_support(Some("bytes"), 200), RangeSupport::Supported);
assert_eq!(range_support(None, 206), RangeSupport::Supported);
assert_eq!(range_support(Some("none"), 200), RangeSupport::Unsupported);
assert_eq!(
range_support(Some("crumbs"), 200),
RangeSupport::Unsupported
);
assert_eq!(range_support(None, 200), RangeSupport::Unknown);
}
#[test]
fn chunked_config_gate() {
let config = ChunkedConfig::default();
assert!(config.enabled);
assert_eq!(config.connections, 4);
// 只有大文件 + 大资源类型才分块。
assert!(config.applies_to(ResourceKind::JavaRuntime, Some(200 * 1024 * 1024)));
assert!(!config.applies_to(ResourceKind::JavaRuntime, Some(1024)));
assert!(!config.applies_to(ResourceKind::ClientJar, Some(200 * 1024 * 1024)));
assert!(!config.applies_to(ResourceKind::JavaRuntime, None));
// 设置项关掉后一律不分块。
assert!(!ChunkedConfig::disabled().applies_to(ResourceKind::JavaRuntime, Some(u64::MAX)));
assert_eq!(
ChunkedConfig {
connections: 999,
..ChunkedConfig::default()
}
.effective_connections(),
MAX_CONNECTIONS
);
}
/// 端到端:`min_size` 调到 1KiB 后,20KiB 的 JDK 类资源真的被切成 4 段并行取。
///
/// 断言三件事:①mock 源收到 4 次带 `Range` 的请求(不是 1 次)②拼装后的文件逐字节正确
/// ③流式 sha1 校验通过(证明分块乱序写没有错位)。
#[tokio::test]
async fn downloads_large_file_over_four_parallel_ranges() {
let payload: Vec<u8> = (0..20 * 1024).map(|index| (index % 251) as u8).collect();
let server = MockServer::start(vec![
MockResponse::ok(payload.clone()),
MockResponse::ok(payload.clone()),
MockResponse::ok(payload.clone()),
MockResponse::ok(payload.clone()),
])
.await;
let url = server.url("/jdk.zip");
let dir = temp_dir("chunked-e2e");
let engine = DownloadEngine::new(
EngineConfig {
max_attempts_per_source: 1,
backoff: BackoffPolicy {
base_ms: 1,
max_ms: 2,
jitter_ratio: 0.0,
max_attempts: 1,
},
read_timeout: Duration::from_secs(5),
connect_timeout: Duration::from_secs(5),
// 真开分块:4 条连接,1KiB 以上就切。
chunked: ChunkedConfig {
enabled: true,
connections: 4,
min_size: 1024,
},
..EngineConfig::default()
},
Arc::new(MirrorTable::builtin()),
Arc::new(RegionProfile::direct(0)),
Arc::new(HealthRegistry::in_memory()),
Arc::new(ContentCache::open(dir.join("cache"))),
);
let dest = dir.join("jdk.zip");
let outcome = engine
.download(
DownloadRequest::new(ResourceKind::JavaRuntime, url.as_str(), &dest)
.with_sha1(sha1_hex(&payload))
.with_size(payload.len() as u64),
)
.await
.expect("分块下载失败");
assert_eq!(outcome.bytes, payload.len() as u64);
assert_eq!(outcome.source, "official");
assert_eq!(
server.hits(),
4,
"4 条连接应各发一次带 Range 的请求(退化单连接会只有 1 次)"
);
assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload);
assert!(
support::part_files(&dir).is_empty(),
"中间文件必须在提交后消失:{:?}",
support::part_files(&dir)
);
server.stop();
}
/// 端到端:目标目录**还不存在**时分块下载也要能落盘。
///
/// 回归用例。分块路径先 `OpenOptions` 预分配整文件(不走 `PartFile::open_append`
/// 那层建目录),所以「下到新建目录」曾让所有候选源瞬间全部失败
/// (现场:`data/java-managed/.cache/` 未创建时装 JDK 六个源全挂)。
#[tokio::test]
async fn chunked_download_creates_the_destination_directory() {
let payload: Vec<u8> = (0..8 * 1024).map(|index| (index % 31) as u8).collect();
let responses = vec![
MockResponse::ok(payload.clone()),
MockResponse::ok(payload.clone()),
MockResponse::ok(payload.clone()),
MockResponse::ok(payload.clone()),
];
let server = MockServer::start(responses).await;
let url = server.url("/jdk.zip");
let dir = temp_dir("chunked-mkdir");
let engine = DownloadEngine::new(
EngineConfig {
max_attempts_per_source: 1,
backoff: BackoffPolicy {
base_ms: 1,
max_ms: 2,
jitter_ratio: 0.0,
max_attempts: 1,
},
read_timeout: Duration::from_secs(5),
connect_timeout: Duration::from_secs(5),
chunked: ChunkedConfig {
enabled: true,
connections: 4,
min_size: 1024,
},
..EngineConfig::default()
},
Arc::new(MirrorTable::builtin()),
Arc::new(RegionProfile::direct(0)),
Arc::new(HealthRegistry::in_memory()),
Arc::new(ContentCache::open(dir.join("cache"))),
);
let dest = dir.join("java-managed/.cache/jdk.zip");
assert!(
!dest.parent().expect("父目录").exists(),
"前置条件:目录不存在"
);
let outcome = engine
.download(
DownloadRequest::new(ResourceKind::JavaRuntime, url.as_str(), &dest)
.with_sha1(sha1_hex(&payload))
.with_size(payload.len() as u64),
)
.await
.expect("分块下载应成功");
assert_eq!(outcome.bytes, payload.len() as u64);
assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload);
server.stop();
}
/// 端到端:服务器声明支持 Range 但实际无视(返回 200)→ 自动退化单连接,结果仍然正确。
#[tokio::test]
async fn falls_back_to_single_connection_when_server_ignores_range() {
let payload: Vec<u8> = (0..8 * 1024).map(|index| (index % 97) as u8).collect();
// 每个请求都会拿到 200 + 整份内容(range_support = false)。
// 响应条数给足:4 个分块请求可能都发出去才被 abort,退化后的整份请求是第 5 条。
let no_range = || MockResponse::ok(payload.clone()).without_range();
let server = MockServer::start(vec![
no_range(),
no_range(),
no_range(),
no_range(),
no_range(),
no_range(),
])
.await;
let url = server.url("/client.jar");
let dir = temp_dir("chunked-fallback");
let engine = DownloadEngine::new(
EngineConfig {
max_attempts_per_source: 1,
backoff: BackoffPolicy::none(),
chunked: ChunkedConfig {
enabled: true,
connections: 4,
min_size: 1024,
},
..EngineConfig::default()
},
Arc::new(MirrorTable::builtin()),
Arc::new(RegionProfile::direct(0)),
Arc::new(HealthRegistry::in_memory()),
Arc::new(ContentCache::open(dir.join("cache"))),
);
let dest = dir.join("client.jar");
let outcome = engine
.download(
DownloadRequest::new(ResourceKind::JavaRuntime, url.as_str(), &dest)
.with_sha1(sha1_hex(&payload))
.with_size(payload.len() as u64),
)
.await
.expect("退化后应仍能下载成功");
assert_eq!(outcome.bytes, payload.len() as u64);
assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload);
server.stop();
}
#[test]
fn content_range_must_match_requested_segment() {
let requested = ByteRange::new(5120, 10239);
// 严格一致才放行。
assert!(content_range_matches(
requested,
Some("bytes 5120-10239/20480")
));
// 起止任一不符 → 判失败(否则「长度恰好但位置错」的响应会拼出坏文件)。
assert!(!content_range_matches(
requested,
Some("bytes 0-5119/20480")
));
assert!(!content_range_matches(
requested,
Some("bytes 5120-20479/20480")
));
// 畸形头判失败。
assert!(!content_range_matches(requested, Some("garbage")));
// 缺头从宽(长度校验仍兜底)。
assert!(content_range_matches(requested, None));
}
/// 端到端:服务器回 206 但 `Content-Range` 谎报段位置 → 分块路径判不符、丢弃中间文件、
/// 退化单连接重下,最终文件仍逐字节正确(不会交付拼错的文件)。
#[tokio::test]
async fn rejects_lying_content_range_and_falls_back() {
let payload: Vec<u8> = (0..8 * 1024).map(|index| (index % 89) as u8).collect();
let lying = || MockResponse::ok(payload.clone()).with_wrong_content_range();
let server = MockServer::start(vec![
lying(),
lying(),
lying(),
lying(),
MockResponse::ok(payload.clone()),
])
.await;
let url = server.url("/jdk.zip");
let dir = temp_dir("chunked-lying-range");
let engine = DownloadEngine::new(
EngineConfig {
max_attempts_per_source: 1,
backoff: BackoffPolicy::none(),
chunked: ChunkedConfig {
enabled: true,
connections: 4,
min_size: 1024,
},
..EngineConfig::default()
},
Arc::new(MirrorTable::builtin()),
Arc::new(RegionProfile::direct(0)),
Arc::new(HealthRegistry::in_memory()),
Arc::new(ContentCache::open(dir.join("cache"))),
);
let dest = dir.join("jdk.zip");
let outcome = engine
.download(
DownloadRequest::new(ResourceKind::JavaRuntime, url.as_str(), &dest)
.with_sha1(sha1_hex(&payload))
.with_size(payload.len() as u64),
)
.await
.expect("谎报 Content-Range 后应退化单连接成功");
assert_eq!(outcome.bytes, payload.len() as u64);
assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload);
server.stop();
}
@@ -0,0 +1,350 @@
//! 引擎端到端(mock 源):成功 / 退避重试 / 换源 / 校验失败 / 缓存命中 / 断点续传。
//!
//! 用 `tests/download/support` 里的进程内 HTTP 服务器,不发真实网络请求。
mod support;
use std::path::PathBuf;
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::ChunkedConfig;
use unlucky_core::download::config::EngineConfig;
use unlucky_core::download::engine::DownloadEngine;
use unlucky_core::download::error::DownloadErrorKind;
use unlucky_core::download::health::HealthRegistry;
use unlucky_core::download::mirrors::{MirrorTable, PrefixRule};
use unlucky_core::download::progress::{ProgressEmitter, ProgressEvent};
use unlucky_core::download::region::RegionProfile;
use unlucky_core::download::resume::ResumeMeta;
use unlucky_core::download::types::{DownloadRequest, HashAlgo, ResourceKind};
use support::{sha1_hex, temp_dir, MockResponse, MockServer};
fn fast_config(attempts: u32) -> EngineConfig {
EngineConfig {
max_attempts_per_source: attempts,
backoff: BackoffPolicy {
base_ms: 1,
max_ms: 2,
jitter_ratio: 0.0,
max_attempts: attempts,
},
connect_timeout: Duration::from_secs(5),
read_timeout: Duration::from_secs(5),
chunked: ChunkedConfig::disabled(),
..EngineConfig::default()
}
}
struct Harness {
engine: DownloadEngine,
dir: PathBuf,
}
fn harness(cfg: EngineConfig, table: MirrorTable, region: RegionProfile, tag: &str) -> Harness {
let dir = temp_dir(tag);
let cache = Arc::new(ContentCache::open(dir.join("cache")));
let health = Arc::new(HealthRegistry::in_memory());
let engine = DownloadEngine::new(cfg, Arc::new(table), Arc::new(region), health, cache);
Harness { engine, dir }
}
fn direct_region() -> RegionProfile {
RegionProfile::direct(0)
}
#[tokio::test]
async fn downloads_verifies_and_leaves_no_part_file() {
let payload = b"unlucky launcher payload".to_vec();
let server = MockServer::start(vec![MockResponse::ok(payload.clone())]).await;
let url = server.url("/client.jar");
let harness = harness(
fast_config(2),
MirrorTable::builtin(),
direct_region(),
"engine-ok",
);
let dest = harness.dir.join("versions/1.20.1/client.jar");
let request = DownloadRequest::new(ResourceKind::ClientJar, url.as_str(), &dest)
.with_sha1(sha1_hex(&payload))
.with_size(payload.len() as u64);
let outcome = harness.engine.download(request).await.expect("下载应成功");
assert_eq!(outcome.bytes, payload.len() as u64);
assert!(!outcome.from_cache);
assert_eq!(outcome.resumed_from, 0);
assert_eq!(std::fs::read(&dest).unwrap(), payload);
let version_dir = harness.dir.join("versions/1.20.1");
assert!(
support::part_files(&version_dir).is_empty(),
"中间文件必须被提交掉:{:?}",
support::part_files(&version_dir)
);
server.stop();
}
#[tokio::test]
async fn retries_same_source_after_server_error() {
let payload = b"retry me".to_vec();
let server = MockServer::start(vec![
MockResponse::status(503),
MockResponse::ok(payload.clone()),
])
.await;
let url = server.url("/lib.jar");
let harness = harness(
fast_config(3),
MirrorTable::builtin(),
direct_region(),
"engine-retry",
);
let dest = harness.dir.join("lib.jar");
let outcome = harness
.engine
.download(
DownloadRequest::new(ResourceKind::Library, url.as_str(), &dest)
.with_sha1(sha1_hex(&payload)),
)
.await
.expect("退避后应成功");
assert_eq!(outcome.bytes, payload.len() as u64);
assert_eq!(server.hits(), 2, "第一次 503 之后应重试一次");
server.stop();
}
#[tokio::test]
async fn switches_to_official_when_mirror_keeps_failing() {
let payload = b"official wins".to_vec();
let official = MockServer::start(vec![MockResponse::ok(payload.clone())]).await;
let mirror = MockServer::start(vec![
MockResponse::status(500),
MockResponse::status(500),
MockResponse::status(500),
])
.await;
let mut table = MirrorTable::builtin();
let official_prefix = format!("http://127.0.0.1:{}", official.port());
let mirror_prefix = format!("http://localhost:{}", mirror.port());
table.add_custom_rule(PrefixRule::new(
"mock-mirror",
official_prefix,
mirror_prefix,
));
let harness = harness(
fast_config(2),
table,
RegionProfile::mirror_first(0),
"engine-switch",
);
let dest = harness.dir.join("client.jar");
let url = official.url("/client.jar");
let outcome = harness
.engine
.download(
DownloadRequest::new(ResourceKind::ClientJar, url.as_str(), &dest)
.with_sha1(sha1_hex(&payload)),
)
.await
.expect("镜像全挂后应切到官方");
assert_eq!(outcome.source, "official");
assert_eq!(std::fs::read(&dest).unwrap(), payload);
assert_eq!(mirror.hits(), 2, "每个源最多尝试 max_attempts 次");
official.stop();
mirror.stop();
}
#[tokio::test]
async fn rejects_wrong_checksum_and_falls_back() {
let payload = b"the real jar".to_vec();
let official = MockServer::start(vec![MockResponse::ok(payload.clone())]).await;
let mirror = MockServer::start(vec![MockResponse::ok(b"tampered by mirror".to_vec())]).await;
let mut table = MirrorTable::builtin();
table.add_custom_rule(PrefixRule::new(
"mock-mirror",
format!("http://127.0.0.1:{}", official.port()),
format!("http://localhost:{}", mirror.port()),
));
let harness = harness(
fast_config(1),
table,
RegionProfile::mirror_first(0),
"engine-checksum",
);
let dest = harness.dir.join("client.jar");
let url = official.url("/client.jar");
let outcome = harness
.engine
.download(
DownloadRequest::new(ResourceKind::ClientJar, url.as_str(), &dest)
.with_sha1(sha1_hex(&payload))
.with_size(payload.len() as u64),
)
.await
.expect("校验失败应换源而不是交付坏文件");
assert_eq!(outcome.source, "official");
assert_eq!(std::fs::read(&dest).unwrap(), payload);
assert!(mirror.hits() >= 1);
official.stop();
mirror.stop();
}
#[tokio::test]
async fn second_download_hits_content_cache_without_network() {
let payload = b"cached payload".to_vec();
let server = MockServer::start(vec![
MockResponse::ok(payload.clone()),
MockResponse::ok(payload.clone()),
])
.await;
let url = server.url("/lib.jar");
let harness = harness(
fast_config(2),
MirrorTable::builtin(),
direct_region(),
"engine-cache",
);
let integrity = sha1_hex(&payload);
let first = harness
.engine
.download(
DownloadRequest::new(
ResourceKind::Library,
url.as_str(),
harness.dir.join("a/lib.jar"),
)
.with_sha1(integrity.as_str()),
)
.await
.expect("首次下载失败");
assert!(!first.from_cache);
let hits_after_first = server.hits();
let second = harness
.engine
.download(
DownloadRequest::new(
ResourceKind::Library,
url.as_str(),
harness.dir.join("b/lib.jar"),
)
.with_sha1(integrity.as_str()),
)
.await
.expect("缓存命中失败");
assert!(second.from_cache);
assert_eq!(second.bytes, payload.len() as u64);
assert_eq!(server.hits(), hits_after_first, "缓存命中不能再发请求");
assert_eq!(
std::fs::read(harness.dir.join("b/lib.jar")).unwrap(),
payload
);
server.stop();
}
#[tokio::test]
async fn resumes_from_existing_part_file() {
let payload = b"0123456789abcdefghij".to_vec();
let split = 8_usize;
let server = MockServer::start(vec![MockResponse::ok(payload.clone())]).await;
let url = server.url("/client.jar");
let harness = harness(
fast_config(2),
MirrorTable::builtin(),
direct_region(),
"engine-resume",
);
let dest = harness.dir.join("client.jar");
let part = harness.dir.join("client.jar.ullpart");
let meta_path = harness.dir.join("client.jar.ullpart.ullmeta.json");
std::fs::write(&part, &payload[..split]).unwrap();
ResumeMeta {
url: url.clone(),
etag: Some("\"v1\"".to_string()),
last_modified: None,
total: Some(payload.len() as u64),
algo: HashAlgo::Sha1,
hash: sha1_hex(&payload),
saved_at_ms: 1,
}
.save(&meta_path)
.expect("写续传元数据失败");
let outcome = harness
.engine
.download(
DownloadRequest::new(ResourceKind::ClientJar, url.as_str(), &dest)
.with_sha1(sha1_hex(&payload))
.with_size(payload.len() as u64),
)
.await
.expect("续传失败");
assert_eq!(outcome.resumed_from, split as u64, "应从已有偏移续传");
assert_eq!(std::fs::read(&dest).unwrap(), payload);
assert_eq!(server.hits(), 1);
server.stop();
}
#[tokio::test]
async fn reports_progress_events_and_errors_when_all_sources_fail() {
let server = MockServer::start(vec![
MockResponse::status(500),
MockResponse::status(500),
MockResponse::status(500),
MockResponse::status(500),
])
.await;
let url = server.url("/dead.jar");
let dir = temp_dir("engine-fail");
let cache = Arc::new(ContentCache::open(dir.join("cache")));
let (emitter, mut events) = ProgressEmitter::channel();
let engine = DownloadEngine::new(
fast_config(1),
Arc::new(MirrorTable::builtin()),
Arc::new(direct_region()),
Arc::new(HealthRegistry::in_memory()),
cache,
)
.with_progress(emitter);
let error = engine
.download(DownloadRequest::new(
ResourceKind::Library,
url.as_str(),
dir.join("dead.jar"),
))
.await
.expect_err("所有源都 500 时必须失败");
assert_eq!(error.kind, DownloadErrorKind::AllSourcesFailed);
let mut started = false;
let mut failed_source = false;
while let Ok(event) = events.try_recv() {
match event {
ProgressEvent::Started { candidates, .. } => {
started = true;
assert!(candidates >= 1);
}
ProgressEvent::SourceFailed { will_retry, .. } => {
failed_source = true;
assert!(!will_retry, "单次尝试用尽后不应再声明重试");
}
_ => {}
}
}
assert!(started && failed_source, "进度事件缺失");
server.stop();
}
+268
View File
@@ -0,0 +1,268 @@
//! 健康度记忆的单测:冷却、恢复探测、排序、持久化 + 引擎驱动的落盘接线。
mod support;
use std::sync::Arc;
use std::time::Duration;
use unlucky_core::download::backoff::BackoffPolicy;
use unlucky_core::download::cache::{now_ms, ContentCache};
use unlucky_core::download::config::EngineConfig;
use unlucky_core::download::engine::DownloadEngine;
use unlucky_core::download::error::DownloadErrorKind;
use unlucky_core::download::health::{
HealthRegistry, SourceHealth, COOLDOWN_MS, FAILURE_THRESHOLD,
};
use unlucky_core::download::mirrors::MirrorTable;
use unlucky_core::download::region::RegionProfile;
use unlucky_core::download::types::{Candidate, DownloadRequest, ResourceKind, SOURCE_OFFICIAL};
use support::{MockResponse, MockServer};
const NOW: u64 = 1_000_000;
fn registry() -> HealthRegistry {
HealthRegistry::in_memory()
}
#[test]
fn fresh_source_is_neutral_not_penalized() {
let health = SourceHealth::default();
assert!(!health.is_cooling_down(NOW));
assert_eq!(health.failure_rate(), 0.0);
// 未知源必须是中间分,不能比刚成功的源更优先。
let healthy = SourceHealth {
samples: 3,
successes: 3,
ema_rtt_ms: 120.0,
..SourceHealth::default()
};
assert!(healthy.score(NOW) < health.score(NOW));
}
#[test]
fn three_consecutive_failures_trigger_cooldown() {
let mut health = SourceHealth::default();
for _ in 0..FAILURE_THRESHOLD {
health.record_failure("HTTP 502", NOW);
}
assert!(health.is_cooling_down(NOW));
assert!(health.is_cooling_down(NOW + COOLDOWN_MS - 1));
// 冷却到期后不再是冷却态,而是「可以探测」。
assert!(!health.is_cooling_down(NOW + COOLDOWN_MS));
assert!(health.is_probe(NOW + COOLDOWN_MS));
// 冷却中的源排序惩罚要明显高于健康源。
assert!(health.score(NOW) >= 1_000.0);
}
#[test]
fn missing_artifact_is_recorded_without_cooling_down() {
// 院校镜像只留最新补丁:请求旧补丁必然 404,不能把源判成「坏」。
let mut health = SourceHealth::default();
for _ in 0..(FAILURE_THRESHOLD * 2) {
health.record_missing("HTTP 404", NOW);
}
assert!(!health.is_cooling_down(NOW), "4xx 不进冷却");
assert!(!health.is_probe(NOW), "4xx 不该产生恢复探测状态");
assert_eq!(health.failures, 0, "4xx 不计失败次数");
assert_eq!(health.not_found, u64::from(FAILURE_THRESHOLD * 2));
assert_eq!(health.failure_rate(), 0.0);
assert!(health.last_error.is_some(), "诊断仍要留痕");
// 打分不应被 404 拉高(仍是未知源的中位分)。
assert_eq!(health.score(NOW), SourceHealth::default().score(NOW));
assert_eq!(
health.score(NOW),
unlucky_core::download::health::SCORE_UNKNOWN
);
}
#[test]
fn not_found_ageing_does_not_persist_a_cooldown() {
let registry = HealthRegistry::in_memory();
for _ in 0..10 {
registry.record_missing("tuna", ResourceKind::JavaRuntime, "HTTP 404", NOW);
}
let health = registry.health_of("tuna", ResourceKind::JavaRuntime);
assert!(!health.is_cooling_down(NOW));
assert_eq!(health.not_found, 10);
assert_eq!(health.consecutive_failures, 0);
}
#[test]
fn probe_failure_re_arms_cooldown_and_success_clears_it() {
let mut health = SourceHealth::default();
for _ in 0..FAILURE_THRESHOLD {
health.record_failure("timeout", NOW);
}
let after_cooldown = NOW + COOLDOWN_MS + 1;
assert!(!health.is_cooling_down(after_cooldown));
// 探测再一次失败 → 立即重新冷却。
health.record_failure("timeout again", after_cooldown);
assert!(health.is_cooling_down(after_cooldown));
// 成功则全部清零。
health.record_success(90, after_cooldown + 1);
assert!(!health.is_cooling_down(after_cooldown + 1));
assert!(!health.is_probe(after_cooldown + 1));
assert_eq!(health.consecutive_failures, 0);
assert_eq!(health.cooldown_until_ms, 0);
assert_eq!(health.last_error, None);
}
#[test]
fn ema_smooths_latency_and_counts_samples() {
let mut health = SourceHealth::default();
health.record_success(100, NOW);
assert_eq!(health.ema_rtt_ms, 100.0);
health.record_success(200, NOW);
// 0.3 * 200 + 0.7 * 100 = 130
assert!((health.ema_rtt_ms - 130.0).abs() < 1e-9);
assert_eq!(health.samples, 2);
assert_eq!(health.successes, 2);
}
#[test]
fn failure_rate_counts_both_sides() {
let mut health = SourceHealth::default();
health.record_success(50, NOW);
health.record_failure("boom", NOW);
assert!((health.failure_rate() - 0.5).abs() < 1e-9);
}
#[test]
fn registry_ranks_healthy_first_and_cooling_last() {
let registry = registry();
for _ in 0..FAILURE_THRESHOLD {
registry.record_failure("mirror-b", ResourceKind::ClientJar, "HTTP 500", NOW);
}
registry.record_success(
"mirror-a",
ResourceKind::ClientJar,
Duration::from_millis(80),
NOW,
);
let candidates = vec![
Candidate::new("mirror-b", "https://b.example/file"),
Candidate::new("unknown", "https://u.example/file"),
Candidate::new("mirror-a", "https://a.example/file"),
];
let ranked = registry.rank(&candidates, ResourceKind::ClientJar, NOW);
assert_eq!(ranked[0].source, "mirror-a");
assert_eq!(ranked[1].source, "unknown");
assert_eq!(
ranked[2].source, "mirror-b",
"冷却中的源排最后但仍保留为兜底"
);
}
#[test]
fn health_is_tracked_per_resource_kind() {
let registry = registry();
for _ in 0..FAILURE_THRESHOLD {
registry.record_failure("bmclapi", ResourceKind::Asset, "404", NOW);
}
assert!(registry.is_cooling_down("bmclapi", ResourceKind::Asset, NOW));
// 另一种资源类型不受影响(资产挂了不代表 JDK 也挂)。
assert!(!registry.is_cooling_down("bmclapi", ResourceKind::JavaRuntime, NOW));
}
#[test]
fn persistence_roundtrip_and_age_pruning() {
let dir = std::env::temp_dir().join(format!("ull-health-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("health.json");
let registry = HealthRegistry::open(&path, 7 * 24 * 3600 * 1000, NOW);
registry.record_failure("bmclapi", ResourceKind::Library, "HTTP 503", NOW);
registry.record_success(
"official",
ResourceKind::Library,
Duration::from_millis(55),
NOW,
);
registry.save().expect("持久化失败");
let reloaded = HealthRegistry::open(&path, 7 * 24 * 3600 * 1000, NOW + 1_000);
assert_eq!(reloaded.snapshot().len(), 2);
assert_eq!(
reloaded
.health_of("bmclapi", ResourceKind::Library)
.failures,
1
);
assert!(
reloaded
.health_of("official", ResourceKind::Library)
.ema_rtt_ms
> 0.0
);
// 超出最大保留期 → 丢弃旧记录(缓存目录被长时间放着的情况)。
let pruned = HealthRegistry::open(&path, 10, NOW + 60_000);
assert!(pruned.snapshot().is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
/// P1 回归:健康度必须由**引擎的生产路径**落盘——下载→(模拟进程退出)→重开 registry,
/// 冷却状态与失败计数都要还在,否则「连续 3 败冷却 10 分钟」每轮启动从零学起。
#[tokio::test]
async fn engine_persists_health_so_a_restart_keeps_the_cooldown() {
let failed = || MockResponse::status(500);
let server = MockServer::start(vec![failed(), failed(), failed(), failed()]).await;
let url = server.url("/lib.jar");
let dir = support::temp_dir("health-engine-persist");
let path = dir.join("cache/health.json");
let now = now_ms();
let max_age_ms = 7 * 24 * 3600 * 1000;
let registry = Arc::new(HealthRegistry::open(&path, max_age_ms, now));
let engine = DownloadEngine::new(
EngineConfig {
max_attempts_per_source: FAILURE_THRESHOLD,
backoff: BackoffPolicy {
base_ms: 1,
max_ms: 2,
jitter_ratio: 0.0,
max_attempts: FAILURE_THRESHOLD,
},
..EngineConfig::default()
},
Arc::new(MirrorTable::builtin()),
Arc::new(RegionProfile::direct(0)),
Arc::clone(&registry),
Arc::new(ContentCache::open(dir.join("cache"))),
);
let error = engine
.download(DownloadRequest::new(
ResourceKind::Library,
url.as_str(),
dir.join("lib.jar"),
))
.await
.expect_err("全 500 必须失败");
assert_eq!(error.kind, DownloadErrorKind::AllSourcesFailed);
server.stop();
// 第 3 次连续失败进入冷却时就该落盘(不等收尾 flush)。
assert!(path.is_file(), "健康度必须已经落盘到 cache/health.json");
let before = registry.health_of(SOURCE_OFFICIAL, ResourceKind::Library);
assert!(before.consecutive_failures >= FAILURE_THRESHOLD);
assert!(before.is_cooling_down(now_ms()));
// 「进程退出后重开」:从同一个文件恢复,冷却状态与失败计数都还在。
let reopened = HealthRegistry::open(&path, max_age_ms, now_ms());
let restored = reopened.health_of(SOURCE_OFFICIAL, ResourceKind::Library);
assert_eq!(restored.consecutive_failures, before.consecutive_failures);
assert_eq!(restored.failures, before.failures);
assert!(
restored.is_cooling_down(now_ms()),
"重开后仍应处于冷却:{restored:?}"
);
assert!(restored.last_error.is_some(), "失败原因也要持久化");
let _ = std::fs::remove_dir_all(&dir);
}
+281
View File
@@ -0,0 +1,281 @@
//! 镜像清单与前缀重写的单测(纯字符串,无网络)。
use unlucky_core::download::adoptium::{parse_adoptium_artifact, JdkFlavor};
use unlucky_core::download::mirrors::{host_of, source_id_for, MirrorTable, PrefixRule};
use unlucky_core::download::region::RegionProfile;
use unlucky_core::download::types::ResourceKind;
const LIBRARIES_URL: &str =
"https://libraries.minecraft.net/com/google/guava/guava/32.0.0/guava-32.0.0.jar";
const ASSET_URL: &str = "https://resources.download.minecraft.net/ab/abcdef0123456789";
const GITHUB_URL: &str =
"https://github.com/AskaEth/Unlucky-Launcher/releases/download/v1.0.0/ull-windows-x64.exe";
const ADOPTIUM_URL: &str = "https://github.com/adoptium/temurin17-binaries/releases/download/JDK17.0.13%2B11/OpenJDK17U-jdk_x64_windows_hotspot_17.0.13_11.zip";
#[test]
fn builtin_table_is_embedded_and_complete() {
let table = MirrorTable::builtin();
assert!(table.version >= 1);
assert!(table.rules.len() >= 10, "内置规则太少");
assert!(table.adoptium.len() >= 3, "院校镜像至少要 TUNA/USTC/SJTU");
assert!(!table.gh_proxies.is_empty());
assert!(table.gh_proxies[0].prefix.starts_with("https://gh-proxy"));
}
#[test]
fn prefix_rule_preserves_sub_path() {
let table = MirrorTable::builtin();
let rule = table
.prefix_rule_for(LIBRARIES_URL, ResourceKind::Library)
.expect("libraries 规则缺失");
let candidate = rule.apply(LIBRARIES_URL).expect("重写失败");
assert_eq!(candidate.source, "bmclapi");
assert_eq!(
candidate.url,
LIBRARIES_URL.replace(
"https://libraries.minecraft.net",
"https://bmclapi2.bangbang93.com"
)
);
}
#[test]
fn asset_rule_injects_path_prefix() {
let table = MirrorTable::builtin();
let candidate = table
.prefix_rule_for(ASSET_URL, ResourceKind::Asset)
.and_then(|rule| rule.apply(ASSET_URL))
.expect("资源对象规则缺失");
assert_eq!(
candidate.url,
"https://bmclapi2.bangbang93.com/assets/ab/abcdef0123456789"
);
}
#[test]
fn direct_mode_puts_official_first() {
let table = MirrorTable::builtin();
let region = RegionProfile::direct(0);
let candidates = table.expand(LIBRARIES_URL, ResourceKind::Library, &region);
assert_eq!(candidates[0].source, "official");
assert_eq!(candidates[0].url, LIBRARIES_URL);
// 镜像仍然保留为兜底(官方失败时能救回来)。
assert!(candidates
.iter()
.any(|candidate| candidate.source == "bmclapi"));
}
#[test]
fn mirror_first_mode_puts_mirror_first() {
let table = MirrorTable::builtin();
let region = RegionProfile::mirror_first(0);
let candidates = table.expand(LIBRARIES_URL, ResourceKind::Library, &region);
assert_eq!(candidates[0].source, "bmclapi");
assert_eq!(
candidates.last().map(|c| c.source.as_str()),
Some("official")
);
}
#[test]
fn gh_proxy_only_for_github_hosts_in_domestic_mode() {
let table = MirrorTable::builtin();
let domestic = RegionProfile::mirror_first(0);
let direct = RegionProfile::direct(0);
let github = table.expand(GITHUB_URL, ResourceKind::GitHubRelease, &domestic);
assert!(
github.iter().any(|candidate| candidate.source == "gh_proxy"
&& candidate.url.starts_with("https://gh-proxy.com/")),
"国内模式下 GitHub 资源必须套 gh-proxy:{github:?}"
);
let non_github = table.expand(LIBRARIES_URL, ResourceKind::Library, &domestic);
assert!(
non_github
.iter()
.all(|candidate| candidate.source != "gh_proxy"),
"非 GitHub 资源不受 gh-proxy 影响"
);
let github_direct = table.expand(GITHUB_URL, ResourceKind::GitHubRelease, &direct);
assert!(github_direct
.iter()
.all(|candidate| candidate.source != "gh_proxy"));
}
#[test]
fn pinned_or_disabled_gh_proxy_is_respected() {
let table = MirrorTable::builtin();
// 用户关掉:地区档案显式不带前缀 → 一条 gh_proxy 候选都不出。
let off = RegionProfile::mirror_first(0).with_gh_proxy(None);
let candidates = table.expand(GITHUB_URL, ResourceKind::GitHubRelease, &off);
assert!(candidates.iter().all(|c| c.source != "gh_proxy"));
// 自定义前缀(设置项写进地区档案)。
let custom = RegionProfile::mirror_first(0).with_gh_proxy(Some("https://proxy.example/"));
let candidates = table.expand(GITHUB_URL, ResourceKind::GitHubRelease, &custom);
let proxy = candidates
.iter()
.find(|candidate| candidate.source == "gh_proxy")
.expect("自定义前缀未生效");
assert_eq!(proxy.url, format!("https://proxy.example/{GITHUB_URL}"));
// 清单给设置做默认值。
assert_eq!(table.default_gh_proxy(), Some("https://gh-proxy.com/"));
}
#[test]
fn adoptium_artifact_parsing_and_mirror_ordering() {
let parsed = parse_adoptium_artifact(ADOPTIUM_URL).expect("解析 Temurin 产物名失败");
assert_eq!(parsed.major, "17");
assert_eq!(parsed.flavor, JdkFlavor::Jdk);
assert_eq!(parsed.arch, "x64");
assert_eq!(parsed.os, "windows");
assert!(parsed.filename.starts_with("OpenJDK17U-jdk_x64_windows"));
let table = MirrorTable::builtin();
let region = RegionProfile::mirror_first(0);
let candidates = table.expand(ADOPTIUM_URL, ResourceKind::JavaRuntime, &region);
let first = &candidates[0];
assert_eq!(first.source, "tuna");
assert!(
first
.url
.starts_with("https://mirrors.tuna.tsinghua.edu.cn/Adoptium/17/jdk/x64/windows/"),
"院校镜像路径不符合模板:{}",
first.url
);
// 官方仍在候选里(全部镜像不可达时的兜底)。
assert!(candidates
.iter()
.any(|candidate| candidate.source == "official"));
}
/// JRE 产物必须落到镜像的 `/jre/` 子树(否则 4 个镜像全 404,加速白费)。
#[test]
fn adoptium_jre_uses_jre_path_segment() {
let jre_url = ADOPTIUM_URL.replace("OpenJDK17U-jdk_", "OpenJDK17U-jre_");
let parsed = parse_adoptium_artifact(&jre_url).expect("解析 JRE 产物名失败");
assert_eq!(parsed.flavor, JdkFlavor::Jre);
assert_eq!(
parsed.mirror_url("https://mirrors.tuna.tsinghua.edu.cn/Adoptium"),
format!(
"https://mirrors.tuna.tsinghua.edu.cn/Adoptium/17/jre/x64/windows/{}",
parsed.filename
)
);
let table = MirrorTable::builtin();
let candidates = table.expand(
&jre_url,
ResourceKind::JavaRuntime,
&RegionProfile::mirror_first(0),
);
let first = &candidates[0];
assert_eq!(first.source, "tuna");
assert!(
first.url.contains("/17/jre/x64/windows/"),
"JRE 必须走 /jre/ 子树:{}",
first.url
);
// `-jre-17…` 这种兜底形态同样识别。
let fallback = "https://github.com/adoptium/temurin21-binaries/releases/download/jre-21.0.5%2B11/OpenJDK21U-jre_x64_linux_hotspot_21.0.5_11.tar.gz";
assert_eq!(
parse_adoptium_artifact(fallback).map(|artifact| artifact.flavor),
Some(JdkFlavor::Jre)
);
}
#[test]
fn custom_rules_take_precedence() {
let mut table = MirrorTable::builtin();
table.add_custom_rule(PrefixRule::new(
"my-mirror",
"https://libraries.minecraft.net",
"https://mirror.example/libraries",
));
let candidate = table
.prefix_rule_for(LIBRARIES_URL, ResourceKind::Library)
.and_then(|rule| rule.apply(LIBRARIES_URL))
.expect("自定义规则未生效");
assert_eq!(candidate.source, "mirror.example");
assert_eq!(
candidate.url,
"https://mirror.example/libraries/com/google/guava/guava/32.0.0/guava-32.0.0.jar"
);
}
#[test]
fn fallback_rule_keeps_official_first_even_in_domestic_mode() {
let mut table = MirrorTable {
version: 1,
notes: Vec::new(),
rules: vec![PrefixRule::new(
"modrinth-accelerator",
"https://api.modrinth.com",
"https://cdn.example",
)
.fallback()],
// v3 新增的三张 JDK 源表在这张夹具里必须为空(否则会混进别的候选)。
adoptium: Vec::new(),
oracle_openjdk: Vec::new(),
microsoft_openjdk: None,
zulu: None,
gh_proxies: Vec::new(),
};
let region = RegionProfile::mirror_first(0);
let candidates = table.expand(
"https://api.modrinth.com/v2/project/sodium",
ResourceKind::ModFile,
&region,
);
assert_eq!(
candidates[0].source, "official",
"「仅加速」规则不能顶掉官方"
);
assert_eq!(candidates[1].source, "cdn.example");
// 关掉规则后只剩官方一条。
table.rules.clear();
let candidates = table.expand(
"https://api.modrinth.com/v2/project/sodium",
ResourceKind::ModFile,
&region,
);
assert_eq!(candidates.len(), 1);
}
#[test]
fn host_and_source_id_helpers() {
assert_eq!(
host_of("https://libraries.minecraft.net/a/b"),
Some("libraries.minecraft.net")
);
assert_eq!(
host_of("https://user:pass@mirror.example:8443/x/y"),
Some("mirror.example")
);
assert_eq!(host_of("http://127.0.0.1:8080/file"), Some("127.0.0.1"));
assert_eq!(host_of("not-a-url"), Some("not-a-url"));
assert_eq!(
source_id_for("https://bmclapi2.bangbang93.com/assets/ab/cd"),
"bmclapi"
);
assert_eq!(
source_id_for("https://mirrors.tuna.tsinghua.edu.cn/Adoptium/17/x.zip"),
"tuna"
);
assert_eq!(
source_id_for("https://mirrors.ustc.edu.cn/adoptium/17/x.zip"),
"ustc"
);
assert_eq!(
source_id_for("https://gh-proxy.com/https://github.com/a/b"),
"gh_proxy"
);
assert_eq!(source_id_for("https://example.org/file"), "example.org");
}
+233
View File
@@ -0,0 +1,233 @@
//! `download::atomic::PartFile`:中间文件的进程/实例隔离、续传收养与孤儿清理。
//!
//! 背景(M35 真机 finding):中间文件路径原先只由目标路径决定,两个启动器进程装同一
//! 数据根时共用一个 `.ullpart`,先提交的一方 `rename` 走,另一方 `os error 2`
//! (实测两次 `ull install` 各失败 819 / 1116 个资源)。这里守住三条:名字带会话标识、
//! 旧材料能收养、孤儿清理不许误删有人在用的中间文件。
mod support;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
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::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::types::{DownloadRequest, ResourceKind};
use unlucky_core::download::{PartFile, ADOPT_MIN_AGE, META_SUFFIX, ORPHAN_MAX_AGE, PART_SUFFIX};
use support::{age_file, part_files, sha1_hex, temp_dir, MockResponse, MockServer};
/// 另一个会话号(保证与本进程不同)。
fn foreign_session() -> u32 {
std::process::id() ^ 1
}
fn part_name(name: &str, session: u32, seq: u64) -> String {
format!("{name}{PART_SUFFIX}-{session}-{seq}")
}
fn write_part(dir: &Path, name: &str) -> PathBuf {
let path = dir.join(name);
fs::write(&path, b"0123456789").expect("写中间文件失败");
path
}
#[test]
fn part_file_names_carry_session_and_sequence() {
let dir = temp_dir("partfile-identity");
let dest = dir.join("client.jar");
let first = PartFile::for_dest(&dest);
let second = PartFile::for_dest(&dest);
assert_ne!(
first.path(),
second.path(),
"同一 dest 的两个守卫不能共用文件"
);
assert_eq!(first.path().parent(), Some(dir.as_path()));
let name = first.path().file_name().unwrap().to_string_lossy();
assert!(
name.starts_with(&format!("client.jar{PART_SUFFIX}-")),
"中间文件名要带进程标识:{name}"
);
assert!(
name.contains(&std::process::id().to_string()),
"进程号要在名字里(跨进程隔离的依据):{name}"
);
}
#[test]
fn two_threads_writing_the_same_dest_commit_independently() {
let dir = temp_dir("partfile-threads");
let dest = dir.join("client.jar");
let handles: Vec<_> = (0..2_u8)
.map(|index| {
let dest = dest.clone();
std::thread::spawn(move || {
let payload = vec![b'a' + index; 4096];
let part = PartFile::for_dest(&dest);
part.open_truncate()
.expect("打开中间文件失败")
.write_all(&payload)
.expect("写中间文件失败");
part.commit(&dest).expect("原子落盘失败");
payload
})
})
.collect();
let payloads: Vec<Vec<u8>> = handles
.into_iter()
.map(|handle| handle.join().expect("下载线程 panic"))
.collect();
let written = fs::read(&dest).expect("读落盘文件失败");
assert!(
payloads.contains(&written),
"落盘内容必须是某个线程的完整产物"
);
assert!(
part_files(&dir).is_empty(),
"两线程各自的中间文件都要被提交掉:{:?}",
part_files(&dir)
);
}
#[test]
fn legacy_part_file_is_adopted_for_resume() {
let dir = temp_dir("partfile-legacy");
let dest = dir.join("client.jar");
let legacy = write_part(&dir, &format!("client.jar{PART_SUFFIX}"));
let legacy_meta = dir.join(format!("client.jar{PART_SUFFIX}{META_SUFFIX}"));
fs::write(&legacy_meta, b"{}").expect("写元数据失败");
let part = PartFile::for_dest(&dest);
assert_ne!(part.path(), legacy.as_path(), "新守卫要换自己的名字");
assert_eq!(part.len(), 10, "旧版中间文件必须被收养(续传语义不变)");
assert!(!legacy.exists(), "收养后不许留下旧名字");
assert!(!legacy_meta.exists(), "元数据要跟着中间文件一起搬");
assert!(part.meta_path().is_file(), "收养后的元数据路径要就位");
}
#[test]
fn foreign_part_file_is_adopted_only_after_it_cools_down() {
let dir = temp_dir("partfile-foreign");
let dest = dir.join("client.jar");
let foreign = write_part(&dir, &part_name("client.jar", foreign_session(), 1));
let fresh = PartFile::for_dest(&dest);
assert!(!fresh.exists(), "外进程刚写过的中间文件不能抢");
assert!(foreign.is_file(), "更不能删(可能正被那个进程写着)");
age_file(&foreign, ADOPT_MIN_AGE + Duration::from_secs(60));
let adopted = PartFile::for_dest(&dest);
assert_eq!(adopted.len(), 10, "静置够久的外来中间文件应被收养续传");
assert!(!foreign.exists(), "收养后旧名字应消失");
}
#[test]
fn sweeps_ownerless_orphans_but_keeps_fresh_and_live_files() {
let dir = temp_dir("partfile-sweep");
let stale = write_part(&dir, &format!("other.jar{PART_SUFFIX}"));
let stale_meta = dir.join(format!("other.jar{PART_SUFFIX}{META_SUFFIX}"));
fs::write(&stale_meta, b"{}").expect("写元数据失败");
age_file(&stale, ORPHAN_MAX_AGE + Duration::from_secs(60));
let fresh = write_part(&dir, &format!("fresh.jar{PART_SUFFIX}"));
let _guard = PartFile::for_dest(&dir.join("client.jar"));
assert!(!stale.exists(), "无主且超龄的中间文件应被清理");
assert!(!stale_meta.exists(), "它的元数据也要一起清");
assert_eq!(
fs::read(&fresh).unwrap().len(),
10,
"还新鲜的无主文件不能删"
);
}
#[test]
fn keeps_another_live_guard_of_the_same_dest() {
let dir = temp_dir("partfile-live");
let dest = dir.join("client.jar");
let running = PartFile::for_dest(&dest);
running
.open_truncate()
.expect("打开中间文件失败")
.write_all(&[b'x'; 1024])
.expect("写中间文件失败");
let other = PartFile::for_dest(&dest);
assert!(
running.path().is_file(),
"有人在用的中间文件不能被清理或收养"
);
assert_eq!(running.len(), 1024);
assert_ne!(other.path(), running.path());
}
fn fast_engine(cache: &Path) -> DownloadEngine {
DownloadEngine::new(
EngineConfig {
max_attempts_per_source: 1,
backoff: BackoffPolicy {
base_ms: 1,
max_ms: 2,
jitter_ratio: 0.0,
max_attempts: 1,
},
read_timeout: Duration::from_secs(5),
connect_timeout: Duration::from_secs(5),
chunked: ChunkedConfig::disabled(),
..EngineConfig::default()
},
Arc::new(MirrorTable::builtin()),
Arc::new(RegionProfile::direct(0)),
Arc::new(HealthRegistry::in_memory()),
Arc::new(ContentCache::open(cache)),
)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn two_engines_downloading_the_same_dest_do_not_share_parts() {
let payload = b"same destination payload".to_vec();
let server = MockServer::start(vec![
MockResponse::ok(payload.clone()),
MockResponse::ok(payload.clone()),
])
.await;
let url = server.url("/client.jar");
let dir = temp_dir("partfile-engine");
let dest = dir.join("client.jar");
// 两份独立的缓存目录:否则后到的那次会直接吃内容缓存,绕过中间文件路径。
let left = fast_engine(&dir.join("cache-a"));
let right = fast_engine(&dir.join("cache-b"));
let request = || {
DownloadRequest::new(ResourceKind::ClientJar, url.as_str(), &dest)
.with_sha1(sha1_hex(&payload))
.with_size(payload.len() as u64)
};
let (first, second) = tokio::join!(left.download(request()), right.download(request()));
first.expect("并行下载 A 失败");
second.expect("并行下载 B 失败");
assert_eq!(fs::read(&dest).expect("读落盘文件失败"), payload);
assert!(
part_files(&dir).is_empty(),
"并发下载不能留下中间文件:{:?}",
part_files(&dir)
);
server.stop();
}
+121
View File
@@ -0,0 +1,121 @@
//! 地区判定的单测:只测纯判定与缓存,不真发探测请求。
use unlucky_core::download::region::{
decide_from, load_cached, store_cached, RegionMode, RegionProfile, RegionTrigger, Signals,
DECISION_TTL_MS, DEFAULT_GH_PROXY,
};
fn signals(timezone: Option<&str>, language: Option<&str>, google: Option<bool>) -> Signals {
Signals {
timezone: timezone.map(str::to_string),
languages: language.map(str::to_string).into_iter().collect(),
google_reachable: google,
baidu_reachable: None,
}
}
#[test]
fn timezone_signal_detects_greater_china() {
assert!(signals(Some("Asia/Shanghai"), None, None).is_greater_china());
assert!(signals(Some("Asia/Taipei"), None, None).is_greater_china());
// 大小写/空白容错。
assert!(signals(Some(" asia/hong_kong "), None, None).is_greater_china());
assert!(!signals(Some("Europe/Berlin"), None, None).is_greater_china());
}
#[test]
fn language_signal_handles_posix_and_bcp47() {
assert!(signals(None, Some("zh_CN.UTF-8"), None).is_greater_china());
assert!(signals(None, Some("zh-Hant-TW"), None).is_greater_china());
assert!(!signals(None, Some("en_US.UTF-8"), None).is_greater_china());
// 时区不命中、语言命中也算。
assert!(signals(Some("UTC"), Some("zh_TW"), None).is_greater_china());
}
#[test]
fn domestic_without_google_goes_mirror_first() {
let profile = decide_from(&signals(Some("Asia/Shanghai"), None, Some(false)), 1);
assert_eq!(profile.mode, RegionMode::MirrorFirst);
assert!(profile.greater_china);
assert_eq!(profile.trigger, RegionTrigger::Auto);
assert_eq!(profile.gh_proxy.as_deref(), Some(DEFAULT_GH_PROXY));
assert!(profile.gh_proxy_enabled());
}
#[test]
fn domestic_with_google_reachable_stays_direct() {
// 已有全局代理:尊重用户代理,不再插镜像。
let profile = decide_from(&signals(Some("Asia/Shanghai"), None, Some(true)), 1);
assert_eq!(profile.mode, RegionMode::Direct);
assert!(profile.gh_proxy.is_none());
assert!(!profile.gh_proxy_enabled());
}
#[test]
fn non_greater_china_is_always_direct() {
for google in [Some(true), Some(false), None] {
let profile = decide_from(&signals(Some("America/New_York"), Some("en-US"), google), 1);
assert_eq!(profile.mode, RegionMode::Direct, "google={google:?}");
assert!(!profile.greater_china);
}
}
#[test]
fn probes_disabled_defaults_to_mirror_first_for_chinese_env() {
let profile = decide_from(&signals(None, Some("zh_CN.UTF-8"), None), 1);
assert_eq!(profile.mode, RegionMode::MirrorFirst);
assert!(!profile.probes_enabled);
}
#[test]
fn pinned_mode_never_goes_stale() {
let pinned = RegionProfile::pinned(RegionMode::Direct, 10);
assert_eq!(pinned.trigger, RegionTrigger::UserPinned);
assert!(!pinned.is_stale(10 + DECISION_TTL_MS * 10));
let auto = decide_from(&signals(Some("Asia/Shanghai"), None, Some(false)), 10);
assert!(!auto.is_stale(10 + 1_000));
assert!(auto.is_stale(10 + DECISION_TTL_MS + 1));
}
#[test]
fn gh_proxy_helpers() {
let mut profile = RegionProfile::mirror_first(0);
assert!(profile.gh_proxy_enabled());
profile = profile.with_gh_proxy(Some(" "));
assert!(!profile.gh_proxy_enabled(), "空白前缀视为不启用");
profile = profile.with_gh_proxy(Some("https://proxy.example/"));
assert!(profile.gh_proxy_enabled());
// 直连模式下即便配了前缀也不生效(gh-proxy 只在国内模式用)。
let direct = RegionProfile::direct(0).with_gh_proxy(Some("https://proxy.example/"));
assert!(!direct.gh_proxy_enabled());
assert_eq!(direct.label(), "直连");
assert_eq!(RegionProfile::mirror_first(0).label(), "镜像优先");
}
#[test]
fn cache_roundtrip_and_ttl_expiry() {
let dir = std::env::temp_dir().join(format!("ull-region-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("region.json");
let profile = decide_from(&signals(Some("Asia/Shanghai"), None, Some(false)), 1_000);
store_cached(&path, &profile).expect("写缓存失败");
let cached = load_cached(&path, 1_000 + 60_000).expect("应有缓存");
assert_eq!(cached, profile);
// 过期 → 视为没有缓存。
assert!(load_cached(&path, 1_000 + DECISION_TTL_MS + 1).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn missing_cache_file_is_not_an_error() {
let path = std::env::temp_dir().join("ull-region-does-not-exist.json");
let _ = std::fs::remove_file(&path);
assert!(load_cached(&path, 0).is_none());
}
+223
View File
@@ -0,0 +1,223 @@
//! 断点续传保守判定的单测:矩阵覆盖 + 416 处理。
use unlucky_core::download::resume::{
decide, on_range_unsatisfiable, RestartReason, ResumeDecision, ResumeMeta, Validator,
};
use unlucky_core::download::types::{HashAlgo, ResumePolicy};
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,
}
}
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);
}
+278
View File
@@ -0,0 +1,278 @@
//! 测试支撑:临时目录、摘要工具、进程内极简 HTTP 服务器(零额外依赖)。
//!
//! 为什么自己写 mock server 而不用 wiremock:P0 阶段控制依赖面(Win7 Tier-3 构建 +
//! 单文件体积),一个 60 行的 `TcpListener` 足够表达「成功 / 500 / 校验不符 / 无视 Range」。
#![allow(dead_code)]
use std::net::SocketAddr;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
/// 断言用的小工具:唯一临时目录(进程内计数 + 时间戳,避免并行测试互相踩)。
pub fn temp_dir(tag: &str) -> std::path::PathBuf {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_nanos());
let dir = std::env::temp_dir().join(format!("ull-download-test-{tag}-{stamp}-{unique}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("创建临时目录失败");
dir
}
/// 算一段字节的 SHA-1(测试断言用)。
pub fn sha1_hex(bytes: &[u8]) -> String {
use sha1::Digest;
let digest = sha1::Sha1::digest(bytes);
let mut out = String::with_capacity(40);
for byte in digest {
out.push_str(&format!("{byte:02x}"));
}
out
}
/// 目录里(不含子目录)所有中间文件家族的名字(`.ullpart` 与 `.ullmeta.json`)。
pub fn part_files(dir: &std::path::Path) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut names: Vec<String> = entries
.flatten()
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| name.contains(unlucky_core::download::PART_SUFFIX))
.collect();
names.sort();
names
}
/// 把文件的修改时间挪到「`age` 之前」(清理/收养的年龄判定用)。
pub fn age_file(path: &std::path::Path, age: std::time::Duration) {
let times = std::fs::FileTimes::new().set_modified(std::time::SystemTime::now() - age);
std::fs::File::options()
.write(true)
.open(path)
.expect("打开待改 mtime 的文件失败")
.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,
}
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,
}
}
/// 固定状态码 + 空体。
pub fn status(status: u16) -> Self {
Self {
status,
body: Vec::new(),
range_support: false,
etag: None,
lie_content_range: false,
}
}
/// 声明不支持 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);
let total = response.body.len() as u64;
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)
};
(
206,
vec![
format!("Content-Range: bytes {claimed_start}-{claimed_end}/{total}"),
format!("Content-Length: {}", slice.len()),
"Accept-Ranges: bytes".to_string(),
],
slice,
)
}
(Some(_), true) => (
416,
vec![
format!("Content-Range: bytes */{total}"),
"Content-Length: 0".to_string(),
],
Vec::new(),
),
_ => {
let mut headers = vec![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?;
stream.write_all(&body).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",
}
}