use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::time::Duration; use sha2::Digest; 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"); // Only declare the lockfile rerun when the lockfile 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 lockfile path is only the source-of-truth in this repo's // contributor builds; everywhere else `cli-version.txt` is canonical. let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set"); let lockfile = Path::new(&manifest_dir) .join("..") .join("nodejs") .join("package-lock.json"); if lockfile.is_file() { println!("cargo:rerun-if-changed={}", lockfile.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 CLI 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 + per-asset SHA-256 from one of two sources, in order: // 1. `cli-version.txt` snapshot at the crate root (published-crate // consumer; generated by the publish workflow). Combined format: // `version=X` line + per-asset hash lines. Committing the hashes // makes the publish workflow the trust boundary — an attacker who // later re-points the release tag can't silently poison consumer // builds. // 2. Sibling `../nodejs/package-lock.json` (contributor build inside // the github/copilot-sdk repo; live SHA256SUMS.txt fetch). Matches // the .NET `_GetCopilotCliVersion` MSBuild target and the Go // `cmd/bundler` tool. let (version, expected_hash) = resolve_version_and_hash(platform.asset_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 base_url = format!("https://github.com/github/copilot-cli/releases/download/v{version}"); let cache_dir = std::env::var("BUNDLED_CLI_CACHE_DIR") .ok() .map(std::path::PathBuf::from); // Versioned cache key since copilot asset names don't include the version. let cache_key = format!("v{version}-{}", platform.asset_name); if std::env::var_os("CARGO_FEATURE_BUNDLED_CLI").is_some() { // Embed mode: we need the archive bytes to bake into the rlib, so // always run the download (cache hit short-circuits inside // `cached_download`). let archive = cached_download( &format!("{base_url}/{}", platform.asset_name), &cache_key, &expected_hash, &cache_dir, ); verify_binary_present_in_archive(&archive, platform.binary_name, platform.asset_name); emit_embedded(out, &archive); println!("cargo:rustc-cfg=has_bundled_cli"); } else { // With `bundled-cli` off the extracted binary *is* the cache. // Skip the upstream download entirely when it already exists at // the expected path. No two separate caches. // // Runtime resolution (see `src/resolve.rs::extracted_cli_path`) // 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 final_path = install_dir.join(platform.binary_name); // Invalidate build.rs whenever the cached binary 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. println!("cargo:rerun-if-changed={}", final_path.display()); if !final_path.is_file() { let archive = cached_download( &format!("{base_url}/{}", platform.asset_name), &cache_key, &expected_hash, &cache_dir, ); verify_binary_present_in_archive(&archive, platform.binary_name, platform.asset_name); extract_to_cache(&archive, &install_dir, platform); } // Re-check after potential download+extract above; not an `else` // because we need to verify the extraction actually produced the file. if final_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 the `bundled_cli.rs` glue + `copilot_cli.archive` blob into `OUT_DIR` /// for embed mode (`bundled-cli` cargo feature on). The version is exposed /// crate-wide via the unconditional `cargo:rustc-env=COPILOT_SDK_CLI_VERSION` /// emit; the binary name is OS-derived at runtime — so all we need to /// generate here is the archive blob include. fn emit_embedded(out: &Path, archive: &[u8]) { std::fs::write(out.join("copilot_cli.archive"), archive) .expect("failed to write copilot_cli.archive"); let generated = r#"// Auto-generated by github-copilot-sdk build.rs. Do not edit. pub(super) static CLI_ARCHIVE: &[u8] = include_bytes!("copilot_cli.archive"); "#; std::fs::write(out.join("bundled_cli.rs"), generated).expect("failed to write bundled_cli.rs"); } /// Resolve the CLI version and the expected SHA-256 hash for the current /// target's archive. Picks one of two sources in order. Panics with a clear /// error if neither is available. fn resolve_version_and_hash(asset_name: &str) -> (String, String) { 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.txt"); if snapshot.is_file() { let contents = std::fs::read_to_string(&snapshot) .unwrap_or_else(|e| panic!("failed to read {}: {e}", snapshot.display())); return parse_snapshot(&contents, asset_name) .unwrap_or_else(|e| panic!("invalid {}: {e}", snapshot.display())); } // 2. Lockfile fallback (contributor build inside github/copilot-sdk) — // read version, fetch live SHA256SUMS. let lockfile = Path::new(&manifest_dir) .join("..") .join("nodejs") .join("package-lock.json"); if lockfile.is_file() { let version = read_version_from_package_lock(&lockfile); let hash = fetch_live_sha256(&version, asset_name); return (version, hash); } panic!( "Could not resolve the Copilot CLI version.\n\ Tried:\n\ - {} (missing)\n\ - {} (missing)\n\ In a published crate or vendored slot, `cli-version.txt` should be present.\n\ Inside the github/copilot-sdk repo, `../nodejs/package-lock.json` is the source.", snapshot.display(), lockfile.display(), ); } /// Parse the `cli-version.txt` snapshot file. Format is one `key=value` per /// line. The first non-comment line is `version=X.Y.Z`; subsequent lines map /// asset filename to hex SHA-256. Blank lines and lines starting with `#` /// are skipped. fn parse_snapshot(contents: &str, asset_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 (key, value) = line .split_once('=') .ok_or_else(|| format!("line {}: expected `key=value`, got `{raw}`", line_no + 1))?; match key.trim() { "version" => version = Some(value.trim().to_string()), k if k == asset_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 asset `{asset_name}`"))?; Ok((version, hash)) } /// Read the `@github/copilot` version from `nodejs/package-lock.json`. fn read_version_from_package_lock(path: &Path) -> String { let contents = std::fs::read_to_string(path) .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); // Minimal JSON walk: find `"node_modules/@github/copilot"` object and // its `"version"` field. Full JSON parsing keeps build.rs dep-light by // using a regex; the file is generated by npm and we're matching an // exact key path. let key = "\"node_modules/@github/copilot\""; let key_pos = contents .find(key) .unwrap_or_else(|| panic!("{} does not contain {key}", path.display())); let after_key = &contents[key_pos + key.len()..]; let version_key = "\"version\""; let v_pos = after_key .find(version_key) .unwrap_or_else(|| panic!("no `version` field found near {key} in {}", path.display())); let after_v = &after_key[v_pos + version_key.len()..]; let q1 = after_v.find('"').expect("malformed version"); let after_q1 = &after_v[q1 + 1..]; let q2 = after_q1.find('"').expect("malformed version"); after_q1[..q2].to_string() } /// Fetch the live `SHA256SUMS.txt` for the given version from GitHub Releases /// and pluck out the entry for `asset_name`. fn fetch_live_sha256(version: &str, asset_name: &str) -> String { let base_url = format!("https://github.com/github/copilot-cli/releases/download/v{version}"); let checksums_url = format!("{base_url}/SHA256SUMS.txt"); let checksums = download_with_retry(&checksums_url); let checksums_text = std::str::from_utf8(&checksums).expect("checksums file is not valid UTF-8"); find_sha256_for_asset(checksums_text, asset_name) } #[derive(Clone, Copy)] struct Platform { asset_name: &'static str, binary_name: &'static str, } fn target_platform() -> Option { let os = std::env::var("CARGO_CFG_TARGET_OS").ok()?; let arch = std::env::var("CARGO_CFG_TARGET_ARCH").ok()?; match (os.as_str(), arch.as_str()) { ("macos", "aarch64") => Some(Platform { asset_name: "copilot-darwin-arm64.tar.gz", binary_name: "copilot", }), ("macos", "x86_64") => Some(Platform { asset_name: "copilot-darwin-x64.tar.gz", binary_name: "copilot", }), ("linux", "x86_64") => Some(Platform { asset_name: "copilot-linux-x64.tar.gz", binary_name: "copilot", }), ("linux", "aarch64") => Some(Platform { asset_name: "copilot-linux-arm64.tar.gz", binary_name: "copilot", }), ("windows", "x86_64") => Some(Platform { asset_name: "copilot-win32-x64.zip", binary_name: "copilot.exe", }), ("windows", "aarch64") => Some(Platform { asset_name: "copilot-win32-arm64.zip", binary_name: "copilot.exe", }), _ => None, } } /// Write the single binary entry from `archive` to /// `/` and return the resulting 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) -> PathBuf { let final_path = install_dir.join(platform.binary_name); // Caller already gated on `final_path.is_file()`; this is a safety // net for any future caller that forgets. if final_path.is_file() { return final_path; } std::fs::create_dir_all(install_dir).unwrap_or_else(|e| { panic!( "failed to create install dir {}: {e}", install_dir.display() ) }); let bytes = extract_binary_bytes(archive, platform); // 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}", platform.binary_name, 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)] { 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 SHA-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() ); } // Surface where the binary landed so contributors can find it. Quiet // on the hot path: the caller's `is_file()` short-circuit (and the // safety net at the top of this function) means this only fires on a // true cache miss. println!( "cargo:warning=Extracted Copilot CLI to {}", final_path.display() ); final_path } /// 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() } /// Extract the single `binary_name` entry from the release archive. Reused /// between embed mode's `verify_binary_present_in_archive` and the /// `extract_to_cache` path used when `bundled-cli` is off. Panics if the /// entry isn't found — callers have already invoked /// `verify_binary_present_in_archive`. fn extract_binary_bytes(archive: &[u8], platform: Platform) -> Vec { if platform.asset_name.ends_with(".zip") { let cursor = std::io::Cursor::new(archive); let mut zip = zip::ZipArchive::new(cursor) .unwrap_or_else(|e| panic!("failed to open zip archive: {e}")); for i in 0..zip.len() { let mut entry = zip .by_index(i) .unwrap_or_else(|e| panic!("failed to read zip entry {i}: {e}")); let name = entry.name().to_string(); if name == platform.binary_name || name.ends_with(&format!("/{}", platform.binary_name)) { let mut bytes = Vec::with_capacity(entry.size() as usize); std::io::copy(&mut entry, &mut bytes) .unwrap_or_else(|e| panic!("failed to read zip entry bytes: {e}")); return bytes; } } } else { let gz = flate2::read::GzDecoder::new(archive); let mut tar = tar::Archive::new(gz); for entry in tar .entries() .unwrap_or_else(|e| panic!("failed to read tar entries: {e}")) { let mut entry = entry.unwrap_or_else(|e| panic!("failed to read tar entry: {e}")); let path = entry .path() .unwrap_or_else(|e| panic!("failed to read tar entry path: {e}")); let name = path.to_string_lossy().into_owned(); if name == platform.binary_name || name.ends_with(&format!("/{}", platform.binary_name)) { let mut bytes = Vec::with_capacity(entry.size() as usize); entry .read_to_end(&mut bytes) .unwrap_or_else(|e| panic!("failed to read tar entry bytes: {e}")); return bytes; } } } panic!( "binary `{}` not found in archive `{}`", platform.binary_name, platform.asset_name ); } /// 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 hex_sha256(&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); let actual_hash = hex_sha256(&data); if actual_hash != expected_hash { panic!( "Archive integrity check failed for {url}!\n expected: {expected_hash}\n actual: {actual_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 agent = ureq::AgentBuilder::new() .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 find_sha256_for_asset(sums: &str, asset_name: &str) -> String { for line in sums.lines() { // Format: " " (two spaces) if let Some((hash, name)) = line.split_once(" ") && name.trim() == asset_name { return hash.trim().to_string(); } } panic!("SHA256SUMS.txt does not contain an entry for {asset_name}"); } fn sha256(data: &[u8]) -> [u8; 32] { let mut hasher = sha2::Sha256::new(); hasher.update(data); hasher.finalize().into() } /// Walks the downloaded archive at build time to confirm an entry matching /// `binary_name` exists. Panics with a clear message if not — defends against /// silent breakage if the upstream archive layout ever changes. fn verify_binary_present_in_archive(archive: &[u8], binary_name: &str, asset_name: &str) { let found = if asset_name.ends_with(".zip") { archive_contains_zip_entry(archive, binary_name) } else { archive_contains_tar_entry(archive, binary_name) }; if !found { panic!( "Copilot CLI archive `{asset_name}` does not contain an entry named `{binary_name}`. \ The upstream archive layout may have changed; runtime extraction would fail. \ Update `verify_binary_present_in_archive` in build.rs and the matching `extract_binary` in src/embeddedcli.rs." ); } } fn archive_contains_tar_entry(targz: &[u8], binary_name: &str) -> bool { let gz = flate2::read::GzDecoder::new(targz); let mut archive = tar::Archive::new(gz); let Ok(entries) = archive.entries() else { return false; }; 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 true; } } false } fn archive_contains_zip_entry(zip_bytes: &[u8], binary_name: &str) -> bool { let cursor = std::io::Cursor::new(zip_bytes); let Ok(mut archive) = zip::ZipArchive::new(cursor) else { return false; }; for i in 0..archive.len() { let Ok(entry) = archive.by_index(i) else { continue; }; let name = entry.name(); if name == binary_name || name.ends_with(&format!("/{binary_name}")) { return true; } } false } fn hex_sha256(data: &[u8]) -> String { sha256(data).iter().map(|b| format!("{b:02x}")).collect() }