- CLI Rust (am) : list/install/uninstall/update/start/stop/restart/run/status/search/info/config/doctor/completion/export/import/self-update + shell interactif (Tab, historique) - 9 methodes d'installation (npm, pip, uv, cargo, go, bun, curl, binary, git) - catalogue de 72 agents, dependances, processus, securite (dry-run, checksums) - sondes de version paralleles + cache (am list ~25 ms a chaud) - installateurs one-liner : install.sh, install.ps1
471 lines
15 KiB
Rust
471 lines
15 KiB
Rust
//! Downloading (with checksum verification), GitHub Releases resolution,
|
|
//! archive extraction and executable discovery.
|
|
|
|
use anyhow::{anyhow, bail, Context, Result};
|
|
use serde::Deserialize;
|
|
use sha2::{Digest, Sha256};
|
|
use std::io::{IsTerminal, Read, Write};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
pub const USER_AGENT: &str = concat!("agent-manager/", env!("CARGO_PKG_VERSION"));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// URL validation + download
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Only http, https and file schemes are allowed.
|
|
pub fn validate_url(url: &str) -> Result<()> {
|
|
let lower = url.to_lowercase();
|
|
let ok = lower.starts_with("https://")
|
|
|| lower.starts_with("http://")
|
|
|| lower.starts_with("file://");
|
|
if !ok {
|
|
bail!(
|
|
"refusing URL with unsupported scheme (only http, https and file are allowed): {url}"
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Download a URL to dest, verifying the optional sha256 checksum.
|
|
pub fn download(url: &str, dest: &Path, expected_sha256: Option<&str>, progress: bool) -> Result<()> {
|
|
validate_url(url)?;
|
|
if let Some(local) = url.strip_prefix("file://") {
|
|
let src = PathBuf::from(local);
|
|
std::fs::copy(&src, dest)
|
|
.with_context(|| format!("cannot copy {} to {}", src.display(), dest.display()))?;
|
|
verify_sha256(dest, expected_sha256)?;
|
|
return Ok(());
|
|
}
|
|
|
|
let resp = ureq::get(url)
|
|
.set("User-Agent", USER_AGENT)
|
|
.call()
|
|
.map_err(|e| {
|
|
match e {
|
|
ureq::Error::Status(code, r) => anyhow!(
|
|
"download failed for {url}: HTTP {code} {}",
|
|
r.status_text()
|
|
),
|
|
other => anyhow!("download failed for {url}: {other}"),
|
|
}
|
|
})?;
|
|
let total: Option<u64> = resp.header("content-length").and_then(|v| v.parse().ok());
|
|
let mut reader = resp.into_reader();
|
|
let mut file = std::fs::File::create(dest)
|
|
.with_context(|| format!("cannot create {}", dest.display()))?;
|
|
let mut buf = [0u8; 65536];
|
|
let mut done: u64 = 0;
|
|
let mut last_shown: u64 = 0;
|
|
let show = progress && std::io::stderr().is_terminal();
|
|
loop {
|
|
let n = reader
|
|
.read(&mut buf)
|
|
.with_context(|| format!("read error while downloading {url}"))?;
|
|
if n == 0 {
|
|
break;
|
|
}
|
|
file.write_all(&buf[..n])?;
|
|
done += n as u64;
|
|
if show && (done - last_shown >= 8 * 1024 * 1024 || done == total.unwrap_or(u64::MAX)) {
|
|
last_shown = done;
|
|
match total {
|
|
Some(t) => eprint!(
|
|
"\r downloading {:.1} / {:.1} MB",
|
|
done as f64 / 1e6,
|
|
t as f64 / 1e6
|
|
),
|
|
None => eprint!("\r downloaded {:.1} MB", done as f64 / 1e6),
|
|
}
|
|
std::io::stderr().flush().ok();
|
|
}
|
|
}
|
|
if show {
|
|
eprintln!();
|
|
}
|
|
verify_sha256(dest, expected_sha256)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn verify_sha256(path: &Path, expected: Option<&str>) -> Result<()> {
|
|
let Some(exp) = expected else {
|
|
return Ok(());
|
|
};
|
|
let hex = exp
|
|
.strip_prefix("sha256:")
|
|
.or_else(|| exp.strip_prefix("SHA256:"))
|
|
.unwrap_or(exp)
|
|
.trim();
|
|
if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
|
|
bail!("invalid sha256 checksum in config: {exp}");
|
|
}
|
|
let mut f = std::fs::File::open(path)?;
|
|
let mut hasher = Sha256::new();
|
|
std::io::copy(&mut f, &mut hasher)?;
|
|
let actual = to_hex(&hasher.finalize());
|
|
if !actual.eq_ignore_ascii_case(hex) {
|
|
bail!(
|
|
"checksum mismatch for {}: expected {hex}, got {actual}",
|
|
path.display()
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn to_hex(bytes: &[u8]) -> String {
|
|
let mut s = String::with_capacity(bytes.len() * 2);
|
|
for b in bytes {
|
|
s.push_str(&format!("{b:02x}"));
|
|
}
|
|
s
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GitHub Releases
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct GhAsset {
|
|
pub name: String,
|
|
pub browser_download_url: String,
|
|
#[serde(default)]
|
|
pub size: u64,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct GhRelease {
|
|
pub tag_name: String,
|
|
#[serde(default)]
|
|
pub assets: Vec<GhAsset>,
|
|
}
|
|
|
|
pub fn latest_release(repo: &str) -> Result<GhRelease> {
|
|
let url = format!("https://api.github.com/repos/{repo}/releases/latest");
|
|
let resp = ureq::get(&url)
|
|
.set("User-Agent", USER_AGENT)
|
|
.call()
|
|
.map_err(|e| match e {
|
|
ureq::Error::Status(404, _) => {
|
|
anyhow!("no GitHub releases found for repository '{repo}'")
|
|
}
|
|
other => anyhow!("GitHub API request failed for '{repo}': {other}"),
|
|
})?;
|
|
let text = resp
|
|
.into_string()
|
|
.context("reading GitHub API response")?;
|
|
serde_json::from_str::<GhRelease>(&text).context("parsing GitHub API response")
|
|
}
|
|
|
|
pub struct PickedAsset {
|
|
pub name: String,
|
|
pub url: String,
|
|
pub tag: String,
|
|
}
|
|
|
|
/// Choose the release asset that best matches the current platform and arch.
|
|
pub fn pick_asset(
|
|
release: &GhRelease,
|
|
want: &[String],
|
|
exclude: &[String],
|
|
binary_hint: Option<&str>,
|
|
) -> Option<PickedAsset> {
|
|
let platform = if cfg!(windows) {
|
|
"windows"
|
|
} else if cfg!(target_os = "macos") {
|
|
"macos"
|
|
} else {
|
|
"linux"
|
|
};
|
|
let arch = if cfg!(target_arch = "aarch64") {
|
|
"aarch64"
|
|
} else {
|
|
"x86_64"
|
|
};
|
|
let mut best: Option<(u32, usize, &GhAsset)> = None;
|
|
for asset in &release.assets {
|
|
let lower = asset.name.to_lowercase();
|
|
// Skip checksums, signatures and desktop packages.
|
|
if lower.ends_with(".sha256")
|
|
|| lower.ends_with(".sha512")
|
|
|| lower.ends_with(".sig")
|
|
|| lower.ends_with(".asc")
|
|
|| lower.ends_with(".pem")
|
|
|| lower.contains("checksums")
|
|
|| lower.ends_with(".dmg")
|
|
|| lower.ends_with(".deb")
|
|
|| lower.ends_with(".rpm")
|
|
|| lower.ends_with(".msi")
|
|
|| lower.ends_with(".apk")
|
|
{
|
|
continue;
|
|
}
|
|
if !want.iter().all(|w| lower.contains(&w.to_lowercase())) {
|
|
continue;
|
|
}
|
|
if exclude.iter().any(|w| lower.contains(&w.to_lowercase())) {
|
|
continue;
|
|
}
|
|
let score = match platform_score(&lower, platform, arch) {
|
|
Some(s) => s,
|
|
None => continue,
|
|
};
|
|
let replace = match best {
|
|
None => true,
|
|
Some((s, _, _)) => score > s,
|
|
};
|
|
if replace {
|
|
best = Some((score, asset.name.len(), asset));
|
|
}
|
|
}
|
|
best.map(|(_, _, a)| {
|
|
let _ = binary_hint;
|
|
PickedAsset {
|
|
name: a.name.clone(),
|
|
url: a.browser_download_url.clone(),
|
|
tag: release.tag_name.clone(),
|
|
}
|
|
})
|
|
}
|
|
|
|
fn platform_score(lower: &str, platform: &str, arch: &str) -> Option<u32> {
|
|
let plat_ok = match platform {
|
|
"windows" => {
|
|
lower.contains("windows")
|
|
|| lower.ends_with(".exe")
|
|
|| lower.contains("win64")
|
|
|| lower.contains("win32")
|
|
|| lower.contains("msvc")
|
|
|| lower.contains("mingw")
|
|
|| lower.contains("pc-windows")
|
|
}
|
|
"macos" => {
|
|
lower.contains("darwin")
|
|
|| lower.contains("macos")
|
|
|| lower.contains("apple")
|
|
|| lower.contains("osx")
|
|
}
|
|
_ => {
|
|
lower.contains("linux")
|
|
|| lower.contains("unknown-linux")
|
|
|| lower.contains("manylinux")
|
|
}
|
|
};
|
|
if !plat_ok {
|
|
return None;
|
|
}
|
|
let arch_ok = match arch {
|
|
"aarch64" => lower.contains("aarch64") || lower.contains("arm64") || lower.contains("armv8"),
|
|
_ => {
|
|
lower.contains("x86_64")
|
|
|| lower.contains("amd64")
|
|
|| lower.contains("x64")
|
|
|| lower.contains("i686")
|
|
}
|
|
};
|
|
if !arch_ok && !lower.contains("universal") {
|
|
return None;
|
|
}
|
|
let mut s = 1u32;
|
|
if lower.contains("universal") {
|
|
s += 4;
|
|
}
|
|
if arch == "aarch64" && lower.contains("arm64") {
|
|
s += 3;
|
|
}
|
|
if arch == "x86_64" && (lower.contains("x86_64") || lower.contains("amd64")) {
|
|
s += 3;
|
|
}
|
|
if lower.contains("musl") {
|
|
s += 1;
|
|
}
|
|
if lower.contains("static") || lower.contains("portable") {
|
|
s += 1;
|
|
}
|
|
if lower.contains("armv7")
|
|
|| lower.contains("armhf")
|
|
|| lower.contains("armv6")
|
|
|| lower.contains("i686")
|
|
|| lower.contains("i386")
|
|
{
|
|
s = s.saturating_sub(2);
|
|
}
|
|
Some(s)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Archives and executables
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Extract zip / tar.gz / tgz / tar / gz archives. Returns false when the
|
|
/// file is not a recognized archive (caller treats it as a plain binary).
|
|
pub fn extract_archive(path: &Path, dest: &Path) -> Result<bool> {
|
|
let name = path
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("")
|
|
.to_lowercase();
|
|
std::fs::create_dir_all(dest)?;
|
|
if name.ends_with(".zip") {
|
|
extract_zip(path, dest)?;
|
|
Ok(true)
|
|
} else if name.ends_with(".tar.gz") || name.ends_with(".tgz") {
|
|
let f = std::fs::File::open(path)?;
|
|
let dec = flate2::read::GzDecoder::new(f);
|
|
let mut ar = tar::Archive::new(dec);
|
|
ar.unpack(dest)
|
|
.with_context(|| format!("extracting {}", path.display()))?;
|
|
Ok(true)
|
|
} else if name.ends_with(".tar") {
|
|
let f = std::fs::File::open(path)?;
|
|
let mut ar = tar::Archive::new(f);
|
|
ar.unpack(dest)
|
|
.with_context(|| format!("extracting {}", path.display()))?;
|
|
Ok(true)
|
|
} else if name.ends_with(".gz") {
|
|
let inner = name.trim_end_matches(".gz").to_string();
|
|
let out = dest.join(inner);
|
|
let f = std::fs::File::open(path)?;
|
|
let mut dec = flate2::read::GzDecoder::new(f);
|
|
let mut o = std::fs::File::create(&out)?;
|
|
std::io::copy(&mut dec, &mut o)?;
|
|
Ok(true)
|
|
} else {
|
|
Ok(false)
|
|
}
|
|
}
|
|
|
|
fn extract_zip(path: &Path, dest: &Path) -> Result<()> {
|
|
let f = std::fs::File::open(path)?;
|
|
let mut zip = zip::ZipArchive::new(f)
|
|
.with_context(|| format!("opening zip archive {}", path.display()))?;
|
|
for i in 0..zip.len() {
|
|
let mut entry = zip.by_index(i)?;
|
|
let Some(enclosed) = entry.enclosed_name() else {
|
|
bail!("zip archive {} contains an unsafe path", path.display());
|
|
};
|
|
let out_path = dest.join(enclosed);
|
|
if entry.is_dir() {
|
|
std::fs::create_dir_all(&out_path)?;
|
|
} else {
|
|
if let Some(parent) = out_path.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
let mut out = std::fs::File::create(&out_path)?;
|
|
std::io::copy(&mut entry, &mut out)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Find the executable in a directory tree: exact name match first, then a
|
|
/// single candidate file, then a single executable file (Unix).
|
|
pub fn find_executable(dir: &Path, expected: Option<&str>) -> Result<PathBuf> {
|
|
let mut files = Vec::new();
|
|
collect_files(dir, &mut files);
|
|
if files.is_empty() {
|
|
bail!("no files found in {}", dir.display());
|
|
}
|
|
if let Some(exp) = expected {
|
|
for f in &files {
|
|
let name = f
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
let stripped = name.strip_suffix(".exe").unwrap_or(&name);
|
|
if stripped.eq_ignore_ascii_case(exp) {
|
|
return Ok(f.clone());
|
|
}
|
|
}
|
|
}
|
|
if files.len() == 1 {
|
|
return Ok(files.remove(0));
|
|
}
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let exec: Vec<&PathBuf> = files
|
|
.iter()
|
|
.filter(|f| {
|
|
std::fs::metadata(f)
|
|
.map(|m| m.permissions().mode() & 0o111 != 0)
|
|
.unwrap_or(false)
|
|
})
|
|
.collect();
|
|
if exec.len() == 1 {
|
|
return Ok(exec[0].clone());
|
|
}
|
|
}
|
|
let names: Vec<String> = files
|
|
.iter()
|
|
.map(|f| format!(" {}", f.display()))
|
|
.collect();
|
|
bail!(
|
|
"cannot determine which file is the executable in {}; candidates:
|
|
{}",
|
|
dir.display(),
|
|
names.join("
|
|
")
|
|
)
|
|
}
|
|
|
|
fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) {
|
|
if let Ok(rd) = std::fs::read_dir(dir) {
|
|
let mut entries: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
|
|
entries.sort();
|
|
for p in entries {
|
|
if p.is_dir() {
|
|
collect_files(&p, out);
|
|
} else {
|
|
out.push(p);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn url_validation() {
|
|
assert!(validate_url("https://example.com/x").is_ok());
|
|
assert!(validate_url("http://example.com/x").is_ok());
|
|
assert!(validate_url("file:///tmp/x").is_ok());
|
|
assert!(validate_url("ftp://example.com/x").is_err());
|
|
assert!(validate_url("javascript:alert(1)").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn sha256_verification() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let p = dir.path().join("f.bin");
|
|
std::fs::write(&p, b"hello world").unwrap();
|
|
let good = to_hex(&Sha256::digest(b"hello world"));
|
|
verify_sha256(&p, Some(&good)).unwrap();
|
|
verify_sha256(&p, Some("0000000000000000000000000000000000000000000000000000000000000000"))
|
|
.unwrap_err();
|
|
}
|
|
|
|
#[test]
|
|
fn extracts_and_finds() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let payload = dir.path().join("app.tar.gz");
|
|
{
|
|
let f = std::fs::File::create(&payload).unwrap();
|
|
let enc = flate2::write::GzEncoder::new(f, flate2::Compression::default());
|
|
let mut ar = tar::Builder::new(enc);
|
|
let mut header = tar::Header::new_gnu();
|
|
header.set_size(6);
|
|
header.set_mode(0o755);
|
|
header.set_cksum();
|
|
ar.append_data(&mut header, "bin/app", &b"binary"[..]).unwrap();
|
|
ar.finish().unwrap();
|
|
}
|
|
let out = dir.path().join("out");
|
|
assert!(extract_archive(&payload, &out).unwrap());
|
|
let found = find_executable(&out, Some("app")).unwrap();
|
|
assert!(found.ends_with("bin/app"));
|
|
}
|
|
}
|