Skip to main content

dagger_sdk/core/
downloader.rs

1use std::{
2    fs::File,
3    io::{copy, Write},
4    os::unix::prelude::PermissionsExt,
5    path::{Path, PathBuf},
6};
7
8use eyre::Context;
9use flate2::read::GzDecoder;
10use platform_info::{PlatformInfoAPI, UNameAPI};
11use reqwest::StatusCode;
12use sha2::Digest;
13use tar::Archive;
14use tempfile::tempfile;
15use thiserror::Error;
16
17use crate::errors::DaggerError;
18
19#[allow(dead_code)]
20#[derive(Clone)]
21pub struct Platform {
22    pub os: String,
23    pub arch: String,
24}
25
26impl Platform {
27    pub fn from_system() -> Platform {
28        let platform = platform_info::PlatformInfo::new()
29            .expect("Unable to determine platform information, use `dagger run <app> instead`");
30        let os_name = platform.sysname().to_string_lossy().to_lowercase();
31        let arch = platform.machine().to_string_lossy().to_lowercase();
32        let normalize_arch = match arch.as_str() {
33            "x86_64" => "amd64",
34            "aarch" => "arm64",
35            "aarch64" => "arm64",
36            arch => arch,
37        };
38
39        Self {
40            os: os_name,
41            arch: normalize_arch.into(),
42        }
43    }
44}
45
46#[allow(dead_code)]
47pub struct TempFile {
48    prefix: String,
49    directory: PathBuf,
50    file: File,
51}
52
53#[allow(dead_code)]
54impl TempFile {
55    pub fn new(prefix: &str, directory: &Path) -> eyre::Result<Self> {
56        let prefix = prefix.to_string();
57
58        let file = tempfile()?;
59
60        Ok(Self {
61            prefix,
62            file,
63            directory: directory.to_path_buf(),
64        })
65    }
66}
67
68#[allow(dead_code)]
69pub type CliVersion = String;
70
71#[allow(dead_code)]
72pub struct Downloader {
73    version: CliVersion,
74    platform: Platform,
75    cli_base_url: String,
76}
77#[allow(dead_code)]
78const DEFAULT_CLI_HOST: &str = "dl.dagger.io";
79#[allow(dead_code)]
80const CLI_BIN_PREFIX: &str = "dagger-";
81#[allow(dead_code)]
82const CLI_BASE_URL: &str = "https://dl.dagger.io/dagger/releases";
83
84#[allow(dead_code)]
85impl Downloader {
86    pub fn new(version: CliVersion) -> Self {
87        Self {
88            version,
89            platform: Platform::from_system(),
90            cli_base_url: CLI_BASE_URL.into(),
91        }
92    }
93
94    pub fn archive_url(&self) -> String {
95        let ext = match self.platform.os.as_str() {
96            "windows" => "zip",
97            _ => "tar.gz",
98        };
99        let version = &self.version;
100        let os = &self.platform.os;
101        let arch = &self.platform.arch;
102
103        format!(
104            "{}/{version}/dagger_v{version}_{os}_{arch}.{ext}",
105            self.cli_base_url
106        )
107    }
108
109    pub fn checksum_url(&self) -> String {
110        let version = &self.version;
111
112        format!("{}/{version}/checksums.txt", self.cli_base_url)
113    }
114
115    pub fn cache_dir(&self) -> eyre::Result<PathBuf> {
116        let env = std::env::var("XDG_CACHE_HOME").unwrap_or("".into());
117        let env = env.trim();
118        let mut path = match env {
119            "" => dirs::cache_dir().ok_or(eyre::anyhow!(
120                "could not find cache_dir, either in env or XDG_CACHE_HOME"
121            ))?,
122            path => PathBuf::from(path),
123        };
124
125        path.push("dagger");
126
127        std::fs::create_dir_all(&path)?;
128
129        Ok(path)
130    }
131
132    pub async fn get_cli(&self) -> Result<PathBuf, DaggerError> {
133        let version = &self.version;
134        let mut cli_bin_path = self.cache_dir().map_err(DaggerError::DownloadClient)?;
135        cli_bin_path.push(format!("{CLI_BIN_PREFIX}{version}"));
136        if self.platform.os == "windows" {
137            cli_bin_path = cli_bin_path.with_extension("exe")
138        }
139
140        if !cli_bin_path.exists() {
141            cli_bin_path = self
142                .download(cli_bin_path)
143                .await
144                .context("failed to download CLI from archive")
145                .map_err(DaggerError::DownloadClient)?;
146        }
147
148        Ok(cli_bin_path)
149    }
150
151    async fn download(&self, path: PathBuf) -> eyre::Result<PathBuf> {
152        let expected_checksum = self.expected_checksum().await?;
153
154        let mut bytes = vec![];
155        let actual_hash = self.extract_cli_archive(&mut bytes).await?;
156
157        if expected_checksum != actual_hash {
158            eyre::bail!("downloaded CLI binary checksum: {actual_hash} doesn't match checksum from checksums.txt: {expected_checksum}")
159        }
160
161        let mut file = std::fs::File::create(&path)?;
162        let meta = file.metadata()?;
163        let mut perm = meta.permissions();
164        perm.set_mode(0o700);
165        file.set_permissions(perm)?;
166        file.write_all(bytes.as_slice())?;
167
168        Ok(path)
169    }
170
171    async fn expected_checksum(&self) -> eyre::Result<String> {
172        let archive_url = &self.archive_url();
173        let archive_path = PathBuf::from(&archive_url);
174        let archive_name = archive_path
175            .file_name()
176            .ok_or(eyre::anyhow!("could not get file_name from archive_url"))?;
177        let checksum_url = self.checksum_url();
178        let resp = reqwest::get(&checksum_url).await?;
179        let status = resp.status();
180        if is_cli_release_unavailable(status) {
181            return Err(CliReleaseUnavailableError {
182                url: checksum_url,
183                status,
184            }
185            .into());
186        }
187        let resp = resp.error_for_status()?;
188        for line in resp.text().await?.lines() {
189            let mut content = line.split_whitespace();
190            let checksum = content
191                .next()
192                .ok_or(eyre::anyhow!("could not find checksum in checksums.txt"))?;
193            let file_name = content
194                .next()
195                .ok_or(eyre::anyhow!("could not find file_name in checksums.txt"))?;
196
197            if file_name == archive_name {
198                return Ok(checksum.to_string());
199            }
200        }
201
202        eyre::bail!("could not find a matching version or binary in checksums.txt")
203    }
204
205    pub async fn extract_cli_archive(&self, dest: &mut Vec<u8>) -> eyre::Result<String> {
206        let archive_url = self.archive_url();
207        let resp = reqwest::get(&archive_url).await?;
208        let resp = resp.error_for_status()?;
209        let bytes = resp.bytes().await?;
210        let mut hasher = sha2::Sha256::new();
211        hasher.update(&bytes);
212        let res = hasher.finalize();
213
214        if archive_url.ends_with(".zip") {
215            // TODO:  Nothing for now
216            todo!()
217        } else {
218            self.extract_from_tar(&bytes, dest)?;
219        }
220
221        Ok(hex::encode(res))
222    }
223
224    fn extract_from_tar(&self, temp: &[u8], output: &mut Vec<u8>) -> eyre::Result<()> {
225        let decompressed_temp = GzDecoder::new(temp);
226        let mut archive = Archive::new(decompressed_temp);
227
228        for entry in archive.entries()? {
229            let mut entry = entry?;
230            let path = entry.path()?;
231
232            if path.ends_with("dagger") {
233                copy(&mut entry, output)?;
234
235                return Ok(());
236            }
237        }
238
239        eyre::bail!("could not find a matching file")
240    }
241}
242
243#[derive(Debug, Error)]
244#[error("CLI release unavailable: failed to download checksums from {url}: {status}")]
245pub(super) struct CliReleaseUnavailableError {
246    pub(super) url: String,
247    pub(super) status: StatusCode,
248}
249
250pub(super) fn has_cli_release_unavailable_error(error: &DaggerError) -> bool {
251    match error {
252        DaggerError::DownloadClient(error) => {
253            error.downcast_ref::<CliReleaseUnavailableError>().is_some()
254        }
255        _ => false,
256    }
257}
258
259fn is_cli_release_unavailable(status: StatusCode) -> bool {
260    // dl.dagger.io returns 403 for missing S3 objects.
261    status == StatusCode::FORBIDDEN || status == StatusCode::NOT_FOUND
262}
263
264#[cfg(test)]
265mod tests {
266    use reqwest::StatusCode;
267    use tokio::{
268        io::{AsyncReadExt, AsyncWriteExt},
269        net::TcpListener,
270    };
271
272    use crate::errors::DaggerError;
273
274    use super::{
275        has_cli_release_unavailable_error, CliReleaseUnavailableError, Downloader, Platform,
276    };
277
278    #[tokio::test]
279    async fn download() {
280        let cli_path = Downloader::new("0.3.10".into()).get_cli().await.unwrap();
281
282        assert_eq!(
283            Some("dagger-0.3.10"),
284            cli_path.file_name().and_then(|s| s.to_str())
285        )
286    }
287
288    #[tokio::test]
289    async fn checksum_marks_release_unavailable() {
290        for status in [StatusCode::FORBIDDEN, StatusCode::NOT_FOUND] {
291            let downloader = test_downloader(status_server(status).await);
292
293            let error = downloader.expected_checksum().await.unwrap_err();
294
295            assert!(error.downcast_ref::<CliReleaseUnavailableError>().is_some());
296        }
297    }
298
299    #[tokio::test]
300    async fn missing_archive_does_not_mark_release_unavailable() {
301        // Missing checksums mean the release is absent; a missing archive may be a
302        // partial or broken release, so it must remain fatal.
303        for status in [StatusCode::FORBIDDEN, StatusCode::NOT_FOUND] {
304            let downloader = test_downloader(status_server(status).await);
305
306            let error = downloader
307                .extract_cli_archive(&mut Vec::new())
308                .await
309                .unwrap_err();
310
311            assert!(error.downcast_ref::<CliReleaseUnavailableError>().is_none());
312        }
313    }
314
315    #[test]
316    fn detects_release_unavailable_through_download_context() {
317        let error = CliReleaseUnavailableError {
318            url: "https://example.test/checksums.txt".into(),
319            status: StatusCode::NOT_FOUND,
320        };
321        let error = eyre::Report::new(error).wrap_err("failed to download CLI from archive");
322        let error = DaggerError::DownloadClient(error);
323
324        assert!(has_cli_release_unavailable_error(&error));
325    }
326
327    fn test_downloader(cli_base_url: String) -> Downloader {
328        Downloader {
329            version: "unreleased".into(),
330            platform: Platform {
331                os: "linux".into(),
332                arch: "amd64".into(),
333            },
334            cli_base_url,
335        }
336    }
337
338    async fn status_server(status: StatusCode) -> String {
339        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
340        let address = listener.local_addr().unwrap();
341
342        tokio::spawn(async move {
343            let (mut stream, _) = listener.accept().await.unwrap();
344            let mut request = [0; 1024];
345            stream.read(&mut request).await.unwrap();
346            let response = format!(
347                "HTTP/1.1 {} {}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
348                status.as_u16(),
349                status.canonical_reason().unwrap()
350            );
351            stream.write_all(response.as_bytes()).await.unwrap();
352        });
353
354        format!("http://{address}")
355    }
356}