Skip to main content

github_copilot_sdk/
embeddedcli.rs

1#[cfg(any(has_bundled_cli, test))]
2use std::fs;
3#[cfg(any(has_bundled_cli, test))]
4use std::io::{self, Read, Write};
5#[cfg(any(has_bundled_cli, test))]
6use std::path::Path;
7use std::path::PathBuf;
8use std::sync::OnceLock;
9
10#[cfg(has_bundled_cli)]
11use tracing::{info, warn};
12
13// When the SDK is built with COPILOT_CLI_VERSION set, build.rs generates
14// bundled_cli.rs with the compressed binary bytes, hash, and version.
15#[cfg(has_bundled_cli)]
16mod build_time {
17    include!(concat!(env!("OUT_DIR"), "/bundled_cli.rs"));
18}
19
20static INSTALLED_PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
21
22/// Returns the bundled CLI version string, if one was embedded at build time.
23pub fn bundled_version() -> Option<&'static str> {
24    #[cfg(has_bundled_cli)]
25    {
26        Some(build_time::CLI_VERSION)
27    }
28    #[cfg(not(has_bundled_cli))]
29    {
30        None
31    }
32}
33
34/// Returns the path to the installed CLI binary, lazily extracting on first call.
35///
36/// When the SDK was built with `COPILOT_CLI_VERSION` set, this extracts the
37/// embedded binary to `~/.cache/github-copilot-sdk-{version}/copilot` (or
38/// `copilot.exe` on Windows), verifies the SHA-256 hash, and returns the
39/// path. Subsequent calls return the cached result.
40///
41/// Returns `None` if no CLI was embedded at build time.
42pub fn path() -> Option<PathBuf> {
43    INSTALLED_PATH
44        .get_or_init(|| {
45            #[cfg(has_bundled_cli)]
46            {
47                match install(
48                    build_time::CLI_BYTES,
49                    build_time::CLI_HASH,
50                    build_time::CLI_VERSION,
51                ) {
52                    Ok(path) => {
53                        info!(path = %path.display(), version = build_time::CLI_VERSION, "embedded CLI installed");
54                        return Some(path);
55                    }
56                    Err(e) => {
57                        warn!(error = %e, "embedded CLI installation failed");
58                    }
59                }
60            }
61            None
62        })
63        .clone()
64}
65
66#[cfg(has_bundled_cli)]
67fn install(
68    compressed: &[u8],
69    expected_hash: [u8; 32],
70    version: &str,
71) -> Result<PathBuf, EmbeddedCliError> {
72    let verbose = std::env::var("COPILOT_CLI_INSTALL_VERBOSE").ok().as_deref() == Some("1");
73
74    let cache = dirs::cache_dir().unwrap_or_else(std::env::temp_dir);
75    // Use a versioned directory so multiple versions can coexist,
76    // but keep the binary named `copilot` — the CLI checks argv[0]
77    // for this exact name.
78    let install_dir = if version.is_empty() {
79        cache.join("github-copilot-sdk")
80    } else {
81        cache.join(format!("github-copilot-sdk-{}", sanitize_version(version)))
82    };
83    fs::create_dir_all(&install_dir).map_err(EmbeddedCliError::CreateDir)?;
84
85    let binary_name = binary_name();
86    let final_path = install_dir.join(&binary_name);
87
88    // If the binary already exists and hash matches, skip extraction.
89    if final_path.is_file() {
90        let existing_hash = hash_file(&final_path)?;
91        if existing_hash == expected_hash {
92            if verbose {
93                eprintln!("embedded CLI already installed at {}", final_path.display());
94            }
95            return Ok(final_path);
96        }
97        if verbose {
98            eprintln!("embedded CLI hash mismatch, reinstalling");
99        }
100    }
101
102    let start = std::time::Instant::now();
103    let decompressed = decompress(compressed)?;
104
105    let actual_hash = sha256(&decompressed);
106    if actual_hash != expected_hash {
107        return Err(EmbeddedCliError::HashMismatch);
108    }
109
110    write_binary(&final_path, &decompressed)?;
111
112    if verbose {
113        eprintln!(
114            "embedded CLI installed at {} in {:?}",
115            final_path.display(),
116            start.elapsed()
117        );
118    }
119
120    Ok(final_path)
121}
122
123#[cfg(any(has_bundled_cli, test))]
124fn binary_name() -> String {
125    if cfg!(target_os = "windows") {
126        "copilot.exe".to_string()
127    } else {
128        "copilot".to_string()
129    }
130}
131
132#[cfg(has_bundled_cli)]
133fn sanitize_version(version: &str) -> String {
134    version
135        .chars()
136        .map(|c| match c {
137            'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' | '_' => c,
138            _ => '_',
139        })
140        .collect()
141}
142
143#[cfg(any(has_bundled_cli, test))]
144fn decompress(data: &[u8]) -> Result<Vec<u8>, EmbeddedCliError> {
145    let mut decoder = zstd::Decoder::new(data).map_err(EmbeddedCliError::Decompress)?;
146    let mut out = Vec::new();
147    decoder
148        .read_to_end(&mut out)
149        .map_err(EmbeddedCliError::Decompress)?;
150    Ok(out)
151}
152
153#[cfg(any(has_bundled_cli, test))]
154fn sha256(data: &[u8]) -> [u8; 32] {
155    use sha2::Digest;
156    let mut hasher = sha2::Sha256::new();
157    hasher.update(data);
158    hasher.finalize().into()
159}
160
161#[cfg(has_bundled_cli)]
162fn hash_file(path: &Path) -> Result<[u8; 32], EmbeddedCliError> {
163    use sha2::Digest;
164    let mut file = fs::File::open(path).map_err(EmbeddedCliError::Io)?;
165    let mut hasher = sha2::Sha256::new();
166    let mut buf = [0u8; 8192];
167    loop {
168        let n = file.read(&mut buf).map_err(EmbeddedCliError::Io)?;
169        if n == 0 {
170            break;
171        }
172        hasher.update(&buf[..n]);
173    }
174    Ok(hasher.finalize().into())
175}
176
177#[cfg(any(has_bundled_cli, test))]
178fn write_binary(path: &Path, data: &[u8]) -> Result<(), EmbeddedCliError> {
179    let mut file = fs::OpenOptions::new()
180        .write(true)
181        .create(true)
182        .truncate(true)
183        .open(path)
184        .map_err(EmbeddedCliError::Io)?;
185
186    file.write_all(data).map_err(EmbeddedCliError::Io)?;
187
188    #[cfg(unix)]
189    {
190        use std::os::unix::fs::PermissionsExt;
191        fs::set_permissions(path, fs::Permissions::from_mode(0o755))
192            .map_err(EmbeddedCliError::Io)?;
193    }
194
195    Ok(())
196}
197
198#[cfg(any(has_bundled_cli, test))]
199#[derive(Debug, thiserror::Error)]
200#[allow(dead_code)]
201enum EmbeddedCliError {
202    #[error("failed to create install directory: {0}")]
203    CreateDir(io::Error),
204
205    #[error("decompression failed: {0}")]
206    Decompress(io::Error),
207
208    #[error("SHA-256 hash of decompressed binary does not match expected hash")]
209    HashMismatch,
210
211    #[error("I/O error: {0}")]
212    Io(io::Error),
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn install_extracts_to_cache_dir() {
221        let temp = tempfile::tempdir().expect("should create temp dir");
222        let original = b"fake copilot binary";
223        let hash = sha256(original);
224        let compressed = zstd::encode_all(&original[..], 3).expect("compression should succeed");
225
226        // Override cache dir via env for test isolation.
227        let path = install_to_dir(&temp, &compressed, hash);
228        let expected_name = binary_name();
229        assert!(path.is_file());
230        assert_eq!(
231            path.file_name().and_then(|s| s.to_str()),
232            Some(expected_name.as_str())
233        );
234
235        let installed_content = fs::read(&path).expect("should read installed binary");
236        assert_eq!(installed_content, original);
237
238        // Second install should be idempotent (hash matches, skips extraction).
239        let path2 = install_to_dir(&temp, &compressed, hash);
240        assert_eq!(path, path2);
241    }
242
243    #[test]
244    fn install_rejects_hash_mismatch() {
245        let temp = tempfile::tempdir().expect("should create temp dir");
246        let original = b"fake copilot binary";
247        let wrong_hash = [0u8; 32];
248        let compressed = zstd::encode_all(&original[..], 3).expect("compression should succeed");
249
250        let result = install_to_dir_result(&temp, &compressed, wrong_hash);
251        assert!(result.is_err());
252        assert!(result.unwrap_err().to_string().contains("SHA-256"),);
253    }
254
255    // Test helpers that install to a specific directory instead of the global cache.
256    fn install_to_dir(temp: &tempfile::TempDir, compressed: &[u8], hash: [u8; 32]) -> PathBuf {
257        install_to_dir_result(temp, compressed, hash).expect("install should succeed")
258    }
259
260    fn install_to_dir_result(
261        temp: &tempfile::TempDir,
262        compressed: &[u8],
263        hash: [u8; 32],
264    ) -> Result<PathBuf, EmbeddedCliError> {
265        let install_dir = temp.path().to_path_buf();
266        fs::create_dir_all(&install_dir).expect("create dir");
267        let binary_name = binary_name();
268        let final_path = install_dir.join(&binary_name);
269
270        let decompressed = decompress(compressed)?;
271        let actual_hash = sha256(&decompressed);
272        if actual_hash != hash {
273            return Err(EmbeddedCliError::HashMismatch);
274        }
275        write_binary(&final_path, &decompressed)?;
276        Ok(final_path)
277    }
278}