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
This commit is contained in:
+470
@@ -0,0 +1,470 @@
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user