1use std::io;
5use std::process::{Command, Stdio};
6
7use crate::error::{AppError, Result};
8use crate::vendor::vendor_secret_env_vars_to_remove;
9
10pub fn default_hosts_path() -> Result<std::path::PathBuf> {
20 hosts_path_with(
21 |name| std::env::var_os(name).filter(|value| !value.is_empty()),
22 crate::cache::home_dir()?,
23 )
24}
25
26pub fn hosts_path_with(
29 environment: impl Fn(&str) -> Option<std::ffi::OsString>,
30 home: std::path::PathBuf,
31) -> Result<std::path::PathBuf> {
32 if let Some(dir) = environment("GH_CONFIG_DIR") {
33 return Ok(std::path::PathBuf::from(dir).join("hosts.yml"));
34 }
35 if let Some(dir) = environment("XDG_CONFIG_HOME") {
36 return Ok(std::path::PathBuf::from(dir).join("gh").join("hosts.yml"));
37 }
38 if cfg!(windows)
39 && let Some(dir) = environment("AppData")
40 {
41 return Ok(std::path::PathBuf::from(dir)
42 .join("GitHub CLI")
43 .join("hosts.yml"));
44 }
45 Ok(home.join(".config").join("gh").join("hosts.yml"))
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct GhAuthTokenCommand {
53 pub program: std::path::PathBuf,
54 pub args: Vec<String>,
59 pub env_remove: Vec<&'static str>,
60}
61
62impl GhAuthTokenCommand {
63 pub fn standard(gh_binary: Option<&std::path::Path>) -> Self {
68 Self {
69 program: gh_binary.map_or_else(|| std::path::PathBuf::from("gh"), Into::into),
70 args: vec!["auth".to_string(), "token".to_string()],
71 env_remove: vendor_secret_env_vars_to_remove(&[]),
74 }
75 }
76
77 pub fn for_user(gh_binary: Option<&std::path::Path>, login: &str) -> Result<Self> {
86 validate_gh_login(login)?;
87 let mut command = Self::standard(gh_binary);
88 command.args.push("--user".to_string());
89 command.args.push(login.to_string());
90 Ok(command)
91 }
92}
93
94fn validate_gh_login(login: &str) -> Result<()> {
99 let bad = |why: &str| {
100 Err(AppError::Other(format!(
101 "GitHub Copilot: {login:?} is not a valid GitHub login ({why}). \
102 Use the account's login name, as shown by `gh auth status`."
103 )))
104 };
105 if login.is_empty() || login.len() > 39 {
106 return bad("1 to 39 characters");
107 }
108 if !login
109 .bytes()
110 .all(|b| b.is_ascii_alphanumeric() || b == b'-')
111 {
112 return bad("letters, digits and hyphens only");
113 }
114 if login.starts_with('-') || login.ends_with('-') || login.contains("--") {
115 return bad("no leading, trailing or repeated hyphens");
116 }
117 Ok(())
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct GhAuthTokenOutput {
122 pub success: bool,
123 pub stdout: Vec<u8>,
124}
125
126pub trait GhAuthTokenRunner {
129 fn run(&self, command: &GhAuthTokenCommand) -> io::Result<GhAuthTokenOutput>;
130}
131
132pub struct SystemGhAuthTokenRunner;
133
134impl GhAuthTokenRunner for SystemGhAuthTokenRunner {
135 fn run(&self, command: &GhAuthTokenCommand) -> io::Result<GhAuthTokenOutput> {
136 let mut process = Command::new(&command.program);
137 process
138 .args(&command.args)
139 .stdin(Stdio::null())
140 .stdout(Stdio::piped())
141 .stderr(Stdio::null());
142 for variable in &command.env_remove {
143 process.env_remove(variable);
144 }
145 #[cfg(windows)]
148 {
149 use std::os::windows::process::CommandExt;
150 process.creation_flags(crate::process::CREATE_NO_WINDOW);
151 }
152 let output = process.output()?;
153 Ok(GhAuthTokenOutput {
154 success: output.status.success(),
155 stdout: output.stdout,
156 })
157 }
158}
159
160pub fn resolve_with(
163 runner: &impl GhAuthTokenRunner,
164 gh_binary: Option<&std::path::Path>,
165 login: Option<&str>,
166) -> Result<String> {
167 let command = match login {
168 Some(login) => GhAuthTokenCommand::for_user(gh_binary, login)?,
169 None => GhAuthTokenCommand::standard(gh_binary),
170 };
171 let output = match runner.run(&command) {
172 Ok(output) => output,
173 Err(error) if error.kind() == io::ErrorKind::NotFound => {
174 return Err(login_error(
175 "GitHub CLI (`gh`) is not installed. Install it, then run",
176 ));
177 }
178 Err(_) => return Err(login_error("GitHub CLI could not be started. Run")),
179 };
180 if !output.success {
181 return match login {
182 Some(login) => Err(AppError::Credentials(format!(
183 "GitHub Copilot: `gh` has no token for {login:?}. Check the login name \
184 against `gh auth status`, then `gh auth login --web` for that account."
185 ))),
186 None => Err(login_error("GitHub CLI is not logged in. Run")),
187 };
188 }
189 let token = String::from_utf8(output.stdout)
190 .ok()
191 .map(|value| value.trim().to_string())
192 .filter(|value| !value.is_empty())
193 .ok_or_else(|| login_error("GitHub CLI returned no OAuth token. Run"))?;
194 Ok(token)
195}
196
197fn login_error(prefix: &str) -> AppError {
198 AppError::Credentials(format!(
199 "GitHub Copilot: {prefix} `gh auth login --web`, then select GitHub Copilot as the primary provider in Settings."
200 ))
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206 use std::cell::RefCell;
207
208 struct FakeRunner {
209 result: io::Result<GhAuthTokenOutput>,
210 command: RefCell<Option<GhAuthTokenCommand>>,
211 }
212
213 impl GhAuthTokenRunner for FakeRunner {
214 fn run(&self, command: &GhAuthTokenCommand) -> io::Result<GhAuthTokenOutput> {
215 *self.command.borrow_mut() = Some(command.clone());
216 self.result
217 .as_ref()
218 .map(Clone::clone)
219 .map_err(|error| io::Error::new(error.kind(), "fake gh failure"))
220 }
221 }
222
223 #[test]
224 fn runs_only_fixed_gh_auth_token_command_and_returns_trimmed_token() {
225 let runner = FakeRunner {
226 result: Ok(GhAuthTokenOutput {
227 success: true,
228 stdout: b"test-github-oauth-token\n".to_vec(),
229 }),
230 command: RefCell::new(None),
231 };
232
233 assert_eq!(
234 resolve_with(&runner, None, None).unwrap(),
235 "test-github-oauth-token"
236 );
237 let command = runner.command.into_inner().unwrap();
238 assert_eq!(command.program, std::path::Path::new("gh"));
239 assert_eq!(command.args, ["auth", "token"]);
240 assert!(command.env_remove.contains(&"ZAI_API_KEY"));
241 assert!(command.env_remove.contains(&"GITHUB_COPILOT_TOKEN"));
242 assert!(command.env_remove.contains(&"GH_TOKEN"));
243 assert!(command.env_remove.contains(&"GITHUB_TOKEN"));
244 }
245
246 #[test]
249 fn a_named_account_appends_only_user_to_the_fixed_argv() {
250 let runner = FakeRunner {
251 result: Ok(GhAuthTokenOutput {
252 success: true,
253 stdout: b"work-token\n".to_vec(),
254 }),
255 command: RefCell::new(None),
256 };
257 assert_eq!(
258 resolve_with(&runner, None, Some("octocat-work")).unwrap(),
259 "work-token"
260 );
261 let command = runner.command.into_inner().unwrap();
262 assert_eq!(command.args, ["auth", "token", "--user", "octocat-work"]);
263 assert!(command.env_remove.contains(&"GH_TOKEN"));
265 assert!(command.env_remove.contains(&"GITHUB_TOKEN"));
266 assert!(command.env_remove.contains(&"GITHUB_COPILOT_TOKEN"));
267 }
268
269 #[test]
274 fn a_login_outside_githubs_grammar_never_reaches_argv() {
275 for bad in [
276 "--hostname", "-u", "octocat --hostname x", "octo cat", "octo/cat", "octo;cat", "octo@cat", "", "-octocat", "octocat-", "octo--cat", "ã", ] {
289 let error = GhAuthTokenCommand::for_user(None, bad)
290 .expect_err(&format!("{bad:?} must be refused"))
291 .to_string();
292 assert!(
293 error.contains("not a valid GitHub login"),
294 "{bad:?}: {error}"
295 );
296 }
297 let command = GhAuthTokenCommand::for_user(None, "octo-cat-99").unwrap();
299 assert_eq!(command.args, ["auth", "token", "--user", "octo-cat-99"]);
300 assert!(GhAuthTokenCommand::for_user(None, &"a".repeat(39)).is_ok());
302 assert!(GhAuthTokenCommand::for_user(None, &"a".repeat(40)).is_err());
303 }
304
305 #[test]
309 fn a_signed_out_named_account_names_the_account_in_the_error() {
310 let runner = FakeRunner {
311 result: Ok(GhAuthTokenOutput {
312 success: false,
313 stdout: b"never-echo-gh-output".to_vec(),
314 }),
315 command: RefCell::new(None),
316 };
317 let error = resolve_with(&runner, None, Some("octocat"))
318 .unwrap_err()
319 .to_string();
320 assert!(error.contains("octocat"), "{error}");
321 assert!(error.contains("gh auth status"), "{error}");
322 assert!(!error.contains("never-echo-gh-output"), "{error}");
323 }
324
325 #[test]
333 fn hosts_path_follows_the_github_cli_precedence() {
334 use std::ffi::OsString;
335 use std::path::PathBuf;
336 let home = PathBuf::from("/home/u");
337 let only = |wanted: &'static str, value: &'static str| {
338 move |name: &str| (name == wanted).then(|| OsString::from(value))
339 };
340
341 assert_eq!(
342 hosts_path_with(only("GH_CONFIG_DIR", "/cfg/gh"), home.clone()).unwrap(),
343 PathBuf::from("/cfg/gh/hosts.yml"),
344 "GH_CONFIG_DIR is used verbatim, with no `gh` segment appended"
345 );
346 assert_eq!(
347 hosts_path_with(only("XDG_CONFIG_HOME", "/xdg"), home.clone()).unwrap(),
348 PathBuf::from("/xdg/gh/hosts.yml")
349 );
350 assert_eq!(
351 hosts_path_with(|_| None, home.clone()).unwrap(),
352 home.join(".config").join("gh").join("hosts.yml"),
353 "with nothing set, the home convention"
354 );
355
356 let both = |name: &str| match name {
358 "GH_CONFIG_DIR" => Some(OsString::from("/cfg/gh")),
359 "XDG_CONFIG_HOME" => Some(OsString::from("/xdg")),
360 _ => None,
361 };
362 assert_eq!(
363 hosts_path_with(both, home.clone()).unwrap(),
364 PathBuf::from("/cfg/gh/hosts.yml")
365 );
366
367 let appdata =
370 hosts_path_with(only("AppData", "C:/Users/u/AppData/Roaming"), home.clone()).unwrap();
371 if cfg!(windows) {
372 assert_eq!(
373 appdata,
374 PathBuf::from("C:/Users/u/AppData/Roaming/GitHub CLI/hosts.yml")
375 );
376 } else {
377 assert_eq!(appdata, home.join(".config").join("gh").join("hosts.yml"));
378 }
379 }
380
381 #[test]
382 fn a_configured_gh_binary_replaces_the_path_lookup() {
383 let pinned = std::path::Path::new("/opt/github/bin/gh");
384 assert_eq!(
385 GhAuthTokenCommand::standard(Some(pinned)).program,
386 pinned,
387 "a pinned binary must be used verbatim"
388 );
389 assert_eq!(
390 GhAuthTokenCommand::standard(None).program,
391 std::path::Path::new("gh"),
392 "unset still means the PATH lookup"
393 );
394 for command in [
396 GhAuthTokenCommand::standard(Some(pinned)),
397 GhAuthTokenCommand::standard(None),
398 ] {
399 assert_eq!(command.args, ["auth", "token"]);
400 assert!(command.env_remove.contains(&"GITHUB_TOKEN"));
401 }
402 }
403
404 #[test]
405 fn login_failure_never_echoes_gh_output() {
406 let runner = FakeRunner {
407 result: Ok(GhAuthTokenOutput {
408 success: false,
409 stdout: b"private-token-or-error".to_vec(),
410 }),
411 command: RefCell::new(None),
412 };
413
414 let error = resolve_with(&runner, None, None).unwrap_err().to_string();
415 assert!(error.contains("gh auth login --web"));
416 assert!(!error.contains("private-token-or-error"));
417 }
418}