diff --git a/src/download.rs b/src/download.rs index f41b40f..9dab046 100644 --- a/src/download.rs +++ b/src/download.rs @@ -281,6 +281,14 @@ fn platform_score(lower: &str, platform: &str, arch: &str) -> Option { if lower.contains("static") || lower.contains("portable") { s += 1; } + // Prefer plain archives (extracted to a single executable) over + // installer/setup programs, which cannot be used directly. + if lower.ends_with(".zip") || lower.ends_with(".tar.gz") || lower.ends_with(".tgz") { + s += 2; + } + if lower.contains("setup") || lower.contains("installer") || lower.contains("-install") { + s = s.saturating_sub(3); + } if lower.contains("armv7") || lower.contains("armhf") || lower.contains("armv6") @@ -296,41 +304,102 @@ fn platform_score(lower: &str, platform: &str, arch: &str) -> Option { // 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 { +/// What kind of archive a file is, sniffed from its content. +enum ArchiveKind { + Zip, + TarGz, + Tar, + Gz, + Plain, +} + +/// Detect the archive type from the file's magic bytes, falling back to the +/// file name. Downloads are stored under a generic name ("download"), so +/// content sniffing is the reliable way to recognize a zip/tar.gz asset. +fn sniff_archive(path: &Path) -> Result { + let mut head = [0u8; 512]; + let n = std::fs::File::open(path) + .and_then(|mut f| std::io::Read::read(&mut f, &mut head)) + .unwrap_or(0); + if n >= 4 && &head[0..4] == b"PK\x03\x04" { + return Ok(ArchiveKind::Zip); + } + if n >= 2 && head[0] == 0x1f && head[1] == 0x8b { + // gzip: peek at the decompressed stream to see whether it is a tar. + let f = std::fs::File::open(path)?; + let mut dec = flate2::read::GzDecoder::new(f); + let mut inner = [0u8; 512]; + let m = std::io::Read::read(&mut dec, &mut inner).unwrap_or(0); + if m >= 262 && &inner[257..262] == b"ustar" { + return Ok(ArchiveKind::TarGz); + } + return Ok(ArchiveKind::Gz); + } + if n >= 262 && &head[257..262] == b"ustar" { + return Ok(ArchiveKind::Tar); + } + // Content is not an archive: keep the name-based detection for files + // that still carry a real archive extension. 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) + return Ok(ArchiveKind::Zip); + } + if name.ends_with(".tar.gz") || name.ends_with(".tgz") { + return Ok(ArchiveKind::TarGz); + } + if name.ends_with(".tar") { + return Ok(ArchiveKind::Tar); + } + if name.ends_with(".gz") { + return Ok(ArchiveKind::Gz); + } + Ok(ArchiveKind::Plain) +} + +/// Extract zip / tar.gz / tgz / tar / gz archives, detected by content. +/// 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 { + std::fs::create_dir_all(dest)?; + match sniff_archive(path)? { + ArchiveKind::Zip => { + extract_zip(path, dest)?; + Ok(true) + } + ArchiveKind::TarGz => { + 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) + } + ArchiveKind::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) + } + ArchiveKind::Gz => { + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_lowercase(); + 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) + } + ArchiveKind::Plain => Ok(false), } } @@ -447,6 +516,26 @@ mod tests { .unwrap_err(); } + #[test] + fn extracts_zip_detected_by_magic_bytes() { + // Downloads land under a generic name with no extension: only the + // PK magic bytes reveal that it is a zip. + let dir = tempfile::tempdir().unwrap(); + let payload = dir.path().join("download"); + { + let f = std::fs::File::create(&payload).unwrap(); + let mut zip = zip::ZipWriter::new(f); + let opts = zip::write::FileOptions::default(); + zip.start_file("zeroclaw.exe", opts).unwrap(); + std::io::Write::write_all(&mut zip, b"MZfake").unwrap(); + zip.finish().unwrap(); + } + let out = dir.path().join("out"); + assert!(extract_archive(&payload, &out).unwrap()); + let found = find_executable(&out, Some("zeroclaw")).unwrap(); + assert!(found.ends_with("zeroclaw.exe"), "{found:?}"); + } + #[test] fn extracts_and_finds() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/installers/binary.rs b/src/installers/binary.rs index 512f615..b16d19f 100644 --- a/src/installers/binary.rs +++ b/src/installers/binary.rs @@ -76,6 +76,9 @@ pub fn install( 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; @@ -88,6 +91,49 @@ pub fn install( }) } +#[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,