v0.2.14 : correction install binaire - detection des archives par magic bytes (un zip renomme n est plus copie tel quel), preference des archives sur les setup/installers, validation de l executable (MZ/ELF/Mach-O) avec erreur claire avant le premier start
This commit is contained in:
+118
-29
@@ -281,6 +281,14 @@ fn platform_score(lower: &str, platform: &str, arch: &str) -> Option<u32> {
|
||||
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<u32> {
|
||||
// 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> {
|
||||
/// 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<ArchiveKind> {
|
||||
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<bool> {
|
||||
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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user