use crate::lockfile::ToolEntry;
use crate::registry::{self, ArtifactFormat};
use arcbox_asset::download::{download_and_verify, sha256_file};
use arcbox_asset::{PreparePhase, PrepareProgress, ProgressCallback};
use std::path::{Path, PathBuf};
use tracing::info;
pub struct HostToolManager {
arch: String,
install_dir: PathBuf,
tools: Vec<ToolEntry>,
bundle_dir: Option<PathBuf>,
}
impl HostToolManager {
#[must_use]
pub fn new(tools: Vec<ToolEntry>, arch: impl Into<String>, install_dir: PathBuf) -> Self {
Self {
arch: arch.into(),
install_dir,
tools,
bundle_dir: None,
}
}
#[must_use]
pub fn with_bundle_dir(mut self, dir: PathBuf) -> Self {
self.bundle_dir = Some(dir);
self
}
pub async fn install_all(
&self,
progress: Option<&ProgressCallback>,
) -> Result<(), HostToolError> {
tokio::fs::create_dir_all(&self.install_dir)
.await
.map_err(HostToolError::Io)?;
let total = self.tools.len();
for (idx, tool) in self.tools.iter().enumerate() {
self.install_one(tool, idx + 1, total, progress).await?;
}
Ok(())
}
async fn install_one(
&self,
tool: &ToolEntry,
current: usize,
total: usize,
progress: Option<&ProgressCallback>,
) -> Result<(), HostToolError> {
let expected_sha =
tool.sha256_for_arch(&self.arch)
.ok_or_else(|| HostToolError::UnsupportedArch {
tool: tool.name.clone(),
arch: self.arch.clone(),
})?;
let dest = self.install_dir.join(&tool.name);
let format = registry::artifact_format(&tool.name);
let pg = |phase: PreparePhase| {
if let Some(cb) = progress {
cb(PrepareProgress {
name: tool.name.clone(),
current,
total,
phase,
});
}
};
pg(PreparePhase::Checking);
if dest.exists() && self.is_cached(&tool.name, expected_sha, format).await {
pg(PreparePhase::Cached);
info!(tool = %tool.name, "already installed, checksum matches");
return Ok(());
}
if self
.try_install_from_bundle(tool, &dest, expected_sha, format)
.await?
{
mark_executable(&dest).await?;
write_sidecar(&self.install_dir, &tool.name, expected_sha).await?;
pg(PreparePhase::Ready);
info!(tool = %tool.name, version = %tool.version, "installed from bundle");
return Ok(());
}
let url = registry::download_url(&tool.name, &tool.version, &self.arch);
match format {
ArtifactFormat::Binary => {
download_and_verify(&url, &dest, expected_sha, &tool.name, |dl, tot| {
pg(PreparePhase::Downloading {
downloaded: dl,
total: tot,
});
})
.await
.map_err(HostToolError::Asset)?;
}
ArtifactFormat::Tgz => {
let tgz_path = self.install_dir.join(format!("{}.tgz", tool.name));
download_and_verify(&url, &tgz_path, expected_sha, &tool.name, |dl, tot| {
pg(PreparePhase::Downloading {
downloaded: dl,
total: tot,
});
})
.await
.map_err(HostToolError::Asset)?;
pg(PreparePhase::Verifying);
extract_from_tgz(&tgz_path, registry::tgz_inner_path(&tool.name), &dest)?;
let _ = tokio::fs::remove_file(&tgz_path).await;
}
}
mark_executable(&dest).await?;
write_sidecar(&self.install_dir, &tool.name, expected_sha).await?;
pg(PreparePhase::Ready);
info!(tool = %tool.name, version = %tool.version, "installed");
Ok(())
}
async fn is_cached(&self, name: &str, expected_sha: &str, format: ArtifactFormat) -> bool {
match format {
ArtifactFormat::Binary => {
let path = self.install_dir.join(name);
sha256_file(&path)
.await
.is_ok_and(|actual| actual == expected_sha)
}
ArtifactFormat::Tgz => {
let sidecar = self.install_dir.join(format!("{name}.sha256"));
tokio::fs::read_to_string(&sidecar)
.await
.is_ok_and(|content| content.trim() == expected_sha)
}
}
}
async fn try_install_from_bundle(
&self,
tool: &ToolEntry,
dest: &Path,
expected_sha: &str,
format: ArtifactFormat,
) -> Result<bool, HostToolError> {
let bundle_dir = match &self.bundle_dir {
Some(dir) => dir,
None => return Ok(false),
};
let src = bundle_dir.join(&tool.name);
if !src.exists() {
return Ok(false);
}
let tmp = tempfile::NamedTempFile::new_in(&self.install_dir).map_err(HostToolError::Io)?;
{
use tokio::io::AsyncWriteExt;
let src_bytes = tokio::fs::read(&src).await;
match src_bytes {
Ok(bytes) => {
let std_file = tmp.as_file().try_clone().map_err(HostToolError::Io)?;
let mut async_file = tokio::fs::File::from_std(std_file);
if let Err(e) = async_file.write_all(&bytes).await {
let _ = tmp.close();
info!(tool = %tool.name, error = %e, "bundle copy failed, will download");
return Ok(false);
}
async_file.flush().await.map_err(HostToolError::Io)?;
}
Err(e) => {
let _ = tmp.close();
info!(tool = %tool.name, error = %e, "bundle read failed, will download");
return Ok(false);
}
}
}
let tmp_path = tmp.into_temp_path();
match format {
ArtifactFormat::Binary => {
match sha256_file(&tmp_path).await {
Ok(actual) if actual == expected_sha => {}
Ok(_) => {
let _ = tmp_path.close();
info!(tool = %tool.name, "bundle checksum mismatch, will download");
return Ok(false);
}
Err(_) => {
let _ = tmp_path.close();
info!(tool = %tool.name, "bundle checksum read failed, will download");
return Ok(false);
}
}
}
ArtifactFormat::Tgz => {
let checksum_path = bundle_dir.join(format!("{}.sha256", tool.name));
match tokio::fs::read_to_string(&checksum_path).await {
Ok(contents) if contents.trim() == expected_sha => {}
Ok(_) => {
let _ = tmp_path.close();
info!(tool = %tool.name, "bundle archive checksum mismatch, will download");
return Ok(false);
}
Err(_) => {
let _ = tmp_path.close();
info!(tool = %tool.name, "bundle checksum file missing, will download");
return Ok(false);
}
}
}
}
if let Err(e) = tmp_path.persist(dest) {
info!(tool = %tool.name, error = %e, "bundle persist failed, will download");
return Ok(false);
}
Ok(true)
}
pub async fn validate_all(&self) -> Result<(), HostToolError> {
for tool in &self.tools {
let expected_sha =
tool.sha256_for_arch(&self.arch)
.ok_or_else(|| HostToolError::UnsupportedArch {
tool: tool.name.clone(),
arch: self.arch.clone(),
})?;
let path = self.install_dir.join(&tool.name);
if !path.exists() {
return Err(HostToolError::NotInstalled(tool.name.clone()));
}
let format = registry::artifact_format(&tool.name);
match format {
ArtifactFormat::Binary => {
let actual = sha256_file(&path).await.map_err(HostToolError::Asset)?;
if actual != expected_sha {
return Err(HostToolError::Asset(
arcbox_asset::AssetError::ChecksumMismatch {
name: tool.name.clone(),
expected: expected_sha.to_string(),
actual,
},
));
}
}
ArtifactFormat::Tgz => {
let sidecar = self.install_dir.join(format!("{}.sha256", tool.name));
let content = match tokio::fs::read_to_string(&sidecar).await {
Ok(content) => content,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(HostToolError::NotInstalled(tool.name.clone()));
}
Err(e) => return Err(HostToolError::Io(e)),
};
if content.trim() != expected_sha {
return Err(HostToolError::Asset(
arcbox_asset::AssetError::ChecksumMismatch {
name: tool.name.clone(),
expected: expected_sha.to_string(),
actual: content.trim().to_string(),
},
));
}
}
}
}
Ok(())
}
#[must_use]
pub fn install_dir(&self) -> &Path {
&self.install_dir
}
#[must_use]
pub fn tools(&self) -> &[ToolEntry] {
&self.tools
}
}
#[cfg(unix)]
async fn mark_executable(path: &Path) -> Result<(), HostToolError> {
use std::os::unix::fs::PermissionsExt;
let mut perms = tokio::fs::metadata(path)
.await
.map_err(HostToolError::Io)?
.permissions();
perms.set_mode(0o755);
tokio::fs::set_permissions(path, perms)
.await
.map_err(HostToolError::Io)?;
Ok(())
}
#[cfg(not(unix))]
async fn mark_executable(_path: &Path) -> Result<(), HostToolError> {
Ok(())
}
async fn write_sidecar(
install_dir: &Path,
name: &str,
expected_sha: &str,
) -> Result<(), HostToolError> {
let sidecar = install_dir.join(format!("{name}.sha256"));
tokio::fs::write(&sidecar, expected_sha)
.await
.map_err(HostToolError::Io)?;
Ok(())
}
fn extract_from_tgz(
archive_path: &Path,
inner_path: &str,
dest: &Path,
) -> Result<(), HostToolError> {
let file = std::fs::File::open(archive_path).map_err(HostToolError::Io)?;
let gz = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(gz);
for entry in archive.entries().map_err(HostToolError::Io)? {
let mut entry = entry.map_err(HostToolError::Io)?;
let path = entry.path().map_err(HostToolError::Io)?;
if path.to_string_lossy() == inner_path {
entry.unpack(dest).map_err(HostToolError::Io)?;
return Ok(());
}
}
Err(HostToolError::ExtractFailed {
archive: archive_path.display().to_string(),
inner: inner_path.to_string(),
})
}
#[derive(Debug, thiserror::Error)]
pub enum HostToolError {
#[error("asset error: {0}")]
Asset(#[from] arcbox_asset::AssetError),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("tool '{tool}' has no binary for architecture '{arch}'")]
UnsupportedArch { tool: String, arch: String },
#[error("tool '{0}' is not installed")]
NotInstalled(String),
#[error("failed to extract '{inner}' from archive '{archive}'")]
ExtractFailed { archive: String, inner: String },
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lockfile::{ArchEntry, ToolGroup};
fn make_tool(name: &str, sha: &str) -> ToolEntry {
ToolEntry {
name: name.to_string(),
group: ToolGroup::Docker,
version: "1.0.0".to_string(),
arch: std::collections::HashMap::from([(
"arm64".to_string(),
ArchEntry {
sha256: sha.to_string(),
},
)]),
}
}
#[tokio::test]
async fn is_cached_binary_hit() {
let dir = tempfile::tempdir().unwrap();
let content = b"hello binary";
let sha = sha256_bytes(content);
tokio::fs::write(dir.path().join("my-tool"), content)
.await
.unwrap();
let mgr = HostToolManager::new(vec![], "arm64", dir.path().to_path_buf());
assert!(mgr.is_cached("my-tool", &sha, ArtifactFormat::Binary).await);
}
#[tokio::test]
async fn is_cached_binary_miss() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(dir.path().join("my-tool"), b"old version")
.await
.unwrap();
let mgr = HostToolManager::new(vec![], "arm64", dir.path().to_path_buf());
assert!(
!mgr.is_cached("my-tool", "wrong-sha", ArtifactFormat::Binary)
.await
);
}
#[tokio::test]
async fn is_cached_tgz_sidecar_hit() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(dir.path().join("docker"), b"binary")
.await
.unwrap();
tokio::fs::write(dir.path().join("docker.sha256"), "abc123")
.await
.unwrap();
let mgr = HostToolManager::new(vec![], "arm64", dir.path().to_path_buf());
assert!(mgr.is_cached("docker", "abc123", ArtifactFormat::Tgz).await);
}
#[tokio::test]
async fn is_cached_tgz_sidecar_miss() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(dir.path().join("docker"), b"binary")
.await
.unwrap();
tokio::fs::write(dir.path().join("docker.sha256"), "old-sha")
.await
.unwrap();
let mgr = HostToolManager::new(vec![], "arm64", dir.path().to_path_buf());
assert!(
!mgr.is_cached("docker", "new-sha", ArtifactFormat::Tgz)
.await
);
}
#[tokio::test]
async fn is_cached_tgz_no_sidecar() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(dir.path().join("docker"), b"binary")
.await
.unwrap();
let mgr = HostToolManager::new(vec![], "arm64", dir.path().to_path_buf());
assert!(
!mgr.is_cached("docker", "any-sha", ArtifactFormat::Tgz)
.await
);
}
#[tokio::test]
async fn bundle_install_binary_ok() {
let install_dir = tempfile::tempdir().unwrap();
let bundle_dir = tempfile::tempdir().unwrap();
let content = b"good binary";
let sha = sha256_bytes(content);
tokio::fs::write(bundle_dir.path().join("my-tool"), content)
.await
.unwrap();
let tool = make_tool("my-tool", &sha);
let dest = install_dir.path().join("my-tool");
let mgr = HostToolManager::new(vec![], "arm64", install_dir.path().to_path_buf())
.with_bundle_dir(bundle_dir.path().to_path_buf());
let ok = mgr
.try_install_from_bundle(&tool, &dest, &sha, ArtifactFormat::Binary)
.await
.unwrap();
assert!(ok);
assert!(dest.exists());
}
#[tokio::test]
async fn bundle_install_binary_checksum_mismatch() {
let install_dir = tempfile::tempdir().unwrap();
let bundle_dir = tempfile::tempdir().unwrap();
tokio::fs::write(bundle_dir.path().join("my-tool"), b"bad binary")
.await
.unwrap();
let tool = make_tool("my-tool", "wrong-sha");
let dest = install_dir.path().join("my-tool");
let mgr = HostToolManager::new(vec![], "arm64", install_dir.path().to_path_buf())
.with_bundle_dir(bundle_dir.path().to_path_buf());
let ok = mgr
.try_install_from_bundle(&tool, &dest, "wrong-sha", ArtifactFormat::Binary)
.await
.unwrap();
assert!(!ok);
assert!(!dest.exists());
}
#[tokio::test]
async fn bundle_install_tgz_requires_checksum_file() {
let install_dir = tempfile::tempdir().unwrap();
let bundle_dir = tempfile::tempdir().unwrap();
tokio::fs::write(bundle_dir.path().join("docker"), b"docker binary")
.await
.unwrap();
let tool = make_tool("docker", "archive-sha");
let dest = install_dir.path().join("docker");
let mgr = HostToolManager::new(vec![], "arm64", install_dir.path().to_path_buf())
.with_bundle_dir(bundle_dir.path().to_path_buf());
let ok = mgr
.try_install_from_bundle(&tool, &dest, "archive-sha", ArtifactFormat::Tgz)
.await
.unwrap();
assert!(!ok);
}
#[tokio::test]
async fn bundle_install_tgz_with_checksum_file() {
let install_dir = tempfile::tempdir().unwrap();
let bundle_dir = tempfile::tempdir().unwrap();
tokio::fs::write(bundle_dir.path().join("docker"), b"docker binary")
.await
.unwrap();
tokio::fs::write(bundle_dir.path().join("docker.sha256"), "archive-sha")
.await
.unwrap();
let tool = make_tool("docker", "archive-sha");
let dest = install_dir.path().join("docker");
let mgr = HostToolManager::new(vec![], "arm64", install_dir.path().to_path_buf())
.with_bundle_dir(bundle_dir.path().to_path_buf());
let ok = mgr
.try_install_from_bundle(&tool, &dest, "archive-sha", ArtifactFormat::Tgz)
.await
.unwrap();
assert!(ok);
assert!(dest.exists());
}
#[tokio::test]
async fn bundle_install_no_bundle_dir() {
let install_dir = tempfile::tempdir().unwrap();
let tool = make_tool("my-tool", "sha");
let dest = install_dir.path().join("my-tool");
let mgr = HostToolManager::new(vec![], "arm64", install_dir.path().to_path_buf());
let ok = mgr
.try_install_from_bundle(&tool, &dest, "sha", ArtifactFormat::Binary)
.await
.unwrap();
assert!(!ok);
}
fn sha256_bytes(data: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(data);
format!("{:x}", hasher.finalize())
}
}