150 lines
5.1 KiB
Rust
150 lines
5.1 KiB
Rust
//! 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<InstallOutcome> {
|
|
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<String>) = 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<Option<String>> {
|
|
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()))
|
|
}
|