use std::io;
use std::process::{Command, Stdio};
use std::time::Duration;
use crate::error::{AppError, Result};
use crate::vendor::vendor_secret_env_vars_to_remove;
pub fn default_hosts_path() -> Result<std::path::PathBuf> {
hosts_path_with(
|name| std::env::var_os(name).filter(|value| !value.is_empty()),
crate::cache::home_dir()?,
)
}
pub fn hosts_path_with(
environment: impl Fn(&str) -> Option<std::ffi::OsString>,
home: std::path::PathBuf,
) -> Result<std::path::PathBuf> {
if let Some(dir) = environment("GH_CONFIG_DIR") {
return Ok(std::path::PathBuf::from(dir).join("hosts.yml"));
}
if let Some(dir) = environment("XDG_CONFIG_HOME") {
return Ok(std::path::PathBuf::from(dir).join("gh").join("hosts.yml"));
}
if cfg!(windows)
&& let Some(dir) = environment("AppData")
{
return Ok(std::path::PathBuf::from(dir)
.join("GitHub CLI")
.join("hosts.yml"));
}
Ok(home.join(".config").join("gh").join("hosts.yml"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GhAuthTokenCommand {
pub program: std::path::PathBuf,
pub args: Vec<String>,
pub env_remove: Vec<&'static str>,
}
impl GhAuthTokenCommand {
pub fn standard(gh_binary: Option<&std::path::Path>) -> Self {
Self {
program: gh_binary.map_or_else(|| std::path::PathBuf::from("gh"), Into::into),
args: vec!["auth".to_string(), "token".to_string()],
env_remove: vendor_secret_env_vars_to_remove(&[]),
}
}
pub fn for_user(gh_binary: Option<&std::path::Path>, login: &str) -> Result<Self> {
validate_gh_login(login)?;
let mut command = Self::standard(gh_binary);
command.args.push("--user".to_string());
command.args.push(login.to_string());
Ok(command)
}
}
fn validate_gh_login(login: &str) -> Result<()> {
let bad = |why: &str| {
Err(AppError::Other(format!(
"GitHub Copilot: {login:?} is not a valid GitHub login ({why}). \
Use the account's login name, as shown by `gh auth status`."
)))
};
if login.is_empty() || login.len() > 39 {
return bad("1 to 39 characters");
}
if !login
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
{
return bad("letters, digits and hyphens only");
}
if login.starts_with('-') || login.ends_with('-') || login.contains("--") {
return bad("no leading, trailing or repeated hyphens");
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GhAuthTokenOutput {
pub success: bool,
pub stdout: Vec<u8>,
}
pub trait GhAuthTokenRunner {
fn run(&self, command: &GhAuthTokenCommand) -> io::Result<GhAuthTokenOutput>;
}
pub struct SystemGhAuthTokenRunner;
const GH_AUTH_TOKEN_TIMEOUT: Duration = Duration::from_secs(5);
impl GhAuthTokenRunner for SystemGhAuthTokenRunner {
fn run(&self, command: &GhAuthTokenCommand) -> io::Result<GhAuthTokenOutput> {
let mut process = Command::new(&command.program);
process
.args(&command.args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
for variable in &command.env_remove {
process.env_remove(variable);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
process.creation_flags(crate::process::CREATE_NO_WINDOW);
}
let output = process.output()?;
Ok(GhAuthTokenOutput {
success: output.status.success(),
stdout: output.stdout,
})
}
}
impl SystemGhAuthTokenRunner {
async fn run_async(
&self,
command: GhAuthTokenCommand,
timeout: Duration,
) -> io::Result<GhAuthTokenOutput> {
tokio::spawn(async move { run_system_async(command, timeout).await })
.await
.map_err(|error| io::Error::other(format!("gh credential task failed: {error}")))?
}
}
async fn run_system_async(
command: GhAuthTokenCommand,
timeout: Duration,
) -> io::Result<GhAuthTokenOutput> {
use tokio::io::AsyncReadExt;
let mut process = tokio::process::Command::new(&command.program);
process
.args(&command.args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true);
for variable in &command.env_remove {
process.env_remove(variable);
}
#[cfg(windows)]
{
process.creation_flags(crate::process::CREATE_NO_WINDOW);
}
let mut child = process.spawn()?;
let mut stdout = child
.stdout
.take()
.ok_or_else(|| io::Error::other("gh stdout pipe was unavailable"))?;
let mut bytes = Vec::new();
let completion = async {
let ((), status) = tokio::try_join!(
async {
stdout.read_to_end(&mut bytes).await?;
Ok::<(), io::Error>(())
},
child.wait()
)?;
Ok::<_, io::Error>((status, bytes))
};
match tokio::time::timeout(timeout, completion).await {
Ok(result) => {
let (status, stdout) = result?;
Ok(GhAuthTokenOutput {
success: status.success(),
stdout,
})
}
Err(_) => {
let _ = child.kill().await;
let _ = child.wait().await;
Err(io::Error::new(
io::ErrorKind::TimedOut,
"gh auth token exceeded its deadline",
))
}
}
}
pub fn resolve_with(
runner: &impl GhAuthTokenRunner,
gh_binary: Option<&std::path::Path>,
login: Option<&str>,
) -> Result<String> {
let command = match login {
Some(login) => GhAuthTokenCommand::for_user(gh_binary, login)?,
None => GhAuthTokenCommand::standard(gh_binary),
};
let output = match runner.run(&command) {
Ok(output) => output,
Err(error) if error.kind() == io::ErrorKind::NotFound => {
return Err(login_error(
"GitHub CLI (`gh`) is not installed. Install it, then run",
));
}
Err(_) => return Err(login_error("GitHub CLI could not be started. Run")),
};
token_from_output(output, login)
}
pub async fn resolve_async(
gh_binary: Option<&std::path::Path>,
login: Option<&str>,
) -> Result<String> {
let command = match login {
Some(login) => GhAuthTokenCommand::for_user(gh_binary, login)?,
None => GhAuthTokenCommand::standard(gh_binary),
};
let output = match SystemGhAuthTokenRunner
.run_async(command, GH_AUTH_TOKEN_TIMEOUT)
.await
{
Ok(output) => output,
Err(error) if error.kind() == io::ErrorKind::NotFound => {
return Err(login_error(
"GitHub CLI (`gh`) is not installed. Install it, then run",
));
}
Err(error) if error.kind() == io::ErrorKind::TimedOut => {
return Err(AppError::Other(
"GitHub Copilot: timed out while asking `gh` for the saved login token; try again."
.into(),
));
}
Err(_) => return Err(login_error("GitHub CLI could not be started. Run")),
};
token_from_output(output, login)
}
fn token_from_output(output: GhAuthTokenOutput, login: Option<&str>) -> Result<String> {
if !output.success {
return match login {
Some(login) => Err(AppError::Credentials(format!(
"GitHub Copilot: `gh` has no token for {login:?}. Check the login name \
against `gh auth status`, then `gh auth login --web` for that account."
))),
None => Err(login_error("GitHub CLI is not logged in. Run")),
};
}
String::from_utf8(output.stdout)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| login_error("GitHub CLI returned no OAuth token. Run"))
}
fn login_error(prefix: &str) -> AppError {
AppError::Credentials(format!(
"GitHub Copilot: {prefix} `gh auth login --web`, then select GitHub Copilot as the primary provider in Settings."
))
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
#[cfg(unix)]
fn executable_script(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("fake-gh");
std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
(dir, path)
}
#[cfg(unix)]
#[tokio::test(flavor = "current_thread")]
async fn a_slow_helper_does_not_stall_the_reactor_and_is_timed_out() {
let (_dir, helper) = executable_script("while :; do :; done");
let command = GhAuthTokenCommand::standard(Some(&helper));
let start = std::time::Instant::now();
let (timer_elapsed, result) = tokio::join!(
async {
tokio::time::sleep(Duration::from_millis(20)).await;
start.elapsed()
},
SystemGhAuthTokenRunner.run_async(command, Duration::from_millis(80))
);
assert!(
timer_elapsed < Duration::from_millis(60),
"timer took {timer_elapsed:?}"
);
assert_eq!(result.unwrap_err().kind(), io::ErrorKind::TimedOut);
assert!(start.elapsed() < Duration::from_secs(2));
}
#[cfg(unix)]
#[tokio::test]
async fn async_resolution_keeps_normal_and_named_account_argv() {
let dir = tempfile::tempdir().unwrap();
let helper = dir.path().join("fake-gh");
let args = dir.path().join("args");
use std::os::unix::fs::PermissionsExt;
std::fs::write(
&helper,
format!(
"#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\nprintf 'async-token\\n'\n",
args.display()
),
)
.unwrap();
std::fs::set_permissions(&helper, std::fs::Permissions::from_mode(0o700)).unwrap();
assert_eq!(
resolve_async(Some(&helper), Some("octocat-work"))
.await
.unwrap(),
"async-token"
);
assert_eq!(
std::fs::read_to_string(args).unwrap(),
"auth\ntoken\n--user\noctocat-work\n"
);
}
struct FakeRunner {
result: io::Result<GhAuthTokenOutput>,
command: RefCell<Option<GhAuthTokenCommand>>,
}
impl GhAuthTokenRunner for FakeRunner {
fn run(&self, command: &GhAuthTokenCommand) -> io::Result<GhAuthTokenOutput> {
*self.command.borrow_mut() = Some(command.clone());
self.result
.as_ref()
.map(Clone::clone)
.map_err(|error| io::Error::new(error.kind(), "fake gh failure"))
}
}
#[test]
fn runs_only_fixed_gh_auth_token_command_and_returns_trimmed_token() {
let runner = FakeRunner {
result: Ok(GhAuthTokenOutput {
success: true,
stdout: b"test-github-oauth-token\n".to_vec(),
}),
command: RefCell::new(None),
};
assert_eq!(
resolve_with(&runner, None, None).unwrap(),
"test-github-oauth-token"
);
let command = runner.command.into_inner().unwrap();
assert_eq!(command.program, std::path::Path::new("gh"));
assert_eq!(command.args, ["auth", "token"]);
assert!(command.env_remove.contains(&"ZAI_API_KEY"));
assert!(command.env_remove.contains(&"GITHUB_COPILOT_TOKEN"));
assert!(command.env_remove.contains(&"GH_TOKEN"));
assert!(command.env_remove.contains(&"GITHUB_TOKEN"));
}
#[test]
fn a_named_account_appends_only_user_to_the_fixed_argv() {
let runner = FakeRunner {
result: Ok(GhAuthTokenOutput {
success: true,
stdout: b"work-token\n".to_vec(),
}),
command: RefCell::new(None),
};
assert_eq!(
resolve_with(&runner, None, Some("octocat-work")).unwrap(),
"work-token"
);
let command = runner.command.into_inner().unwrap();
assert_eq!(command.args, ["auth", "token", "--user", "octocat-work"]);
assert!(command.env_remove.contains(&"GH_TOKEN"));
assert!(command.env_remove.contains(&"GITHUB_TOKEN"));
assert!(command.env_remove.contains(&"GITHUB_COPILOT_TOKEN"));
}
#[test]
fn a_login_outside_githubs_grammar_never_reaches_argv() {
for bad in [
"--hostname", "-u", "octocat --hostname x", "octo cat", "octo/cat", "octo;cat", "octo@cat", "", "-octocat", "octocat-", "octo--cat", "ã", ] {
let error = GhAuthTokenCommand::for_user(None, bad)
.expect_err(&format!("{bad:?} must be refused"))
.to_string();
assert!(
error.contains("not a valid GitHub login"),
"{bad:?}: {error}"
);
}
let command = GhAuthTokenCommand::for_user(None, "octo-cat-99").unwrap();
assert_eq!(command.args, ["auth", "token", "--user", "octo-cat-99"]);
assert!(GhAuthTokenCommand::for_user(None, &"a".repeat(39)).is_ok());
assert!(GhAuthTokenCommand::for_user(None, &"a".repeat(40)).is_err());
}
#[test]
fn a_signed_out_named_account_names_the_account_in_the_error() {
let runner = FakeRunner {
result: Ok(GhAuthTokenOutput {
success: false,
stdout: b"never-echo-gh-output".to_vec(),
}),
command: RefCell::new(None),
};
let error = resolve_with(&runner, None, Some("octocat"))
.unwrap_err()
.to_string();
assert!(error.contains("octocat"), "{error}");
assert!(error.contains("gh auth status"), "{error}");
assert!(!error.contains("never-echo-gh-output"), "{error}");
}
#[test]
fn hosts_path_follows_the_github_cli_precedence() {
use std::ffi::OsString;
use std::path::PathBuf;
let home = PathBuf::from("/home/u");
let only = |wanted: &'static str, value: &'static str| {
move |name: &str| (name == wanted).then(|| OsString::from(value))
};
assert_eq!(
hosts_path_with(only("GH_CONFIG_DIR", "/cfg/gh"), home.clone()).unwrap(),
PathBuf::from("/cfg/gh/hosts.yml"),
"GH_CONFIG_DIR is used verbatim, with no `gh` segment appended"
);
assert_eq!(
hosts_path_with(only("XDG_CONFIG_HOME", "/xdg"), home.clone()).unwrap(),
PathBuf::from("/xdg/gh/hosts.yml")
);
assert_eq!(
hosts_path_with(|_| None, home.clone()).unwrap(),
home.join(".config").join("gh").join("hosts.yml"),
"with nothing set, the home convention"
);
let both = |name: &str| match name {
"GH_CONFIG_DIR" => Some(OsString::from("/cfg/gh")),
"XDG_CONFIG_HOME" => Some(OsString::from("/xdg")),
_ => None,
};
assert_eq!(
hosts_path_with(both, home.clone()).unwrap(),
PathBuf::from("/cfg/gh/hosts.yml")
);
let appdata =
hosts_path_with(only("AppData", "C:/Users/u/AppData/Roaming"), home.clone()).unwrap();
if cfg!(windows) {
assert_eq!(
appdata,
PathBuf::from("C:/Users/u/AppData/Roaming/GitHub CLI/hosts.yml")
);
} else {
assert_eq!(appdata, home.join(".config").join("gh").join("hosts.yml"));
}
}
#[test]
fn a_configured_gh_binary_replaces_the_path_lookup() {
let pinned = std::path::Path::new("/opt/github/bin/gh");
assert_eq!(
GhAuthTokenCommand::standard(Some(pinned)).program,
pinned,
"a pinned binary must be used verbatim"
);
assert_eq!(
GhAuthTokenCommand::standard(None).program,
std::path::Path::new("gh"),
"unset still means the PATH lookup"
);
for command in [
GhAuthTokenCommand::standard(Some(pinned)),
GhAuthTokenCommand::standard(None),
] {
assert_eq!(command.args, ["auth", "token"]);
assert!(command.env_remove.contains(&"GITHUB_TOKEN"));
}
}
#[test]
fn login_failure_never_echoes_gh_output() {
let runner = FakeRunner {
result: Ok(GhAuthTokenOutput {
success: false,
stdout: b"private-token-or-error".to_vec(),
}),
command: RefCell::new(None),
};
let error = resolve_with(&runner, None, None).unwrap_err().to_string();
assert!(error.contains("gh auth login --web"));
assert!(!error.contains("private-token-or-error"));
}
}