github_copilot_sdk/
embeddedcli.rs1#[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#[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
22pub 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
34pub 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 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 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 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 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 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}