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"];
pub fn ensure_gh_account_in_dir(
expected_user: &str,
config_dir: &std::path::Path,
) -> anyhow::Result<()> {
let dir = config_dir.to_string_lossy().to_string();
let host = crate::core::trusty_tools_config::DEFAULT_GITHUB_HOST;
let status_text = run_gh_scoped(&dir, ["auth", "status"])
.context("failed to run `gh auth status` scoped to the project's GH_CONFIG_DIR")?;
if verify_active_account(&status_text, expected_user).is_ok() {
return Ok(());
}
let switch = std::process::Command::new("gh")
.args([
"auth",
"switch",
"--hostname",
host,
"--user",
expected_user,
])
.env(GH_CONFIG_DIR_ENV, &dir)
.env_remove(GH_TOKEN_ENV_VARS[0])
.env_remove(GH_TOKEN_ENV_VARS[1])
.output()
.with_context(|| format!("failed to spawn `gh auth switch --user {expected_user}`"))?;
if !switch.status.success() {
let stderr = String::from_utf8_lossy(&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()
);
}
let recheck = run_gh_scoped(&dir, ["auth", "status"])
.context("failed to re-verify `gh auth status` after switching")?;
verify_active_account(&recheck, expected_user).map_err(|msg| {
anyhow::anyhow!(
"gh account switch to '{expected_user}' did not take effect inside {dir}: {msg}"
)
})
}
fn run_gh_scoped(config_dir: &str, args: [&'static str; 2]) -> anyhow::Result<String> {
let config_dir = config_dir.to_string();
run_bounded(GH_ENFORCE_TIMEOUT, move || {
let out = std::process::Command::new("gh")
.args(args)
.env(GH_CONFIG_DIR_ENV, &config_dir)
.env_remove(GH_TOKEN_ENV_VARS[0])
.env_remove(GH_TOKEN_ENV_VARS[1])
.output()
.ok()?;
let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
text.push('\n');
text.push_str(&String::from_utf8_lossy(&out.stderr));
Some(text)
})
.ok_or_else(|| anyhow::anyhow!("`gh` did not respond within {GH_ENFORCE_TIMEOUT:?}"))
}
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, 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}'"
));
}
match parse_active_account_from_status(status_text) {
Some(active) if active == expected => Ok(()),
Some(active) => Err(format!(
"active account is '{active}', expected '{expected}'"
)),
None => Err(format!(
"could not determine an active gh account (expected '{expected}')"
)),
}
}
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)
}
pub fn configured_account_pair(config: Option<&GithubConfig>) -> Option<(PathBuf, String)> {
let cfg = config?;
let dir = cfg.config_dir.clone()?;
let account = cfg
.account
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())?;
Some((dir, account.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
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, "bobmatnyc").is_ok());
}
#[test]
fn verify_active_account_mismatch() {
let err = verify_active_account(MULTI_AUTH_STATUS, "bobmatnyc").unwrap_err();
assert!(err.contains("bob-duetto"), "err: {err}");
assert!(err.contains("bobmatnyc"), "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, "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((PathBuf::from("/cfg/acct"), "bobmatnyc".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);
}
fn fake_gh_lock() -> std::sync::MutexGuard<'static, ()> {
crate::core::trusty_tools_config::env_test_lock()
}
#[cfg(unix)]
fn write_fake_gh(bin_dir: &std::path::Path, initial: &str) {
use std::os::unix::fs::PermissionsExt;
let script = format!(
r#"#!/bin/sh
STATE="$GH_CONFIG_DIR/.fake_active"
if [ "$1" = "auth" ] && [ "$2" = "status" ]; then
if [ -f "$STATE" ]; then
ACTIVE=$(cat "$STATE")
else
ACTIVE="{initial}"
fi
echo "github.com"
echo " - Logged in to github.com account $ACTIVE (keyring)"
echo " - Active account: true"
exit 0
fi
if [ "$1" = "auth" ] && [ "$2" = "switch" ]; then
if [ "$FAKE_GH_SWITCH_FAILS" = "1" ]; then
echo "fake gh: switch refused" >&2
exit 1
fi
target=""
prev=""
for a in "$@"; do
if [ "$prev" = "--user" ]; then target="$a"; fi
prev="$a"
done
echo "$target" > "$STATE"
exit 0
fi
exit 1
"#
);
let path = bin_dir.join("gh");
std::fs::write(&path, script).expect("write fake gh");
let mut perms = std::fs::metadata(&path)
.expect("stat fake gh")
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).expect("chmod fake gh");
}
#[cfg(unix)]
fn prepend_path(dir: &std::path::Path) -> Option<String> {
let prior = std::env::var("PATH").ok();
let new_path = match &prior {
Some(p) => format!("{}:{p}", dir.display()),
None => dir.display().to_string(),
};
unsafe { std::env::set_var("PATH", new_path) };
prior
}
#[cfg(unix)]
fn restore_path(prior: Option<String>) {
unsafe {
match prior {
Some(p) => std::env::set_var("PATH", p),
None => std::env::remove_var("PATH"),
}
}
}
#[cfg(unix)]
#[test]
fn ensure_gh_account_in_dir_self_heals_mismatch() {
let _g = fake_gh_lock();
let bin_dir = tempfile::tempdir().expect("bin tempdir");
let config_dir = tempfile::tempdir().expect("config tempdir");
write_fake_gh(bin_dir.path(), "wrong-account");
let prior_path = prepend_path(bin_dir.path());
unsafe { std::env::remove_var("FAKE_GH_SWITCH_FAILS") };
let result = ensure_gh_account_in_dir("bobmatnyc", config_dir.path());
restore_path(prior_path);
assert!(result.is_ok(), "expected self-heal to succeed: {result:?}");
let state = std::fs::read_to_string(config_dir.path().join(".fake_active"))
.expect("state file written by switch");
assert_eq!(state.trim(), "bobmatnyc");
}
#[cfg(unix)]
#[test]
fn ensure_gh_account_in_dir_switch_failure_is_err() {
let _g = fake_gh_lock();
let bin_dir = tempfile::tempdir().expect("bin tempdir");
let config_dir = tempfile::tempdir().expect("config tempdir");
write_fake_gh(bin_dir.path(), "wrong-account");
let prior_path = prepend_path(bin_dir.path());
unsafe { std::env::set_var("FAKE_GH_SWITCH_FAILS", "1") };
let result = ensure_gh_account_in_dir("bobmatnyc", config_dir.path());
unsafe { std::env::remove_var("FAKE_GH_SWITCH_FAILS") };
restore_path(prior_path);
let err = result.expect_err("switch failure must be a hard error");
assert!(err.to_string().contains("bobmatnyc"), "err: {err}");
}
#[cfg(unix)]
#[test]
fn ensure_gh_account_in_dir_noop_when_already_active() {
let _g = fake_gh_lock();
let bin_dir = tempfile::tempdir().expect("bin tempdir");
let config_dir = tempfile::tempdir().expect("config tempdir");
write_fake_gh(bin_dir.path(), "bobmatnyc");
let prior_path = prepend_path(bin_dir.path());
unsafe { std::env::set_var("FAKE_GH_SWITCH_FAILS", "1") };
let result = ensure_gh_account_in_dir("bobmatnyc", config_dir.path());
unsafe { std::env::remove_var("FAKE_GH_SWITCH_FAILS") };
restore_path(prior_path);
assert!(
result.is_ok(),
"already-active must be a no-op Ok: {result:?}"
);
assert!(
!config_dir.path().join(".fake_active").exists(),
"no switch should have been attempted"
);
}
}