diff --git a/Cargo.toml b/Cargo.toml index d85a8fa..b28982d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -93,6 +93,7 @@ tree-sitter-ruby = { version = "0.23", optional = true } tree-sitter-php = { version = "0.24", optional = true } tree-sitter-scala = { version = "0.26", optional = true } tree-sitter-javascript = { version = "0.25", optional = true } +tempfile = "3" [features] default = ["tree-sitter"] @@ -116,7 +117,6 @@ tree-sitter = [ [dev-dependencies] assert_cmd = "2" predicates = "3" -tempfile = "3" tokio-test = "0.4" [profile.release] diff --git a/install.sh b/install.sh index c9b937b..9f56f08 100755 --- a/install.sh +++ b/install.sh @@ -109,21 +109,32 @@ install() { fi # Verify SHA256 checksum (prevents MITM / corrupted download). - info "Downloading checksums..." - if curl -fsSL "$CHECKSUMS_URL" -o "$CHECKSUM_FILE"; then + # Mandatory: a missing checksums file or entry is fatal unless the user + # explicitly opts out with CORA_SKIP_CHECKSUM=1. + if [ "${CORA_SKIP_CHECKSUM:-}" = "1" ]; then + warn "CORA_SKIP_CHECKSUM=1 set - checksum verification DISABLED. The binary is NOT verified." + else + info "Downloading checksums..." + if ! curl -fsSL "$CHECKSUMS_URL" -o "$CHECKSUM_FILE"; then + error "Failed to download checksums. Refusing to install an unverified binary (set CORA_SKIP_CHECKSUM=1 to override, unsafe)." + fi info "Verifying SHA256 checksum..." - EXPECTED=$(grep -F "$ARCHIVE_NAME" "$CHECKSUM_FILE" | awk '{print $1}') - if [ -n "$EXPECTED" ]; then + # Exact filename match (optionally prefixed with '*' or './'). + EXPECTED=$(awk -v n="$ARCHIVE_NAME" '{f=$2; sub(/^\*/, "", f); sub(/^\.\//, "", f); if (f == n) {print $1; exit}}' "$CHECKSUM_FILE") + if [ -z "$EXPECTED" ]; then + error "Checksum for ${ARCHIVE_NAME} not found in checksums file. Refusing to install (set CORA_SKIP_CHECKSUM=1 to override, unsafe)." + fi + if command -v sha256sum >/dev/null 2>&1; then ACTUAL=$(sha256sum "$ARCHIVE" | awk '{print $1}') - if [ "$ACTUAL" != "$EXPECTED" ]; then - error "Checksum mismatch! Expected: ${EXPECTED}, got: ${ACTUAL}" - fi - info "Checksum verified: $EXPECTED" + elif command -v shasum >/dev/null 2>&1; then + ACTUAL=$(shasum -a 256 "$ARCHIVE" | awk '{print $1}') else - warn "Checksum for ${ARCHIVE_NAME} not found in checksums file — skipping verification" + error "Neither sha256sum nor shasum found; cannot verify checksum (set CORA_SKIP_CHECKSUM=1 to override, unsafe)." fi - else - warn "Failed to download checksums — skipping verification" + if [ "$ACTUAL" != "$EXPECTED" ]; then + error "Checksum mismatch! Expected: ${EXPECTED}, got: ${ACTUAL}" + fi + info "Checksum verified: $EXPECTED" fi # Verify archive contents before extraction (CWE-22 path traversal). @@ -133,8 +144,17 @@ install() { error "Archive contains unsafe paths (absolute or directory traversal) — refusing to extract" fi + # Reject symlink/hardlink entries (first char of the verbose mode string: + # 'l' = symlink; bsdtar lists hardlinks with 'h'). + if tar -tvzf "$ARCHIVE" | grep -qE '^[lh]'; then + error "Archive contains symlink/hardlink entries - refusing to extract" + fi + info "Extracting..." - tar -xzf "$ARCHIVE" -C "$TEMP_DIR" + # Extract only the single binary entry (tolerate an optional ./ prefix). + tar -xzf "$ARCHIVE" -C "$TEMP_DIR" "${BINARY_NAME}" 2>/dev/null \ + || tar -xzf "$ARCHIVE" -C "$TEMP_DIR" "./${BINARY_NAME}" \ + || error "Binary '${BINARY_NAME}' not found in archive" mkdir -p "$INSTALL_DIR" mv "${TEMP_DIR}/${BINARY_NAME}" "${INSTALL_DIR}/" diff --git a/src/commands/upgrade.rs b/src/commands/upgrade.rs index f2bf492..4694ff8 100644 --- a/src/commands/upgrade.rs +++ b/src/commands/upgrade.rs @@ -16,6 +16,15 @@ use sha2::{Digest, Sha256}; const REPO: &str = "codecoradev/cora-code"; const BINARY_NAME: &str = "cora"; +/// Maximum accepted size for a release archive (compressed). +const MAX_ARCHIVE_BYTES: u64 = 256 * 1024 * 1024; +/// Maximum accepted size for the checksums file / API JSON. +const MAX_SMALL_BYTES: u64 = 1024 * 1024; +/// Maximum accepted size for the extracted binary. +const MAX_BINARY_BYTES: u64 = 512 * 1024 * 1024; +const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); +const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); + /// Entry point for `cora upgrade`. /// /// `check_only` = true corresponds to `cora upgrade --check`: @@ -105,15 +114,16 @@ pub async fn run(yes: bool, check_only: bool) -> anyhow::Result { println!("{} Downloading {archive_name} ...", "[INFO]".green()); - let temp_dir = std::env::temp_dir().join(format!("cora-update-{latest_version}")); - fs::create_dir_all(&temp_dir).map_err(|e| anyhow::anyhow!("Failed to create temp dir: {e}"))?; + // Random, 0700 temp dir (removed on drop, including early returns). + let temp_guard = + tempfile::tempdir().map_err(|e| anyhow::anyhow!("Failed to create temp dir: {e}"))?; + let temp_dir = temp_guard.path().to_path_buf(); let archive_path = temp_dir.join(&archive_name); // Download using a blocking tokio runtime (reqwest is async-only) - let archive_bytes = match download_async(&download_url).await { + let archive_bytes = match download_async(&download_url, MAX_ARCHIVE_BYTES).await { Ok(b) => b, Err(e) => { - let _ = fs::remove_dir_all(&temp_dir); eprintln!("{} Download failed: {e}", "[ERROR]".red()); return Ok(1); } @@ -129,22 +139,34 @@ pub async fn run(yes: bool, check_only: bool) -> anyhow::Result { println!("{} Verifying checksum ...", "[INFO]".green()); - let skip_checksum = std::env::var("CORA_UPGRADE_SKIP_CHECKSUM") - .map(|v| v == "1" || v == "true") - .unwrap_or(false); + let skip_checksum = match skip_checksum_decision( + std::env::var("CORA_UPGRADE_SKIP_CHECKSUM").ok().as_deref(), + std::env::var("CORA_UPGRADE_I_UNDERSTAND").ok().as_deref(), + ) { + Ok(v) => v, + Err(msg) => { + eprintln!("{} {msg}", "[ERROR]".red()); + return Ok(1); + } + }; if skip_checksum { - println!( - "{} Checksum verification skipped (CORA_UPGRADE_SKIP_CHECKSUM=1)", - "[WARN]".yellow() + eprintln!( + "{} !!! CHECKSUM VERIFICATION DISABLED (CORA_UPGRADE_SKIP_CHECKSUM + CORA_UPGRADE_I_UNDERSTAND) !!!", + "[WARN]".yellow().bold() + ); + eprintln!( + "{} The downloaded binary is NOT verified and will replace the running one.", + "[WARN]".yellow().bold() ); } else { - let checksums_text = match download_async(&checksums_url).await { + let checksums_text = match download_async(&checksums_url, MAX_SMALL_BYTES).await { Ok(b) => String::from_utf8_lossy(&b).to_string(), Err(e) => { - let _ = fs::remove_dir_all(&temp_dir); eprintln!("{} Failed to download checksums: {e}", "[ERROR]".red()); - eprintln!(" Set CORA_UPGRADE_SKIP_CHECKSUM=1 to skip."); + eprintln!( + " (Unsafe bypass: CORA_UPGRADE_SKIP_CHECKSUM=1 CORA_UPGRADE_I_UNDERSTAND=1)" + ); return Ok(1); } }; @@ -152,19 +174,19 @@ pub async fn run(yes: bool, check_only: bool) -> anyhow::Result { let expected = match parse_checksum(&checksums_text, &archive_name) { Some(h) => h, None => { - let _ = fs::remove_dir_all(&temp_dir); eprintln!( "{} Checksum for '{archive_name}' not found in checksums file.", "[ERROR]".red() ); - eprintln!(" Set CORA_UPGRADE_SKIP_CHECKSUM=1 to bypass."); + eprintln!( + " (Unsafe bypass: CORA_UPGRADE_SKIP_CHECKSUM=1 CORA_UPGRADE_I_UNDERSTAND=1)" + ); return Ok(1); } }; let actual = sha256_file(&archive_path)?; if actual != expected { - let _ = fs::remove_dir_all(&temp_dir); eprintln!( "{} Checksum mismatch! Expected: {expected}, got: {actual}", "[ERROR]".red() @@ -174,51 +196,15 @@ pub async fn run(yes: bool, check_only: bool) -> anyhow::Result { println!("{} Checksum verified: {actual}", "[INFO]".green()); } - // Verify archive integrity (path traversal check) - let file = fs::File::open(&archive_path) - .map_err(|e| anyhow::anyhow!("Failed to open archive: {e}"))?; - let gz = flate2::read::GzDecoder::new(file); - let mut archive = tar::Archive::new(gz); - for entry in archive - .entries() - .map_err(|e| anyhow::anyhow!("Failed to read archive entries: {e}"))? - { - let entry = entry.map_err(|e| anyhow::anyhow!("Failed to read archive entry: {e}"))?; - let path = entry - .path() - .map_err(|e| anyhow::anyhow!("Archive path error: {e}"))?; - let path_str = path.to_string_lossy(); - if path_str.starts_with('/') || path_str.contains("..") { - let _ = fs::remove_dir_all(&temp_dir); - eprintln!( - "{} Archive contains unsafe paths — refusing to extract", - "[ERROR]".red() - ); + // Extract only the single regular-file binary entry; reject links. + println!("{} Extracting ...", "[INFO]".green()); + let extracted_binary = match extract_binary(&archive_path, &temp_dir) { + Ok(p) => p, + Err(e) => { + eprintln!("{} {e}", "[ERROR]".red()); return Ok(1); } - } - drop(archive); - - // Extract - println!("{} Extracting ...", "[INFO]".green()); - let file = fs::File::open(&archive_path) - .map_err(|e| anyhow::anyhow!("Failed to open archive: {e}"))?; - let gz = flate2::read::GzDecoder::new(file); - let mut archive = tar::Archive::new(gz); - archive - .unpack(&temp_dir) - .map_err(|e| anyhow::anyhow!("Failed to extract archive: {e}"))?; - - // Find and replace binary - let extracted_binary = temp_dir.join(BINARY_NAME); - if !extracted_binary.exists() { - let _ = fs::remove_dir_all(&temp_dir); - eprintln!( - "{} Binary '{BINARY_NAME}' not found in archive", - "[ERROR]".red() - ); - return Ok(1); - } + }; let install_dir = current_exe .parent() @@ -244,7 +230,6 @@ pub async fn run(yes: bool, check_only: bool) -> anyhow::Result { } Ok(output) => { let _ = fs::remove_file(&temp_new); - let _ = fs::remove_dir_all(&temp_dir); eprintln!( "{} New binary failed to run: {}", "[ERROR]".red(), @@ -254,7 +239,6 @@ pub async fn run(yes: bool, check_only: bool) -> anyhow::Result { } Err(e) => { let _ = fs::remove_file(&temp_new); - let _ = fs::remove_dir_all(&temp_dir); eprintln!("{} Failed to verify new binary: {e}", "[ERROR]".red()); return Ok(1); } @@ -265,7 +249,6 @@ pub async fn run(yes: bool, check_only: bool) -> anyhow::Result { .map_err(|e| anyhow::anyhow!("Failed to replace binary: {e}"))?; // Cleanup - let _ = fs::remove_dir_all(&temp_dir); println!( "{} Update complete. ({current_version} → {latest_version})", @@ -275,26 +258,137 @@ pub async fn run(yes: bool, check_only: bool) -> anyhow::Result { Ok(0) } -/// Download a URL using the current tokio runtime. -/// -/// reqwest in cora-code is async-only (no `blocking` feature). -/// This must be called from within a tokio runtime context. -async fn download_async(url: &str) -> anyhow::Result> { - let resp = reqwest::get(url) +fn http_client(follow_redirects: bool) -> anyhow::Result { + let mut b = reqwest::Client::builder() + .connect_timeout(CONNECT_TIMEOUT) + .timeout(REQUEST_TIMEOUT) + .user_agent("cora-upgrade"); + if !follow_redirects { + b = b.redirect(reqwest::redirect::Policy::none()); + } + b.build() + .map_err(|e| anyhow::anyhow!("Failed to build HTTP client: {e}")) +} + +/// Download a URL with timeouts and a hard cap on the body size. +async fn download_async(url: &str, max_bytes: u64) -> anyhow::Result> { + let mut resp = http_client(true)? + .get(url) + .send() .await .map_err(|e| anyhow::anyhow!("HTTP request failed: {e}"))?; if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - anyhow::bail!("HTTP {status}: {body}"); + anyhow::bail!("HTTP {}", resp.status()); + } + if let Some(len) = resp.content_length() { + if len > max_bytes { + anyhow::bail!("Response too large ({len} bytes, limit {max_bytes})"); + } } - let bytes = resp - .bytes() + let mut buf: Vec = Vec::new(); + while let Some(chunk) = resp + .chunk() .await - .map_err(|e| anyhow::anyhow!("Failed to read response body: {e}"))?; - Ok(bytes.to_vec()) + .map_err(|e| anyhow::anyhow!("Failed to read response body: {e}"))? + { + if buf.len() as u64 + chunk.len() as u64 > max_bytes { + anyhow::bail!("Response exceeded size limit of {max_bytes} bytes"); + } + buf.extend_from_slice(&chunk); + } + Ok(buf) +} + +/// Decide whether checksum verification may be skipped. +/// +/// Requires BOTH `CORA_UPGRADE_SKIP_CHECKSUM` (1/true) and the explicit +/// acknowledgement `CORA_UPGRADE_I_UNDERSTAND` (1/true). The skip variable +/// alone is an error rather than a silent downgrade. +fn skip_checksum_decision(skip: Option<&str>, ack: Option<&str>) -> Result { + let on = |v: Option<&str>| matches!(v, Some("1") | Some("true")); + if !on(skip) { + return Ok(false); + } + if on(ack) { + Ok(true) + } else { + Err("CORA_UPGRADE_SKIP_CHECKSUM is set but unsafe skipping also requires CORA_UPGRADE_I_UNDERSTAND=1. Refusing to continue." + .to_string()) + } +} + +/// A release tag must look like `vX.Y.Z[-suffix]` (safe chars only) since it is +/// interpolated into download URLs. +fn is_valid_tag(tag: &str) -> bool { + tag.len() <= 64 + && tag.starts_with('v') + && tag.len() > 1 + && tag + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+')) +} + +/// Extract only the top-level `cora` regular file from the tarball into `dest_dir`. +/// +/// Any symlink/hardlink entry anywhere in the archive causes the whole archive +/// to be rejected, as do unsafe paths. +fn extract_binary( + archive_path: &std::path::Path, + dest_dir: &std::path::Path, +) -> anyhow::Result { + let file = + fs::File::open(archive_path).map_err(|e| anyhow::anyhow!("Failed to open archive: {e}"))?; + let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(file)); + let dest = dest_dir.join(BINARY_NAME); + let mut found = false; + + for entry in archive + .entries() + .map_err(|e| anyhow::anyhow!("Failed to read archive entries: {e}"))? + { + let mut entry = entry.map_err(|e| anyhow::anyhow!("Failed to read archive entry: {e}"))?; + let ty = entry.header().entry_type(); + let path = entry + .path() + .map_err(|e| anyhow::anyhow!("Archive path error: {e}"))? + .into_owned(); + let path_str = path.to_string_lossy().to_string(); + + if path_str.starts_with('/') + || path + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + anyhow::bail!("Archive contains unsafe paths - refusing to extract"); + } + if ty.is_symlink() || ty.is_hard_link() { + anyhow::bail!( + "Archive contains a symlink/hardlink entry ('{path_str}') - refusing to extract" + ); + } + if path_str.trim_start_matches("./") == BINARY_NAME { + if !ty.is_file() { + anyhow::bail!("Archive entry '{BINARY_NAME}' is not a regular file"); + } + if found { + anyhow::bail!("Archive contains duplicate '{BINARY_NAME}' entries"); + } + if entry.size() > MAX_BINARY_BYTES { + anyhow::bail!("Binary in archive is too large ({} bytes)", entry.size()); + } + entry + .unpack(&dest) + .map_err(|e| anyhow::anyhow!("Failed to extract binary: {e}"))?; + found = true; + } + } + + if !found { + anyhow::bail!("Binary '{BINARY_NAME}' not found in archive"); + } + Ok(dest) } fn detect_os() -> String { @@ -328,10 +422,11 @@ fn get_target(os: &str, arch: &str) -> Result { /// Primary: parse 302 redirect (no API call, no rate limit). /// Fallback: GitHub REST API. async fn get_latest_version() -> Result { - let client = reqwest::Client::new(); + let probe = http_client(false).map_err(|e| e.to_string())?; + let client = http_client(true).map_err(|e| e.to_string())?; - // Primary: HEAD request, parse Location header redirect - let resp = client + // Primary: HEAD request, parse Location header redirect (not followed) + let resp = probe .head(format!("https://github.com/{REPO}/releases/latest")) .send() .await @@ -341,8 +436,9 @@ async fn get_latest_version() -> Result { let loc = location.to_str().unwrap_or_default(); // Redirect URL: https://github.com/codecoradev/cora-code/releases/tag/v0.14.0 if let Some(tag) = loc.rsplit('/').next() { - if tag.starts_with('v') { - return Ok(tag.trim_end_matches('?').to_string()); + let tag = tag.trim_end_matches('?'); + if is_valid_tag(tag) { + return Ok(tag.to_string()); } } } @@ -351,18 +447,27 @@ async fn get_latest_version() -> Result { let api_url = format!("https://api.github.com/repos/{REPO}/releases/latest"); let resp = client .get(&api_url) - .header("User-Agent", "cora-upgrade") .send() .await .map_err(|e| format!("GitHub API failed: {e}"))?; if resp.status().is_success() { - let json: serde_json::Value = resp - .json() + if resp.content_length().is_some_and(|l| l > MAX_SMALL_BYTES) { + return Err("GitHub API response too large".to_string()); + } + let body = resp + .bytes() .await + .map_err(|e| format!("Failed to read GitHub API response: {e}"))?; + if body.len() as u64 > MAX_SMALL_BYTES { + return Err("GitHub API response too large".to_string()); + } + let json: serde_json::Value = serde_json::from_slice(&body) .map_err(|e| format!("Failed to parse GitHub API response: {e}"))?; if let Some(tag) = json["tag_name"].as_str() { - return Ok(tag.to_string()); + if is_valid_tag(tag) { + return Ok(tag.to_string()); + } } } @@ -374,8 +479,12 @@ async fn get_latest_version() -> Result { fn parse_checksum(checksums_text: &str, archive_name: &str) -> Option { for line in checksums_text.lines() { let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() >= 2 && parts[1].contains(archive_name) { - return Some(parts[0].to_string()); + if parts.len() == 2 { + let name = parts[1].trim_start_matches('*'); + let name = name.strip_prefix("./").unwrap_or(name); + if name == archive_name { + return Some(parts[0].to_ascii_lowercase()); + } } } None @@ -387,3 +496,114 @@ fn sha256_file(path: &PathBuf) -> anyhow::Result { io::copy(&mut file, &mut hasher).map_err(|e| anyhow::anyhow!("Failed to read file: {e}"))?; Ok(format!("{:x}", hasher.finalize())) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn checksum_exact_match_only() { + let t = "aaa cora-x-v1.tar.gz.sig\nbbb evil-cora-x-v1.tar.gz\nccc *cora-x-v1.tar.gz\n"; + assert_eq!( + parse_checksum(t, "cora-x-v1.tar.gz").as_deref(), + Some("ccc") + ); + assert_eq!( + parse_checksum("ddd ./cora-x-v1.tar.gz", "cora-x-v1.tar.gz").as_deref(), + Some("ddd") + ); + assert_eq!( + parse_checksum("aaa cora-x-v1.tar.gz.sig", "cora-x-v1.tar.gz"), + None + ); + assert_eq!( + parse_checksum("bbb evil-cora-x-v1.tar.gz", "cora-x-v1.tar.gz"), + None + ); + } + + #[test] + fn skip_checksum_requires_ack() { + assert_eq!(skip_checksum_decision(None, None), Ok(false)); + assert_eq!(skip_checksum_decision(None, Some("1")), Ok(false)); + assert!(skip_checksum_decision(Some("1"), None).is_err()); + assert!(skip_checksum_decision(Some("true"), Some("no")).is_err()); + assert_eq!(skip_checksum_decision(Some("1"), Some("1")), Ok(true)); + } + + #[test] + fn tag_validation() { + assert!(is_valid_tag("v0.14.0")); + assert!(is_valid_tag("v1.0.0-rc.1")); + assert!(!is_valid_tag("0.14.0")); + assert!(!is_valid_tag("v1/../x")); + assert!(!is_valid_tag("v1?a=b")); + assert!(!is_valid_tag("v")); + } + + fn build_tar(entries: &[(&str, tar::EntryType, &[u8], Option<&str>)]) -> Vec { + let mut b = tar::Builder::new(Vec::new()); + for (name, ty, data, link) in entries { + let mut h = tar::Header::new_gnu(); + h.set_entry_type(*ty); + h.set_size(data.len() as u64); + h.set_mode(0o755); + if let Some(l) = link { + h.set_link_name(l).unwrap(); + } + h.set_cksum(); + b.append_data(&mut h, name, *data).unwrap(); + } + let raw = b.into_inner().unwrap(); + let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + enc.write_all(&raw).unwrap(); + enc.finish().unwrap() + } + + fn write_archive(dir: &std::path::Path, bytes: &[u8]) -> PathBuf { + let p = dir.join("a.tar.gz"); + fs::write(&p, bytes).unwrap(); + p + } + + #[test] + fn extract_regular_binary_only() { + let d = tempfile::tempdir().unwrap(); + let out = tempfile::tempdir().unwrap(); + let tgz = build_tar(&[ + ("README", tar::EntryType::Regular, b"hi", None), + ("cora", tar::EntryType::Regular, b"BIN", None), + ]); + let a = write_archive(d.path(), &tgz); + let p = extract_binary(&a, out.path()).unwrap(); + assert_eq!(fs::read(p).unwrap(), b"BIN"); + assert!(!out.path().join("README").exists()); + } + + #[test] + fn extract_rejects_symlink_and_hardlink() { + for ty in [tar::EntryType::Symlink, tar::EntryType::Link] { + let d = tempfile::tempdir().unwrap(); + let out = tempfile::tempdir().unwrap(); + let tgz = build_tar(&[ + ("evil", ty, b"", Some("/etc/passwd")), + ("cora", tar::EntryType::Regular, b"BIN", None), + ]); + let a = write_archive(d.path(), &tgz); + assert!(extract_binary(&a, out.path()).is_err()); + } + } + + #[test] + fn extract_rejects_symlink_named_cora_and_missing_binary() { + let d = tempfile::tempdir().unwrap(); + let out = tempfile::tempdir().unwrap(); + let tgz = build_tar(&[("cora", tar::EntryType::Symlink, b"", Some("/bin/sh"))]); + let a = write_archive(d.path(), &tgz); + assert!(extract_binary(&a, out.path()).is_err()); + + let tgz = build_tar(&[("other", tar::EntryType::Regular, b"x", None)]); + let a = write_archive(d.path(), &tgz); + assert!(extract_binary(&a, out.path()).is_err()); + } +}