Merge branch 'feat/javapin-core-jdk-install-with-explicit-v'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-12 22:46:09 +08:00
commit f931af793d
20 files changed
+2236 -413

No files matched your search

+20 -4
View File
@@ -40,8 +40,10 @@ pub const fn general() -> &'static str {
config get [<key>] [--show-secret] 读配置(生效值;无 key 时输出整份) config get [<key>] [--show-secret] 读配置(生效值;无 key 时输出整份)
config set <key> <value> 写配置(走 setting schema 校验) config set <key> <value> 写配置(走 setting schema 校验)
java list 列出可用 Java(含每个 JDK 的实际落点) java list 列出可用 Java(含每个 JDK 的实际落点)
java install <major> [--vendor X] 安装托管 JDK(缺省按地区回退链选发行版; java install <major> [--vendor X] [--version V] [--dry-run]
--vendor temurin|oracle|microsoft|zulu|termux 钉死一家) 安装托管 JDK(缺省按地区回退链选发行版;
--vendor temurin|oracle|microsoft|zulu|termux 钉死一家;
--version 钉具体版本,--dry-run 只查版本不下载)
java vendors 列出各发行版的镜像/支持版本/当前优先级 java vendors 列出各发行版的镜像/支持版本/当前优先级
agent serve [--stdio] [--port N] 起对外 MCP server(默认 127.0.0.1 的 Streamable HTTP; agent serve [--stdio] [--port N] 起对外 MCP server(默认 127.0.0.1 的 Streamable HTTP;
[--allow-write] --stdio 给 llama.cpp 内置 client 用;默认只读) [--allow-write] --stdio 给 llama.cpp 内置 client 用;默认只读)
@@ -57,7 +59,8 @@ pub const fn general() -> &'static str {
--no-color 关闭颜色(v1 输出不含 ANSI,参数保留以兼容脚本) --no-color 关闭颜色(v1 输出不含 ANSI,参数保留以兼容脚本)
--quiet 静默人类可读输出(错误与提示仍进 stderr) --quiet 静默人类可读输出(错误与提示仍进 stderr)
--help, -h 帮助(有位置参数时按该命令给帮助) --help, -h 帮助(有位置参数时按该命令给帮助)
--version, -V 版本 + 系统摘要 --version, -V 版本 + 系统摘要(只在子命令**之前**生效:`ull --version`;
子命令里的 `--version` 是它自己的旗标,如 `java install <major> --version V`)
退出码: 0 成功 / 1 用户错误 / 2 内部错误 / 3 部分成功(仅 pack import)" 退出码: 0 成功 / 1 用户错误 / 2 内部错误 / 3 部分成功(仅 pack import)"
} }
@@ -251,9 +254,22 @@ authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CL
"\ "\
%prog% java list 探测 JAVA_HOME / PATH / 托管目录 / 实例覆盖里的 Java %prog% java list 探测 JAVA_HOME / PATH / 托管目录 / 实例覆盖里的 Java
并打印每个 JDK 的实际落点(managed 项带目录) 并打印每个 JDK 的实际落点(managed 项带目录)
%prog% java install <major> [--vendor X] 安装托管 JDK(D13/D23):回退链查版本 → 下载 → 解压 → 验证 %prog% java install <major> [--vendor X] [--version V] [--dry-run]
安装托管 JDK(D13/D23):回退链查版本 → 下载 → 解压 → 验证
%prog% java vendors 列出各发行版的镜像/支持版本/当前优先级顺序 %prog% java vendors 列出各发行版的镜像/支持版本/当前优先级顺序
指定版本(M280,`--version`):
Temurin temurin 能:打 Adoptium 的「指定版本」端点(不是 latest),版本不存在时列出可用版本
Oracle oracle 能:版本串来自镜像目录页(`versions_for` 枚举),选定就用它
Microsoft microsoft 只能装它内置快照里那一个版本号(当前 21 是 21.0.13.0.2);写别的版本会报错
Zulu zulu 不能:上游只提供 latest;给了 --version 会报错,不会换成别的版本
Termux termux 不能:版本由系统包管理器决定
钉不了的那几家,报错里会写清原因并提示改用 `--vendor temurin`;不会静默回落到最新,
也不会换一家装个别的版本冒充。`--version` 与 `--vendor` 不匹配时就是「这家没有这个版本」。
注:全局 `--version`(打印启动器版本)只在子命令**之前**成立:`ull --version` 是版本,
`ull java install 21 --version 21.0.13+9` 里的是 JDK 版本。
`--dry-run`:只查版本并打印**最终会打哪个版本串 / 哪个 URL**,不下载、不解压、不写盘。
落点(D23 三模式,随 core.data_mode 与设置键 java.installRoot 走): 落点(D23 三模式,随 core.data_mode 与设置键 java.installRoot 走):
portable(默认) <数据根>/.ULL/data/java-managed/jdk-<major>-<vendor>/ portable(默认) <数据根>/.ULL/data/java-managed/jdk-<major>-<vendor>/
standard Windows %APPDATA%\\Java\\<发行方>\\<版本>\\;macOS ~/Library/Application Support/Java/… standard Windows %APPDATA%\\Java\\<发行方>\\<版本>\\;macOS ~/Library/Application Support/Java/…
+5 -1
View File
@@ -55,12 +55,16 @@ pub enum Command {
ConfigSet(ConfigSetArgs), ConfigSet(ConfigSetArgs),
/// `java list`。 /// `java list`。
JavaList, JavaList,
/// `java install <major> [--vendor X]`。 /// `java install <major> [--vendor X] [--version V] [--dry-run]`。
JavaInstall { JavaInstall {
/// 目标 JDK 主版本(8 / 17 / 21)。 /// 目标 JDK 主版本(8 / 17 / 21)。
major: u32, major: u32,
/// 钉死的发行版(缺省走 D23 的回退链)。 /// 钉死的发行版(缺省走 D23 的回退链)。
vendor: Option<unlucky_core::game::java::JdkVendor>, vendor: Option<unlucky_core::game::java::JdkVendor>,
/// 钉死的具体版本(`--version`;`None` = 该家最新 GA)。
version: Option<String>,
/// 只查版本、打印将要下载的版本与 URL,不落盘(`--dry-run`)。
dry_run: bool,
}, },
/// `java vendors`:列出各发行版的可用性 / 镜像 / 当前优先级。 /// `java vendors`:列出各发行版的可用性 / 镜像 / 当前优先级。
JavaVendors, JavaVendors,
+5 -36
View File
@@ -3,13 +3,12 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::path::PathBuf; use std::path::PathBuf;
use unlucky_core::game::java::JdkVendor;
use crate::error::CliError; use crate::error::CliError;
mod accounts; mod accounts;
mod agent; mod agent;
mod instances; mod instances;
mod java;
mod update; mod update;
use super::model::{ use super::model::{
@@ -45,7 +44,9 @@ pub fn parse(args: &[String]) -> Result<Invocation, CliError> {
// 帮助/版本是「伪命令」,但不提前返回:`--help` 出现在命令前面时, // 帮助/版本是「伪命令」,但不提前返回:`--help` 出现在命令前面时,
// 主题要从后面收完的位置参数里取(`ull --help launch` 与 `ull launch --help` 等价)。 // 主题要从后面收完的位置参数里取(`ull --help launch` 与 `ull launch --help` 等价)。
"--help" | "-h" => help_requested = true, "--help" | "-h" => help_requested = true,
"--version" | "-V" => version_requested = true, // 全局 `--version` 只在**子命令之前**成立(`ull --version`):`java install <major>
// --version <v>` 里的同名旗标是 JDK 版本,必须原样交给子命令解析(M280)。
"--version" | "-V" if rest.is_empty() => version_requested = true,
"--data-dir" => { "--data-dir" => {
index += 1; index += 1;
globals.data_dir = Some(PathBuf::from(take_value(args, index, "data-dir")?)); globals.data_dir = Some(PathBuf::from(take_value(args, index, "data-dir")?));
@@ -91,7 +92,7 @@ fn parse_command(rest: &[String], globals: &Globals) -> Result<Command, CliError
"logs" => parse_logs(tail), "logs" => parse_logs(tail),
"diag" => parse_diag(tail), "diag" => parse_diag(tail),
"config" => parse_config(tail), "config" => parse_config(tail),
"java" => parse_java(tail), "java" => java::parse_java(tail),
"accounts" => accounts::parse_accounts(tail), "accounts" => accounts::parse_accounts(tail),
"agent" => agent::parse_agent(tail), "agent" => agent::parse_agent(tail),
"update" => update::parse_update(tail), "update" => update::parse_update(tail),
@@ -243,38 +244,6 @@ fn parse_config(tail: &[String]) -> Result<Command, CliError> {
} }
} }
fn parse_java(tail: &[String]) -> Result<Command, CliError> {
match tail.first().map(String::as_str) {
None | Some("list") => {
ensure_no_extra(tail.get(1..).unwrap_or_default(), "java list")?;
Ok(Command::JavaList)
}
Some("vendors") => {
ensure_no_extra(tail.get(1..).unwrap_or_default(), "java vendors")?;
Ok(Command::JavaVendors)
}
Some("install") => {
let parsed = parse_flags(tail.get(1..).unwrap_or_default(), &["vendor"], &[])?;
let raw = require_single_positional(&parsed, "java install <major>")?;
let major = raw
.parse::<u32>()
.map_err(|_| CliError::usage(format!("JDK 主版本需要整数,收到 `{raw}`")))?;
let vendor = match parsed.value("vendor") {
Some(text) => Some(JdkVendor::parse(&text).ok_or_else(|| {
CliError::usage(format!(
"--vendor 只有 temurin|oracle|microsoft|zulu,收到 `{text}`"
))
})?),
None => None,
};
Ok(Command::JavaInstall { major, vendor })
}
Some(other) => Err(CliError::usage(format!(
"java 的子命令只有 list|install|vendors,收到 `{other}`"
))),
}
}
/// 单个位置参数(多一个少一个都算用法错误)。 /// 单个位置参数(多一个少一个都算用法错误)。
fn require_single_positional(parsed: &ParsedArgs, usage: &str) -> Result<String, CliError> { fn require_single_positional(parsed: &ParsedArgs, usage: &str) -> Result<String, CliError> {
match parsed.positionals.as_slice() { match parsed.positionals.as_slice() {
+135
View File
@@ -0,0 +1,135 @@
//! `java` 的子命令解析(`list` / `install` / `vendors`)。
//!
//! 从 `parse.rs` 拆出来只为守 400 行硬线(M280 的 `--version` / `--dry-run` 让那边贴近上限);
//! `list` / `vendors` 是纯搬移,`install` 多了两个旗标:
//!
//! ```text
//! --version <v> 钉死具体版本(缺省 = 该家最新 GA);能不能钉由发行版决定,
//! 钉不了的时候由 core 报错(本层不猜、不改写用户给的值)
//! --dry-run 只查版本并打印将要下载的版本串与 URL,不落盘
//! ```
use unlucky_core::game::java::JdkVendor;
use super::super::model::Command;
use super::*;
pub(super) fn parse_java(tail: &[String]) -> Result<Command, CliError> {
match tail.first().map(String::as_str) {
None | Some("list") => {
ensure_no_extra(tail.get(1..).unwrap_or_default(), "java list")?;
Ok(Command::JavaList)
}
Some("vendors") => {
ensure_no_extra(tail.get(1..).unwrap_or_default(), "java vendors")?;
Ok(Command::JavaVendors)
}
Some("install") => parse_java_install(tail.get(1..).unwrap_or_default()),
Some(other) => Err(CliError::usage(format!(
"java 的子命令只有 list|install|vendors,收到 `{other}`"
))),
}
}
/// `java install <major> [--vendor X] [--version V] [--dry-run]`。
fn parse_java_install(args: &[String]) -> Result<Command, CliError> {
let parsed = parse_flags(args, &["vendor", "version"], &["dry-run"])?;
let raw = require_single_positional(&parsed, "java install <major>")?;
let major = raw
.parse::<u32>()
.map_err(|_| CliError::usage(format!("JDK 主版本需要整数,收到 `{raw}`")))?;
let vendor = match parsed.value("vendor") {
Some(text) => Some(JdkVendor::parse(&text).ok_or_else(|| {
CliError::usage(format!(
"--vendor 只有 temurin|oracle|microsoft|zulu,收到 `{text}`"
))
})?),
None => None,
};
// 版本串**原样**往下传(`21.0.13+9` / `jdk-21.0.13+9` / `21.0.13` 各有语义)。
// 空值在这里就拦掉:`--version ""` 到 core 会变成一个没有任何版本能匹配的请求。
let version = match parsed.value("version") {
Some(text) if text.trim().is_empty() => {
return Err(
CliError::usage("--version 需要一个版本串,例如 --version 21.0.13+9")
.with_hint("%prog% help java 看各发行版能不能指定版本"),
);
}
Some(text) => Some(text.trim().to_string()),
None => None,
};
Ok(Command::JavaInstall {
major,
vendor,
version,
dry_run: parsed.has("dry-run"),
})
}
#[cfg(test)]
mod tests {
use unlucky_core::game::java::JdkVendor;
use crate::cli::{parse, Command};
use crate::error::CliError;
fn parse_args(args: &[&str]) -> Result<Command, CliError> {
let owned: Vec<String> = args.iter().map(|arg| arg.to_string()).collect();
parse(&owned).map(|invocation| invocation.command)
}
fn command(args: &[&str]) -> Command {
parse_args(args).expect("应解析")
}
/// M280:`--version` 与 `--dry-run` 是 `java install` 的旗标,不是全局 `--version`。
///
/// 变异证明:把全局 `--version` 的 `if rest.is_empty()` 守卫去掉 ⇒ 本用例第一条红
/// (解析成 `Command::Version`,装版本的需求被吞掉)。
#[test]
fn pinned_version_and_dry_run_are_install_flags() {
assert_eq!(
command(&["java", "install", "21", "--version", "21.0.13+9"]),
Command::JavaInstall {
major: 21,
vendor: None,
version: Some("21.0.13+9".to_string()),
dry_run: false,
}
);
assert_eq!(
command(&["java", "install", "21", "--dry-run"]),
Command::JavaInstall {
major: 21,
vendor: None,
version: None,
dry_run: true,
}
);
assert_eq!(
command(&[
"java",
"install",
"21",
"--vendor",
"oracle",
"--version",
"21.0.2",
"--dry-run",
]),
Command::JavaInstall {
major: 21,
vendor: Some(JdkVendor::Oracle),
version: Some("21.0.2".to_string()),
dry_run: true,
}
);
}
/// 空版本串在解析层就拦住(到 core 会变成「没有任何版本能匹配」的请求)。
#[test]
fn an_empty_version_is_a_usage_error() {
assert!(parse_args(&["java", "install", "21", "--version", ""]).is_err());
assert!(parse_args(&["java", "install", "21", "--version"]).is_err());
}
}
+6 -87
View File
@@ -16,11 +16,9 @@ use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use serde_json::{json, Value}; use serde_json::{json, Value};
use unlucky_core::download::blocking::block_on;
use unlucky_core::download::{DownloadEngine, RegionProfile}; use unlucky_core::download::{DownloadEngine, RegionProfile};
use unlucky_core::game::java::{ use unlucky_core::game::java::{
self, location, vendor, InstallRequest, JavaOrigin, JavaRuntime, JdkVendor, LocationMode, self, location, vendor, JavaOrigin, JavaRuntime, JdkVendor, LocationMode, DEFAULT_MAJOR,
DEFAULT_MAJOR,
}; };
use unlucky_core::launch::request::JavaSelection; use unlucky_core::launch::request::JavaSelection;
use unlucky_core::platform::paths::ProcessEnv; use unlucky_core::platform::paths::ProcessEnv;
@@ -31,12 +29,10 @@ use crate::context::Ctx;
use crate::error::CliError; use crate::error::CliError;
use crate::output::Report; use crate::output::Report;
pub use super::java_install::install;
pub use super::java_launch::{resolve_for_launch, LaunchJava}; pub use super::java_launch::{resolve_for_launch, LaunchJava};
pub use super::java_vendors::vendors; pub use super::java_vendors::vendors;
/// 可自动安装的最老大版本(再老的 Temurin 没有 GA 产物)。
const MIN_INSTALLABLE_MAJOR: u32 = 8;
/// 装配用的 Java 选择(`--java` > `JAVA_HOME` > PATH 上的 `java`)。 /// 装配用的 Java 选择(`--java` > `JAVA_HOME` > PATH 上的 `java`)。
/// ///
/// `snapshot::prepare` 调它拿 [`JavaSelection`];真正的来源优先级在 /// `snapshot::prepare` 调它拿 [`JavaSelection`];真正的来源优先级在
@@ -131,83 +127,6 @@ pub fn list(ctx: &Ctx) -> Result<Report, CliError> {
Ok(Report::new(data, human)) Ok(Report::new(data, human))
} }
/// `ull java install <major> [--vendor X]`:回退链查版本 → 下载 → 解压 → `java -version` 验证。
///
/// 幂等:同一个归档装过就直接复用(不联网、不解压)。下载走 `core::download` 引擎,
/// 国内环境按 D7 的地区策略优先院校/国内镜像。
///
/// # Errors
///
/// 大版本不合法、平台无产物、所有发行版都失败(网络/校验/解压/跑不起来)时返回用户错误。
pub fn install(ctx: &Ctx, major: u32, vendor: Option<JdkVendor>) -> Result<Report, CliError> {
if !(MIN_INSTALLABLE_MAJOR..=99).contains(&major) {
return Err(CliError::usage(format!(
"大版本号看起来不对:{major}(可用 8 / 11 / 17 / 21 / 25 这类 GA 大版本)"
)));
}
let settings = JavaSettings::load(ctx);
let engine = build_engine(ctx);
let install_root = settings.location(ctx)?;
let request = match vendor {
Some(vendor) => {
InstallRequest::with_vendor(major, vendor).with_priority(settings.priority.clone())
}
None => InstallRequest::new(major).with_priority(settings.priority.clone()),
};
let installed =
block_on(java::install(&engine, &install_root, request)).map_err(map_java_error)?;
let data = json!({
"major": installed.major,
"vendor": installed.vendor.id(),
"release": installed.release.release_name,
"version": installed.release.version,
"semver": installed.release.semver,
"archive": installed.release.archive.name,
"sha256": installed.release.archive.sha256,
"size": installed.release.archive.size,
"root": installed.root.display().to_string(),
"install_mode": installed.mode.as_str(),
"binary": installed.binary().display().to_string(),
"reused": installed.reused(),
"source": installed.downloaded,
"bytes": installed.bytes,
"files": installed.files,
"extracted": installed.extracted,
"chain": installed.chain.to_json(),
"runtime": candidate_json(&installed.runtime),
});
let mut human = Vec::new();
if installed.reused() {
human.push(format!(
"Java {}({})已就绪({}),未走网络",
installed.major,
installed.vendor.id(),
installed.root.display()
));
} else {
human.push(format!(
"已安装 Java {}({},来源 {},{} 字节 / {} 个文件)",
installed.major,
installed.release.summary(),
installed.downloaded.as_deref().unwrap_or("?"),
installed.bytes,
installed.files
));
}
human.push(format!("落点:{}", installed.location_line()));
human.push(format!("可执行文件:{}", installed.binary().display()));
if let Some(version) = &installed.runtime.version {
human.push(format!("探测:{version}"));
}
for note in installed.chain.degradations() {
human.push(format!("降级:{note}"));
}
human.push("`%prog% launch` 会自动优先用它".to_string());
Ok(Report::new(data, human))
}
/// 设置里与 JDK 有关的两项(`java.installRoot` / `java.vendorPriority`)。 /// 设置里与 JDK 有关的两项(`java.installRoot` / `java.vendorPriority`)。
#[derive(Debug, Clone, Default, PartialEq, Eq)] #[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct JavaSettings { pub(crate) struct JavaSettings {
@@ -255,7 +174,7 @@ impl JavaSettings {
} }
/// 真正要装到哪(解析失败 → 用户错误,带可照着做的提示)。 /// 真正要装到哪(解析失败 → 用户错误,带可照着做的提示)。
fn location(&self, ctx: &Ctx) -> Result<java::JavaInstallRoot, CliError> { pub(super) fn location(&self, ctx: &Ctx) -> Result<java::JavaInstallRoot, CliError> {
java::JavaInstallRoot::resolve( java::JavaInstallRoot::resolve(
ctx.data.mode, ctx.data.mode,
&ctx.layout(), &ctx.layout(),
@@ -268,7 +187,7 @@ impl JavaSettings {
} }
/// 落点模式 → 人读一行(含「没设覆盖键时默认落在哪」的解释)。 /// 落点模式 → 人读一行(含「没设覆盖键时默认落在哪」的解释)。
fn describe_mode(mode: LocationMode, override_root: Option<&Path>) -> String { pub(super) fn describe_mode(mode: LocationMode, override_root: Option<&Path>) -> String {
match mode { match mode {
LocationMode::Custom => format!( LocationMode::Custom => format!(
"custom(java.installRoot = {})", "custom(java.installRoot = {})",
@@ -282,7 +201,7 @@ fn describe_mode(mode: LocationMode, override_root: Option<&Path>) -> String {
} }
/// 一个运行时的 `--json` 形状。 /// 一个运行时的 `--json` 形状。
fn candidate_json(runtime: &JavaRuntime) -> Value { pub(super) fn candidate_json(runtime: &JavaRuntime) -> Value {
json!({ json!({
"binary": runtime.binary.display().to_string(), "binary": runtime.binary.display().to_string(),
"source": runtime.origin.as_str(), "source": runtime.origin.as_str(),
@@ -314,7 +233,7 @@ pub(crate) fn build_engine(ctx: &Ctx) -> Arc<DownloadEngine> {
} }
/// JDK 下载/解压错误 → CLI 错误(保留稳定码与提示)。 /// JDK 下载/解压错误 → CLI 错误(保留稳定码与提示)。
fn map_java_error(error: java::JavaError) -> CliError { pub(super) fn map_java_error(error: java::JavaError) -> CliError {
let cli = CliError::user(error.code(), error.message().to_string()); let cli = CliError::user(error.code(), error.message().to_string());
match error.hint() { match error.hint() {
Some(hint) => cli.with_hint(hint.to_string()), Some(hint) => cli.with_hint(hint.to_string()),
+174
View File
@@ -0,0 +1,174 @@
//! `ull java install`:回退链查版本 → 下载 → 解压 → `java -version` 验证(以及 `--dry-run`)。
//!
//! 从 `commands/java.rs` 拆出来只为守 400 行硬线(M280 的 `--version` / `--dry-run` 让那边到
//! 421 行);`list` / 设置读取 / 渲染助手仍在 `java.rs`。与 `java_launch.rs` / `java_vendors.rs`
//! 同一个拆法。
//!
//! 版本这一维(M280):`--version` 不给就是该家**最新 GA**(与加旗标之前逐字节相同);
//! 给了就要求**精确**那一版——能按版本取的只有 Temurin / Oracle,Microsoft 只认它内置的
//! 那份快照,Zulu 只有 latest。钉不了时 core 报错并列出可用版本,**绝不回落到最新**。
use serde_json::json;
use unlucky_core::download::blocking::block_on;
use unlucky_core::download::DownloadEngine;
use unlucky_core::game::java::{self, InstallRequest, JdkVendor};
use super::java::{build_engine, candidate_json, map_java_error, JavaSettings};
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::Report;
/// 可自动安装的最老大版本(再老的 Temurin 没有 GA 产物)。
const MIN_INSTALLABLE_MAJOR: u32 = 8;
/// `ull java install <major> [--vendor X] [--version V] [--dry-run]`:
/// 回退链查版本 → 下载 → 解压 → `java -version` 验证。
///
/// 幂等:同一个归档装过就直接复用(不联网、不解压)。下载走 `core::download` 引擎,
/// 国内环境按 D7 的地区策略优先院校/国内镜像。
///
/// `--dry-run` 只查版本:打印最终会打哪个版本串、哪个 URL,一个字节都不落盘。
///
/// # Errors
///
/// 大版本不合法、平台无产物、该家没有这个版本、所有发行版都失败(网络/校验/解压/跑不起来)时返回用户错误。
pub fn install(
ctx: &Ctx,
major: u32,
vendor: Option<JdkVendor>,
version: Option<&str>,
dry_run: bool,
) -> Result<Report, CliError> {
if !(MIN_INSTALLABLE_MAJOR..=99).contains(&major) {
return Err(CliError::usage(format!(
"大版本号看起来不对:{major}(可用 8 / 11 / 17 / 21 / 25 这类 GA 大版本)"
)));
}
let settings = JavaSettings::load(ctx);
let engine = build_engine(ctx);
let install_root = settings.location(ctx)?;
let request = match vendor {
Some(vendor) => {
InstallRequest::with_vendor(major, vendor).with_priority(settings.priority.clone())
}
None => InstallRequest::new(major).with_priority(settings.priority.clone()),
}
.with_version(version.map(str::to_string));
if dry_run {
return dry_run_report(&engine, &install_root, &request);
}
let installed =
block_on(java::install(&engine, &install_root, request.clone())).map_err(map_java_error)?;
let data = json!({
"major": installed.major,
"vendor": installed.vendor.id(),
"release": installed.release.release_name,
"version": installed.release.version,
"requested_version": request.version,
"semver": installed.release.semver,
"archive": installed.release.archive.name,
"sha256": installed.release.archive.sha256,
"size": installed.release.archive.size,
"root": installed.root.display().to_string(),
"install_mode": installed.mode.as_str(),
"binary": installed.binary().display().to_string(),
"reused": installed.reused(),
"source": installed.downloaded,
"bytes": installed.bytes,
"files": installed.files,
"extracted": installed.extracted,
"chain": installed.chain.to_json(),
"runtime": candidate_json(&installed.runtime),
});
let mut human = Vec::new();
if installed.reused() {
human.push(format!(
"Java {}({})已就绪({}),未走网络",
installed.major,
installed.vendor.id(),
installed.root.display()
));
} else {
human.push(format!(
"已安装 Java {}({},来源 {},{} 字节 / {} 个文件)",
installed.major,
installed.release.summary(),
installed.downloaded.as_deref().unwrap_or("?"),
installed.bytes,
installed.files
));
}
if let Some(wanted) = &request.version {
human.push(format!(
"指定版本:{wanted} → 命中 {}",
installed.release.version
));
}
human.push(format!("落点:{}", installed.location_line()));
human.push(format!("可执行文件:{}", installed.binary().display()));
if let Some(version) = &installed.runtime.version {
human.push(format!("探测:{version}"));
}
for note in installed.chain.degradations() {
human.push(format!("降级:{note}"));
}
human.push("`%prog% launch` 会自动优先用它".to_string());
Ok(Report::new(data, human))
}
/// `--dry-run`:查一次版本(与真装走同一条选择路径),打印版本串与 URL,不下载、不解压、不写盘。
///
/// 查版本本身仍要联网,但它把「会装哪一版、打哪个 URL」在下载之前摆出来——
/// 验证版本选择逻辑不必先下 200 MB。
fn dry_run_report(
engine: &DownloadEngine,
location: &java::JavaInstallRoot,
request: &InstallRequest,
) -> Result<Report, CliError> {
let (release, root, ready) =
block_on(java::plan(engine, location, request)).map_err(map_java_error)?;
let data = json!({
"dry_run": true,
"major": release.major,
"vendor": release.vendor.id(),
"version": release.version,
"requested_version": request.version,
"semver": release.semver,
"release": release.release_name,
"archive": release.archive.name,
"url": release.archive.url,
"sha256": release.archive.sha256,
"size": release.archive.size,
"root": root.display().to_string(),
"install_mode": location.mode.as_str(),
"already_installed": ready,
});
let mut human = vec![format!(
"将安装 Java {}({})",
release.major,
release.summary()
)];
if let Some(wanted) = &request.version {
human.push(format!("指定版本:{wanted} → 命中 {}", release.version));
}
human.push(format!("版本:{}", release.version));
human.push(format!("URL:{}", release.archive.url));
human.push(format!(
"落点:{}({})",
root.display(),
location.mode.as_str()
));
human.push(format!(
"已装好:{}",
if ready {
"是(会直接复用,不联网)"
} else {
"否(会下载)"
}
));
human.push("(--dry-run:没有下载、没有解压、没有写盘)".to_string());
Ok(Report::new(data, human))
}
+8 -2
View File
@@ -8,6 +8,7 @@ pub mod install;
pub mod instance_launch; pub mod instance_launch;
pub mod instances; pub mod instances;
pub mod java; pub mod java;
pub mod java_install;
pub mod java_launch; pub mod java_launch;
pub mod java_vendors; pub mod java_vendors;
pub mod launch; pub mod launch;
@@ -58,8 +59,13 @@ pub fn run(invocation: &Invocation) -> Result<Outcome, CliError> {
config::set(ctx, &args.key, &args.value) config::set(ctx, &args.key, &args.value)
}), }),
Command::JavaList => with_ctx(&invocation.globals, java::list), Command::JavaList => with_ctx(&invocation.globals, java::list),
Command::JavaInstall { major, vendor } => with_ctx(&invocation.globals, |ctx| { Command::JavaInstall {
java::install(ctx, *major, *vendor) major,
vendor,
version,
dry_run,
} => with_ctx(&invocation.globals, |ctx| {
java::install(ctx, *major, *vendor, version.as_deref(), *dry_run)
}), }),
Command::JavaVendors => with_ctx(&invocation.globals, java::vendors), Command::JavaVendors => with_ctx(&invocation.globals, java::vendors),
Command::UpdateCheck(args) => { Command::UpdateCheck(args) => {
+7 -1
View File
@@ -236,7 +236,9 @@ fn java_subcommands() {
invoke(&["java", "install", "21"]).command, invoke(&["java", "install", "21"]).command,
Command::JavaInstall { Command::JavaInstall {
major: 21, major: 21,
vendor: None vendor: None,
version: None,
dry_run: false,
} }
); );
// D23:`--vendor` 钉死发行版(别名也认),`java vendors` 列出四家。 // D23:`--vendor` 钉死发行版(别名也认),`java vendors` 列出四家。
@@ -245,6 +247,8 @@ fn java_subcommands() {
Command::JavaInstall { Command::JavaInstall {
major: 21, major: 21,
vendor: Some(unlucky_core::game::java::JdkVendor::Oracle), vendor: Some(unlucky_core::game::java::JdkVendor::Oracle),
version: None,
dry_run: false,
} }
); );
assert_eq!( assert_eq!(
@@ -252,6 +256,8 @@ fn java_subcommands() {
Command::JavaInstall { Command::JavaInstall {
major: 17, major: 17,
vendor: Some(unlucky_core::game::java::JdkVendor::Temurin), vendor: Some(unlucky_core::game::java::JdkVendor::Temurin),
version: None,
dry_run: false,
} }
); );
assert_eq!(invoke(&["java", "vendors"]).command, Command::JavaVendors); assert_eq!(invoke(&["java", "vendors"]).command, Command::JavaVendors);
+46 -23
View File
@@ -1,6 +1,8 @@
//! Adoptium(Eclipse Temurin)版本查询:`api.adoptium.net` → 一个可下载的 JDK 归档。 //! Adoptium(Eclipse Temurin)版本查询:`api.adoptium.net` → 一个可下载的 JDK 归档。
//! //!
//! 只用 v3 的 `assets/latest/{major}/hotspot` 这一条接口(D13:自动下载只装最新 GA): //! **最新 GA**(`assets/latest/{major}/hotspot`,D13 的自动下载口径)走本模块;
//! 「指定版本」走子模块 [`version`](`assets/feature_releases` 翻页枚举 → `assets/release_name`
//! 取那一份),两者共用同一套响应结构。本模块只描述 latest 这条:
//! //!
//! ```text //! ```text
//! GET https://api.adoptium.net/v3/assets/latest/21/hotspot?os=windows&arch=x64&image_type=jdk&vendor=eclipse //! GET https://api.adoptium.net/v3/assets/latest/21/hotspot?os=windows&arch=x64&image_type=jdk&vendor=eclipse
@@ -25,6 +27,10 @@ use super::vendor::JdkVendor;
/// Adoptium API 根。 /// Adoptium API 根。
pub const ADOPTIUM_API_BASE: &str = "https://api.adoptium.net/v3"; pub const ADOPTIUM_API_BASE: &str = "https://api.adoptium.net/v3";
mod version;
pub use version::{fetch_version, parse_release, parse_release_list, release_url, releases_url};
/// 托管 JDK 的默认供应商标识(D13 v1 的目录名 `jdk-<major>-vendor` 沿用它)。 /// 托管 JDK 的默认供应商标识(D13 v1 的目录名 `jdk-<major>-vendor` 沿用它)。
pub const JDK_VENDOR: &str = "temurin"; pub const JDK_VENDOR: &str = "temurin";
@@ -112,7 +118,7 @@ pub async fn fetch_latest(
/// 响应里的 `package` 子对象。 /// 响应里的 `package` 子对象。
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct ApiPackage { pub(super) struct ApiPackage {
name: String, name: String,
link: String, link: String,
#[serde(default)] #[serde(default)]
@@ -121,9 +127,9 @@ struct ApiPackage {
size: u64, size: u64,
} }
/// 响应里的 `binary` 子对象。 /// 响应里的 `binary` / `binaries[]` 子对象(`assets/latest` 用前者,`assets/release_name` 用后者)。
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct ApiBinary { pub(super) struct ApiBinary {
#[serde(default)] #[serde(default)]
os: Option<String>, os: Option<String>,
#[serde(default)] #[serde(default)]
@@ -134,9 +140,9 @@ struct ApiBinary {
package: Option<ApiPackage>, package: Option<ApiPackage>,
} }
/// 响应里的 `version` 子对象。 /// 响应里的 `version` / `version_data` 子对象(两者字段同形)。
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct ApiVersion { pub(super) struct ApiVersion {
#[serde(default)] #[serde(default)]
major: Option<u32>, major: Option<u32>,
#[serde(default)] #[serde(default)]
@@ -154,27 +160,28 @@ struct ApiAsset {
binary: Option<ApiBinary>, binary: Option<ApiBinary>,
} }
impl ApiAsset { impl ApiBinary {
/// 是否就是目标平台的 jdk 归档(`arch` 参数不过滤,必须自己判)。 /// 是否就是目标平台的 jdk 归档(`arch` 参数不过滤,必须自己判)。
fn matches(&self, target: AdoptiumTarget) -> bool { pub(super) fn matches(&self, target: AdoptiumTarget) -> bool {
let Some(binary) = &self.binary else { let same_os = self.os.as_deref().is_some_and(|os| os == target.os);
return false; let same_arch = self
};
let same_os = binary.os.as_deref().is_some_and(|os| os == target.os);
let same_arch = binary
.architecture .architecture
.as_deref() .as_deref()
.is_some_and(|arch| arch == target.arch); .is_some_and(|arch| arch == target.arch);
let is_jdk = binary.image_type.as_deref() != Some("jre"); let is_jdk = self.image_type.as_deref() != Some("jre");
same_os && same_arch && is_jdk && binary.package.is_some() same_os && same_arch && is_jdk && self.package.is_some()
} }
/// 转成对外结果(校验 sha256 形态)。 /// 转成对外结果(校验 sha256 形态)。
fn into_release(self) -> Result<JdkRelease, JavaError> { ///
let package = self /// `assets/latest` 与 `assets/release_name` 的响应把版本包在不同的外层字段里
.binary /// (`version` / `version_data` + `release_name`),所以这两个由调用方传进来。
.and_then(|binary| binary.package) pub(super) fn into_release(
.ok_or_else(|| { self,
release_name: String,
version: Option<ApiVersion>,
) -> Result<JdkRelease, JavaError> {
let package = self.package.ok_or_else(|| {
JavaError::new(JavaErrorKind::Metadata, "Adoptium 条目缺少 package 子对象") JavaError::new(JavaErrorKind::Metadata, "Adoptium 条目缺少 package 子对象")
})?; })?;
let sha256 = package let sha256 = package
@@ -189,7 +196,6 @@ impl ApiAsset {
) )
.with_hint("换一个大版本,或手动装 JDK 后设 JAVA_HOME") .with_hint("换一个大版本,或手动装 JDK 后设 JAVA_HOME")
})?; })?;
let version = self.version;
let major = version let major = version
.as_ref() .as_ref()
.and_then(|version| version.major) .and_then(|version| version.major)
@@ -200,7 +206,7 @@ impl ApiAsset {
format!("Adoptium 条目里读不出大版本号({})", package.name), format!("Adoptium 条目里读不出大版本号({})", package.name),
) )
})?; })?;
let native_version = version_from_release_name(&self.release_name) let native_version = version_from_release_name(&release_name)
.or_else(|| version.as_ref().and_then(|version| version.semver.clone())) .or_else(|| version.as_ref().and_then(|version| version.semver.clone()))
.unwrap_or_else(|| major.to_string()); .unwrap_or_else(|| major.to_string());
Ok(JdkRelease { Ok(JdkRelease {
@@ -208,7 +214,7 @@ impl ApiAsset {
major, major,
version: native_version, version: native_version,
semver: version.and_then(|version| version.semver), semver: version.and_then(|version| version.semver),
release_name: self.release_name, release_name,
archive: JdkArchive { archive: JdkArchive {
name: package.name, name: package.name,
url: package.link, url: package.link,
@@ -219,6 +225,23 @@ impl ApiAsset {
} }
} }
impl ApiAsset {
/// 是否就是目标平台的 jdk 归档(`arch` 参数不过滤,必须自己判)。
fn matches(&self, target: AdoptiumTarget) -> bool {
self.binary
.as_ref()
.is_some_and(|binary| binary.matches(target))
}
/// 转成对外结果。
fn into_release(self) -> Result<JdkRelease, JavaError> {
let binary = self.binary.ok_or_else(|| {
JavaError::new(JavaErrorKind::Metadata, "Adoptium 条目缺少 binary 子对象")
})?;
binary.into_release(self.release_name, self.version)
}
}
/// 从产物名兜底读大版本(`OpenJDK21U-jdk_x64_windows_hotspot_…` → `21`)。 /// 从产物名兜底读大版本(`OpenJDK21U-jdk_x64_windows_hotspot_…` → `21`)。
fn major_from_name(name: &str) -> Option<u32> { fn major_from_name(name: &str) -> Option<u32> {
let rest = name.split_once("OpenJDK")?.1; let rest = name.split_once("OpenJDK")?.1;
+286
View File
@@ -0,0 +1,286 @@
//! Adoptium(Eclipse Temurin)的**指定版本**查询:先枚举该大版本的 GA,把用户给的版本串对上
//! 一条 release,再用 `assets/release_name` 取那一份归档。
//!
//! 为什么是两跳(2026-11-12 逐个端点 curl 验过,见 mission M280 报告):
//!
//! 1. `/v3/assets/version/{version}` 在 `/q/openapi` 里有这条路由,但**部署上的服务回 404 空体**
//! (`21.0.13+9` 与 `21.0.13%2B9` 都一样),所以不用它;
//! 2. `GET /v3/assets/release_name/{vendor}/{release_name}`(如 `eclipse/jdk-21.0.13+9`)回 200,
//! 体是 `{release_name, version_data{…}, binaries[]{os,architecture,image_type,package{…}}}` ——
//! 这就是「指定版本」那一跳;
//! 3. 但 `release_name` 要完整名(`jdk-21.0.13+9`),用户给的常是公共版本号(`21.0.13`),
//! 所以先打 `GET /v3/assets/feature_releases/{major}/ga?…&sort_order=DESC&page_size=20`
//! (openapi 里 `page_size` 的 `maximum` 就是 20)把前者翻成后者。同一份响应的 release 名
//! 列表同时是「这个大版本有哪些版本」的唯一来源——版本不存在时就靠它列给人看。
//!
//! **必须翻页**(M280 r1):`page_size` 上限 20 是上游硬约束,而 8 / 11 / 17 线各有 26 条 GA
//! (2026-11-12 实测:`17/ga` page=0 回 20 条、page=1 回 6 条)。只读第一页会把
//! `17.0.4+8` 这种「上游有、第一页没有」的版本报成「没有这个版本」——文案还会把窗口当总数。
//! 所以这里按 `page` 翻到「本页不满一页」为止,上限 [`MAX_PAGES`] 页;真到上限就把清单标成
//! [`VersionListScope::Truncated`],错误文案里如实写「只查了前 N 条」。
//!
//! 这里**只有取**:匹配用 [`matches_version`],匹配不上时把可用清单交给
//! [`version_pin_error`] 生成错误。**不换家、不回落 latest**(那是 `install` / `source` 的事)。
use serde::Deserialize;
use super::{ApiBinary, ApiVersion, ADOPTIUM_API_BASE};
use crate::game::java::catalog::{
canonical_version, matches_version, version_pin_error, VersionListScope,
};
use crate::game::java::error::{JavaError, JavaErrorKind};
use crate::game::java::net;
use crate::game::java::release::JdkRelease;
use crate::game::java::target::AdoptiumTarget;
use crate::game::java::vendor::JdkVendor;
/// `feature_releases` 的每页条数(Adoptium 允许的上限)。
pub const PAGE_SIZE: usize = 20;
/// 最多翻几页(正常最多 2 页;5 页 = 100 条,纯防御,别让上游的异常响应把内存吃掉)。
pub const MAX_PAGES: usize = 5;
/// 某大版本第 `page` 页的 GA 清单地址(新 → 旧,`page` 从 0 起)。
#[must_use]
pub fn releases_url(major: u32, target: AdoptiumTarget, page: usize) -> String {
format!(
"{ADOPTIUM_API_BASE}/assets/feature_releases/{major}/ga?os={}&arch={}&image_type=jdk&page={page}&page_size={PAGE_SIZE}&sort_order=DESC&vendor=eclipse",
target.os, target.arch
)
}
/// 指定 release 名的产物地址。
#[must_use]
pub fn release_url(release_name: &str, target: AdoptiumTarget) -> String {
format!(
"{ADOPTIUM_API_BASE}/assets/release_name/eclipse/{release_name}?os={}&arch={}&image_type=jdk&vendor=eclipse",
target.os, target.arch
)
}
/// `feature_releases` / `release_name` 响应里的一条(只有这几项用得上)。
#[derive(Debug, Deserialize)]
struct ApiRelease {
#[serde(default)]
release_name: String,
#[serde(default)]
version_data: Option<ApiVersion>,
#[serde(default)]
binaries: Vec<ApiBinary>,
}
/// GA 清单响应 → release 名列表(`jdk-21.0.13+9`,上游给的顺序即新 → 旧)。
///
/// # Errors
///
/// 响应不是 JSON 时返回 [`JavaErrorKind::Metadata`](调用方据此降级到下一家发行版)。
pub fn parse_release_list(json: &str) -> Result<Vec<String>, JavaError> {
let releases: Vec<ApiRelease> = serde_json::from_str(json).map_err(|error| {
JavaError::new(
JavaErrorKind::Metadata,
format!("Adoptium GA 清单不是预期 JSON:{error}"),
)
})?;
Ok(releases
.into_iter()
.map(|release| release.release_name)
.filter(|name| !canonical_version(name).is_empty())
.collect())
}
/// 指定版本的响应 → 一份可下载归档(挑出目标平台的 jdk 那一条)。
///
/// # Errors
///
/// 响应不是 JSON、没有目标平台产物、sha256 不合法时返回错误。
pub fn parse_release(
json: &str,
expected: &str,
target: AdoptiumTarget,
) -> Result<JdkRelease, JavaError> {
let release: ApiRelease = serde_json::from_str(json).map_err(|error| {
JavaError::new(
JavaErrorKind::Metadata,
format!("Adoptium 指定版本响应不是预期 JSON:{error}"),
)
})?;
let name = if release.release_name.is_empty() {
expected.to_string()
} else {
release.release_name
};
let binary = release
.binaries
.into_iter()
.find(|binary| binary.matches(target))
.ok_or_else(|| {
JavaError::new(
JavaErrorKind::Metadata,
format!(
"Adoptium 的 {name} 没有 {}/{} 的 jdk 产物",
target.os, target.arch
),
)
})?;
binary.into_release(name, release.version_data)
}
/// 翻页把该大版本的 GA release 名全部取回来(按页拼,直到「这一页不存在」/「本页不满一页」/ 到 [`MAX_PAGES`])。
///
/// `fetch_page(page)` 由调用方给:真跑时是一发 HTTP(见 [`fetch_release_page`]),单测里是夹具闭包
/// ——所以「翻几页 / 什么时候停 / 到上限了没有」这段判断**完全离线可测**。
///
/// 返回 `Ok(None)` 的语义是**「上游明确说没有这一页」**(404/410 或 200+空体),
/// 与「这一页读不懂 / 网络坏了」是两件事:
///
/// * `Ok(None)` 在**第一页** ⇒ 「这个大版本真没有 GA 清单」,仍然 `Err`(与 r2 之前同口径);
/// * `Ok(None)` 在**第二页及以后** ⇒「翻到尾巴」,把已取到的交出去,`truncated = false`;
/// * `Err(..)` ⇒ **一律照抛**(含第二页及以后)。读不懂的响应一旦被当成「没有更多」,
/// 用户会看到「这个 major 没有版本 X」,而 X 其实在上游——一句可证伪的假话。
///
/// 返回值第二项 = 是否**因为到上限**而停(还有没看到的页)。错误文案要用它决定说
/// 「上游就这些」还是「只查了前 N 条」。
///
/// # Errors
///
/// 任何一页**读不懂**(解析失败 / 非 2xx 非 404 / 网络)都照抛,不吞。
pub async fn collect_releases<F, Fut>(mut fetch_page: F) -> Result<(Vec<String>, bool), JavaError>
where
F: FnMut(usize) -> Fut,
Fut: std::future::Future<Output = Result<Option<Vec<String>>, JavaError>>,
{
let mut all: Vec<String> = Vec::new();
for page in 0..MAX_PAGES {
let Some(names) = fetch_page(page).await? else {
if page == 0 {
// 第一页就不存在 = 这个大版本真没有 GA 清单(不是「翻到尾巴」)。
// 与 r2 之前的行为一致:那一条仍然报错,只是判据从「错误的种类」换成了
// 「哪一页不存在」。
return Err(JavaError::new(
JavaErrorKind::Metadata,
"Adoptium 没有该大版本的 GA 清单(HTTP 404 / 空响应)",
));
}
// 「这一页不存在」= 翻到尾巴。上游对越界页回 404(2026-11-12 实测),
// 某条 GA 线的条数恰好是 20 / 40 / 60 时就会撞上这一发;此时已取到的前几页必须交出去。
return Ok((all, false));
};
let short_page = names.len() < PAGE_SIZE;
all.extend(names);
if short_page {
return Ok((all, false));
}
}
Ok((all, true))
}
/// 一页响应的判定:`Ok(None)` = 「上游明确说没有这一页」,`Err` = 读不懂或网络坏。
///
/// **判据是语义的**(状态码 + 体的形状),不匹配任何错误文案:
///
/// | 上游情形 | 结果 |
/// | --- | --- |
/// | 404 / 410(越界页实测就是这个) | `Ok(None)` |
/// | 200 + 空体 | `Ok(None)`(等价于「没有这一页」) |
/// | 其它非 2xx(5xx / 403 / 429…) | `Err(Network)` |
/// | 200 + 不是预期 JSON | `Err(Metadata)` —— **不许**被当成「没有更多」 |
/// | 200 + 正常清单 | `Ok(Some(names))` |
///
/// # Errors
///
/// 非 2xx 且不是 404/410 时是 [`JavaErrorKind::Network`];体读不懂时是
/// [`JavaErrorKind::Metadata`]。
pub fn page_outcome(status: u16, body: &str) -> Result<Option<Vec<String>>, JavaError> {
if status == 404 || status == 410 {
return Ok(None);
}
if !(200..300).contains(&status) {
return Err(JavaError::new(
JavaErrorKind::Network,
format!("Adoptium GA 清单返回 HTTP {status}"),
));
}
if body.trim().is_empty() {
return Ok(None);
}
parse_release_list(body).map(Some)
}
/// 取一页 GA 清单(HTTP 那一跳)。判定全交给 [`page_outcome`],这里只负责把响应取回来。
async fn fetch_release_page(
client: &reqwest::Client,
url: &str,
) -> Result<Option<Vec<String>>, JavaError> {
let response = client.get(url).send().await.map_err(|error| {
JavaError::new(
JavaErrorKind::Network,
format!("取 Adoptium GA 清单失败:{error}({url})"),
)
.with_hint("检查网络/代理;已装的 JDK 仍可用 `ull java list` 看到")
})?;
let status = response.status().as_u16();
let body = response.text().await.map_err(|error| {
JavaError::new(
JavaErrorKind::Network,
format!("读 Adoptium GA 清单响应失败:{error}({url})"),
)
})?;
page_outcome(status, &body)
}
/// 在枚举出来的 release 名里挑用户要的那一版(**不取最新、不取第一条**)。
#[must_use]
pub fn pick_release<'a>(releases: &'a [String], wanted: &str) -> Option<&'a String> {
releases.iter().find(|name| matches_version(wanted, name))
}
/// 查一次「某大版本的指定版本」。
///
/// # Errors
///
/// 清单不可达、该大版本没有版本(错误里列出可用版本)、目标平台无产物、sha256 缺失时返回错误。
pub async fn fetch_version(
client: &reqwest::Client,
major: u32,
target: AdoptiumTarget,
wanted: &str,
) -> Result<JdkRelease, JavaError> {
let (releases, truncated) = collect_releases(|page| {
let url = releases_url(major, target, page);
async move { fetch_release_page(client, &url).await }
})
.await?;
let Some(name) = pick_release(&releases, wanted).cloned() else {
let versions = releases
.iter()
.map(|name| canonical_version(name).to_string())
.collect::<Vec<_>>();
let scope = if truncated {
VersionListScope::Truncated
} else {
VersionListScope::Complete
};
return Err(version_pin_error(
JdkVendor::Temurin,
major,
wanted,
&versions,
scope,
));
};
let body = net::get_text(client, &release_url(&name, target), "Adoptium 指定版本产物").await?;
let release = parse_release(&body, &name, target)?;
if release.major != major {
return Err(JavaError::new(
JavaErrorKind::Metadata,
format!(
"Adoptium 的 {name} 属于 Java {},不是请求的 {major}",
release.major
),
));
}
Ok(release)
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,389 @@
//! `api::version` 的单测:端点形状、两页夹具下的翻页、按版本挑而不是挑最新。
//!
//! 从 `version.rs` 拆出来只为守 400 行硬线(M280 r1 加了 P2-1/P2-2 的守卫后那边到 417 行);
//! **纯搬移**,既有断言一字未改。
use super::{
collect_releases, page_outcome, parse_release, parse_release_list, pick_release, release_url,
releases_url, MAX_PAGES, PAGE_SIZE,
};
use crate::game::java::error::{JavaError, JavaErrorKind};
use crate::game::java::target::AdoptiumTarget;
const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64");
/// 真机 `feature_releases/21/ga` 响应的裁剪(2026-11-12,只留两条与用到的字段)。
fn list_body() -> String {
r#"[
{"release_name":"jdk-21.0.13+9","version_data":{"major":21,"semver":"21.0.13+9.0.LTS"},"binaries":[]},
{"release_name":"jdk-21.0.12+8","version_data":{"major":21,"semver":"21.0.12+8.0.LTS"},"binaries":[]}
]"#
.to_string()
}
/// 真机 `assets/release_name/eclipse/jdk-21.0.13+9` 响应的裁剪。
fn release_body() -> String {
r#"{
"release_name": "jdk-21.0.13+9",
"version_data": {"major": 21, "semver": "21.0.13+9.0.LTS", "build": 9},
"binaries": [
{
"os": "windows", "architecture": "x64", "image_type": "jdk", "jvm_impl": "hotspot",
"package": {
"name": "OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip",
"link": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.13%2B9/OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip",
"checksum": "a4dbaa05fca4bd89d84584e58c459bd732efebb1722af80ab13c6516832004f9",
"size": 205421281
}
}
]
}"#
.to_string()
}
#[test]
fn urls_target_the_versioned_endpoints_never_latest() {
let releases = releases_url(21, WINDOWS, 0);
assert!(releases.starts_with("https://api.adoptium.net/v3/assets/feature_releases/21/ga?"));
assert!(releases.contains("page_size=20"));
assert!(releases.contains("page=0"));
// 页码真的进 URL(翻页不是装饰)。
assert!(releases_url(21, WINDOWS, 1).contains("page=1"));
assert!(releases.contains("sort_order=DESC"));
assert!(!releases.contains("latest"));
let release = release_url("jdk-21.0.13+9", WINDOWS);
assert!(release
.starts_with("https://api.adoptium.net/v3/assets/release_name/eclipse/jdk-21.0.13+9?"));
assert!(release.contains("os=windows") && release.contains("arch=x64"));
assert!(!release.contains("latest"));
}
#[test]
fn release_list_keeps_the_upstream_order_and_names() {
assert_eq!(
parse_release_list(&list_body()).expect("应解析"),
vec!["jdk-21.0.13+9".to_string(), "jdk-21.0.12+8".to_string()]
);
let error = parse_release_list("<html>502</html>").expect_err("HTML 应被拒");
assert_eq!(error.kind(), JavaErrorKind::Metadata);
}
#[test]
fn release_body_becomes_the_pinned_archive() {
let release = parse_release(&release_body(), "jdk-21.0.13+9", WINDOWS).expect("应解析");
assert_eq!(release.version, "21.0.13+9", "版本串取自 release 名");
assert_eq!(release.semver.as_deref(), Some("21.0.13+9.0.LTS"));
assert_eq!(release.major, 21);
assert_eq!(
release.archive.name,
"OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip"
);
assert_eq!(release.archive.size, 205_421_281);
assert!(release.archive.url.contains("temurin21-binaries"));
}
#[test]
fn other_platform_has_no_pinned_archive() {
let error = parse_release(
&release_body(),
"jdk-21.0.13+9",
AdoptiumTarget::new("windows", "x86-32"),
)
.expect_err("x86-32 应没有条目");
assert_eq!(error.kind(), JavaErrorKind::Metadata);
}
/// 真机 `17/ga` 的两页形状(2026-11-12 实测 26 条:page=0 满 20 条、page=1 只有 6 条)。
/// 目标版本 `jdk-17.0.4+8` 在**第二页**,第一页 20 条里没有它。
fn page_zero() -> Vec<String> {
// 17.0.21 … 17.0.6 共 16 条 + 4 条补丁变体,凑满一页 20 条;
// 这一页里**没有** 17.0.4(它只在第二页)。
let mut names: Vec<String> = (0..16)
.map(|index| format!("jdk-17.0.{}+9", 21 - index))
.collect();
for filler in ["17.0.5.1+1", "17.0.5+11", "17.0.6.1+1", "17.0.10.1+1"] {
names.push(format!("jdk-{filler}"));
}
assert_eq!(names.len(), PAGE_SIZE);
assert!(
pick_release(&names, "17.0.4").is_none(),
"第一页不许有 17.0.4"
);
names
}
fn page_one() -> Vec<String> {
vec![
"jdk-17.0.4.1+1".to_string(),
"jdk-17.0.4+8".to_string(),
"jdk-17.0.3+7".to_string(),
"jdk-17+35".to_string(),
"jdk-16.0.2+7".to_string(),
"jdk-16+36".to_string(),
]
}
/// 假的上游:按 page 返回夹具(`pages` 用完之后一律空页)。
fn two_pages(page: usize) -> Result<Option<Vec<String>>, JavaError> {
match page {
0 => Ok(Some(page_zero())),
1 => Ok(Some(page_one())),
// 越界页:上游说「没有这一页」(404/410 或 200+空体)
_ => Ok(None),
}
}
/// **P2-1 守卫**:`17.0.4` 只在第二页出现,必须翻到第二页才命中。
///
/// 变异证明(复审实测过的那个):把 `collect_releases` 的循环改成「只取第一页」
/// ⇒ 本用例红(`pick_release` 找不到 `jdk-17.0.4+8`)。
#[tokio::test]
async fn a_version_on_the_second_page_is_still_found() {
let pages: std::cell::RefCell<Vec<usize>> = std::cell::RefCell::new(Vec::new());
let (releases, truncated) = collect_releases(|page| {
pages.borrow_mut().push(page);
async move { two_pages(page) }
})
.await
.expect("两页都应取到");
assert_eq!(*pages.borrow(), vec![0, 1], "应该正好翻两页");
assert_eq!(releases.len(), PAGE_SIZE + page_one().len());
assert!(!truncated, "第二页不满一页 ⇒ 清单是完整的");
assert_eq!(
pick_release(&releases, "17.0.4").map(String::as_str),
Some("jdk-17.0.4+8"),
"第二页那一版必须能被选中"
);
assert_eq!(
pick_release(&releases, "17.0.4.1").map(String::as_str),
Some("jdk-17.0.4.1+1")
);
}
/// **P2-2 守卫 1**:列表里同时有目标与更新的版本时,必须选**目标**,不许取第一条/最新。
///
/// 变异证明(复审的变异 B):把 `pick_release` 换成 `releases.first()`
/// ⇒ 本用例第一条红(会拿到 `jdk-17.0.21+9`)。
#[test]
fn the_pinned_version_wins_over_the_newest_entry() {
let releases = vec![
"jdk-17.0.21+9".to_string(),
"jdk-17.0.12+7".to_string(),
"jdk-17.0.4+8".to_string(),
];
assert_eq!(
pick_release(&releases, "17.0.4").map(String::as_str),
Some("jdk-17.0.4+8"),
"点名 17.0.4 时不许拿最新的 17.0.21+9"
);
assert_eq!(
pick_release(&releases, "17.0.12+7").map(String::as_str),
Some("jdk-17.0.12+7")
);
assert_eq!(pick_release(&releases, "17.0.99"), None);
}
/// 上限兜底:上游每页都满(异常情况)时,翻到 [`MAX_PAGES`] 页就停,并如实标「还有更多」。
///
/// 变异证明:把 `collect_releases` 的 `for page in 0..MAX_PAGES` 改成 `loop`
/// ⇒ 夹具永远满页,本用例挂死/超时(红)。
#[tokio::test]
async fn a_never_ending_catalog_stops_at_the_page_cap_and_says_so() {
let calls = std::cell::Cell::new(0usize);
let (releases, truncated) = collect_releases(|page| {
calls.set(calls.get() + 1);
async move {
let _ = page;
Ok(Some(
(0..PAGE_SIZE)
.map(|index| format!("jdk-17.0.{}+1", index + 1))
.collect::<Vec<_>>(),
))
}
})
.await
.expect("每页都成功");
assert_eq!(calls.get(), MAX_PAGES, "翻满上限就停");
assert_eq!(releases.len(), MAX_PAGES * PAGE_SIZE);
assert!(truncated, "到上限时必须标成截断,文案才能如实说");
}
/// **判据 1(别退回去)**:某条 GA 线的条数恰好是 20 的整数倍时,最后一页是满页 ⇒ 会多要一页
/// ⇒ 上游对那一页说「没有这一页」(404/410 或 200+空体,见 [`page_outcome`])⇒
/// 必须把**已经取到的**交出去,且 `truncated = false`。
///
/// 它真正钉住的是「`collect_releases` 收到『这一页不存在』时要把已取到的交出去」;
/// **不是**「上游越界页就是 404」——后者只能靠 curl 实测,夹具手写不出来
/// (r3 复审 P3-1 的更正)。
///
/// 变异证明:把 `collect_releases` 里 `let Some(names) = … else { return Ok((all, false)) }`
/// 的 `None` 分支改成 `return Err(…)`(即「没有这一页」也当失败)⇒ 本用例红。
#[tokio::test]
async fn a_missing_page_after_a_full_page_ends_the_tail_cleanly() {
let calls = std::cell::Cell::new(0usize);
let result = collect_releases(|page| {
calls.set(calls.get() + 1);
async move {
match page {
0 => Ok(Some(page_zero())),
// 越界页的语义信号:上游明确说「没有这一页」。
_ => Ok(None),
}
}
})
.await;
let (releases, truncated) = result.expect("第一页拿到了就是拿到了,越界页不该作废它");
assert_eq!(releases.len(), PAGE_SIZE, "正好一页,条数要精确");
assert_eq!(releases, page_zero(), "逐条相等(不是「大概 20 个」)");
assert!(!truncated, "这是「翻到尾巴」,不是「到上限」");
assert_eq!(
calls.get(),
2,
"确实多要了第二页(这正是上游说 404 的那一发)"
);
}
/// **判据 1 的语义底座**:`page_outcome` 把「这一页不存在」与「这一页读不懂」分开,
/// 判据是状态码 + 体的形状,**不匹配任何错误文案**。
///
/// 变异证明:把 404/410 那一支删掉 ⇒ 第一条红;把空体那一支删掉 ⇒ 第三条红;
/// 把解析失败并进 `Ok(None)` ⇒ 最后一条红。
#[test]
fn a_missing_page_is_404_410_or_an_empty_body_and_nothing_else() {
assert_eq!(page_outcome(404, "").expect("404 = 没有这一页"), None);
assert_eq!(
page_outcome(410, "<html>gone</html>").expect("410 = 没有这一页"),
None
);
assert_eq!(
page_outcome(200, " \n\t ").expect("200 + 空体 = 没有这一页"),
None
);
// 正常清单:真的解析出名字(顺序即上游给的顺序)。
assert_eq!(
page_outcome(200, &list_body()).expect("应解析"),
Some(vec![
"jdk-21.0.13+9".to_string(),
"jdk-21.0.12+8".to_string()
])
);
// 读不懂 = 错误,**不许**被当成「没有这一页」。
let parse_failure = page_outcome(200, "<html>502 Bad Gateway</html>").expect_err("HTML 应被拒");
assert_eq!(parse_failure.kind(), JavaErrorKind::Metadata);
// 其它非 2xx 是网络侧,同样不许被当成「没有这一页」。
for status in [500u16, 403, 429, 502] {
let error = page_outcome(status, "").expect_err("非 2xx 应报错");
assert_eq!(error.kind(), JavaErrorKind::Network, "status={status}");
}
}
/// **判据 2(r3 的 p2 本体)**:第二页**读不懂**(解析失败)⇒ 必须 `Err`,绝不许 `Ok`。
///
/// 夹具用的是**真的** `parse_release_list` 产出的错误(不是手写字符串),所以它跟着解析实现走。
/// 返回类型是 `Result<(Vec<String>, bool), JavaError>`,`Err` 分支在类型上就**不可能**带出
/// 部分结果——「不能既 Err 又把 all 交出去」由签名保证,用例断言的是「不许变成 Ok」。
///
/// 变异证明:在 `collect_releases` 里把 `Metadata` 错误重新并进「翻到尾巴」的分支
/// (`Err(e) if e.kind() == Metadata => return Ok((all, false))`)⇒ 本用例红。
#[tokio::test]
async fn a_parse_failure_on_a_later_page_is_an_error_not_the_end_of_the_list() {
let parse_failure = parse_release_list("<html>502 Bad Gateway</html>")
.expect_err("HTML 不是 GA 清单,必须报错");
assert_eq!(
parse_failure.kind(),
JavaErrorKind::Metadata,
"先确认这一类错误确实落在 Metadata 桶里(这正是 r3 那条 p2 的入口)"
);
let result = collect_releases(|page| {
let error = parse_failure.clone();
async move {
match page {
0 => Ok(Some(page_zero())),
_ => Err(error),
}
}
})
.await;
let error = result.expect_err("第二页读不懂 ⇒ 必须报错,不许静默截断");
assert_eq!(error.kind(), JavaErrorKind::Metadata);
assert!(
error.message().contains("不是预期 JSON"),
"报的必须是解析失败本身,不是一句无关的 404:{}",
error.message()
);
}
/// **判据 3**:错误落在**最后一页**(`page == MAX_PAGES - 1`)时同样不许报「清单完整」——
/// 那是被错误截断,不是「上游就这些」。
///
/// 变异证明:同判据 2 的那个变异(把 `Metadata` 并进吞掉的分支)⇒ 本用例红
/// (会拿到 `Ok((…, false))`,即文案会走 `Complete`)。
#[tokio::test]
async fn an_error_on_the_last_allowed_page_is_never_reported_as_complete() {
let parse_failure =
parse_release_list("<html>502</html>").expect_err("HTML 不是 GA 清单,必须报错");
let full_page: Vec<String> = (0..PAGE_SIZE)
.map(|index| format!("jdk-17.0.{}+1", index + 1))
.collect();
let result = collect_releases(|page| {
let error = parse_failure.clone();
let full_page = full_page.clone();
async move {
if page + 1 == MAX_PAGES {
Err(error)
} else {
Ok(Some(full_page))
}
}
})
.await;
let error = result.expect_err("最后一页读不懂也必须报错,不许报「完整」");
assert_eq!(error.kind(), JavaErrorKind::Metadata);
assert!(
error.message().contains("不是预期 JSON"),
"{}",
error.message()
);
}
/// 网络错误在任何一页都不许被当成「翻到尾巴」——那是真坏了。
///
/// 变异证明:把 `fetch_page` 的错误改成一律 `Ok(None)` ⇒ 本用例红。
#[tokio::test]
async fn a_network_error_on_a_later_page_is_not_swallowed() {
let result = collect_releases(|page| async move {
match page {
0 => Ok(Some(page_zero())),
_ => Err(JavaError::new(
JavaErrorKind::Network,
"连不上 Adoptium API:connection reset",
)),
}
})
.await;
let error = result.expect_err("网络错误要冒泡");
assert_eq!(error.kind(), JavaErrorKind::Network);
}
/// **第一页就不存在**:那是「这个大版本真没有 GA 清单」,仍然报错(与 r2 之前同口径),
/// 不许被当成「翻到尾巴」而返回空清单——否则用户会看到
/// 「没有版本 X(上游一个都没列出来)」这种什么都说明不了的文案。
///
/// 变异证明:把 `collect_releases` 里 `if page == 0` 那一支删掉 ⇒ 本用例红。
#[tokio::test]
async fn a_missing_first_page_is_still_an_error() {
let result = collect_releases(|_page| async { Ok::<_, JavaError>(None) }).await;
let error = result.expect_err("第一页就不存在 = 这个 major 没有 GA 清单");
assert_eq!(error.kind(), JavaErrorKind::Metadata);
assert!(
error.message().contains("没有该大版本的 GA 清单"),
"{}",
error.message()
);
}
+173 -64
View File
@@ -14,7 +14,15 @@
//! //!
//! [`BASELINE_MAJOR`](17)是 D13 定的基准:缺 Java 时先装它。它同时也是目录的一员, //! [`BASELINE_MAJOR`](17)是 D13 定的基准:缺 Java 时先装它。它同时也是目录的一员,
//! 但不是唯一一员——`26.1` 起 MC 要 25,`1.12.2` 只能吃 8,所以要能挑。 //! 但不是唯一一员——`26.1` 起 MC 要 25,`1.12.2` 只能吃 8,所以要能挑。
//!
//! ## 指定版本(M280)
//!
//! 「用户给的版本串是不是这一份」只有一个判据:[`matches_version`]。四家的版本串语义不同
//! (Temurin `21.0.13+9`、Oracle `21.0.2`、MS `21.0.13.0.2`、Zulu `21.0.13.0.3`),
//! 但「相等」这件事可以统一:先按 [`public_version`] 去掉构建号段(`+9`)再比。
//! 各家模块只负责**把候选版本喂进来**,不许自己再写一份前缀/包含判断。
use super::error::{JavaError, JavaErrorKind};
use super::vendor::{self, JdkVendor}; use super::vendor::{self, JdkVendor};
/// D13 的基准 JDK 大版本(没有上下文时「装哪个」的默认答案)。 /// D13 的基准 JDK 大版本(没有上下文时「装哪个」的默认答案)。
@@ -104,68 +112,169 @@ pub fn default_major(required: Option<u32>) -> u32 {
.unwrap_or(BASELINE_MAJOR) .unwrap_or(BASELINE_MAJOR)
} }
/// 版本串的规范化:去首尾空白 + 去掉 `jdk-` 前缀(Adoptium 的 release 名是 `jdk-21.0.13+9`)。
#[must_use]
pub fn canonical_version(text: &str) -> &str {
let trimmed = text.trim();
trimmed
.strip_prefix("jdk-")
.or_else(|| trimmed.strip_prefix("JDK-"))
.unwrap_or(trimmed)
.trim()
}
/// 版本串的「公共部分」:`21.0.13+9` / `21.0.13+9.0.LTS` → `21.0.13`(`+` 之后是构建号)。
///
/// 目录 `/ 戳记 / --json` 用的是 vendor 原生整串,只有「是不是同一个版本」这件事看公共部分。
#[must_use]
pub fn public_version(text: &str) -> &str {
let canonical = canonical_version(text);
let end = canonical.find('+').unwrap_or(canonical.len());
canonical[..end].trim_end()
}
/// 构建号的首段数字(`+9` / `+9.0.LTS` / `+9-LTS` 都归成 `9`;没有 `+` 段时 `None`)。
fn build_head(text: &str) -> Option<u32> {
let canonical = canonical_version(text);
let rest = canonical.get(canonical.find('+')? + 1..)?;
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
digits.parse().ok()
}
/// 用户点名的版本就是这份候选吗(四家共用的**唯一**判据)。
///
/// 三种情形都算命中:整串相同(`21.0.13+9`)、只给公共部分(`21.0.13` ↔ `21.0.13+9`)、
/// 构建号首段相同(`21.0.13+9` ↔ `21.0.13+9.0.LTS`)。
/// **不做**前缀近似:`21.0` 不算 `21.0.13`,`21.0.13` 也不算 Microsoft 的 `21.0.13.0.2`
/// —— 少一位就是另一个版本,宁可报错并列出该大版本的可用版本。
#[must_use]
pub fn matches_version(requested: &str, candidate: &str) -> bool {
let wanted = canonical_version(requested);
let found = canonical_version(candidate);
if wanted.is_empty() || found.is_empty() || public_version(wanted) != public_version(found) {
return false;
}
match (build_head(wanted), build_head(found)) {
// 只给公共部分:该公共部分的唯一产物就是它。
(None, _) => true,
(Some(wanted), Some(found)) => wanted == found,
// 用户点名了构建号、候选里没给:不能当作命中(可能不是同一个构建)。
(Some(_), None) => false,
}
}
/// 兜底闸:实际拿到的版本必须真是用户点名的那一版(`None` = 没钉版本,永远通过)。
///
/// 这是「不静默回落」的最后一道保险:即使某家的实现哪天真回落到了 latest,
/// 调用方(`install` / `plan`)也会把它记成一次失败并继续走链,而不是把 latest
/// 当成用户要的版本装下去。
#[must_use]
pub fn version_mismatch(wanted: Option<&str>, found: &str) -> Option<String> {
let wanted = wanted?;
(!matches_version(wanted, found)).then(|| format!("返回的版本是 {found},不是请求的 {wanted}"))
}
/// 可用版本清单的完整度:决定错误文案里那句「还有没有更多」怎么说。
///
/// 上游的版本清单有三种形态:Oracle 目录页一次就能看全、Microsoft 只有一份快照、
/// Zulu/Termux 压根没有清单([`Complete`]);Temurin 要走分页,翻到上限还没翻完时
/// 只能如实说「只查了前 N 条」——**不许把窗口当全集**。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VersionListScope {
/// 上游的清单已经全看到了。
Complete,
/// 只看了上游的前若干条(Temurin 翻页到上限)。
Truncated,
}
/// 可用版本列表的人读形态(错误里列给人看;超过 `limit` 条就收口成「等 N 个」)。
///
/// `scope` 只影响尾巴那句:`Complete` 时「等 N 个」就是上游总数,`Truncated` 时补一句
/// 「只查了前 N 条」,因为这时 N 是**我们的窗口**而不是上游的总数。
#[must_use]
pub fn available_versions_text(
versions: &[String],
limit: usize,
scope: VersionListScope,
) -> String {
if versions.is_empty() {
return "(上游一个都没列出来)".to_string();
}
let shown = versions
.iter()
.take(limit)
.map(String::as_str)
.collect::<Vec<_>>()
.join(" / ");
let listed = if versions.len() > limit {
format!("{shown} 等 {} 个", versions.len())
} else {
shown
};
match scope {
VersionListScope::Complete => listed,
VersionListScope::Truncated => {
format!("{listed}(只查了前 {} 条,后面可能还有)", versions.len())
}
}
}
/// 「这家装不了你要的那个版本」的错误:**四家文案互不相同**,各自说清原因 + 一句「哪家能做」。
///
/// `available` 是这家真正能给的版本串(Temurin/Oracle 给该大版本的清单,MS 给快照里那一个,
/// Zulu/Termux 给空数组——上游根本没有版本清单)。这条错误是用户唯一能看见的下一步,
/// 所以「不静默回落、也不假装支持」这句话只说一次,就在这儿说。
#[must_use]
pub fn version_pin_error(
vendor: JdkVendor,
major: u32,
wanted: &str,
available: &[String],
scope: VersionListScope,
) -> JavaError {
let listed = available_versions_text(available, 10, scope);
let try_other = "要这个版本换一家:`--vendor temurin`(Adoptium 全量补丁)或 `--vendor oracle`";
let vendor_id = vendor.id();
match vendor {
JdkVendor::Temurin | JdkVendor::Oracle => {
// `source` 自己带连接词:拉丁文名与中文名需要的空格不一样,
// 模板里再补一个空格就会拼出「Oracle 镜像目录 里可用」。
let source = if vendor == JdkVendor::Temurin {
"Adoptium 里"
} else {
"Oracle 镜像目录里"
};
JavaError::new(
JavaErrorKind::Metadata,
format!("{vendor_id} 的 Java {major} 没有版本 {wanted}({source}可用:{listed})"),
)
.with_hint("不给 --version 就装该大版本最新的 GA;指定版本要写上面列出的形态")
}
JdkVendor::Microsoft => JavaError::new(
JavaErrorKind::Metadata,
format!(
"microsoft 的 Java {major} 只内置了 {listed} 这一份快照,没有 {wanted}(这家不能自选版本,要自选请换 temurin 或 oracle)"
),
)
.with_hint(format!(
"微软这套版本号写死在快照里:装它就 `ull java install {major} --vendor microsoft`;{try_other}"
)),
JdkVendor::Zulu => JavaError::new(
JavaErrorKind::Metadata,
format!("zulu 只查最新一份(api 的 latest=true),不能指定版本 {wanted}(要自选请换 temurin 或 oracle)"),
)
.with_hint(format!(
"只装该大版本最新的 Zulu 就去掉 --version;{try_other}"
)),
JdkVendor::Termux => JavaError::new(
JavaErrorKind::Unsupported,
format!(
"termux 的 JDK 由系统包(pkg install openjdk-{major})决定版本,不能指定 {wanted}(普通平台上要自选请换 temurin 或 oracle)"
),
)
.with_hint("去掉 --version 让系统包装它给的那份;要指定版本只能在普通平台上装归档"),
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests;
use super::{choices, default_major, find, vendors_for, BASELINE_MAJOR};
use crate::game::java::vendor::{ids, JdkVendor};
/// 目录必须覆盖用户点名的三档(8 / 17 / 21),并钉住基准版本。
///
/// 变异证明:从 `CHOICES` 里删掉 8 那一项 ⇒ 本用例红(`find(8)` 为 `None`)。
#[test]
fn the_catalog_covers_eight_seventeen_and_twenty_one() {
for major in [8, 17, 21] {
assert!(find(major).is_some(), "目录里必须有 Java {major}");
}
assert_eq!(BASELINE_MAJOR, 17, "D13 的基准版本是 17");
assert!(find(BASELINE_MAJOR).expect("基准版本在目录里").baseline);
assert_eq!(
choices().len(),
5,
"目录是 8/11/17/21/25 五档,改动要同步本断言与 UI 文案"
);
assert!(
choices()
.windows(2)
.all(|pair| pair[0].major < pair[1].major),
"目录必须升序(UI 直接按这个顺序画)"
);
}
/// 「8 只能由 Temurin / Zulu 承接」:Oracle 从 9 起、MS 已停发 8。
///
/// 变异证明:把 `vendors_for` 里的 `supports` 过滤删掉 ⇒ 本用例红(8 会列出 oracle/microsoft)。
#[test]
fn java_eight_is_only_served_by_the_vendors_that_still_ship_it() {
assert_eq!(ids(&vendors_for(8, &[], true)), vec!["temurin", "zulu"]);
assert_eq!(
ids(&vendors_for(21, &[], true)),
vec!["temurin", "oracle", "microsoft", "zulu"],
"21 四家都在(国内默认序)"
);
assert!(
!vendors_for(8, &[], true).contains(&JdkVendor::Microsoft),
"微软已停发 8"
);
}
/// 用户优先级要真的排在最前面(与 `install` 的链同源)。
#[test]
fn the_user_priority_leads_the_vendor_list() {
let priority = vec![JdkVendor::Zulu];
assert_eq!(ids(&vendors_for(17, &priority, true))[0], "zulu");
assert_eq!(ids(&vendors_for(17, &priority, true)).len(), 4);
}
/// 「该装哪个」对齐到目录里的一档:向上取整,没有上下文给基准,超出目录给基准。
#[test]
fn the_default_major_rounds_up_into_the_catalog() {
assert_eq!(default_major(None), 17);
assert_eq!(default_major(Some(8)), 8, "1.12.2 → 8");
assert_eq!(default_major(Some(16)), 17, "1.17 要 16 → 目录里的 17");
assert_eq!(default_major(Some(17)), 17);
assert_eq!(default_major(Some(21)), 21, "1.20.5+ → 21");
assert_eq!(default_major(Some(25)), 25, "26.1 → 25");
assert_eq!(default_major(Some(99)), 17, "超出目录时退回基准");
}
}
+241
View File
@@ -0,0 +1,241 @@
//! `catalog` 的单测:目录、版本判据、拒绝文案、兜底闸。
//!
//! 从 `catalog.rs` 拆出来只为守 400 行硬线(M280 r1 要在这里加「兜底闸」与「清单被截断」
//! 的用例,留在主文件会到 470+);**纯搬移**,既有断言一字未改。
use super::{
available_versions_text, choices, default_major, find, matches_version, vendors_for,
version_mismatch, version_pin_error, VersionListScope, BASELINE_MAJOR,
};
use crate::game::java::vendor::{ids, JdkVendor};
/// 目录必须覆盖用户点名的三档(8 / 17 / 21),并钉住基准版本。
///
/// 变异证明:从 `CHOICES` 里删掉 8 那一项 ⇒ 本用例红(`find(8)` 为 `None`)。
#[test]
fn the_catalog_covers_eight_seventeen_and_twenty_one() {
for major in [8, 17, 21] {
assert!(find(major).is_some(), "目录里必须有 Java {major}");
}
assert_eq!(BASELINE_MAJOR, 17, "D13 的基准版本是 17");
assert!(find(BASELINE_MAJOR).expect("基准版本在目录里").baseline);
assert_eq!(
choices().len(),
5,
"目录是 8/11/17/21/25 五档,改动要同步本断言与 UI 文案"
);
assert!(
choices()
.windows(2)
.all(|pair| pair[0].major < pair[1].major),
"目录必须升序(UI 直接按这个顺序画)"
);
}
/// 「8 只能由 Temurin / Zulu 承接」:Oracle 从 9 起、MS 已停发 8。
///
/// 变异证明:把 `vendors_for` 里的 `supports` 过滤删掉 ⇒ 本用例红(8 会列出 oracle/microsoft)。
#[test]
fn java_eight_is_only_served_by_the_vendors_that_still_ship_it() {
assert_eq!(ids(&vendors_for(8, &[], true)), vec!["temurin", "zulu"]);
assert_eq!(
ids(&vendors_for(21, &[], true)),
vec!["temurin", "oracle", "microsoft", "zulu"],
"21 四家都在(国内默认序)"
);
assert!(
!vendors_for(8, &[], true).contains(&JdkVendor::Microsoft),
"微软已停发 8"
);
}
/// 用户优先级要真的排在最前面(与 `install` 的链同源)。
#[test]
fn the_user_priority_leads_the_vendor_list() {
let priority = vec![JdkVendor::Zulu];
assert_eq!(ids(&vendors_for(17, &priority, true))[0], "zulu");
assert_eq!(ids(&vendors_for(17, &priority, true)).len(), 4);
}
/// 「该装哪个」对齐到目录里的一档:向上取整,没有上下文给基准,超出目录给基准。
#[test]
fn the_default_major_rounds_up_into_the_catalog() {
assert_eq!(default_major(None), 17);
assert_eq!(default_major(Some(8)), 8, "1.12.2 → 8");
assert_eq!(default_major(Some(16)), 17, "1.17 要 16 → 目录里的 17");
assert_eq!(default_major(Some(17)), 17);
assert_eq!(default_major(Some(21)), 21, "1.20.5+ → 21");
assert_eq!(default_major(Some(25)), 25, "26.1 → 25");
assert_eq!(default_major(Some(99)), 17, "超出目录时退回基准");
}
/// 四家的版本串用同一把尺子量(`matches_version` 是唯一判据)。
///
/// 变异证明:把 `matches_version` 换成 `starts_with` ⇒ 本用例红
/// (`21.0.13` 会命中 MS 的 `21.0.13.0.2`,那是另一个版本)。
#[test]
fn version_matching_is_shared_and_never_prefix_fuzzy() {
// Temurin:公共部分 ↔ 带构建号;semver 与 release 名两种写法都算同一份。
assert!(matches_version("21.0.13", "21.0.13+9"));
assert!(matches_version("21.0.13+9", "jdk-21.0.13+9"));
assert!(matches_version("21.0.13+9", "21.0.13+9.0.LTS"));
assert!(matches_version(" 21.0.13 ", "21.0.13"));
// Oracle:目录名就是三段,公共部分一致即命中。
assert!(matches_version("21.0.2", "21.0.2"));
// 少一位、多一段都不是同一个版本(不许前缀近似)。
assert!(!matches_version("21.0", "21.0.13"));
assert!(!matches_version("21.0.13", "21.0.13.0.2"));
assert!(!matches_version("21.0.13+8", "21.0.13+9"));
// 用户点名了构建号、候选没给 → 不算命中。
assert!(!matches_version("21.0.13+9", "21.0.13"));
assert!(!matches_version("", "21.0.13"));
}
/// 可用版本列表:给几条就列几条,多了收口(错误文案不能无限长)。
#[test]
fn available_versions_text_caps_the_list() {
let versions: Vec<String> = ["21.0.13", "21.0.12", "21.0.11"]
.iter()
.map(|text| (*text).to_string())
.collect();
assert_eq!(
available_versions_text(&versions, 10, VersionListScope::Complete),
"21.0.13 / 21.0.12 / 21.0.11"
);
assert_eq!(
available_versions_text(&versions, 2, VersionListScope::Complete),
"21.0.13 / 21.0.12 等 3 个"
);
assert!(available_versions_text(&[], 5, VersionListScope::Complete).contains("一个都没列出来"));
}
/// 四家的「这家不能按你要的版本装」文案必须**互不相同**、各自说清原因,且给下一步。
///
/// 变异证明:把某两家共用同一句「不支持指定版本」⇒ 本用例红。
#[test]
fn pin_refusals_are_distinct_and_each_explains_itself() {
let snapshot = vec!["21.0.13.0.2".to_string()];
let listed = vec!["21.0.13+9".to_string()];
let errors = [
version_pin_error(
JdkVendor::Temurin,
21,
"21.0.5",
&listed,
VersionListScope::Complete,
),
version_pin_error(
JdkVendor::Oracle,
21,
"21.0.5",
&["21.0.2".to_string()],
VersionListScope::Complete,
),
version_pin_error(
JdkVendor::Microsoft,
21,
"21.0.5",
&snapshot,
VersionListScope::Complete,
),
version_pin_error(
JdkVendor::Zulu,
21,
"21.0.5",
&[],
VersionListScope::Complete,
),
version_pin_error(
JdkVendor::Termux,
21,
"21.0.5",
&[],
VersionListScope::Complete,
),
];
for (index, left) in errors.iter().enumerate() {
for right in errors.iter().skip(index + 1) {
assert_ne!(
left.message(),
right.message(),
"每家的拒绝理由必须各说各的"
);
}
assert!(
left.message().contains("21.0.5"),
"错误里要带上用户要的版本"
);
}
assert!(
errors[0].message().contains("21.0.13+9"),
"可用版本要列出来"
);
// P3-3:连接词由 `source` 自带,模板不再补空格 —— 两种拼法都必须读得通。
// (正向断言已蕴含「没有多余空格」,所以不留反向断言:r3 复审 P3-2 判它冗余。)
assert!(
errors[0].message().contains("Adoptium 里可用"),
"{}",
errors[0].message()
);
assert!(
errors[1].message().contains("Oracle 镜像目录里可用"),
"{}",
errors[1].message()
);
assert!(errors[2].message().contains("只内置了 21.0.13.0.2"));
assert!(errors[2]
.hint()
.unwrap_or_default()
.contains("--vendor microsoft"));
assert!(errors[3].message().contains("latest=true"));
assert!(errors[3]
.hint()
.unwrap_or_default()
.contains("--vendor temurin"));
assert!(errors[4].message().contains("系统包"));
}
/// **兜底闸(P2-2 守卫 2)**:实际拿到的版本与请求不符时必须给出原因,相符/没钉版本时必须放行。
///
/// 变异证明:把 `version_mismatch` 改成恒 `None`(复审的变异 A)⇒ 本用例第一条红。
#[test]
fn the_version_gate_rejects_a_different_version_and_lets_everything_else_through() {
let reason = version_mismatch(Some("21.0.12"), "21.0.13+9")
.expect("要 21.0.12、拿到 21.0.13+9 ⇒ 必须拒绝");
assert!(reason.contains("21.0.13+9"), "{reason}");
assert!(reason.contains("不是请求的 21.0.12"), "{reason}");
// 同一个版本(公共部分一致、构建号一致)⇒ 放行。
assert_eq!(version_mismatch(Some("21.0.12"), "21.0.12+8"), None);
assert_eq!(version_mismatch(Some("21.0.12+8"), "jdk-21.0.12+8"), None);
// 没钉版本 ⇒ 恒放行(不许对默认路径误报)。
assert_eq!(version_mismatch(None, "21.0.13+9"), None);
// 构建号不同 ⇒ 拒绝。
assert!(version_mismatch(Some("21.0.12+8"), "21.0.12+9").is_some());
}
/// **清单被截断时不许把窗口当全集(P2-1 文案)**:`Complete` 说「等 N 个」,
/// `Truncated` 必须补一句「只查了前 N 条」。
///
/// 变异证明:把 `Truncated` 分支改成与 `Complete` 相同 ⇒ 本用例第二条红。
#[test]
fn a_truncated_list_never_pretends_to_be_the_whole_catalog() {
let versions: Vec<String> = (0..20).map(|index| format!("17.0.{}", index + 1)).collect();
let complete = available_versions_text(&versions, 2, VersionListScope::Complete);
let truncated = available_versions_text(&versions, 2, VersionListScope::Truncated);
assert_eq!(complete, "17.0.1 / 17.0.2 等 20 个");
assert_ne!(complete, truncated);
assert!(
truncated.contains("只查了前 20 条"),
"截断时必须如实说:{truncated}"
);
// 完整清单的拒绝文案里不许出现「只查了」。
let error = version_pin_error(
JdkVendor::Temurin,
17,
"17.0.99",
&versions,
VersionListScope::Complete,
);
assert!(!error.message().contains("只查了"), "{}", error.message());
}
+68 -40
View File
@@ -3,7 +3,7 @@
//! ```text //! ```text
//! [Termux] termux_install::attempt 系统包(.deb)——与下面这条链不共用任何一步 //! [Termux] termux_install::attempt 系统包(.deb)——与下面这条链不共用任何一步
//! vendor::order(用户优先级, 地区) D23 的尝试顺序 //! vendor::order(用户优先级, 地区) D23 的尝试顺序
//! → source::fetch(vendor, major) 查到该家最新 GA(Temurin/Oracle/MS/Zulu) //! → source::fetch(vendor, major, 版本) 该家最新 GA,或 `--version` 指定的那一版
//! → location::JavaInstallRoot::jdk_dir 按落点模式算目录 //! → location::JavaInstallRoot::jdk_dir 按落点模式算目录
//! → [戳记 sha256 相同 && bin/java 在] 复用 幂等的第一层 //! → [戳记 sha256 相同 && bin/java 在] 复用 幂等的第一层
//! → engine.download(JavaRuntime 请求) 镜像/健康度/退避/续传/边下边算全在引擎 //! → engine.download(JavaRuntime 请求) 镜像/健康度/退避/续传/边下边算全在引擎
@@ -28,6 +28,7 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use super::catalog;
use super::error::{JavaError, JavaErrorKind}; use super::error::{JavaError, JavaErrorKind};
use super::extract; use super::extract;
use super::location::{JavaInstallRoot, LocationMode}; use super::location::{JavaInstallRoot, LocationMode};
@@ -35,14 +36,19 @@ use super::probe::{self, JavaRuntime};
use super::release::JdkRelease; use super::release::JdkRelease;
use super::source::{self, ChainLog}; use super::source::{self, ChainLog};
use super::target::AdoptiumTarget; use super::target::AdoptiumTarget;
use super::termux;
use super::vendor::{self, JdkVendor}; use super::vendor::{self, JdkVendor};
use crate::download::{DownloadEngine, DownloadRequest, ResourceKind}; use crate::download::{DownloadEngine, DownloadRequest, ResourceKind};
use one::install_one; use one::install_one;
mod chain;
mod one; mod one;
mod plan;
mod termux_install; mod termux_install;
use chain::resolve_one;
/// 归档缓存目录名(在安装根下;托管目录扫描跳过它)。 /// 归档缓存目录名(在安装根下;托管目录扫描跳过它)。
pub use super::location::CACHE_DIR; pub use super::location::CACHE_DIR;
@@ -55,6 +61,11 @@ pub struct InstallRequest {
pub vendor: Option<JdkVendor>, pub vendor: Option<JdkVendor>,
/// 用户优先级(`java.vendorPriority`,空 = 按地区默认序)。 /// 用户优先级(`java.vendorPriority`,空 = 按地区默认序)。
pub priority: Vec<JdkVendor>, pub priority: Vec<JdkVendor>,
/// 钉死具体版本(`--version`);`None` = 该家**最新 GA**(M280 之前的唯一行为)。
///
/// 能不能钉由各家的上游决定(Temurin/Oracle 能、Microsoft 只认它的内置快照、
/// Zulu 只有 latest)——不能钉时**报错**,不会回落到最新还声称装成了这一版。
pub version: Option<String>,
} }
impl InstallRequest { impl InstallRequest {
@@ -65,6 +76,7 @@ impl InstallRequest {
major, major,
vendor: None, vendor: None,
priority: Vec::new(), priority: Vec::new(),
version: None,
} }
} }
@@ -75,6 +87,7 @@ impl InstallRequest {
major, major,
vendor: Some(vendor), vendor: Some(vendor),
priority: Vec::new(), priority: Vec::new(),
version: None,
} }
} }
@@ -85,6 +98,13 @@ impl InstallRequest {
self self
} }
/// 钉死具体版本(`--version`;`None` 与不调用本方法完全等价)。
#[must_use]
pub fn with_version(mut self, version: Option<String>) -> Self {
self.version = version;
self
}
/// 实际尝试顺序(`--vendor` 钉死时链里只放它)。 /// 实际尝试顺序(`--vendor` 钉死时链里只放它)。
#[must_use] #[must_use]
pub fn order(&self, domestic: bool) -> Vec<JdkVendor> { pub fn order(&self, domestic: bool) -> Vec<JdkVendor> {
@@ -200,13 +220,37 @@ pub async fn install(
) -> Result<InstalledJdk, JavaError> { ) -> Result<InstalledJdk, JavaError> {
let domestic = engine.region().is_domestic(); let domestic = engine.region().is_domestic();
let priority = request.order(domestic); let priority = request.order(domestic);
let wanted = request.version.as_deref();
let mut reasons: Vec<String> = Vec::new(); let mut reasons: Vec<String> = Vec::new();
// 清掉上次中断留下的 .staging-* 与空壳(只碰我们自己的形状,见 location 模块)。 // 清掉上次中断留下的 .staging-* 与空壳(只碰我们自己的形状,见 location 模块)。
let _ = super::location::cleanup_orphans(&location.root); let _ = super::location::cleanup_orphans(&location.root);
// Termux 那一条路(系统包)单独成模块:它与归档链不共用任何一步,见 `termux_install`。 // Termux 那一条路(系统包)单独成模块:它与归档链不共用任何一步,见 `termux_install`。
// 它把 chain 还回来(没走成时),归档链接着往上记。 // 它把 chain 还回来(没走成时),归档链接着往上记。
let mut chain = match termux_install::attempt( // 系统包的版本由包管理器决定,所以钉了版本(`--version`)就不走它:`--vendor termux`
// 直接报「不能指定版本」,其余情况记一条跳过原因后交给归档链。
let mut chain = match wanted {
Some(wanted) if request.vendor == Some(JdkVendor::Termux) => {
return Err(catalog::version_pin_error(
JdkVendor::Termux,
request.major,
wanted,
&[],
catalog::VersionListScope::Complete,
));
}
Some(_) => {
let mut chain = ChainLog::new();
// 只有真在 Termux 上才需要解释「为什么不走系统包」(别的平台这条路本来就不存在)。
if termux::available() {
chain.skip(
JdkVendor::Termux,
"系统包不能指定版本(--version),改走归档链",
);
}
chain
}
None => match termux_install::attempt(
request.vendor, request.vendor,
request.major, request.major,
location, location,
@@ -217,6 +261,7 @@ pub async fn install(
{ {
termux_install::Outcome::Done(installed) => return Ok(*installed), termux_install::Outcome::Done(installed) => return Ok(*installed),
termux_install::Outcome::Skipped(chain) => chain, termux_install::Outcome::Skipped(chain) => chain,
},
}; };
// 归档发行版需要 Adoptium 的平台参数;Termux/Android 上这里会 Unsupported, // 归档发行版需要 Adoptium 的平台参数;Termux/Android 上这里会 Unsupported,
@@ -245,11 +290,17 @@ pub async fn install(
continue; continue;
} }
}; };
let release = match source::fetch(engine, vendor, request.major, target).await { // 取版本 + 兜底闸(版本必须真是点名的那一版,任何一家都不许回落 latest):
// 判据在 `chain::resolve_one` 里,`--dry-run` 的 `plan` 走同一个函数。
let release = match resolve_one(wanted, || {
source::fetch(engine, vendor, request.major, target, wanted)
})
.await
{
Ok(release) => release, Ok(release) => release,
Err(error) => { Err(reason) => {
chain.failed(vendor, error.message().to_string()); chain.failed(vendor, reason.clone());
reasons.push(format!("{}:{}", vendor.id(), error.message())); reasons.push(format!("{}:{reason}", vendor.id()));
continue; continue;
} }
}; };
@@ -278,7 +329,7 @@ pub async fn install(
} }
} }
Err(JavaError::new( let error = JavaError::new(
JavaErrorKind::Metadata, JavaErrorKind::Metadata,
format!( format!(
"没有装成 Java {}(试过 {}):{}", "没有装成 Java {}(试过 {}):{}",
@@ -286,42 +337,19 @@ pub async fn install(
priority.len(), priority.len(),
reasons.join(";") reasons.join(";")
), ),
);
// 钉了版本时给一句能照着做的:哪几家真能按版本装(默认路径的提示一个字都不动)。
Err(if wanted.is_some() {
error.with_hint(
"能按版本装的是 `--vendor temurin` 与 `--vendor oracle`;\
microsoft 只认它内置的那份快照,zulu 上游只提供 latest",
) )
.with_hint("`ull java vendors` 看各发行版的可用性;或手动装 JDK 后设 JAVA_HOME")) } else {
error.with_hint("`ull java vendors` 看各发行版的可用性;或手动装 JDK 后设 JAVA_HOME")
})
} }
/// 只做规划不落盘:返回将要安装的版本、目标目录与「是否已经装好」。 pub use plan::plan;
///
/// # Errors
///
/// 与 [`install`] 的查询段相同(平台不支持 / 网络失败 / 所有发行版都没有该大版本)。
pub async fn plan(
engine: &DownloadEngine,
location: &JavaInstallRoot,
major: u32,
) -> Result<(JdkRelease, PathBuf, bool), JavaError> {
let target = AdoptiumTarget::host()?;
let domestic = engine.region().is_domestic();
let mut reasons: Vec<String> = Vec::new();
for vendor in vendor::order(&[], domestic) {
if !vendor.supports(major) {
reasons.push(format!("{}:不支持 Java {major}", vendor.id()));
continue;
}
match source::fetch(engine, vendor, major, target).await {
Ok(release) => {
let root = location.jdk_dir(release.major, release.vendor, &release.version);
let ready = extract::is_ready(&root, release.sha256());
return Ok((release, root, ready));
}
Err(error) => reasons.push(format!("{}:{}", vendor.id(), error.message())),
}
}
Err(JavaError::new(
JavaErrorKind::Metadata,
format!("没有可用的 Java {major}({} )", reasons.join(";")),
))
}
fn majors_text(vendor: JdkVendor) -> String { fn majors_text(vendor: JdkVendor) -> String {
vendor vendor
+220
View File
@@ -0,0 +1,220 @@
//! `install` 回退链的骨架:一次尝试的判据(取到 / 版本不符 / 失败)与「按顺序试、命中即停」。
//!
//! 单独成模块有两个理由:
//!
//! 1. `install.rs` 贴着 400 行硬线(M280 r1 加了这些守卫后到 440+);
//! 2. 这段是**纯判断**——真正的「取版本」由闭包注入,所以「版本不符 ⇒ 记失败继续走链」
//! 与「钉死一家时只试它一家」都能**离线**测。复审的变异 A(把
//! `catalog::version_mismatch` 改成恒 `None`)就是靠这里的用例变红的:没有它,
//! 「绝不把 latest 当用户要的版本装下去」只有代码、没有守卫。
use std::future::Future;
use super::super::catalog;
use super::super::error::JavaError;
use super::super::release::JdkRelease;
use super::super::vendor::JdkVendor;
/// 一次尝试的结果:`Ok` = 这一版就是用户要的;`Err` = 一句话原因(进降级记录)。
pub(super) type Attempt = Result<JdkRelease, String>;
/// 一次尝试的**判据**:取不到 → 原因;取到了但版本不是用户点名的那一版 → 拒绝。
///
/// `fetch` 由调用方给(真跑是 `source::fetch`,单测是夹具),所以这道闸离线可测。
/// `wanted = None`(没钉版本)时恒放行——默认路径不受任何影响。
pub(super) async fn resolve_one<F, Fut>(wanted: Option<&str>, fetch: F) -> Attempt
where
F: FnOnce() -> Fut,
Fut: Future<Output = Result<JdkRelease, JavaError>>,
{
match fetch().await {
Err(error) => Err(error.message().to_string()),
Ok(release) => match catalog::version_mismatch(wanted, &release.version) {
Some(reason) => Err(reason),
None => Ok(release),
},
}
}
/// 回退链骨架:按顺序试,第一个通过的即命中;失败与版本不符都变成一条原因继续下一家。
///
/// 返回 `(命中的发行版与版本, 每家失败的原因)`。`plan`(`--dry-run`)用它;`install` 还要记
/// `ChainLog` 与「不支持该大版本」的跳过原因,所以自己驱动循环,但**唯一入口仍是
/// [`resolve_one`]**——两边的判据只有一份。
pub(super) async fn chain_fetch<F, Fut>(
vendors: &[JdkVendor],
wanted: Option<&str>,
mut fetch: F,
) -> (Option<(JdkVendor, JdkRelease)>, Vec<(JdkVendor, String)>)
where
F: FnMut(JdkVendor) -> Fut,
Fut: Future<Output = Result<JdkRelease, JavaError>>,
{
let mut failures: Vec<(JdkVendor, String)> = Vec::new();
for candidate in vendors {
let vendor = *candidate;
match resolve_one(wanted, || fetch(vendor)).await {
Ok(release) => return (Some((vendor, release)), failures),
Err(reason) => failures.push((vendor, reason)),
}
}
(None, failures)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::download::RegionProfile;
use crate::download::{
ContentCache, DownloadEngine, EngineConfig, HealthRegistry, MirrorTable,
};
use crate::game::java::error::JavaError;
use crate::game::java::install::{self, InstallRequest, JavaInstallRoot};
use crate::game::java::release::{JdkArchive, JdkRelease};
use crate::game::java::vendor::JdkVendor;
use super::{chain_fetch, resolve_one};
fn release(version: &str) -> JdkRelease {
JdkRelease {
vendor: JdkVendor::Temurin,
major: 21,
version: version.to_string(),
semver: None,
release_name: format!("jdk-{version}"),
archive: JdkArchive {
name: format!("jdk-{version}.zip"),
url: "https://example.invalid/x.zip".to_string(),
sha256: "a".repeat(64),
size: 1,
},
}
}
fn temp_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("ull-javapin-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建临时目录");
dir
}
/// **P2-2 守卫(兜底闸)**:拿到的版本不是点名的那一版 ⇒ 拒绝,原因里带双方版本号。
///
/// 变异证明(复审的变异 A):把 `catalog::version_mismatch` 改成恒 `None`
/// ⇒ 本用例第一条红(`expect_err` 拿到 `Ok`)。
#[tokio::test]
async fn a_release_that_is_not_the_requested_version_is_rejected() {
let refused = resolve_one(Some("21.0.12"), || async { Ok(release("21.0.13+9")) })
.await
.expect_err("要 21.0.12、供应商回 latest 21.0.13+9 ⇒ 必须拒绝");
assert!(refused.contains("21.0.13+9"), "{refused}");
assert!(refused.contains("不是请求的 21.0.12"), "{refused}");
// 相符 ⇒ 放行(公共部分一致也算相符)。
assert!(
resolve_one(Some("21.0.12"), || async { Ok(release("21.0.12+8")) })
.await
.is_ok()
);
// 没钉版本 ⇒ 恒放行(默认路径不许误报)。
assert!(resolve_one(None, || async { Ok(release("21.0.13+9")) })
.await
.is_ok());
// 真正取不到 ⇒ 原因原样带出来。
let failed = resolve_one(Some("21.0.12"), || async {
Err::<JdkRelease, JavaError>(JavaError::new(
crate::game::java::JavaErrorKind::Network,
"连不上",
))
})
.await
.expect_err("取不到就得报错");
assert_eq!(failed, "连不上");
}
/// **P2-2 守卫(继续走链)**:第一家回落的 latest 必须被记成失败,第二家(真给了那一版)命中。
///
/// 变异证明:把 `chain_fetch` 的 `Err(reason) => failures.push(..)` 改成直接返回
/// ⇒ 本用例红(命中变成第一家那个不符的版本)。
#[tokio::test]
async fn a_mismatch_records_a_failure_and_the_chain_moves_on() {
let vendors = [JdkVendor::Temurin, JdkVendor::Oracle];
let (hit, failures) = chain_fetch(&vendors, Some("21.0.12"), |vendor| async move {
match vendor {
// 第一家没按版本取(就是复审担心的那种 bug:静默回落 latest)。
JdkVendor::Temurin => Ok(release("21.0.13+9")),
_ => Ok(release("21.0.12+8")),
}
})
.await;
let (vendor, picked) = hit.expect("第二家应命中");
assert_eq!(vendor, JdkVendor::Oracle, "命中不许是第一家");
assert_eq!(picked.version, "21.0.12+8");
assert_eq!(failures.len(), 1);
assert_eq!(failures[0].0, JdkVendor::Temurin);
assert!(failures[0].1.contains("不是请求的 21.0.12"));
}
/// 全都不符 ⇒ 没有命中,每家一条原因(命令据此退出非 0 并列出全部原因)。
#[tokio::test]
async fn nothing_matching_means_no_hit_and_every_reason_is_kept() {
let vendors = [JdkVendor::Temurin, JdkVendor::Oracle];
let (hit, failures) = chain_fetch(&vendors, Some("21.0.12"), |_vendor| async {
Ok(release("21.0.13+9"))
})
.await;
assert!(hit.is_none(), "一家都没给对版本 ⇒ 不许命中");
assert_eq!(failures.len(), 2);
assert!(failures
.iter()
.all(|(_, reason)| reason.contains("不是请求的")));
}
/// **P2-2 守卫(不换家)**:钉死一家、而这家不支持指定版本 ⇒ `install` 只试它一家
/// (错误里的「试过 N」必须是 1),一个请求都不发。
///
/// 变异证明:把 `source::pinned` 里 Zulu 的拒绝改成回落 `fetch_latest`
/// ⇒ 本用例红(错误里不再有 `latest=true`,且「试过」不再是 1 条原因)。
#[tokio::test]
async fn a_pinned_vendor_that_cannot_pin_is_tried_alone() {
let dir = temp_dir("pinned-alone");
let cache = Arc::new(ContentCache::open(dir.join("cache")));
let engine = DownloadEngine::new(
EngineConfig::default(),
Arc::new(MirrorTable::builtin()),
Arc::new(RegionProfile::direct(0)),
Arc::new(HealthRegistry::in_memory()),
cache,
);
let location = JavaInstallRoot::custom(dir.join("java-managed"));
let request = InstallRequest::with_vendor(21, JdkVendor::Zulu)
.with_version(Some("21.0.13".to_string()));
let error = install::install(&engine, &location, request)
.await
.expect_err("zulu 上游只有 latest ⇒ 必须报错");
assert!(
error.message().contains("试过 1"),
"只许试点名的那一家:{}",
error.message()
);
assert!(
error.message().contains("latest=true"),
"{}",
error.message()
);
assert!(
error
.hint()
.unwrap_or_default()
.contains("--vendor temurin"),
"要给出能做的下一步:{:?}",
error.hint()
);
// 一个字节都没落盘(连目录都不该建出来)。
assert!(!dir.join("java-managed").exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
+67
View File
@@ -0,0 +1,67 @@
//! `install` 的**只读取**规划段:`--dry-run` 用它算出「会装哪一版、打哪个 URL」。
//!
//! 从 `install.rs` 拆出来的直接原因是守 400 行硬线(M280 r1 给回退链加了守卫后那边到 401 行),
//! 但**不是纯搬移**,三处语义变了,别照着「搬移」去读它:
//!
//! 1. 签名从 `plan(engine, location, major)` 变成 `plan(engine, location, &InstallRequest)`
//! —— 版本这一维(`request.version`)现在参与选择;
//! 2. 函数体不再自己写 `for` + `continue`,改成走 [`super::chain::chain_fetch`],
//! **与 `install` 共用同一份判据**(取版本 + 「版本必须对得上」的兜底闸);
//! 3. `request.priority`(设置里的 `java.vendorPriority`)现在参与选择顺序;旧实现用
//! `vendor::order(&[], domestic)`,等于忽略这条设置。
//!
//! 与 `install/one.rs`(真装的一步)、`install/chain.rs`(判据与骨架)同一个拆法。
use std::path::PathBuf;
use super::super::error::{JavaError, JavaErrorKind};
use super::super::extract;
use super::super::location::JavaInstallRoot;
use super::super::release::JdkRelease;
use super::super::source;
use super::super::target::AdoptiumTarget;
use super::{chain::chain_fetch, chain_order, InstallRequest};
use crate::download::DownloadEngine;
/// 只做规划不落盘:返回将要安装的版本、目标目录与「是否已经装好」。
///
/// `--dry-run` 用它:`request.version` 与 `--vendor` 都参与选择(钉了版本就把这一维钉死),
/// 所以「最后会打哪个 URL、装哪个版本串」在下载之前就能看见——这也是不联网时
/// 唯一能验证版本选择逻辑的输出。
///
/// # Errors
///
/// 与 [`install`] 的查询段相同(平台不支持 / 网络失败 / 该家没有这个版本)。
pub async fn plan(
engine: &DownloadEngine,
location: &JavaInstallRoot,
request: &InstallRequest,
) -> Result<(JdkRelease, PathBuf, bool), JavaError> {
let target = AdoptiumTarget::host()?;
let domestic = engine.region().is_domestic();
let wanted = request.version.as_deref();
let candidates = chain_order(request, domestic);
let (hit, failures) = chain_fetch(&candidates, wanted, |vendor| {
source::fetch(engine, vendor, request.major, target, wanted)
})
.await;
match hit {
Some((_, release)) => {
let root = location.jdk_dir(release.major, release.vendor, &release.version);
let ready = extract::is_ready(&root, release.sha256());
Ok((release, root, ready))
}
None => Err(JavaError::new(
JavaErrorKind::Metadata,
format!(
"没有可用的 Java {}({} )",
request.major,
failures
.iter()
.map(|(vendor, reason)| format!("{}:{reason}", vendor.id()))
.collect::<Vec<_>>()
.join(";")
),
)),
}
}
+74 -12
View File
@@ -16,6 +16,7 @@
//! 3. **GitHub 侧没有任何资产**(`microsoft/openjdk` 的 release `assets` 全空), //! 3. **GitHub 侧没有任何资产**(`microsoft/openjdk` 的 release `assets` 全空),
//! 所以 gh-proxy 对这条链毫无用处——`domestic_mirrors` 实测为空数组。 //! 所以 gh-proxy 对这条链毫无用处——`domestic_mirrors` 实测为空数组。
use super::catalog::{matches_version, version_pin_error, VersionListScope};
use super::error::{JavaError, JavaErrorKind}; use super::error::{JavaError, JavaErrorKind};
use super::net; use super::net;
use super::release::{parse_sha256_text, JdkArchive, JdkRelease}; use super::release::{parse_sha256_text, JdkArchive, JdkRelease};
@@ -137,16 +138,7 @@ pub async fn fetch_latest(
major: u32, major: u32,
target: AdoptiumTarget, target: AdoptiumTarget,
) -> Result<JdkRelease, JavaError> { ) -> Result<JdkRelease, JavaError> {
let version = snapshot_version(major).ok_or_else(|| { let version = snapshot_version(major).ok_or_else(|| missing_major(major))?;
JavaError::new(
JavaErrorKind::Metadata,
format!(
"Microsoft OpenJDK 没有 {major} 的 GA(只发 {})",
majors_text()
),
)
.with_hint("8 用 temurin;其它大版本用 oracle / zulu")
})?;
let filename = archive_name(version, target); let filename = archive_name(version, target);
let sha_url = expand_template(&table.sha256_template, version, target); let sha_url = expand_template(&table.sha256_template, version, target);
let sha_text = net::get_text(client, &sha_url, "Microsoft OpenJDK 校验文件").await?; let sha_text = net::get_text(client, &sha_url, "Microsoft OpenJDK 校验文件").await?;
@@ -171,6 +163,48 @@ pub async fn fetch_latest(
}) })
} }
/// 查一次「某大版本的指定版本 MS OpenJDK」。
///
/// 微软没有「按版本查」的接口(官方只有人读 HTML 表),[`SNAPSHOT`] 里每个大版本只有**一个**
/// 版本号(`size` 也是按它实测的),所以只认快照里那一个:别的版本号**明确报错**
/// (文案在 `catalog::version_pin_error`:说清这家不能自选版本、可用的是哪一个),
/// 绝不回落到 latest 假装装成了。
///
/// # Errors
///
/// 该大版本微软不发(含 8)、以及请求的版本号不是快照里那一个时返回错误。
pub async fn fetch_version(
client: &reqwest::Client,
table: &MsOpenJdkTable,
major: u32,
target: AdoptiumTarget,
wanted: &str,
) -> Result<JdkRelease, JavaError> {
let known = snapshot_version(major).ok_or_else(|| missing_major(major))?;
if !matches_version(wanted, known) {
return Err(version_pin_error(
JdkVendor::Microsoft,
major,
wanted,
&[known.to_string()],
VersionListScope::Complete,
));
}
fetch_latest(client, table, major, target).await
}
/// 「微软不发这个 Java 大版本」(含 8)。
fn missing_major(major: u32) -> JavaError {
JavaError::new(
JavaErrorKind::Metadata,
format!(
"Microsoft OpenJDK 没有 {major} 的 GA(只发 {})",
majors_text()
),
)
.with_hint("8 用 temurin;其它大版本用 oracle / zulu")
}
fn majors_text() -> String { fn majors_text() -> String {
SNAPSHOT SNAPSHOT
.iter() .iter()
@@ -182,13 +216,41 @@ fn majors_text() -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
archive_name, expand_template, ms_os, parse_version, snapshot_size, snapshot_version, archive_name, expand_template, fetch_version, ms_os, parse_version, snapshot_size,
SNAPSHOT, snapshot_version, SNAPSHOT,
}; };
use crate::download::MirrorTable;
use crate::game::java::error::JavaErrorKind;
use crate::game::java::target::AdoptiumTarget; use crate::game::java::target::AdoptiumTarget;
const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64"); const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64");
/// 指定版本:除了快照里那一个版本号,别的一律**报错**(不回落 latest),
/// 而且错误里要说清「只有这份快照」与「可用的是哪个」。
///
/// 变异证明:把 `fetch_version` 里的版本校验删掉(直接 `fetch_latest`)⇒ 本用例红。
#[tokio::test]
async fn pinned_version_outside_the_snapshot_is_refused_not_fallen_back() {
let table = MirrorTable::builtin();
let microsoft = table.microsoft_openjdk().expect("内置表应带 MS 分发");
let client = reqwest::Client::new();
// 错误在取校验文件之前就返回,所以这条用例一个请求都不发。
let error = fetch_version(&client, microsoft, 21, WINDOWS, "21.0.5")
.await
.expect_err("快照里没有 21.0.5");
assert_eq!(error.kind(), JavaErrorKind::Metadata);
assert!(
error.message().contains("21.0.13.0.2"),
"错误里要说清这家有哪个版本:{}",
error.message()
);
assert!(
error.message().contains("不能自选版本"),
"要把「这家不能自选版本」说明白:{}",
error.message()
);
}
#[test] #[test]
fn snapshot_covers_the_four_majors_microsoft_ships() { fn snapshot_covers_the_four_majors_microsoft_ships() {
assert_eq!(snapshot_version(21), Some("21.0.13.0.2")); assert_eq!(snapshot_version(21), Some("21.0.13.0.2"));
+49 -123
View File
@@ -32,6 +32,12 @@ use super::target::AdoptiumTarget;
use super::vendor::JdkVendor; use super::vendor::JdkVendor;
use crate::download::MirrorTable; use crate::download::MirrorTable;
mod listing;
pub use listing::{find_archive, parse_links, pick_version, version_key, versions_for};
use listing::select_version;
/// Oracle 的 os 段(Adoptium 的 `mac` 在 Oracle 里叫 `macos`)。 /// Oracle 的 os 段(Adoptium 的 `mac` 在 Oracle 里叫 `macos`)。
#[must_use] #[must_use]
pub fn oracle_os(target: AdoptiumTarget) -> &'static str { pub fn oracle_os(target: AdoptiumTarget) -> &'static str {
@@ -50,71 +56,10 @@ pub fn oracle_arch(target: AdoptiumTarget) -> &'static str {
} }
} }
/// 目录页 → 本级条目名列表(去掉尾部 `/`、跳过 `../` 与绝对链接,去重保序)。
#[must_use]
pub fn parse_links(html: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut rest = html;
while let Some(start) = rest.find("href=\"") {
let after = &rest[start + "href=\"".len()..];
let Some(end) = after.find('"') else {
break;
};
let value = after[..end].trim();
rest = &after[end..];
if value.is_empty()
|| value.starts_with("http")
|| value.starts_with("//")
|| value.starts_with('?')
|| value.contains('#')
{
continue;
}
let name = value.trim_end_matches('/');
let Some(name) = name.rsplit('/').next() else {
continue;
};
if name.is_empty() || name == "." || name == ".." {
continue;
}
if !out.iter().any(|known| known == name) {
out.push(name.to_string());
}
}
out
}
/// 版本串 → 可比较的数字段(`21.0.2` → `[21, 0, 2]`,`27.0.1.0.3` → `[27,0,1,0,3]`)。
#[must_use]
pub fn version_key(version: &str) -> Option<Vec<u64>> {
let mut parts = Vec::new();
for segment in version.split('.') {
parts.push(segment.parse::<u64>().ok()?);
}
(!parts.is_empty()).then_some(parts)
}
/// 目录页里属于 `major` 的版本串,按版本**降序**(最新在前)。
#[must_use]
pub fn versions_for(html: &str, major: u32) -> Vec<String> {
let mut found: Vec<(Vec<u64>, String)> = parse_links(html)
.into_iter()
.filter_map(|name| {
let key = version_key(&name)?;
(key.first() == Some(&u64::from(major))).then_some((key, name))
})
.collect();
found.sort_by(|left, right| right.0.cmp(&left.0));
found.into_iter().map(|(_, name)| name).collect()
}
/// 取 `major` 线的最新 GA 版本串。
#[must_use]
pub fn pick_version(html: &str, major: u32) -> Option<String> {
versions_for(html, major).into_iter().next()
}
/// 期望文件名(Oracle 的命名是**模板**,哈希只在目录层级上,不在文件名里)。 /// 期望文件名(Oracle 的命名是**模板**,哈希只在目录层级上,不在文件名里)。
///
/// 目录页解析与版本枚举(`parse_links` / `versions_for` / `select_version`)在
/// [`listing`] 里,这里只留「拿到版本串之后」的那一段。
#[must_use] #[must_use]
pub fn archive_name(version: &str, target: AdoptiumTarget) -> String { pub fn archive_name(version: &str, target: AdoptiumTarget) -> String {
format!( format!(
@@ -137,12 +82,6 @@ pub fn checksum_url(base: &str, version: &str, filename: &str) -> String {
format!("{}.sha256", archive_url(base, version, filename)) format!("{}.sha256", archive_url(base, version, filename))
} }
/// 目录页里是否有这个文件(**唯一**的「存在」判据:不信状态码)。
#[must_use]
pub fn find_archive(html: &str, filename: &str) -> bool {
parse_links(html).iter().any(|name| name == filename)
}
/// 查一次「某大版本的最新 Oracle OpenJDK GA」。 /// 查一次「某大版本的最新 Oracle OpenJDK GA」。
/// ///
/// 按 `mirrors.json` 里 `oracle_openjdk` 的启用顺序逐个镜像试(国内:华为云 → 南大), /// 按 `mirrors.json` 里 `oracle_openjdk` 的启用顺序逐个镜像试(国内:华为云 → 南大),
@@ -157,6 +96,36 @@ pub async fn fetch_latest(
table: &MirrorTable, table: &MirrorTable,
major: u32, major: u32,
target: AdoptiumTarget, target: AdoptiumTarget,
) -> Result<JdkRelease, JavaError> {
try_mirrors(client, table, major, target, None).await
}
/// 查一次「某大版本的指定版本 Oracle OpenJDK」。
///
/// 版本串来自镜像目录页([`versions_for`] 本来就能枚举某 major 的全部版本),
/// 选定就用它,**不再取 [`pick_version`] 的最新**;目录页里没有这个版本时
/// 由 `catalog::version_pin_error` 报错并列出可用版本。
///
/// # Errors
///
/// 所有镜像都没有这个版本(或版本目录页 / `.sha256` 取不到)时返回错误。
pub async fn fetch_version(
client: &reqwest::Client,
table: &MirrorTable,
major: u32,
target: AdoptiumTarget,
wanted: &str,
) -> Result<JdkRelease, JavaError> {
try_mirrors(client, table, major, target, Some(wanted)).await
}
/// 逐个镜像试(`wanted` 决定是取最新还是取指定版本)。
async fn try_mirrors(
client: &reqwest::Client,
table: &MirrorTable,
major: u32,
target: AdoptiumTarget,
wanted: Option<&str>,
) -> Result<JdkRelease, JavaError> { ) -> Result<JdkRelease, JavaError> {
let mirrors: Vec<_> = table.oracle_mirrors().collect(); let mirrors: Vec<_> = table.oracle_mirrors().collect();
if mirrors.is_empty() { if mirrors.is_empty() {
@@ -168,17 +137,18 @@ pub async fn fetch_latest(
} }
let mut reasons: Vec<String> = Vec::new(); let mut reasons: Vec<String> = Vec::new();
for mirror in mirrors { for mirror in mirrors {
match fetch_from(client, mirror.base(), major, target).await { match fetch_from(client, mirror.base(), major, target, wanted).await {
Ok(release) => return Ok(release), Ok(release) => return Ok(release),
Err(error) => reasons.push(format!("{}:{}", mirror.name, error.message())), Err(error) => reasons.push(format!("{}:{}", mirror.name, error.message())),
} }
} }
let subject = match wanted {
None => format!("Oracle OpenJDK {major}"),
Some(wanted) => format!("oracle 的 Java {major} 版本 {wanted}"),
};
Err(JavaError::new( Err(JavaError::new(
JavaErrorKind::Network, JavaErrorKind::Network,
format!( format!("{subject} 在所有镜像上都取不到:{}", reasons.join(";")),
"Oracle OpenJDK {major} 在所有镜像上都取不到:{}",
reasons.join(";")
),
) )
.with_hint("Oracle 的 21 线只到 21.0.2(只发首个 GA + ≤2 update);换 17/25 或改用 temurin")) .with_hint("Oracle 的 21 线只到 21.0.2(只发首个 GA + ≤2 update);换 17/25 或改用 temurin"))
} }
@@ -189,6 +159,7 @@ async fn fetch_from(
base: &str, base: &str,
major: u32, major: u32,
target: AdoptiumTarget, target: AdoptiumTarget,
wanted: Option<&str>,
) -> Result<JdkRelease, JavaError> { ) -> Result<JdkRelease, JavaError> {
let index = net::get_text( let index = net::get_text(
client, client,
@@ -196,15 +167,7 @@ async fn fetch_from(
"Oracle 镜像根目录", "Oracle 镜像根目录",
) )
.await?; .await?;
let version = pick_version(&index, major).ok_or_else(|| { let version = select_version(&index, major, wanted)?;
JavaError::new(
JavaErrorKind::Metadata,
format!(
"镜像目录里没有 {major} 线的 Oracle OpenJDK(目录页 {} 项)",
parse_links(&index).len()
),
)
})?;
let filename = archive_name(&version, target); let filename = archive_name(&version, target);
let listing = net::get_text( let listing = net::get_text(
client, client,
@@ -248,23 +211,10 @@ async fn fetch_from(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::listing::find_archive;
archive_name, archive_url, checksum_url, find_archive, oracle_os, parse_links, use super::{archive_name, archive_url, checksum_url, oracle_os};
pick_version, version_key, versions_for,
};
use crate::game::java::target::AdoptiumTarget; use crate::game::java::target::AdoptiumTarget;
/// 华为云 `/openjdk/` 的真实目录页片段(2026-10-30 抓取,`<pre>` 列表)。
const INDEX: &str = r#"<html><body><pre>Name Last modified Size</pre><hr><pre>
<a href="10/" onclick="handleLinkUrl(this)">10/</a> 22-Aug-2021 15:18 -
<a href="17.0.2/" onclick="handleLinkUrl(this)">17.0.2/</a> 22-Aug-2021 15:19 -
<a href="21/" onclick="handleLinkUrl(this)">21/</a> 22-Aug-2021 15:19 -
<a href="21.0.1/" onclick="handleLinkUrl(this)">21.0.1/</a> 22-Aug-2021 15:19 -
<a href="21.0.2/" onclick="handleLinkUrl(this)">21.0.2/</a> 06-Jan-2024 15:26 -
<a href="27.0.1.0.3/" onclick="handleLinkUrl(this)">27.0.1.0.3/</a> 06-Jan-2026 15:26 -
<a href="../" onclick="handleLinkUrl(this)">../</a>
</pre></body></html>"#;
/// 华为云 `/openjdk/21.0.2/` 的真实文件列表片段。 /// 华为云 `/openjdk/21.0.2/` 的真实文件列表片段。
const LISTING: &str = r#"<html><body><pre><a href="../">../</a> const LISTING: &str = r#"<html><body><pre><a href="../">../</a>
<a href="openjdk-21.0.2_linux-x64_bin.tar.gz" onclick="handleLinkUrl(this)">openjdk-21.0.2_linux-x64_bin.tar.gz</a> 194.18 MB <a href="openjdk-21.0.2_linux-x64_bin.tar.gz" onclick="handleLinkUrl(this)">openjdk-21.0.2_linux-x64_bin.tar.gz</a> 194.18 MB
@@ -273,30 +223,6 @@ mod tests {
const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64"); const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64");
#[test]
fn directory_links_are_scraped_without_any_hash_segment() {
let links = parse_links(INDEX);
assert!(links.contains(&"21.0.2".to_string()));
assert!(!links.iter().any(|name| name == ".."));
// Oracle 直链里的构建哈希(f2283984656d49d69e91c558476027ac)在镜像目录里根本不存在:
// 目录层级只有 `{ver}/`,所以实现里没有任何拼哈希的位置。
assert!(!links.iter().any(|name| name.len() == 32));
}
#[test]
fn newest_version_of_a_line_wins_and_ordering_is_numeric() {
assert_eq!(
versions_for(INDEX, 21),
vec!["21.0.2".to_string(), "21.0.1".to_string(), "21".to_string()]
);
assert_eq!(pick_version(INDEX, 21).as_deref(), Some("21.0.2"));
// 27.0.1.0.3 是四段+,比 27.0.1 新(字符串比较会得出相反结论)。
assert_eq!(version_key("27.0.1.0.3"), Some(vec![27, 0, 1, 0, 3]));
assert_eq!(version_key("27.0.1"), Some(vec![27, 0, 1]));
assert!(version_key("27.0.1") < version_key("27.0.1.0.3"));
assert_eq!(pick_version(INDEX, 8), None);
}
#[test] #[test]
fn filename_template_matches_the_archive_on_every_platform() { fn filename_template_matches_the_archive_on_every_platform() {
assert_eq!( assert_eq!(
+190
View File
@@ -0,0 +1,190 @@
//! Oracle OpenJDK 的**纯**那一半:目录页解析、版本枚举、版本选择。
//!
//! 从 `oracle.rs` 拆出来只为守 400 行硬线(M280 的指定版本选择让那边到 418 行);
//! 语义未变——这里一个请求都不发,全是「HTML 文本 → 名字列表 / 版本串」的纯函数:
//!
//! ```text
//! parse_links 一级目录页 → 条目名(去重保序,跳过 ../ 与绝对链接)
//! version_key 版本串 → 可比较的数字段(27.0.1.0.3 > 27.0.1)
//! versions_for 目录页里属于某 major 的版本串(降序)
//! pick_version 该线最新(= versions_for 的第一个)
//! select_version 在目录页里挑版本:`None` = 最新,`Some` = 必须是这个版本(不近似)
//! find_archive 目录页里是否真有这个文件名(**唯一**的「存在」判据,不信状态码)
//! ```
//!
//! 「存在不信状态码」的理由见 `oracle.rs` 头:华为云对任意不存在的路径都回 200 + 门户 HTML。
use super::super::catalog::{matches_version, version_pin_error, VersionListScope};
use super::super::error::{JavaError, JavaErrorKind};
use super::super::vendor::JdkVendor;
/// 目录页 → 本级条目名列表(去掉尾部 `/`、跳过 `../` 与绝对链接,去重保序)。
#[must_use]
pub fn parse_links(html: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut rest = html;
while let Some(start) = rest.find("href=\"") {
let after = &rest[start + "href=\"".len()..];
let Some(end) = after.find('"') else {
break;
};
let value = after[..end].trim();
rest = &after[end..];
if value.is_empty()
|| value.starts_with("http")
|| value.starts_with("//")
|| value.starts_with('?')
|| value.contains('#')
{
continue;
}
let name = value.trim_end_matches('/');
let Some(name) = name.rsplit('/').next() else {
continue;
};
if name.is_empty() || name == "." || name == ".." {
continue;
}
if !out.iter().any(|known| known == name) {
out.push(name.to_string());
}
}
out
}
/// 版本串 → 可比较的数字段(`21.0.2` → `[21, 0, 2]`,`27.0.1.0.3` → `[27,0,1,0,3]`)。
#[must_use]
pub fn version_key(version: &str) -> Option<Vec<u64>> {
let mut parts = Vec::new();
for segment in version.split('.') {
parts.push(segment.parse::<u64>().ok()?);
}
(!parts.is_empty()).then_some(parts)
}
/// 目录页里属于 `major` 的版本串,按版本**降序**(最新在前)。
#[must_use]
pub fn versions_for(html: &str, major: u32) -> Vec<String> {
let mut found: Vec<(Vec<u64>, String)> = parse_links(html)
.into_iter()
.filter_map(|name| {
let key = version_key(&name)?;
(key.first() == Some(&u64::from(major))).then_some((key, name))
})
.collect();
found.sort_by(|left, right| right.0.cmp(&left.0));
found.into_iter().map(|(_, name)| name).collect()
}
/// 取 `major` 线的最新 GA 版本串。
#[must_use]
pub fn pick_version(html: &str, major: u32) -> Option<String> {
versions_for(html, major).into_iter().next()
}
/// 目录页里是否有这个文件(**唯一**的「存在」判据:不信状态码)。
#[must_use]
pub fn find_archive(html: &str, filename: &str) -> bool {
parse_links(html).iter().any(|name| name == filename)
}
/// 在目录页里挑版本:`None` = 该线最新,`Some` = 必须是这个版本(不做前缀近似)。
pub(super) fn select_version(
html: &str,
major: u32,
wanted: Option<&str>,
) -> Result<String, JavaError> {
let Some(wanted) = wanted else {
return pick_version(html, major).ok_or_else(|| missing_major(html, major));
};
let available = versions_for(html, major);
available
.iter()
.find(|version| matches_version(wanted, version))
.cloned()
.ok_or_else(|| {
version_pin_error(
JdkVendor::Oracle,
major,
wanted,
&available,
VersionListScope::Complete,
)
})
}
/// 「目录页里没有这个大版本」。
pub(super) fn missing_major(html: &str, major: u32) -> JavaError {
JavaError::new(
JavaErrorKind::Metadata,
format!(
"镜像目录里没有 {major} 线的 Oracle OpenJDK(目录页 {} 项)",
parse_links(html).len()
),
)
}
#[cfg(test)]
mod tests {
use super::{parse_links, pick_version, select_version, version_key, versions_for};
/// 华为云 `/openjdk/` 的真实目录页片段(2026-10-30 抓取,`<pre>` 列表)。
const INDEX: &str = r#"<html><body><pre>Name Last modified Size</pre><hr><pre>
<a href="10/" onclick="handleLinkUrl(this)">10/</a> 22-Aug-2021 15:18 -
<a href="17.0.2/" onclick="handleLinkUrl(this)">17.0.2/</a> 22-Aug-2021 15:19 -
<a href="21/" onclick="handleLinkUrl(this)">21/</a> 22-Aug-2021 15:19 -
<a href="21.0.1/" onclick="handleLinkUrl(this)">21.0.1/</a> 22-Aug-2021 15:19 -
<a href="21.0.2/" onclick="handleLinkUrl(this)">21.0.2/</a> 06-Jan-2024 15:26 -
<a href="27.0.1.0.3/" onclick="handleLinkUrl(this)">27.0.1.0.3/</a> 06-Jan-2026 15:26 -
<a href="../" onclick="handleLinkUrl(this)">../</a>
</pre></body></html>"#;
#[test]
fn directory_links_are_scraped_without_any_hash_segment() {
let links = parse_links(INDEX);
assert!(links.contains(&"21.0.2".to_string()));
assert!(!links.iter().any(|name| name == ".."));
// Oracle 直链里的构建哈希(f2283984656d49d69e91c558476027ac)在镜像目录里根本不存在:
// 目录层级只有 `{ver}/`,所以实现里没有任何拼哈希的位置。
assert!(!links.iter().any(|name| name.len() == 32));
}
#[test]
fn newest_version_of_a_line_wins_and_ordering_is_numeric() {
assert_eq!(
versions_for(INDEX, 21),
vec!["21.0.2".to_string(), "21.0.1".to_string(), "21".to_string()]
);
assert_eq!(pick_version(INDEX, 21).as_deref(), Some("21.0.2"));
// 27.0.1.0.3 是四段+,比 27.0.1 新(字符串比较会得出相反结论)。
assert_eq!(version_key("27.0.1.0.3"), Some(vec![27, 0, 1, 0, 3]));
assert_eq!(version_key("27.0.1"), Some(vec![27, 0, 1]));
assert!(version_key("27.0.1") < version_key("27.0.1.0.3"));
assert_eq!(pick_version(INDEX, 8), None);
}
/// 指定版本走同一条枚举路径:命中就用那一版,**不是** `pick_version` 的最新;
/// 版本不存在时错误里列出该 major 真正有的版本(不许只回一句 404)。
///
/// 变异证明:把 `select_version` 的 `Some` 分支改回 `pick_version` ⇒ 第一条断言红。
#[test]
fn pinned_version_is_taken_from_the_listing_not_the_newest() {
assert_eq!(
select_version(INDEX, 21, Some("21.0.1")).expect("目录页里有 21.0.1"),
"21.0.1"
);
assert_eq!(
select_version(INDEX, 21, Some("21.0.2")).expect("目录页里有 21.0.2"),
"21.0.2"
);
// 不给版本 = 今天的行为(最新)。
assert_eq!(select_version(INDEX, 21, None).expect("最新"), "21.0.2");
let error = select_version(INDEX, 21, Some("21.0.9")).expect_err("目录页里没有 21.0.9");
assert!(error.message().contains("21.0.9"), "{}", error.message());
assert!(
error.message().contains("21.0.2") && error.message().contains("21.0.1"),
"错误里要列出可用版本:{}",
error.message()
);
}
}
+73 -20
View File
@@ -11,57 +11,110 @@
//! 换下一家;同一家内部的镜像换源(TUNA→官方、华为云→南大)仍然归 `core::download` 引擎管。 //! 换下一家;同一家内部的镜像换源(TUNA→官方、华为云→南大)仍然归 `core::download` 引擎管。
use super::api; use super::api;
use super::error::JavaError; use super::catalog;
use super::error::{JavaError, JavaErrorKind};
use super::microsoft; use super::microsoft;
use super::oracle; use super::oracle;
use super::release::JdkRelease; use super::release::JdkRelease;
use super::target::AdoptiumTarget; use super::target::AdoptiumTarget;
use super::vendor::JdkVendor; use super::vendor::JdkVendor;
use super::zulu; use super::zulu;
use crate::download::DownloadEngine; use crate::download::{DownloadEngine, MirrorTable};
/// 查一次「某发行版某大版本的最新 GA」。 /// 查一次「某发行版某大版本」的归档。
///
/// `version = None` 是要该家**最新 GA**(D13 口径,行为与 M280 之前逐字节一致);
/// `Some(v)` 要求精确版本:能按版本取的只有 Temurin / Oracle,Microsoft 只认内置快照里那一个,
/// Zulu(只有 `latest=true`)与 Termux(系统包定版本)直接报错——错误文案由
/// [`catalog::version_pin_error`] 统一出,**这里不换家、不回落 latest**。
/// ///
/// # Errors /// # Errors
/// ///
/// 平台无产物、清单不可达、该家没有这个大版本、校验缺失时返回错误——调用方据此降级。 /// 平台无产物、清单不可达、该版本不存在、校验缺失、这家不支持指定版本时返回错误——调用方据此降级。
pub async fn fetch( pub async fn fetch(
engine: &DownloadEngine, engine: &DownloadEngine,
vendor: JdkVendor, vendor: JdkVendor,
major: u32, major: u32,
target: AdoptiumTarget, target: AdoptiumTarget,
version: Option<&str>,
) -> Result<JdkRelease, JavaError> { ) -> Result<JdkRelease, JavaError> {
let client = engine.client(); let client = engine.client();
let table = engine.mirrors(); let table = engine.mirrors();
match version {
None => latest(client, table, vendor, major, target).await,
Some(wanted) => pinned(client, table, vendor, major, target, wanted).await,
}
}
/// 该家的最新 GA(`fetch` 的 `None` 那一路,M280 之前只有这一条)。
async fn latest(
client: &reqwest::Client,
table: &MirrorTable,
vendor: JdkVendor,
major: u32,
target: AdoptiumTarget,
) -> Result<JdkRelease, JavaError> {
match vendor { match vendor {
JdkVendor::Temurin => api::fetch_latest(client, major, target).await, JdkVendor::Temurin => api::fetch_latest(client, major, target).await,
JdkVendor::Oracle => oracle::fetch_latest(client, table, major, target).await, JdkVendor::Oracle => oracle::fetch_latest(client, table, major, target).await,
JdkVendor::Microsoft => { JdkVendor::Microsoft => {
let table = table.microsoft_openjdk().ok_or_else(|| { microsoft::fetch_latest(client, microsoft_table(table)?, major, target).await
}
JdkVendor::Zulu => zulu::fetch_latest(client, zulu_table(table)?, major, target).await,
JdkVendor::Termux => Err(termux_archive_error()),
}
}
/// 该家的指定版本(`fetch` 的 `Some` 那一路)。
async fn pinned(
client: &reqwest::Client,
table: &MirrorTable,
vendor: JdkVendor,
major: u32,
target: AdoptiumTarget,
wanted: &str,
) -> Result<JdkRelease, JavaError> {
match vendor {
JdkVendor::Temurin => api::fetch_version(client, major, target, wanted).await,
JdkVendor::Oracle => oracle::fetch_version(client, table, major, target, wanted).await,
JdkVendor::Microsoft => {
microsoft::fetch_version(client, microsoft_table(table)?, major, target, wanted).await
}
// 上游只给最新一份(Zulu)/ 由系统包管理器定版本(Termux):明确报错,
// 绝不换成别的版本假装装成了用户点名的那一版。
JdkVendor::Zulu | JdkVendor::Termux => Err(catalog::version_pin_error(
vendor,
major,
wanted,
&[],
catalog::VersionListScope::Complete,
)),
}
}
fn microsoft_table(table: &MirrorTable) -> Result<&crate::download::MsOpenJdkTable, JavaError> {
table.microsoft_openjdk().ok_or_else(|| {
JavaError::new( JavaError::new(
super::error::JavaErrorKind::Metadata, JavaErrorKind::Metadata,
"镜像清单里没有 microsoft_openjdk 表", "镜像清单里没有 microsoft_openjdk 表",
) )
.with_hint("升级 mirrors.json 到 v3,或改用 --vendor temurin") .with_hint("升级 mirrors.json 到 v3,或改用 --vendor temurin")
})?; })
microsoft::fetch_latest(client, table, major, target).await
} }
JdkVendor::Zulu => {
let table = table.zulu().ok_or_else(|| { fn zulu_table(table: &MirrorTable) -> Result<&crate::download::ZuluTable, JavaError> {
JavaError::new( table.zulu().ok_or_else(|| {
super::error::JavaErrorKind::Metadata, JavaError::new(JavaErrorKind::Metadata, "镜像清单里没有 zulu 表")
"镜像清单里没有 zulu 表",
)
.with_hint("升级 mirrors.json 到 v3,或改用 --vendor temurin") .with_hint("升级 mirrors.json 到 v3,或改用 --vendor temurin")
})?; })
zulu::fetch_latest(client, table, major, target).await
} }
JdkVendor::Termux => Err(JavaError::new(
super::error::JavaErrorKind::Unsupported, fn termux_archive_error() -> JavaError {
JavaError::new(
JavaErrorKind::Unsupported,
"Termux 的 JDK 由系统包管理器安装,不走归档下载链", "Termux 的 JDK 由系统包管理器安装,不走归档下载链",
) )
.with_hint("在 Termux 里用 `ull java install <major>`(默认走 termux 源)")), .with_hint("在 Termux 里用 `ull java install <major>`(默认走 termux 源)")
}
} }
/// 一跳的结果。 /// 一跳的结果。