Skip to main content

osdk_core/backend/
jvm_tools.rs

1use std::path::PathBuf;
2
3use async_trait::async_trait;
4
5use crate::backend::{Backend, Ctx, InstallCtx};
6use crate::error::{Error, Result};
7use crate::pipeline::{self, ArchiveKind, Checksum, HashAlgo, InstallPlan, PipelineCtx};
8use crate::source::Source;
9use crate::version::{ToolVersion, VersionInfo};
10
11#[derive(Clone, Copy)]
12pub enum JvmToolBackend {
13    Maven,
14    Gradle,
15    Kotlin,
16}
17
18struct Release {
19    version: &'static str,
20    file: &'static str,
21    url: &'static str,
22    checksum: &'static str,
23    algorithm: HashAlgo,
24}
25
26impl JvmToolBackend {
27    fn release(self) -> Release {
28        match self {
29            Self::Maven => Release {
30                version: "3.9.16",
31                file: "apache-maven-3.9.16-bin.tar.gz",
32                url: "https://downloads.apache.org/maven/maven-3/3.9.16/binaries/apache-maven-3.9.16-bin.tar.gz",
33                checksum: "831a8591fe20c8243b1dbe7d71e3244f31d1665b0804b2e825e38cbbe5ce0cafb8338851f90780735568773e0a6cd07bbec107cda0b896b008b861075358b6f6",
34                algorithm: HashAlgo::Sha512,
35            },
36            Self::Gradle => Release {
37                version: "9.7.0",
38                file: "gradle-9.7.0-bin.zip",
39                url: "https://services.gradle.org/distributions/gradle-9.7.0-bin.zip",
40                checksum: "84fbba45c7f4c64abc77460e1c00f541e9f960e3c7ed2538f1ede19eacd873ae",
41                algorithm: HashAlgo::Sha256,
42            },
43            Self::Kotlin => Release {
44                version: "2.4.10",
45                file: "kotlin-compiler-2.4.10.zip",
46                url: "https://github.com/JetBrains/kotlin/releases/download/v2.4.10/kotlin-compiler-2.4.10.zip",
47                checksum: "473dd66c7a3ef4b182065b3da670466c1bf2773a9dbb0ed8b33a39fe9d4f876d",
48                algorithm: HashAlgo::Sha256,
49            },
50        }
51    }
52
53    fn bin_path(self) -> &'static str {
54        match self {
55            Self::Kotlin => "bin",
56            Self::Maven | Self::Gradle => "bin",
57        }
58    }
59}
60
61#[async_trait]
62impl Backend for JvmToolBackend {
63    fn id(&self) -> &str {
64        match self {
65            Self::Maven => "maven",
66            Self::Gradle => "gradle",
67            Self::Kotlin => "kotlin",
68        }
69    }
70
71    fn aliases(&self) -> &[&str] {
72        match self {
73            Self::Maven => &["mvn"],
74            Self::Gradle => &[],
75            Self::Kotlin => &["kotlinc"],
76        }
77    }
78
79    fn default_sources(&self) -> Vec<Source> {
80        let release = self.release();
81        let mut sources = vec![Source::official("official", release.url)];
82        if release.url.contains("github.com") {
83            sources.insert(
84                0,
85                Source::mirror(
86                    "ghproxy",
87                    &format!("https://gh-proxy.com/{}", release.url),
88                    10,
89                ),
90            );
91        }
92        sources
93    }
94
95    fn probe_url(&self, _ctx: &Ctx, source: &Source) -> Option<String> {
96        Some(source.download_url.clone())
97    }
98
99    async fn list_remote_versions(&self, _ctx: &Ctx) -> Result<Vec<VersionInfo>> {
100        Ok(vec![VersionInfo::stable(self.release().version)])
101    }
102
103    async fn install(&self, ictx: &InstallCtx<'_>, tv: &ToolVersion) -> Result<()> {
104        let ctx = ictx.ctx;
105        if tv.version != self.release().version {
106            return Err(Error::VersionResolve {
107                tool: self.id().into(),
108                spec: tv.version.clone(),
109                hint: Some("version is not in the built-in JVM tool catalog".into()),
110            });
111        }
112        if let Some(plan) = pipeline::locked_install_plan(self.id(), tv, true)? {
113            return run_plan(ctx, &plan).await;
114        }
115
116        let release = self.release();
117        let urls = self
118            .default_sources()
119            .into_iter()
120            .map(|source| source.download_url)
121            .collect();
122        let plan = InstallPlan {
123            tool: self.id().into(),
124            version: tv.version.clone(),
125            urls,
126            file_name: release.file.into(),
127            kind: ArchiveKind::from_name(release.file)?,
128            checksum: Some(Checksum {
129                algo: release.algorithm,
130                hex: release.checksum.into(),
131            }),
132            strip_root: true,
133            subdir: None,
134        };
135        run_plan(ctx, &plan).await
136    }
137
138    fn bin_paths(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Vec<PathBuf>> {
139        Ok(vec![ctx
140            .dirs
141            .install_path(self.id(), &tv.version)
142            .join(self.bin_path())])
143    }
144
145    fn bin_names(&self, _ctx: &Ctx, _tv: &ToolVersion) -> Result<Vec<String>> {
146        Ok(match self {
147            Self::Maven => vec!["mvn".into(), "mvnDebug".into()],
148            Self::Gradle => vec!["gradle".into()],
149            Self::Kotlin => vec![
150                "kotlin".into(),
151                "kotlinc".into(),
152                "kotlinc-js".into(),
153                "kotlinc-jvm".into(),
154            ],
155        })
156    }
157
158    fn idiomatic_files(&self) -> &[&str] {
159        match self {
160            Self::Maven => &[".mvn-version"],
161            Self::Gradle => &[".gradle-version"],
162            Self::Kotlin => &[".kotlin-version"],
163        }
164    }
165}
166
167async fn run_plan(ctx: &Ctx, plan: &InstallPlan) -> Result<()> {
168    let pipeline_ctx = PipelineCtx {
169        client: &ctx.client,
170        dirs: &ctx.dirs,
171        cas: &ctx.cas,
172        link_mode: ctx.config.settings.link_mode,
173        show_progress: ctx.show_progress,
174        offline: ctx.config.settings.offline,
175        require_checksums: true,
176    };
177    pipeline::run(plan, &pipeline_ctx).await?;
178    Ok(())
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[tokio::test]
186    async fn candidates_are_independent_and_checksums_are_required() {
187        let cases = [
188            (JvmToolBackend::Maven, "maven", "3.9.16", "mvn"),
189            (JvmToolBackend::Gradle, "gradle", "9.7.0", "gradle"),
190            (JvmToolBackend::Kotlin, "kotlin", "2.4.10", "kotlinc"),
191        ];
192        for (backend, id, version, binary) in cases {
193            assert_eq!(backend.id(), id);
194            assert_eq!(
195                backend.list_remote_versions(&dummy_ctx()).await.unwrap()[0].version,
196                version
197            );
198            assert!(backend
199                .bin_names(&dummy_ctx(), &ToolVersion::new(id, version))
200                .unwrap()
201                .contains(&binary.to_string()));
202            assert!(!backend.release().checksum.is_empty());
203        }
204    }
205
206    #[tokio::test]
207    async fn every_jvm_tool_installs_from_a_verified_offline_fixture() {
208        let temp = tempfile::tempdir().unwrap();
209        let mut ctx = dummy_ctx_at(temp.path());
210        ctx.config.settings.offline = true;
211        let cases = [
212            (JvmToolBackend::Maven, "mvn"),
213            (JvmToolBackend::Gradle, "gradle"),
214            (JvmToolBackend::Kotlin, "kotlinc"),
215        ];
216        for (backend, binary) in cases {
217            let release = backend.release();
218            let file_name = format!("{}-fixture.tar.gz", backend.id());
219            let archive = temp.path().join(&file_name);
220            write_fixture_archive(&archive, binary);
221            let checksum = pipeline::verify::hash_file(&archive, HashAlgo::Sha256).unwrap();
222            let cached =
223                pipeline::artifact_cache_path(&ctx.dirs, backend.id(), release.version, &file_name)
224                    .unwrap();
225            std::fs::create_dir_all(cached.parent().unwrap()).unwrap();
226            std::fs::copy(&archive, &cached).unwrap();
227            let mut version = ToolVersion::new(backend.id(), release.version);
228            version.options.extend(std::collections::BTreeMap::from([
229                (
230                    pipeline::LOCKED_ARTIFACT_URL_OPTION.into(),
231                    "https://invalid.example/fixture.tar.gz".into(),
232                ),
233                (pipeline::LOCKED_ARTIFACT_FILE_OPTION.into(), file_name),
234                (
235                    pipeline::LOCKED_ARTIFACT_CHECKSUM_OPTION.into(),
236                    format!("sha256:{checksum}"),
237                ),
238            ]));
239            backend
240                .install(&InstallCtx { ctx: &ctx }, &version)
241                .await
242                .unwrap();
243            assert!(ctx
244                .dirs
245                .install_path(backend.id(), release.version)
246                .join("bin")
247                .join(binary)
248                .is_file());
249        }
250    }
251
252    fn write_fixture_archive(path: &std::path::Path, binary: &str) {
253        let file = std::fs::File::create(path).unwrap();
254        let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::fast());
255        let mut archive = tar::Builder::new(encoder);
256        let contents = b"#!/bin/sh\nexit 0\n";
257        let mut header = tar::Header::new_gnu();
258        header.set_size(contents.len() as u64);
259        header.set_mode(0o755);
260        header.set_cksum();
261        archive
262            .append_data(&mut header, format!("fixture/bin/{binary}"), &contents[..])
263            .unwrap();
264        archive.finish().unwrap();
265    }
266
267    fn dummy_ctx() -> Ctx {
268        let temp = tempfile::tempdir().unwrap().keep();
269        dummy_ctx_at(&temp)
270    }
271
272    fn dummy_ctx_at(temp: &std::path::Path) -> Ctx {
273        let dirs = crate::dirs::Dirs::resolve_from(|key| match key {
274            "OSDK_DATA_DIR" => Some(temp.join("data").display().to_string()),
275            "OSDK_CACHE_DIR" => Some(temp.join("cache").display().to_string()),
276            "OSDK_CONFIG_DIR" => Some(temp.join("config").display().to_string()),
277            _ => None,
278        })
279        .unwrap();
280        dirs.ensure().unwrap();
281        Ctx {
282            cas: std::sync::Arc::new(crate::store::Cas::new(dirs.store.clone())),
283            dirs,
284            platform: crate::platform::Platform::current(),
285            config: crate::config::Config {
286                settings: Default::default(),
287                sources: Default::default(),
288                tools: Default::default(),
289                tool_configs: Default::default(),
290                global_tools: Default::default(),
291                global_tool_configs: Default::default(),
292                tool_origins: Default::default(),
293                aliases: Default::default(),
294                project_config_path: None,
295            },
296            client: reqwest::Client::new(),
297            show_progress: false,
298        }
299    }
300}