use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::time::Duration;
use anyhow::{anyhow, Context};
use flate2::read::GzDecoder;
use sha2::{Digest, Sha256};
pub fn find_jdk(major_version: u32, auto_install: bool) -> anyhow::Result<PathBuf> {
let jdk_dir = jdk_cache_dir()?;
let cached = jdk_dir.join(major_version.to_string());
if looks_like_jdk_root(&cached) {
return Ok(cached);
} else if cached.exists() || cached.is_symlink() {
let _ = remove_stale_cache_entry(&cached);
}
let discovered = discover_all_jdks()?;
for (major, root) in &discovered {
let link = jdk_dir.join(major.to_string());
if !link.exists() {
let _ = create_symlink_dir(root, &link);
}
}
if let Some(root) = discovered.get(&major_version) {
let cached_path = jdk_dir.join(major_version.to_string());
if looks_like_jdk_root(&cached_path) {
return Ok(cached_path);
}
return Ok(root.clone());
}
if auto_install {
let installed = install_from_adoptium(major_version)?;
return Ok(installed);
}
Err(anyhow!(
"JDK {major_version} not found. Install it manually or run with auto-provisioning enabled."
))
}
pub fn list_jdks() -> anyhow::Result<Vec<(u32, PathBuf)>> {
let mut result = Vec::new();
let jdk_dir = jdk_cache_dir()?;
if jdk_dir.exists() {
for entry in fs::read_dir(&jdk_dir)? {
let entry = entry?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if let Ok(major) = name_str.parse::<u32>() {
let root = entry.path();
if looks_like_jdk_root(&root) {
result.push((major, root));
}
}
}
}
let discovered = discover_all_jdks()?;
for (major, root) in discovered {
if !result.iter().any(|(m, _)| *m == major) {
result.push((major, root));
}
}
result.sort_by_key(|(m, _)| *m);
Ok(result)
}
pub fn install_jdk(major_version: u32) -> anyhow::Result<PathBuf> {
install_from_adoptium(major_version)
}
fn discover_all_jdks() -> anyhow::Result<std::collections::HashMap<u32, PathBuf>> {
let mut jdks = std::collections::HashMap::new();
if let Ok(java_home) = std::env::var("JAVA_HOME") {
if let Some((major, root)) = probe_jdk_root(&PathBuf::from(&java_home)) {
jdks.entry(major).or_insert(root);
}
}
if let Ok(java) = which::which("java") {
if let Ok(resolved) = fs::canonicalize(&java) {
if let Some(parent) = resolved.parent().and_then(|p| p.parent()) {
if let Some((major, root)) = probe_jdk_root(parent) {
jdks.entry(major).or_insert(root);
}
}
}
}
if let Some(home) = dirs::home_dir() {
let jbang_dir = home.join(".jbang").join("jdks");
probe_major_version_dirs(&jbang_dir, &mut jdks);
let sdkman_dir = home.join(".sdkman").join("candidates").join("java");
probe_versioned_dirs(&sdkman_dir, &mut jdks);
let mise_dir = home
.join(".local")
.join("share")
.join("mise")
.join("installs");
probe_mise_java_dirs(&mise_dir, &mut jdks);
let gradle_dir = home.join(".gradle").join("jdks");
probe_gradle_jdk_dirs(&gradle_dir, &mut jdks);
}
let jvm_dir = Path::new("/usr/lib/jvm");
if jvm_dir.exists() {
probe_system_jvm_dirs(jvm_dir, &mut jdks);
}
Ok(jdks)
}
fn probe_major_version_dirs(base: &Path, jdks: &mut std::collections::HashMap<u32, PathBuf>) {
if !base.is_dir() {
return;
}
if let Ok(entries) = fs::read_dir(base) {
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.parse::<u32>().is_ok() {
if let Some((major, root)) = probe_jdk_root(&entry.path()) {
jdks.entry(major).or_insert(root);
}
}
}
}
}
fn probe_versioned_dirs(base: &Path, jdks: &mut std::collections::HashMap<u32, PathBuf>) {
if !base.is_dir() {
return;
}
if let Ok(entries) = fs::read_dir(base) {
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str == "current" {
continue;
}
let root = entry.path();
if let Some((major, root)) = probe_jdk_root(&root) {
jdks.entry(major).or_insert(root);
}
}
}
}
fn probe_mise_java_dirs(base: &Path, jdks: &mut std::collections::HashMap<u32, PathBuf>) {
if !base.is_dir() {
return;
}
if let Ok(entries) = fs::read_dir(base) {
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with("java-") {
if let Some((major, root)) = probe_jdk_root(&entry.path()) {
jdks.entry(major).or_insert(root);
}
}
}
}
}
fn probe_gradle_jdk_dirs(base: &Path, jdks: &mut std::collections::HashMap<u32, PathBuf>) {
if !base.is_dir() {
return;
}
if let Ok(entries) = fs::read_dir(base) {
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with("jdk-") {
if let Some((major, root)) = probe_jdk_root(&entry.path()) {
jdks.entry(major).or_insert(root);
}
}
}
}
}
fn probe_system_jvm_dirs(base: &Path, jdks: &mut std::collections::HashMap<u32, PathBuf>) {
if let Ok(entries) = fs::read_dir(base) {
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with('.') || name_str.ends_with(".jinfo") {
continue;
}
let root = entry.path();
let resolved = if root.is_symlink() {
match fs::canonicalize(&root) {
Ok(r) => r,
Err(_) => continue,
}
} else {
root
};
if !looks_like_jdk_root(&resolved) {
continue;
}
if let Some(major) = detect_jdk_major_version(&resolved) {
jdks.entry(major).or_insert(resolved);
}
}
}
}
pub fn detect_jdk_major_version(jdk_root: &Path) -> Option<u32> {
let release_file = jdk_root.join("release");
if let Ok(content) = fs::read_to_string(&release_file) {
for line in content.lines() {
if let Some(value) = line.strip_prefix("JAVA_VERSION=") {
let version = value.trim_matches('"').trim();
return parse_major_from_version_string(version);
}
}
}
let java_bin = java_bin_path(jdk_root);
if java_bin.exists() {
if let Ok(output) = std::process::Command::new(&java_bin)
.arg("-version")
.output()
{
let text = String::from_utf8_lossy(&output.stderr);
return parse_major_from_java_version_output(&text);
}
}
None
}
fn parse_major_from_java_version_output(output: &str) -> Option<u32> {
output.lines().find_map(|line| {
if !line.contains("version \"") {
return None;
}
let start = line.find('"')? + 1;
let end = line[start..].find('"')? + start;
parse_major_from_version_string(&line[start..end])
})
}
fn parse_major_from_version_string(version: &str) -> Option<u32> {
static LEGACY_RE: OnceLock<regex::Regex> = OnceLock::new();
static MODERN_RE: OnceLock<regex::Regex> = OnceLock::new();
if version.starts_with("1.") {
let re = LEGACY_RE.get_or_init(|| regex::Regex::new(r"^1\.(\d+)").expect("valid regex"));
return re
.captures(version)
.and_then(|caps| caps.get(1).and_then(|m| m.as_str().parse().ok()));
}
let re = MODERN_RE.get_or_init(|| regex::Regex::new(r"^(\d+)").expect("valid regex"));
re.captures(version)
.and_then(|caps| caps.get(1).and_then(|m| m.as_str().parse().ok()))
}
pub fn parse_java_version_directive(version: &str) -> anyhow::Result<u32> {
parse_major_from_version_string(version)
.with_context(|| format!("invalid JAVA version directive: {version}"))
}
fn probe_jdk_root(jdk_root: &Path) -> Option<(u32, PathBuf)> {
if !looks_like_jdk_root(jdk_root) {
return None;
}
detect_jdk_major_version(jdk_root).map(|major| (major, jdk_root.to_path_buf()))
}
fn looks_like_jdk_root(dir: &Path) -> bool {
dir.is_dir()
&& java_bin_path(dir).is_file()
&& javac_bin_path(dir).is_file()
&& dir.join("release").is_file()
}
fn install_from_adoptium(major_version: u32) -> anyhow::Result<PathBuf> {
let jdk_dir = jdk_cache_dir()?;
let target_dir = jdk_dir.join(major_version.to_string());
if looks_like_jdk_root(&target_dir) {
return Ok(target_dir);
}
let (os, arch) = detect_platform()?;
let archive_url = format!(
"https://api.adoptium.net/v3/binary/latest/{major_version}/ga/{os}/{arch}/jdk/hotspot/normal/eclipse"
);
let checksum_url = format!(
"https://api.adoptium.net/v3/assets/latest/{major_version}/hotspot?architecture={arch}&image_type=jdk&os={os}&vendor=eclipse"
);
eprintln!("Downloading JDK {major_version} from Adoptium...");
let agent = format!("jbx/{}", env!("CARGO_PKG_VERSION"));
let expected_checksum = normalize_sha256(&fetch_adoptium_checksum(&checksum_url, &agent)?)?;
let archive_path = target_dir.with_extension(if os == "windows" {
"zip.tmp"
} else {
"tar.gz.tmp"
});
if archive_path.exists() {
fs::remove_file(&archive_path).with_context(|| {
format!(
"failed to remove stale JDK archive {}",
archive_path.display()
)
})?;
}
if let Some(parent) = archive_path.parent() {
fs::create_dir_all(parent)?;
}
let response = ureq::AgentBuilder::new()
.timeout_read(Duration::from_secs(300))
.timeout_write(Duration::from_secs(30))
.redirects(5)
.build()
.get(&archive_url)
.set("User-Agent", &agent)
.call()
.with_context(|| format!("failed to download JDK {major_version} from Adoptium"))?;
let mut reader = response.into_reader();
let mut file = fs::File::create(&archive_path)
.with_context(|| format!("failed to create JDK archive {}", archive_path.display()))?;
let mut hasher = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = reader
.read(&mut buffer)
.context("failed to read JDK archive")?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
file.write_all(&buffer[..read])
.context("failed to write JDK archive")?;
}
file.flush().context("failed to flush JDK archive")?;
let actual_checksum = format!("{:x}", hasher.finalize());
if actual_checksum != expected_checksum {
let _ = fs::remove_file(&archive_path);
return Err(anyhow!(
"JDK archive checksum mismatch: expected {expected_checksum}, got {actual_checksum}"
));
}
let tmp_dir = target_dir.with_extension("tmp");
if tmp_dir.exists() {
let _ = fs::remove_dir_all(&tmp_dir);
}
fs::create_dir_all(&tmp_dir)
.with_context(|| format!("failed to create temp JDK dir {}", tmp_dir.display()))?;
extract_jdk_archive(&archive_path, &tmp_dir, os)?;
let actual_root = find_extracted_jdk_root(&tmp_dir, os);
if target_dir.exists() {
let _ = remove_stale_cache_entry(&target_dir);
}
fs::rename(&actual_root, &target_dir)
.with_context(|| format!("failed to move JDK to {}", target_dir.display()))?;
if tmp_dir != actual_root && tmp_dir.exists() {
let _ = fs::remove_dir_all(&tmp_dir);
}
let _ = fs::remove_file(&archive_path);
eprintln!("JDK {major_version} installed to {}", target_dir.display());
Ok(target_dir)
}
fn fetch_adoptium_checksum(url: &str, agent: &str) -> anyhow::Result<String> {
let response = ureq::AgentBuilder::new()
.timeout_read(Duration::from_secs(30))
.build()
.get(url)
.set("User-Agent", agent)
.call()
.context("failed to fetch JDK checksum metadata")?;
let mut body_text = String::new();
response
.into_reader()
.read_to_string(&mut body_text)
.context("failed to read JDK checksum metadata")?;
let body: serde_json::Value =
serde_json::from_str(&body_text).context("failed to parse JDK checksum metadata JSON")?;
body.get(0)
.and_then(|v| v.get("binary"))
.and_then(|v| v.get("package"))
.and_then(|v| v.get("checksum"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| anyhow!("JDK checksum metadata did not contain binary.package.checksum"))
}
fn normalize_sha256(checksum: &str) -> anyhow::Result<String> {
let normalized = checksum.trim().replace('-', "").to_ascii_lowercase();
if normalized.len() != 64 || !normalized.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(anyhow!(
"invalid SHA-256 checksum from Adoptium: {checksum}"
));
}
Ok(normalized)
}
fn extract_jdk_archive(archive_path: &Path, target_dir: &Path, os: &str) -> anyhow::Result<()> {
if os == "windows" {
let cursor = fs::File::open(archive_path).with_context(|| {
format!("failed to open JDK zip archive {}", archive_path.display())
})?;
let mut archive =
zip::ZipArchive::new(cursor).with_context(|| "failed to read JDK zip archive")?;
archive
.extract(target_dir)
.with_context(|| "failed to extract JDK zip archive")?;
} else {
let cursor = fs::File::open(archive_path).with_context(|| {
format!(
"failed to open JDK tar.gz archive {}",
archive_path.display()
)
})?;
let gz_decoder = GzDecoder::new(cursor);
let mut archive = tar::Archive::new(gz_decoder);
archive
.unpack(target_dir)
.with_context(|| "failed to extract JDK tar.gz archive")?;
}
Ok(())
}
fn find_extracted_jdk_root(dir: &Path, os: &str) -> PathBuf {
let single_child = find_single_subdir(dir);
if os == "mac" {
if let Some(bundle) = single_child.as_ref() {
let home = bundle.join("Contents").join("Home");
if looks_like_jdk_root(&home) {
return home;
}
}
}
single_child.unwrap_or_else(|| dir.to_path_buf())
}
fn find_single_subdir(dir: &Path) -> Option<PathBuf> {
let mut children = Vec::new();
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
children.push(path);
}
}
}
if children.len() == 1 {
Some(children.into_iter().next().unwrap())
} else {
None
}
}
fn detect_platform() -> anyhow::Result<(&'static str, &'static str)> {
let os = if cfg!(target_os = "linux") {
if cfg!(target_env = "musl") {
"alpine-linux"
} else {
"linux"
}
} else if cfg!(target_os = "macos") {
"mac"
} else if cfg!(target_os = "windows") {
"windows"
} else {
return Err(anyhow!("unsupported operating system"));
};
let arch = if cfg!(target_arch = "x86_64") {
"x64"
} else if cfg!(target_arch = "aarch64") {
"aarch64"
} else if cfg!(target_arch = "arm") {
"arm"
} else if cfg!(target_arch = "riscv64") {
"riscv64"
} else if cfg!(target_arch = "powerpc64") && !cfg!(target_endian = "little") {
"ppc64"
} else if cfg!(target_arch = "powerpc64") && cfg!(target_endian = "little") {
"ppc64le"
} else if cfg!(target_arch = "s390x") {
"s390x"
} else {
return Err(anyhow!("unsupported architecture"));
};
Ok((os, arch))
}
fn jdk_cache_dir() -> anyhow::Result<PathBuf> {
let cache = dirs::cache_dir().ok_or_else(|| anyhow!("cannot determine cache directory"))?;
Ok(cache.join("jbx").join("jdks"))
}
pub fn java_bin_path(jdk_root: &Path) -> PathBuf {
if cfg!(windows) {
jdk_root.join("bin").join("java.exe")
} else {
jdk_root.join("bin").join("java")
}
}
pub fn javac_bin_path(jdk_root: &Path) -> PathBuf {
if cfg!(windows) {
jdk_root.join("bin").join("javac.exe")
} else {
jdk_root.join("bin").join("javac")
}
}
pub fn javadoc_bin_path(jdk_root: &Path) -> PathBuf {
if cfg!(windows) {
jdk_root.join("bin").join("javadoc.exe")
} else {
jdk_root.join("bin").join("javadoc")
}
}
fn remove_stale_cache_entry(path: &Path) -> anyhow::Result<()> {
if path.is_symlink() || path.is_file() {
fs::remove_file(path)?;
} else if path.is_dir() {
fs::remove_dir_all(path)?;
}
Ok(())
}
fn create_symlink_dir(target: &Path, link: &Path) -> anyhow::Result<()> {
if link.exists() {
return Ok(());
}
if link.is_symlink() {
fs::remove_file(link)
.with_context(|| format!("failed to remove broken symlink {}", link.display()))?;
}
if let Some(parent) = link.parent() {
fs::create_dir_all(parent)?;
}
#[cfg(unix)]
{
std::os::unix::fs::symlink(target, link)
.with_context(|| format!("symlink {} → {}", link.display(), target.display()))?;
}
#[cfg(windows)]
{
if std::os::windows::fs::symlink_dir(target, link).is_err() {
copy_dir_recursive(target, link)?;
}
}
Ok(())
}
#[cfg(windows)]
fn copy_dir_recursive(source: &Path, target: &Path) -> anyhow::Result<()> {
fs::create_dir_all(target)
.with_context(|| format!("failed to create directory {}", target.display()))?;
for entry in fs::read_dir(source)
.with_context(|| format!("failed to read directory {}", source.display()))?
{
let entry = entry?;
let source_path = entry.path();
let target_path = target.join(entry.file_name());
if source_path.is_dir() {
copy_dir_recursive(&source_path, &target_path)?;
} else {
fs::copy(&source_path, &target_path).with_context(|| {
format!(
"failed to copy {} to {}",
source_path.display(),
target_path.display()
)
})?;
}
}
Ok(())
}
pub fn resolve_jdk(java_version: &Option<String>, auto_install: bool) -> anyhow::Result<PathBuf> {
let major = match java_version {
Some(v) => parse_java_version_directive(v)?,
None => default_java_version(),
};
find_jdk(major, auto_install)
}
fn default_java_version() -> u32 {
25
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_major_from_version_string() {
assert_eq!(parse_major_from_version_string("21.0.3"), Some(21));
assert_eq!(parse_major_from_version_string("1.8.0_432"), Some(8));
assert_eq!(parse_major_from_version_string("17.0.11+10"), Some(17));
assert_eq!(parse_major_from_version_string("25"), Some(25));
}
#[test]
fn test_parse_java_version_directive_accepts_jbang_range_suffixes() {
assert_eq!(parse_java_version_directive("25+").unwrap(), 25);
assert_eq!(parse_java_version_directive("17.0.11+10").unwrap(), 17);
assert_eq!(parse_java_version_directive("1.8+").unwrap(), 8);
}
#[test]
fn test_parse_major_from_java_version_output_ignores_tool_options() {
let output = "Picked up JAVA_TOOL_OPTIONS: -Xmx4g\nopenjdk version \"21.0.3\" 2024-04-16\nOpenJDK Runtime Environment\n";
assert_eq!(parse_major_from_java_version_output(output), Some(21));
}
#[test]
fn test_detect_platform() {
let (os, arch) = detect_platform().unwrap();
assert!(matches!(os, "linux" | "mac" | "windows" | "alpine-linux"));
assert!(matches!(
arch,
"x64" | "aarch64" | "arm" | "riscv64" | "ppc64" | "ppc64le" | "s390x"
));
}
#[test]
fn test_find_extracted_jdk_root_handles_macos_bundle() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("jdk-25.jdk").join("Contents").join("Home");
fs::create_dir_all(home.join("bin")).unwrap();
fs::write(java_bin_path(&home), "").unwrap();
fs::write(javac_bin_path(&home), "").unwrap();
fs::write(home.join("release"), "JAVA_VERSION=\"25\"\n").unwrap();
assert_eq!(find_extracted_jdk_root(tmp.path(), "mac"), home);
}
#[test]
#[cfg(unix)]
fn test_create_symlink_dir_replaces_broken_symlink() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("jdk-25");
fs::create_dir_all(target.join("bin")).unwrap();
fs::write(java_bin_path(&target), "").unwrap();
fs::write(javac_bin_path(&target), "").unwrap();
fs::write(target.join("release"), "JAVA_VERSION=\"25\"\n").unwrap();
let link = tmp.path().join("cache").join("25");
fs::create_dir_all(link.parent().unwrap()).unwrap();
std::os::unix::fs::symlink(tmp.path().join("missing-jdk"), &link).unwrap();
assert!(link.is_symlink());
assert!(!link.exists());
create_symlink_dir(&target, &link).unwrap();
assert!(link.exists());
assert!(looks_like_jdk_root(&link));
}
#[test]
fn test_default_java_version() {
assert_eq!(default_java_version(), 25);
}
}