Skip to main content

arcbox_docker_tools/
manager.rs

1//! Host tool manager — download, extract, install, and validate ArcBox-managed
2//! host 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 ArcBox host tool installation.
12pub struct HostToolManager {
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 HostToolManager {
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<(), HostToolError> {
53        tokio::fs::create_dir_all(&self.install_dir)
54            .await
55            .map_err(HostToolError::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<(), HostToolError> {
73        let expected_sha =
74            tool.sha256_for_arch(&self.arch)
75                .ok_or_else(|| HostToolError::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(HostToolError::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(HostToolError::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                    .is_ok_and(|actual| actual == expected_sha)
168            }
169            ArtifactFormat::Tgz => {
170                let sidecar = self.install_dir.join(format!("{name}.sha256"));
171                tokio::fs::read_to_string(&sidecar)
172                    .await
173                    .is_ok_and(|content| content.trim() == expected_sha)
174            }
175        }
176    }
177
178    /// Try to install a tool from the app bundle directory.
179    ///
180    /// Returns `true` if the tool was successfully installed from the bundle.
181    /// Uses a randomized temp file to prevent symlink attacks, then atomically
182    /// renames into `dest`.
183    async fn try_install_from_bundle(
184        &self,
185        tool: &ToolEntry,
186        dest: &Path,
187        expected_sha: &str,
188        format: ArtifactFormat,
189    ) -> Result<bool, HostToolError> {
190        let bundle_dir = match &self.bundle_dir {
191            Some(dir) => dir,
192            None => return Ok(false),
193        };
194
195        let src = bundle_dir.join(&tool.name);
196        if !src.exists() {
197            return Ok(false);
198        }
199
200        // Create a secure temp file in the install dir (randomized name,
201        // O_CREAT|O_EXCL). Copy into the already-open handle to avoid TOCTOU
202        // symlink races on the temp path.
203        let tmp = tempfile::NamedTempFile::new_in(&self.install_dir).map_err(HostToolError::Io)?;
204        {
205            use tokio::io::AsyncWriteExt;
206            let src_bytes = tokio::fs::read(&src).await;
207            match src_bytes {
208                Ok(bytes) => {
209                    let std_file = tmp.as_file().try_clone().map_err(HostToolError::Io)?;
210                    let mut async_file = tokio::fs::File::from_std(std_file);
211                    if let Err(e) = async_file.write_all(&bytes).await {
212                        let _ = tmp.close();
213                        info!(tool = %tool.name, error = %e, "bundle copy failed, will download");
214                        return Ok(false);
215                    }
216                    async_file.flush().await.map_err(HostToolError::Io)?;
217                }
218                Err(e) => {
219                    let _ = tmp.close();
220                    info!(tool = %tool.name, error = %e, "bundle read failed, will download");
221                    return Ok(false);
222                }
223            }
224        }
225        let tmp_path = tmp.into_temp_path();
226
227        // Verify the bundled binary.
228        match format {
229            ArtifactFormat::Binary => {
230                // SHA-256 in assets.lock is for the binary itself.
231                match sha256_file(&tmp_path).await {
232                    Ok(actual) if actual == expected_sha => {}
233                    Ok(_) => {
234                        let _ = tmp_path.close();
235                        info!(tool = %tool.name, "bundle checksum mismatch, will download");
236                        return Ok(false);
237                    }
238                    Err(_) => {
239                        let _ = tmp_path.close();
240                        info!(tool = %tool.name, "bundle checksum read failed, will download");
241                        return Ok(false);
242                    }
243                }
244            }
245            ArtifactFormat::Tgz => {
246                // SHA-256 in assets.lock is for the archive, not the extracted
247                // binary. Require a bundle-provided `{name}.sha256` file whose
248                // contents match `expected_sha` to confirm the binary version.
249                let checksum_path = bundle_dir.join(format!("{}.sha256", tool.name));
250                match tokio::fs::read_to_string(&checksum_path).await {
251                    Ok(contents) if contents.trim() == expected_sha => {}
252                    Ok(_) => {
253                        let _ = tmp_path.close();
254                        info!(tool = %tool.name, "bundle archive checksum mismatch, will download");
255                        return Ok(false);
256                    }
257                    Err(_) => {
258                        let _ = tmp_path.close();
259                        info!(tool = %tool.name, "bundle checksum file missing, will download");
260                        return Ok(false);
261                    }
262                }
263            }
264        }
265
266        // Atomic persist into final destination.
267        if let Err(e) = tmp_path.persist(dest) {
268            info!(tool = %tool.name, error = %e, "bundle persist failed, will download");
269            return Ok(false);
270        }
271
272        Ok(true)
273    }
274
275    /// Validate that all tools are installed and checksums match.
276    pub async fn validate_all(&self) -> Result<(), HostToolError> {
277        for tool in &self.tools {
278            let expected_sha =
279                tool.sha256_for_arch(&self.arch)
280                    .ok_or_else(|| HostToolError::UnsupportedArch {
281                        tool: tool.name.clone(),
282                        arch: self.arch.clone(),
283                    })?;
284
285            let path = self.install_dir.join(&tool.name);
286            if !path.exists() {
287                return Err(HostToolError::NotInstalled(tool.name.clone()));
288            }
289
290            let format = registry::artifact_format(&tool.name);
291            match format {
292                ArtifactFormat::Binary => {
293                    let actual = sha256_file(&path).await.map_err(HostToolError::Asset)?;
294                    if actual != expected_sha {
295                        return Err(HostToolError::Asset(
296                            arcbox_asset::AssetError::ChecksumMismatch {
297                                name: tool.name.clone(),
298                                expected: expected_sha.to_string(),
299                                actual,
300                            },
301                        ));
302                    }
303                }
304                ArtifactFormat::Tgz => {
305                    let sidecar = self.install_dir.join(format!("{}.sha256", tool.name));
306                    let content = match tokio::fs::read_to_string(&sidecar).await {
307                        Ok(content) => content,
308                        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
309                            // Missing sidecar means an older install that didn't
310                            // create one — treat as not installed so callers can
311                            // trigger a reinstall.
312                            return Err(HostToolError::NotInstalled(tool.name.clone()));
313                        }
314                        Err(e) => return Err(HostToolError::Io(e)),
315                    };
316                    if content.trim() != expected_sha {
317                        return Err(HostToolError::Asset(
318                            arcbox_asset::AssetError::ChecksumMismatch {
319                                name: tool.name.clone(),
320                                expected: expected_sha.to_string(),
321                                actual: content.trim().to_string(),
322                            },
323                        ));
324                    }
325                }
326            }
327        }
328        Ok(())
329    }
330
331    /// Returns the install directory.
332    #[must_use]
333    pub fn install_dir(&self) -> &Path {
334        &self.install_dir
335    }
336
337    /// Returns the list of tool entries.
338    #[must_use]
339    pub fn tools(&self) -> &[ToolEntry] {
340        &self.tools
341    }
342}
343
344/// Mark a file as executable (0o755).
345#[cfg(unix)]
346async fn mark_executable(path: &Path) -> Result<(), HostToolError> {
347    use std::os::unix::fs::PermissionsExt;
348    let mut perms = tokio::fs::metadata(path)
349        .await
350        .map_err(HostToolError::Io)?
351        .permissions();
352    perms.set_mode(0o755);
353    tokio::fs::set_permissions(path, perms)
354        .await
355        .map_err(HostToolError::Io)?;
356    Ok(())
357}
358
359#[cfg(not(unix))]
360async fn mark_executable(_path: &Path) -> Result<(), HostToolError> {
361    Ok(())
362}
363
364/// Write a sidecar `{name}.sha256` file recording the expected checksum from
365/// `assets.lock`.  Used for tgz-based tools where the on-disk binary cannot be
366/// compared against the archive checksum directly.
367async fn write_sidecar(
368    install_dir: &Path,
369    name: &str,
370    expected_sha: &str,
371) -> Result<(), HostToolError> {
372    let sidecar = install_dir.join(format!("{name}.sha256"));
373    tokio::fs::write(&sidecar, expected_sha)
374        .await
375        .map_err(HostToolError::Io)?;
376    Ok(())
377}
378
379/// Extract a single file from a `.tgz` archive.
380fn extract_from_tgz(
381    archive_path: &Path,
382    inner_path: &str,
383    dest: &Path,
384) -> Result<(), HostToolError> {
385    let file = std::fs::File::open(archive_path).map_err(HostToolError::Io)?;
386    let gz = flate2::read::GzDecoder::new(file);
387    let mut archive = tar::Archive::new(gz);
388
389    for entry in archive.entries().map_err(HostToolError::Io)? {
390        let mut entry = entry.map_err(HostToolError::Io)?;
391        let path = entry.path().map_err(HostToolError::Io)?;
392        if path.to_string_lossy() == inner_path {
393            entry.unpack(dest).map_err(HostToolError::Io)?;
394            return Ok(());
395        }
396    }
397
398    Err(HostToolError::ExtractFailed {
399        archive: archive_path.display().to_string(),
400        inner: inner_path.to_string(),
401    })
402}
403
404/// Errors from host tool operations.
405#[derive(Debug, thiserror::Error)]
406pub enum HostToolError {
407    #[error("asset error: {0}")]
408    Asset(#[from] arcbox_asset::AssetError),
409
410    #[error("io error: {0}")]
411    Io(#[from] std::io::Error),
412
413    #[error("tool '{tool}' has no binary for architecture '{arch}'")]
414    UnsupportedArch { tool: String, arch: String },
415
416    #[error("tool '{0}' is not installed")]
417    NotInstalled(String),
418
419    #[error("failed to extract '{inner}' from archive '{archive}'")]
420    ExtractFailed { archive: String, inner: String },
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use crate::lockfile::{ArchEntry, ToolGroup};
427
428    fn make_tool(name: &str, sha: &str) -> ToolEntry {
429        ToolEntry {
430            name: name.to_string(),
431            group: ToolGroup::Docker,
432            version: "1.0.0".to_string(),
433            arch: std::collections::HashMap::from([(
434                "arm64".to_string(),
435                ArchEntry {
436                    sha256: sha.to_string(),
437                },
438            )]),
439        }
440    }
441
442    // -- is_cached tests --
443
444    #[tokio::test]
445    async fn is_cached_binary_hit() {
446        let dir = tempfile::tempdir().unwrap();
447        let content = b"hello binary";
448        let sha = sha256_bytes(content);
449        tokio::fs::write(dir.path().join("my-tool"), content)
450            .await
451            .unwrap();
452
453        let mgr = HostToolManager::new(vec![], "arm64", dir.path().to_path_buf());
454        assert!(mgr.is_cached("my-tool", &sha, ArtifactFormat::Binary).await);
455    }
456
457    #[tokio::test]
458    async fn is_cached_binary_miss() {
459        let dir = tempfile::tempdir().unwrap();
460        tokio::fs::write(dir.path().join("my-tool"), b"old version")
461            .await
462            .unwrap();
463
464        let mgr = HostToolManager::new(vec![], "arm64", dir.path().to_path_buf());
465        assert!(
466            !mgr.is_cached("my-tool", "wrong-sha", ArtifactFormat::Binary)
467                .await
468        );
469    }
470
471    #[tokio::test]
472    async fn is_cached_tgz_sidecar_hit() {
473        let dir = tempfile::tempdir().unwrap();
474        tokio::fs::write(dir.path().join("docker"), b"binary")
475            .await
476            .unwrap();
477        tokio::fs::write(dir.path().join("docker.sha256"), "abc123")
478            .await
479            .unwrap();
480
481        let mgr = HostToolManager::new(vec![], "arm64", dir.path().to_path_buf());
482        assert!(mgr.is_cached("docker", "abc123", ArtifactFormat::Tgz).await);
483    }
484
485    #[tokio::test]
486    async fn is_cached_tgz_sidecar_miss() {
487        let dir = tempfile::tempdir().unwrap();
488        tokio::fs::write(dir.path().join("docker"), b"binary")
489            .await
490            .unwrap();
491        tokio::fs::write(dir.path().join("docker.sha256"), "old-sha")
492            .await
493            .unwrap();
494
495        let mgr = HostToolManager::new(vec![], "arm64", dir.path().to_path_buf());
496        assert!(
497            !mgr.is_cached("docker", "new-sha", ArtifactFormat::Tgz)
498                .await
499        );
500    }
501
502    #[tokio::test]
503    async fn is_cached_tgz_no_sidecar() {
504        let dir = tempfile::tempdir().unwrap();
505        tokio::fs::write(dir.path().join("docker"), b"binary")
506            .await
507            .unwrap();
508
509        let mgr = HostToolManager::new(vec![], "arm64", dir.path().to_path_buf());
510        assert!(
511            !mgr.is_cached("docker", "any-sha", ArtifactFormat::Tgz)
512                .await
513        );
514    }
515
516    // -- try_install_from_bundle tests --
517
518    #[tokio::test]
519    async fn bundle_install_binary_ok() {
520        let install_dir = tempfile::tempdir().unwrap();
521        let bundle_dir = tempfile::tempdir().unwrap();
522        let content = b"good binary";
523        let sha = sha256_bytes(content);
524
525        tokio::fs::write(bundle_dir.path().join("my-tool"), content)
526            .await
527            .unwrap();
528
529        let tool = make_tool("my-tool", &sha);
530        let dest = install_dir.path().join("my-tool");
531
532        let mgr = HostToolManager::new(vec![], "arm64", install_dir.path().to_path_buf())
533            .with_bundle_dir(bundle_dir.path().to_path_buf());
534
535        let ok = mgr
536            .try_install_from_bundle(&tool, &dest, &sha, ArtifactFormat::Binary)
537            .await
538            .unwrap();
539        assert!(ok);
540        assert!(dest.exists());
541    }
542
543    #[tokio::test]
544    async fn bundle_install_binary_checksum_mismatch() {
545        let install_dir = tempfile::tempdir().unwrap();
546        let bundle_dir = tempfile::tempdir().unwrap();
547
548        tokio::fs::write(bundle_dir.path().join("my-tool"), b"bad binary")
549            .await
550            .unwrap();
551
552        let tool = make_tool("my-tool", "wrong-sha");
553        let dest = install_dir.path().join("my-tool");
554
555        let mgr = HostToolManager::new(vec![], "arm64", install_dir.path().to_path_buf())
556            .with_bundle_dir(bundle_dir.path().to_path_buf());
557
558        let ok = mgr
559            .try_install_from_bundle(&tool, &dest, "wrong-sha", ArtifactFormat::Binary)
560            .await
561            .unwrap();
562        assert!(!ok);
563        assert!(!dest.exists());
564    }
565
566    #[tokio::test]
567    async fn bundle_install_tgz_requires_checksum_file() {
568        let install_dir = tempfile::tempdir().unwrap();
569        let bundle_dir = tempfile::tempdir().unwrap();
570
571        tokio::fs::write(bundle_dir.path().join("docker"), b"docker binary")
572            .await
573            .unwrap();
574        // No docker.sha256 in bundle → should fail.
575
576        let tool = make_tool("docker", "archive-sha");
577        let dest = install_dir.path().join("docker");
578
579        let mgr = HostToolManager::new(vec![], "arm64", install_dir.path().to_path_buf())
580            .with_bundle_dir(bundle_dir.path().to_path_buf());
581
582        let ok = mgr
583            .try_install_from_bundle(&tool, &dest, "archive-sha", ArtifactFormat::Tgz)
584            .await
585            .unwrap();
586        assert!(!ok);
587    }
588
589    #[tokio::test]
590    async fn bundle_install_tgz_with_checksum_file() {
591        let install_dir = tempfile::tempdir().unwrap();
592        let bundle_dir = tempfile::tempdir().unwrap();
593
594        tokio::fs::write(bundle_dir.path().join("docker"), b"docker binary")
595            .await
596            .unwrap();
597        tokio::fs::write(bundle_dir.path().join("docker.sha256"), "archive-sha")
598            .await
599            .unwrap();
600
601        let tool = make_tool("docker", "archive-sha");
602        let dest = install_dir.path().join("docker");
603
604        let mgr = HostToolManager::new(vec![], "arm64", install_dir.path().to_path_buf())
605            .with_bundle_dir(bundle_dir.path().to_path_buf());
606
607        let ok = mgr
608            .try_install_from_bundle(&tool, &dest, "archive-sha", ArtifactFormat::Tgz)
609            .await
610            .unwrap();
611        assert!(ok);
612        assert!(dest.exists());
613    }
614
615    #[tokio::test]
616    async fn bundle_install_no_bundle_dir() {
617        let install_dir = tempfile::tempdir().unwrap();
618        let tool = make_tool("my-tool", "sha");
619        let dest = install_dir.path().join("my-tool");
620
621        let mgr = HostToolManager::new(vec![], "arm64", install_dir.path().to_path_buf());
622        let ok = mgr
623            .try_install_from_bundle(&tool, &dest, "sha", ArtifactFormat::Binary)
624            .await
625            .unwrap();
626        assert!(!ok);
627    }
628
629    /// Compute SHA-256 hex of in-memory bytes (test helper).
630    fn sha256_bytes(data: &[u8]) -> String {
631        use sha2::{Digest, Sha256};
632        let mut hasher = Sha256::new();
633        hasher.update(data);
634        format!("{:x}", hasher.finalize())
635    }
636}