Skip to main content

osdk_core/backend/
java.rs

1//! Java backend: discovers JDKs across vendors via the Foojay Disco API and
2//! downloads the vendor archive. Default distribution is Temurin.
3
4use 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)] // parsed from the API; retained for schema clarity
46    archive_type: String,
47    #[serde(default)]
48    links: PackageLinks,
49    #[serde(default)]
50    #[allow(dead_code)] // parsed from the API; retained for schema clarity
51    distribution: String,
52    // Present on the /ids/<id> detail response, not the /packages list.
53    #[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    /// The distribution to use, from request options or the default.
96    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        // A tiny metadata endpoint for probing.
169        Some("https://api.foojay.io/disco/v3.0/distributions".to_string())
170    }
171
172    /// java version specs can carry a distribution prefix like `temurin-21`.
173    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        // Query packages for this distribution and select by the spec.
191        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        // Query the exact package for this version.
250        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        // Resolve the foojay redirect to the real vendor URL so we can add a
289        // gh-proxy fallback for GitHub-hosted assets (Temurin etc.) in CN.
290        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                    // Prefer a CN proxy first, then the direct GitHub URL.
296                    urls.push(format!("https://gh-proxy.com/{real}"));
297                    urls.push(real);
298                } else {
299                    urls.push(real);
300                }
301            }
302        }
303        // Always keep the foojay redirect itself as a final fallback.
304        urls.push(redirect);
305
306        // Fetch the per-id detail to get the vendor-published sha256 checksum.
307        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, // JDK archives wrap in jdk-<ver>/
317            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        // macOS JDK bundles nest under Contents/Home.
335        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    /// Fetch the vendor-published sha256 for a package id from the foojay
445    /// `/ids/<id>` detail endpoint. Best-effort: returns None on any failure so
446    /// installs still proceed (download failover/extraction remain the guard).
447    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        // base_index is ".../disco/v3.0/packages"; the ids endpoint is a sibling.
457        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        // foojay currently publishes sha256; guard in case that changes.
466        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
477/// Resolve a redirect URL to its final `Location` without downloading the body.
478/// Uses a one-off client with redirects disabled so we can read the header.
479async 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    // Not a redirect (some mirrors serve directly); use the final URL.
492    Ok(resp.url().to_string())
493}
494
495/// Split a java spec like `temurin-21` or `zulu-17.0.1` into (distribution,
496/// version-spec). Plain `21`/`lts`/`latest` use the default distribution.
497fn 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}