use std::path::PathBuf;
use anyhow::Context;
use crate::core::trusty_tools_config::GithubConfig;
use super::{GH_ENFORCE_TIMEOUT, extract_login_token, run_bounded};
pub const GH_CONFIG_DIR_ENV: &str = "GH_CONFIG_DIR";
const GH_TOKEN_ENV_VARS: [&str; 2] = ["GH_TOKEN", "GITHUB_TOKEN"];
const GH_HOST_ENV: &str = "GH_HOST";
pub fn ensure_gh_account_in_dir(
expected_user: &str,
config_dir: &std::path::Path,
host: &str,
) -> anyhow::Result<()> {
let dir = config_dir.to_string_lossy().to_string();
if let Some(name) = ambient_token_override() {
anyhow::bail!(
"{name} is set in this environment and outranks {GH_CONFIG_DIR_ENV} in gh's \
credential resolution, so `gh` operations would run as whatever account that \
token belongs to — not necessarily '{expected_user}' — whatever {dir} contains. \
Unset {name} (or drop this project's `github.account` pin) and retry."
);
}
let status_text = run_gh_scoped(&dir, host, ["auth", "status"])
.context("failed to run `gh auth status` scoped to the project's GH_CONFIG_DIR")?;
let probe = api_login_scoped(&dir, host);
match verify_active_account(
&status_text,
probe.as_deref().map_err(String::as_str),
expected_user,
) {
Ok(()) => {
warn_on_identity_divergence(&status_text, &probe);
return Ok(());
}
Err(msg) if probe.is_err() => {
anyhow::bail!("refusing to switch accounts inside {dir}: {msg}")
}
Err(_) => {}
}
switch_account_scoped(&dir, host, expected_user)?;
let recheck = run_gh_scoped(&dir, host, ["auth", "status"])
.context("failed to re-verify `gh auth status` after switching")?;
let recheck_probe = api_login_scoped(&dir, host);
let verdict = verify_active_account(
&recheck,
recheck_probe.as_deref().map_err(String::as_str),
expected_user,
);
if verdict.is_ok() {
warn_on_identity_divergence(&recheck, &recheck_probe);
}
verdict.map_err(|msg| {
anyhow::anyhow!(
"gh account switch to '{expected_user}' did not take effect inside {dir}: {msg}"
)
})
}
pub(crate) fn api_login_scoped(config_dir: &str, host: &str) -> Result<String, String> {
#[cfg(any(test, debug_assertions))]
if let Some(stub) = crate::core::gh_scoped_stub::installed() {
return stub.api_login();
}
let dir = config_dir.to_string();
let host = host.to_string();
run_bounded(GH_ENFORCE_TIMEOUT, move || {
Some(
scoped_gh(["api", "user", "--jq", ".login"], &dir, &host)
.nonempty_stdout_blocking()
.map_err(|e| format!("`gh api user --jq .login` failed: {e}")),
)
})
.unwrap_or_else(|| {
Err(format!(
"`gh api user --jq .login` did not respond within {GH_ENFORCE_TIMEOUT:?}"
))
})
}
fn warn_on_identity_divergence(status_text: &str, probe: &Result<String, String>) {
let Ok(api_login) = probe else { return };
if let Some(claimed) = identity_divergence(status_text, api_login) {
tracing::warn!(
claimed_by_config_dir = %claimed,
authenticated_as = %api_login,
"gh auth status and gh api user name different accounts; \
the credential did not come from this GH_CONFIG_DIR (see #5656)"
);
}
}
fn identity_divergence(status_text: &str, api_login: &str) -> Option<String> {
parse_active_account_from_status(status_text).filter(|claimed| claimed != api_login)
}
fn switch_account_scoped(dir: &str, host: &str, expected_user: &str) -> anyhow::Result<()> {
#[cfg(any(test, debug_assertions))]
if let Some(stub) = crate::core::gh_scoped_stub::installed() {
return stub.switch(expected_user).map_err(|stderr| {
anyhow::anyhow!(
"`gh auth switch --user {expected_user}` failed inside {dir}: {stderr} \
(is '{expected_user}' logged in under this GH_CONFIG_DIR? run \
`GH_CONFIG_DIR={dir} gh auth login` first)"
)
});
}
let switch = scoped_gh(
[
"auth",
"switch",
"--hostname",
host,
"--user",
expected_user,
],
dir,
host,
)
.output_blocking()
.with_context(|| format!("failed to spawn `gh auth switch --user {expected_user}`"))?;
if !switch.success {
let stderr = &switch.stderr;
anyhow::bail!(
"`gh auth switch --user {expected_user}` failed inside {dir}: {} \
(is '{expected_user}' logged in under this GH_CONFIG_DIR? run \
`GH_CONFIG_DIR={dir} gh auth login` first)",
stderr.trim()
);
}
Ok(())
}
fn run_gh_scoped(config_dir: &str, host: &str, args: [&'static str; 2]) -> anyhow::Result<String> {
#[cfg(any(test, debug_assertions))]
if let Some(stub) = crate::core::gh_scoped_stub::installed() {
debug_assert_eq!(args, ["auth", "status"], "the stub answers `auth status`");
return Ok(stub.auth_status());
}
let config_dir = config_dir.to_string();
let host = host.to_string();
run_bounded(GH_ENFORCE_TIMEOUT, move || {
Some(
scoped_gh(args, &config_dir, &host)
.output_blocking()
.map(|out| out.combined())
.map_err(|e| format!("failed to spawn `gh {}`: {e}", args.join(" "))),
)
})
.unwrap_or_else(|| {
Err(format!(
"`gh {}` did not respond within {GH_ENFORCE_TIMEOUT:?}",
args.join(" ")
))
})
.map_err(|msg| anyhow::anyhow!(msg))
}
fn scoped_gh<const N: usize>(
args: [&str; N],
config_dir: &str,
host: &str,
) -> trusty_common::gh::GhCommand {
trusty_common::gh::GhCommand::new(args)
.env(GH_CONFIG_DIR_ENV, config_dir)
.env(GH_HOST_ENV, host)
.env_remove(GH_TOKEN_ENV_VARS[0])
.env_remove(GH_TOKEN_ENV_VARS[1])
}
fn ambient_token_override() -> Option<&'static str> {
GH_TOKEN_ENV_VARS
.into_iter()
.find(|name| std::env::var_os(name).is_some_and(|v| !v.is_empty()))
}
pub(crate) fn parse_active_account_from_status(auth_status: &str) -> Option<String> {
let mut current: Option<String> = None;
for line in auth_status.lines() {
if let Some((_, rest)) = line.split_once("Logged in to") {
current = extract_login_token(rest);
continue;
}
if let Some((_, value)) = line.split_once("Active account:")
&& value.trim().eq_ignore_ascii_case("true")
{
return current.clone();
}
}
None
}
pub(crate) fn verify_active_account(
status_text: &str,
api_login: Result<&str, &str>,
expected: &str,
) -> Result<(), String> {
let lower = status_text.to_lowercase();
if lower.contains("environment variable")
&& (lower.contains("gh_token") || lower.contains("github_token"))
{
return Err(format!(
"gh is authenticating via a GH_TOKEN/GITHUB_TOKEN environment variable override, \
so the active account inside this GH_CONFIG_DIR cannot be verified as '{expected}'"
));
}
let active = api_login.map_err(|why| {
format!(
"could not establish which account gh authenticates as \
(expected '{expected}'): {why}"
)
})?;
if active == expected {
return Ok(());
}
let claimed = identity_divergence(status_text, active)
.map(|c| format!(" (`gh auth status` claims '{c}' is active — see #5656)"))
.unwrap_or_default();
Err(format!(
"gh authenticates as '{active}', expected '{expected}'{claimed}"
))
}
pub fn ensure_gh_account_for_project(gh_user: &str) -> anyhow::Result<()> {
let gh_user = gh_user.trim();
anyhow::ensure!(!gh_user.is_empty(), "gh_user must not be empty");
let config_dir = std::env::var_os(GH_CONFIG_DIR_ENV)
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
.ok_or_else(|| {
anyhow::anyhow!(
"no isolated {GH_CONFIG_DIR_ENV} is set in this environment; refusing to run \
`gh auth switch` against the shared ~/.config/gh store (that would leak \
identity across concurrently-running projects). Export {GH_CONFIG_DIR_ENV} \
to a per-project gh config directory (see `github.config_dir` in \
TrustyToolsConfig, or the equivalent per-directory shell convention) before \
retrying."
)
})?;
ensure_gh_account_in_dir(
gh_user,
&config_dir,
crate::core::trusty_tools_config::DEFAULT_GITHUB_HOST,
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct AccountTarget {
pub config_dir: PathBuf,
pub account: String,
pub host: String,
}
pub fn configured_account_pair(config: Option<&GithubConfig>) -> Option<AccountTarget> {
let cfg = config?;
let config_dir = cfg.config_dir.clone()?;
let account = cfg
.account
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())?;
let host = cfg
.host
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(crate::core::trusty_tools_config::DEFAULT_GITHUB_HOST);
Some(AccountTarget {
config_dir,
account: account.to_string(),
host: host.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_HOST: &str = crate::core::trusty_tools_config::DEFAULT_GITHUB_HOST;
const MULTI_AUTH_STATUS: &str = "\
github.com
✓ Logged in to github.com account bob-duetto (keyring)
- Active account: true
- Git operations protocol: https
- Token: gho_************************************
- Token scopes: 'admin:org', 'gist', 'project', 'repo', 'workflow'
✓ Logged in to github.com account bobmatnyc (keyring)
- Active account: false
- Git operations protocol: https
- Token: gho_************************************
- Token scopes: 'gist', 'read:org', 'repo', 'workflow'
";
const SINGLE_AUTH_STATUS: &str = "\
github.com
✓ Logged in to github.com account bobmatnyc (keyring)
- Active account: true
- Git operations protocol: https
- Token: gho_************************************
";
#[test]
fn parse_active_account_from_status_multi() {
assert_eq!(
parse_active_account_from_status(MULTI_AUTH_STATUS).as_deref(),
Some("bob-duetto")
);
}
#[test]
fn parse_active_account_from_status_single() {
assert_eq!(
parse_active_account_from_status(SINGLE_AUTH_STATUS).as_deref(),
Some("bobmatnyc")
);
}
#[test]
fn parse_active_account_from_status_none_active() {
assert_eq!(parse_active_account_from_status(""), None);
assert_eq!(
parse_active_account_from_status(
"github.com\n ✓ Logged in to github.com account x\n - Active account: false\n"
),
None
);
}
#[test]
fn verify_active_account_matches() {
assert!(verify_active_account(SINGLE_AUTH_STATUS, Ok("bobmatnyc"), "bobmatnyc").is_ok());
}
#[test]
fn verify_active_account_mismatch() {
let err =
verify_active_account(MULTI_AUTH_STATUS, Ok("bob-duetto"), "bobmatnyc").unwrap_err();
assert!(err.contains("bob-duetto"), "err: {err}");
assert!(err.contains("bobmatnyc"), "err: {err}");
}
#[test]
fn verify_active_account_follows_the_api_over_the_transcript() {
let claims_ghost = "\
github.com
✓ Logged in to github.com account nonexistent-user-xyz (keyring)
- Active account: true
";
let err = verify_active_account(claims_ghost, Ok("bobmatnyc"), "nonexistent-user-xyz")
.unwrap_err();
assert!(err.contains("bobmatnyc"), "err: {err}");
assert!(err.contains("nonexistent-user-xyz"), "err: {err}");
assert!(err.contains("5656"), "err: {err}");
}
#[test]
fn verify_active_account_fails_closed_when_the_probe_failed() {
let err = verify_active_account(SINGLE_AUTH_STATUS, Err("gh: not found"), "bobmatnyc")
.unwrap_err();
assert!(err.contains("could not establish"), "err: {err}");
assert!(err.contains("gh: not found"), "err: {err}");
}
#[test]
fn verify_active_account_rejects_env_token_override() {
let text = "The value of the GH_TOKEN environment variable is being used for authentication.\ngithub.com\n ✓ Logged in to github.com account bobmatnyc\n - Active account: true\n";
let err = verify_active_account(text, Ok("bobmatnyc"), "bobmatnyc").unwrap_err();
assert!(err.contains("environment variable"), "err: {err}");
}
#[test]
fn ensure_gh_account_for_project_refuses_without_config_dir() {
let _g = crate::core::trusty_tools_config::env_test_lock();
let prior = std::env::var_os(GH_CONFIG_DIR_ENV);
unsafe { std::env::remove_var(GH_CONFIG_DIR_ENV) };
let err = ensure_gh_account_for_project("bobmatnyc").unwrap_err();
unsafe {
match prior {
Some(v) => std::env::set_var(GH_CONFIG_DIR_ENV, v),
None => std::env::remove_var(GH_CONFIG_DIR_ENV),
}
}
let msg = err.to_string();
assert!(msg.contains(GH_CONFIG_DIR_ENV), "msg: {msg}");
assert!(
msg.contains("shared"),
"expected the shared-store refusal rationale: {msg}"
);
}
#[test]
fn ensure_gh_account_for_project_rejects_empty_login() {
let err = ensure_gh_account_for_project(" ").unwrap_err();
assert!(err.to_string().contains("must not be empty"));
}
fn github_cfg(config_dir: Option<&str>, account: Option<&str>) -> GithubConfig {
GithubConfig {
config_dir: config_dir.map(PathBuf::from),
token_env: None,
account: account.map(str::to_string),
host: None,
}
}
#[test]
fn configured_account_pair_both_set() {
let cfg = github_cfg(Some("/cfg/acct"), Some("bobmatnyc"));
assert_eq!(
configured_account_pair(Some(&cfg)),
Some(AccountTarget {
config_dir: PathBuf::from("/cfg/acct"),
account: "bobmatnyc".to_string(),
host: crate::core::trusty_tools_config::DEFAULT_GITHUB_HOST.to_string(),
})
);
}
#[test]
fn configured_account_pair_carries_a_custom_host() {
let mut cfg = github_cfg(Some("/cfg/acct"), Some("bobmatnyc"));
cfg.host = Some("github.acme.example".to_string());
assert_eq!(
configured_account_pair(Some(&cfg)).map(|t| t.host),
Some("github.acme.example".to_string())
);
}
#[test]
fn configured_account_pair_config_dir_only() {
let cfg = github_cfg(Some("/cfg/acct"), None);
assert_eq!(configured_account_pair(Some(&cfg)), None);
}
#[test]
fn configured_account_pair_account_only() {
let cfg = github_cfg(None, Some("bobmatnyc"));
assert_eq!(configured_account_pair(Some(&cfg)), None);
}
#[test]
fn configured_account_pair_blank_account() {
let cfg = github_cfg(Some("/cfg/acct"), Some(" "));
assert_eq!(configured_account_pair(Some(&cfg)), None);
}
#[test]
fn configured_account_pair_none() {
assert_eq!(configured_account_pair(None), None);
}
use crate::core::gh_scoped_stub::GhScopedStub;
fn fake_gh_lock() -> std::sync::MutexGuard<'static, ()> {
crate::core::trusty_tools_config::env_test_lock()
}
fn strip_ambient_tokens() -> Vec<(&'static str, Option<std::ffi::OsString>)> {
GH_TOKEN_ENV_VARS
.into_iter()
.map(|name| {
let prior = std::env::var_os(name);
unsafe { std::env::remove_var(name) };
(name, prior)
})
.collect()
}
fn restore_ambient_tokens(prior: Vec<(&'static str, Option<std::ffi::OsString>)>) {
for (name, value) in prior {
unsafe {
match value {
Some(v) => std::env::set_var(name, v),
None => std::env::remove_var(name),
}
}
}
}
#[test]
fn ensure_gh_account_in_dir_self_heals_mismatch() {
let _g = fake_gh_lock();
let config_dir = tempfile::tempdir().expect("config tempdir");
let (stub, _stub) = GhScopedStub::logged_in_as("wrong-account").install();
let prior_tokens = strip_ambient_tokens();
let result = ensure_gh_account_in_dir("bobmatnyc", config_dir.path(), TEST_HOST);
restore_ambient_tokens(prior_tokens);
assert!(result.is_ok(), "expected self-heal to succeed: {result:?}");
assert_eq!(
stub.active_account(),
"bobmatnyc",
"the switch must have taken effect"
);
}
#[test]
fn ensure_gh_account_in_dir_switch_failure_is_err() {
let _g = fake_gh_lock();
let config_dir = tempfile::tempdir().expect("config tempdir");
let (_stub_handle, _stub) = GhScopedStub::logged_in_as("wrong-account")
.with_failing_switch()
.install();
let prior_tokens = strip_ambient_tokens();
let result = ensure_gh_account_in_dir("bobmatnyc", config_dir.path(), TEST_HOST);
restore_ambient_tokens(prior_tokens);
let err = result.expect_err("switch failure must be a hard error");
assert!(err.to_string().contains("bobmatnyc"), "err: {err}");
}
#[test]
fn ensure_gh_account_in_dir_noop_when_already_active() {
let _g = fake_gh_lock();
let config_dir = tempfile::tempdir().expect("config tempdir");
let (stub, _stub) = GhScopedStub::logged_in_as("bobmatnyc")
.with_failing_switch()
.install();
let prior_tokens = strip_ambient_tokens();
let result = ensure_gh_account_in_dir("bobmatnyc", config_dir.path(), TEST_HOST);
restore_ambient_tokens(prior_tokens);
assert!(
result.is_ok(),
"already-active must be a no-op Ok: {result:?}"
);
assert_eq!(stub.active_account(), "bobmatnyc");
}
#[test]
fn ensure_gh_account_in_dir_rejects_a_transcript_the_api_contradicts() {
let _g = fake_gh_lock();
let config_dir = tempfile::tempdir().expect("config tempdir");
let (_stub_handle, _stub) = GhScopedStub::logged_in_as("nonexistent-user-xyz")
.with_api_login("bobmatnyc")
.install();
let prior_tokens = strip_ambient_tokens();
let result = ensure_gh_account_in_dir("nonexistent-user-xyz", config_dir.path(), TEST_HOST);
restore_ambient_tokens(prior_tokens);
let err = result.expect_err("a transcript the credential contradicts must not pass");
let msg = err.to_string();
assert!(msg.contains("bobmatnyc"), "err: {msg}");
assert!(msg.contains("nonexistent-user-xyz"), "err: {msg}");
}
#[test]
fn ensure_gh_account_in_dir_fails_closed_when_the_api_probe_fails() {
let _g = fake_gh_lock();
let config_dir = tempfile::tempdir().expect("config tempdir");
let (stub, _stub) = GhScopedStub::logged_in_as("bobmatnyc")
.with_failing_api()
.install();
let prior_tokens = strip_ambient_tokens();
let result = ensure_gh_account_in_dir("bobmatnyc", config_dir.path(), TEST_HOST);
restore_ambient_tokens(prior_tokens);
let err = result.expect_err("an unverifiable identity must never pass");
assert!(
err.to_string().contains("could not establish"),
"err: {err}"
);
assert_eq!(
stub.active_account(),
"bobmatnyc",
"no switch may be attempted on an unverifiable probe"
);
}
#[test]
fn ensure_gh_account_in_dir_fails_closed_on_a_blank_probe_answer() {
let _g = fake_gh_lock();
let config_dir = tempfile::tempdir().expect("config tempdir");
let (stub, _stub) = GhScopedStub::logged_in_as("bobmatnyc")
.with_blank_api()
.install();
let prior_tokens = strip_ambient_tokens();
let result = ensure_gh_account_in_dir("bobmatnyc", config_dir.path(), TEST_HOST);
restore_ambient_tokens(prior_tokens);
let err = result.expect_err("a blank login is not an identity");
assert!(
err.to_string().contains("could not establish"),
"err: {err}"
);
assert_eq!(
stub.active_account(),
"bobmatnyc",
"no switch may be attempted on an unverifiable probe"
);
}
#[test]
fn ensure_gh_account_in_dir_refuses_under_an_ambient_token() {
let _g = fake_gh_lock();
let config_dir = tempfile::tempdir().expect("config tempdir");
let (_stub_handle, _stub) = GhScopedStub::logged_in_as("bobmatnyc").install();
let prior_tokens = strip_ambient_tokens();
unsafe { std::env::set_var("GH_TOKEN", "gho_someoneelses_token") };
let result = ensure_gh_account_in_dir("bobmatnyc", config_dir.path(), TEST_HOST);
restore_ambient_tokens(prior_tokens);
let err = result.expect_err("an ambient token outranks the config dir");
let msg = err.to_string();
assert!(msg.contains("GH_TOKEN"), "err: {msg}");
assert!(msg.contains(GH_CONFIG_DIR_ENV), "err: {msg}");
}
#[test]
fn ambient_token_override_counts_a_whitespace_token() {
let _g = fake_gh_lock();
let prior_tokens = strip_ambient_tokens();
unsafe { std::env::set_var("GH_TOKEN", " ") };
let seen = ambient_token_override();
restore_ambient_tokens(prior_tokens);
assert_eq!(seen, Some("GH_TOKEN"));
}
#[test]
fn identity_divergence_reports_a_contradicting_claim() {
assert_eq!(
identity_divergence(SINGLE_AUTH_STATUS, "someone-else").as_deref(),
Some("bobmatnyc")
);
}
#[test]
fn identity_divergence_is_none_when_they_agree() {
assert_eq!(identity_divergence(SINGLE_AUTH_STATUS, "bobmatnyc"), None);
assert_eq!(identity_divergence("", "bobmatnyc"), None);
}
#[test]
fn ensure_gh_account_in_dir_accepts_the_api_answer_over_a_stale_transcript() {
let _g = fake_gh_lock();
let config_dir = tempfile::tempdir().expect("config tempdir");
let (stub, _stub) = GhScopedStub::logged_in_as("stale-account")
.with_api_login("bobmatnyc")
.with_failing_switch()
.install();
let prior_tokens = strip_ambient_tokens();
let result = ensure_gh_account_in_dir("bobmatnyc", config_dir.path(), TEST_HOST);
restore_ambient_tokens(prior_tokens);
assert!(
result.is_ok(),
"the credential already matches; no switch is owed: {result:?}"
);
assert_eq!(
stub.active_account(),
"stale-account",
"no switch should have been attempted"
);
}
}