From f7b90155d24935d9139bbb8cd64bcee9f49c29f8 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Thu, 11 Jun 2026 13:59:53 -0700 Subject: [PATCH] Harden bundled Copilot CLI extraction against corrupt/partial installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The embedded-CLI installer opened the final binary path with open→truncate→write and returned without re-verifying the written bytes. An interrupted write, a multi-process race, or Windows Defender/ASR quarantining the freshly-written executable could leave an invalid image that was handed back as "good" — surfacing downstream as ERROR_BAD_EXE_FORMAT ("not a valid application for this OS platform"). Replace the in-place write with a safe publish pattern: - Atomic write: extract to a unique temp file (pid + counter + nanos) in the *same* target directory, flush + fsync, set 0o755 on unix, then fs::rename onto the final path. rename overwrites atomically on POSIX; on Windows (no atomic overwrite) we remove-then-rename, with the race guarded by re-verification and peer-install acceptance. - Verify after publish: the bytes extracted from the trusted embedded archive are the known-good reference. Verify the staged temp file and again re-verify the published file byte-for-byte (size first), catching truncation or AV tampering between staging and publishing. - Trustworthy fast path: an existing install is reused only after a cheap re-check (integrity-marker size + executable-image header); a truncated, empty, or quarantined binary is re-extracted instead of trusted. - Retry + fallback: re-extract/re-publish up to 3 times to ride out transient AV interference, then surface a clear, actionable error ("appears blocked or corrupt ... possibly quarantined by antivirus") rather than returning a broken path. Adds unit tests covering the temp+rename publish, marker recording, detection of corrupt/truncated/unmarked/invalid-image installs, and post-write size/content verification. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/src/embeddedcli.rs | 545 ++++++++++++++++++++++++++++++++++++---- rust/src/lib.rs | 6 +- 2 files changed, 499 insertions(+), 52 deletions(-) diff --git a/rust/src/embeddedcli.rs b/rust/src/embeddedcli.rs index a92f37d46..56f97e0c0 100644 --- a/rust/src/embeddedcli.rs +++ b/rust/src/embeddedcli.rs @@ -9,17 +9,36 @@ //! **raw archive bytes** //! into the consumer's compiled artifact via `include_bytes!()`. Extraction //! to a real on-disk path is deferred until the first call to -//! [`path`] / [`install_at`] — at which point the bytes are part of the -//! consumer's signed binary and trusted, so no further hashing is done. +//! [`path`] / [`install_at`]. +//! +//! The embedded bytes are part of the consumer's signed binary and therefore +//! trusted *as the source of truth* — but the bytes that land on disk are not. +//! A non-atomic write, a multi-process race, or antivirus quarantining the +//! freshly-written executable can leave a truncated or corrupt image that, if +//! handed back as "good", fails to launch (e.g. Windows `ERROR_BAD_EXE_FORMAT`). +//! Installation therefore: extracts to a unique temp file in the target dir, +//! fsyncs and marks it executable, verifies the staged bytes against the +//! trusted in-memory image, atomically renames it into place, re-verifies the +//! published file, and records an integrity marker. Subsequent runs trust an +//! existing install only after a cheap re-check (size marker + executable-image +//! header); anything that looks truncated or quarantined is re-extracted, and +//! the whole publish is retried before surfacing a clear, actionable error. -#[cfg(has_bundled_cli)] +// The atomic-publish + verify helpers (and their unit tests) are pure +// std-only logic that doesn't touch the embedded archive, so they compile +// whenever the binary is bundled *or* we're building the test harness — +// the standard `cargo test --no-default-features` job has `has_bundled_cli` +// off but still needs to exercise them. +#[cfg(any(has_bundled_cli, test))] use std::fs; #[cfg(all(has_bundled_cli, not(windows)))] use std::io::Read; -#[cfg(has_bundled_cli)] +#[cfg(any(has_bundled_cli, test))] use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::OnceLock; +#[cfg(any(has_bundled_cli, test))] +use std::sync::atomic::{AtomicU64, Ordering}; #[cfg(has_bundled_cli)] use tracing::{info, warn}; @@ -61,10 +80,11 @@ static INSTALLED_PATH: OnceLock> = OnceLock::new(); /// and returns the resulting path. The cache dir comes from /// [`dirs::cache_dir()`] — `%LOCALAPPDATA%` on Windows, /// `~/Library/Caches/` on macOS, `$XDG_CACHE_HOME` (or `~/.cache/`) on -/// Linux. Subsequent calls return the cached result. The extraction -/// is skipped when the target file already exists — the per-version -/// install directory and the assumption that the consumer's binary is -/// trusted mean no further hashing is needed. +/// Linux. Subsequent calls return the cached result. Extraction +/// is skipped when a previously-published binary is still present and +/// passes a cheap integrity re-check (size marker + executable-image +/// header); a truncated, empty, or quarantined binary is re-extracted +/// rather than returned. /// /// Returns `None` if no CLI was embedded at build time. #[cfg(feature = "bundled-cli")] @@ -93,7 +113,9 @@ pub(crate) fn path() -> Option { /// default `/github-copilot-sdk/cli//` location /// (see [`path`] for the per-platform mapping). /// -/// Idempotent: skips extraction if the target binary already exists. +/// Idempotent: skips extraction when an already-published binary passes the +/// integrity re-check (size marker + executable-image header), and +/// re-extracts a corrupt or quarantined one. /// Returns `None` when the SDK was built without a bundled CLI. #[cfg(feature = "bundled-cli")] #[allow(dead_code)] // Used by resolve.rs when ClientOptions::bundled_cli_extract_dir is set. @@ -128,6 +150,13 @@ fn default_install_dir(version: &str) -> PathBuf { } } +/// Number of times we re-extract + re-publish the binary before giving up. +/// A single transient failure (e.g. antivirus briefly locking or quarantining +/// the freshly-written file) is retried; a persistent one surfaces a clear +/// error rather than handing back a broken path. +#[cfg(has_bundled_cli)] +const MAX_PUBLISH_ATTEMPTS: u32 = 3; + #[cfg(has_bundled_cli)] fn install(install_dir: &Path, archive: &[u8]) -> Result { let verbose = std::env::var("COPILOT_CLI_INSTALL_VERBOSE").ok().as_deref() == Some("1"); @@ -136,29 +165,277 @@ fn install(install_dir: &Path, archive: &[u8]) -> Result = None; + for attempt in 1..=MAX_PUBLISH_ATTEMPTS { + match publish_verified(install_dir, &final_path, &marker_path, &bytes) { + Ok(()) => { + if verbose { + eprintln!( + "embedded CLI extracted to {} in {:?}", + final_path.display(), + start.elapsed() + ); + } + return Ok(final_path); + } + Err(e) => { + // Another process may have raced us and published the same + // good binary; if what's on disk matches our trusted bytes, + // accept its install rather than fighting over it. + if verify_on_disk_matches(&final_path, &bytes).is_ok() { + let _ = write_marker(&marker_path, bytes.len() as u64); + return Ok(final_path); + } + warn!(attempt, error = %e, "embedded CLI publish attempt failed; retrying"); + last_err = Some(e); + } + } + } + + Err(EmbeddedCliError::with_source( + EmbeddedCliErrorKind::Blocked, + last_err, + )) +} + +/// Path of the integrity marker written next to the installed binary. Its +/// presence (and recorded size) is proof a previous run published a verified +/// binary, letting the fast path skip re-extraction without trusting a bare +/// `is_file()` check. +#[cfg(any(has_bundled_cli, test))] +fn marker_path(install_dir: &Path) -> PathBuf { + install_dir.join(".copilot-cli.ok") +} + +/// Cheap, allocation-light validity check for an already-installed binary: +/// the file exists and is non-empty, an integrity marker recording its +/// expected size is present and matches, and the first bytes look like a +/// valid executable image for this platform. Catches the realistic failure +/// modes (zero-length / truncated / quarantined-to-garbage) without re-reading +/// the whole file. +#[cfg(any(has_bundled_cli, test))] +fn existing_install_is_valid(final_path: &Path, marker_path: &Path) -> bool { + let Ok(meta) = fs::metadata(final_path) else { + return false; + }; + if !meta.is_file() || meta.len() == 0 { + return false; + } + match read_marker_len(marker_path) { + Some(expected) if expected == meta.len() => looks_like_valid_image(final_path), + _ => false, + } +} + +/// Extract → stage in a unique temp file in the *same* directory → verify the +/// staged bytes → atomically rename into place → re-verify the published file +/// → write the integrity marker. Every step that can leave a partial file +/// cleans up after itself, so a failure never leaves a half-written binary at +/// the final path. +#[cfg(any(has_bundled_cli, test))] +fn publish_verified( + install_dir: &Path, + final_path: &Path, + marker_path: &Path, + bytes: &[u8], +) -> Result<(), EmbeddedCliError> { + let tmp = write_temp_file(install_dir, bytes)?; + + // Verify the staged copy before it ever becomes the live binary, so a + // short write or in-flight antivirus tampering is caught here. + if let Err(e) = verify_on_disk_matches(&tmp, bytes) { + let _ = fs::remove_file(&tmp); + return Err(e); + } + + if let Err(e) = publish(&tmp, final_path) { + let _ = fs::remove_file(&tmp); + return Err(e); + } + + // Re-verify after the rename: catches the window where antivirus + // quarantines or rewrites the file between staging and publishing. + verify_on_disk_matches(final_path, bytes)?; + + write_marker(marker_path, bytes.len() as u64)?; + Ok(()) +} + +/// Write `contents` to a uniquely-named temp file in `dir` (same filesystem as +/// the final path so the later rename is atomic), flushing and fsync-ing the +/// bytes to disk and marking it executable on unix before returning its path. +#[cfg(any(has_bundled_cli, test))] +fn write_temp_file(dir: &Path, contents: &[u8]) -> Result { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let unique = format!( + ".copilot-cli.tmp.{}.{}.{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed), + nanos + ); + let tmp = dir.join(unique); + + // `create_new` guarantees we never clobber a sibling's in-flight temp + // file (the pid + counter + nanos name already makes that practically + // impossible). + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp) + .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Io, e))?; + + if let Err(e) = file + .write_all(contents) + .and_then(|()| file.flush()) + .and_then(|()| file.sync_all()) + { + drop(file); + let _ = fs::remove_file(&tmp); + return Err(EmbeddedCliError::new(EmbeddedCliErrorKind::Io, e)); + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Err(e) = fs::set_permissions(&tmp, fs::Permissions::from_mode(0o755)) { + drop(file); + let _ = fs::remove_file(&tmp); + return Err(EmbeddedCliError::new(EmbeddedCliErrorKind::Io, e)); + } } - Ok(final_path) + drop(file); + Ok(tmp) +} + +/// Atomically move the staged temp file onto `final_path`. +/// +/// `rename` replaces the target atomically on POSIX, but on Windows it fails +/// when the target already exists — so on that error we remove the stale file +/// and retry. The remove-then-rename is the only non-atomic window, and it's +/// guarded upstream: callers re-verify the published file and, on a lost race, +/// accept a peer's identical install instead of erroring. +#[cfg(any(has_bundled_cli, test))] +fn publish(tmp: &Path, final_path: &Path) -> Result<(), EmbeddedCliError> { + match fs::rename(tmp, final_path) { + Ok(()) => Ok(()), + Err(_) if final_path.exists() => { + let _ = fs::remove_file(final_path); + fs::rename(tmp, final_path) + .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Publish, e)) + } + Err(e) => Err(EmbeddedCliError::new(EmbeddedCliErrorKind::Publish, e)), + } +} + +/// Read the file at `path` and confirm it byte-for-byte matches the trusted +/// `expected` image. Size is checked first so the common corruption case +/// (truncation) produces a precise error. +#[cfg(any(has_bundled_cli, test))] +fn verify_on_disk_matches(path: &Path, expected: &[u8]) -> Result<(), EmbeddedCliError> { + let actual = fs::read(path).map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Io, e))?; + if actual.len() != expected.len() { + return Err(EmbeddedCliError::with_message( + EmbeddedCliErrorKind::Verification, + format!( + "size mismatch: on-disk {} bytes, expected {} bytes", + actual.len(), + expected.len() + ), + )); + } + if actual != expected { + return Err(EmbeddedCliError::with_message( + EmbeddedCliErrorKind::Verification, + "on-disk binary differs from the embedded image", + )); + } + Ok(()) +} + +/// Best-effort check that the first bytes of `path` are a valid executable +/// image header for the current platform (PE on Windows, Mach-O on macOS, +/// ELF elsewhere). Returns `false` on any I/O error or unrecognized header. +#[cfg(any(has_bundled_cli, test))] +fn looks_like_valid_image(path: &Path) -> bool { + use std::io::Read as _; + let mut buf = [0u8; 4]; + let Ok(mut file) = fs::File::open(path) else { + return false; + }; + let Ok(read) = file.read(&mut buf) else { + return false; + }; + let head = &buf[..read]; + + #[cfg(windows)] + { + head.starts_with(b"MZ") + } + #[cfg(target_os = "macos")] + { + matches!( + head, + [0xfe, 0xed, 0xfa, 0xce] // Mach-O 32-bit + | [0xfe, 0xed, 0xfa, 0xcf] // Mach-O 64-bit + | [0xce, 0xfa, 0xed, 0xfe] // byte-swapped 32-bit + | [0xcf, 0xfa, 0xed, 0xfe] // byte-swapped 64-bit + | [0xca, 0xfe, 0xba, 0xbe] // universal (fat) + | [0xbe, 0xba, 0xfe, 0xca] // byte-swapped universal + ) + } + #[cfg(all(not(windows), not(target_os = "macos")))] + { + head.starts_with(b"\x7fELF") + } +} + +/// Write the integrity marker recording the published binary's size. Best +/// effort: a torn write just means the next run can't parse it and re-extracts. +#[cfg(any(has_bundled_cli, test))] +fn write_marker(marker_path: &Path, size: u64) -> Result<(), EmbeddedCliError> { + fs::write(marker_path, size.to_string()) + .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Io, e)) +} + +/// Parse the size recorded in the integrity marker, or `None` if it's missing +/// or unparsable. +#[cfg(any(has_bundled_cli, test))] +fn read_marker_len(marker_path: &Path) -> Option { + fs::read_to_string(marker_path) + .ok()? + .trim() + .parse::() + .ok() } #[cfg(all(has_bundled_cli, not(windows)))] @@ -217,29 +494,7 @@ fn sanitize_version(version: &str) -> String { .collect() } -#[cfg(has_bundled_cli)] -fn write_binary(path: &Path, data: &[u8]) -> Result<(), EmbeddedCliError> { - let mut file = fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(path) - .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Io, e))?; - - file.write_all(data) - .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Io, e))?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(path, fs::Permissions::from_mode(0o755)) - .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Io, e))?; - } - - Ok(()) -} - -#[cfg(has_bundled_cli)] +#[cfg(any(has_bundled_cli, test))] #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[allow(dead_code)] enum EmbeddedCliErrorKind { @@ -250,9 +505,16 @@ enum EmbeddedCliErrorKind { Zip, BinaryNotFoundInArchive, Io, + /// Atomically renaming the staged temp file onto the final path failed. + Publish, + /// The published (or staged) file didn't match the trusted embedded image. + Verification, + /// Extraction kept producing a corrupt/missing binary across all retries — + /// most likely antivirus interference. + Blocked, } -#[cfg(has_bundled_cli)] +#[cfg(any(has_bundled_cli, test))] impl std::fmt::Display for EmbeddedCliErrorKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -265,17 +527,28 @@ impl std::fmt::Display for EmbeddedCliErrorKind { f.write_str("CLI binary not found in embedded archive") } EmbeddedCliErrorKind::Io => f.write_str("I/O error"), + EmbeddedCliErrorKind::Publish => { + f.write_str("failed to publish the extracted CLI binary") + } + EmbeddedCliErrorKind::Verification => { + f.write_str("extracted CLI binary failed integrity verification") + } + EmbeddedCliErrorKind::Blocked => f.write_str( + "bundled CLI appears blocked or corrupt after multiple attempts \ + (possibly quarantined by antivirus)", + ), } } } -#[cfg(has_bundled_cli)] +#[cfg(any(has_bundled_cli, test))] #[allow(dead_code)] struct EmbeddedCliError { repr: crate::errors::Repr, } -#[cfg(has_bundled_cli)] +#[cfg(any(has_bundled_cli, test))] +#[allow(dead_code)] impl EmbeddedCliError { fn new(kind: EmbeddedCliErrorKind, error: E) -> Self where @@ -288,9 +561,29 @@ impl EmbeddedCliError { }), } } + + fn with_message( + kind: EmbeddedCliErrorKind, + message: impl Into>, + ) -> Self { + Self { + repr: crate::errors::Repr::SimpleMessage(kind, message.into()), + } + } + + /// Build an error from `kind`, attaching the last failure as the source + /// when one is available so the actionable message still carries context. + fn with_source(kind: EmbeddedCliErrorKind, source: Option) -> Self { + match source { + Some(source) => Self::new(kind, Box::new(source)), + None => Self { + repr: crate::errors::Repr::Simple(kind), + }, + } + } } -#[cfg(has_bundled_cli)] +#[cfg(any(has_bundled_cli, test))] impl From for EmbeddedCliError { fn from(kind: EmbeddedCliErrorKind) -> Self { Self { @@ -299,7 +592,7 @@ impl From for EmbeddedCliError { } } -#[cfg(has_bundled_cli)] +#[cfg(any(has_bundled_cli, test))] impl std::fmt::Display for EmbeddedCliError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match &self.repr { @@ -312,14 +605,14 @@ impl std::fmt::Display for EmbeddedCliError { } } -#[cfg(has_bundled_cli)] +#[cfg(any(has_bundled_cli, test))] impl std::fmt::Debug for EmbeddedCliError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "EmbeddedCliError({self})") } } -#[cfg(has_bundled_cli)] +#[cfg(any(has_bundled_cli, test))] impl std::error::Error for EmbeddedCliError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match &self.repr { @@ -328,3 +621,155 @@ impl std::error::Error for EmbeddedCliError { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Bytes whose header looks like a valid executable image on the host + /// platform, so `looks_like_valid_image` accepts them. `extra` padding + /// bytes follow the magic so size checks have something to disagree about. + fn fake_image(extra: usize) -> Vec { + let mut bytes = Vec::new(); + #[cfg(windows)] + bytes.extend_from_slice(b"MZ\x90\x00"); + #[cfg(target_os = "macos")] + bytes.extend_from_slice(&[0xfe, 0xed, 0xfa, 0xcf]); + #[cfg(all(not(windows), not(target_os = "macos")))] + bytes.extend_from_slice(b"\x7fELF"); + bytes.extend(std::iter::repeat_n(0xAB, extra)); + bytes + } + + #[test] + fn publish_verified_writes_and_records_marker() { + let dir = tempfile::tempdir().expect("tempdir"); + let final_path = dir.path().join("copilot-bin"); + let marker = marker_path(dir.path()); + let bytes = fake_image(2048); + + publish_verified(dir.path(), &final_path, &marker, &bytes).expect("publish"); + + assert!(final_path.is_file(), "binary should be published"); + assert_eq!(fs::read(&final_path).expect("read"), bytes); + assert_eq!(read_marker_len(&marker), Some(bytes.len() as u64)); + assert!(existing_install_is_valid(&final_path, &marker)); + + // No leftover temp files in the install dir. + let leftovers: Vec<_> = fs::read_dir(dir.path()) + .expect("read_dir") + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().contains(".tmp.")) + .collect(); + assert!(leftovers.is_empty(), "temp files should be cleaned up"); + } + + #[test] + fn publish_overwrites_an_existing_binary() { + let dir = tempfile::tempdir().expect("tempdir"); + let final_path = dir.path().join("copilot-bin"); + let marker = marker_path(dir.path()); + + // Pre-existing (stale) binary at the destination. + fs::write(&final_path, b"old contents").expect("seed"); + + let bytes = fake_image(512); + publish_verified(dir.path(), &final_path, &marker, &bytes).expect("publish"); + + assert_eq!(fs::read(&final_path).expect("read"), bytes); + } + + #[test] + fn corrupt_or_unmarked_install_is_rejected() { + let dir = tempfile::tempdir().expect("tempdir"); + let final_path = dir.path().join("copilot-bin"); + let marker = marker_path(dir.path()); + let bytes = fake_image(4096); + + // Missing binary entirely. + assert!(!existing_install_is_valid(&final_path, &marker)); + + // Valid binary but no marker (e.g. installed by an older SDK). + fs::write(&final_path, &bytes).expect("write binary"); + assert!( + !existing_install_is_valid(&final_path, &marker), + "an install without a marker must not be trusted" + ); + + // Marker present but the binary was later truncated (partial write / + // antivirus). Marker still records the original full size. + write_marker(&marker, bytes.len() as u64).expect("marker"); + assert!(existing_install_is_valid(&final_path, &marker)); + fs::write(&final_path, &bytes[..bytes.len() / 2]).expect("truncate"); + assert!( + !existing_install_is_valid(&final_path, &marker), + "a truncated binary must be detected via the size marker" + ); + + // Zero-length binary (quarantined to empty). + fs::write(&final_path, b"").expect("empty"); + assert!(!existing_install_is_valid(&final_path, &marker)); + } + + #[test] + fn invalid_image_header_is_rejected() { + let dir = tempfile::tempdir().expect("tempdir"); + let final_path = dir.path().join("copilot-bin"); + let marker = marker_path(dir.path()); + + // Right size, has a marker, but the bytes are not a valid image. + let garbage = vec![0u8; 4096]; + fs::write(&final_path, &garbage).expect("write garbage"); + write_marker(&marker, garbage.len() as u64).expect("marker"); + + assert!( + !existing_install_is_valid(&final_path, &marker), + "a non-executable image must be rejected even with a matching marker" + ); + } + + #[test] + fn verification_rejects_size_and_content_mismatch() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("staged"); + let expected = fake_image(1024); + + // Exact match passes. + fs::write(&path, &expected).expect("write"); + verify_on_disk_matches(&path, &expected).expect("exact match should verify"); + + // Truncated -> size mismatch. + fs::write(&path, &expected[..100]).expect("truncate"); + assert!(verify_on_disk_matches(&path, &expected).is_err()); + + // Same length, different bytes -> content mismatch. + let mut tampered = expected.clone(); + *tampered.last_mut().expect("non-empty") ^= 0xFF; + fs::write(&path, &tampered).expect("tamper"); + assert!(verify_on_disk_matches(&path, &expected).is_err()); + + // Missing file -> I/O error. + fs::remove_file(&path).expect("remove"); + assert!(verify_on_disk_matches(&path, &expected).is_err()); + } + + #[test] + fn temp_files_are_unique_and_synced() { + let dir = tempfile::tempdir().expect("tempdir"); + let data = fake_image(256); + + let a = write_temp_file(dir.path(), &data).expect("temp a"); + let b = write_temp_file(dir.path(), &data).expect("temp b"); + + assert_ne!(a, b, "temp file names must be unique"); + assert_eq!(fs::read(&a).expect("read a"), data); + assert_eq!(fs::read(&b).expect("read b"), data); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(&a).expect("meta").permissions().mode(); + assert_eq!(mode & 0o777, 0o755, "temp binary should be executable"); + } + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index cab34b476..84ed9165e 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -155,8 +155,10 @@ pub const HAS_BUNDLED_CLI: bool = cfg!(has_bundled_cli); /// it directly so callers (health checks, diagnostics, version probes) /// can reach the bundled binary without spinning up a full [`Client`]. /// -/// Subsequent calls return the cached result. Extraction is skipped -/// when the target file already exists. +/// Subsequent calls return the cached result. Extraction is skipped when +/// an already-published binary passes a cheap integrity re-check; a +/// truncated, empty, or antivirus-quarantined binary is re-extracted and +/// re-verified rather than returned. /// /// Returns `None` when the `bundled-cli` feature is off, the target /// platform isn't supported by `build.rs`, or extraction failed (the