diff --git a/.github/workflows/rust-sdk-tests.yml b/.github/workflows/rust-sdk-tests.yml index 440641bbdc..fdca5c3094 100644 --- a/.github/workflows/rust-sdk-tests.yml +++ b/.github/workflows/rust-sdk-tests.yml @@ -330,7 +330,9 @@ jobs: BUNDLED_CLI_CACHE_DIR: ${{ github.workspace }}/rust/.bundled-cli-cache run: | cargo build - cargo test --features bundled-in-process --lib embedded_archive_contains_only_expected_files + cargo test --features bundled-in-process --lib embeddedcli::tests:: + cargo test --features bundled-in-process --test cli_resolution_test install_bundled_ + cargo test --features bundled-in-process --test cli_resolution_test bundled_cli_is_distinct_from_runtime_and_supports_version_probe # Build natively inside Alpine so Cargo reports target_env="musl". This # exercises selection and extraction of the linuxmusl-arm64 runtime artifact, diff --git a/rust/Cargo.lock b/rust/Cargo.lock index b91eebd06c..deddeea2e8 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -278,6 +278,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -2427,6 +2428,12 @@ dependencies = [ "zopfli", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.21" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 4495d3928c..9920c50354 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -57,7 +57,7 @@ parking_lot = "0.12" regex = "1" getrandom = "0.2" uuid = { version = "1", default-features = false, features = ["v4"] } -flate2 = { version = "1", optional = true } +flate2 = { version = "1", default-features = false, features = ["zlib-rs"], optional = true } tar = { version = "0.4", optional = true } # LLM inference callback transport: idiomatic HTTP/WebSocket forwarding for the # `CopilotRequestHandler`, plus base64/byte/stream plumbing for the chunk protocol. @@ -69,7 +69,8 @@ reqwest = { version = "0.12", default-features = false, features = ["stream", "h tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "native-tls"] } [target.'cfg(windows)'.dependencies] -zip = { version = "2", default-features = false, features = ["deflate"], optional = true } +# Reuse the runtime flate2 backend instead of enabling zip's miniz_oxide default. +zip = { version = "2", default-features = false, features = ["deflate-flate2", "flate2"], optional = true } windows-sys = { version = "0.61", default-features = false, features = [ "Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", @@ -78,11 +79,13 @@ windows-sys = { version = "0.61", default-features = false, features = [ ] } [dev-dependencies] +flate2 = { version = "1", default-features = false, features = ["zlib-rs"] } rusqlite = { version = "0.35", features = ["bundled"] } schemars = "1" serial_test = "3" tempfile = "3" tokio = { version = "1", features = ["rt-multi-thread"] } +zip = { version = "2", default-features = false, features = ["deflate-flate2", "flate2"] } # Integration tests that call test-support-only Client methods (e.g. # `from_streams_with_connection_token`, `from_streams_with_trace_provider`) diff --git a/rust/src/embeddedcli.rs b/rust/src/embeddedcli.rs index 574ed42a6f..8b5d5bc2aa 100644 --- a/rust/src/embeddedcli.rs +++ b/rust/src/embeddedcli.rs @@ -285,7 +285,7 @@ fn install_runtime(install_dir: &Path, archive: &[u8]) -> Result Result<(), EmbeddedCliError> { - let gz = flate2::read::GzDecoder::new(archive); + let gz = flate2::bufread::GzDecoder::new(archive); let mut tar = tar::Archive::new(gz); for entry in tar .entries() @@ -710,6 +710,11 @@ fn extract_cli_binary(archive: &[u8]) -> Result, EmbeddedCliError> { #[cfg(all(has_bundled_cli, windows))] fn extract_cli_binary(archive: &[u8]) -> Result, EmbeddedCliError> { + extract_zip_binary(archive, CLI_BINARY_NAME) +} + +#[cfg(all(has_bundled_cli, any(windows, test)))] +fn extract_zip_binary(archive: &[u8], binary_name: &str) -> Result, EmbeddedCliError> { let reader = std::io::Cursor::new(archive); let mut zip = zip::ZipArchive::new(reader) .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Archive, e))?; @@ -717,8 +722,7 @@ fn extract_cli_binary(archive: &[u8]) -> Result, EmbeddedCliError> { let mut entry = zip .by_index(index) .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Archive, e))?; - if entry.name() == CLI_BINARY_NAME || entry.name().ends_with(&format!("/{CLI_BINARY_NAME}")) - { + if entry.name() == binary_name || entry.name().ends_with(&format!("/{binary_name}")) { let mut bytes = Vec::with_capacity(entry.size() as usize); entry .read_to_end(&mut bytes) @@ -731,7 +735,7 @@ fn extract_cli_binary(archive: &[u8]) -> Result, EmbeddedCliError> { #[cfg(has_bundled_cli)] fn extract_binary(archive: &[u8], binary_name: &str) -> Result, EmbeddedCliError> { - let gz = flate2::read::GzDecoder::new(archive); + let gz = flate2::bufread::GzDecoder::new(archive); let mut tar = tar::Archive::new(gz); for entry in tar .entries() @@ -891,6 +895,133 @@ impl std::error::Error for EmbeddedCliError { mod tests { use super::*; + #[cfg(has_bundled_cli)] + fn zip_archive(path: &str, bytes: &[u8]) -> Vec { + let mut archive = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + archive.start_file(path, options).unwrap(); + archive.write_all(bytes).unwrap(); + archive.finish().unwrap().into_inner() + } + + #[cfg(has_bundled_cli)] + #[test] + fn zip_cli_extraction_preserves_bytes_and_entry_selection() { + let bytes: Vec = (0..=255).cycle().take(65_537).collect(); + for name in ["copilot.exe", "package/copilot.exe"] { + let archive = zip_archive(name, &bytes); + assert_eq!(extract_zip_binary(&archive, "copilot.exe").unwrap(), bytes); + } + let archive = zip_archive("not-the-cli", &bytes); + assert!(extract_zip_binary(&archive, "copilot.exe").is_err()); + } + + #[cfg(has_bundled_cli)] + #[test] + fn zip_cli_extraction_rejects_corrupt_deflate_and_checksum() { + let archive = zip_archive("copilot.exe", &[0xAB; 65_537]); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&archive)).unwrap(); + let payload_start = zip.by_index(0).unwrap().data_start() as usize; + let mut invalid_deflate = archive.clone(); + // BTYPE=3 is reserved in DEFLATE, regardless of the chosen encoder. + invalid_deflate[payload_start] |= 0b110; + + let central_directory = zip.central_directory_start() as usize; + let mut invalid_crc = archive.clone(); + // The central directory file header stores CRC-32 at byte offset 16. + invalid_crc[central_directory + 16] ^= 0xFF; + let truncated = &archive[..archive.len() / 2]; + + for invalid in [ + invalid_deflate.as_slice(), + invalid_crc.as_slice(), + truncated, + ] { + let error = extract_zip_binary(invalid, "copilot.exe").unwrap_err(); + assert!( + std::error::Error::source(&error).is_some(), + "invalid ZIP must be an archive error, not a missing-file result" + ); + } + } + + #[cfg(has_bundled_cli)] + fn gzip_archive(path: &str, bytes: &[u8], mode: u32) -> Vec { + let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + let mut archive = tar::Builder::new(encoder); + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(mode); + header.set_cksum(); + archive.append_data(&mut header, path, bytes).unwrap(); + archive.into_inner().unwrap().finish().unwrap() + } + + #[cfg(has_bundled_cli)] + #[test] + fn gzip_extraction_preserves_bytes_modes_and_member_boundary() { + let dir = tempfile::tempdir().unwrap(); + let bytes: Vec = (0..=255).cycle().take(65_537).collect(); + let mut archive = gzip_archive("assets/first", &bytes, 0o750); + archive.extend(gzip_archive("second", b"another gzip member", 0o644)); + + assert_eq!(extract_binary(&archive, "first").unwrap(), bytes); + assert!(extract_binary(&archive, "second").is_err()); + install_hostless_assets(dir.path(), &archive).unwrap(); + let installed = dir.path().join("assets/first"); + assert_eq!(fs::read(&installed).unwrap(), bytes); + assert!(!dir.path().join("second").exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(installed).unwrap().permissions().mode() & 0o777, + 0o750 + ); + } + } + + #[cfg(has_bundled_cli)] + #[test] + fn gzip_extraction_rejects_invalid_header_and_truncated_payload() { + let archive = gzip_archive("asset", &[0xAB; 65_537], 0o644); + let mut invalid_header = archive.clone(); + invalid_header[0] ^= 0xFF; + let truncated = &archive[..archive.len() / 2]; + for invalid in [invalid_header.as_slice(), truncated] { + let dir = tempfile::tempdir().unwrap(); + assert!(extract_binary(invalid, "asset").is_err()); + assert!(install_hostless_assets(dir.path(), invalid).is_err()); + assert!(!dir.path().join("asset").exists()); + } + } + + #[cfg(has_bundled_cli)] + #[test] + fn gzip_extraction_rejects_invalid_trailers_at_end_of_stream() { + // An empty TAR stream reaches the gzip trailer instead of stopping at + // TAR end-of-archive blocks or returning a selected file early. + let empty = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()) + .finish() + .unwrap(); + let mut invalid_crc = empty.clone(); + invalid_crc[empty.len() - 8] ^= 0xFF; + let mut invalid_size = empty.clone(); + invalid_size[empty.len() - 4] ^= 0xFF; + let truncated = &empty[..empty.len() - 1]; + let dir = tempfile::tempdir().unwrap(); + install_hostless_assets(dir.path(), &empty).unwrap(); + for invalid in [invalid_crc.as_slice(), invalid_size.as_slice(), truncated] { + assert!(install_hostless_assets(dir.path(), invalid).is_err()); + let error = extract_binary(invalid, "absent").unwrap_err(); + assert!( + std::error::Error::source(&error).is_some(), + "invalid gzip must be an archive error, not a missing-file result" + ); + } + } + #[cfg(all(has_bundled_cli, feature = "bundled-in-process"))] #[test] fn embedded_runtime_archive_contains_runtime_assets_and_excludes_cli() {