Files
agent-manager/src/download.rs
T
bruno 8ee3ddf3db agent-manager v0.2.0 : gestionnaire d'agents IA locaux
- 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
2026-08-15 11:26:34 -04:00

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"));
}
}