Skip to main content

arcbox_docker_tools/
manager.rs

1//! Docker tool manager — download, extract, install, and validate Docker CLI
2//! binaries from the versions pinned in `assets.lock`.
3
4use 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
11/// Manages Docker CLI tool installation.
12pub struct DockerToolManager {
13    /// Architecture string (e.g. "arm64", "x86_64").
14    arch: String,
15    /// Directory for downloaded artifacts (e.g. `~/.arcbox/runtime/bin/`).
16    install_dir: PathBuf,
17    /// Tool entries parsed from `assets.lock`.
18    tools: Vec<ToolEntry>,
19    /// Optional directory containing pre-built binaries from an app bundle
20    /// (e.g. `Contents/MacOS/xbin/`).
21    bundle_dir: Option<PathBuf>,
22}
23
24impl DockerToolManager {
25    /// Create a new manager from parsed tool entries.
26    #[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    /// Set an app-bundle directory containing pre-built binaries.
37    ///
38    /// When set, `install_one` will try to copy from this directory before
39    /// falling back to a CDN download.
40    #[must_use]
41    pub fn with_bundle_dir(mut self, dir: PathBuf) -> Self {
42        self.bundle_dir = Some(dir);
43        self
44    }
45
46    /// Install all configured Docker tools.
47    ///
48    /// For each tool: check cache → try bundle → download → verify → extract → chmod.
49    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    /// Install a single tool.
66    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        // Check cache: if the binary exists and the checksum/sidecar matches, skip.
97        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        // Try to install from app bundle before downloading from CDN.
104        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                // Direct binary download — verified by arcbox-asset.
120                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                // Download tgz to temp, verify checksum, then extract the binary.
131                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    /// Check whether the installed binary is up-to-date.
156    ///
157    /// For `Binary` artifacts the SHA-256 of the file on disk is compared
158    /// directly against `expected_sha`.  For `Tgz` artifacts (e.g. `docker`)
159    /// the expected hash refers to the *archive*, not the extracted binary, so
160    /// we store/read a sidecar `{name}.sha256` file instead.
161    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    /// Try to install a tool from the app bundle directory.
181    ///
182    /// Returns `true` if the tool was successfully installed from the bundle.
183    /// Uses a randomized temp file to prevent symlink attacks, then atomically
184    /// renames into `dest`.
185    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        // Create a secure temp file in the install dir (randomized name,
203        // O_CREAT|O_EXCL). Copy into the already-open handle to avoid TOCTOU
204        // symlink races on the temp path.
205        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        // Verify the bundled binary.
231        match format {
232            ArtifactFormat::Binary => {
233                // SHA-256 in assets.lock is for the binary itself.
234                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                // SHA-256 in assets.lock is for the archive, not the extracted
250                // binary. Require a bundle-provided `{name}.sha256` file whose
251                // contents match `expected_sha` to confirm the binary version.
252                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        // Atomic persist into final destination.
270        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    /// Validate that all tools are installed and checksums match.
279    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                            // Missing sidecar means an older install that didn't
313                            // create one — treat as not installed so callers can
314                            // trigger a reinstall.
315                            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    /// Returns the install directory.
335    #[must_use]
336    pub fn install_dir(&self) -> &Path {
337        &self.install_dir
338    }
339
340    /// Returns the list of tool entries.
341    #[must_use]
342    pub fn tools(&self) -> &[ToolEntry] {
343        &self.tools
344    }
345}
346
347// =============================================================================
348// Helpers
349// =============================================================================
350
351/// Mark a file as executable (0o755).
352#[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
371/// Write a sidecar `{name}.sha256` file recording the expected checksum from
372/// `assets.lock`.  Used for tgz-based tools where the on-disk binary cannot be
373/// compared against the archive checksum directly.
374async 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
386/// Extract a single file from a `.tgz` archive.
387fn 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/// Errors from Docker tool operations.
412#[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    // -- is_cached tests --
449
450    #[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    // -- try_install_from_bundle tests --
523
524    #[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        // No docker.sha256 in bundle → should fail.
581
582        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    /// Compute SHA-256 hex of in-memory bytes (test helper).
636    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}