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 HostToolManager {
13 arch: String,
15 install_dir: PathBuf,
17 tools: Vec<ToolEntry>,
19 bundle_dir: Option<PathBuf>,
22}
23
24impl HostToolManager {
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<(), 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 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 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(HostToolError::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(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 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 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 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 match format {
229 ArtifactFormat::Binary => {
230 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 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 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 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 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 #[must_use]
333 pub fn install_dir(&self) -> &Path {
334 &self.install_dir
335 }
336
337 #[must_use]
339 pub fn tools(&self) -> &[ToolEntry] {
340 &self.tools
341 }
342}
343
344#[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
364async 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
379fn 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#[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 #[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 #[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 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 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}