use std::fmt;
use std::io::{Read as _, Write as _};
use std::process::{Command, Stdio};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
const HELPER_TIMEOUT: Duration = Duration::from_secs(3);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Source {
Explicit,
Environment,
GhCli,
GitCredential,
}
impl Source {
pub const fn as_str(self) -> &'static str {
match self {
Source::Explicit => "an explicitly supplied token",
Source::Environment => "$GITHUB_TOKEN",
Source::GhCli => "gh auth token",
Source::GitCredential => "git credential fill",
}
}
pub const fn is_discovered(self) -> bool {
matches!(self, Source::GhCli | Source::GitCredential)
}
}
impl fmt::Display for Source {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct Token {
value: String,
source: Source,
}
impl Token {
pub fn new(value: impl Into<String>, source: Source) -> Self {
Self {
value: value.into(),
source,
}
}
pub fn explicit(value: impl Into<String>) -> Option<Self> {
Self::clean(value.into(), Source::Explicit)
}
pub fn from_env() -> Option<Self> {
for name in ["GITHUB_TOKEN", "GH_TOKEN"] {
if let Ok(value) = std::env::var(name)
&& let Some(token) = Self::clean(value, Source::Environment)
{
return Some(token);
}
}
None
}
pub fn from_gh_cli() -> Option<Self> {
let mut command = Command::new("gh");
command.arg("auth").arg("token");
command.env("GH_PROMPT_DISABLED", "1");
run_command(&mut command, None, HELPER_TIMEOUT)
.and_then(|value| Self::clean(value, Source::GhCli))
}
pub fn from_git_credential() -> Option<Self> {
let mut command = Command::new("git");
command.arg("credential").arg("fill");
command.env("GIT_TERMINAL_PROMPT", "0");
let request = "protocol=https\nhost=github.com\n\n";
run_command(&mut command, Some(request), HELPER_TIMEOUT)
.and_then(|output| parse_credential_output(&output))
.and_then(|value| Self::clean(value, Source::GitCredential))
}
pub fn detect() -> Option<&'static Token> {
static CACHE: OnceLock<Option<Token>> = OnceLock::new();
CACHE
.get_or_init(|| {
Self::from_env()
.or_else(Self::from_gh_cli)
.or_else(Self::from_git_credential)
})
.as_ref()
}
pub fn value(&self) -> &str {
&self.value
}
pub fn source(&self) -> Source {
self.source
}
pub fn may_send_to(&self, api_base: &str) -> bool {
!self.source.is_discovered()
|| host_of(api_base)
.is_some_and(|host| host == "github.com" || host.ends_with(".github.com"))
}
fn clean(value: String, source: Source) -> Option<Self> {
let value = value.trim();
(!value.is_empty()).then(|| Self::new(value, source))
}
}
impl fmt::Debug for Token {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Token")
.field("source", &self.source)
.field("value", &"<redacted>")
.finish()
}
}
fn parse_credential_output(output: &str) -> Option<String> {
output.lines().find_map(|line| {
let (key, value) = line.split_once('=')?;
(key.trim() == "password" && !value.trim().is_empty()).then(|| value.trim().to_string())
})
}
fn host_of(url: &str) -> Option<&str> {
let rest = url.split_once("://")?.1;
let authority = rest.split(['/', '?', '#']).next()?;
let host = authority.rsplit('@').next()?;
Some(host.split(':').next().unwrap_or(host))
}
fn run_command(command: &mut Command, input: Option<&str>, timeout: Duration) -> Option<String> {
command
.stdin(if input.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::null());
let mut child = command.spawn().ok()?;
if let Some(input) = input {
let mut stdin = child.stdin.take()?;
stdin.write_all(input.as_bytes()).ok()?;
drop(stdin);
}
let deadline = Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) => {
if !status.success() {
return None;
}
let mut stdout = child.stdout.take()?;
let mut output = String::new();
stdout.read_to_string(&mut output).ok()?;
let output = output.trim();
return (!output.is_empty()).then(|| output.to_string());
}
Ok(None) if Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(20));
}
_ => {
let _ = child.kill();
let _ = child.wait();
return None;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_git_credential_output() {
let output = "protocol=https\nhost=github.com\nusername=octocat\npassword=gho_secret\n\n";
assert_eq!(
parse_credential_output(output).as_deref(),
Some("gho_secret")
);
let output = "protocol=https\r\nhost=github.com\r\npassword=gho_secret\r\n";
assert_eq!(
parse_credential_output(output).as_deref(),
Some("gho_secret")
);
}
#[test]
fn ignores_output_without_a_password() {
assert_eq!(parse_credential_output(""), None);
assert_eq!(
parse_credential_output("protocol=https\nhost=github.com\n"),
None
);
assert_eq!(parse_credential_output("password=\n"), None);
assert_eq!(parse_credential_output("password_hash=abc\n"), None);
}
#[test]
fn rejects_blank_values() {
assert!(Token::explicit("").is_none());
assert!(Token::explicit(" ").is_none());
assert!(Token::explicit(" token ").is_some());
assert_eq!(Token::explicit(" token ").unwrap().value(), "token");
}
#[test]
fn parses_url_hosts() {
assert_eq!(
host_of("https://api.github.com/repos/x/y"),
Some("api.github.com")
);
assert_eq!(host_of("https://github.com"), Some("github.com"));
assert_eq!(
host_of("https://user:pw@github.com:443/x"),
Some("github.com")
);
assert_eq!(
host_of("http://ghe.corp.example/api/v3"),
Some("ghe.corp.example")
);
assert_eq!(host_of("not a url"), None);
}
#[test]
fn discovered_tokens_stay_on_github() {
let discovered = Token::new("t", Source::GhCli);
assert!(discovered.may_send_to("https://api.github.com"));
assert!(discovered.may_send_to("https://github.com"));
assert!(!discovered.may_send_to("https://ghe.corp.example/api/v3"));
assert!(!discovered.may_send_to("https://github.com.evil.example"));
assert!(!discovered.may_send_to("https://notgithub.com"));
}
#[test]
fn supplied_tokens_go_wherever_the_caller_points() {
for source in [Source::Explicit, Source::Environment] {
let token = Token::new("t", source);
assert!(token.may_send_to("https://ghe.corp.example/api/v3"));
assert!(token.may_send_to("https://api.github.com"));
}
}
#[test]
fn debug_redacts_the_value() {
let token = Token::new("gho_supersecret", Source::Explicit);
let rendered = format!("{token:?}");
assert!(!rendered.contains("gho_supersecret"), "{rendered}");
assert!(rendered.contains("<redacted>"), "{rendered}");
assert!(rendered.contains("Explicit"), "{rendered}");
}
}