Unlucky Launcher v0.0.1-alpha
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s

Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
AskaEth committed 2026-10-31 21:54:10 +08:00
commit bc41dfcf7d
517 files changed
+92844

No files matched your search

+344
View File
@@ -0,0 +1,344 @@
//! Adoptium(Eclipse Temurin)版本查询:`api.adoptium.net` → 一个可下载的 JDK 归档。
//!
//! 只用 v3 的 `assets/latest/{major}/hotspot` 这一条接口(D13:自动下载只装最新 GA):
//!
//! ```text
//! GET https://api.adoptium.net/v3/assets/latest/21/hotspot?os=windows&arch=x64&image_type=jdk&vendor=eclipse
//! → [ { release_name, version{major,semver}, binary{os,architecture,image_type,package{name,link,checksum,size}} } ]
//! ```
//!
//! 两个实测坑(都在 Windows x64 / Linux x64 上确认过):
//!
//! 1. **`arch` 参数不参与过滤**:`os=windows&arch=x64` 也会把 `aarch64` 的条目一起返回,
//! 必须自己按 `binary.architecture` 再筛一遍,否则 ARM 包会被当成 x64 装下来;
//! 2. **`package.link` 是 GitHub release 直链**(`github.com/adoptium/temurin21-binaries/releases/download/…`),
//! 这正是 [`crate::download::adoptium`] 的镜像展开所认识的形态——所以院校镜像加速
//! 不需要本模块拼任何 URL。
use serde::Deserialize;
use super::error::{JavaError, JavaErrorKind};
use super::release::version_from_release_name;
use super::target::AdoptiumTarget;
use super::vendor::JdkVendor;
/// Adoptium API 根。
pub const ADOPTIUM_API_BASE: &str = "https://api.adoptium.net/v3";
/// 托管 JDK 的默认供应商标识(D13 v1 的目录名 `jdk-<major>-vendor` 沿用它)。
pub const JDK_VENDOR: &str = "temurin";
/// 归档与查询结果的结构定义在 [`super::release`](四家发行版共用);这里重导出以保持
/// `api::JdkRelease` / `api::JdkArchive` 这两个老路径可用。
pub use super::release::{JdkArchive, JdkRelease};
/// 拼 `latest/{major}` 的查询地址。
#[must_use]
pub fn latest_url(major: u32, target: AdoptiumTarget) -> String {
format!(
"{ADOPTIUM_API_BASE}/assets/latest/{major}/hotspot?os={}&arch={}&image_type=jdk&vendor=eclipse",
target.os, target.arch
)
}
/// 解析 `assets/latest` 的响应体,挑出与目标平台匹配的那一条。
///
/// # Errors
///
/// 响应体不是 JSON、列表为空、没有匹配 `os` + `arch` 的 jdk 条目、sha256 不合法时返回错误。
pub fn parse_latest(json: &str, target: AdoptiumTarget) -> Result<JdkRelease, JavaError> {
let assets: Vec<ApiAsset> = serde_json::from_str(json).map_err(|error| {
JavaError::new(
JavaErrorKind::Metadata,
format!("Adoptium 响应不是预期 JSON:{error}"),
)
})?;
let matched = assets
.into_iter()
.find(|asset| asset.matches(target))
.ok_or_else(|| {
JavaError::new(
JavaErrorKind::Metadata,
format!(
"Adoptium 没有 {}/{} 的 jdk 产物(响应里 {} 条都不匹配)",
target.os, target.arch, "全部"
),
)
.with_hint("可能该大版本只有别的架构;用 `ull java list` 挑已有 JDK")
})?;
matched.into_release()
}
/// 请求一次「某大版本的最新 GA」。
///
/// # Errors
///
/// 网络不可达、HTTP 非 2xx、响应体不认识、没有对应平台产物时返回错误。
pub async fn fetch_latest(
client: &reqwest::Client,
major: u32,
target: AdoptiumTarget,
) -> Result<JdkRelease, JavaError> {
let url = latest_url(major, target);
let response = client.get(&url).send().await.map_err(|error| {
JavaError::new(
JavaErrorKind::Network,
format!("连不上 Adoptium API:{error}"),
)
.with_hint("检查网络/代理;已装的 JDK 仍可用 `ull java list` 看到")
})?;
let status = response.status();
if status == reqwest::StatusCode::NOT_FOUND {
return Err(JavaError::new(
JavaErrorKind::Metadata,
format!("Adoptium 没有 {major} 的 GA 产物(HTTP 404)"),
)
.with_hint("可自动下载的一般是 8 / 11 / 17 / 21 / 25 这类 GA 大版本"));
}
if !status.is_success() {
return Err(JavaError::new(
JavaErrorKind::Network,
format!("Adoptium API 返回 HTTP {status}({url})"),
));
}
let body = response.text().await.map_err(|error| {
JavaError::new(
JavaErrorKind::Network,
format!("读 Adoptium 响应失败:{error}"),
)
})?;
parse_latest(&body, target)
}
/// 响应里的 `package` 子对象。
#[derive(Debug, Deserialize)]
struct ApiPackage {
name: String,
link: String,
#[serde(default)]
checksum: Option<String>,
#[serde(default)]
size: u64,
}
/// 响应里的 `binary` 子对象。
#[derive(Debug, Deserialize)]
struct ApiBinary {
#[serde(default)]
os: Option<String>,
#[serde(default)]
architecture: Option<String>,
#[serde(default)]
image_type: Option<String>,
#[serde(default)]
package: Option<ApiPackage>,
}
/// 响应里的 `version` 子对象。
#[derive(Debug, Deserialize)]
struct ApiVersion {
#[serde(default)]
major: Option<u32>,
#[serde(default)]
semver: Option<String>,
}
/// 响应列表里的一条。
#[derive(Debug, Deserialize)]
struct ApiAsset {
#[serde(default)]
release_name: String,
#[serde(default)]
version: Option<ApiVersion>,
#[serde(default)]
binary: Option<ApiBinary>,
}
impl ApiAsset {
/// 是否就是目标平台的 jdk 归档(`arch` 参数不过滤,必须自己判)。
fn matches(&self, target: AdoptiumTarget) -> bool {
let Some(binary) = &self.binary else {
return false;
};
let same_os = binary.os.as_deref().is_some_and(|os| os == target.os);
let same_arch = binary
.architecture
.as_deref()
.is_some_and(|arch| arch == target.arch);
let is_jdk = binary.image_type.as_deref() != Some("jre");
same_os && same_arch && is_jdk && binary.package.is_some()
}
/// 转成对外结果(校验 sha256 形态)。
fn into_release(self) -> Result<JdkRelease, JavaError> {
let package = self
.binary
.and_then(|binary| binary.package)
.ok_or_else(|| {
JavaError::new(JavaErrorKind::Metadata, "Adoptium 条目缺少 package 子对象")
})?;
let sha256 = package
.checksum
.as_deref()
.map(|hex| hex.trim().to_ascii_lowercase())
.filter(|hex| hex.len() == 64 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()))
.ok_or_else(|| {
JavaError::new(
JavaErrorKind::Integrity,
format!("Adoptium 没给可用的 sha256({})", package.name),
)
.with_hint("换一个大版本,或手动装 JDK 后设 JAVA_HOME")
})?;
let version = self.version;
let major = version
.as_ref()
.and_then(|version| version.major)
.or_else(|| major_from_name(&package.name))
.ok_or_else(|| {
JavaError::new(
JavaErrorKind::Metadata,
format!("Adoptium 条目里读不出大版本号({})", package.name),
)
})?;
let native_version = version_from_release_name(&self.release_name)
.or_else(|| version.as_ref().and_then(|version| version.semver.clone()))
.unwrap_or_else(|| major.to_string());
Ok(JdkRelease {
vendor: JdkVendor::Temurin,
major,
version: native_version,
semver: version.and_then(|version| version.semver),
release_name: self.release_name,
archive: JdkArchive {
name: package.name,
url: package.link,
sha256,
size: package.size,
},
})
}
}
/// 从产物名兜底读大版本(`OpenJDK21U-jdk_x64_windows_hotspot_…` → `21`)。
fn major_from_name(name: &str) -> Option<u32> {
let rest = name.split_once("OpenJDK")?.1;
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
digits.parse().ok()
}
#[cfg(test)]
mod tests {
use super::{latest_url, parse_latest, ApiAsset};
use crate::game::java::error::JavaErrorKind;
use crate::game::java::target::AdoptiumTarget;
const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64");
fn sample() -> String {
// 实测:`arch` 参数不过滤,响应里会混进别的架构。
r#"[
{
"release_name": "jdk-21.0.13+9",
"version": {"major": 21, "semver": "21.0.13+9.0.LTS"},
"binary": {
"os": "windows", "architecture": "aarch64", "image_type": "jdk",
"package": {
"name": "OpenJDK21U-jdk_aarch64_windows_hotspot_21.0.12.1_1.zip",
"link": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_aarch64_windows_hotspot_21.0.12.1_1.zip",
"checksum": "9dcbb8ebe24aa9ee5a47a7fab2106a7c8988d7acee461260fef107411df91dc5",
"size": 179666944
}
}
},
{
"release_name": "jdk-21.0.13+9",
"version": {"major": 21, "semver": "21.0.13+9.0.LTS"},
"binary": {
"os": "windows", "architecture": "x64", "image_type": "jdk",
"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": 190000000
}
}
}
]"#
.to_string()
}
#[test]
fn picks_the_matching_arch_and_keeps_the_github_link() {
let release = parse_latest(&sample(), WINDOWS).expect("应解析出 x64 条目");
assert_eq!(release.major, 21);
assert_eq!(
release.vendor,
crate::game::java::vendor::JdkVendor::Temurin
);
assert_eq!(release.version, "21.0.13+9", "版本串取自 release_name");
assert_eq!(release.release_name, "jdk-21.0.13+9");
assert_eq!(release.semver.as_deref(), Some("21.0.13+9.0.LTS"));
assert_eq!(
release.archive.name,
"OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip"
);
assert_eq!(release.archive.size, 190_000_000);
assert!(release.archive.url.contains("temurin21-binaries"));
assert_eq!(
crate::download::adoptium::parse_adoptium_artifact(&release.archive.url)
.map(|artifact| (artifact.major, artifact.arch, artifact.os)),
Some(("21".to_string(), "x64".to_string(), "windows".to_string()))
);
}
#[test]
fn missing_arch_is_an_error_not_a_guess() {
let error = parse_latest(&sample(), AdoptiumTarget::new("windows", "x86-32"))
.expect_err("x86-32 应没有条目");
assert_eq!(error.kind(), JavaErrorKind::Metadata);
}
#[test]
fn bad_checksum_is_rejected() {
let body = sample().replace(
"a4dbaa05fca4bd89d84584e58c459bd732efebb1722af80ab13c6516832004f9",
"deadbeef",
);
let error = parse_latest(&body, WINDOWS).expect_err("短 sha256 应被拒");
assert_eq!(error.kind(), JavaErrorKind::Integrity);
}
#[test]
fn latest_url_carries_os_arch_and_flavor() {
let url = latest_url(17, AdoptiumTarget::new("linux", "aarch64"));
assert!(url.starts_with("https://api.adoptium.net/v3/assets/latest/17/hotspot?"));
assert!(url.contains("os=linux"));
assert!(url.contains("arch=aarch64"));
assert!(url.contains("image_type=jdk"));
}
#[test]
fn non_json_body_is_metadata_error() {
let error = parse_latest("<html>502</html>", WINDOWS).expect_err("HTML 应被拒");
assert_eq!(error.kind(), JavaErrorKind::Metadata);
}
#[test]
fn major_falls_back_to_the_artifact_name() {
let body = r#"[{"release_name":"jdk-17.0.13+11",
"binary":{"os":"windows","architecture":"x64","image_type":"jdk",
"package":{"name":"OpenJDK17U-jdk_x64_windows_hotspot_17.0.13_11.zip",
"link":"https://example.invalid/x.zip",
"checksum":"a4dbaa05fca4bd89d84584e58c459bd732efebb1722af80ab13c6516832004f9",
"size":1}}}]"#;
let release = parse_latest(body, WINDOWS).expect("应解析");
assert_eq!(release.major, 17);
assert_eq!(release.semver, None);
}
#[test]
fn asset_without_binary_never_matches() {
let asset: ApiAsset = serde_json::from_str(r#"{"release_name":"x"}"#).expect("最小条目");
assert!(!asset.matches(WINDOWS));
}
}
+270
View File
@@ -0,0 +1,270 @@
//! 标准安装位置扫描 + 全部发现源的合并去重(`java list` 的「系统发现」半边)。
//!
//! 只认 `JAVA_HOME`/`PATH` 会漏掉最常见的一类用户:**用 MSI 装的 Temurin / Microsoft OpenJDK
//! 不设环境变量**(安装器只登记开始菜单)。HMCL 的 `JavaManager.searchAllJavaInRepository`
//! 与 PCL 的 `JavaUtils.SearchFolders` 都扫这些位置,本模块照同一思路:
//!
//! | 平台 | 扫描位置 |
//! | --- | --- |
//! | Windows | `%ProgramFiles%\{Java, Eclipse Adoptium, Microsoft, Zulu, Amazon Corretto, BellSoft, Semeru}\*`(含 `ProgramFiles(x86)` / `ProgramW6432` 变体,WOW64 才看得到 32 位安装)、`%APPDATA%\.minecraft\runtime\*`(**官方启动器装的运行时**,跨产品兼容)、`HKLM\SOFTWARE\JavaSoft\JDK`(老 Oracle 写的,新版发行版通常没有→容错跳过) |
//! | Linux | `/usr/lib/jvm/*`、`/usr/java/*` |
//! | macOS | `/Library/Java/JavaVirtualMachines/*.jdk/Contents/Home` |
//!
//! 三个纪律:
//!
//! 1. **目录不存在是常态**(本机实测 `%APPDATA%\.minecraft\runtime` 与 `JavaSoft\JDK` 都没有),
//! 一律静默跳过,绝不报错;
//! 2. **同一路径多来源命中只列一次**,保留优先级高者(`JAVA_HOME` 指向 Program Files 里的
//! JDK 时,用户看到的是 `JAVA_HOME` 而不是 `system`);
//! 3. **扫描只在需要时才做**:`java list` 全扫;启动路径上先看托管+环境,不满足才扫盘
//! (别为了一次启动白跑 N 次 `java -version`)。
use std::path::{Path, PathBuf};
use std::process::Command;
use super::extract::java_binary_name;
use super::probe::{self, discover_managed_roots, system_candidates, JavaOrigin, JavaRuntime};
/// 全部发现源(托管 → `JAVA_HOME` → `PATH` → 标准安装位置),已按路径去重。
#[must_use]
pub fn candidates(managed_root: &Path) -> Vec<JavaRuntime> {
candidates_in_roots(std::slice::from_ref(&managed_root.to_path_buf()))
}
/// 同上,但托管区可以是**多个安装根**(Portable 数据根 + Standard/Custom 落点,D23)。
#[must_use]
pub fn candidates_in_roots(managed_roots: &[PathBuf]) -> Vec<JavaRuntime> {
let mut all = discover_managed_roots(managed_roots);
all.extend(system_candidates());
all.extend(standard_candidates());
dedupe(all)
}
/// 标准安装位置扫出来的运行时(逐个探测)。
#[must_use]
pub fn standard_candidates() -> Vec<JavaRuntime> {
scan_java_binaries()
.into_iter()
.map(|binary| probe::probe(&binary, JavaOrigin::System))
.collect()
}
/// 扫描标准安装位置,返回 `bin/java[.exe]` 路径表(稳定排序,未探测)。
#[must_use]
pub fn scan_java_binaries() -> Vec<PathBuf> {
let mut found: Vec<PathBuf> = Vec::new();
for root in standard_roots() {
let binary = root.join("bin").join(java_binary_name());
if binary.is_file() {
found.push(binary);
}
}
for home in registry_homes() {
let binary = home.join("bin").join(java_binary_name());
if binary.is_file() {
found.push(binary);
}
}
found.sort();
found.dedup();
found
}
/// 候选 JDK 根目录(`bin/java` 的父目录的父目录)。
#[must_use]
pub fn standard_roots() -> Vec<PathBuf> {
let mut roots = Vec::new();
if cfg!(windows) {
for variable in ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"] {
let Some(base) = std::env::var_os(variable) else {
continue;
};
let base = PathBuf::from(base);
for vendor in WINDOWS_VENDOR_DIRS {
roots.extend(child_dirs(&base.join(vendor)));
}
}
if let Some(appdata) = std::env::var_os("APPDATA") {
roots.extend(child_dirs(
&PathBuf::from(appdata).join(".minecraft").join("runtime"),
));
}
} else if cfg!(target_os = "macos") {
for entry in child_dirs(Path::new("/Library/Java/JavaVirtualMachines")) {
// `*.jdk/Contents/Home`(`java_home` 语义)。
let home = entry.join("Contents").join("Home");
roots.push(if home.is_dir() { home } else { entry });
}
} else {
for base in ["/usr/lib/jvm", "/usr/java"] {
roots.extend(child_dirs(Path::new(base)));
}
}
roots.sort();
roots.dedup();
roots
}
/// Windows 各发行版的安装父目录(每种下面按子目录名当版本)。
const WINDOWS_VENDOR_DIRS: &[&str] = &[
"Java",
"Eclipse Adoptium",
"Microsoft",
"Zulu",
"Amazon Corretto",
"BellSoft",
"Semeru",
];
/// 列一个目录的直接子目录(不存在/无权限一律空)。
fn child_dirs(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
entries
.flatten()
.filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
.map(|entry| entry.path())
.collect()
}
/// 注册表里登记的 `JavaHome`(只查老 Oracle 的 `JavaSoft\JDK`;查不到返回空)。
///
/// 不走额外依赖:`reg.exe` 在 Win10+ 恒定存在,输出格式也稳定
/// (` JavaHome REG_SZ C:\Program Files\Java\jdk-17`)。
#[must_use]
pub fn registry_homes() -> Vec<PathBuf> {
if !cfg!(windows) {
return Vec::new();
}
let mut homes = Vec::new();
for view in ["64", "32"] {
let Ok(output) = Command::new("reg")
.args(["query", "HKLM\\SOFTWARE\\JavaSoft\\JDK", "/s", "/reg:"])
.arg(view)
.output()
else {
continue;
};
if !output.status.success() {
continue;
}
homes.extend(parse_java_home(&String::from_utf8_lossy(&output.stdout)));
}
homes.sort();
homes.dedup();
homes
}
/// 从 `reg query /s` 的输出里抠出 `JavaHome` 的值(纯函数,便于用真实输出做单测)。
#[must_use]
pub fn parse_java_home(output: &str) -> Vec<PathBuf> {
output
.lines()
.filter_map(|line| {
let rest = line.trim().strip_prefix("JavaHome")?;
let (_, value) = rest.split_once("REG_SZ")?;
let home = value.trim();
(!home.is_empty()).then(|| PathBuf::from(home))
})
.collect()
}
/// 按路径去重,保留**先出现**的那个(调用方按优先级排好序)。
#[must_use]
pub fn dedupe(runtimes: Vec<JavaRuntime>) -> Vec<JavaRuntime> {
let mut seen: Vec<String> = Vec::with_capacity(runtimes.len());
let mut kept = Vec::with_capacity(runtimes.len());
for runtime in runtimes {
let key = path_key(&runtime.binary);
if seen.contains(&key) {
continue;
}
seen.push(key);
kept.push(runtime);
}
kept
}
/// 去重键:去掉 Windows 的 `\\?\` 前缀、统一分隔符、大小写不敏感(Windows)。
#[must_use]
pub fn path_key(path: &Path) -> String {
let text = path.display().to_string();
let text = text.strip_prefix(r"\\?\").unwrap_or(&text).to_string();
if cfg!(windows) {
text.replace('/', "\\").to_ascii_lowercase()
} else {
text
}
}
#[cfg(test)]
mod tests {
use super::{dedupe, parse_java_home, path_key, standard_roots};
use crate::game::java::probe::{JavaOrigin, JavaRuntime};
fn runtime(path: &str, origin: JavaOrigin) -> JavaRuntime {
JavaRuntime {
binary: std::path::PathBuf::from(path),
origin,
version: None,
major: Some(21),
bits: 64,
vendor: None,
}
}
#[test]
fn dedupe_keeps_the_higher_priority_source() {
let list = vec![
runtime(
"C:/Program Files/Java/jdk-21/bin/java.exe",
JavaOrigin::JavaHome,
),
runtime(
"C:\\Program Files\\Java\\jdk-21\\bin\\java.exe",
JavaOrigin::System,
),
runtime(
"C:/Program Files/Zulu/zulu-17/bin/java.exe",
JavaOrigin::System,
),
];
let kept = dedupe(list);
assert_eq!(kept.len(), 2);
assert_eq!(kept[0].origin, JavaOrigin::JavaHome, "重复路径保留高优先级");
}
#[test]
fn path_key_normalises_separators() {
let left = path_key(std::path::Path::new("C:/a/b/java.exe"));
let right = path_key(std::path::Path::new("C:\\a\\b\\java.exe"));
assert_eq!(left, right);
assert!(!path_key(std::path::Path::new(r"\\?\C:\a\java.exe")).starts_with(r"\\"));
}
#[test]
fn parses_real_reg_query_output() {
// 实测形态(`reg query HKLM\SOFTWARE\JavaSoft\JDK /s`)。
let output = "\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\JavaSoft\\JDK\r\n CurrentVersion REG_SZ 1.8\r\n\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\JavaSoft\\JDK\\1.8\r\n JavaHome REG_SZ C:\\Program Files\\Java\\jdk1.8.0_402\r\n";
let homes = parse_java_home(output);
assert_eq!(
homes,
vec![std::path::PathBuf::from(
r"C:\Program Files\Java\jdk1.8.0_402"
)]
);
}
#[test]
fn missing_registry_key_is_not_an_error() {
assert!(parse_java_home("错误: 系统找不到指定的注册表项或值。\r\n").is_empty());
}
#[test]
fn standard_roots_never_panic_on_missing_directories() {
// 本机大概率缺若干目录;这里只要求「能跑、不 panic」。
let roots = standard_roots();
assert!(roots.iter().all(|root| !root.as_os_str().is_empty()));
}
}
+138
View File
@@ -0,0 +1,138 @@
//! Java 管理模块的错误形状:稳定分类 + 人读说明 + 下一步提示。
//!
//! 与 `core::game::install::InstallError` 同一个套路(分类进 `--json` 的 `error.code`,
//! 说明给人看,提示告诉用户下一步做什么),只是没有复用那个类型——安装层的错误语义
//! 绑在版本本体上,JDK 下载的失败面(API 不可达 / 无对应产物 / 解压后跑不起来)不一样。
use std::fmt;
use crate::download::{DownloadError, DownloadErrorKind};
/// 失败大类(CLI 映射成稳定错误码,脚本可分流)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JavaErrorKind {
/// 网络侧:Adoptium API 不可达,或所有候选镜像源都失败。
Network,
/// 元数据不认识:API 返回体缺字段、空列表、要的大版本没有 GA 产物。
Metadata,
/// 摘要校验不符(镜像给了错内容)或 API 没给可用的 sha256。
Integrity,
/// 本地 IO:建目录 / 解压 / 落盘。
Io,
/// 当前平台没有对应的 Temurin 产物(架构不认识等)。
Unsupported,
/// 装完探测不到可用 java(解压成功但跑不起来 / 版本不符)。
Probe,
}
impl JavaErrorKind {
/// 稳定字符串 id(进 `--json` 的 `error.code`)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Network => "java_network",
Self::Metadata => "java_metadata",
Self::Integrity => "java_integrity",
Self::Io => "java_io",
Self::Unsupported => "java_unsupported",
Self::Probe => "java_probe",
}
}
}
/// Java 管理期错误。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JavaError {
kind: JavaErrorKind,
message: String,
hint: Option<String>,
}
impl JavaError {
/// 构造。
#[must_use]
pub fn new(kind: JavaErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
hint: None,
}
}
/// 补一句「下一步怎么办」。
#[must_use]
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
/// 分类。
#[must_use]
pub const fn kind(&self) -> JavaErrorKind {
self.kind
}
/// 稳定错误码。
#[must_use]
pub const fn code(&self) -> &'static str {
self.kind.as_str()
}
/// 人读说明。
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
/// 下一步提示。
#[must_use]
pub fn hint(&self) -> Option<&str> {
self.hint.as_deref()
}
/// 在说明前面加一段上下文(保留分类与提示)。
#[must_use]
pub fn with_context(mut self, context: impl fmt::Display) -> Self {
self.message = format!("{context}:{}", self.message);
self
}
}
impl fmt::Display for JavaError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for JavaError {}
impl From<DownloadError> for JavaError {
fn from(error: DownloadError) -> Self {
let kind = match error.kind {
DownloadErrorKind::Integrity => JavaErrorKind::Integrity,
DownloadErrorKind::Io => JavaErrorKind::Io,
DownloadErrorKind::InvalidRequest => JavaErrorKind::Metadata,
DownloadErrorKind::NotFound
| DownloadErrorKind::ServerError
| DownloadErrorKind::Timeout
| DownloadErrorKind::Network
| DownloadErrorKind::RangeUnsupported
| DownloadErrorKind::AllSourcesFailed => JavaErrorKind::Network,
};
let mut message = error.message.clone();
if let Some(source) = &error.source {
message.push_str(&format!("(源 {source})"));
}
// 逐源失败摘要:没有这一段,「全部失败」在现场只能靠复现去猜(本轮真机验收就踩到了)。
if !error.suppressed.is_empty() {
message.push_str(&format!(";逐源明细:{}", error.suppressed.join(" / ")));
}
Self::new(kind, message)
}
}
/// IO 错误包装(带上「在做什么 / 哪个路径」,日志里才追得动)。
#[must_use]
pub fn io_error(op: &'static str, path: impl fmt::Display, source: &std::io::Error) -> JavaError {
JavaError::new(JavaErrorKind::Io, format!("{op} 失败({path}):{source}"))
}
+318
View File
@@ -0,0 +1,318 @@
//! 归档解压:zip(Windows)/ tar.gz(Linux、macOS 的 Temurin 只发这种)+ 幂等戳记。
//!
//! # 为什么不用「解压到最终目录」了事
//!
//! Temurin 的归档里所有条目都在一个顶层目录下(`jdk-21.0.13+9/bin/java`),
//! 而托管布局要的是 `java-managed/jdk-21-temurin/bin/java`。裸解压会多出一层,
//! 于是这里先在**暂存目录**里解完,再把那个唯一的顶层目录(或暂存目录本身)
//! 提升成最终目录——这样终态恒定,`bin/java` 的位置不会因为厂商换打包方式而漂移。
//!
//! # 幂等
//!
//! 戳记 `.ull-jdk.json` 记下「这份目录是用哪个 sha256 的归档解出来的 + 文件数」。
//! 重复 `ull java install 21` 时 sha256 相同就**一个字节都不动**(返回 `skipped`);
//! 官方发了新版(sha256 变)才重解。戳记缺失/损坏一律当「没装过」。
//!
//! # 安全
//!
//! zip 走 `enclosed_name()`(拒绝 `..` 与绝对路径),tar 走 `unpack_in()`
//! (同样拒绝越界),两者都不会把文件写到目标目录之外(zip-slip)。
//! tar 里的符号链接条目跳过(Windows 建不了;Temurin 的 JDK 归档不依赖它们)。
use std::fs::{self, File};
use std::io;
use std::path::{Path, PathBuf};
use flate2::read::GzDecoder;
use serde::{Deserialize, Serialize};
use zip::ZipArchive;
use super::error::{io_error, JavaError, JavaErrorKind};
/// 戳记文件名(放在托管 JDK 根目录里)。
pub const STAMP_FILE: &str = ".ull-jdk.json";
/// 戳记 schema 版本(改字段语义才升)。
const STAMP_VERSION: u8 = 1;
/// 暂存目录前缀(解压在它里面进行,成功后提升成最终目录)。
const STAGING_PREFIX: &str = ".staging-";
/// 托管 JDK 的戳记。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JdkStamp {
/// schema 版本。
pub v: u8,
/// 归档文件名。
pub archive: String,
/// 归档的 SHA-256(幂等判据)。
pub sha256: String,
/// 大版本号。
pub major: u32,
/// 解出来的文件数。
pub files: usize,
}
/// 一次解压的结果。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExtractReport {
/// 本次落盘的文件数(`skipped` 时为 0)。
pub files: usize,
/// 是否因为戳记命中而整体跳过。
pub skipped: bool,
}
impl ExtractReport {
/// 是否真的写盘了。
#[must_use]
pub const fn wrote(self) -> bool {
!self.skipped
}
}
/// 托管 JDK 的 `java` 可执行文件路径。
#[must_use]
pub fn java_binary(dir: &Path) -> PathBuf {
dir.join("bin").join(java_binary_name())
}
/// 平台上的 java 可执行文件名。
#[must_use]
pub const fn java_binary_name() -> &'static str {
if cfg!(windows) {
"java.exe"
} else {
"java"
}
}
/// 读戳记(不存在 / 损坏 / 版本不符都当作「没有」)。
#[must_use]
pub fn read_stamp(dir: &Path) -> Option<JdkStamp> {
let text = fs::read_to_string(dir.join(STAMP_FILE)).ok()?;
let stamp: JdkStamp = serde_json::from_str(&text).ok()?;
(stamp.v == STAMP_VERSION).then_some(stamp)
}
/// 这个目录里是不是已经躺着一份用**同一个归档**解出来的 JDK。
#[must_use]
pub fn is_ready(dir: &Path, sha256: &str) -> bool {
let Some(stamp) = read_stamp(dir) else {
return false;
};
stamp.sha256 == sha256.to_ascii_lowercase() && java_binary(dir).is_file()
}
/// 解压一个 JDK 归档到 `dest`(幂等)。
///
/// # Errors
///
/// 归档打不开/格式不认识、解压写盘失败、`dest` 已存在却不由我们管理
/// (没有戳记)时返回错误。
pub fn extract_archive(
archive: &Path,
sha256: &str,
major: u32,
dest: &Path,
) -> Result<ExtractReport, JavaError> {
if is_ready(dest, sha256) {
return Ok(ExtractReport {
files: 0,
skipped: true,
});
}
let parent = dest.parent().ok_or_else(|| {
JavaError::new(
JavaErrorKind::Io,
format!("托管目录没有父目录:{}", dest.display()),
)
})?;
fs::create_dir_all(parent)
.map_err(|error| io_error("创建托管目录", parent.display(), &error))?;
let folder = dest
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "jdk".to_string());
let staging = parent.join(format!("{STAGING_PREFIX}{folder}"));
if staging.exists() {
fs::remove_dir_all(&staging)
.map_err(|error| io_error("清理暂存目录", staging.display(), &error))?;
}
fs::create_dir_all(&staging)
.map_err(|error| io_error("创建暂存目录", staging.display(), &error))?;
let extracted = if is_zip(archive) {
extract_zip(archive, &staging)
} else {
extract_tar_gz(archive, &staging)
};
let files = match extracted {
Ok(files) => files,
Err(error) => {
let _ = fs::remove_dir_all(&staging);
return Err(error);
}
};
let root = single_top_level_dir(&staging).unwrap_or_else(|| staging.clone());
if dest.exists() {
// 只回收「我们自己装的」目录:有戳记,或空目录。其余一律不碰。
let ours = read_stamp(dest).is_some() || is_empty_dir(dest);
if !ours {
let _ = fs::remove_dir_all(&staging);
return Err(JavaError::new(
JavaErrorKind::Io,
format!("目标目录已存在且不是托管 JDK:{}", dest.display()),
)
.with_hint("换个目录(`--data-dir`),或先手动清掉它"));
}
fs::remove_dir_all(dest)
.map_err(|error| io_error("清空旧托管目录", dest.display(), &error))?;
}
fs::rename(&root, dest).map_err(|error| {
io_error(
"提升暂存目录",
format!("{} → {}", root.display(), dest.display()),
&error,
)
})?;
if root != staging {
let _ = fs::remove_dir_all(&staging);
}
if !java_binary(dest).is_file() {
return Err(JavaError::new(
JavaErrorKind::Probe,
format!("解压完成但找不到 bin/{}", java_binary_name()),
)
.with_hint("归档形态与平台不匹配(例如把 Linux 包当成 Windows 包装)"));
}
let stamp = JdkStamp {
v: STAMP_VERSION,
archive: archive
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default(),
sha256: sha256.to_ascii_lowercase(),
major,
files,
};
let text = serde_json::to_string_pretty(&stamp).map_err(|error| {
JavaError::new(
JavaErrorKind::Metadata,
format!("序列化 JDK 戳记失败:{error}"),
)
})?;
let stamp_path = dest.join(STAMP_FILE);
fs::write(&stamp_path, text)
.map_err(|error| io_error("写 JDK 戳记", stamp_path.display(), &error))?;
Ok(ExtractReport {
files,
skipped: false,
})
}
/// 归档是不是 zip(魔数优先,读不到才退回扩展名)。
///
/// 不按扩展名一刀切:镜像把 `.zip` 存成 `.zip.tmp` 之类时魔数仍然可靠,
/// 反过来把 tar.gz 命名成 `.zip` 也不会被错解。
#[must_use]
pub fn is_zip(archive: &Path) -> bool {
let mut head = [0u8; 4];
let magic = File::open(archive)
.and_then(|mut file| io::Read::read_exact(&mut file, &mut head).map(|()| head));
if let Ok(head) = magic {
// `PK\x03\x04` 普通条目 / `PK\x05\x06` 空归档。
return head == *b"PK\x03\x04" || head == *b"PK\x05\x06";
}
archive
.file_name()
.map(|name| name.to_string_lossy().to_ascii_lowercase())
.is_some_and(|name| name.ends_with(".zip"))
}
/// zip:逐条目落盘,`enclosed_name` 挡路径穿越。
fn extract_zip(archive: &Path, dir: &Path) -> Result<usize, JavaError> {
let file = File::open(archive)
.map_err(|error| io_error("打开 JDK 归档", archive.display(), &error))?;
let mut zip = ZipArchive::new(file).map_err(|error| {
JavaError::new(
JavaErrorKind::Io,
format!("JDK 归档不是合法 zip({}):{error}", archive.display()),
)
})?;
let mut files = 0;
for index in 0..zip.len() {
let mut entry = zip.by_index(index).map_err(|error| {
JavaError::new(
JavaErrorKind::Io,
format!("读 zip 条目失败({}):{error}", archive.display()),
)
})?;
if entry.is_dir() {
continue;
}
let Some(relative) = entry.enclosed_name() else {
continue;
};
let dest = dir.join(&relative);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.map_err(|error| io_error("创建解压子目录", parent.display(), &error))?;
}
let mut out = File::create(&dest)
.map_err(|error| io_error("创建解压文件", dest.display(), &error))?;
io::copy(&mut entry, &mut out)
.map_err(|error| io_error("写解压文件", dest.display(), &error))?;
files += 1;
}
Ok(files)
}
/// tar.gz:`unpack_in` 挡路径穿越,符号链接条目跳过。
fn extract_tar_gz(archive: &Path, dir: &Path) -> Result<usize, JavaError> {
let file = File::open(archive)
.map_err(|error| io_error("打开 JDK 归档", archive.display(), &error))?;
let mut tar = tar::Archive::new(GzDecoder::new(file));
let entries = tar.entries().map_err(|error| {
JavaError::new(
JavaErrorKind::Io,
format!("JDK 归档不是合法 tar.gz({}):{error}", archive.display()),
)
})?;
let mut files = 0;
for entry in entries {
let mut entry = entry.map_err(|error| {
JavaError::new(
JavaErrorKind::Io,
format!("读 tar 条目失败({}):{error}", archive.display()),
)
})?;
if !entry.header().entry_type().is_file() {
continue;
}
let unpacked = entry
.unpack_in(dir)
.map_err(|error| io_error("解压 tar 条目", dir.display(), &error))?;
if unpacked {
files += 1;
}
}
Ok(files)
}
/// 暂存目录里是不是只有一个顶层目录(Temurin 的 `jdk-21.0.13+9/`)。
fn single_top_level_dir(dir: &Path) -> Option<PathBuf> {
let mut entries = fs::read_dir(dir).ok()?;
let first = entries.next()?.ok()?;
if entries.next().is_some() {
return None;
}
let path = first.path();
path.is_dir().then_some(path)
}
fn is_empty_dir(dir: &Path) -> bool {
fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none())
}
+379
View File
@@ -0,0 +1,379 @@
//! 托管 JDK 安装编排:发行版回退链 → 下载引擎 → 解压 → `java -version` 验证。
//!
//! ```text
//! vendor::order(用户优先级, 地区) D23 的尝试顺序
//! → source::fetch(vendor, major) 查到该家最新 GA(Temurin/Oracle/MS/Zulu)
//! → location::JavaInstallRoot::jdk_dir 按落点模式算目录
//! → [戳记 sha256 相同 && bin/java 在] 复用 幂等的第一层
//! → engine.download(JavaRuntime 请求) 镜像/健康度/退避/续传/边下边算全在引擎
//! → extract::extract_archive zip / tar.gz + 幂等戳记
//! → probe::probe(bin/java -version) 幂等的第二层,也是「装完真能用」的证据
//! ```
//!
//! **回退的粒度是发行版**(D23):该家没有这个大版本、镜像全 4xx、sha256 取不到、下载校验失败,
//! 都记一条降级原因然后换下一家;只有全部失败才整体报错,错误里带上每一跳的原因。
//!
//! 这一步**不拼任何镜像 URL**(Oracle 的哈希段不可推导,见 `oracle` 模块):Temurin 的
//! GitHub 直链交给 `MirrorTable::expand` 展开成院校镜像,Oracle/MS/Zulu 的链接由各自模块
//! 从目录页/模板/API 拿到最终形态,引擎只负责「把这个 URL 下下来并校验」。
use std::path::{Path, PathBuf};
use super::error::{io_error, JavaError, JavaErrorKind};
use super::extract;
use super::location::{JavaInstallRoot, LocationMode};
use super::probe::{self, JavaOrigin, JavaRuntime};
use super::release::JdkRelease;
use super::source::{self, ChainLog};
use super::target::AdoptiumTarget;
use super::vendor::{self, JdkVendor};
use crate::download::{DownloadEngine, DownloadRequest, ResourceKind};
/// 归档缓存目录名(在安装根下;托管目录扫描跳过它)。
pub use super::location::CACHE_DIR;
/// 一次安装请求。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstallRequest {
/// 目标大版本。
pub major: u32,
/// 钉死某一家发行版(`--vendor`);`None` = 按回退链自己选。
pub vendor: Option<JdkVendor>,
/// 用户优先级(`java.vendorPriority`,空 = 按地区默认序)。
pub priority: Vec<JdkVendor>,
}
impl InstallRequest {
/// 只要大版本(走回退链,优先级用地区默认)。
#[must_use]
pub const fn new(major: u32) -> Self {
Self {
major,
vendor: None,
priority: Vec::new(),
}
}
/// 钉死发行版。
#[must_use]
pub const fn with_vendor(major: u32, vendor: JdkVendor) -> Self {
Self {
major,
vendor: Some(vendor),
priority: Vec::new(),
}
}
/// 带上用户优先级(只影响没有 `--vendor` 的情形)。
#[must_use]
pub fn with_priority(mut self, priority: Vec<JdkVendor>) -> Self {
self.priority = priority;
self
}
/// 实际尝试顺序(`--vendor` 钉死时链里只放它)。
#[must_use]
pub fn order(&self, domestic: bool) -> Vec<JdkVendor> {
match self.vendor {
Some(vendor) => vec![vendor],
None => vendor::order(&self.priority, domestic),
}
}
}
/// 会真正进入回退链的发行版(顺序 + 能力过滤)。
///
/// 与 [`install`] 的循环同源,但**纯**:不联网、不落盘,给 `--dry-run` 与离线测试用。
/// 过滤条件只有一条——该家发不发这个 GA 大版本(MS 没有 8、Oracle 从 9 起)。
/// `--vendor` 钉死时**不过滤**:链里就它一个,由 `install` 明确报「不支持 Java X」,
/// 而不是悄悄把用户点名的发行版换成别的。
#[must_use]
pub fn chain_order(request: &InstallRequest, domestic: bool) -> Vec<JdkVendor> {
if request.vendor.is_some() {
return request.order(domestic);
}
request
.order(domestic)
.into_iter()
.filter(|vendor| vendor.supports(request.major))
.collect()
}
/// 一次成功安装的结果。
#[derive(Debug, Clone)]
pub struct InstalledJdk {
/// 大版本号。
pub major: u32,
/// 实际装上的发行版。
pub vendor: JdkVendor,
/// 版本查询结果(含归档名与 sha256)。
pub release: JdkRelease,
/// 托管目录(Portable:`java-managed/jdk-21-temurin`;Standard:`Java/oracle/21.0.2`)。
pub root: PathBuf,
/// 落点模式(报告里要说清「装到哪」)。
pub mode: LocationMode,
/// 归档路径(缓存)。
pub archive: PathBuf,
/// 探测到的运行时。
pub runtime: JavaRuntime,
/// 实际下载的源标识(`None` = 复用已装,没走网络)。
pub downloaded: Option<String>,
/// 本次下载字节数(复用已装时为 0)。
pub bytes: u64,
/// 本次解压的文件数(复用已装时可能为 0)。
pub files: usize,
/// 本次是否真的解压了。
pub extracted: bool,
/// 回退链记录(命中与降级原因)。
pub chain: ChainLog,
}
impl InstalledJdk {
/// 是否完全复用了已装的那一份。
#[must_use]
pub fn reused(&self) -> bool {
self.downloaded.is_none()
}
/// `bin/java[.exe]` 路径。
#[must_use]
pub fn binary(&self) -> PathBuf {
extract::java_binary(&self.root)
}
/// 实际落点(人读一行)。
#[must_use]
pub fn location_line(&self) -> String {
format!("{} · {}", self.mode.as_str(), self.root.display())
}
}
/// 托管目录路径(Portable 布局 `java-managed/jdk-<major>-<vendor>`)。
#[must_use]
pub fn managed_root_dir(managed_root: &Path, major: u32, vendor: &str) -> PathBuf {
managed_root.join(probe::managed_dir_name(major, vendor))
}
/// 归档缓存路径(`<root>/.cache/<归档名>`)。
#[must_use]
pub fn archive_path(root: &Path, release: &JdkRelease) -> PathBuf {
root.join(CACHE_DIR).join(&release.archive.name)
}
/// 归档下载请求(`ResourceKind::JavaRuntime` + sha256 + 期望大小)。
#[must_use]
pub fn archive_request(release: &JdkRelease, dest: PathBuf) -> DownloadRequest {
let request =
DownloadRequest::new(ResourceKind::JavaRuntime, release.archive.url.clone(), dest)
.with_sha256(release.archive.sha256.clone());
if release.archive.size > 0 {
request.with_size(release.archive.size)
} else {
request
}
}
/// 按优先级链装一个托管 JDK(幂等:同一份归档装过就直接复用)。
///
/// # Errors
///
/// 全部发行版都失败(没有该大版本 / 平台无产物 / 网络 / 校验 / 解压 / 装完跑不起来)时
/// 返回错误,`message()` 里带着每一跳的降级原因。
pub async fn install(
engine: &DownloadEngine,
location: &JavaInstallRoot,
request: InstallRequest,
) -> Result<InstalledJdk, JavaError> {
let target = AdoptiumTarget::host()?;
let domestic = engine.region().is_domestic();
let priority = request.order(domestic);
let mut chain = ChainLog::new();
let mut reasons: Vec<String> = Vec::new();
// 清掉上次中断留下的 .staging-* 与空壳(只碰我们自己的形状,见 location 模块)。
let _ = super::location::cleanup_orphans(&location.root);
for candidate in &priority {
let vendor = *candidate;
if !vendor.supports(request.major) {
let reason = format!(
"不支持 Java {}({} 只发 {})",
request.major,
vendor.id(),
majors_text(vendor)
);
chain.skip(vendor, reason.clone());
reasons.push(format!("{}:{reason}", vendor.id()));
continue;
}
let release = match source::fetch(engine, vendor, request.major, target).await {
Ok(release) => release,
Err(error) => {
chain.failed(vendor, error.message().to_string());
reasons.push(format!("{}:{}", vendor.id(), error.message()));
continue;
}
};
match install_one(engine, location, &release).await {
Ok(prepared) => {
chain.hit(vendor, format!("命中 {}", release.summary()));
return Ok(InstalledJdk {
major: release.major,
vendor,
release,
root: prepared.root,
mode: location.mode,
archive: prepared.archive,
runtime: prepared.runtime,
downloaded: prepared.downloaded,
bytes: prepared.bytes,
files: prepared.files,
extracted: prepared.extracted,
chain,
});
}
Err(error) => {
chain.failed(vendor, error.message().to_string());
reasons.push(format!("{}:{}", vendor.id(), error.message()));
}
}
}
Err(JavaError::new(
JavaErrorKind::Metadata,
format!(
"没有装成 Java {}(试过 {}):{}",
request.major,
priority.len(),
reasons.join(";")
),
)
.with_hint("`ull java vendors` 看各发行版的可用性;或手动装 JDK 后设 JAVA_HOME"))
}
/// 某一家的中间结果(把七元组元组换成具名结构,读起来才不费劲)。
struct Prepared {
root: PathBuf,
archive: PathBuf,
runtime: JavaRuntime,
downloaded: Option<String>,
bytes: u64,
files: usize,
extracted: bool,
}
/// 装某一家的那一份(下载 → 解压 → 探测)。
async fn install_one(
engine: &DownloadEngine,
location: &JavaInstallRoot,
release: &JdkRelease,
) -> Result<Prepared, JavaError> {
let root = location.jdk_dir(release.major, release.vendor, &release.version);
let binary = extract::java_binary(&root);
let archive = archive_path(&location.root, release);
// 幂等第一层:戳记里的 sha256 与本次一致,且 bin/java 在 → 一个字节都不动。
if extract::is_ready(&root, release.sha256()) {
let runtime = probe::probe(&binary, JavaOrigin::Managed);
if runtime.major == Some(release.major) {
let mut runtime = runtime;
runtime.vendor = Some(release.vendor.id().to_string());
return Ok(Prepared {
root,
archive,
runtime,
downloaded: None,
bytes: 0,
files: 0,
extracted: false,
});
}
note(&format!(
"托管目录 {} 里的 java 跑不起来,重装一份",
root.display()
));
}
if let Some(parent) = archive.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| io_error("创建归档缓存目录", parent.display(), &error))?;
}
let outcome = engine
.download(archive_request(release, archive.clone()))
.await?;
let report = extract::extract_archive(&archive, release.sha256(), release.major, &root)?;
let mut runtime = probe::probe(&binary, JavaOrigin::Managed);
runtime.vendor = Some(release.vendor.id().to_string());
if runtime.major != Some(release.major) {
return Err(JavaError::new(
JavaErrorKind::Probe,
format!(
"装好的 JDK 探测结果是 Java {},与请求的 {} 不符({})",
runtime
.major
.map_or_else(|| "未知".to_string(), |major| major.to_string()),
release.major,
binary.display()
),
)
.with_hint("删掉该托管目录后重跑 `ull java install`;或手动设 JAVA_HOME"));
}
Ok(Prepared {
root,
archive,
runtime,
downloaded: Some(outcome.source),
bytes: outcome.bytes,
files: report.files,
extracted: report.wrote(),
})
}
/// 只做规划不落盘:返回将要安装的版本、目标目录与「是否已经装好」。
///
/// # 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 {
vendor
.hint_majors()
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join("/")
}
fn note(message: &str) {
if cfg!(debug_assertions) {
eprintln!("[ull::game::java] {message}");
} else {
let _ = message;
}
}
+335
View File
@@ -0,0 +1,335 @@
//! 托管 JDK 的落点模式(D23 三模式,与 D6 数据目录三模式对齐)。
//!
//! | 模式 | 落点 | 说明 |
//! | --- | --- | --- |
//! | Portable | `<数据根>/.ULL/data/java-managed/jdk-<major>-<vendor>/` | v1 既有布局,M33/M35 的托管根解析语义不变 |
//! | Standard | Windows `%APPDATA%\Java\<vendor>\<version>\`;macOS `~/Library/Application Support/Java/<vendor>/<version>`;Linux `$XDG_DATA_HOME/java/<vendor>/<version>`(回落 `~/.local/share/java/…`) | 用户原话「安装到 %APPDATA%\Java\发行方\Java版本\」 |
//! | Custom | 设置键 `java.installRoot` 指定的目录,同样按 `<vendor>/<version>` 分层 | 覆盖前两种模式 |
//!
//! 三条纪律:
//!
//! 1. **不猜平台目录**:`%APPDATA%` / `XDG_DATA_HOME` / `HOME` 全走 `platform::paths::EnvSource`,
//! 测试能注入假环境确定性地覆盖各 OS 落点;
//! 2. **Portable 布局永不改**:`.ULL/data/java-managed/jdk-<major>-<vendor>` 是 M33 定下的,
//! 改成 `<vendor>/<version>` 会让既有托管 JDK 变成孤儿——迁移语义靠「两种形状都扫得到」兜住;
//! 3. **清理只碰自己的形状**:`cleanup_orphans` 只删 `.staging-*` 半成品与空目录,
//! 没有戳记的非空目录一律不动(用户可能自己往里塞了 JDK)。
use std::path::{Path, PathBuf};
use crate::platform::data_dir::DataMode;
use crate::platform::layout::LauncherLayout;
use crate::platform::paths::{home_dir, EnvSource, Platform};
use super::error::{JavaError, JavaErrorKind};
use super::vendor::JdkVendor;
/// Standard 模式下 JDK 的父目录名(Windows 是 `%APPDATA%\Java`)。
pub const JAVA_DIR_NAME: &str = "Java";
/// 归档缓存目录名(在安装根下;托管目录扫描跳过它)。
pub const CACHE_DIR: &str = ".cache";
/// 解压暂存目录前缀(中断后残留的就是孤儿)。
pub const STAGING_PREFIX: &str = ".staging-";
/// 落地模式。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LocationMode {
/// 数据根内(v1 布局)。
Portable,
/// OS 标准应用数据目录。
Standard,
/// `java.installRoot` 指定。
Custom,
}
impl LocationMode {
/// 稳定名(JSON / 日志)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Portable => "portable",
Self::Standard => "standard",
Self::Custom => "custom",
}
}
/// 从配置字符串解析。
#[must_use]
pub fn parse(text: &str) -> Option<Self> {
match text {
"portable" => Some(Self::Portable),
"standard" => Some(Self::Standard),
"custom" => Some(Self::Custom),
_ => None,
}
}
}
/// 解析好的安装根 + 它是怎么来的。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JavaInstallRoot {
/// 落地模式。
pub mode: LocationMode,
/// 安装根目录(各 JDK 目录在它下面)。
pub root: PathBuf,
}
impl JavaInstallRoot {
/// Portable:`<数据根>/.ULL/data/java-managed`。
#[must_use]
pub fn portable(launcher: &LauncherLayout) -> Self {
Self {
mode: LocationMode::Portable,
root: launcher.java_managed(),
}
}
/// Standard:OS 标准应用数据目录 + `Java`。
///
/// 平台约定与 `platform::paths::standard_launcher_home` 同源:
/// - Windows:`%APPDATA%\Java`(回落 `%USERPROFILE%\AppData\Roaming\Java`)
/// - macOS:`~/Library/Application Support/Java`
/// - Linux / 其它:`$XDG_DATA_HOME/java`(回落 `~/.local/share/java`)
#[must_use]
pub fn standard(platform: Platform, env: &dyn EnvSource) -> Option<Self> {
standard_java_root(platform, env).map(|root| Self {
mode: LocationMode::Standard,
root,
})
}
/// Custom:`java.installRoot` 指定的目录(相对路径按数据根解析)。
#[must_use]
pub fn custom(root: impl Into<PathBuf>) -> Self {
Self {
mode: LocationMode::Custom,
root: root.into(),
}
}
/// 按设置解析出真正的落点。
///
/// 顺序:`java.installRoot`(设了就用,模式 Custom)→ Standard(数据目录 Standard 模式)
/// → Portable(数据根内,含 `--data-dir` 的 Custom 数据模式)。
///
/// # Errors
///
/// 数据目录是 Standard 但拿不到系统标准目录时返回 [`JavaErrorKind::Unsupported`]
/// (不静默落到别处——否则用户会在 `%APPDATA%\Java` 里找不到刚装的 JDK)。
pub fn resolve(
data_mode: DataMode,
launcher: &LauncherLayout,
override_root: Option<&Path>,
platform: Platform,
env: &dyn EnvSource,
) -> Result<Self, JavaError> {
if let Some(root) = override_root.filter(|root| !root.as_os_str().is_empty()) {
let root = if root.is_absolute() {
root.to_path_buf()
} else {
launcher.home().join(root)
};
return Ok(Self::custom(root));
}
if data_mode == DataMode::Standard {
return Self::standard(platform, env).ok_or_else(|| {
JavaError::new(
JavaErrorKind::Unsupported,
"Standard 模式下拿不到系统标准目录(%APPDATA% / HOME 都读不到)",
)
.with_hint(
"设置 `java.installRoot` 指定一个目录,或把 `core.data_mode` 改回 portable",
)
});
}
Ok(Self::portable(launcher))
}
/// 某个 JDK 的目录。
///
/// Standard / Custom 用 `<vendor>/<version>`(D23 用户原话的 `<发行方>\<Java版本>\`),
/// Portable 保持 v1 的 `jdk-<major>-<vendor>`(改形状会让既有托管 JDK 变孤儿)。
#[must_use]
pub fn jdk_dir(&self, major: u32, vendor: JdkVendor, version: &str) -> PathBuf {
match self.mode {
LocationMode::Standard | LocationMode::Custom => {
self.root.join(vendor.id()).join(sanitize_version(version))
}
LocationMode::Portable => self
.root
.join(super::probe::managed_dir_name(major, vendor.id())),
}
}
/// 归档缓存目录(`<root>/.cache`)。
#[must_use]
pub fn cache_dir(&self) -> PathBuf {
self.root.join(CACHE_DIR)
}
/// 人读描述(`Standard(%APPDATA%\Java)` 这种)。
#[must_use]
pub fn describe(&self) -> String {
format!("{}({})", self.mode.as_str(), self.root.display())
}
}
/// OS 标准 JDK 根(纯函数,测试注入环境)。
///
/// 注意 `HOME` 不用 `platform::paths::home_dir`:那个函数按**编译目标**分叉(Windows 上读
/// `USERPROFILE`),会让「在 Windows 上断言 macOS/Linux 落点」这种测试无法成立。
/// 这里按传入的 `platform` 参数走,语义与 `paths::standard_launcher_home` 的落点一致。
#[must_use]
pub fn standard_java_root(platform: Platform, env: &dyn EnvSource) -> Option<PathBuf> {
match platform {
Platform::Windows => roaming_app_data(env).map(|dir| dir.join(JAVA_DIR_NAME)),
Platform::MacOs => {
unix_home(env).map(|home| home.join("Library/Application Support").join(JAVA_DIR_NAME))
}
Platform::Linux | Platform::Other => env
.var("XDG_DATA_HOME")
.map(PathBuf::from)
.filter(|dir| dir.is_absolute())
.or_else(|| unix_home(env).map(|home| home.join(".local/share")))
.map(|dir| dir.join("java")),
}
}
/// Unix 家目录(`HOME`,缺了才退回 `platform::paths::home_dir`)。
fn unix_home(env: &dyn EnvSource) -> Option<PathBuf> {
env.var("HOME").map(PathBuf::from).or_else(|| home_dir(env))
}
/// `%APPDATA%`,回落 `%USERPROFILE%\AppData\Roaming`。
fn roaming_app_data(env: &dyn EnvSource) -> Option<PathBuf> {
env.var("APPDATA").map(PathBuf::from).or_else(|| {
env.var("USERPROFILE")
.map(|profile| PathBuf::from(profile).join("AppData/Roaming"))
})
}
/// 版本号 → 目录名(只保留文件系统安全的字符)。
///
/// Temurin `21.0.13+9`、MS `21.0.13.0.2`、Zulu `21.0.13+3` 都原样可作目录名;
/// 遇到 `~` / `/` 这类才替换(Zulu 的 `21.0.13+3-x86_64` 之类不会出现,但别赌)。
#[must_use]
pub fn sanitize_version(version: &str) -> String {
let cleaned: String = version
.trim()
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_' | '+') {
ch
} else {
'_'
}
})
.collect();
if cleaned.is_empty() {
"unknown".to_string()
} else {
cleaned
}
}
/// `java list` 要扫的全部安装根(Portable 数据根在前,去重)。
#[must_use]
pub fn scan_roots(
data_mode: DataMode,
launcher: &LauncherLayout,
override_root: Option<&Path>,
platform: Platform,
env: &dyn EnvSource,
) -> Vec<PathBuf> {
let mut roots = vec![launcher.java_managed()];
if let Ok(resolved) =
JavaInstallRoot::resolve(data_mode, launcher, override_root, platform, env)
{
if !roots.contains(&resolved.root) {
roots.push(resolved.root);
}
}
// 数据目录是 Standard 时,便携目录里的老装法仍然可能还在(用户从便携切过来的)。
if let Some(standard) = standard_java_root(platform, env) {
if !roots.contains(&standard) {
roots.push(standard);
}
}
roots
}
/// 清理结果。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CleanupReport {
/// 删掉的路径(暂存目录、空壳目录)。
pub removed: Vec<PathBuf>,
/// 保留下来的条目数(都是我们没资格动的)。
pub kept: usize,
}
impl CleanupReport {
/// 是否什么都没删。
#[must_use]
pub fn is_noop(&self) -> bool {
self.removed.is_empty()
}
}
/// 清掉安装根下的半成品与空壳。
///
/// 只碰两种东西:①`.staging-*`(解压中断的残留,永远可以删)②空目录(含空 vendor 目录)。
/// 其余一律保留并计入 `kept`——**宁可不删,也不误删用户手工放进来的 JDK**。
#[must_use]
pub fn cleanup_orphans(root: &Path) -> CleanupReport {
let mut report = CleanupReport::default();
let Ok(entries) = std::fs::read_dir(root) else {
return report;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if !path.is_dir() || name == CACHE_DIR {
report.kept += 1;
continue;
}
if name.starts_with(STAGING_PREFIX) {
if std::fs::remove_dir_all(&path).is_ok() {
report.removed.push(path);
} else {
report.kept += 1;
}
continue;
}
if is_empty_dir(&path) {
if std::fs::remove_dir(&path).is_ok() {
report.removed.push(path);
} else {
report.kept += 1;
}
continue;
}
// 两层的 Standard/Custom 布局:`<vendor>/<version>` 里的空目录也要收。
if JdkVendor::parse(&name).is_some() {
for child in std::fs::read_dir(&path).into_iter().flatten().flatten() {
let child_path = child.path();
if child_path.is_dir()
&& is_empty_dir(&child_path)
&& std::fs::remove_dir(&child_path).is_ok()
{
report.removed.push(child_path);
}
}
if is_empty_dir(&path) && std::fs::remove_dir(&path).is_ok() {
report.removed.push(path);
}
}
report.kept += 1;
}
report
}
fn is_empty_dir(dir: &Path) -> bool {
std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none())
}
+376
View File
@@ -0,0 +1,376 @@
//! MC 版本 ↔ Java 版本映射(平移 HMCL 约束体系)+ 不符检查。
//!
//! 依据 `DevDocs/analyse/02-launch-pipeline.md` §5.4(HMCL `GameJavaVersion.getMinimumJavaVersion`)
//! 与 §3.1 的强制约束清单(`JavaVersionConstraint` 的 `isMandatory` 项)。两条权威叠加:
//!
//! | MC 版本 | 最低 | 最高 | 出处 |
//! | --- | --- | --- | --- |
//! | ≥ 26.1 | 25 | — | `java-runtime-epsilon` |
//! | ≥ 1.20.5 | 21 | — | `java-runtime-delta` |
//! | ≥ 1.18 | 17 | — | `java-runtime-beta` |
//! | ≥ 1.17 | 16 | — | `java-runtime-alpha` |
//! | ≥ 1.13 | 8 | — | `jre-legacy` |
//! | < 1.13 | 8 | **8** | `LAUNCH_WRAPPER`:LaunchWrapper 的 `URLClassLoader` 只能吃 Java 8 |
//!
//! **版本 JSON 优先**:装了 1.17 的玩家看的是 Mojang 自己写的 `javaVersion.majorVersion`
//! (1.17 就是 16),静态表只在清单没写时兜底——这也是 HMCL 的 `GAME_JSON` 强制项语义。
//!
//! 上限(`maximum`)只对老版本存在:1.12.2 用 Java 17 会直接
//! `UnsupportedClassVersionError`-级别的失败(启动器侧是 LaunchWrapper 假设),
//! 所以「1.12.2 + Java 21」要被判成不符,而不是「19 以上都能跑」。
use std::cmp::Ordering;
use super::super::inherit::resolve;
use super::super::layout::GameLayout;
use super::super::manifest::{JavaVersion, VersionJson};
/// 要求来源(`--json` 的 `requirement_source`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequirementSource {
/// 版本 JSON 的 `javaVersion`(含继承链解析)。
Manifest,
/// 静态表(HMCL 约束体系的平移)。
Table,
}
impl RequirementSource {
/// 稳定字符串 id。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Manifest => "manifest",
Self::Table => "table",
}
}
}
/// 一个版本对 Java 的要求(闭区间)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JavaRequirement {
/// 最低主版本号(`21`)。
pub minimum: u32,
/// 最高主版本号(`Some(8)` = 只能用 8 及以下)。
pub maximum: Option<u32>,
/// 来源。
pub source: RequirementSource,
}
impl JavaRequirement {
/// 构造。
#[must_use]
pub const fn new(minimum: u32, maximum: Option<u32>, source: RequirementSource) -> Self {
Self {
minimum,
maximum,
source,
}
}
/// 静态表来源的构造。
#[must_use]
pub const fn table(minimum: u32, maximum: Option<u32>) -> Self {
Self::new(minimum, maximum, RequirementSource::Table)
}
/// 某主版本号是否满足要求。
#[must_use]
pub fn satisfied_by(&self, major: u32) -> bool {
major >= self.minimum && self.maximum.is_none_or(|maximum| major <= maximum)
}
/// 人读描述(`Java 21+` / `Java 8` / `Java 8–16`)。
#[must_use]
pub fn describe(&self) -> String {
match self.maximum {
Some(maximum) if maximum == self.minimum => format!("Java {}", self.minimum),
Some(maximum) => format!("Java {}–{}", self.minimum, maximum),
None => format!("Java {}+", self.minimum),
}
}
}
/// 不符的两种形态(D13:只提示,不阻断)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JavaMismatch {
/// Java 太低。
TooOld {
/// 需要的最低主版本。
required: u32,
/// 实际主版本。
actual: u32,
},
/// Java 太高(老版本只能用 8)。
TooNew {
/// 允许的最高主版本。
maximum: u32,
/// 实际主版本。
actual: u32,
},
}
impl JavaMismatch {
/// 稳定字符串 id(`--json` 的 `mismatch.code`)。
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::TooOld { .. } => "java_too_old",
Self::TooNew { .. } => "java_too_new",
}
}
/// 人读说明(GUI 弹窗 P1 后直接复用这句)。
#[must_use]
pub fn message(self) -> String {
match self {
Self::TooOld { required, actual } => format!(
"该版本需要 Java {required} 及以上,当前用的是 Java {actual}(大概率起不来)"
),
Self::TooNew { maximum, actual } => format!(
"该版本只能用 Java {maximum} 及以下,当前用的是 Java {actual}(老加载器吃不了新 class 文件)"
),
}
}
}
/// 判需求与实际是否相符(`None` = 相符)。
#[must_use]
pub fn check(actual: u32, required: &JavaRequirement) -> Option<JavaMismatch> {
if actual < required.minimum {
return Some(JavaMismatch::TooOld {
required: required.minimum,
actual,
});
}
if let Some(maximum) = required.maximum {
if actual > maximum {
return Some(JavaMismatch::TooNew { maximum, actual });
}
}
None
}
/// 静态表:MC 版本 → Java 要求。
///
/// 无法解析的版本(周快照 `20w14a`、魔改 id)只给下限 `8`、不设上限——
/// 宁可漏判一次,也不要把「Java 21 + 1.20 快照」误锁成「必须降到 8」。
#[must_use]
pub fn requirement_for(mc_version: &str) -> JavaRequirement {
let parts = version_parts(mc_version);
if parts.is_empty() {
return JavaRequirement::table(8, None);
}
if at_least(&parts, &[26, 1]) {
return JavaRequirement::table(25, None);
}
if at_least(&parts, &[1, 20, 5]) {
return JavaRequirement::table(21, None);
}
if at_least(&parts, &[1, 18]) {
return JavaRequirement::table(17, None);
}
if at_least(&parts, &[1, 17]) {
return JavaRequirement::table(16, None);
}
if at_least(&parts, &[1, 13]) {
return JavaRequirement::table(8, None);
}
JavaRequirement::table(8, Some(8))
}
/// 静态表的最低位(`recommended_major("1.20.5") == 21`)。
#[must_use]
pub fn recommended_major(mc_version: &str) -> u32 {
requirement_for(mc_version).minimum
}
/// 版本 JSON 的 `javaVersion` → 要求(字段缺失或为 0 时返回 `None`)。
///
/// Mojang 这个字段给的是**最低**要求(`majorVersion: 21` 表示 21 起跳),
/// 不声明上限——所以这里 `maximum` 恒为 `None`;上限由 [`requirement_for`] 补
/// (见 [`requirement_for_version`] 的合成说明)。
#[must_use]
pub fn requirement_from_manifest(java: &JavaVersion) -> Option<JavaRequirement> {
let major = java.major_version.filter(|major| *major > 0)?;
Some(JavaRequirement::new(
major,
None,
RequirementSource::Manifest,
))
}
/// 已安装版本的 Java 要求:解析继承链 → 版本 JSON 的 `javaVersion` 优先 → 静态表兜底。
///
/// **下限与上限来源不同**:Mojang 的 `javaVersion` 只声明「最低要多少」,
/// 而「1.12.2 及更早吃不了 Java 9+」这条知识来自 HMCL 的 `LAUNCH_WRAPPER` 约束,
/// 不在清单里。所以合成时下限取清单、上限取静态表——否则「1.12.2 + Java 21」
/// 会被判成完全合法(真机上必崩)。
///
/// 版本 JSON 读不到(未安装 / 损坏)时直接走静态表,不报错——这是「启动前提示」的路径,
/// 不该因为清单缺件就拦住用户(真正拦人的是 `snapshot::prepare`)。
#[must_use]
pub fn requirement_for_version(layout: &GameLayout, version_id: &str) -> JavaRequirement {
let table = requirement_for(version_id);
let read = |id: &str| -> Option<VersionJson> {
let text = std::fs::read_to_string(layout.version_json(id)).ok()?;
VersionJson::from_json_str(&text).ok()
};
let Some(manifest) = read(version_id) else {
return table;
};
let resolved = resolve(&read, &manifest).unwrap_or(manifest);
let Some(declared) = resolved
.java_version
.as_ref()
.and_then(requirement_from_manifest)
else {
// 清单没写 javaVersion:按 id 的静态表(继承来的 id 可能是魔改名)。
return match resolved.id.as_deref() {
Some(id) => requirement_for(id),
None => table,
};
};
JavaRequirement {
minimum: declared.minimum,
maximum: table.maximum,
source: RequirementSource::Manifest,
}
}
/// 版本串 → 数字分量(`1.20.5-pre1` → `[1, 20, 5]`;周快照 → 空)。
fn version_parts(raw: &str) -> Vec<u32> {
let trimmed = raw.trim();
if is_weekly_snapshot(trimmed) {
return Vec::new();
}
let mut parts = Vec::new();
for piece in trimmed.split('.') {
let digits: String = piece.chars().take_while(char::is_ascii_digit).collect();
match digits.parse::<u32>() {
Ok(value) => parts.push(value),
Err(_) => break,
}
}
parts
}
/// `20w14a` / `26w14a` 形态(与 `launch::game_version` 同一套判据)。
fn is_weekly_snapshot(raw: &str) -> bool {
let bytes = raw.as_bytes();
bytes.len() == 6
&& bytes[..2].iter().all(u8::is_ascii_digit)
&& bytes[2].eq_ignore_ascii_case(&b'w')
&& bytes[3..5].iter().all(u8::is_ascii_digit)
&& bytes[5].is_ascii_alphabetic()
}
/// `parts >= floor`(缺位按 0:`1.20` < `1.20.5`)。
fn at_least(parts: &[u32], floor: &[u32]) -> bool {
let len = parts.len().max(floor.len());
for index in 0..len {
let left = parts.get(index).copied().unwrap_or(0);
let right = floor.get(index).copied().unwrap_or(0);
match left.cmp(&right) {
Ordering::Greater => return true,
Ordering::Less => return false,
Ordering::Equal => {}
}
}
true
}
#[cfg(test)]
mod tests {
use super::{check, recommended_major, requirement_for, JavaMismatch, JavaRequirement};
#[test]
fn table_covers_the_hmcl_boundaries() {
let cases: &[(&str, u32, Option<u32>)] = &[
("1.6.4", 8, Some(8)),
("1.12.2", 8, Some(8)),
("1.13", 8, None),
("1.15.2", 8, None),
("1.16.5", 8, None),
("1.17", 16, None),
("1.17.1", 16, None),
("1.18", 17, None),
("1.19.4", 17, None),
("1.20.1", 17, None),
("1.20.4", 17, None),
("1.20.5", 21, None),
("1.21.4", 21, None),
("26.1", 25, None),
("26.3", 25, None),
("26.3-snapshot-1", 25, None),
];
for (version, minimum, maximum) in cases {
let requirement = requirement_for(version);
assert_eq!(
(requirement.minimum, requirement.maximum),
(*minimum, *maximum),
"{version}"
);
assert_eq!(recommended_major(version), *minimum, "{version}");
}
}
#[test]
fn weekly_snapshots_get_no_upper_bound() {
// 20w14a 的前导数字是 20,绝不能被当成「26.x」或误锁上限。
let requirement = requirement_for("20w14a");
assert_eq!(requirement.minimum, 8);
assert_eq!(requirement.maximum, None);
assert!(requirement.satisfied_by(21));
}
#[test]
fn pre_release_suffixes_do_not_change_the_floor() {
assert_eq!(requirement_for("1.20.5-pre1").minimum, 21);
assert_eq!(requirement_for("1.20.4-rc1").minimum, 17);
}
#[test]
fn check_reports_both_directions() {
let modern = JavaRequirement::table(21, None);
assert_eq!(check(21, &modern), None);
assert_eq!(check(25, &modern), None);
assert_eq!(
check(17, &modern),
Some(JavaMismatch::TooOld {
required: 21,
actual: 17
})
);
let legacy = JavaRequirement::table(8, Some(8));
assert_eq!(check(8, &legacy), None);
assert_eq!(
check(21, &legacy),
Some(JavaMismatch::TooNew {
maximum: 8,
actual: 21
})
);
assert_eq!(legacy.describe(), "Java 8");
assert_eq!(modern.describe(), "Java 21+");
assert_eq!(JavaRequirement::table(8, Some(16)).describe(), "Java 8–16");
}
#[test]
fn mismatch_messages_name_both_sides() {
let message = JavaMismatch::TooOld {
required: 21,
actual: 8,
}
.message();
assert!(message.contains("Java 21"));
assert!(message.contains("Java 8"));
assert_eq!(
JavaMismatch::TooNew {
maximum: 8,
actual: 21
}
.code(),
"java_too_new"
);
}
}
+261
View File
@@ -0,0 +1,261 @@
//! Microsoft OpenJDK:`aka.ms` 模板下载 + 成对 `.sha256sum.txt` 校验。
//!
//! 分发形态与 Temurin / Oracle 都不同(research/06 §3.2):
//!
//! ```text
//! 下载 https://aka.ms/download-jdk/microsoft-jdk-{version}-{os}-{arch}.zip
//! → 301 → https://download.visualstudio.microsoft.com/download/pr/{guid}/{hash}/{同名}
//! 校验 https://aka.ms/download-jdk/<同一文件名>.sha256sum.txt # "<hash> <filename>"
//! ```
//!
//! 三条硬事实(都影响实现):
//!
//! 1. **版本是四段/五段**(`21.0.13.0.2` = JDK 21.0.13+2;11 线是 `11.0.32.1`),
//! 不能拿 Temurin 的三段号直接拼——所以这里有自己的 [`parse_version`] 与内置快照表;
//! 2. **没有 8**(微软已停发,官方页面让去下 Temurin 8),`supports()` 也这么判;
//! 3. **GitHub 侧没有任何资产**(`microsoft/openjdk` 的 release `assets` 全空),
//! 所以 gh-proxy 对这条链毫无用处——`domestic_mirrors` 实测为空数组。
use super::error::{JavaError, JavaErrorKind};
use super::net;
use super::release::{parse_sha256_text, JdkArchive, JdkRelease};
use super::target::AdoptiumTarget;
use super::vendor::JdkVendor;
use crate::download::MsOpenJdkTable;
/// 内置版本快照(`learn.microsoft.com/java/openjdk/download` 2026-10-30 的表,research/06 §1.3)。
///
/// 官方只有人读 HTML 表、没有 JSON API,逐版本探测又要 `aka.ms` 301(0.3–0.8 s/次),
/// 所以按 research/06 §3.3 的建议 A 内置快照;`size` 与实测字节数一致,用于长度对拍。
pub const SNAPSHOT: &[(u32, &str, u64)] = &[
(25, "25.0.5.0.2", 220_869_659),
(21, "21.0.13.0.2", 201_450_315),
(17, "17.0.21.0.2", 187_236_595),
(11, "11.0.32.1", 191_250_397),
];
/// 四段/五段版本号(`21.0.13.0.2` / `11.0.32.1`)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MsVersion {
/// 大版本(`21`)。
pub major: u32,
/// 各数字段(长度 3..=5)。
pub segments: Vec<u32>,
}
impl MsVersion {
/// 原样的版本串。
#[must_use]
pub fn text(&self) -> String {
self.segments
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(".")
}
}
/// 解析 MS 的版本串;段数必须 3..=5(MS 的语义:`major.update.patch[.build[.rev]]`)。
#[must_use]
pub fn parse_version(text: &str) -> Option<MsVersion> {
let mut segments = Vec::new();
for segment in text.trim().split('.') {
segments.push(segment.parse::<u32>().ok()?);
}
if segments.len() < 3 || segments.len() > 5 {
return None;
}
Some(MsVersion {
major: segments[0],
segments,
})
}
/// MS 的 os 段(`windows` / `macos` / `linux`)。
#[must_use]
pub fn ms_os(target: AdoptiumTarget) -> &'static str {
match target.os {
"mac" => "macos",
other => other,
}
}
/// MS 的 arch 段(`x64` / `aarch64`)。
#[must_use]
pub fn ms_arch(target: AdoptiumTarget) -> &'static str {
target.arch
}
/// 归档文件名(模板:`microsoft-jdk-{version}-{os}-{arch}.zip`,全平台都是 zip)。
#[must_use]
pub fn archive_name(version: &str, target: AdoptiumTarget) -> String {
format!(
"microsoft-jdk-{version}-{}-{}.zip",
ms_os(target),
ms_arch(target)
)
}
/// 把模板里的 `{version}` / `{os}` / `{arch}` 换掉。
#[must_use]
pub fn expand_template(template: &str, version: &str, target: AdoptiumTarget) -> String {
template
.replace("{version}", version)
.replace("{os}", ms_os(target))
.replace("{arch}", ms_arch(target))
}
/// 快照里该大版本的版本串(没有 → `None`)。
#[must_use]
pub fn snapshot_version(major: u32) -> Option<&'static str> {
SNAPSHOT
.iter()
.find(|(known, _, _)| *known == major)
.map(|(_, version, _)| *version)
}
/// 快照里该大版本的字节数。
#[must_use]
pub fn snapshot_size(major: u32) -> Option<u64> {
SNAPSHOT
.iter()
.find(|(known, _, _)| *known == major)
.map(|(_, _, size)| *size)
}
/// 查一次「某大版本的最新 MS OpenJDK」。
///
/// 两跳:内置快照挑版本 → 取 `.sha256sum.txt` 拿哈希。校验文件取不到就整家失败
/// (宁可降级到 Zulu,也不下一个没法验证的 200 MB 归档)。
///
/// # Errors
///
/// 表里没配模板、该大版本微软不发(含 8)、校验文件取不到或格式不对时返回错误。
pub async fn fetch_latest(
client: &reqwest::Client,
table: &MsOpenJdkTable,
major: u32,
target: AdoptiumTarget,
) -> Result<JdkRelease, JavaError> {
let version = snapshot_version(major).ok_or_else(|| {
JavaError::new(
JavaErrorKind::Metadata,
format!(
"Microsoft OpenJDK 没有 {major} 的 GA(只发 {})",
majors_text()
),
)
.with_hint("8 用 temurin;其它大版本用 oracle / zulu")
})?;
let filename = archive_name(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 sha256 = parse_sha256_text(&sha_text).ok_or_else(|| {
JavaError::new(
JavaErrorKind::Integrity,
format!("{filename} 的 .sha256sum.txt 里没有 64 位哈希"),
)
})?;
Ok(JdkRelease {
vendor: JdkVendor::Microsoft,
major,
version: version.to_string(),
semver: None,
release_name: version.to_string(),
archive: JdkArchive {
name: filename.clone(),
url: expand_template(&table.template, version, target),
sha256,
size: snapshot_size(major).unwrap_or(0),
},
})
}
fn majors_text() -> String {
SNAPSHOT
.iter()
.map(|(major, _, _)| major.to_string())
.collect::<Vec<_>>()
.join(" / ")
}
#[cfg(test)]
mod tests {
use super::{
archive_name, expand_template, ms_os, parse_version, snapshot_size, snapshot_version,
SNAPSHOT,
};
use crate::game::java::target::AdoptiumTarget;
const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64");
#[test]
fn snapshot_covers_the_four_majors_microsoft_ships() {
assert_eq!(snapshot_version(21), Some("21.0.13.0.2"));
assert_eq!(snapshot_version(11), Some("11.0.32.1"));
assert_eq!(snapshot_version(8), None, "微软已停发 8");
assert_eq!(snapshot_size(17), Some(187_236_595));
assert_eq!(
SNAPSHOT
.iter()
.map(|(major, _, _)| *major)
.collect::<Vec<_>>(),
vec![25, 21, 17, 11]
);
}
#[test]
fn four_and_five_segment_versions_have_their_own_semantics() {
// 21.0.13.0.2 = JDK 21.0.13+2(五段);11 线只有四段。
let ms = parse_version("21.0.13.0.2").expect("五段应解析");
assert_eq!(ms.major, 21);
assert_eq!(ms.segments.len(), 5);
assert_eq!(ms.text(), "21.0.13.0.2");
let eleven = parse_version("11.0.32.1").expect("四段应解析");
assert_eq!(eleven.major, 11);
assert_eq!(eleven.segments.len(), 4);
// Temurin 的三段号形态在这里同样成立,但语义由快照表定义,不共用 Temurin 的解析。
assert_eq!(
parse_version("17.0.21").map(|version| version.major),
Some(17)
);
assert_eq!(parse_version("21"), None);
assert_eq!(parse_version("21.0"), None);
assert_eq!(parse_version("21.0.13.0.2.1"), None);
assert_eq!(parse_version("21.0.x.2"), None);
}
#[test]
fn archive_names_and_templates_expand_per_platform() {
assert_eq!(
archive_name("21.0.13.0.2", WINDOWS),
"microsoft-jdk-21.0.13.0.2-windows-x64.zip"
);
let template = "https://aka.ms/download-jdk/microsoft-jdk-{version}-{os}-{arch}.zip";
assert_eq!(
expand_template(template, "17.0.21.0.2", WINDOWS),
"https://aka.ms/download-jdk/microsoft-jdk-17.0.21.0.2-windows-x64.zip"
);
assert_eq!(
expand_template(
"https://aka.ms/download-jdk/microsoft-jdk-{version}-{os}-{arch}.zip.sha256sum.txt",
"21.0.13.0.2",
AdoptiumTarget::new("linux", "aarch64")
),
"https://aka.ms/download-jdk/microsoft-jdk-21.0.13.0.2-linux-aarch64.zip.sha256sum.txt"
);
assert_eq!(ms_os(AdoptiumTarget::new("mac", "x64")), "macos");
}
#[test]
fn builtin_table_uses_the_official_templates() {
let table = crate::download::MirrorTable::builtin();
let microsoft = table.microsoft_openjdk().expect("内置表应带 MS 分发");
assert!(microsoft
.template
.starts_with("https://aka.ms/download-jdk/"));
assert!(microsoft.sha256_template.ends_with(".sha256sum.txt"));
assert!(microsoft.domestic_mirrors.is_empty(), "MS 无国内镜像");
assert_eq!(microsoft.majors, vec![25, 21, 17, 11]);
}
}
+90
View File
@@ -0,0 +1,90 @@
//! Java 运行时管理(D13 v1 → D23 v2):托管 JDK 的查询 / 下载 / 解压 / 探测,以及 MC↔Java 映射。
//!
//! ```text
//! target 宿主平台 → Adoptium 的 os/arch(Win=zip、Linux/mac=tar.gz)
//! vendor 四家发行版与用户可调的优先级链(国内 Temurin→Oracle→MS / 国外 Temurin→MS→Oracle)
//! api Temurin:api.adoptium.net 查询最新 GA → (github 直链, sha256, size)
//! oracle Oracle OpenJDK:镜像目录页解析(hash 段不可推导,禁止拼 URL)+ .sha256
//! microsoft MS OpenJDK:aka.ms 模板 + 内置四段版本快照 + .sha256sum.txt
//! zulu Azul Zulu:两跳 JSON API(列表 → 详情)+ CDN 直链(第四备选)
//! source 发行版分派 + 回退链记录(命中与降级原因进事件流)
//! location 落点三模式(Portable / Standard / Custom)+ 孤儿目录清理
//! release 一次版本查询的统一产物(vendor 原生版本串 + 归档 + sha256 + 大小)
//! net 版本查询用的极小 GET 文本工具(下载不走这里)
//! install 编排:回退链 → 下载引擎(镜像/健康度/退避)→ 解压 → 探测验证
//! extract zip / tar.gz 解压 + 顶层目录剥离 + 幂等戳记 + zip-slip 防护
//! probe java -version 探测、托管目录扫描(两种布局都认)
//! discover 标准安装位置扫描(Program Files 系 / 官方启动器 runtime / 注册表 / /usr/lib/jvm)+ 去重
//! select 启动选 Java 的**纯**优先链(满足要求优先,再按来源)
//! mapping MC↔Java 静态表(HMCL `GameJavaVersion`)+ 版本 JSON 优先 + 不符检查
//! ```
//!
//! # 三条边界
//!
//! 1. **不碰网络细节**:包下载一律交 `core::download` 的引擎,本模块不写重试循环
//! (`MirrorTable::expand` 负责 Temurin 的镜像展开;Oracle 的目录解析在 `oracle`);
//! 2. **不弹窗**:不符检查只产出数据([`JavaMismatch`]),提示交给 CLI 的告警行与 GUI;
//! 3. **不改启动策略**:「用哪个 Java 启动」是启动期的派生结果,不落盘;落盘的是**装到哪**
//! ([`JavaInstallRoot`],由 `java.installRoot` + 数据目录模式决定)。
//!
//! # 托管布局(D23 后两种并存)
//!
//! ```text
//! Portable:data/java-managed/
//! ├── .cache/OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip 归档缓存(可删)
//! └── jdk-21-temurin/ 一个 major+vendor 一份(永不改)
//! ├── .ull-jdk.json 戳记(幂等判据)
//! └── bin/java[.exe]
//!
//! Standard / Custom:%APPDATA%\Java\<vendor>\<version>\ (mac/Linux 见 location 模块)
//! ```
pub mod api;
pub mod discover;
pub mod error;
pub mod extract;
pub mod install;
pub mod location;
pub mod mapping;
pub mod microsoft;
pub mod net;
pub mod oracle;
pub mod probe;
pub mod release;
pub mod select;
pub mod source;
pub mod target;
pub mod vendor;
pub mod zulu;
pub use self::api::{
latest_url, parse_latest, JdkArchive, JdkRelease, ADOPTIUM_API_BASE, JDK_VENDOR,
};
pub use self::discover::{
candidates, candidates_in_roots, scan_java_binaries, standard_candidates, standard_roots,
};
pub use self::error::{JavaError, JavaErrorKind};
pub use self::extract::{
extract_archive, is_ready, java_binary, java_binary_name, read_stamp, ExtractReport, JdkStamp,
STAMP_FILE,
};
pub use self::install::{
archive_path, archive_request, chain_order, install, managed_root_dir, plan, InstallRequest,
InstalledJdk,
};
pub use self::location::{
cleanup_orphans, sanitize_version, scan_roots, CleanupReport, JavaInstallRoot, LocationMode,
};
pub use self::mapping::{
check, recommended_major, requirement_for, requirement_for_version, JavaMismatch,
JavaRequirement, RequirementSource,
};
pub use self::probe::{
discover_managed, discover_managed_roots, managed_dir_name, parse_managed_dir, probe,
resolve_on_path, system_candidates, JavaOrigin, JavaRuntime, DEFAULT_MAJOR,
};
pub use self::release::parse_sha256_text;
pub use self::select::{pick, resolve, JavaResolution};
pub use self::source::{ChainLog, VendorAttempt};
pub use self::target::AdoptiumTarget;
pub use self::vendor::{order as vendor_order, JdkVendor, VENDOR_IDS};
+60
View File
@@ -0,0 +1,60 @@
//! 版本查询用的极小 HTTP 取文本工具(四家发行版的「一锤子 GET」共用)。
//!
//! 只有一件事:GET → 状态分类 → 整段文本。**下载不走这里**(那归 `core::download` 引擎),
//! 所以没有重试、没有镜像展开、没有进度事件——查清单失败就直接把错误抛给发行版回退链,
//! 让它换下一家(这是 D23「vendor 级失败自动降下一 vendor」的最短路径)。
use super::error::{JavaError, JavaErrorKind};
/// 取一段文本(`what` 是给用户看的名词,例如「华为云 openjdk 目录」)。
///
/// # Errors
///
/// 连不上 / 超时 → [`JavaErrorKind::Network`];404 或 410 → [`JavaErrorKind::Metadata`]
/// (「这个东西上游没有」比「网络坏了」更准确,回退链据此换 vendor 而不是重试同一条)。
pub async fn get_text(
client: &reqwest::Client,
url: &str,
what: &str,
) -> Result<String, JavaError> {
let response = client.get(url).send().await.map_err(|error| {
JavaError::new(
JavaErrorKind::Network,
format!("取 {what} 失败:{error}({url})"),
)
})?;
let status = response.status();
if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::GONE {
return Err(JavaError::new(
JavaErrorKind::Metadata,
format!("{what} 不存在(HTTP {status}):{url}"),
));
}
if !status.is_success() {
return Err(JavaError::new(
JavaErrorKind::Network,
format!("{what} 返回 HTTP {status}:{url}"),
));
}
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string();
let body = response.text().await.map_err(|error| {
JavaError::new(
JavaErrorKind::Network,
format!("读 {what} 响应失败:{error}"),
)
})?;
// 门户兜底页会把「路径不存在」伪装成 200 + HTML;版本清单页面本身可能也是 HTML,
// 所以这里不做内容判定,只看调用方能不能解析出来(解析失败会给出更准的原因)。
if body.trim().is_empty() {
return Err(JavaError::new(
JavaErrorKind::Metadata,
format!("{what} 返回空响应(Content-Type: {content_type}):{url}"),
));
}
Ok(body)
}
+344
View File
@@ -0,0 +1,344 @@
//! Oracle OpenJDK(`download.java.net` GA):**枚举与下载都走国内镜像目录页**。
//!
//! 为什么不能像 Temurin 那样「先拿官方链接、再反解镜像路径」:Oracle 的直链里有一整段
//! 构建哈希(`/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip`),
//! 那段**不可推导**(research/06 §4.1 实测)。于是本模块的纪律是:
//!
//! > 候选 URL 的 `{ver}` 段与文件名段都必须来自对镜像目录页的解析(`parse_links`),
//! > 代码里没有任何一处把哈希段拼出来。
//!
//! 目录形状(华为云 / 南大与官方完全同构,§4.2):
//!
//! ```text
//! {base}/ → Apache 列表页:21/ 21.0.1/ 21.0.2/ 27.0.1.0.3/ …
//! {base}/{ver}/ → 文件列表:openjdk-21.0.2_windows-x64_bin.zip …
//! {base}/{ver}/{filename} → 归档本体(大小与官方逐字节一致)
//! {base}/{ver}/{filename}.sha256 → 64 位哈希纯文本(成对提供)
//! ```
//!
//! 三处实测坑(都写进了实现):
//!
//! 1. **华为云对任意不存在的路径都回 200 + 12 434 B 门户 HTML**(假阳性)——所以本模块只信
//! 「目录页里真的出现了这个文件名」,不信状态码;下载侧另有 `download::http::html_mirror_reason`
//! 兜一层(Content-Type 非 HTML + Content-Length 对拍)。
//! 2. **只发「首个 GA + ≤2 个 update」**:21 线最高只到 21.0.2,别当补丁源用。
//! 3. **目录页给的大小是四舍五入的**(`192 MB`)→ 不可当 `expected_size`,本模块一律填 0,
//! 校验交给成对的 `.sha256`。
use super::error::{JavaError, JavaErrorKind};
use super::net;
use super::release::{parse_sha256_text, JdkArchive, JdkRelease};
use super::target::AdoptiumTarget;
use super::vendor::JdkVendor;
use crate::download::MirrorTable;
/// Oracle 的 os 段(Adoptium 的 `mac` 在 Oracle 里叫 `macos`)。
#[must_use]
pub fn oracle_os(target: AdoptiumTarget) -> &'static str {
match target.os {
"mac" => "macos",
other => other,
}
}
/// Oracle 的 arch 段(`x86-32` → `x86`)。
#[must_use]
pub fn oracle_arch(target: AdoptiumTarget) -> &'static str {
match target.arch {
"x86-32" => "x86",
other => other,
}
}
/// 目录页 → 本级条目名列表(去掉尾部 `/`、跳过 `../` 与绝对链接,去重保序)。
#[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 的命名是**模板**,哈希只在目录层级上,不在文件名里)。
#[must_use]
pub fn archive_name(version: &str, target: AdoptiumTarget) -> String {
format!(
"openjdk-{version}_{}-{}_bin.{}",
oracle_os(target),
oracle_arch(target),
target.archive_ext()
)
}
/// `{base}/{ver}/{filename}`。
#[must_use]
pub fn archive_url(base: &str, version: &str, filename: &str) -> String {
format!("{}/{version}/{filename}", base.trim_end_matches('/'))
}
/// 归档旁的 `.sha256` 地址。
#[must_use]
pub fn checksum_url(base: &str, version: &str, filename: &str) -> String {
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」。
///
/// 按 `mirrors.json` 里 `oracle_openjdk` 的启用顺序逐个镜像试(国内:华为云 → 南大),
/// 每个镜像三跳:目录页 → 版本目录页 → `.sha256`。任何一跳失败就记下原因换下一个镜像。
///
/// # Errors
///
/// 所有镜像都失败(没有该大版本 / 目录页不可解析 / 缺 sha256)时返回错误,
/// 由回退链决定降级到下一家发行版。
pub async fn fetch_latest(
client: &reqwest::Client,
table: &MirrorTable,
major: u32,
target: AdoptiumTarget,
) -> Result<JdkRelease, JavaError> {
let mirrors: Vec<_> = table.oracle_mirrors().collect();
if mirrors.is_empty() {
return Err(JavaError::new(
JavaErrorKind::Metadata,
"镜像清单里没有启用的 oracle_openjdk 源",
)
.with_hint("`mirrors.json` 的 `oracle_openjdk` 表需要至少一个 enabled 条目"));
}
let mut reasons: Vec<String> = Vec::new();
for mirror in mirrors {
match fetch_from(client, mirror.base(), major, target).await {
Ok(release) => return Ok(release),
Err(error) => reasons.push(format!("{}:{}", mirror.name, error.message())),
}
}
Err(JavaError::new(
JavaErrorKind::Network,
format!(
"Oracle OpenJDK {major} 在所有镜像上都取不到:{}",
reasons.join(";")
),
)
.with_hint("Oracle 的 21 线只到 21.0.2(只发首个 GA + ≤2 update);换 17/25 或改用 temurin"))
}
/// 单个镜像的完整三跳查询。
async fn fetch_from(
client: &reqwest::Client,
base: &str,
major: u32,
target: AdoptiumTarget,
) -> Result<JdkRelease, JavaError> {
let index = net::get_text(
client,
&format!("{}/", base.trim_end_matches('/')),
"Oracle 镜像根目录",
)
.await?;
let version = pick_version(&index, major).ok_or_else(|| {
JavaError::new(
JavaErrorKind::Metadata,
format!(
"镜像目录里没有 {major} 线的 Oracle OpenJDK(目录页 {} 项)",
parse_links(&index).len()
),
)
})?;
let filename = archive_name(&version, target);
let listing = net::get_text(
client,
&format!("{}/{version}/", base.trim_end_matches('/')),
"Oracle 版本目录",
)
.await?;
if !find_archive(&listing, &filename) {
return Err(JavaError::new(
JavaErrorKind::Metadata,
format!("{version} 目录里没有 {filename}(该平台没有产物,或目录页不可解析)"),
));
}
let sha_text = net::get_text(
client,
&checksum_url(base, &version, &filename),
"Oracle 归档 sha256",
)
.await?;
let sha256 = parse_sha256_text(&sha_text).ok_or_else(|| {
JavaError::new(
JavaErrorKind::Integrity,
format!("{filename} 的 .sha256 不是 64 位十六进制"),
)
})?;
Ok(JdkRelease {
vendor: JdkVendor::Oracle,
major,
version: version.clone(),
semver: None,
release_name: version.clone(),
archive: JdkArchive {
name: filename.clone(),
url: archive_url(base, &version, &filename),
sha256,
// 目录页给的是四舍五入的大小(192 MB),不能当 expected_size。
size: 0,
},
})
}
#[cfg(test)]
mod tests {
use super::{
archive_name, archive_url, checksum_url, find_archive, oracle_os, parse_links,
pick_version, version_key, versions_for,
};
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/` 的真实文件列表片段。
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_windows-x64_bin.zip" onclick="handleLinkUrl(this)">openjdk-21.0.2_windows-x64_bin.zip</a> 192 MB
</pre></body></html>"#;
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]
fn filename_template_matches_the_archive_on_every_platform() {
assert_eq!(
archive_name("21.0.2", WINDOWS),
"openjdk-21.0.2_windows-x64_bin.zip"
);
assert_eq!(
archive_name("21.0.2", AdoptiumTarget::new("linux", "aarch64")),
"openjdk-21.0.2_linux-aarch64_bin.tar.gz"
);
assert_eq!(
archive_name("17.0.2", AdoptiumTarget::new("mac", "x64")),
"openjdk-17.0.2_macos-x64_bin.tar.gz"
);
assert_eq!(oracle_os(AdoptiumTarget::new("mac", "x64")), "macos");
assert_eq!(oracle_os(WINDOWS), "windows");
}
#[test]
fn archive_is_only_published_when_it_really_appears_in_the_listing() {
let filename = archive_name("21.0.2", WINDOWS);
assert!(find_archive(LISTING, &filename));
// 华为云的门户兜底页(任意路径 200 + HTML)里没有这个链接 → 必须判为不存在。
assert!(!find_archive(
"<html><body>mirrors 门户</body></html>",
&filename
));
assert_eq!(
archive_url(
"https://mirrors.huaweicloud.com/openjdk/",
"21.0.2",
&filename
),
format!("https://mirrors.huaweicloud.com/openjdk/21.0.2/{filename}")
);
assert_eq!(
checksum_url(
"https://mirrors.huaweicloud.com/openjdk",
"21.0.2",
&filename
),
format!("https://mirrors.huaweicloud.com/openjdk/21.0.2/{filename}.sha256")
);
}
}
+370
View File
@@ -0,0 +1,370 @@
//! Java 运行时探测:跑一次 `java -version`、扫托管目录、列系统候选。
//!
//! 探测动作只有两种:读托管目录(`.ULL/data/java-managed/jdk-<major>-<vendor>/`)
//! 与跑 `java -version`(输出在 stderr,主版本号从引号里的版本号解析,
//! `1.8.0_402` 这种老形态换算成 8)。
//!
//! 「选哪个 Java 启动」不在这里,而在 [`super::select`]——那边是**纯函数**,
//! 候选运行时由调用方注入,测试因此不需要真的装三个 JDK。
use std::path::{Path, PathBuf};
use std::process::Command;
use super::extract::{java_binary_name, read_stamp};
use super::vendor::JdkVendor;
/// 缺省 Java 主版本(探测不到任何东西时的保守值,D13:JDK 17 基准)。
pub const DEFAULT_MAJOR: u32 = 17;
/// 托管目录名前缀。
pub const MANAGED_PREFIX: &str = "jdk-";
/// 一个 Java 运行时的来源。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum JavaOrigin {
/// 实例覆盖(`--java` 参数或实例档案的 `javaPath`)。
Instance,
/// 托管目录(`ull java install` 装的)。
Managed,
/// `JAVA_HOME`。
JavaHome,
/// `PATH` 上的 `java`。
Path,
/// 标准安装位置扫出来的(MSI 装的 Temurin / 官方启动器的 `runtime/` / 注册表等)。
System,
}
impl JavaOrigin {
/// 稳定字符串 id(`--json` 的 `source` 字段)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Instance => "instance",
Self::Managed => "managed",
Self::JavaHome => "JAVA_HOME",
Self::Path => "PATH",
Self::System => "system",
}
}
/// 展示类别(`--json` 的 `source_category`):GUI 用它分组,不必区分 `JAVA_HOME`/`PATH`。
#[must_use]
pub const fn category(self) -> &'static str {
match self {
Self::Instance => "instance",
Self::Managed => "managed",
Self::JavaHome | Self::Path => "env",
Self::System => "system",
}
}
/// 同档内的优先序(小者优先)。
///
/// 「环境变量」先于「扫盘」:用户显式设过的 `JAVA_HOME`/`PATH` 是意图表达,
/// 盘上扫出来的只是候选(HMCL/PCL 也是这个顺序)。
#[must_use]
pub const fn rank(self) -> u8 {
match self {
Self::Instance => 0,
Self::Managed => 1,
Self::JavaHome => 2,
Self::Path => 3,
Self::System => 4,
}
}
}
/// 一个探测到的 Java 运行时。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JavaRuntime {
/// 可执行文件。
pub binary: PathBuf,
/// 来源。
pub origin: JavaOrigin,
/// `java -version` 的首行(含 `version` 字样)。
pub version: Option<String>,
/// 主版本号(`1.8.0_402` → 8)。
pub major: Option<u32>,
/// 位数(32 / 64;探测不到时按宿主)。
pub bits: u32,
/// 托管目录里的供应商(`temurin`);其它来源为 `None`。
pub vendor: Option<String>,
}
impl JavaRuntime {
/// 探测不出来时给个「版本未知」的占位。
#[must_use]
pub fn unknown(binary: impl Into<PathBuf>, origin: JavaOrigin) -> Self {
Self {
binary: binary.into(),
origin,
version: None,
major: None,
bits: host_bits(),
vendor: None,
}
}
/// 主版本号(未知时退回 [`DEFAULT_MAJOR`])。
#[must_use]
pub fn major_or_default(&self) -> u32 {
self.major.unwrap_or(DEFAULT_MAJOR)
}
}
/// 跑一次 `java -version` 填满运行时信息(探测失败不报错,只留空字段)。
///
/// 失败不报错的理由:`ull java list` 要能列出「探测不到的项」而不是整体失败,
/// 而启动路径上探测失败会自然退化为系统默认 Java(见 [`super::select::resolve`])。
#[must_use]
pub fn probe(binary: &Path, origin: JavaOrigin) -> JavaRuntime {
let mut runtime = JavaRuntime::unknown(binary.to_path_buf(), origin);
let Ok(output) = Command::new(binary).arg("-version").output() else {
return runtime;
};
let mut text = String::from_utf8_lossy(&output.stderr).into_owned();
text.push_str(&String::from_utf8_lossy(&output.stdout));
if let Some((line, major)) = parse_version_output(&text) {
runtime.major = Some(major);
runtime.version = Some(line);
runtime.bits = bits_of(&text);
}
runtime
}
/// 扫描一个安装根里的托管 JDK,逐个探测。
///
/// **两种布局都要认**(D23 之后同时存在):
///
/// ```text
/// <root>/jdk-<major>-<vendor>/bin/java Portable(v1 布局,M33 定下,永不改)
/// <root>/<vendor>/<version>/bin/java Standard / Custom(D23 的 <发行方>/<版本>)
/// ```
///
/// 只认带戳记的目录:托管区是我们自己写的地方,但没有戳记的子目录可能是用户塞进来的
/// 别的东西,不去猜它。`major` 一律以 `java -version` 为准(戳记只用于幂等判定)。
#[must_use]
pub fn discover_managed(managed_root: &Path) -> Vec<JavaRuntime> {
let mut found: Vec<JavaRuntime> = Vec::new();
let Ok(entries) = std::fs::read_dir(managed_root) else {
return found;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if !path.is_dir() || name.starts_with('.') {
continue;
}
if let Some(runtime) = probe_managed_dir(&path, parse_managed_dir(&name).map(|(_, v)| v)) {
found.push(runtime);
continue;
}
// 两层布局:`<vendor>/<version>`——vendor 名取自父目录名。
if JdkVendor::parse(&name).is_some() {
let Ok(children) = std::fs::read_dir(&path) else {
continue;
};
for child in children.flatten() {
let child_path = child.path();
if let Some(runtime) = probe_managed_dir(&child_path, Some(&name)) {
found.push(runtime);
}
}
}
}
sort_runtimes(&mut found);
found
}
/// 扫多个安装根(Portable 数据根 + Standard/Custom 落点),按 `bin` 路径去重。
#[must_use]
pub fn discover_managed_roots(roots: &[PathBuf]) -> Vec<JavaRuntime> {
let mut found: Vec<JavaRuntime> = Vec::new();
for root in roots {
for runtime in discover_managed(root) {
if !found.iter().any(|known| known.binary == runtime.binary) {
found.push(runtime);
}
}
}
sort_runtimes(&mut found);
found
}
/// 探测一个候选托管目录(有戳记 + `bin/java` 才算数)。
fn probe_managed_dir(path: &Path, vendor: Option<&str>) -> Option<JavaRuntime> {
if !path.is_dir() || read_stamp(path).is_none() {
return None;
}
let binary = path.join("bin").join(java_binary_name());
if !binary.is_file() {
return None;
}
let mut runtime = probe(&binary, JavaOrigin::Managed);
runtime.vendor = vendor.map(str::to_string);
Some(runtime)
}
/// 大版本升序,输出稳定(同 major 按路径)。
fn sort_runtimes(found: &mut [JavaRuntime]) {
found.sort_by(|left, right| {
left.major
.cmp(&right.major)
.then_with(|| left.binary.cmp(&right.binary))
});
}
/// 系统来源的候选:`JAVA_HOME` 与 `PATH` 上的 `java`(都探测一次)。
///
/// `PATH` 上的那一项会先**解析成绝对路径**再探测:否则它与扫盘结果(绝对路径)看起来是
/// 两个不同的文件,`java list` 会把同一个 JDK 列两次。
#[must_use]
pub fn system_candidates() -> Vec<JavaRuntime> {
let mut found = Vec::new();
if let Some(home) = std::env::var_os("JAVA_HOME") {
let binary = PathBuf::from(home).join("bin").join(java_binary_name());
if binary.is_file() {
found.push(probe(&binary, JavaOrigin::JavaHome));
}
}
let name = java_binary_name();
let binary = resolve_on_path(name).unwrap_or_else(|| PathBuf::from(name));
found.push(probe(&binary, JavaOrigin::Path));
found
}
/// 在 `PATH` 里找某个可执行文件的绝对路径(找不到返回 `None`)。
#[must_use]
pub fn resolve_on_path(name: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|dir| dir.join(name))
.find(|candidate| candidate.is_file())
}
/// 托管目录名(`jdk-21-temurin`)。
#[must_use]
pub fn managed_dir_name(major: u32, vendor: &str) -> String {
format!("{MANAGED_PREFIX}{major}-{vendor}")
}
/// 反解托管目录名 →(大版本,供应商)。
#[must_use]
pub fn parse_managed_dir(name: &str) -> Option<(u32, &str)> {
let rest = name.strip_prefix(MANAGED_PREFIX)?;
let (major, vendor) = rest.split_once('-')?;
let major: u32 = major.parse().ok()?;
(!vendor.is_empty()).then_some((major, vendor))
}
/// 从 `java -version` 输出里取(含 `version` 的首行,主版本号)。
#[must_use]
pub fn parse_version_output(text: &str) -> Option<(String, u32)> {
let line = text
.lines()
.find(|line| line.contains("version"))
.or_else(|| text.lines().find(|line| !line.trim().is_empty()))?
.trim();
let version = quoted_version(line).or_else(|| bare_version(line))?;
let major = major_of(&version)?;
Some((line.to_string(), major))
}
/// 引号里的版本号(`openjdk version "21.0.13" 2026-01-20 LTS`)。
fn quoted_version(line: &str) -> Option<String> {
let start = line.find('"')? + 1;
let rest = &line[start..];
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
/// 无引号形态(`openjdk 25.0.3 2026-04-21`)。
///
/// 「含 `version` 的那一段之后逐个试」优先——避开 `Java(TM) SE Runtime` 这类噪声;
/// 没有 `version` 字样时从头试(部分精简发行版就这么打印)。
fn bare_version(line: &str) -> Option<String> {
let tokens: Vec<&str> = line.split_whitespace().collect();
let start = tokens
.iter()
.position(|token| token.contains("version"))
.map_or(0, |position| position + 1);
tokens[start..].iter().find_map(|token| {
let candidate = token.trim_matches(['"', ',', '(', ')']);
(candidate.contains('.') && major_of(candidate).is_some()).then(|| candidate.to_string())
})
}
/// 版本号 → 主版本(`1.8.0_402` → 8,`21.0.13` → 21)。
#[must_use]
pub fn major_of(version: &str) -> Option<u32> {
let mut parts = version.split(['.', '_', '-']);
let first = parts.next()?.parse::<u32>().ok()?;
if first == 1 {
parts.next()?.parse::<u32>().ok()
} else {
Some(first)
}
}
/// `java -version` 输出里的位数(`64-Bit` / `32-Bit`;读不到按宿主)。
#[must_use]
pub fn bits_of(text: &str) -> u32 {
let lowered = text.to_ascii_lowercase();
if lowered.contains("64-bit") {
64
} else if lowered.contains("32-bit") {
32
} else {
host_bits()
}
}
/// 宿主指针宽度(32 / 64)。
#[must_use]
pub const fn host_bits() -> u32 {
if cfg!(target_pointer_width = "64") {
64
} else {
32
}
}
#[cfg(test)]
mod tests {
use super::{bits_of, major_of, managed_dir_name, parse_managed_dir, parse_version_output};
#[test]
fn parses_modern_and_legacy_version_lines() {
let modern =
parse_version_output("openjdk version \"21.0.13\" 2026-01-20 LTS\nOpenJDK 64-Bit");
assert_eq!(modern.map(|(_, major)| major), Some(21));
let legacy = parse_version_output("java version \"1.8.0_402\"\nJava(TM) SE Runtime");
assert_eq!(legacy.map(|(_, major)| major), Some(8));
let bare = parse_version_output("openjdk 25.0.3 2026-04-21\nOpenJDK 64-Bit Server VM");
assert_eq!(bare.map(|(_, major)| major), Some(25));
assert!(parse_version_output("no java here").is_none());
}
#[test]
fn parses_bit_width() {
assert_eq!(bits_of("OpenJDK 64-Bit Server VM (build 25.0.3)"), 64);
assert_eq!(bits_of("Java HotSpot(TM) Client VM (32-Bit)"), 32);
}
#[test]
fn major_of_handles_legacy_scheme() {
assert_eq!(major_of("1.8.0_402"), Some(8));
assert_eq!(major_of("17.0.13+11"), Some(17));
assert_eq!(major_of("not-a-version"), None);
}
#[test]
fn managed_dir_names_round_trip() {
assert_eq!(parse_managed_dir("jdk-21-temurin"), Some((21, "temurin")));
assert_eq!(parse_managed_dir("jdk-8-temurin"), Some((8, "temurin")));
assert_eq!(parse_managed_dir(".staging-jdk-21-temurin"), None);
assert_eq!(parse_managed_dir("java-21-temurin"), None);
assert_eq!(parse_managed_dir("jdk-x-temurin"), None);
assert_eq!(managed_dir_name(25, "temurin"), "jdk-25-temurin");
}
}
+170
View File
@@ -0,0 +1,170 @@
//! 一次版本查询的统一产物:一个可下载的 JDK 归档(四家发行版共用形态)。
//!
//! 各家的**版本号语义不同**(research/06 §5.3 第 3 条),所以这里只存「vendor 原生的版本串」
//! 而不是硬拆成 semver 四段:
//!
//! | 发行版 | 版本串 | 出处 |
//! | --- | --- | --- |
//! | Temurin | `21.0.13+9`(三段+构建号) | Adoptium `release_name` |
//! | Oracle OpenJDK | `21.0.2`(三段,只到 GA + ≤2 update) | 镜像目录名 |
//! | Microsoft OpenJDK | `21.0.13.0.2`(四段/五段),11 线是 `11.0.32.1` | `aka.ms` 文件名 |
//! | Azul Zulu | `21.0.13+3`(三段+构建号,文件名里还有 `21.0.13.0.3` 另一种写法) | Zulu API |
//!
//! 目录名、戳记、`--json` 都直接用这个串(`location::sanitize_version` 只做字符白名单)。
use super::vendor::JdkVendor;
/// 一个可下载的 JDK 归档。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JdkArchive {
/// 产物文件名(`OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip`)。
pub name: String,
/// 官方下载链接(镜像展开的输入;Oracle / MS / Zulu 已经是最终链接)。
pub url: String,
/// SHA-256(小写十六进制)。
pub sha256: String,
/// 字节数(`0` = 上游没给可信大小,引擎跳过长度对拍只验哈希)。
pub size: u64,
}
/// 一次版本查询的结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JdkRelease {
/// 发行版。
pub vendor: JdkVendor,
/// 大版本号(`21`)。
pub major: u32,
/// vendor 原生的完整版本串(目录名与展示都用它)。
pub version: String,
/// Temurin 系才有的 semver(其它家为 `None`)。
pub semver: Option<String>,
/// release 名(Temurin `jdk-21.0.13+9`;其它家用版本串)。
pub release_name: String,
/// 归档。
pub archive: JdkArchive,
}
impl JdkRelease {
/// 是否就是这个发行版 + 大版本的产物。
#[must_use]
pub fn matches(&self, vendor: JdkVendor, major: u32) -> bool {
self.vendor == vendor && self.major == major
}
/// sha256(幂等判据用)。
#[must_use]
pub fn sha256(&self) -> &str {
&self.archive.sha256
}
/// 人读一行(`Temurin 21.0.13+9 · OpenJDK21U-…zip`)。
#[must_use]
pub fn summary(&self) -> String {
format!(
"{} {} · {}",
self.vendor.display(),
self.version,
self.archive.name
)
}
/// `--json` 的归档块(四家共用一个形状)。
#[must_use]
pub fn archive_json(&self) -> serde_json::Value {
serde_json::json!({
"vendor": self.vendor.id(),
"version": self.version,
"name": self.archive.name,
"url": self.archive.url,
"sha256": self.archive.sha256,
"size": self.archive.size,
})
}
}
/// 从 `release_name`(Temurin 的 `jdk-21.0.13+9`)取版本串。
#[must_use]
pub fn version_from_release_name(release_name: &str) -> Option<String> {
let trimmed = release_name.trim();
let stripped = trimmed.strip_prefix("jdk-").unwrap_or(trimmed);
(!stripped.is_empty()).then(|| stripped.to_string())
}
/// 解析 sha256 文本(`.sha256` / `.sha256sum.txt` / 裸哈希都吃),校验 64 位十六进制。
#[must_use]
pub fn parse_sha256_text(text: &str) -> Option<String> {
for token in text.split_whitespace() {
let lowered = token.trim().to_ascii_lowercase();
let candidate = lowered.strip_prefix('*').unwrap_or(lowered.as_str());
if candidate.len() == 64 && candidate.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Some(candidate.to_string());
}
}
None
}
#[cfg(test)]
mod tests {
use super::{parse_sha256_text, version_from_release_name, JdkArchive, JdkRelease};
use crate::game::java::vendor::JdkVendor;
fn release() -> JdkRelease {
JdkRelease {
vendor: JdkVendor::Oracle,
major: 21,
version: "21.0.2".to_string(),
semver: None,
release_name: "21.0.2".to_string(),
archive: JdkArchive {
name: "openjdk-21.0.2_windows-x64_bin.zip".to_string(),
url: "https://mirrors.huaweicloud.com/openjdk/21.0.2/openjdk-21.0.2_windows-x64_bin.zip"
.to_string(),
sha256: "b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"
.to_string(),
size: 0,
},
}
}
#[test]
fn release_carries_vendor_and_native_version() {
let item = release();
assert!(item.matches(JdkVendor::Oracle, 21));
assert!(!item.matches(JdkVendor::Microsoft, 21));
assert!(item.summary().contains("Oracle OpenJDK 21.0.2"));
assert_eq!(item.archive_json()["vendor"], "oracle");
}
#[test]
fn sha256_texts_from_every_vendor_parse_the_same_way() {
// Huawei 的 .sha256:只有 64 位哈希,没有文件名。
assert_eq!(
parse_sha256_text("b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664\n")
.as_deref(),
Some("b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664")
);
// MS 的 .sha256sum.txt:`<hash> <filename>`,并且带大写与 CRLF。
assert_eq!(
parse_sha256_text(
"6ECFA864AE0A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D4E5F60718293A4 microsoft-jdk-21.0.13-windows-x64.zip\r\n"
)
.as_deref(),
Some("6ecfa864ae0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4")
);
assert_eq!(parse_sha256_text("<html>404</html>"), None);
assert_eq!(parse_sha256_text("deadbeef"), None);
}
#[test]
fn version_falls_back_to_the_release_name() {
assert_eq!(
version_from_release_name("jdk-21.0.13+9").as_deref(),
Some("21.0.13+9")
);
assert_eq!(
version_from_release_name("21.0.13+9").as_deref(),
Some("21.0.13+9")
);
assert_eq!(version_from_release_name(""), None);
}
}
+160
View File
@@ -0,0 +1,160 @@
//! 「启动选哪个 Java」的**纯**优先链(D13)。
//!
//! 优先级:实例覆盖(`--java` / 档案 `javaPath`)> 托管目录里**满足要求**的 JDK >
//! `JAVA_HOME` > `PATH`。实例覆盖由调用方单独判(用户明确指定了就不参与打分),
//! 这里只处理「一堆已探测的候选 + 一个要求」的挑选。
//!
//! 挑选规则(比「机械按来源排序」更贴近用户预期):
//!
//! 1. **满足要求的候选优先**——1.12.2 只能用 Java 8,托管的 21 再新也不能选;
//! 2. 同档内按来源:托管 > `JAVA_HOME` > `PATH`(D13:托管目录是启动器的地盘,
//! 用户 PATH 上的 java 可能是另一个 JDK 的残留);
//! 3. 全都不满足时仍给一个(按来源优先取最好那个),并报不符——**提示而不是拒绝**。
//!
//! 抽成纯函数是为了可测:测试注入「托管 21 + PATH 8」这类组合,不必真装 JDK。
use super::mapping::{check, JavaMismatch, JavaRequirement};
use super::probe::{JavaRuntime, DEFAULT_MAJOR};
/// 一次启动的 Java 选择结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JavaResolution {
/// 选中的运行时(一个候选都没有时 `None`)。
pub runtime: Option<JavaRuntime>,
/// 目标版本对 Java 的要求。
pub required: JavaRequirement,
/// 不符说明(`None` = 满足要求,或压根没探测到 Java)。
pub mismatch: Option<JavaMismatch>,
}
impl JavaResolution {
/// 生效的主版本号(没有运行时按缺省值)。
#[must_use]
pub fn major(&self) -> u32 {
self.runtime
.as_ref()
.map_or(DEFAULT_MAJOR, JavaRuntime::major_or_default)
}
/// 是否满足要求(没有运行时视为「不知道」,返回 `true`)。
#[must_use]
pub fn is_satisfied(&self) -> bool {
self.mismatch.is_none()
}
}
/// 从候选里挑一个:满足要求的优先,同档内按来源优先序。
#[must_use]
pub fn pick<'a>(
candidates: &'a [JavaRuntime],
required: &JavaRequirement,
) -> Option<&'a JavaRuntime> {
candidates.iter().min_by_key(|runtime| {
let ok = runtime
.major
.is_some_and(|major| required.satisfied_by(major));
// 先排「不满足」(`ok=false` 的 key 大),再排来源优先序。
(!ok, runtime.origin.rank())
})
}
/// 挑 + 判不符。
#[must_use]
pub fn resolve(candidates: &[JavaRuntime], required: &JavaRequirement) -> JavaResolution {
let runtime = pick(candidates, required).cloned();
let mismatch = runtime
.as_ref()
.and_then(|runtime| runtime.major)
.and_then(|major| check(major, required));
JavaResolution {
runtime,
required: required.clone(),
mismatch,
}
}
#[cfg(test)]
mod tests {
use super::resolve;
use crate::game::java::mapping::{JavaRequirement, RequirementSource};
use crate::game::java::probe::{JavaOrigin, JavaRuntime, DEFAULT_MAJOR};
fn runtime(major: u32, origin: JavaOrigin) -> JavaRuntime {
JavaRuntime {
binary: std::path::PathBuf::from(format!("java{major}")),
origin,
version: Some(format!("openjdk version \"{major}.0.1\"")),
major: Some(major),
bits: 64,
vendor: None,
}
}
fn at_least(minimum: u32) -> JavaRequirement {
JavaRequirement::new(minimum, None, RequirementSource::Table)
}
#[test]
fn satisfying_managed_runtime_beats_mismatching_system_java() {
let candidates = [
runtime(8, JavaOrigin::Path),
runtime(21, JavaOrigin::Managed),
];
let picked = resolve(&candidates, &at_least(17));
assert_eq!(picked.major(), 21);
assert!(picked.is_satisfied());
}
#[test]
fn satisfying_system_java_beats_mismatching_managed() {
// 1.12.2 只能 Java 8:托管的 21 不满足,PATH 上的 8 满足 → 选 8。
let required = JavaRequirement::new(8, Some(8), RequirementSource::Table);
let candidates = [
runtime(21, JavaOrigin::Managed),
runtime(8, JavaOrigin::Path),
];
let picked = resolve(&candidates, &required);
assert_eq!(picked.major(), 8);
assert!(picked.is_satisfied());
}
#[test]
fn managed_wins_when_both_satisfy() {
let candidates = [
runtime(21, JavaOrigin::Path),
runtime(17, JavaOrigin::JavaHome),
runtime(17, JavaOrigin::Managed),
];
assert_eq!(resolve(&candidates, &at_least(17)).major(), 17);
}
#[test]
fn java_home_beats_path_when_both_satisfy() {
let candidates = [
runtime(21, JavaOrigin::Path),
runtime(21, JavaOrigin::JavaHome),
];
let picked = resolve(&candidates, &at_least(21));
assert_eq!(
picked.runtime.map(|runtime| runtime.origin),
Some(JavaOrigin::JavaHome)
);
}
#[test]
fn no_candidate_keeps_the_default_and_reports_no_mismatch() {
let picked = resolve(&[], &at_least(21));
assert_eq!(picked.major(), DEFAULT_MAJOR);
assert!(picked.runtime.is_none());
assert!(picked.is_satisfied());
}
#[test]
fn mismatch_is_reported_when_nothing_fits() {
let candidates = [runtime(8, JavaOrigin::Path)];
let picked = resolve(&candidates, &at_least(21));
assert_eq!(picked.major(), 8);
let mismatch = picked.mismatch.expect("应报不符");
assert_eq!(mismatch.code(), "java_too_old");
}
}
+199
View File
@@ -0,0 +1,199 @@
//! 版本查询的发行版分派 + 回退链记录(D23)。
//!
//! 两件事:
//!
//! 1. [`fetch`]:把一次「某发行版某大版本的最新 GA」查询分派给对应实现——
//! Temurin 走 `api.adoptium.net`(v1 既有),Oracle / MS / Zulu 各走自己的源表;
//! 2. [`ChainLog`]:按用户优先级依次尝试时,**每一跳的结果与降级原因**都记下来。
//! 这份记录直接进 `--json` 与事件流,是「为什么最后装的是 MS 而不是 Temurin」的唯一解释。
//!
//! 回退的粒度是**发行版级**(D23 原话):版本不存在、镜像全 4xx、校验失败都算这一家失败,
//! 换下一家;同一家内部的镜像换源(TUNA→官方、华为云→南大)仍然归 `core::download` 引擎管。
use super::api;
use super::error::JavaError;
use super::microsoft;
use super::oracle;
use super::release::JdkRelease;
use super::target::AdoptiumTarget;
use super::vendor::JdkVendor;
use super::zulu;
use crate::download::DownloadEngine;
/// 查一次「某发行版某大版本的最新 GA」。
///
/// # Errors
///
/// 平台无产物、清单不可达、该家没有这个大版本、校验缺失时返回错误——调用方据此降级。
pub async fn fetch(
engine: &DownloadEngine,
vendor: JdkVendor,
major: u32,
target: AdoptiumTarget,
) -> Result<JdkRelease, JavaError> {
let client = engine.client();
let table = engine.mirrors();
match vendor {
JdkVendor::Temurin => api::fetch_latest(client, major, target).await,
JdkVendor::Oracle => oracle::fetch_latest(client, table, major, target).await,
JdkVendor::Microsoft => {
let table = table.microsoft_openjdk().ok_or_else(|| {
JavaError::new(
super::error::JavaErrorKind::Metadata,
"镜像清单里没有 microsoft_openjdk 表",
)
.with_hint("升级 mirrors.json 到 v3,或改用 --vendor temurin")
})?;
microsoft::fetch_latest(client, table, major, target).await
}
JdkVendor::Zulu => {
let table = table.zulu().ok_or_else(|| {
JavaError::new(
super::error::JavaErrorKind::Metadata,
"镜像清单里没有 zulu 表",
)
.with_hint("升级 mirrors.json 到 v3,或改用 --vendor temurin")
})?;
zulu::fetch_latest(client, table, major, target).await
}
}
}
/// 一跳的结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VendorAttempt {
/// 尝试的发行版。
pub vendor: JdkVendor,
/// 是否命中(成功拿到可下载的归档)。
pub ok: bool,
/// 命中/降级的一句话原因(`"命中"` / `"没有 8 的 GA(只发 11/17/21/25)"`)。
pub detail: String,
}
impl VendorAttempt {
/// `--json` 形状。
#[must_use]
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"vendor": self.vendor.id(),
"ok": self.ok,
"detail": self.detail,
})
}
}
/// 一条回退链的执行记录。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ChainLog {
attempts: Vec<VendorAttempt>,
}
impl ChainLog {
/// 空记录。
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// 记一次「跳过」(该家不支持这个大版本,根本没查网络)。
pub fn skip(&mut self, vendor: JdkVendor, detail: impl Into<String>) {
self.attempts.push(VendorAttempt {
vendor,
ok: false,
detail: detail.into(),
});
}
/// 记一次失败(查到了但拿不到、或下载/校验失败)。
pub fn failed(&mut self, vendor: JdkVendor, detail: impl Into<String>) {
self.attempts.push(VendorAttempt {
vendor,
ok: false,
detail: detail.into(),
});
}
/// 记一次命中。
pub fn hit(&mut self, vendor: JdkVendor, detail: impl Into<String>) {
self.attempts.push(VendorAttempt {
vendor,
ok: true,
detail: detail.into(),
});
}
/// 是否已经命中(命中后不再往下试)。
#[must_use]
pub fn hit_vendor(&self) -> Option<JdkVendor> {
self.attempts
.iter()
.find(|attempt| attempt.ok)
.map(|attempt| attempt.vendor)
}
/// 全部记录。
#[must_use]
pub fn attempts(&self) -> &[VendorAttempt] {
&self.attempts
}
/// 降级说明(「已跳过」的那些;给事件流与人读告警用)。
#[must_use]
pub fn degradations(&self) -> Vec<String> {
self.attempts
.iter()
.filter(|attempt| !attempt.ok)
.map(|attempt| format!("{}:{}", attempt.vendor.id(), attempt.detail))
.collect()
}
/// 实际尝试了几家(不含跳过)。
#[must_use]
pub fn tried(&self) -> usize {
self.attempts.len()
}
/// `--json` 形状。
#[must_use]
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"hit": self.hit_vendor().map(JdkVendor::id),
"attempts": self.attempts.iter().map(VendorAttempt::to_json).collect::<Vec<_>>(),
})
}
}
#[cfg(test)]
mod tests {
use super::ChainLog;
use crate::game::java::vendor::JdkVendor;
#[test]
fn chain_skips_unsupported_majors_and_records_the_reason() {
let mut log = ChainLog::new();
log.skip(JdkVendor::Oracle, "不支持 8(Oracle archive 从 9 起)");
log.skip(JdkVendor::Microsoft, "没有 8 的 GA(只发 11/17/21/25)");
log.hit(JdkVendor::Temurin, "命中 8.0.504+1");
assert_eq!(log.hit_vendor(), Some(JdkVendor::Temurin));
let degradations = log.degradations();
assert_eq!(degradations.len(), 2);
assert!(degradations[1].contains("microsoft"));
assert_eq!(log.attempts().len(), 3);
let json = log.to_json();
assert_eq!(json["hit"], "temurin");
assert_eq!(json["attempts"][0]["vendor"], "oracle");
}
#[test]
fn failure_reasons_are_kept_for_the_event_stream() {
let mut log = ChainLog::new();
log.failed(JdkVendor::Temurin, "所有镜像都 4xx");
log.hit(JdkVendor::Oracle, "命中 21.0.2");
assert_eq!(
log.degradations(),
vec!["temurin:所有镜像都 4xx".to_string()],
"降级原因必须原样可读(用户要能看懂为什么换了发行版)"
);
assert_eq!(log.tried(), 2);
}
}
+85
View File
@@ -0,0 +1,85 @@
//! 宿主平台 → Adoptium 请求参数,以及归档形态(Win = zip,Linux/mac = tar.gz)。
//!
//! Adoptium 的 `os` / `arch` 取值与 Rust 的 `target_os` / `target_arch` 不同形
//! (`windows`/`mac`/`linux` × `x64`/`aarch64`/`x86-32`……),这里做唯一一次翻译。
//! 平台差异仍然按项目纪律收敛在 `core::platform` 的语义里,本文件只做**命名映射**,
//! 不写任何能力探测。
use super::error::{JavaError, JavaErrorKind};
/// 一次 Adoptium 请求的目标。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AdoptiumTarget {
/// Adoptium 的 `os` 段(`windows` / `linux` / `mac`)。
pub os: &'static str,
/// Adoptium 的 `arch` 段(`x64` / `aarch64` / `x86-32` …)。
pub arch: &'static str,
}
impl AdoptiumTarget {
/// 宿主平台(不认识的架构直接拒绝,不猜一个错的产物下来)。
///
/// # Errors
///
/// 当前 OS / 架构没有 Temurin 产物时返回 [`JavaErrorKind::Unsupported`]。
pub fn host() -> Result<Self, JavaError> {
let os = if cfg!(windows) {
"windows"
} else if cfg!(target_os = "macos") {
"mac"
} else if cfg!(target_os = "linux") {
"linux"
} else {
return Err(JavaError::new(
JavaErrorKind::Unsupported,
format!("当前系统({})没有 Temurin 产物", std::env::consts::OS),
)
.with_hint("手动指定 JAVA_HOME,或用 `ull launch --java <path>` 挑已有 JDK"));
};
let arch = arch_name(std::env::consts::ARCH).ok_or_else(|| {
JavaError::new(
JavaErrorKind::Unsupported,
format!("当前架构({})没有 Temurin 产物", std::env::consts::ARCH),
)
.with_hint("手动指定 JAVA_HOME,或用 `ull launch --java <path>` 挑已有 JDK")
})?;
Ok(Self { os, arch })
}
/// 构造(测试注入用)。
#[must_use]
pub const fn new(os: &'static str, arch: &'static str) -> Self {
Self { os, arch }
}
/// 该平台的产物是不是 zip(Windows 是;Linux/mac 的 Temurin 只发 `.tar.gz`)。
#[must_use]
pub fn is_zip(self) -> bool {
self.os == "windows"
}
/// 归档扩展名(拼「期望文件名」与给出「不支持的形态」提示时用)。
#[must_use]
pub fn archive_ext(self) -> &'static str {
if self.is_zip() {
"zip"
} else {
"tar.gz"
}
}
}
/// Rust 架构名 → Adoptium 架构名(不认识返回 `None`)。
#[must_use]
pub fn arch_name(arch: &str) -> Option<&'static str> {
match arch {
"x86_64" => Some("x64"),
"aarch64" => Some("aarch64"),
"x86" | "i686" => Some("x86-32"),
"arm" => Some("arm"),
"ppc64le" => Some("ppc64le"),
"s390x" => Some("s390x"),
"riscv64" => Some("riscv64"),
_ => None,
}
}
+219
View File
@@ -0,0 +1,219 @@
//! JDK 发行版(vendor)抽象与用户可调优先级链(D23)。
//!
//! 四家发行版的**默认顺序按地区分区**(用户定案 + research/06 §5.1 实测修正):
//!
//! ```text
//! 国内:Temurin(TUNA) → Oracle OpenJDK(华为云→南大) → MS OpenJDK(境外 CDN) → Zulu
//! 国外:Temurin → MS OpenJDK → Oracle OpenJDK → Zulu
//! ```
//!
//! 国内把 Oracle 提到 MS 之前的依据是实测差 28×(Oracle 国内 11.5 MB/s vs MS 410 KB/s);
//! 用户原话是「Temurin → MS → Oracle」,但同一句话里给了「优先级可调」,所以这只是默认值,
//! 设置键 `java.vendorPriority` 可以整体覆盖(D23 定稿)。
//!
//! 这里的表只描述**能力与顺序**,不碰网络:怎么查、怎么下在 `oracle` / `microsoft` /
//! `zulu` / `api`(Temurin)四个模块里。
/// 设置键 `java.vendorPriority` 的允许值(顺序即默认序的覆盖来源)。
pub const VENDOR_IDS: &[&str] = &["temurin", "oracle", "microsoft", "zulu"];
/// 一家 JDK 发行版。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum JdkVendor {
/// Eclipse Temurin(Adoptium),v1 唯一实现。
Temurin,
/// Oracle OpenJDK(`download.java.net` GA,只发首个 GA + ≤2 个 update)。
Oracle,
/// Microsoft OpenJDK(`aka.ms` 模板 + VS CDN,无 8)。
Microsoft,
/// Azul Zulu(第四备选,API 元数据 + CDN 直链)。
Zulu,
}
impl JdkVendor {
/// 全部发行版(枚举顺序,不是优先级顺序)。
pub const ALL: [Self; 4] = [Self::Temurin, Self::Oracle, Self::Microsoft, Self::Zulu];
/// 稳定 id(目录名段、`--vendor` 取值、设置值、JSON 字段)。
#[must_use]
pub const fn id(self) -> &'static str {
match self {
Self::Temurin => "temurin",
Self::Oracle => "oracle",
Self::Microsoft => "microsoft",
Self::Zulu => "zulu",
}
}
/// 展示名(人读输出 + `java vendors`)。
#[must_use]
pub const fn display(self) -> &'static str {
match self {
Self::Temurin => "Eclipse Temurin",
Self::Oracle => "Oracle OpenJDK",
Self::Microsoft => "Microsoft OpenJDK",
Self::Zulu => "Azul Zulu",
}
}
/// 解析 `--vendor` / 设置值(容忍常见别名,大小写不敏感)。
#[must_use]
pub fn parse(text: &str) -> Option<Self> {
match text.trim().to_ascii_lowercase().as_str() {
"temurin" | "adoptium" | "eclipse" | "eclipse-temurin" => Some(Self::Temurin),
"oracle" | "oracle_openjdk" | "openjdk" | "java.net" => Some(Self::Oracle),
"microsoft" | "ms" | "msopenjdk" | "ms_openjdk" | "microsoft-openjdk" => {
Some(Self::Microsoft)
}
"zulu" | "azul" => Some(Self::Zulu),
_ => None,
}
}
/// 该发行版能不能装这个大版本。
///
/// Temurin / Zulu 覆盖 8 起的全部 GA;Oracle 的 archive 从 9 起(8 由 Temurin 负责);
/// MS 只发 11/17/21/25(**已停发 8**,官方页面直接让去下 Temurin 8)。
#[must_use]
pub const fn supports(self, major: u32) -> bool {
match self {
Self::Temurin | Self::Zulu => major >= 8,
Self::Oracle => major >= 9,
Self::Microsoft => matches!(major, 11 | 17 | 21 | 25),
}
}
/// 已知在发的 GA 大版本(`java vendors` 展示与设置页提示用;不是硬上限)。
#[must_use]
pub const fn hint_majors(self) -> &'static [u32] {
match self {
Self::Temurin => &[8, 11, 17, 21, 25],
Self::Oracle => &[17, 21, 25],
Self::Microsoft => &[11, 17, 21, 25],
Self::Zulu => &[8, 11, 17, 21, 25],
}
}
/// 国内默认序(用户定案 + 实测修正)。
#[must_use]
pub const fn domestic_default() -> [Self; 4] {
[Self::Temurin, Self::Oracle, Self::Microsoft, Self::Zulu]
}
/// 国外默认序(用户原话顺序)。
#[must_use]
pub const fn global_default() -> [Self; 4] {
[Self::Temurin, Self::Microsoft, Self::Oracle, Self::Zulu]
}
/// 按地区取默认序。
#[must_use]
pub const fn default_order(domestic: bool) -> [Self; 4] {
if domestic {
Self::domestic_default()
} else {
Self::global_default()
}
}
}
/// 用户优先级(设置值)→ 实际尝试顺序。
///
/// 语义刻意做成「**补集追加**」而不是「必须写全」:用户写了 `["oracle"]` 就只把 oracle 提上来,
/// 其余按地区默认序跟在后面。理由——写全四家是负担,漏写更不该等于「其余不试」。
#[must_use]
pub fn order(priority: &[JdkVendor], domestic: bool) -> Vec<JdkVendor> {
let mut out: Vec<JdkVendor> = Vec::with_capacity(JdkVendor::ALL.len());
for vendor in priority {
if !out.contains(vendor) {
out.push(*vendor);
}
}
for vendor in JdkVendor::default_order(domestic) {
if !out.contains(&vendor) {
out.push(vendor);
}
}
out
}
/// 解析设置值里的字符串数组(未知项忽略;顺序保留)。
#[must_use]
pub fn parse_priority(items: &[String]) -> Vec<JdkVendor> {
let mut out = Vec::with_capacity(items.len());
for item in items {
if let Some(vendor) = JdkVendor::parse(item) {
if !out.contains(&vendor) {
out.push(vendor);
}
}
}
out
}
/// 尝试顺序 → 稳定 id 列表(JSON / 人读输出)。
#[must_use]
pub fn ids(vendors: &[JdkVendor]) -> Vec<&'static str> {
vendors.iter().map(|vendor| vendor.id()).collect()
}
#[cfg(test)]
mod tests {
use super::{ids, order, parse_priority, JdkVendor};
#[test]
fn domestic_and_global_defaults_follow_d23() {
assert_eq!(
ids(&order(&[], true)),
vec!["temurin", "oracle", "microsoft", "zulu"],
"国内:Oracle 在 MS 之前(实测差 28×)"
);
assert_eq!(
ids(&order(&[], false)),
vec!["temurin", "microsoft", "oracle", "zulu"],
"国外:维持用户原话 Temurin→MS→Oracle"
);
}
#[test]
fn user_priority_leads_and_the_rest_follows_as_a_complement() {
let priority = parse_priority(&["microsoft".to_string()]);
assert_eq!(
ids(&order(&priority, true)),
vec!["microsoft", "temurin", "oracle", "zulu"],
"只写一家也要把其余按默认序补齐"
);
let full = parse_priority(&["zulu".to_string(), "oracle".to_string(), "zulu".to_string()]);
assert_eq!(
ids(&order(&full, true)),
vec!["zulu", "oracle", "temurin", "microsoft"],
"重复项去重,未列出的追加在后面"
);
}
#[test]
fn unknown_setting_values_are_ignored() {
let parsed = parse_priority(&["graalvm".to_string(), "AZUL".to_string()]);
assert_eq!(parsed, vec![JdkVendor::Zulu]);
}
#[test]
fn vendor_ids_majors_and_aliases() {
assert_eq!(JdkVendor::parse("Adoptium"), Some(JdkVendor::Temurin));
assert_eq!(JdkVendor::parse("ms"), Some(JdkVendor::Microsoft));
assert_eq!(JdkVendor::parse("graalvm"), None);
// MS 没有 8:这是「回退链要跳过某家」的真实判据。
assert!(!JdkVendor::Microsoft.supports(8));
assert!(JdkVendor::Microsoft.supports(21));
assert!(JdkVendor::Oracle.supports(9));
assert!(!JdkVendor::Oracle.supports(8), "8 交给 Temurin");
assert!(JdkVendor::Temurin.supports(8));
for vendor in JdkVendor::ALL {
assert!(
vendor.hint_majors().contains(&21),
"{} 应发 21",
vendor.id()
);
}
}
}
+275
View File
@@ -0,0 +1,275 @@
//! Azul Zulu:**第四备选**(D23)。API 元数据 + CDN 直链,实现刻意简化。
//!
//! 为什么排最后:国内没有任何 Zulu 镜像(三家云 + 四家校园源全无 `zulu`/`azul` 目录),
//! 只能吃 `cdn.azul.com` 的 3.3 MB/s(211 MB ≈ 64 s);而前三家在国内都有更好的路。
//! 留这一家的价值是「三家全挂时的最后一条活路」,以及 Temurin/Oracle/MS 都缺某版本时的兜底。
//!
//! 两跳(research/06 §4.3):
//!
//! ```text
//! GET {api}?java_version=21&os=windows&arch=x64&java_package_type=jdk&archive_type=zip&latest=true
//! → [{ package_uuid, name, java_version, download_url }] # 注意:不含 sha256
//! GET {detail({uuid} 占位)} → { sha256_hash, size(不可信,差 133 B) }
//! ```
//!
//! 三条实测约束:①`latest=true` 的响应**没有** `sha256_hash`,必须再取一次详情;
//! ②`size` 字段与真实字节数差 133 B → 一律不信,只填 0;③文件名必须照抄 API 返回值
//! (`zulu21.54.109-ca-jdk21.0.13.0.3-win_x64.zip` 里混着三段与四段两种写法,拼不出来)。
use serde::Deserialize;
use super::error::{JavaError, JavaErrorKind};
use super::net;
use super::release::{JdkArchive, JdkRelease};
use super::target::AdoptiumTarget;
use super::vendor::JdkVendor;
use crate::download::ZuluTable;
/// Zulu 的 os 段(`windows` / `macos` / `linux`)。
#[must_use]
pub fn zulu_os(target: AdoptiumTarget) -> &'static str {
match target.os {
"mac" => "macos",
other => other,
}
}
/// 包列表查询地址(`latest=true` 只返回最新一个)。
#[must_use]
pub fn api_url(table: &ZuluTable, major: u32, target: AdoptiumTarget) -> String {
format!(
"{}?java_version={major}&os={}&arch={}&java_package_type=jdk&archive_type=zip&latest=true",
table.api,
zulu_os(target),
target.arch
)
}
/// 包详情地址(`{uuid}` 占位替换)。
#[must_use]
pub fn detail_url(table: &ZuluTable, uuid: &str) -> String {
table.detail.replace("{uuid}", uuid)
}
/// 列表响应里我们关心的字段。
///
/// ⚠️ 真机形态(2026-10-30 实测):`latest=true` **不止返回一条**,而且 `java_version` 是
/// **数字数组**(`[21,0,13,0,3]`)而不是字符串;同一批里还混着 `-crac-`(CRaC)与
/// `-fx-`(JavaFX)变体。所以这里按「字段宽进 + 只挑纯 JDK 包」处理,见 [`parse_packages`]。
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct ZuluPackage {
/// 包 id(取详情用)。
pub package_uuid: String,
/// 归档文件名(照抄,不拼)。
pub name: String,
/// JDK 版本(`[21,0,13,0,3]`,Azul 用数组给)。
#[serde(default)]
pub java_version: Vec<u32>,
/// CDN 直链。
pub download_url: String,
}
impl ZuluPackage {
/// 版本串(`[21,0,13,0,3]` → `21.0.13.0.3`;空数组时退回文件名)。
#[must_use]
pub fn version_text(&self) -> String {
if self.java_version.is_empty() {
return self.name.clone();
}
self.java_version
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(".")
}
}
/// 是否是「纯 JDK」包(排除 CRaC / JavaFX / JRE 变体)。
#[must_use]
pub fn is_plain_jdk(name: &str) -> bool {
name.contains("-jdk")
&& !name.contains("-jre")
&& !name.contains("crac")
&& !name.contains("-fx")
}
/// 列表响应 → 纯 JDK 包(`latest=true` 会给多条,必须自己挑)。
///
/// # Errors
///
/// 响应不是 JSON、列表为空时返回错误。
pub fn parse_packages(json: &str) -> Result<ZuluPackage, JavaError> {
let packages: Vec<ZuluPackage> = serde_json::from_str(json).map_err(|error| {
JavaError::new(
JavaErrorKind::Metadata,
format!("Zulu 列表响应不是预期 JSON:{error}"),
)
})?;
let picked = packages
.iter()
.find(|package| is_plain_jdk(&package.name))
.or_else(|| packages.first())
.ok_or_else(|| {
JavaError::new(
JavaErrorKind::Metadata,
"Zulu 没有该版本/平台的 JDK 包(列表为空)",
)
})?;
Ok(picked.clone())
}
/// 详情响应里的 `sha256_hash`(只信这一个字段,`size` 不可对拍)。
///
/// # Errors
///
/// 响应不是 JSON 或缺可用哈希时返回错误。
pub fn parse_detail_sha256(json: &str) -> Result<String, JavaError> {
#[derive(Deserialize)]
struct Detail {
#[serde(default)]
sha256_hash: Option<String>,
}
let detail: Detail = serde_json::from_str(json).map_err(|error| {
JavaError::new(
JavaErrorKind::Metadata,
format!("Zulu 详情响应不是预期 JSON:{error}"),
)
})?;
detail
.sha256_hash
.map(|hex| hex.trim().to_ascii_lowercase())
.filter(|hex| hex.len() == 64 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()))
.ok_or_else(|| {
JavaError::new(
JavaErrorKind::Integrity,
"Zulu 详情里没有可用的 sha256_hash",
)
})
}
/// 查一次「某大版本的最新 Zulu JDK」。
///
/// # Errors
///
/// 表里没配 Zulu、API 不可达/无包、详情缺哈希时返回错误(回退链到此为止,报总失败)。
pub async fn fetch_latest(
client: &reqwest::Client,
table: &ZuluTable,
major: u32,
target: AdoptiumTarget,
) -> Result<JdkRelease, JavaError> {
let list = net::get_text(client, &api_url(table, major, target), "Zulu 包列表").await?;
let package = parse_packages(&list)?;
let detail = net::get_text(
client,
&detail_url(table, &package.package_uuid),
"Zulu 包详情",
)
.await?;
let sha256 = parse_detail_sha256(&detail)?;
// Zulu 的版本串用 `java_version` 数组拼(真机是数组不是字符串);文件名照抄,
// 因为文件名里 `jdk21.0.13.0.3` 这类四段/五段写法拼不出来。
Ok(JdkRelease {
vendor: JdkVendor::Zulu,
major,
version: package.version_text(),
semver: None,
release_name: package.name.clone(),
archive: JdkArchive {
name: package.name,
url: package.download_url,
sha256,
// size 字段不可信(实测差 133 B)→ 填 0,交给 sha256 校验。
size: 0,
},
})
}
#[cfg(test)]
mod tests {
use super::{api_url, detail_url, is_plain_jdk, parse_detail_sha256, parse_packages, zulu_os};
use crate::download::MirrorTable;
use crate::game::java::target::AdoptiumTarget;
const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64");
fn table() -> crate::download::ZuluTable {
MirrorTable::builtin()
.zulu()
.expect("内置表应带 Zulu")
.clone()
}
#[test]
fn urls_carry_every_filter_the_api_needs() {
let table = table();
let url = api_url(&table, 21, WINDOWS);
assert!(url.starts_with("https://api.azul.com/metadata/v1/zulu/packages/?"));
for needle in [
"java_version=21",
"os=windows",
"arch=x64",
"java_package_type=jdk",
"archive_type=zip",
"latest=true",
] {
assert!(url.contains(needle), "{needle} 应在 {url}");
}
assert_eq!(
detail_url(&table, "abc-123"),
"https://api.azul.com/metadata/v1/zulu/packages/abc-123"
);
assert_eq!(zulu_os(AdoptiumTarget::new("mac", "aarch64")), "macos");
}
#[test]
fn latest_listing_gives_the_uuid_and_the_exact_filename() {
// 真机响应(2026-10-30):`latest=true` 返回多条,`java_version` 是数组,
// 并且混着 `-crac-` / `-fx-` 变体——必须挑出纯 JDK 那条。
let json = r#"[
{"package_uuid":"4ee30e3a-b26a-40d3-9d04-2182eae4075e",
"name":"zulu21.54.109-ca-jdk21.0.13.0.3-win_x64.zip",
"java_version":[21,0,13,0,3],
"download_url":"https://cdn.azul.com/zulu/bin/zulu21.54.109-ca-jdk21.0.13.0.3-win_x64.zip"},
{"package_uuid":"2781fb21-9401-40d3-a88e-cf8f36541e3c",
"name":"zulu21.52.203-ca-crac-jdk21.0.12.1-win_x64.zip",
"java_version":[21,0,12,1],
"download_url":"https://cdn.azul.com/zulu/bin/zulu21.52.203-ca-crac-jdk21.0.12.1-win_x64.zip"},
{"package_uuid":"49851aa2-4fd2-4e54-a903-323989cdf711",
"name":"zulu21.52.203-ca-fx-jdk21.0.12.1-win_x64.zip",
"java_version":[21,0,12,1],
"download_url":"https://cdn.azul.com/zulu/bin/zulu21.52.203-ca-fx-jdk21.0.12.1-win_x64.zip"}
]"#;
let package = parse_packages(json).expect("应解析");
assert_eq!(package.package_uuid, "4ee30e3a-b26a-40d3-9d04-2182eae4075e");
assert_eq!(
package.name, "zulu21.54.109-ca-jdk21.0.13.0.3-win_x64.zip",
"文件名必须照抄 API(三段/四段混写,拼不出来)"
);
assert_eq!(package.version_text(), "21.0.13.0.3");
assert!(is_plain_jdk(&package.name));
assert!(!is_plain_jdk(
"zulu21.52.203-ca-crac-jdk21.0.12.1-win_x64.zip"
));
assert!(!is_plain_jdk(
"zulu21.52.203-ca-fx-jdk21.0.12.1-win_x64.zip"
));
assert!(
parse_packages("[]").is_err(),
"空列表要报错而不是造一个空包"
);
assert!(parse_packages("<html>502</html>").is_err());
}
#[test]
fn detail_gives_a_trustworthy_sha256_only() {
let json = r#"{"package_uuid":"x","size":211151000,
"sha256_hash":"6B0E2B0F4C1D2A3E4F5061728394A5B6C7D8E9F0A1B2C3D4E5F60718293A4B5C"}"#;
assert_eq!(
parse_detail_sha256(json).expect("大写哈希应被接受并转小写"),
"6b0e2b0f4c1d2a3e4f5061728394a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c"
);
assert!(parse_detail_sha256(r#"{"size":1}"#).is_err());
assert!(parse_detail_sha256("not json").is_err());
}
}