diff --git a/rust/src/embeddedcli.rs b/rust/src/embeddedcli.rs index 574ed42a6f..59dddd0ed2 100644 --- a/rust/src/embeddedcli.rs +++ b/rust/src/embeddedcli.rs @@ -13,19 +13,28 @@ //! 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, +//! Full CLI 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. +//! +//! Runtime assets are compared and extracted with bounded buffers rather than +//! whole-file allocations. Matching files need only read access and retain +//! their installed permissions, provided the wrapper remains executable by +//! the current process. Changed files are staged beside their targets and +//! published after archive validation, including the gzip trailer. +//! Installation is atomic per file, not across the entire runtime bundle. // 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(has_bundled_cli)] +use std::collections::HashSet; #[cfg(any(has_bundled_cli, test))] use std::fs; #[cfg(has_bundled_cli)] @@ -276,17 +285,15 @@ const RUNTIME_LIBRARY_NAME: &str = "libcopilot_runtime.so"; fn install_runtime(install_dir: &Path, archive: &[u8]) -> Result { fs::create_dir_all(install_dir) .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::CreateDir, e))?; - install_hostless_assets(install_dir, archive)?; - install_runtime_pair(install_dir, archive)?; + let root = fs::canonicalize(install_dir) + .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Io, e))?; + let mut required = vec![RUNTIME_BINARY_NAME, RUNTIME_NODE_NAME]; #[cfg(feature = "bundled-in-process")] - install_runtime_library(install_dir, archive)?; - Ok(install_dir.join(RUNTIME_BINARY_NAME)) -} - -#[cfg(has_bundled_cli)] -fn install_hostless_assets(install_dir: &Path, archive: &[u8]) -> Result<(), EmbeddedCliError> { - let gz = flate2::read::GzDecoder::new(archive); - let mut tar = tar::Archive::new(gz); + required.push(RUNTIME_LIBRARY_NAME); + let mut seen = HashSet::new(); + let mut changed = HashSet::new(); + let mut pending = Vec::new(); + let mut tar = tar::Archive::new(flate2::read::GzDecoder::new(archive)); for entry in tar .entries() .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Archive, e))? @@ -298,118 +305,355 @@ fn install_hostless_assets(install_dir: &Path, archive: &[u8]) -> Result<(), Emb } let path = entry .path() - .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Archive, e))? - .into_owned(); - let file_name = path.file_name().and_then(|name| name.to_str()); - if path == Path::new(CLI_BINARY_NAME) - || matches!( - file_name, - Some("copilot_runtime.dll") - | Some("libcopilot_runtime.dylib") - | Some("libcopilot_runtime.so") - ) - { - continue; - } - if path.is_absolute() - || path.components().any(|component| { - matches!( - component, - std::path::Component::Prefix(_) - | std::path::Component::RootDir - | std::path::Component::ParentDir - ) - }) - { + .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Archive, e))?; + let path = runtime_asset_path(&path)?; + if !seen.insert(path.as_os_str().to_ascii_lowercase()) { return Err(EmbeddedCliError::with_message( EmbeddedCliErrorKind::Archive, - format!("unsafe embedded runtime asset path: {}", path.display()), + format!("duplicate embedded runtime asset path: {}", path.display()), )); } - let mut bytes = Vec::with_capacity(entry.size() as usize); - entry - .read_to_end(&mut bytes) - .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Archive, e))?; - let target = install_dir.join(&path); - if fs::read(&target) - .map(|installed| installed == bytes) - .unwrap_or(false) - { + if !selected_runtime_asset(&path) { continue; } + if let Some(index) = required.iter().position(|name| path == Path::new(name)) { + if entry.size() == 0 { + return Err(EmbeddedCliError::with_message( + EmbeddedCliErrorKind::Verification, + format!("embedded runtime artifact is empty: {}", path.display()), + )); + } + required.remove(index); + } + let target = root.join(&path); let parent = target.parent().ok_or_else(|| { EmbeddedCliError::with_message( EmbeddedCliErrorKind::Archive, format!("embedded runtime asset has no parent: {}", path.display()), ) })?; - fs::create_dir_all(parent) - .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::CreateDir, e))?; - let tmp = write_temp_file(parent, &bytes)?; - #[cfg(unix)] + check_runtime_asset_parent(&root, parent, false)?; + match existing_runtime_file(&target, entry.size())? { + Some(mut installed) => { + let matches = runtime_entry_matches(&mut entry, &mut installed)?; + #[cfg(unix)] + let matches = matches + && (path != Path::new(RUNTIME_BINARY_NAME) + || check_runtime_wrapper_execute_access(&target).is_ok()); + if !matches { + changed.insert(path); + } + } + None => { + check_runtime_asset_parent(&root, parent, true)?; + pending.push(stage_runtime_entry(&mut entry, &target)?); + } + } + } + // TAR's end marker can precede gzip's CRC and size trailer. + std::io::copy(&mut tar.into_inner(), &mut std::io::sink()) + .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Archive, e))?; + if !required.is_empty() { + return Err(EmbeddedCliErrorKind::BinaryNotFoundInArchive.into()); + } + // Recover bytes consumed by failed comparisons without retaining their + // prefixes in memory. Cold installs and valid warm caches need one pass. + if !changed.is_empty() { + let mut tar = tar::Archive::new(flate2::read::GzDecoder::new(archive)); + for entry in tar + .entries() + .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Archive, e))? { - use std::os::unix::fs::PermissionsExt; - let mode = entry.header().mode().unwrap_or(0o644) & 0o777; - fs::set_permissions(&tmp, fs::Permissions::from_mode(mode)) - .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Io, e))?; + let mut entry = + entry.map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Archive, e))?; + if !entry.header().entry_type().is_file() { + continue; + } + let path = entry + .path() + .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Archive, e))?; + let path = runtime_asset_path(&path)?; + if changed.contains(&path) { + pending.push(stage_runtime_entry(&mut entry, &root.join(path))?); + } } - if let Err(error) = publish(&tmp, &target) { - let _ = fs::remove_file(&tmp); - return Err(error); + std::io::copy(&mut tar.into_inner(), &mut std::io::sink()) + .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Archive, e))?; + } + for staged in pending { + publish(&staged.temporary, &staged.target)?; + } + #[cfg(unix)] + check_runtime_wrapper_execute_access(&root.join(RUNTIME_BINARY_NAME)) + .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Verification, e))?; + Ok(install_dir.join(RUNTIME_BINARY_NAME)) +} + +#[cfg(all(has_bundled_cli, unix))] +fn check_runtime_wrapper_execute_access(path: &Path) -> std::io::Result<()> { + use std::ffi::{CString, c_char, c_int}; + use std::os::unix::ffi::OsStrExt; + + // std has no effective-credentials access check. Bind the POSIX libc API, + // not a raw syscall: libc handles Linux kernel differences (glibc/musl). + // These / constants are shared by each OS's supported + // x86_64 and aarch64 targets; macOS uses different AT_* values than Linux. + #[cfg(target_os = "linux")] + const AT_FDCWD: c_int = -100; + #[cfg(target_os = "macos")] + const AT_FDCWD: c_int = -2; + #[cfg(target_os = "linux")] + const AT_EACCESS: c_int = 0x200; + #[cfg(target_os = "macos")] + const AT_EACCESS: c_int = 0x10; + const X_OK: c_int = 1; + unsafe extern "C" { + fn faccessat(dirfd: c_int, path: *const c_char, mode: c_int, flags: c_int) -> c_int; + } + + let path = CString::new(path.as_os_str().as_bytes()) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; + // SAFETY: path is NUL-terminated and lives through the call. faccessat + // only reads it and uses the platform's C integer ABI and flag values. + if unsafe { faccessat(AT_FDCWD, path.as_ptr(), X_OK, AT_EACCESS) } == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(has_bundled_cli)] +fn selected_runtime_asset(path: &Path) -> bool { + if path == Path::new(CLI_BINARY_NAME) { + return false; + } + if matches!( + path.file_name().and_then(|name| name.to_str()), + Some("copilot_runtime.dll" | "libcopilot_runtime.dylib" | "libcopilot_runtime.so") + ) { + #[cfg(feature = "bundled-in-process")] + return path == Path::new(RUNTIME_LIBRARY_NAME); + #[cfg(not(feature = "bundled-in-process"))] + return false; + } + true +} + +#[cfg(has_bundled_cli)] +fn runtime_asset_path(path: &Path) -> Result { + let invalid = || { + EmbeddedCliError::with_message( + EmbeddedCliErrorKind::Archive, + format!( + "non-portable embedded runtime asset path: {}", + path.display() + ), + ) + }; + // Bundled release assets use ASCII names. Apply the same conservative + // naming rules on every filesystem, without probing a read-only cache. + // Reject Unicode, DOS device/short names and trailing-dot/space aliases + // rather than approximating platform-specific Unicode normalization. + let name = path + .to_str() + .filter(|name| name.is_ascii()) + .ok_or_else(invalid)?; + if name.starts_with(['/', '\\']) { + return Err(invalid()); + } + let mut normalized = PathBuf::new(); + for component in name.split(['/', '\\']) { + if component.is_empty() || component == "." { + continue; + } + if component == ".." + || component.ends_with(['.', ' ']) + || component + .bytes() + .any(|byte| byte.is_ascii_control() || b"<>:\"|?*~".contains(&byte)) + { + return Err(invalid()); + } + let stem = component + .split('.') + .next() + .expect("nonempty component") + .trim_end_matches(' ') + .to_ascii_uppercase(); + if matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || (stem.len() == 4 + && (stem.starts_with("COM") || stem.starts_with("LPT")) + && matches!(stem.as_bytes()[3], b'1'..=b'9')) + { + return Err(invalid()); + } + normalized.push(component); + } + if normalized.as_os_str().is_empty() { + return Err(invalid()); + } + Ok(normalized) +} + +#[cfg(has_bundled_cli)] +fn check_runtime_asset_parent( + root: &Path, + parent: &Path, + create: bool, +) -> Result<(), EmbeddedCliError> { + let mut current = root.to_path_buf(); + for component in parent + .strip_prefix(root) + .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Archive, e))? + .components() + { + current.push(component); + if create { + match fs::create_dir(¤t) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(EmbeddedCliError::new(EmbeddedCliErrorKind::CreateDir, e)), + } + } + let metadata = match fs::symlink_metadata(¤t) { + Ok(metadata) => metadata, + Err(e) if !create && e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(EmbeddedCliError::new(EmbeddedCliErrorKind::Io, e)), + }; + if !metadata.is_dir() { + return Err(EmbeddedCliError::with_message( + EmbeddedCliErrorKind::Verification, + format!( + "runtime asset parent is not a directory: {}", + current.display() + ), + )); } } Ok(()) } #[cfg(has_bundled_cli)] -fn install_runtime_pair(install_dir: &Path, archive: &[u8]) -> Result<(), EmbeddedCliError> { - install_adjacent_file(install_dir, archive, RUNTIME_NODE_NAME, "runtime.node")?; - install_adjacent_file( - install_dir, - archive, - RUNTIME_BINARY_NAME, - "copilot runtime wrapper", - ) +struct StagedRuntimeFile { + temporary: PathBuf, + target: PathBuf, } -#[cfg(all(has_bundled_cli, feature = "bundled-in-process"))] -fn install_runtime_library(install_dir: &Path, archive: &[u8]) -> Result<(), EmbeddedCliError> { - install_adjacent_file( - install_dir, - archive, - RUNTIME_LIBRARY_NAME, - "in-process FFI runtime library", - ) +#[cfg(has_bundled_cli)] +impl Drop for StagedRuntimeFile { + fn drop(&mut self) { + if let Err(error) = fs::remove_file(&self.temporary) + && error.kind() != std::io::ErrorKind::NotFound + { + warn!(path = %self.temporary.display(), %error, "failed to remove staged runtime asset"); + } + } } #[cfg(has_bundled_cli)] -fn install_adjacent_file( - install_dir: &Path, - archive: &[u8], - file_name: &str, - label: &str, +fn existing_runtime_file(target: &Path, size: u64) -> Result, EmbeddedCliError> { + let metadata = match fs::symlink_metadata(target) { + Ok(metadata) if metadata.is_file() => Some(metadata), + Ok(_) => { + return Err(EmbeddedCliError::with_message( + EmbeddedCliErrorKind::Verification, + format!("runtime asset is not a regular file: {}", target.display()), + )); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(EmbeddedCliError::new(EmbeddedCliErrorKind::Io, e)), + }; + let matches = metadata + .as_ref() + .is_some_and(|metadata| metadata.len() == size); + if matches { + match fs::File::open(target) { + Ok(file) => return Ok(Some(file)), + Err(e) => { + tracing::debug!(path = %target.display(), error = %e, + "existing runtime asset cannot be read; repairing"); + } + } + } + Ok(None) +} + +#[cfg(has_bundled_cli)] +fn runtime_entry_matches( + entry: &mut tar::Entry<'_, R>, + installed: &mut fs::File, +) -> Result { + let mut buffer = [0u8; 64 * 1024]; + let mut on_disk = [0u8; 64 * 1024]; + let mut remaining = entry.size(); + while remaining > 0 { + let length = remaining.min(buffer.len() as u64) as usize; + entry + .read_exact(&mut buffer[..length]) + .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Archive, e))?; + remaining -= length as u64; + if let Err(error) = installed.read_exact(&mut on_disk[..length]) { + tracing::debug!(%error, "existing runtime asset cannot be read; repairing"); + return Ok(false); + } + if on_disk[..length] != buffer[..length] { + return Ok(false); + } + } + match installed.read(&mut on_disk[..1]) { + Ok(read) => Ok(read == 0), + Err(error) => { + tracing::debug!(%error, "existing runtime asset cannot be read; repairing"); + Ok(false) + } + } +} + +#[cfg(has_bundled_cli)] +fn stage_runtime_entry( + entry: &mut tar::Entry<'_, R>, + target: &Path, +) -> Result { + let parent = target.parent().expect("runtime asset has a checked parent"); + let (temporary, mut file) = create_temp_file(parent)?; + let staged = StagedRuntimeFile { + temporary, + target: target.to_path_buf(), + }; + let result = write_runtime_entry(entry, &mut file); + // Close handles before cleanup or replacement, including on Windows. + drop(file); + result?; + Ok(staged) +} + +#[cfg(has_bundled_cli)] +fn write_runtime_entry( + entry: &mut tar::Entry<'_, R>, + file: &mut fs::File, ) -> Result<(), EmbeddedCliError> { - let target = install_dir.join(file_name); - let bytes = extract_binary(archive, file_name)?; - if bytes.is_empty() { + let size = entry.size(); + let written = std::io::copy(&mut (&mut *entry).take(size), &mut *file) + .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Io, e))?; + if written != size { return Err(EmbeddedCliError::with_message( EmbeddedCliErrorKind::Verification, - format!("embedded {label} is empty"), + format!("runtime entry size mismatch: read {written} bytes, expected {size}"), )); } - if fs::read(&target) - .map(|installed| installed == bytes) - .unwrap_or(false) + #[cfg(unix)] { - return Ok(()); - } - let tmp = write_temp_file(install_dir, &bytes)?; - if let Err(e) = publish(&tmp, &target) { - let _ = fs::remove_file(&tmp); - return Err(e); + use std::os::unix::fs::PermissionsExt; + let mode = entry + .header() + .mode() + .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Archive, e))? + & 0o777; + file.set_permissions(fs::Permissions::from_mode(mode)) + .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Io, e))?; } - tracing::debug!(path = %target.display(), %label, "embedded runtime artifact installed"); - Ok(()) + file.sync_all() + .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Io, e)) } #[cfg(has_bundled_cli)] @@ -556,27 +800,7 @@ fn publish_verified( /// 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))?; + let (tmp, mut file) = create_temp_file(dir)?; if let Err(e) = file .write_all(contents) @@ -602,24 +826,35 @@ fn write_temp_file(dir: &Path, contents: &[u8]) -> Result Result<(PathBuf, fs::File), EmbeddedCliError> { + 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 tmp = dir.join(format!( + ".copilot-cli.tmp.{}.{}.{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed), + nanos + )); + let file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp) + .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Io, e))?; + Ok((tmp, file)) +} + /// 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. +/// Rust uses rename on POSIX and MoveFileExW with MOVEFILE_REPLACE_EXISTING on +/// Windows. Never unlink the destination on failure: readers must retain the +/// previous complete file if replacement is blocked. #[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)), - } + fs::rename(tmp, final_path).map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Publish, e)) } /// Read the file at `path` and confirm it byte-for-byte matches the trusted @@ -1121,4 +1356,585 @@ mod tests { dir.path().join("2.0.0") ); } + + #[cfg(has_bundled_cli)] + fn runtime_fixture(extra: &[(&str, &[u8], u32)]) -> Vec { + let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + let mut archive = tar::Builder::new(encoder); + let mut entries = vec![ + (RUNTIME_BINARY_NAME, b"wrapper".as_slice(), 0o755), + (RUNTIME_NODE_NAME, b"runtime".as_slice(), 0o755), + ]; + #[cfg(feature = "bundled-in-process")] + entries.push((RUNTIME_LIBRARY_NAME, b"library".as_slice(), 0o644)); + entries.extend_from_slice(extra); + for (name, bytes, mode) in entries { + let mut header = tar::Header::new_gnu(); + // Raw names also let the installer see traversal fixtures which + // Builder::append_data would reject before reaching product code. + header.as_mut_bytes()[..name.len()].copy_from_slice(name.as_bytes()); + header.set_size(bytes.len() as u64); + header.set_mode(mode); + header.set_cksum(); + archive.append(&header, bytes).expect("append fixture"); + } + archive.into_inner().unwrap().finish().unwrap() + } + + #[cfg(has_bundled_cli)] + #[test] + fn warm_runtime_rejects_same_size_corruption_despite_unchanged_metadata() { + let dir = tempfile::tempdir().unwrap(); + let fixture = runtime_fixture(&[]); + install_runtime(dir.path(), &fixture).unwrap(); + let runtime = dir.path().join(RUNTIME_NODE_NAME); + let original = fs::metadata(&runtime).unwrap(); + fs::write(&runtime, b"corrupt").unwrap(); + fs::File::options() + .write(true) + .open(&runtime) + .unwrap() + .set_times(fs::FileTimes::new().set_modified(original.modified().unwrap())) + .unwrap(); + assert!(install_runtime(dir.path(), b"invalid archive").is_err()); + assert_eq!(fs::read(&runtime).unwrap(), b"corrupt"); + install_runtime(dir.path(), &fixture).unwrap(); + assert_eq!(fs::read(&runtime).unwrap(), b"runtime"); + } + + #[cfg(has_bundled_cli)] + fn assert_no_runtime_temps(dir: &Path) { + for entry in fs::read_dir(dir).unwrap() { + let entry = entry.unwrap(); + assert!( + !entry + .file_name() + .to_string_lossy() + .starts_with(".copilot-cli.tmp.") + ); + if entry.file_type().unwrap().is_dir() { + assert_no_runtime_temps(&entry.path()); + } + } + } + + #[cfg(has_bundled_cli)] + #[test] + fn runtime_cold_install_and_warm_reuse_preserve_bytes_and_modes() { + let dir = tempfile::tempdir().unwrap(); + let data = vec![0xAB; 3 * 64 * 1024 + 17]; + let archive = runtime_fixture(&[("nested/asset", &data, 0o640)]); + let wrapper = install_runtime(dir.path(), &archive).unwrap(); + assert_eq!(wrapper, dir.path().join(RUNTIME_BINARY_NAME)); + let asset = dir.path().join("nested/asset"); + assert_eq!(fs::read(&asset).unwrap(), data); + let modified = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1234567890); + fs::File::options() + .write(true) + .open(&asset) + .unwrap() + .set_times(fs::FileTimes::new().set_modified(modified)) + .unwrap(); + + install_runtime(dir.path(), &archive).unwrap(); + + assert_eq!(fs::metadata(&asset).unwrap().modified().unwrap(), modified); + assert_eq!(fs::read(&asset).unwrap(), data); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&asset).unwrap().permissions().mode() & 0o777, + 0o640 + ); + assert_eq!( + fs::metadata(wrapper).unwrap().permissions().mode() & 0o777, + 0o755 + ); + } + assert_no_runtime_temps(dir.path()); + } + + #[cfg(has_bundled_cli)] + #[test] + fn runtime_repairs_same_size_corruption_truncation_and_extra_bytes() { + let dir = tempfile::tempdir().unwrap(); + let archive = runtime_fixture(&[]); + let runtime = dir.path().join(RUNTIME_NODE_NAME); + install_runtime(dir.path(), &archive).unwrap(); + + for corrupt in [b"runtimX".as_slice(), b"run", b"", b"runtime plus garbage"] { + fs::write(&runtime, corrupt).unwrap(); + install_runtime(dir.path(), &archive).unwrap(); + assert_eq!(fs::read(&runtime).unwrap(), b"runtime"); + assert_no_runtime_temps(dir.path()); + } + } + + #[cfg(has_bundled_cli)] + #[test] + fn runtime_repairs_corruption_across_comparison_chunks() { + let dir = tempfile::tempdir().unwrap(); + let bytes = vec![0xAB; 3 * 64 * 1024 + 17]; + let archive = runtime_fixture(&[("nested/asset", &bytes, 0o644)]); + install_runtime(dir.path(), &archive).unwrap(); + for offset in [0, bytes.len() / 2, bytes.len() - 1] { + let mut corrupt = bytes.clone(); + corrupt[offset] ^= 1; + fs::write(dir.path().join("nested/asset"), &corrupt).unwrap(); + install_runtime(dir.path(), &archive).unwrap(); + assert_eq!(fs::read(dir.path().join("nested/asset")).unwrap(), bytes); + assert_no_runtime_temps(dir.path()); + } + } + + #[cfg(all(has_bundled_cli, unix))] + #[test] + fn runtime_reuse_preserves_executable_caller_selected_permissions() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let fixture = runtime_fixture(&[]); + let wrapper = install_runtime(dir.path(), &fixture).unwrap(); + for mode in [0o745, 0o754, 0o700, 0o500] { + fs::set_permissions(&wrapper, fs::Permissions::from_mode(mode)).unwrap(); + install_runtime(dir.path(), &fixture).unwrap(); + assert_eq!( + fs::metadata(&wrapper).unwrap().permissions().mode() & 0o777, + mode + ); + } + assert_no_runtime_temps(dir.path()); + } + + #[cfg(all(has_bundled_cli, unix))] + #[test] + fn runtime_repairs_nonexecutable_wrapper() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let archive = runtime_fixture(&[]); + let wrapper = install_runtime(dir.path(), &archive).unwrap(); + for mode in [0o400, 0o600] { + fs::set_permissions(&wrapper, fs::Permissions::from_mode(mode)).unwrap(); + install_runtime(dir.path(), &archive).unwrap(); + assert_eq!( + fs::metadata(&wrapper).unwrap().permissions().mode() & 0o777, + 0o755 + ); + assert_eq!(fs::read(&wrapper).unwrap(), b"wrapper"); + assert_no_runtime_temps(dir.path()); + } + } + + #[cfg(all(has_bundled_cli, unix))] + #[test] + fn runtime_rejects_nonexecutable_wrapper_in_readonly_cache() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let archive = runtime_fixture(&[]); + let wrapper = install_runtime(dir.path(), &archive).unwrap(); + fs::set_permissions(&wrapper, fs::Permissions::from_mode(0o400)).unwrap(); + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o555)).unwrap(); + let write_denied = fs::File::create(dir.path().join("write-probe")).is_err(); + let result = install_runtime(dir.path(), &archive); + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755)).unwrap(); + if write_denied { + assert!( + result.is_err(), + "returned a nonexecutable wrapper: {result:?}" + ); + assert_eq!( + fs::metadata(&wrapper).unwrap().permissions().mode() & 0o777, + 0o400 + ); + } else { + eprintln!("read-only permission enforcement unavailable (e.g. privileged user)"); + fs::remove_file(dir.path().join("write-probe")).unwrap(); + result.unwrap(); + assert_eq!( + fs::metadata(&wrapper).unwrap().permissions().mode() & 0o777, + 0o755 + ); + } + assert_no_runtime_temps(dir.path()); + } + + #[cfg(has_bundled_cli)] + #[test] + fn runtime_archive_errors_do_not_publish_and_clean_up_staging() { + let dir = tempfile::tempdir().unwrap(); + let valid = runtime_fixture(&[("nested/asset", b"asset", 0o644)]); + let mut invalid_crc = valid.clone(); + let crc = invalid_crc.len() - 8; + invalid_crc[crc] ^= 0xFF; + let mut invalid_length = valid.clone(); + let length = invalid_length.len() - 4; + invalid_length[length] ^= 0xFF; + let mut truncated_trailer = valid.clone(); + truncated_trailer.truncate(valid.len() - 1); + let mut truncated_body = valid.clone(); + truncated_body.truncate(valid.len() / 2); + for archive in [ + invalid_crc, + invalid_length, + truncated_trailer, + truncated_body, + ] { + let runtime = dir.path().join(RUNTIME_NODE_NAME); + fs::write(&runtime, b"previous complete runtime").unwrap(); + assert!(install_runtime(dir.path(), &archive).is_err()); + assert_eq!(fs::read(runtime).unwrap(), b"previous complete runtime"); + assert!(!dir.path().join(RUNTIME_BINARY_NAME).exists()); + assert!(!dir.path().join("nested/asset").exists()); + assert_no_runtime_temps(dir.path()); + } + } + + #[cfg(has_bundled_cli)] + #[test] + fn runtime_short_entry_is_rejected_without_publishing() { + let dir = tempfile::tempdir().unwrap(); + let mut header = tar::Header::new_gnu(); + header.set_path(RUNTIME_NODE_NAME).unwrap(); + header.set_size(128 * 1024); + header.set_mode(0o755); + header.set_cksum(); + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + encoder.write_all(header.as_bytes()).unwrap(); + encoder.write_all(b"short").unwrap(); + let archive = encoder.finish().unwrap(); + + assert!(install_runtime(dir.path(), &archive).is_err()); + assert!(!dir.path().join(RUNTIME_NODE_NAME).exists()); + assert_no_runtime_temps(dir.path()); + } + + #[cfg(has_bundled_cli)] + #[test] + fn runtime_missing_required_entry_is_rejected_before_publish() { + let dir = tempfile::tempdir().unwrap(); + let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + let mut archive = tar::Builder::new(encoder); + let mut header = tar::Header::new_gnu(); + header.set_size(7); + header.set_mode(0o755); + header.set_cksum(); + archive + .append_data(&mut header, RUNTIME_BINARY_NAME, b"wrapper".as_slice()) + .unwrap(); + let archive = archive.into_inner().unwrap().finish().unwrap(); + + assert!(install_runtime(dir.path(), &archive).is_err()); + assert!(!dir.path().join(RUNTIME_BINARY_NAME).exists()); + assert_no_runtime_temps(dir.path()); + } + + #[cfg(has_bundled_cli)] + #[test] + fn runtime_rejects_traversal_and_cleans_preceding_entries() { + let dir = tempfile::tempdir().unwrap(); + let install_dir = dir.path().join("install"); + let archive = runtime_fixture(&[("../escaped", b"bad", 0o644)]); + assert!(install_runtime(&install_dir, &archive).is_err()); + assert!(!dir.path().join("escaped").exists()); + assert!(!install_dir.join(RUNTIME_BINARY_NAME).exists()); + assert_no_runtime_temps(&install_dir); + } + + #[cfg(all(has_bundled_cli, unix))] + #[test] + fn runtime_rejects_symlink_parents_and_targets() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + fs::write(outside.path().join("asset"), b"outside").unwrap(); + symlink(outside.path(), dir.path().join("nested")).unwrap(); + let archive = runtime_fixture(&[("nested/asset", b"new", 0o644)]); + + assert!(install_runtime(dir.path(), &archive).is_err()); + assert_eq!(fs::read(outside.path().join("asset")).unwrap(), b"outside"); + assert_no_runtime_temps(dir.path()); + fs::remove_file(dir.path().join("nested")).unwrap(); + symlink( + outside.path().join("asset"), + dir.path().join(RUNTIME_NODE_NAME), + ) + .unwrap(); + assert!(install_runtime(dir.path(), &archive).is_err()); + assert_eq!(fs::read(outside.path().join("asset")).unwrap(), b"outside"); + assert_no_runtime_temps(dir.path()); + } + + #[cfg(has_bundled_cli)] + #[test] + fn concurrent_runtime_installers_publish_complete_files() { + let dir = tempfile::tempdir().unwrap(); + let data = vec![0xAB; 256 * 1024 + 1]; + let archive = runtime_fixture(&[("nested/asset", &data, 0o644)]); + let barrier = std::sync::Barrier::new(6); + std::thread::scope(|scope| { + for _ in 0..6 { + scope.spawn(|| { + barrier.wait(); + install_runtime(dir.path(), &archive).unwrap(); + }); + } + }); + assert_eq!(fs::read(dir.path().join("nested/asset")).unwrap(), data); + assert_eq!( + fs::read(dir.path().join(RUNTIME_NODE_NAME)).unwrap(), + b"runtime" + ); + assert_no_runtime_temps(dir.path()); + } + + #[cfg(has_bundled_cli)] + #[test] + fn runtime_duplicate_destinations_fail_on_cold_and_warm_installs() { + for name in ["asset", "./asset"] { + let dir = tempfile::tempdir().unwrap(); + let archive = runtime_fixture(&[("asset", b"first", 0o644), (name, b"last", 0o644)]); + assert!(install_runtime(dir.path(), &archive).is_err()); + assert!(!dir.path().join("asset").exists()); + assert_no_runtime_temps(dir.path()); + + fs::write(dir.path().join("asset"), b"last").unwrap(); + assert!(install_runtime(dir.path(), &archive).is_err()); + assert_eq!(fs::read(dir.path().join("asset")).unwrap(), b"last"); + assert_no_runtime_temps(dir.path()); + } + let dir = tempfile::tempdir().unwrap(); + let archive = runtime_fixture(&[(RUNTIME_NODE_NAME, b"", 0o755)]); + assert!(install_runtime(dir.path(), &archive).is_err()); + assert!(!dir.path().join(RUNTIME_NODE_NAME).exists()); + assert_no_runtime_temps(dir.path()); + install_runtime(dir.path(), &runtime_fixture(&[])).unwrap(); + assert!(install_runtime(dir.path(), &archive).is_err()); + assert_eq!( + fs::read(dir.path().join(RUNTIME_NODE_NAME)).unwrap(), + b"runtime" + ); + assert_no_runtime_temps(dir.path()); + } + + #[cfg(has_bundled_cli)] + #[test] + fn runtime_case_aliases_cannot_overwrite_required_artifacts() { + let names = [ + RUNTIME_NODE_NAME, + RUNTIME_BINARY_NAME, + #[cfg(feature = "bundled-in-process")] + RUNTIME_LIBRARY_NAME, + ]; + for name in names { + let alias = name.to_ascii_uppercase(); + let archive = runtime_fixture(&[(&alias, b"", 0o755)]); + let dir = tempfile::tempdir().unwrap(); + let result = install_runtime(dir.path(), &archive); + assert!(result.is_err(), "accepted alias {alias}: {result:?}"); + assert!(!dir.path().join(name).exists()); + assert_no_runtime_temps(dir.path()); + + install_runtime(dir.path(), &runtime_fixture(&[])).unwrap(); + let original = fs::read(dir.path().join(name)).unwrap(); + assert!(install_runtime(dir.path(), &archive).is_err()); + assert_eq!(fs::read(dir.path().join(name)).unwrap(), original); + fs::write(dir.path().join(name), b"corrupt").unwrap(); + assert!(install_runtime(dir.path(), &archive).is_err()); + assert_eq!(fs::read(dir.path().join(name)).unwrap(), b"corrupt"); + assert_no_runtime_temps(dir.path()); + } + } + + #[cfg(has_bundled_cli)] + #[test] + fn runtime_mixed_separator_and_case_aliases_are_rejected() { + let dir = tempfile::tempdir().unwrap(); + let archive = runtime_fixture(&[ + ("nested/asset", b"first", 0o644), + (r".\NESTED\ASSET", b"last", 0o644), + ]); + assert!(install_runtime(dir.path(), &archive).is_err()); + assert!(!dir.path().join("nested/asset").exists()); + assert_no_runtime_temps(dir.path()); + + install_runtime( + dir.path(), + &runtime_fixture(&[("nested/asset", b"last", 0o644)]), + ) + .unwrap(); + assert!(install_runtime(dir.path(), &archive).is_err()); + assert_eq!(fs::read(dir.path().join("nested/asset")).unwrap(), b"last"); + assert_no_runtime_temps(dir.path()); + } + + #[cfg(has_bundled_cli)] + #[test] + fn runtime_nonportable_alias_paths_are_rejected_before_publish() { + for name in [ + "runtime.node.", + "runtime.node ", + "runtime.node:stream", + "RUNTIM~1.NOD", + "NUL", + "con.txt", + "aux .txt", + "COM1", + "LPT9.txt", + "n\u{00e9}sted/asset", + r"..\escaped", + r"C:\escaped", + r"\\server\share\asset", + ] { + let dir = tempfile::tempdir().unwrap(); + let archive = runtime_fixture(&[(name, b"bad", 0o644)]); + assert!( + install_runtime(dir.path(), &archive).is_err(), + "accepted {name}" + ); + assert!(!dir.path().join(RUNTIME_NODE_NAME).exists()); + assert_no_runtime_temps(dir.path()); + } + } + + #[cfg(all(has_bundled_cli, feature = "bundled-in-process"))] + #[test] + fn runtime_library_aliases_are_rejected_before_repair() { + let alias = format!("./{RUNTIME_LIBRARY_NAME}"); + for duplicate in [b"another".as_slice(), b""] { + let dir = tempfile::tempdir().unwrap(); + let archive = runtime_fixture(&[(&alias, duplicate, 0o644)]); + assert!(install_runtime(dir.path(), &archive).is_err()); + assert!(!dir.path().join(RUNTIME_LIBRARY_NAME).exists()); + assert_no_runtime_temps(dir.path()); + + install_runtime(dir.path(), &runtime_fixture(&[])).unwrap(); + let library = dir.path().join(RUNTIME_LIBRARY_NAME); + assert!(install_runtime(dir.path(), &archive).is_err()); + assert_eq!(fs::read(&library).unwrap(), b"library"); + fs::write(&library, b"corrupt").unwrap(); + assert!(install_runtime(dir.path(), &archive).is_err()); + assert_eq!(fs::read(&library).unwrap(), b"corrupt"); + assert_no_runtime_temps(dir.path()); + } + } + + #[cfg(all(has_bundled_cli, unix))] + #[test] + fn warm_runtime_install_needs_no_writable_cache() { + assert_read_only_runtime_cache(0o555, false); + } + + #[cfg(all(has_bundled_cli, unix))] + #[test] + fn warm_runtime_reuses_owner_only_immutable_cache() { + assert_read_only_runtime_cache(0o500, false); + } + + #[cfg(all(has_bundled_cli, unix))] + #[test] + fn warm_runtime_reuses_nonexecutable_native_library() { + assert_read_only_runtime_cache(0o555, true); + assert_read_only_runtime_cache(0o500, true); + } + + #[cfg(all(has_bundled_cli, unix))] + fn assert_read_only_runtime_cache(permission_mask: u32, readonly_native_library: bool) { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let archive = runtime_fixture(&[("nested/asset", b"asset", 0o644)]); + install_runtime(dir.path(), &archive).unwrap(); + let files = [ + RUNTIME_BINARY_NAME, + RUNTIME_NODE_NAME, + "nested/asset", + #[cfg(feature = "bundled-in-process")] + RUNTIME_LIBRARY_NAME, + ]; + let mut read_only_files = Vec::new(); + for name in files { + let path = dir.path().join(name); + let mut mode = fs::metadata(&path).unwrap().permissions().mode() & permission_mask; + if readonly_native_library && name != RUNTIME_BINARY_NAME { + mode &= !0o111; + } + fs::set_permissions(&path, fs::Permissions::from_mode(mode)).unwrap(); + read_only_files.push((path, mode)); + } + fs::set_permissions( + dir.path().join("nested"), + fs::Permissions::from_mode(permission_mask), + ) + .unwrap(); + fs::set_permissions(dir.path(), fs::Permissions::from_mode(permission_mask)).unwrap(); + let write_denied = fs::File::create(dir.path().join("write-probe")).is_err(); + let result = install_runtime(dir.path(), &archive); + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755)).unwrap(); + fs::set_permissions(dir.path().join("nested"), fs::Permissions::from_mode(0o755)).unwrap(); + result.unwrap(); + if !write_denied { + eprintln!("read-only permission enforcement unavailable (e.g. privileged user)"); + fs::remove_file(dir.path().join("write-probe")).unwrap(); + } + for (path, mode) in read_only_files { + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + mode + ); + } + assert_eq!(fs::read(dir.path().join("nested/asset")).unwrap(), b"asset"); + assert_no_runtime_temps(dir.path()); + } + + #[cfg(all(has_bundled_cli, unix))] + #[test] + fn runtime_repairs_unreadable_but_replaceable_file() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let archive = runtime_fixture(&[("asset", b"trusted", 0o000)]); + let asset = dir.path().join("asset"); + fs::write(&asset, b"corrupt").unwrap(); + fs::set_permissions(&asset, fs::Permissions::from_mode(0o000)).unwrap(); + if fs::File::open(&asset).is_ok() { + eprintln!("unreadable permission enforcement unavailable (e.g. privileged user)"); + } + let result = install_runtime(dir.path(), &archive); + let mode = fs::metadata(&asset).unwrap().permissions().mode() & 0o777; + fs::set_permissions(&asset, fs::Permissions::from_mode(0o600)).unwrap(); + result.unwrap(); + assert_eq!(mode, 0o000); + assert_eq!(fs::read(asset).unwrap(), b"trusted"); + assert_no_runtime_temps(dir.path()); + } + + #[cfg(has_bundled_cli)] + #[test] + fn runtime_replacement_preserves_open_reader_contents() { + let dir = tempfile::tempdir().unwrap(); + let asset = dir.path().join("asset"); + let original = runtime_fixture(&[("asset", b"old complete file", 0o644)]); + install_runtime(dir.path(), &original).unwrap(); + let mut reader = fs::File::open(&asset).unwrap(); + let replacement = runtime_fixture(&[("asset", b"new complete file", 0o644)]); + install_runtime(dir.path(), &replacement).unwrap(); + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes).unwrap(); + assert_eq!(bytes, b"old complete file"); + assert_eq!(fs::read(&asset).unwrap(), b"new complete file"); + assert_no_runtime_temps(dir.path()); + } + + #[test] + fn failed_publish_does_not_remove_previous_file() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("installed"); + fs::write(&target, b"previous complete file").unwrap(); + assert!(publish(&dir.path().join("missing-temporary"), &target).is_err()); + assert_eq!(fs::read(target).unwrap(), b"previous complete file"); + } }