1use std::collections::BTreeMap;
5use std::path::PathBuf;
6
7use async_trait::async_trait;
8use serde::Deserialize;
9
10use crate::backend::{Backend, Ctx, InstallCtx};
11use crate::error::{Error, Result};
12use crate::http;
13use crate::pipeline::{self, ArchiveKind, InstallPlan, PipelineCtx};
14use crate::platform::Os;
15use crate::source::Source;
16use crate::version::{ToolRequest, ToolVersion, VersionInfo, VersionSpec};
17
18pub struct JavaBackend;
19
20const DEFAULT_DISTRIBUTION: &str = "temurin";
21const BUILTIN_TEMURIN_LTS: &[&str] = &[
22 "8.0.502+7",
23 "11.0.32+9",
24 "17.0.20+8",
25 "21.0.12+8",
26 "25.0.4+7",
27];
28
29#[derive(Debug, Deserialize)]
30struct DiscoResponse<T> {
31 result: Vec<T>,
32}
33
34#[derive(Debug, Deserialize, Clone)]
35struct Package {
36 #[serde(default)]
37 id: String,
38 #[serde(default)]
39 java_version: String,
40 #[serde(default)]
41 distribution_version: String,
42 #[serde(default)]
43 filename: String,
44 #[serde(default)]
45 #[allow(dead_code)] archive_type: String,
47 #[serde(default)]
48 links: PackageLinks,
49 #[serde(default)]
50 #[allow(dead_code)] distribution: String,
52 #[serde(default)]
54 checksum: String,
55 #[serde(default)]
56 checksum_type: String,
57 #[serde(default)]
58 package_type: String,
59 #[serde(default)]
60 lib_c_type: String,
61}
62
63#[derive(Debug, Deserialize, Clone, Default)]
64struct PackageLinks {
65 #[serde(default)]
66 pkg_download_redirect: String,
67}
68
69impl JavaBackend {
70 fn os_token(os: Os) -> &'static str {
71 match os {
72 Os::Linux => "linux",
73 Os::Macos => "macos",
74 Os::Windows => "windows",
75 }
76 }
77
78 fn arch_token(ctx: &Ctx) -> &'static str {
79 use crate::platform::Arch;
80 match ctx.platform.arch {
81 Arch::X64 => "x64",
82 Arch::Arm64 => "aarch64",
83 Arch::X86 => "x86",
84 Arch::Arm => "arm",
85 }
86 }
87
88 fn archive_type(os: Os) -> &'static str {
89 match os {
90 Os::Windows => "zip",
91 _ => "tar.gz",
92 }
93 }
94
95 fn distribution(req_opts: &BTreeMap<String, String>) -> String {
97 req_opts
98 .get("distribution")
99 .cloned()
100 .unwrap_or_else(|| DEFAULT_DISTRIBUTION.to_string())
101 }
102
103 fn package_type(req_opts: &BTreeMap<String, String>) -> Result<String> {
104 let value = req_opts
105 .get("package-type")
106 .map(String::as_str)
107 .unwrap_or("jdk");
108 match value {
109 "jdk" | "jre" => Ok(value.into()),
110 _ => Err(Error::config(format!(
111 "invalid Java package type `{value}` (expected jdk|jre)"
112 ))),
113 }
114 }
115
116 fn libc_token(ctx: &Ctx) -> &'static str {
117 match ctx.platform.libc {
118 crate::platform::Libc::Musl => "musl",
119 crate::platform::Libc::Glibc => "glibc",
120 crate::platform::Libc::None => "none",
121 }
122 }
123
124 fn packages_url(
125 ctx: &Ctx,
126 base_index: &str,
127 distribution: &str,
128 package_type: &str,
129 version_filter: Option<&str>,
130 ) -> String {
131 let os = Self::os_token(ctx.platform.os);
132 let arch = Self::arch_token(ctx);
133 let at = Self::archive_type(ctx.platform.os);
134 let mut url = format!(
135 "{base}?distribution={dist}&operating_system={os}&architecture={arch}&archive_type={at}&package_type={package_type}&latest=available",
136 base = base_index.trim_end_matches('/'),
137 dist = distribution,
138 os = os,
139 arch = arch,
140 at = at,
141 package_type = package_type,
142 );
143 if let Some(v) = version_filter {
144 url.push_str(&format!("&version={v}"));
145 }
146 url
147 }
148}
149
150#[async_trait]
151impl Backend for JavaBackend {
152 fn id(&self) -> &str {
153 "java"
154 }
155
156 fn aliases(&self) -> &[&str] {
157 &["jdk", "openjdk"]
158 }
159
160 fn default_sources(&self) -> Vec<Source> {
161 vec![
162 Source::official("foojay", "https://api.foojay.io/disco/v3.0/packages")
163 .with_index("https://api.foojay.io/disco/v3.0/packages"),
164 ]
165 }
166
167 fn probe_url(&self, _ctx: &Ctx, _source: &Source) -> Option<String> {
168 Some("https://api.foojay.io/disco/v3.0/distributions".to_string())
170 }
171
172 async fn resolve_version(&self, ctx: &Ctx, req: &ToolRequest) -> Result<ToolVersion> {
174 if req
175 .options
176 .contains_key(pipeline::LOCKED_ARTIFACT_URL_OPTION)
177 {
178 if let VersionSpec::Exact(identity) = &req.spec {
179 let mut resolved = ToolVersion::new(self.id(), identity);
180 resolved.options = req.options.clone();
181 return Ok(resolved);
182 }
183 }
184 let (distribution, spec) = split_distribution(&req.spec);
185 let mut opts = req.options.clone();
186 opts.insert("distribution".to_string(), distribution.clone());
187 let package_type = Self::package_type(&opts)?;
188 opts.insert("package-type".into(), package_type.clone());
189
190 let versions = self
192 .list_for_distribution(ctx, &distribution, &package_type)
193 .await?;
194 let chosen = crate::version::select_version(&spec, &versions).ok_or_else(|| {
195 Error::VersionResolve {
196 tool: self.id().to_string(),
197 spec: req.spec.to_string(),
198 hint: Some(format!("no {distribution} {package_type} matched")),
199 }
200 })?;
201 let identity = if package_type == "jre" {
202 format!("jre-{}", chosen.version)
203 } else {
204 chosen.version.clone()
205 };
206 let mut tv = ToolVersion::new(self.id(), identity);
207 opts.insert("java-version".into(), chosen.version.clone());
208 tv.options = opts;
209 Ok(tv)
210 }
211
212 async fn list_remote_versions(&self, ctx: &Ctx) -> Result<Vec<VersionInfo>> {
213 self.list_for_distribution(ctx, DEFAULT_DISTRIBUTION, "jdk")
214 .await
215 }
216
217 async fn install(&self, ictx: &InstallCtx<'_>, tv: &ToolVersion) -> Result<()> {
218 let ctx = ictx.ctx;
219 if let Some(plan) = pipeline::locked_install_plan(self.id(), tv, true)? {
220 let pctx = PipelineCtx {
221 client: &ctx.client,
222 dirs: &ctx.dirs,
223 cas: &ctx.cas,
224 link_mode: ctx.config.settings.link_mode,
225 show_progress: ctx.show_progress,
226 offline: ctx.config.settings.offline,
227 require_checksums: ctx.config.settings.require_checksums,
228 };
229 pipeline::run(&plan, &pctx).await?;
230 return Ok(());
231 }
232 let distribution = Self::distribution(&tv.options);
233 let package_type = Self::package_type(&tv.options)?;
234 let java_version = tv
235 .options
236 .get("java-version")
237 .map(String::as_str)
238 .unwrap_or_else(|| tv.version.strip_prefix("jre-").unwrap_or(&tv.version));
239 let sources = crate::source::select::ranked_source_list(ctx, self).await?;
240 let base_index = ctx
241 .config
242 .settings
243 .java
244 .catalog_url
245 .clone()
246 .or_else(|| sources.first().map(|source| source.download_url.clone()))
247 .unwrap_or_else(|| "https://api.foojay.io/disco/v3.0/packages".to_string());
248
249 let url = Self::packages_url(
251 ctx,
252 &base_index,
253 &distribution,
254 &package_type,
255 Some(java_version),
256 );
257 let resp: DiscoResponse<Package> = http::get_cached_json(ctx, &url).await?;
258 let pkg = resp
259 .result
260 .into_iter()
261 .find(|p| {
262 !p.links.pkg_download_redirect.is_empty()
263 && (p.package_type.is_empty() || p.package_type == package_type)
264 && (ctx.platform.os != Os::Linux
265 || p.lib_c_type.is_empty()
266 || p.lib_c_type == Self::libc_token(ctx))
267 })
268 .ok_or_else(|| Error::VersionResolve {
269 tool: self.id().to_string(),
270 spec: java_version.into(),
271 hint: Some(format!(
272 "no {distribution} {package_type} package for this platform"
273 )),
274 })?;
275
276 let file_name = if pkg.filename.is_empty() {
277 format!(
278 "{}-{}.{}",
279 distribution,
280 java_version,
281 Self::archive_type(ctx.platform.os)
282 )
283 } else {
284 pkg.filename.clone()
285 };
286 let kind = ArchiveKind::from_name(&file_name)?;
287
288 let redirect = pkg.links.pkg_download_redirect.clone();
291 let mut urls = Vec::new();
292 if !ctx.config.settings.offline {
293 if let Ok(real) = resolve_redirect(&ctx.client, &redirect).await {
294 if real.contains("github.com") {
295 urls.push(format!("https://gh-proxy.com/{real}"));
297 urls.push(real);
298 } else {
299 urls.push(real);
300 }
301 }
302 }
303 urls.push(redirect);
305
306 let checksum = self.fetch_checksum(ctx, &base_index, &pkg.id).await;
308
309 let plan = InstallPlan {
310 tool: self.id().to_string(),
311 version: tv.version.clone(),
312 urls,
313 file_name,
314 kind,
315 checksum,
316 strip_root: true, subdir: None,
318 };
319 let pctx = PipelineCtx {
320 client: &ctx.client,
321 dirs: &ctx.dirs,
322 cas: &ctx.cas,
323 link_mode: ctx.config.settings.link_mode,
324 show_progress: ctx.show_progress,
325 offline: ctx.config.settings.offline,
326 require_checksums: ctx.config.settings.require_checksums,
327 };
328 pipeline::run(&plan, &pctx).await?;
329 Ok(())
330 }
331
332 fn bin_paths(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Vec<PathBuf>> {
333 let root = ctx.dirs.install_path(self.id(), &tv.version);
334 let home = if ctx.platform.os == Os::Macos && root.join("Contents/Home").exists() {
336 root.join("Contents/Home")
337 } else {
338 root
339 };
340 Ok(vec![home.join("bin")])
341 }
342
343 fn exec_env(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<BTreeMap<String, String>> {
344 let root = ctx.dirs.install_path(self.id(), &tv.version);
345 let home = if ctx.platform.os == Os::Macos && root.join("Contents/Home").exists() {
346 root.join("Contents/Home")
347 } else {
348 root
349 };
350 let mut env = BTreeMap::new();
351 env.insert("JAVA_HOME".to_string(), home.display().to_string());
352 Ok(env)
353 }
354
355 fn bin_names(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Vec<String>> {
356 let paths = self.bin_paths(ctx, tv)?;
357 let discovered = crate::backend::bin_names_in_dirs(&paths);
358 if discovered.is_empty() {
359 if tv.version.starts_with("jre-")
360 || tv.options.get("package-type").map(String::as_str) == Some("jre")
361 {
362 Ok(vec!["java".into(), "keytool".into()])
363 } else {
364 Ok(vec!["java".into(), "javac".into(), "jar".into()])
365 }
366 } else {
367 Ok(discovered)
368 }
369 }
370
371 fn idiomatic_files(&self) -> &[&str] {
372 &[".java-version", ".sdkmanrc"]
373 }
374}
375
376impl JavaBackend {
377 async fn list_for_distribution(
378 &self,
379 ctx: &Ctx,
380 distribution: &str,
381 package_type: &str,
382 ) -> Result<Vec<VersionInfo>> {
383 let sources = crate::source::select::ranked_source_list(ctx, self).await?;
384 let base_index = ctx
385 .config
386 .settings
387 .java
388 .catalog_url
389 .clone()
390 .or_else(|| sources.first().map(|source| source.download_url.clone()))
391 .unwrap_or_else(|| "https://api.foojay.io/disco/v3.0/packages".to_string());
392 let url = Self::packages_url(ctx, &base_index, distribution, package_type, None);
393 let response = http::get_cached_json::<DiscoResponse<Package>>(ctx, &url).await;
394 let mut response_error = None;
395
396 use std::collections::BTreeSet;
397 let mut set: BTreeSet<String> = BTreeSet::new();
398 match response {
399 Ok(response) => {
400 for package in response.result {
401 if !package.package_type.is_empty() && package.package_type != package_type {
402 continue;
403 }
404 if ctx.platform.os == Os::Linux
405 && !package.lib_c_type.is_empty()
406 && package.lib_c_type != Self::libc_token(ctx)
407 {
408 continue;
409 }
410 let version = if !package.java_version.is_empty() {
411 package.java_version
412 } else {
413 package.distribution_version
414 };
415 if !version.is_empty() {
416 set.insert(version);
417 }
418 }
419 }
420 Err(error) => response_error = Some(error),
421 }
422 if distribution == DEFAULT_DISTRIBUTION {
423 set.extend(BUILTIN_TEMURIN_LTS.iter().map(|value| (*value).to_string()));
424 }
425 if set.is_empty() {
426 return Err(response_error.unwrap_or_else(|| Error::VersionResolve {
427 tool: self.id().into(),
428 spec: "latest".into(),
429 hint: Some(format!("no {distribution} {package_type} versions")),
430 }));
431 }
432 let mut out: Vec<VersionInfo> = set
433 .into_iter()
434 .map(|v| VersionInfo {
435 version: v,
436 stable: true,
437 lts: None,
438 })
439 .collect();
440 out.sort_by(|a, b| crate::backend::python::cmp_versions(&a.version, &b.version));
441 Ok(out)
442 }
443
444 async fn fetch_checksum(
448 &self,
449 ctx: &Ctx,
450 base_index: &str,
451 id: &str,
452 ) -> Option<crate::pipeline::Checksum> {
453 if id.is_empty() {
454 return None;
455 }
456 let base = base_index.trim_end_matches('/');
458 let root = base.strip_suffix("/packages").unwrap_or(base);
459 let url = format!("{}/ids/{}", root, id);
460 let resp: DiscoResponse<Package> = http::get_cached_json(ctx, &url).await.ok()?;
461 let pkg = resp.result.into_iter().next()?;
462 if pkg.checksum.is_empty() {
463 return None;
464 }
465 if !pkg.checksum_type.is_empty() && !pkg.checksum_type.eq_ignore_ascii_case("sha256") {
467 tracing::debug!(kind = %pkg.checksum_type, "unsupported java checksum type; skipping");
468 return None;
469 }
470 Some(crate::pipeline::Checksum {
471 algo: crate::pipeline::HashAlgo::Sha256,
472 hex: pkg.checksum,
473 })
474 }
475}
476
477async fn resolve_redirect(_client: &reqwest::Client, url: &str) -> Result<String> {
480 let no_redirect = reqwest::Client::builder()
481 .user_agent(concat!("osdk/", env!("CARGO_PKG_VERSION")))
482 .connect_timeout(std::time::Duration::from_secs(15))
483 .redirect(reqwest::redirect::Policy::none())
484 .build()?;
485 let resp = no_redirect.get(url).send().await?;
486 if let Some(loc) = resp.headers().get(reqwest::header::LOCATION) {
487 if let Ok(s) = loc.to_str() {
488 return Ok(s.to_string());
489 }
490 }
491 Ok(resp.url().to_string())
493}
494
495fn split_distribution(spec: &VersionSpec) -> (String, VersionSpec) {
498 if let VersionSpec::Prefix(p) | VersionSpec::Exact(p) = spec {
499 if let Some((dist, ver)) = p.split_once('-') {
500 if dist
501 .chars()
502 .next()
503 .map(|c| c.is_ascii_alphabetic())
504 .unwrap_or(false)
505 {
506 return (dist.to_string(), VersionSpec::parse(ver));
507 }
508 }
509 }
510 (DEFAULT_DISTRIBUTION.to_string(), spec.clone())
511}
512
513#[cfg(test)]
514mod tests {
515 use std::sync::Arc;
516
517 use super::*;
518 use crate::config::{Config, Settings, SourcesConfig};
519 use crate::dirs::Dirs;
520 use crate::platform::{Arch, Libc, Platform};
521 use crate::store::Cas;
522 use std::collections::BTreeMap;
523
524 #[test]
525 fn distribution_split() {
526 let (d, v) = split_distribution(&VersionSpec::parse("temurin-21"));
527 assert_eq!(d, "temurin");
528 assert_eq!(v, VersionSpec::Prefix("21".into()));
529
530 let (d, v) = split_distribution(&VersionSpec::parse("21"));
531 assert_eq!(d, "temurin");
532 assert_eq!(v, VersionSpec::Prefix("21".into()));
533
534 let (d, _) = split_distribution(&VersionSpec::parse("zulu-17.0.1"));
535 assert_eq!(d, "zulu");
536 }
537
538 fn offline_ctx(root: &std::path::Path) -> Ctx {
539 let dirs = Dirs::resolve_from(|key| match key {
540 "OSDK_DATA_DIR" => Some(root.join("data").display().to_string()),
541 "OSDK_CACHE_DIR" => Some(root.join("cache").display().to_string()),
542 "OSDK_CONFIG_DIR" => Some(root.join("config").display().to_string()),
543 "OSDK_STORE_DIR" => Some(root.join("store").display().to_string()),
544 "OSDK_INSTALL_DIR" => Some(root.join("installs").display().to_string()),
545 _ => None,
546 })
547 .unwrap();
548 dirs.ensure().unwrap();
549 Ctx {
550 cas: Arc::new(Cas::new(dirs.store.clone())),
551 dirs,
552 platform: Platform {
553 os: Os::Linux,
554 arch: Arch::X64,
555 libc: Libc::Glibc,
556 },
557 config: Config {
558 settings: Settings {
559 offline: true,
560 ..Default::default()
561 },
562 sources: SourcesConfig::default(),
563 tools: Default::default(),
564 tool_configs: Default::default(),
565 global_tools: Default::default(),
566 global_tool_configs: Default::default(),
567 tool_origins: Default::default(),
568 aliases: Default::default(),
569 project_config_path: None,
570 },
571 client: reqwest::Client::new(),
572 show_progress: false,
573 }
574 }
575
576 #[tokio::test]
577 async fn empty_cache_offline_resolves_builtin_lts_jdk_and_jre() {
578 let temp = tempfile::tempdir().unwrap();
579 let ctx = offline_ctx(temp.path());
580
581 let jdk = JavaBackend
582 .resolve_version(&ctx, &ToolRequest::parse("java@21").unwrap())
583 .await
584 .unwrap();
585 assert_eq!(jdk.version, "21.0.12+8");
586 assert_eq!(jdk.options["package-type"], "jdk");
587
588 let mut jre_request = ToolRequest::parse("java@21").unwrap();
589 jre_request
590 .options
591 .insert("package-type".into(), "jre".into());
592 let jre = JavaBackend
593 .resolve_version(&ctx, &jre_request)
594 .await
595 .unwrap();
596 assert_eq!(jre.version, "jre-21.0.12+8");
597 assert_eq!(jre.options["java-version"], "21.0.12+8");
598 assert_ne!(jdk.version, jre.version);
599 }
600
601 #[test]
602 fn package_urls_and_filtering_include_runtime_type_and_libc() {
603 let temp = tempfile::tempdir().unwrap();
604 let ctx = offline_ctx(temp.path());
605 let url = JavaBackend::packages_url(
606 &ctx,
607 "https://example.test/packages",
608 "temurin",
609 "jre",
610 Some("21"),
611 );
612 assert!(url.contains("package_type=jre"));
613 assert!(url.contains("version=21"));
614 assert_eq!(JavaBackend::libc_token(&ctx), "glibc");
615 }
616
617 #[tokio::test]
618 async fn locked_java_archive_installs_offline_without_foojay() {
619 let temp = tempfile::tempdir().unwrap();
620 let ctx = offline_ctx(temp.path());
621 let archive = temp.path().join("java-fixture.tar.gz");
622 let file = std::fs::File::create(&archive).unwrap();
623 let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::fast());
624 let mut builder = tar::Builder::new(encoder);
625 let contents = b"#!/bin/sh\nexit 0\n";
626 let mut header = tar::Header::new_gnu();
627 header.set_size(contents.len() as u64);
628 header.set_mode(0o755);
629 header.set_cksum();
630 builder
631 .append_data(&mut header, "jdk/bin/java", &contents[..])
632 .unwrap();
633 builder.finish().unwrap();
634 drop(builder);
635 let checksum =
636 pipeline::verify::hash_file(&archive, crate::pipeline::HashAlgo::Sha256).unwrap();
637 let cached = pipeline::artifact_cache_path(
638 &ctx.dirs,
639 "java",
640 "jre-21.0.12+8",
641 "java-fixture.tar.gz",
642 )
643 .unwrap();
644 std::fs::create_dir_all(cached.parent().unwrap()).unwrap();
645 std::fs::copy(&archive, &cached).unwrap();
646 let mut version = ToolVersion::new("java", "jre-21.0.12+8");
647 version.options = BTreeMap::from([
648 ("package-type".into(), "jre".into()),
649 ("java-version".into(), "21.0.12+8".into()),
650 (
651 pipeline::LOCKED_ARTIFACT_URL_OPTION.into(),
652 "https://invalid.example/java-fixture.tar.gz".into(),
653 ),
654 (
655 pipeline::LOCKED_ARTIFACT_FILE_OPTION.into(),
656 "java-fixture.tar.gz".into(),
657 ),
658 (
659 pipeline::LOCKED_ARTIFACT_CHECKSUM_OPTION.into(),
660 format!("sha256:{checksum}"),
661 ),
662 ]);
663 JavaBackend
664 .install(&InstallCtx { ctx: &ctx }, &version)
665 .await
666 .unwrap();
667 assert!(ctx
668 .dirs
669 .install_path("java", "jre-21.0.12+8")
670 .join("bin/java")
671 .is_file());
672 }
673}