1use crate::lockfile::ToolEntry;
5use crate::registry::{self, ArtifactFormat};
6use arcbox_asset::download::{download_and_verify, sha256_file};
7use arcbox_asset::{PreparePhase, PrepareProgress, ProgressCallback};
8use std::path::{Path, PathBuf};
9use tracing::info;
10
11pub struct DockerToolManager {
13 arch: String,
15 install_dir: PathBuf,
17 tools: Vec<ToolEntry>,
19 bundle_dir: Option<PathBuf>,
22}
23
24impl DockerToolManager {
25 #[must_use]
27 pub fn new(tools: Vec<ToolEntry>, arch: impl Into<String>, install_dir: PathBuf) -> Self {
28 Self {
29 arch: arch.into(),
30 install_dir,
31 tools,
32 bundle_dir: None,
33 }
34 }
35
36 #[must_use]
41 pub fn with_bundle_dir(mut self, dir: PathBuf) -> Self {
42 self.bundle_dir = Some(dir);
43 self
44 }
45
46 pub async fn install_all(
50 &self,
51 progress: Option<&ProgressCallback>,
52 ) -> Result<(), DockerToolError> {
53 tokio::fs::create_dir_all(&self.install_dir)
54 .await
55 .map_err(DockerToolError::Io)?;
56
57 let total = self.tools.len();
58 for (idx, tool) in self.tools.iter().enumerate() {
59 self.install_one(tool, idx + 1, total, progress).await?;
60 }
61
62 Ok(())
63 }
64
65 async fn install_one(
67 &self,
68 tool: &ToolEntry,
69 current: usize,
70 total: usize,
71 progress: Option<&ProgressCallback>,
72 ) -> Result<(), DockerToolError> {
73 let expected_sha =
74 tool.sha256_for_arch(&self.arch)
75 .ok_or_else(|| DockerToolError::UnsupportedArch {
76 tool: tool.name.clone(),
77 arch: self.arch.clone(),
78 })?;
79
80 let dest = self.install_dir.join(&tool.name);
81 let format = registry::artifact_format(&tool.name);
82
83 let pg = |phase: PreparePhase| {
84 if let Some(cb) = progress {
85 cb(PrepareProgress {
86 name: tool.name.clone(),
87 current,
88 total,
89 phase,
90 });
91 }
92 };
93
94 pg(PreparePhase::Checking);
95
96 if dest.exists() && self.is_cached(&tool.name, expected_sha, format).await {
98 pg(PreparePhase::Cached);
99 info!(tool = %tool.name, "already installed, checksum matches");
100 return Ok(());
101 }
102
103 if self
105 .try_install_from_bundle(tool, &dest, expected_sha, format)
106 .await?
107 {
108 mark_executable(&dest).await?;
109 write_sidecar(&self.install_dir, &tool.name, expected_sha).await?;
110 pg(PreparePhase::Ready);
111 info!(tool = %tool.name, version = %tool.version, "installed from bundle");
112 return Ok(());
113 }
114
115 let url = registry::download_url(&tool.name, &tool.version, &self.arch);
116
117 match format {
118 ArtifactFormat::Binary => {
119 download_and_verify(&url, &dest, expected_sha, &tool.name, |dl, tot| {
121 pg(PreparePhase::Downloading {
122 downloaded: dl,
123 total: tot,
124 });
125 })
126 .await
127 .map_err(DockerToolError::Asset)?;
128 }
129 ArtifactFormat::Tgz => {
130 let tgz_path = self.install_dir.join(format!("{}.tgz", tool.name));
132 download_and_verify(&url, &tgz_path, expected_sha, &tool.name, |dl, tot| {
133 pg(PreparePhase::Downloading {
134 downloaded: dl,
135 total: tot,
136 });
137 })
138 .await
139 .map_err(DockerToolError::Asset)?;
140
141 pg(PreparePhase::Verifying);
142 extract_from_tgz(&tgz_path, registry::tgz_inner_path(&tool.name), &dest)?;
143 let _ = tokio::fs::remove_file(&tgz_path).await;
144 }
145 }
146
147 mark_executable(&dest).await?;
148 write_sidecar(&self.install_dir, &tool.name, expected_sha).await?;
149
150 pg(PreparePhase::Ready);
151 info!(tool = %tool.name, version = %tool.version, "installed");
152 Ok(())
153 }
154
155 async fn is_cached(&self, name: &str, expected_sha: &str, format: ArtifactFormat) -> bool {
162 match format {
163 ArtifactFormat::Binary => {
164 let path = self.install_dir.join(name);
165 sha256_file(&path)
166 .await
167 .map(|actual| actual == expected_sha)
168 .unwrap_or(false)
169 }
170 ArtifactFormat::Tgz => {
171 let sidecar = self.install_dir.join(format!("{name}.sha256"));
172 tokio::fs::read_to_string(&sidecar)
173 .await
174 .map(|content| content.trim() == expected_sha)
175 .unwrap_or(false)
176 }
177 }
178 }
179
180 async fn try_install_from_bundle(
186 &self,
187 tool: &ToolEntry,
188 dest: &Path,
189 expected_sha: &str,
190 format: ArtifactFormat,
191 ) -> Result<bool, DockerToolError> {
192 let bundle_dir = match &self.bundle_dir {
193 Some(dir) => dir,
194 None => return Ok(false),
195 };
196
197 let src = bundle_dir.join(&tool.name);
198 if !src.exists() {
199 return Ok(false);
200 }
201
202 let tmp =
206 tempfile::NamedTempFile::new_in(&self.install_dir).map_err(DockerToolError::Io)?;
207 {
208 use tokio::io::AsyncWriteExt;
209 let src_bytes = tokio::fs::read(&src).await;
210 match src_bytes {
211 Ok(bytes) => {
212 let std_file = tmp.as_file().try_clone().map_err(DockerToolError::Io)?;
213 let mut async_file = tokio::fs::File::from_std(std_file);
214 if let Err(e) = async_file.write_all(&bytes).await {
215 let _ = tmp.close();
216 info!(tool = %tool.name, error = %e, "bundle copy failed, will download");
217 return Ok(false);
218 }
219 async_file.flush().await.map_err(DockerToolError::Io)?;
220 }
221 Err(e) => {
222 let _ = tmp.close();
223 info!(tool = %tool.name, error = %e, "bundle read failed, will download");
224 return Ok(false);
225 }
226 }
227 }
228 let tmp_path = tmp.into_temp_path();
229
230 match format {
232 ArtifactFormat::Binary => {
233 match sha256_file(&tmp_path).await {
235 Ok(actual) if actual == expected_sha => {}
236 Ok(_) => {
237 let _ = tmp_path.close();
238 info!(tool = %tool.name, "bundle checksum mismatch, will download");
239 return Ok(false);
240 }
241 Err(_) => {
242 let _ = tmp_path.close();
243 info!(tool = %tool.name, "bundle checksum read failed, will download");
244 return Ok(false);
245 }
246 }
247 }
248 ArtifactFormat::Tgz => {
249 let checksum_path = bundle_dir.join(format!("{}.sha256", tool.name));
253 match tokio::fs::read_to_string(&checksum_path).await {
254 Ok(contents) if contents.trim() == expected_sha => {}
255 Ok(_) => {
256 let _ = tmp_path.close();
257 info!(tool = %tool.name, "bundle archive checksum mismatch, will download");
258 return Ok(false);
259 }
260 Err(_) => {
261 let _ = tmp_path.close();
262 info!(tool = %tool.name, "bundle checksum file missing, will download");
263 return Ok(false);
264 }
265 }
266 }
267 }
268
269 if let Err(e) = tmp_path.persist(dest) {
271 info!(tool = %tool.name, error = %e, "bundle persist failed, will download");
272 return Ok(false);
273 }
274
275 Ok(true)
276 }
277
278 pub async fn validate_all(&self) -> Result<(), DockerToolError> {
280 for tool in &self.tools {
281 let expected_sha = tool.sha256_for_arch(&self.arch).ok_or_else(|| {
282 DockerToolError::UnsupportedArch {
283 tool: tool.name.clone(),
284 arch: self.arch.clone(),
285 }
286 })?;
287
288 let path = self.install_dir.join(&tool.name);
289 if !path.exists() {
290 return Err(DockerToolError::NotInstalled(tool.name.clone()));
291 }
292
293 let format = registry::artifact_format(&tool.name);
294 match format {
295 ArtifactFormat::Binary => {
296 let actual = sha256_file(&path).await.map_err(DockerToolError::Asset)?;
297 if actual != expected_sha {
298 return Err(DockerToolError::Asset(
299 arcbox_asset::AssetError::ChecksumMismatch {
300 name: tool.name.clone(),
301 expected: expected_sha.to_string(),
302 actual,
303 },
304 ));
305 }
306 }
307 ArtifactFormat::Tgz => {
308 let sidecar = self.install_dir.join(format!("{}.sha256", tool.name));
309 let content = match tokio::fs::read_to_string(&sidecar).await {
310 Ok(content) => content,
311 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
312 return Err(DockerToolError::NotInstalled(tool.name.clone()));
316 }
317 Err(e) => return Err(DockerToolError::Io(e)),
318 };
319 if content.trim() != expected_sha {
320 return Err(DockerToolError::Asset(
321 arcbox_asset::AssetError::ChecksumMismatch {
322 name: tool.name.clone(),
323 expected: expected_sha.to_string(),
324 actual: content.trim().to_string(),
325 },
326 ));
327 }
328 }
329 }
330 }
331 Ok(())
332 }
333
334 #[must_use]
336 pub fn install_dir(&self) -> &Path {
337 &self.install_dir
338 }
339
340 #[must_use]
342 pub fn tools(&self) -> &[ToolEntry] {
343 &self.tools
344 }
345}
346
347#[cfg(unix)]
353async fn mark_executable(path: &Path) -> Result<(), DockerToolError> {
354 use std::os::unix::fs::PermissionsExt;
355 let mut perms = tokio::fs::metadata(path)
356 .await
357 .map_err(DockerToolError::Io)?
358 .permissions();
359 perms.set_mode(0o755);
360 tokio::fs::set_permissions(path, perms)
361 .await
362 .map_err(DockerToolError::Io)?;
363 Ok(())
364}
365
366#[cfg(not(unix))]
367async fn mark_executable(_path: &Path) -> Result<(), DockerToolError> {
368 Ok(())
369}
370
371async fn write_sidecar(
375 install_dir: &Path,
376 name: &str,
377 expected_sha: &str,
378) -> Result<(), DockerToolError> {
379 let sidecar = install_dir.join(format!("{name}.sha256"));
380 tokio::fs::write(&sidecar, expected_sha)
381 .await
382 .map_err(DockerToolError::Io)?;
383 Ok(())
384}
385
386fn extract_from_tgz(
388 archive_path: &Path,
389 inner_path: &str,
390 dest: &Path,
391) -> Result<(), DockerToolError> {
392 let file = std::fs::File::open(archive_path).map_err(DockerToolError::Io)?;
393 let gz = flate2::read::GzDecoder::new(file);
394 let mut archive = tar::Archive::new(gz);
395
396 for entry in archive.entries().map_err(DockerToolError::Io)? {
397 let mut entry = entry.map_err(DockerToolError::Io)?;
398 let path = entry.path().map_err(DockerToolError::Io)?;
399 if path.to_string_lossy() == inner_path {
400 entry.unpack(dest).map_err(DockerToolError::Io)?;
401 return Ok(());
402 }
403 }
404
405 Err(DockerToolError::ExtractFailed {
406 archive: archive_path.display().to_string(),
407 inner: inner_path.to_string(),
408 })
409}
410
411#[derive(Debug, thiserror::Error)]
413pub enum DockerToolError {
414 #[error("asset error: {0}")]
415 Asset(#[from] arcbox_asset::AssetError),
416
417 #[error("io error: {0}")]
418 Io(#[from] std::io::Error),
419
420 #[error("tool '{tool}' has no binary for architecture '{arch}'")]
421 UnsupportedArch { tool: String, arch: String },
422
423 #[error("tool '{0}' is not installed")]
424 NotInstalled(String),
425
426 #[error("failed to extract '{inner}' from archive '{archive}'")]
427 ExtractFailed { archive: String, inner: String },
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 fn make_tool(name: &str, sha: &str) -> ToolEntry {
435 use crate::lockfile::ArchEntry;
436 ToolEntry {
437 name: name.to_string(),
438 version: "1.0.0".to_string(),
439 arch: std::collections::HashMap::from([(
440 "arm64".to_string(),
441 ArchEntry {
442 sha256: sha.to_string(),
443 },
444 )]),
445 }
446 }
447
448 #[tokio::test]
451 async fn is_cached_binary_hit() {
452 let dir = tempfile::tempdir().unwrap();
453 let content = b"hello binary";
454 let sha = sha256_bytes(content);
455 tokio::fs::write(dir.path().join("my-tool"), content)
456 .await
457 .unwrap();
458
459 let mgr = DockerToolManager::new(vec![], "arm64", dir.path().to_path_buf());
460 assert!(mgr.is_cached("my-tool", &sha, ArtifactFormat::Binary).await);
461 }
462
463 #[tokio::test]
464 async fn is_cached_binary_miss() {
465 let dir = tempfile::tempdir().unwrap();
466 tokio::fs::write(dir.path().join("my-tool"), b"old version")
467 .await
468 .unwrap();
469
470 let mgr = DockerToolManager::new(vec![], "arm64", dir.path().to_path_buf());
471 assert!(
472 !mgr.is_cached("my-tool", "wrong-sha", ArtifactFormat::Binary)
473 .await
474 );
475 }
476
477 #[tokio::test]
478 async fn is_cached_tgz_sidecar_hit() {
479 let dir = tempfile::tempdir().unwrap();
480 tokio::fs::write(dir.path().join("docker"), b"binary")
481 .await
482 .unwrap();
483 tokio::fs::write(dir.path().join("docker.sha256"), "abc123")
484 .await
485 .unwrap();
486
487 let mgr = DockerToolManager::new(vec![], "arm64", dir.path().to_path_buf());
488 assert!(mgr.is_cached("docker", "abc123", ArtifactFormat::Tgz).await);
489 }
490
491 #[tokio::test]
492 async fn is_cached_tgz_sidecar_miss() {
493 let dir = tempfile::tempdir().unwrap();
494 tokio::fs::write(dir.path().join("docker"), b"binary")
495 .await
496 .unwrap();
497 tokio::fs::write(dir.path().join("docker.sha256"), "old-sha")
498 .await
499 .unwrap();
500
501 let mgr = DockerToolManager::new(vec![], "arm64", dir.path().to_path_buf());
502 assert!(
503 !mgr.is_cached("docker", "new-sha", ArtifactFormat::Tgz)
504 .await
505 );
506 }
507
508 #[tokio::test]
509 async fn is_cached_tgz_no_sidecar() {
510 let dir = tempfile::tempdir().unwrap();
511 tokio::fs::write(dir.path().join("docker"), b"binary")
512 .await
513 .unwrap();
514
515 let mgr = DockerToolManager::new(vec![], "arm64", dir.path().to_path_buf());
516 assert!(
517 !mgr.is_cached("docker", "any-sha", ArtifactFormat::Tgz)
518 .await
519 );
520 }
521
522 #[tokio::test]
525 async fn bundle_install_binary_ok() {
526 let install_dir = tempfile::tempdir().unwrap();
527 let bundle_dir = tempfile::tempdir().unwrap();
528 let content = b"good binary";
529 let sha = sha256_bytes(content);
530
531 tokio::fs::write(bundle_dir.path().join("my-tool"), content)
532 .await
533 .unwrap();
534
535 let tool = make_tool("my-tool", &sha);
536 let dest = install_dir.path().join("my-tool");
537
538 let mgr = DockerToolManager::new(vec![], "arm64", install_dir.path().to_path_buf())
539 .with_bundle_dir(bundle_dir.path().to_path_buf());
540
541 let ok = mgr
542 .try_install_from_bundle(&tool, &dest, &sha, ArtifactFormat::Binary)
543 .await
544 .unwrap();
545 assert!(ok);
546 assert!(dest.exists());
547 }
548
549 #[tokio::test]
550 async fn bundle_install_binary_checksum_mismatch() {
551 let install_dir = tempfile::tempdir().unwrap();
552 let bundle_dir = tempfile::tempdir().unwrap();
553
554 tokio::fs::write(bundle_dir.path().join("my-tool"), b"bad binary")
555 .await
556 .unwrap();
557
558 let tool = make_tool("my-tool", "wrong-sha");
559 let dest = install_dir.path().join("my-tool");
560
561 let mgr = DockerToolManager::new(vec![], "arm64", install_dir.path().to_path_buf())
562 .with_bundle_dir(bundle_dir.path().to_path_buf());
563
564 let ok = mgr
565 .try_install_from_bundle(&tool, &dest, "wrong-sha", ArtifactFormat::Binary)
566 .await
567 .unwrap();
568 assert!(!ok);
569 assert!(!dest.exists());
570 }
571
572 #[tokio::test]
573 async fn bundle_install_tgz_requires_checksum_file() {
574 let install_dir = tempfile::tempdir().unwrap();
575 let bundle_dir = tempfile::tempdir().unwrap();
576
577 tokio::fs::write(bundle_dir.path().join("docker"), b"docker binary")
578 .await
579 .unwrap();
580 let tool = make_tool("docker", "archive-sha");
583 let dest = install_dir.path().join("docker");
584
585 let mgr = DockerToolManager::new(vec![], "arm64", install_dir.path().to_path_buf())
586 .with_bundle_dir(bundle_dir.path().to_path_buf());
587
588 let ok = mgr
589 .try_install_from_bundle(&tool, &dest, "archive-sha", ArtifactFormat::Tgz)
590 .await
591 .unwrap();
592 assert!(!ok);
593 }
594
595 #[tokio::test]
596 async fn bundle_install_tgz_with_checksum_file() {
597 let install_dir = tempfile::tempdir().unwrap();
598 let bundle_dir = tempfile::tempdir().unwrap();
599
600 tokio::fs::write(bundle_dir.path().join("docker"), b"docker binary")
601 .await
602 .unwrap();
603 tokio::fs::write(bundle_dir.path().join("docker.sha256"), "archive-sha")
604 .await
605 .unwrap();
606
607 let tool = make_tool("docker", "archive-sha");
608 let dest = install_dir.path().join("docker");
609
610 let mgr = DockerToolManager::new(vec![], "arm64", install_dir.path().to_path_buf())
611 .with_bundle_dir(bundle_dir.path().to_path_buf());
612
613 let ok = mgr
614 .try_install_from_bundle(&tool, &dest, "archive-sha", ArtifactFormat::Tgz)
615 .await
616 .unwrap();
617 assert!(ok);
618 assert!(dest.exists());
619 }
620
621 #[tokio::test]
622 async fn bundle_install_no_bundle_dir() {
623 let install_dir = tempfile::tempdir().unwrap();
624 let tool = make_tool("my-tool", "sha");
625 let dest = install_dir.path().join("my-tool");
626
627 let mgr = DockerToolManager::new(vec![], "arm64", install_dir.path().to_path_buf());
628 let ok = mgr
629 .try_install_from_bundle(&tool, &dest, "sha", ArtifactFormat::Binary)
630 .await
631 .unwrap();
632 assert!(!ok);
633 }
634
635 fn sha256_bytes(data: &[u8]) -> String {
637 use sha2::{Digest, Sha256};
638 let mut hasher = Sha256::new();
639 hasher.update(data);
640 format!("{:x}", hasher.finalize())
641 }
642}