use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::time::Duration; use sha2::Digest; pub(crate) fn main() { println!("cargo:rerun-if-env-changed=DOCS_RS"); println!("cargo:rerun-if-env-changed=COPILOT_SKIP_CLI_DOWNLOAD"); println!("cargo:rerun-if-env-changed=COPILOT_CLI_EXTRACT_DIR"); println!("cargo:rerun-if-env-changed=BUNDLED_CLI_CACHE_DIR"); println!("cargo::rustc-check-cfg=cfg(has_bundled_cli)"); println!("cargo::rustc-check-cfg=cfg(has_extracted_cli)"); println!("cargo:rerun-if-changed=cli-version.txt"); println!("cargo:rerun-if-changed=cli-version-in-process.txt"); // Only declare the package metadata rerun when it actually exists. // Cargo treats `rerun-if-changed` for a missing path as "always rerun" // — so unconditionally declaring this on consumers without a sibling // `nodejs/` (vendored slots, published crates) would force build.rs // to re-run on every `cargo build` even when nothing has changed. // The package file is only the source-of-truth in this repo's // contributor builds; everywhere else the snapshot files are canonical. let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set"); let package_json = Path::new(&manifest_dir) .join("..") .join("nodejs") .join("package.json"); if package_json.is_file() { println!("cargo:rerun-if-changed={}", package_json.display()); } // Hard opt-out: disable the entire download / bundle / cache mechanism // in one step. For consumers who always supply the CLI via // `CliProgram::Path` or `COPILOT_CLI_PATH` and don't want build.rs to // touch the network (offline builds, locked-down CI, etc.). Works // regardless of the `bundled-cli` cargo feature state — with neither // `has_bundled_cli` nor `has_extracted_cli` emitted, runtime resolution // falls straight through to `Error::BinaryNotFound` unless an explicit // path source resolves first. if std::env::var_os("COPILOT_SKIP_CLI_DOWNLOAD").is_some() { println!( "cargo:warning=COPILOT_SKIP_CLI_DOWNLOAD is set — skipping runtime download/bundle/cache" ); return; } // docs.rs builds in a sandboxed environment without network access. // Skip the CLI download so documentation can be generated successfully. if std::env::var_os("DOCS_RS").is_some() { println!("cargo:warning=DOCS_RS is set — skipping CLI download/bundle/cache"); return; } let Some(platform) = target_platform() else { println!("cargo:warning=Unsupported target platform for Copilot CLI bundling — skipping"); return; }; let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is always set by cargo"); let out = Path::new(&out_dir); // Resolve version and, when available locally, the release SHA-256 from // one of two sources, in order: // 1. `cli-version-in-process.txt` snapshot at the crate root (published-crate // consumer; generated by the publish workflow from SHA256SUMS.txt). // 2. Sibling `../nodejs/package.json` plus the release SHA256SUMS.txt // (contributor build inside the github/copilot-sdk repo). let (version, local_expected_hash) = resolve_version_and_optional_hash(platform.package_name); // Bake the version into the crate regardless of mode. This is the // single source of truth for "what CLI version did build.rs target", // consumed by both the embed-mode path computation in embeddedcli.rs // and the runtime path computation in resolve.rs (when `bundled-cli` // is off). It's a small, machine-independent datum: no absolute // paths, no username/home leakage, so sccache / cross-machine // `target/` reuse stays cache-coherent. println!("cargo:rustc-env=COPILOT_SDK_CLI_VERSION={version}"); let asset_platform = platform .package_name .strip_prefix("copilot-") .expect("platform package names start with copilot-"); let archive_name = format!("github-copilot-{version}-{asset_platform}.tgz"); let download_url = format!( "https://github.com/github/copilot-cli/releases/download/v{version}/{archive_name}" ); let cache_dir = std::env::var("BUNDLED_CLI_CACHE_DIR") .ok() .map(std::path::PathBuf::from); let cache_key = format!("v{version}-{archive_name}"); let include_runtime = std::env::var_os("CARGO_FEATURE_BUNDLED_IN_PROCESS").is_some(); if std::env::var_os("CARGO_FEATURE_BUNDLED_CLI").is_some() { let runtime_expected_hash = local_expected_hash .clone() .unwrap_or_else(|| fetch_in_process_release_hash(&version, platform.package_name)); let runtime_package = cached_download( &download_url, &cache_key, &runtime_expected_hash, &cache_dir, ); verify_runtime_package(&runtime_package, platform, &archive_name); let cli_asset_name = platform.cli_asset_name(); let cli_expected_hash = resolve_cli_hash(&version, &cli_asset_name); let cli_archive = cached_download( &format!( "https://github.com/github/copilot-cli/releases/download/v{version}/{cli_asset_name}" ), &format!("v{version}-{cli_asset_name}"), &cli_expected_hash, &cache_dir, ); let cli_binary_size = verify_cli_archive(&cli_archive, platform, &cli_asset_name); emit_embedded( out, &cli_archive, cli_binary_size, &runtime_package, platform, include_runtime, ); println!("cargo:rustc-cfg=has_bundled_cli"); } else { // With `bundled-cli` off the extracted runtime pair *is* the cache. // Skip the upstream download entirely when both files already exist. // // Runtime resolution (see `src/resolve.rs::extracted_program`) // recomputes this same path from `COPILOT_SDK_CLI_VERSION` + the // OS-derived binary name + optional `COPILOT_CLI_EXTRACT_DIR`, // so we don't bake an absolute path into the crate. let install_dir = extracted_install_dir(&version); let required_paths = [ install_dir.join(platform.runtime_wrapper_name()), install_dir.join("runtime.node"), install_dir.join(".hostless-runtime-assets-v1"), ]; // Invalidate build.rs whenever either cached artifact disappears (cache // GC, manual rm, OS reset, switching extract dir). Without this, cargo // replays the saved `has_extracted_cli` cfg from its build-script // output cache even when the file is gone, and runtime resolution // fails with BinaryNotFound. for path in &required_paths { println!("cargo:rerun-if-changed={}", path.display()); } let marker = std::fs::read_to_string(&required_paths[2]).ok(); let cache_is_current = required_paths.iter().all(|path| path.is_file()) && match local_expected_hash.as_deref() { Some(expected_hash) => { marker.as_deref() == Some(&format!("{version}\n{expected_hash}\n")) } None => marker .as_deref() .is_some_and(|contents| marker_matches_version(contents, &version)), }; if !cache_is_current { let expected_hash = local_expected_hash .unwrap_or_else(|| fetch_in_process_release_hash(&version, platform.package_name)); let expected_marker = format!("{version}\n{expected_hash}\n"); if install_dir.exists() { std::fs::remove_dir_all(&install_dir).unwrap_or_else(|e| { panic!( "failed to clear stale runtime bundle {}: {e}", install_dir.display() ) }); } let archive = cached_download(&download_url, &cache_key, &expected_hash, &cache_dir); verify_runtime_package(&archive, platform, &archive_name); extract_to_cache( &archive, &install_dir, platform, include_runtime, &expected_marker, ); } // Re-check after potential download+extract above; not an `else` // because we need to verify the extraction actually produced the file. if required_paths.iter().all(|path| path.is_file()) { println!("cargo:rustc-cfg=has_extracted_cli"); } } } /// Install directory used when `bundled-cli` is off. Mirrors the runtime /// convention in `src/resolve.rs::extracted_cli_path`: both sides MUST /// compute the same path from the same inputs, otherwise the runtime /// resolver won't find what build.rs extracted. /// /// If `COPILOT_CLI_EXTRACT_DIR` is set the binary lives directly under /// that directory (no per-version subdir) — useful for vendored slots and /// for `.cargo/config.toml [env]`-style pinning that's symmetric between /// build-time write and runtime read. Otherwise the binary lives under /// `/github-copilot-sdk/cli//`. fn extracted_install_dir(version: &str) -> PathBuf { if let Some(custom) = std::env::var_os("COPILOT_CLI_EXTRACT_DIR") { PathBuf::from(custom) } else { let cache = dirs::cache_dir().unwrap_or_else(std::env::temp_dir); cache .join("github-copilot-sdk") .join("cli") .join(sanitize_version(version)) } } /// Emit separate full-CLI and runtime payloads into `OUT_DIR` for embed mode. fn emit_embedded( out: &Path, cli_archive: &[u8], cli_binary_size: u64, runtime_package: &[u8], platform: Platform, include_runtime: bool, ) { let runtime_archive = build_embedded_runtime_archive(runtime_package, platform, include_runtime); std::fs::write(out.join("copilot_cli.archive"), cli_archive) .expect("failed to write copilot_cli.archive"); std::fs::write(out.join("copilot_runtime.archive"), runtime_archive) .expect("failed to write copilot_runtime.archive"); let generated = format!( r#"// Auto-generated by github-copilot-sdk build.rs. Do not edit. pub(super) static CLI_ARCHIVE: &[u8] = include_bytes!("copilot_cli.archive"); pub(super) static RUNTIME_ARCHIVE: &[u8] = include_bytes!("copilot_runtime.archive"); pub(super) const CLI_BINARY_SIZE: u64 = {cli_binary_size}; "# ); std::fs::write(out.join("bundled_cli.rs"), generated).expect("failed to write bundled_cli.rs"); } fn build_embedded_runtime_archive( package: &[u8], platform: Platform, include_runtime: bool, ) -> Vec { let encoder = flate2::GzBuilder::new() .mtime(0) .write(Vec::new(), flate2::Compression::default()); let mut archive = tar::Builder::new(encoder); let runtime = append_hostless_runtime_tree(&mut archive, package, platform); if include_runtime { append_archive_file( &mut archive, platform.runtime_library_name(), &runtime, 0o644, ); } let encoder = archive .into_inner() .expect("failed to finish minimal embedded CLI archive"); encoder .finish() .expect("failed to compress minimal embedded CLI archive") } fn append_hostless_runtime_tree( archive: &mut tar::Builder, package: &[u8], platform: Platform, ) -> Vec { let decoder = flate2::read::GzDecoder::new(package); let mut source = tar::Archive::new(decoder); let mut runtime = None; for entry in source .entries() .unwrap_or_else(|e| panic!("failed to read npm package entries: {e}")) { let mut entry = entry.unwrap_or_else(|e| panic!("failed to read npm package entry: {e}")); if !entry.header().entry_type().is_file() { continue; } let source_path = entry .path() .unwrap_or_else(|e| panic!("failed to read npm package path: {e}")); let Some(destination) = hostless_runtime_path(&source_path.to_string_lossy(), platform) else { continue; }; let mut bytes = Vec::with_capacity(entry.size() as usize); entry .read_to_end(&mut bytes) .unwrap_or_else(|e| panic!("failed to read npm package entry bytes: {e}")); let mode = entry.header().mode().unwrap_or(0o644); if destination == Path::new("runtime.node") { runtime = Some(bytes.clone()); } append_archive_file( archive, destination .to_str() .expect("npm package paths are valid UTF-8"), &bytes, mode, ); } runtime.unwrap_or_else(|| { panic!( "package `{}` does not contain prebuilds//runtime.node", platform.package_name ) }) } fn hostless_runtime_path(source: &str, platform: Platform) -> Option { let relative = source.strip_prefix("package/")?; let parts: Vec<&str> = relative.split('/').collect(); if parts.iter().any(|part| part.is_empty() || *part == "..") { return None; } let top_level = *parts.first()?; let file_name = *parts.last()?; const EXCLUDED_TOP_LEVEL: &[&str] = &[ "app.js", "assets", "changelog.json", "foundry-local-sdk", "index.js", "LICENSE.md", "napi-oop-runtime", "npm-loader.js", "package.json", "pvrecorder", "queries", "README.md", "sea-loader.js", "webview", ]; if EXCLUDED_TOP_LEVEL.contains(&top_level) || (top_level.starts_with("tree-sitter") && top_level.ends_with(".wasm")) || (top_level.starts_with("voice-") && top_level.ends_with(".js")) || file_name == "cli-native.node" || parts.contains(&"mediaremote-adapter") || file_name.starts_with("copilot-runtime-bin") { return None; } if top_level == "prebuilds" { let npm_platform = platform .package_name .strip_prefix("copilot-") .expect("platform package name has copilot- prefix"); if parts.get(1) != Some(&npm_platform) || parts.len() < 3 { return None; } return Some(parts[2..].iter().copied().collect()); } Some(parts.iter().copied().collect()) } fn append_archive_file( archive: &mut tar::Builder, path: &str, bytes: &[u8], mode: u32, ) { let mut header = tar::Header::new_gnu(); header.set_size(bytes.len() as u64); header.set_mode(mode); header.set_uid(0); header.set_gid(0); header.set_mtime(0); header.set_cksum(); archive .append_data(&mut header, path, bytes) .unwrap_or_else(|e| panic!("failed to add `{path}` to embedded CLI archive: {e}")); } /// Resolve the CLI version and any locally snapshotted release hash for the /// current target's platform package. Contributor builds defer fetching the /// checksum until a download is actually required. fn resolve_version_and_optional_hash(package_name: &str) -> (String, Option) { let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set"); // 1. Snapshot file at the crate root (published-crate consumer, // vendored-slot consumer). Combined version + per-asset hashes. let snapshot = Path::new(&manifest_dir).join("cli-version-in-process.txt"); if snapshot.is_file() { let contents = std::fs::read_to_string(&snapshot) .unwrap_or_else(|e| panic!("failed to read {}: {e}", snapshot.display())); let (version, hash) = parse_snapshot(&contents, package_name) .unwrap_or_else(|e| panic!("invalid {}: {e}", snapshot.display())); return (version, Some(hash)); } // 2. Package version plus release checksums (contributor build). let package_json = Path::new(&manifest_dir) .join("..") .join("nodejs") .join("package.json"); if package_json.is_file() { let version = read_version_from_package_json(&package_json); return (version, None); } panic!( "Could not resolve the Copilot CLI version.\n\ Tried:\n\ - {} (missing)\n\ - {} (missing)\n\ In a published crate or vendored slot, `cli-version-in-process.txt` should be present.\n\ Inside the github/copilot-sdk repo, `../nodejs/package.json` is the version source.", snapshot.display(), package_json.display(), ); } fn fetch_in_process_release_hash(version: &str, package_name: &str) -> String { let platform = package_name .strip_prefix("copilot-") .expect("platform package names start with copilot-"); let asset_name = format!("github-copilot-{version}-{platform}.tgz"); fetch_release_hash(version, &asset_name) } fn resolve_cli_hash(version: &str, asset_name: &str) -> String { let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set"); let snapshot = Path::new(&manifest_dir).join("cli-version.txt"); if snapshot.is_file() { let contents = std::fs::read_to_string(&snapshot) .unwrap_or_else(|e| panic!("failed to read {}: {e}", snapshot.display())); let (snapshot_version, hash) = parse_snapshot(&contents, asset_name) .unwrap_or_else(|e| panic!("invalid {}: {e}", snapshot.display())); assert_eq!( snapshot_version, version, "{} and the selected runtime version source must pin the same version", snapshot.display() ); return hash; } fetch_release_hash(version, asset_name) } fn marker_matches_version(contents: &str, version: &str) -> bool { let mut lines = contents.lines(); lines.next() == Some(version) && lines.next().is_some_and(|hash| { hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit()) }) && lines.next().is_none() } /// Parse the `cli-version-in-process.txt` snapshot file. Format is one `key=value` per /// line. The first non-comment line is `version=X.Y.Z`; subsequent lines map /// platform package name to SHA-256. Blank lines and lines starting with `#` /// are skipped. fn parse_snapshot(contents: &str, package_name: &str) -> Result<(String, String), String> { let mut version: Option = None; let mut hash: Option = None; for (line_no, raw) in contents.lines().enumerate() { let line = raw.trim(); if line.is_empty() || line.starts_with('#') { continue; } let Some((key, value)) = line.split_once('=') else { return Err(format!( "line {}: expected `key=value`, got `{raw}`", line_no + 1 )); }; match key.trim() { "version" => version = Some(value.trim().to_string()), k if k == package_name => hash = Some(value.trim().to_string()), _ => {} } } let version = version.ok_or("missing `version=` line")?; let hash = hash.ok_or_else(|| format!("missing hash for package `{package_name}`"))?; Ok((version, hash)) } fn read_version_from_package_json(path: &Path) -> String { let contents = std::fs::read_to_string(path) .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); let package_json: serde_json::Value = serde_json::from_str(&contents) .unwrap_or_else(|e| panic!("failed to parse {}: {e}", path.display())); package_json["copilotCliVersion"] .as_str() .unwrap_or_else(|| panic!("copilotCliVersion is missing in {}", path.display())) .to_string() } fn fetch_release_hash(version: &str, asset_name: &str) -> String { let url = format!( "https://github.com/github/copilot-cli/releases/download/v{version}/SHA256SUMS.txt" ); let checksums = download_with_retry(&url); let checksums = std::str::from_utf8(&checksums).expect("SHA256SUMS.txt is not valid UTF-8"); find_sha256_for_asset(checksums, asset_name) } fn find_sha256_for_asset(sums: &str, asset_name: &str) -> String { sums.lines() .find_map(|line| { let (hash, name) = line.split_once(char::is_whitespace)?; (name.trim_start().trim_start_matches('*') == asset_name).then(|| hash.to_string()) }) .unwrap_or_else(|| panic!("SHA256SUMS.txt does not contain {asset_name}")) } #[derive(Clone, Copy)] struct Platform { package_name: &'static str, binary_name: &'static str, } impl Platform { fn cli_asset_name(&self) -> String { let platform = self .package_name .strip_prefix("copilot-") .expect("platform package name has copilot- prefix"); let extension = if self.package_name.contains("win32") { "zip" } else { "tar.gz" }; format!("copilot-{platform}.{extension}") } fn runtime_wrapper_name(&self) -> &'static str { if self.package_name.contains("win32") { "copilot-runtime.exe" } else { "copilot-runtime" } } fn runtime_library_name(&self) -> &'static str { if self.package_name.contains("win32") { "copilot_runtime.dll" } else if self.package_name.contains("darwin") { "libcopilot_runtime.dylib" } else { "libcopilot_runtime.so" } } } fn target_platform() -> Option { let os = std::env::var("CARGO_CFG_TARGET_OS").ok()?; let arch = std::env::var("CARGO_CFG_TARGET_ARCH").ok()?; let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); match (os.as_str(), arch.as_str(), target_env.as_str()) { ("macos", "aarch64", _) => Some(Platform { package_name: "copilot-darwin-arm64", binary_name: "copilot", }), ("macos", "x86_64", _) => Some(Platform { package_name: "copilot-darwin-x64", binary_name: "copilot", }), ("linux", "x86_64", "musl") => Some(Platform { package_name: "copilot-linuxmusl-x64", binary_name: "copilot", }), ("linux", "aarch64", "musl") => Some(Platform { package_name: "copilot-linuxmusl-arm64", binary_name: "copilot", }), ("linux", "x86_64", _) => Some(Platform { package_name: "copilot-linux-x64", binary_name: "copilot", }), ("linux", "aarch64", _) => Some(Platform { package_name: "copilot-linux-arm64", binary_name: "copilot", }), ("windows", "x86_64", _) => Some(Platform { package_name: "copilot-win32-x64", binary_name: "copilot.exe", }), ("windows", "aarch64", _) => Some(Platform { package_name: "copilot-win32-arm64", binary_name: "copilot.exe", }), _ => None, } } /// Write the runtime wrapper pair from `archive` to `install_dir` and return /// the wrapper path. /// Idempotent — returns the existing path if a previous build already /// populated the target. /// /// Uses file-level staging + atomic rename so a concurrent reader during /// a parallel `cargo build` race never observes a partially-written /// binary. `fs::rename` for files is atomic on both Unix and Windows /// (Windows uses `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING`); for /// directories it is not, which is why we stage at file granularity. fn extract_to_cache( archive: &[u8], install_dir: &Path, platform: Platform, include_runtime: bool, marker: &str, ) -> PathBuf { std::fs::create_dir_all(install_dir).unwrap_or_else(|e| { panic!( "failed to create install dir {}: {e}", install_dir.display() ) }); let decoder = flate2::read::GzDecoder::new(archive); let mut source = tar::Archive::new(decoder); let mut runtime = None; for entry in source .entries() .unwrap_or_else(|e| panic!("failed to read npm package entries: {e}")) { let mut entry = entry.unwrap_or_else(|e| panic!("failed to read npm package entry: {e}")); if !entry.header().entry_type().is_file() { continue; } let source_path = entry .path() .unwrap_or_else(|e| panic!("failed to read npm package path: {e}")); let Some(destination) = hostless_runtime_path(&source_path.to_string_lossy(), platform) else { continue; }; if destination == Path::new(platform.binary_name) { continue; } let mut bytes = Vec::with_capacity(entry.size() as usize); entry .read_to_end(&mut bytes) .unwrap_or_else(|e| panic!("failed to read npm package entry bytes: {e}")); let executable = entry.header().mode().unwrap_or(0o644) & 0o111 != 0; if destination == Path::new("runtime.node") { runtime = Some(bytes.clone()); } install_cached_file_path(install_dir, &destination, &bytes, executable); } let runtime = runtime.expect("verified runtime.node is present"); if include_runtime { install_cached_file( install_dir, platform.runtime_library_name(), &runtime, false, ); } install_cached_file( install_dir, ".hostless-runtime-assets-v1", marker.as_bytes(), false, ); let final_path = install_dir.join(platform.runtime_wrapper_name()); println!( "cargo:warning=Extracted Copilot runtime bundle to {}", install_dir.display() ); final_path } fn install_cached_file(install_dir: &Path, file_name: &str, bytes: &[u8], executable: bool) { install_cached_file_path(install_dir, Path::new(file_name), bytes, executable); } fn install_cached_file_path( install_dir: &Path, relative_path: &Path, bytes: &[u8], executable: bool, ) { // `executable` only affects file permissions on Unix (see the `#[cfg(unix)]` // block below); explicitly mark it used elsewhere so non-Unix targets don't // warn about an unused parameter under `-D warnings`. #[cfg(not(unix))] let _ = executable; assert!( !relative_path.is_absolute() && !relative_path.components().any(|component| { matches!( component, std::path::Component::Prefix(_) | std::path::Component::RootDir | std::path::Component::ParentDir ) }), "unsafe runtime package path: {}", relative_path.display() ); let final_path = install_dir.join(relative_path); if final_path.is_file() { return; } std::fs::create_dir_all(final_path.parent().expect("runtime asset has parent")) .unwrap_or_else(|e| panic!("failed to create runtime asset directory: {e}")); // Staging file is a sibling of the final binary so the rename stays // on the same filesystem (cross-fs rename is not atomic). PID + nanos // disambiguate concurrent builds racing on the same cache. let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); let staging_path = install_dir.join(format!( ".{}.staging-{}-{nanos}", relative_path .file_name() .and_then(|name| name.to_str()) .unwrap_or("runtime-asset"), std::process::id(), )); { let mut f = std::fs::File::create(&staging_path).unwrap_or_else(|e| { let _ = std::fs::remove_file(&staging_path); panic!( "failed to create staging file {}: {e}", staging_path.display() ); }); if let Err(e) = f.write_all(bytes) { let _ = std::fs::remove_file(&staging_path); panic!( "failed to write staging file {}: {e}", staging_path.display() ); } #[cfg(unix)] if executable { use std::os::unix::fs::PermissionsExt; if let Err(e) = f.set_permissions(std::fs::Permissions::from_mode(0o755)) { let _ = std::fs::remove_file(&staging_path); panic!("failed to chmod {}: {e}", staging_path.display()); } } // Backdate the staged binary to the Unix epoch before it lands. We emit // `cargo:rerun-if-changed` on `final_path` (see caller) so a *deleted* // cache binary forces a re-extract — but cargo stamps the build-script // `output` reference when the script is spawned, seconds before this // freshly-downloaded binary is written. A current mtime would therefore // be *newer* than that reference, so the next identical `cargo` // invocation would see the watched file as "changed" and pointlessly // rerun build.rs + recompile the crate + relink every downstream crate. // Pinning to the epoch keeps the file unambiguously older than any real // build reference; `rename` preserves mtime (same inode), so it lands // already-backdated and a no-change rebuild stays a true no-op. The // deleted-file recovery contract is untouched: a missing file can't be // stat'd, so cargo still treats it as stale and reruns regardless. // // Best-effort: a filesystem that refuses the epoch (e.g. FAT's 1980 floor // clamps it — still older than any real reference) or rejects the call // just reverts to the pre-fix redundant-rebuild behaviour, never a broken // build. if let Err(e) = f.set_modified(std::time::SystemTime::UNIX_EPOCH) { println!( "cargo:warning=Could not backdate {} (a redundant rebuild may occur): {e}", staging_path.display() ); } } // Atomic file-replace on both Unix and Windows. If a concurrent build // already produced the same file the rename overwrites it; the bytes // are integrity-verified-identical so replacement is safe. if let Err(e) = std::fs::rename(&staging_path, &final_path) { let _ = std::fs::remove_file(&staging_path); panic!( "failed to rename {} -> {}: {e}", staging_path.display(), final_path.display() ); } } /// Replace characters outside `[a-zA-Z0-9._-]` with `_` so the version /// string is always safe to use as a path component. Kept in sync with /// `embeddedcli::sanitize_version` and `resolve::sanitize_version` so all /// three resolve to the same cache directory for any given version. fn sanitize_version(version: &str) -> String { version .chars() .map(|c| match c { 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' | '_' => c, _ => '_', }) .collect() } /// Read a file from the download cache, or download it (with retries) and save /// to cache. Verifies SHA-256 on every path. Evicts stale/corrupt cache entries /// automatically. Cache I/O failures are treated as cache misses — they never /// break the build. fn cached_download( url: &str, cache_key: &str, expected_hash: &str, cache_dir: &Option, ) -> Vec { if let Some(dir) = cache_dir { let cached_path = dir.join(cache_key); if cached_path.is_file() { match std::fs::read(&cached_path) { Ok(data) if verify_hash(&data, expected_hash) => { // Silent cache hit — nothing to surface. return data; } Ok(_) => { println!("cargo:warning=Cached archive hash mismatch, re-downloading"); let _ = std::fs::remove_file(&cached_path); } Err(e) => { println!( "cargo:warning=Failed to read cache {}, re-downloading: {e}", cached_path.display() ); } } } } println!("cargo:warning=Downloading {url}"); let data = download_with_retry(url); if !verify_hash(&data, expected_hash) { panic!( "Archive integrity check failed for {url}!\n expected: {expected_hash}\n \ This could indicate a corrupted download or a supply-chain attack." ); } if let Some(dir) = cache_dir { if let Err(e) = std::fs::create_dir_all(dir) { println!( "cargo:warning=Failed to create cache directory {}: {e}", dir.display() ); } else { let cached_path = dir.join(cache_key); println!("cargo:warning=Caching archive at {}", cached_path.display()); if let Err(e) = std::fs::write(&cached_path, &data) { println!( "cargo:warning=Failed to write cache file {}: {e}", cached_path.display() ); } } } data } /// Maximum number of HTTP attempts (one initial + this many retries on transient errors). const MAX_RETRIES: u32 = 3; /// Download `url` with bounded retries on transient network errors. Backoff is /// exponential starting at 1s. 4xx responses fail fast; 5xx and connect/read /// errors are retried. fn download_with_retry(url: &str) -> Vec { let mut attempt = 0u32; loop { attempt += 1; match try_download(url) { Ok(bytes) => return bytes, Err(err) if err.transient && attempt <= MAX_RETRIES => { let backoff = Duration::from_secs(1u64 << (attempt - 1)); println!( "cargo:warning=Transient download failure for {url} (attempt {attempt}/{}): {} — retrying in {}s", MAX_RETRIES + 1, err.message, backoff.as_secs(), ); std::thread::sleep(backoff); } Err(err) => panic!("Failed to download {url}: {}", err.message), } } } struct DownloadError { message: String, transient: bool, } fn try_download(url: &str) -> Result, DownloadError> { let connector = native_tls::TlsConnector::new().map_err(|e| DownloadError { message: format!("native-tls init error: {e}"), transient: false, })?; let agent = ureq::AgentBuilder::new() .tls_connector(std::sync::Arc::new(connector)) .timeout_connect(Duration::from_secs(30)) .timeout_read(Duration::from_secs(120)) .build(); match agent.get(url).call() { Ok(response) => { let mut bytes = Vec::new(); response .into_reader() .read_to_end(&mut bytes) .map_err(|e| DownloadError { message: format!("read error: {e}"), transient: true, })?; Ok(bytes) } // 5xx — server-side, treat as transient. Err(ureq::Error::Status(code, response)) if (500..600).contains(&code) => { Err(DownloadError { message: format!("HTTP {code} {}", response.status_text()), transient: true, }) } // 4xx — client-side, fail fast. Err(ureq::Error::Status(code, response)) => Err(DownloadError { message: format!("HTTP {code} {}", response.status_text()), transient: false, }), // Transport-layer (DNS, connect, TLS, read timeout) — treat as transient. Err(ureq::Error::Transport(t)) => Err(DownloadError { message: format!("transport error: {t}"), transient: true, }), } } fn verify_runtime_package(archive: &[u8], platform: Platform, package_name: &str) { for file_name in ["runtime.node", platform.runtime_wrapper_name()] { if archive_contains_tar_entry(archive, file_name) { continue; } panic!( "Copilot runtime package `{package_name}` does not contain an entry named `{file_name}`" ); } } fn verify_cli_archive(archive: &[u8], platform: Platform, archive_name: &str) -> u64 { let binary_size = if platform.package_name.contains("win32") { archive_zip_entry_size(archive, platform.binary_name) } else { archive_tar_entry_size(archive, platform.binary_name) }; binary_size.unwrap_or_else(|| { panic!( "Copilot CLI archive `{archive_name}` does not contain an entry named `{}`", platform.binary_name ) }) } fn archive_contains_tar_entry(targz: &[u8], binary_name: &str) -> bool { archive_tar_entry_size(targz, binary_name).is_some() } fn archive_tar_entry_size(targz: &[u8], binary_name: &str) -> Option { let gz = flate2::read::GzDecoder::new(targz); let mut archive = tar::Archive::new(gz); let Ok(entries) = archive.entries() else { return None; }; for entry in entries.flatten() { let Ok(path) = entry.path() else { continue; }; let name = path.to_string_lossy(); if name == binary_name || name.ends_with(&format!("/{binary_name}")) { return Some(entry.size()); } } None } fn archive_zip_entry_size(zip_bytes: &[u8], binary_name: &str) -> Option { let reader = std::io::Cursor::new(zip_bytes); let Ok(mut archive) = zip::ZipArchive::new(reader) else { return None; }; (0..archive.len()).find_map(|index| { archive.by_index(index).ok().and_then(|entry| { (entry.name() == binary_name || entry.name().ends_with(&format!("/{binary_name}"))) .then(|| entry.size()) }) }) } fn verify_hash(data: &[u8], expected: &str) -> bool { let mut hasher = sha2::Sha256::new(); hasher.update(data); format!("{:x}", hasher.finalize()) == expected }