dagger_sdk/core/
downloader.rs1use 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!()
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 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 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}