//! Binary installer: download a precompiled binary (direct URL or GitHub //! Releases asset), verify its checksum, extract it if needed, and install //! it into the global bin directory. use super::*; pub fn install( app: &App, agent: &AgentDef, method: &MethodSpec, runner: &dyn Runner, ) -> Result { let root = install_root(app, &agent.name); let dist = root.join("dist"); let bindir = bin_dir(app); ensure_dir(app, &root)?; ensure_dir(app, &dist)?; ensure_dir(app, &bindir)?; // Resolve the download URL (direct or GitHub release asset). let (url, tag): (String, Option) = if let Some(u) = &method.url { (u.clone(), None) } else if let Some(repo) = &method.repo { let release = crate::download::latest_release(repo)?; let picked = crate::download::pick_asset( &release, &method.asset_match, &method.asset_exclude, method.binary.as_deref(), ) .ok_or_else(|| { anyhow!( "no release asset of '{repo}' matches this platform ({}/{})", crate::config::current_platform(), std::env::consts::ARCH ) })?; app.log .info(&format!("selected asset: {} ({})", picked.name, picked.tag)); ( picked.url, Some(picked.tag.trim_start_matches('v').to_string()), ) } else { bail!("binary install requires 'url' or 'repo' (GitHub owner/repo)"); }; let names = expected_bin_names(agent, method); let bin_name = names .first() .cloned() .unwrap_or_else(|| agent.name.clone()); let dest_bin = bindir.join(exe_name(&bin_name)); if app.dry_run() { app.log .dry(format!("would download {url} and install {}", dest_bin.display())); return Ok(InstallOutcome { bins: vec![dest_bin], version: tag, }); } let tmpdir = tempfile::tempdir()?; let tmpfile = tmpdir.path().join("download"); crate::download::download(&url, &tmpfile, method.checksum.as_deref(), true)?; if crate::download::extract_archive(&tmpfile, &dist)? { let found = crate::download::find_executable(&dist, Some(&bin_name))?; std::fs::copy(&found, &dest_bin).with_context(|| { format!("cannot copy {} to {}", found.display(), dest_bin.display()) })?; app.log .info(&format!("extracted {} -> {}", found.display(), dest_bin.display())); } else { std::fs::copy(&tmpfile, &dest_bin).with_context(|| { format!("cannot copy {} to {}", tmpfile.display(), dest_bin.display()) })?; } // Guard against installing something that is not an executable for this // platform (wrong architecture, an unextracted archive, ...). verify_executable(&dest_bin)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let _ = std::fs::set_permissions(&dest_bin, std::fs::Permissions::from_mode(0o755)); } let version = detect_version(runner, &dest_bin).or(tag); Ok(InstallOutcome { bins: vec![dest_bin], version, }) } #[cfg(test)] mod tests { use super::*; #[test] fn verify_executable_accepts_pe_and_rejects_elf() { let dir = tempfile::tempdir().unwrap(); let pe = dir.path().join("a.exe"); std::fs::write(&pe, b"MZwhatever").unwrap(); assert!(verify_executable(&pe).is_ok()); let elf = dir.path().join("b"); std::fs::write(&elf, b"ELF").unwrap(); let err = verify_executable(&elf).unwrap_err(); assert!(err.to_string().contains("not a"), "{err}"); } } /// Check that the downloaded file is an executable of this platform. /// Detects the classic Windows "not compatible" (error 216) and Linux /// "exec format error" failures before the agent is ever started. fn verify_executable(path: &std::path::Path) -> Result<()> { let mut head = [0u8; 4]; let n = std::fs::File::open(path) .and_then(|mut f| std::io::Read::read(&mut f, &mut head)) .unwrap_or(0); #[cfg(windows)] let ok = n >= 2 && &head[0..2] == b"MZ"; #[cfg(target_os = "macos")] let ok = (n >= 4 && (&head[0..4] == b"\xcf\xfa\xed\xfe" || &head[0..4] == b"\xfe\xed\xfa\xcf")) || (n >= 4 && &head[0..4] == b"\xca\xfe\xba\xbe"); #[cfg(all(unix, not(target_os = "macos")))] let ok = n >= 4 && &head[0..4] == b"\x7fELF"; if ok { return Ok(()); } bail!( "downloaded file {} is not a {} executable (magic bytes {:02x?}) — the release asset is probably for another platform or is an installer/archive", path.display(), std::env::consts::OS, &head[..n] ) } /// Latest release tag (without the leading "v"). pub fn latest( _app: &App, _agent: &AgentDef, method: &MethodSpec, _runner: &dyn Runner, ) -> Result> { let Some(repo) = method.repo.as_deref() else { return Ok(None); }; let release = crate::download::latest_release(repo)?; Ok(Some(release.tag_name.trim_start_matches('v').to_string())) }