//! 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 = 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, } pub fn latest_release(repo: &str) -> Result { 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::(&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 { 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 { 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 { 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 { 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 = 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) { if let Ok(rd) = std::fs::read_dir(dir) { let mut entries: Vec = 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")); } }