1use std::io;
5use std::process::{Command, Stdio};
6use std::time::Duration;
7
8use crate::error::{AppError, Result};
9use crate::vendor::vendor_secret_env_vars_to_remove;
10
11pub fn default_hosts_path() -> Result<std::path::PathBuf> {
21 hosts_path_with(
22 |name| std::env::var_os(name).filter(|value| !value.is_empty()),
23 crate::cache::home_dir()?,
24 )
25}
26
27pub fn hosts_path_with(
30 environment: impl Fn(&str) -> Option<std::ffi::OsString>,
31 home: std::path::PathBuf,
32) -> Result<std::path::PathBuf> {
33 if let Some(dir) = environment("GH_CONFIG_DIR") {
34 return Ok(std::path::PathBuf::from(dir).join("hosts.yml"));
35 }
36 if let Some(dir) = environment("XDG_CONFIG_HOME") {
37 return Ok(std::path::PathBuf::from(dir).join("gh").join("hosts.yml"));
38 }
39 if cfg!(windows)
40 && let Some(dir) = environment("AppData")
41 {
42 return Ok(std::path::PathBuf::from(dir)
43 .join("GitHub CLI")
44 .join("hosts.yml"));
45 }
46 Ok(home.join(".config").join("gh").join("hosts.yml"))
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct GhAuthTokenCommand {
54 pub program: std::path::PathBuf,
55 pub args: Vec<String>,
60 pub env_remove: Vec<&'static str>,
61}
62
63impl GhAuthTokenCommand {
64 pub fn standard(gh_binary: Option<&std::path::Path>) -> Self {
69 Self {
70 program: gh_binary.map_or_else(|| std::path::PathBuf::from("gh"), Into::into),
71 args: vec!["auth".to_string(), "token".to_string()],
72 env_remove: vendor_secret_env_vars_to_remove(&[]),
75 }
76 }
77
78 pub fn for_user(gh_binary: Option<&std::path::Path>, login: &str) -> Result<Self> {
87 validate_gh_login(login)?;
88 let mut command = Self::standard(gh_binary);
89 command.args.push("--user".to_string());
90 command.args.push(login.to_string());
91 Ok(command)
92 }
93}
94
95fn validate_gh_login(login: &str) -> Result<()> {
100 let bad = |why: &str| {
101 Err(AppError::Other(format!(
102 "GitHub Copilot: {login:?} is not a valid GitHub login ({why}). \
103 Use the account's login name, as shown by `gh auth status`."
104 )))
105 };
106 if login.is_empty() || login.len() > 39 {
107 return bad("1 to 39 characters");
108 }
109 if !login
110 .bytes()
111 .all(|b| b.is_ascii_alphanumeric() || b == b'-')
112 {
113 return bad("letters, digits and hyphens only");
114 }
115 if login.starts_with('-') || login.ends_with('-') || login.contains("--") {
116 return bad("no leading, trailing or repeated hyphens");
117 }
118 Ok(())
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct GhAuthTokenOutput {
123 pub success: bool,
124 pub stdout: Vec<u8>,
125}
126
127pub trait GhAuthTokenRunner {
130 fn run(&self, command: &GhAuthTokenCommand) -> io::Result<GhAuthTokenOutput>;
131}
132
133pub struct SystemGhAuthTokenRunner;
134
135const GH_AUTH_TOKEN_TIMEOUT: Duration = Duration::from_secs(5);
136
137impl GhAuthTokenRunner for SystemGhAuthTokenRunner {
138 fn run(&self, command: &GhAuthTokenCommand) -> io::Result<GhAuthTokenOutput> {
139 let mut process = Command::new(&command.program);
140 process
141 .args(&command.args)
142 .stdin(Stdio::null())
143 .stdout(Stdio::piped())
144 .stderr(Stdio::null());
145 for variable in &command.env_remove {
146 process.env_remove(variable);
147 }
148 #[cfg(windows)]
151 {
152 use std::os::windows::process::CommandExt;
153 process.creation_flags(crate::process::CREATE_NO_WINDOW);
154 }
155 let output = process.output()?;
156 Ok(GhAuthTokenOutput {
157 success: output.status.success(),
158 stdout: output.stdout,
159 })
160 }
161}
162
163impl SystemGhAuthTokenRunner {
164 async fn run_async(
169 &self,
170 command: GhAuthTokenCommand,
171 timeout: Duration,
172 ) -> io::Result<GhAuthTokenOutput> {
173 tokio::spawn(async move { run_system_async(command, timeout).await })
174 .await
175 .map_err(|error| io::Error::other(format!("gh credential task failed: {error}")))?
176 }
177}
178
179async fn run_system_async(
180 command: GhAuthTokenCommand,
181 timeout: Duration,
182) -> io::Result<GhAuthTokenOutput> {
183 use tokio::io::AsyncReadExt;
184
185 let mut process = tokio::process::Command::new(&command.program);
186 process
187 .args(&command.args)
188 .stdin(Stdio::null())
189 .stdout(Stdio::piped())
190 .stderr(Stdio::null())
191 .kill_on_drop(true);
192 for variable in &command.env_remove {
193 process.env_remove(variable);
194 }
195 #[cfg(windows)]
196 {
197 process.creation_flags(crate::process::CREATE_NO_WINDOW);
198 }
199 let mut child = process.spawn()?;
200 let mut stdout = child
201 .stdout
202 .take()
203 .ok_or_else(|| io::Error::other("gh stdout pipe was unavailable"))?;
204 let mut bytes = Vec::new();
205 let completion = async {
206 let ((), status) = tokio::try_join!(
207 async {
208 stdout.read_to_end(&mut bytes).await?;
209 Ok::<(), io::Error>(())
210 },
211 child.wait()
212 )?;
213 Ok::<_, io::Error>((status, bytes))
214 };
215 match tokio::time::timeout(timeout, completion).await {
216 Ok(result) => {
217 let (status, stdout) = result?;
218 Ok(GhAuthTokenOutput {
219 success: status.success(),
220 stdout,
221 })
222 }
223 Err(_) => {
224 let _ = child.kill().await;
228 let _ = child.wait().await;
229 Err(io::Error::new(
230 io::ErrorKind::TimedOut,
231 "gh auth token exceeded its deadline",
232 ))
233 }
234 }
235}
236
237pub fn resolve_with(
240 runner: &impl GhAuthTokenRunner,
241 gh_binary: Option<&std::path::Path>,
242 login: Option<&str>,
243) -> Result<String> {
244 let command = match login {
245 Some(login) => GhAuthTokenCommand::for_user(gh_binary, login)?,
246 None => GhAuthTokenCommand::standard(gh_binary),
247 };
248 let output = match runner.run(&command) {
249 Ok(output) => output,
250 Err(error) if error.kind() == io::ErrorKind::NotFound => {
251 return Err(login_error(
252 "GitHub CLI (`gh`) is not installed. Install it, then run",
253 ));
254 }
255 Err(_) => return Err(login_error("GitHub CLI could not be started. Run")),
256 };
257 token_from_output(output, login)
258}
259
260pub async fn resolve_async(
264 gh_binary: Option<&std::path::Path>,
265 login: Option<&str>,
266) -> Result<String> {
267 let command = match login {
268 Some(login) => GhAuthTokenCommand::for_user(gh_binary, login)?,
269 None => GhAuthTokenCommand::standard(gh_binary),
270 };
271 let output = match SystemGhAuthTokenRunner
272 .run_async(command, GH_AUTH_TOKEN_TIMEOUT)
273 .await
274 {
275 Ok(output) => output,
276 Err(error) if error.kind() == io::ErrorKind::NotFound => {
277 return Err(login_error(
278 "GitHub CLI (`gh`) is not installed. Install it, then run",
279 ));
280 }
281 Err(error) if error.kind() == io::ErrorKind::TimedOut => {
282 return Err(AppError::Other(
283 "GitHub Copilot: timed out while asking `gh` for the saved login token; try again."
284 .into(),
285 ));
286 }
287 Err(_) => return Err(login_error("GitHub CLI could not be started. Run")),
288 };
289 token_from_output(output, login)
290}
291
292fn token_from_output(output: GhAuthTokenOutput, login: Option<&str>) -> Result<String> {
293 if !output.success {
294 return match login {
295 Some(login) => Err(AppError::Credentials(format!(
296 "GitHub Copilot: `gh` has no token for {login:?}. Check the login name \
297 against `gh auth status`, then `gh auth login --web` for that account."
298 ))),
299 None => Err(login_error("GitHub CLI is not logged in. Run")),
300 };
301 }
302 String::from_utf8(output.stdout)
303 .ok()
304 .map(|value| value.trim().to_string())
305 .filter(|value| !value.is_empty())
306 .ok_or_else(|| login_error("GitHub CLI returned no OAuth token. Run"))
307}
308
309fn login_error(prefix: &str) -> AppError {
310 AppError::Credentials(format!(
311 "GitHub Copilot: {prefix} `gh auth login --web`, then select GitHub Copilot as the primary provider in Settings."
312 ))
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use std::cell::RefCell;
319
320 #[cfg(unix)]
321 fn executable_script(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
322 use std::os::unix::fs::PermissionsExt;
323 let dir = tempfile::tempdir().unwrap();
324 let path = dir.path().join("fake-gh");
325 std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
326 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
327 (dir, path)
328 }
329
330 #[cfg(unix)]
331 #[tokio::test(flavor = "current_thread")]
332 async fn a_slow_helper_does_not_stall_the_reactor_and_is_timed_out() {
333 let (_dir, helper) = executable_script("while :; do :; done");
337 let command = GhAuthTokenCommand::standard(Some(&helper));
338 let start = std::time::Instant::now();
339 let (timer_elapsed, result) = tokio::join!(
340 async {
341 tokio::time::sleep(Duration::from_millis(20)).await;
342 start.elapsed()
343 },
344 SystemGhAuthTokenRunner.run_async(command, Duration::from_millis(80))
345 );
346 assert!(
347 timer_elapsed < Duration::from_millis(60),
348 "timer took {timer_elapsed:?}"
349 );
350 assert_eq!(result.unwrap_err().kind(), io::ErrorKind::TimedOut);
351 assert!(start.elapsed() < Duration::from_secs(2));
352 }
353
354 #[cfg(unix)]
355 #[tokio::test]
356 async fn async_resolution_keeps_normal_and_named_account_argv() {
357 let dir = tempfile::tempdir().unwrap();
358 let helper = dir.path().join("fake-gh");
359 let args = dir.path().join("args");
360 use std::os::unix::fs::PermissionsExt;
361 std::fs::write(
362 &helper,
363 format!(
364 "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\nprintf 'async-token\\n'\n",
365 args.display()
366 ),
367 )
368 .unwrap();
369 std::fs::set_permissions(&helper, std::fs::Permissions::from_mode(0o700)).unwrap();
370
371 assert_eq!(
372 resolve_async(Some(&helper), Some("octocat-work"))
373 .await
374 .unwrap(),
375 "async-token"
376 );
377 assert_eq!(
378 std::fs::read_to_string(args).unwrap(),
379 "auth\ntoken\n--user\noctocat-work\n"
380 );
381 }
382
383 struct FakeRunner {
384 result: io::Result<GhAuthTokenOutput>,
385 command: RefCell<Option<GhAuthTokenCommand>>,
386 }
387
388 impl GhAuthTokenRunner for FakeRunner {
389 fn run(&self, command: &GhAuthTokenCommand) -> io::Result<GhAuthTokenOutput> {
390 *self.command.borrow_mut() = Some(command.clone());
391 self.result
392 .as_ref()
393 .map(Clone::clone)
394 .map_err(|error| io::Error::new(error.kind(), "fake gh failure"))
395 }
396 }
397
398 #[test]
399 fn runs_only_fixed_gh_auth_token_command_and_returns_trimmed_token() {
400 let runner = FakeRunner {
401 result: Ok(GhAuthTokenOutput {
402 success: true,
403 stdout: b"test-github-oauth-token\n".to_vec(),
404 }),
405 command: RefCell::new(None),
406 };
407
408 assert_eq!(
409 resolve_with(&runner, None, None).unwrap(),
410 "test-github-oauth-token"
411 );
412 let command = runner.command.into_inner().unwrap();
413 assert_eq!(command.program, std::path::Path::new("gh"));
414 assert_eq!(command.args, ["auth", "token"]);
415 assert!(command.env_remove.contains(&"ZAI_API_KEY"));
416 assert!(command.env_remove.contains(&"GITHUB_COPILOT_TOKEN"));
417 assert!(command.env_remove.contains(&"GH_TOKEN"));
418 assert!(command.env_remove.contains(&"GITHUB_TOKEN"));
419 }
420
421 #[test]
424 fn a_named_account_appends_only_user_to_the_fixed_argv() {
425 let runner = FakeRunner {
426 result: Ok(GhAuthTokenOutput {
427 success: true,
428 stdout: b"work-token\n".to_vec(),
429 }),
430 command: RefCell::new(None),
431 };
432 assert_eq!(
433 resolve_with(&runner, None, Some("octocat-work")).unwrap(),
434 "work-token"
435 );
436 let command = runner.command.into_inner().unwrap();
437 assert_eq!(command.args, ["auth", "token", "--user", "octocat-work"]);
438 assert!(command.env_remove.contains(&"GH_TOKEN"));
440 assert!(command.env_remove.contains(&"GITHUB_TOKEN"));
441 assert!(command.env_remove.contains(&"GITHUB_COPILOT_TOKEN"));
442 }
443
444 #[test]
449 fn a_login_outside_githubs_grammar_never_reaches_argv() {
450 for bad in [
451 "--hostname", "-u", "octocat --hostname x", "octo cat", "octo/cat", "octo;cat", "octo@cat", "", "-octocat", "octocat-", "octo--cat", "ã", ] {
464 let error = GhAuthTokenCommand::for_user(None, bad)
465 .expect_err(&format!("{bad:?} must be refused"))
466 .to_string();
467 assert!(
468 error.contains("not a valid GitHub login"),
469 "{bad:?}: {error}"
470 );
471 }
472 let command = GhAuthTokenCommand::for_user(None, "octo-cat-99").unwrap();
474 assert_eq!(command.args, ["auth", "token", "--user", "octo-cat-99"]);
475 assert!(GhAuthTokenCommand::for_user(None, &"a".repeat(39)).is_ok());
477 assert!(GhAuthTokenCommand::for_user(None, &"a".repeat(40)).is_err());
478 }
479
480 #[test]
484 fn a_signed_out_named_account_names_the_account_in_the_error() {
485 let runner = FakeRunner {
486 result: Ok(GhAuthTokenOutput {
487 success: false,
488 stdout: b"never-echo-gh-output".to_vec(),
489 }),
490 command: RefCell::new(None),
491 };
492 let error = resolve_with(&runner, None, Some("octocat"))
493 .unwrap_err()
494 .to_string();
495 assert!(error.contains("octocat"), "{error}");
496 assert!(error.contains("gh auth status"), "{error}");
497 assert!(!error.contains("never-echo-gh-output"), "{error}");
498 }
499
500 #[test]
508 fn hosts_path_follows_the_github_cli_precedence() {
509 use std::ffi::OsString;
510 use std::path::PathBuf;
511 let home = PathBuf::from("/home/u");
512 let only = |wanted: &'static str, value: &'static str| {
513 move |name: &str| (name == wanted).then(|| OsString::from(value))
514 };
515
516 assert_eq!(
517 hosts_path_with(only("GH_CONFIG_DIR", "/cfg/gh"), home.clone()).unwrap(),
518 PathBuf::from("/cfg/gh/hosts.yml"),
519 "GH_CONFIG_DIR is used verbatim, with no `gh` segment appended"
520 );
521 assert_eq!(
522 hosts_path_with(only("XDG_CONFIG_HOME", "/xdg"), home.clone()).unwrap(),
523 PathBuf::from("/xdg/gh/hosts.yml")
524 );
525 assert_eq!(
526 hosts_path_with(|_| None, home.clone()).unwrap(),
527 home.join(".config").join("gh").join("hosts.yml"),
528 "with nothing set, the home convention"
529 );
530
531 let both = |name: &str| match name {
533 "GH_CONFIG_DIR" => Some(OsString::from("/cfg/gh")),
534 "XDG_CONFIG_HOME" => Some(OsString::from("/xdg")),
535 _ => None,
536 };
537 assert_eq!(
538 hosts_path_with(both, home.clone()).unwrap(),
539 PathBuf::from("/cfg/gh/hosts.yml")
540 );
541
542 let appdata =
545 hosts_path_with(only("AppData", "C:/Users/u/AppData/Roaming"), home.clone()).unwrap();
546 if cfg!(windows) {
547 assert_eq!(
548 appdata,
549 PathBuf::from("C:/Users/u/AppData/Roaming/GitHub CLI/hosts.yml")
550 );
551 } else {
552 assert_eq!(appdata, home.join(".config").join("gh").join("hosts.yml"));
553 }
554 }
555
556 #[test]
557 fn a_configured_gh_binary_replaces_the_path_lookup() {
558 let pinned = std::path::Path::new("/opt/github/bin/gh");
559 assert_eq!(
560 GhAuthTokenCommand::standard(Some(pinned)).program,
561 pinned,
562 "a pinned binary must be used verbatim"
563 );
564 assert_eq!(
565 GhAuthTokenCommand::standard(None).program,
566 std::path::Path::new("gh"),
567 "unset still means the PATH lookup"
568 );
569 for command in [
571 GhAuthTokenCommand::standard(Some(pinned)),
572 GhAuthTokenCommand::standard(None),
573 ] {
574 assert_eq!(command.args, ["auth", "token"]);
575 assert!(command.env_remove.contains(&"GITHUB_TOKEN"));
576 }
577 }
578
579 #[test]
580 fn login_failure_never_echoes_gh_output() {
581 let runner = FakeRunner {
582 result: Ok(GhAuthTokenOutput {
583 success: false,
584 stdout: b"private-token-or-error".to_vec(),
585 }),
586 command: RefCell::new(None),
587 };
588
589 let error = resolve_with(&runner, None, None).unwrap_err().to_string();
590 assert!(error.contains("gh auth login --web"));
591 assert!(!error.contains("private-token-or-error"));
592 }
593}