use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::core::gh_account::PinnedGhIdentity;
#[cfg(test)]
use crate::core::gh_account_dir::AccountDirSources;
use crate::core::gh_account_proof::{AccountProver, normalize_gh_host};
use crate::core::gh_identity::{self, GhEnv, GhIdentityError};
use crate::core::trusty_tools_config::GithubConfig;
use crate::project::record::{Project, repo_url_matches};
const REGISTRY_FILE: &str = "projects.json";
#[derive(Debug, Deserialize)]
struct RegistrySnapshot {
projects: BTreeMap<String, serde_json::Value>,
}
#[cfg(test)]
pub(crate) fn pinned_gh_env_in(registry_dir: &Path, origin: &str) -> Result<Option<GhEnv>, String> {
let prover = AccountProver {
sources: &AccountDirSources::default(),
probe: &crate::core::gh_account_proof::CliTokenProbe,
check: &crate::core::gh_account_proof::HttpUserCheck,
cache: None,
};
pinned_gh_env_with(registry_dir, origin, &prover)
}
pub(crate) fn pinned_gh_env_with(
registry_dir: &Path,
origin: &str,
prover: &AccountProver<'_>,
) -> Result<Option<GhEnv>, String> {
match read_pin(registry_dir, origin)? {
None => Ok(None),
Some(pin) => resolve_pin(&pin, origin, prover),
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct RegistryPin {
pub(crate) account: Option<String>,
pub(crate) github: Option<GithubConfig>,
pub(crate) record: Option<(String, String)>,
}
impl RegistryPin {
pub(crate) fn from_project(project: &Project) -> Self {
Self {
record: Some((project.name.clone(), project.repo_url.clone())),
account: project
.gh_account
.as_deref()
.map(str::trim)
.filter(|a| !a.is_empty())
.map(str::to_string),
github: project.github.clone(),
}
}
fn is_empty(&self) -> bool {
self.account.is_none() && self.github.as_ref().is_none_or(|g| !names_identity(g))
}
pub(crate) fn login(&self) -> Option<&str> {
self.account.as_deref().or_else(|| {
self.github
.as_ref()?
.account
.as_deref()
.map(str::trim)
.filter(|a| !a.is_empty())
})
}
fn who(&self) -> String {
match self.login() {
Some(account) => format!("gh account '{account}'"),
None => "the pinned gh identity".to_string(),
}
}
}
pub(crate) fn read_pin(registry_dir: &Path, origin: &str) -> Result<Option<RegistryPin>, String> {
let path = registry_dir.join(REGISTRY_FILE);
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(unanswerable(&path, &format!("could not be read ({e})"))),
};
let snapshot: RegistrySnapshot = serde_json::from_str(&text)
.map_err(|e| unanswerable(&path, &format!("did not parse ({e})")))?;
let mut candidates = Vec::new();
for (name, raw) in snapshot.projects.iter().filter(|(_, raw)| {
raw.get("repo_url")
.and_then(serde_json::Value::as_str)
.is_some_and(|url| repo_url_matches(url, origin))
}) {
let project: Project = serde_json::from_value(raw.clone()).map_err(|e| {
format!(
"the project registry record '{name}' names this repository but did not parse \
({e}), so whether it pins a gh account is unknown — refusing to probe the \
repository as this machine's global gh account (#5850)"
)
})?;
candidates.push((name.clone(), RegistryPin::from_project(&project)));
}
select_pin(candidates)
}
pub(crate) async fn find_pinned_gh_identity(
registry: &crate::project::ProjectRegistry,
origin: &str,
) -> Result<Option<PinnedGhIdentity>, String> {
let projects = registry
.list()
.await
.map_err(|e| format!("the project registry could not be read ({e})"))?;
let candidates = projects
.iter()
.filter(|p| repo_url_matches(&p.repo_url, origin))
.map(|p| (p.name.clone(), RegistryPin::from_project(p)))
.collect();
Ok(select_pin(candidates)?.map(|pin| pinned_identity(&pin)))
}
pub(crate) fn pinned_identity(pin: &RegistryPin) -> PinnedGhIdentity {
PinnedGhIdentity {
account: pin.login().map(str::to_string),
config_dir: pin.github.as_ref().and_then(selected_config_dir),
..Default::default()
}
}
pub(crate) fn select_pin(
candidates: Vec<(String, RegistryPin)>,
) -> Result<Option<RegistryPin>, String> {
let mut pinned: Vec<(String, RegistryPin)> = candidates
.into_iter()
.filter(|(_, pin)| !pin.is_empty())
.collect();
pinned.sort_by(|a, b| a.0.cmp(&b.0));
let login = |pin: &RegistryPin| pin.login().map(str::to_ascii_lowercase);
let config_dir = |pin: &RegistryPin| pin.github.as_ref().and_then(selected_config_dir);
let token_env = |pin: &RegistryPin| {
pin.github
.as_ref()
.and_then(named_token_env)
.map(str::to_string)
};
if let Some((a, b)) = first_disagreement(&pinned, login) {
return Err(disagreement(a, b, "log in as different gh accounts"));
}
if let Some((a, b)) = first_disagreement(&pinned, config_dir) {
return Err(disagreement(a, b, "pin different gh config dirs"));
}
if let Some((a, b)) = first_disagreement(&pinned, token_env) {
return Err(disagreement(
a,
b,
"pin different `github.token_env` variables",
));
}
let specificity = |pin: &RegistryPin| (config_dir(pin).is_some(), token_env(pin).is_some());
let agreed = pinned
.iter()
.find_map(|(_, pin)| pin.login().map(str::to_string));
Ok(pinned
.into_iter()
.rev()
.max_by_key(|(_, pin)| specificity(pin))
.map(|(_, mut pin)| {
if pin.login().is_none() {
pin.account = agreed;
}
pin
}))
}
fn first_disagreement<'a, T: PartialEq>(
pinned: &'a [(String, RegistryPin)],
field: impl Fn(&RegistryPin) -> Option<T>,
) -> Option<(&'a str, &'a str)> {
let mut seen: Option<(&'a str, T)> = None;
for (name, pin) in pinned {
let Some(value) = field(pin) else { continue };
if let Some((first, first_value)) = &seen {
if *first_value != value {
return Some((first, name));
}
} else {
seen = Some((name, value));
}
}
None
}
fn disagreement(a: &str, b: &str, how: &str) -> String {
format!(
"the project registry records '{a}' and '{b}' both name this repository but {how} — \
refusing to probe it as either, or as this machine's global gh account (#5850). \
Remove one of the records or make their gh_account/github settings agree."
)
}
fn resolve_pin(
pin: &RegistryPin,
origin: &str,
prover: &AccountProver<'_>,
) -> Result<Option<GhEnv>, String> {
if let (Some(account), Some(bound)) = (
pin.account.as_deref(),
pin.github
.as_ref()
.and_then(|g| g.account.as_deref())
.map(str::trim),
) && !bound.is_empty()
&& !account.eq_ignore_ascii_case(bound)
{
return Err(format!(
"this repository's registry record pins gh_account '{account}' but its \
`github.account` is '{bound}' — refusing to probe it as either, or as this \
machine's global gh account (#8510). Make the two agree."
));
}
let mut cfg = pin.github.clone().unwrap_or_default();
if cfg.account.is_none() {
cfg.account = pin.account.clone();
}
if let Some(login) = pin.login()
&& selected_config_dir(&cfg).is_none()
&& named_token_env(&cfg).is_none()
{
return match prover.prove(login, origin) {
Ok(proven) => {
let mut vars = proven.identity_vars();
if let Some(host) = cfg.host.as_deref().map(str::trim).filter(|h| !h.is_empty()) {
if normalize_gh_host(host).as_deref() != Ok(proven.host.as_str()) {
return Err(host_mismatch_refusal(login, host, &proven.host));
}
vars.push(("GH_HOST".to_string(), proven.host.clone()));
}
Ok(Some(GhEnv::from_identity_vars(vars)))
}
Err(reasons) => Err(account_only_refusal(pin, login, &reasons)),
};
}
let env = match gh_identity::resolve_gh_env(Some(&cfg)) {
Ok(env) => env,
Err(GhIdentityError::AccountStrategyUnsupported(account)) => {
return Err(match named_token_env(&cfg) {
Some(var) => unset_token_env_refusal(pin, var),
None => account_only_refusal(pin, &account, &[]),
});
}
};
if let Some(var) = named_token_env(&cfg)
&& selected_config_dir(&cfg).is_none()
&& !env.vars().iter().any(|(key, _)| key == "GH_TOKEN")
{
return Err(unset_token_env_refusal(pin, var));
}
if !env
.vars()
.iter()
.any(|(key, _)| key == "GH_CONFIG_DIR" || key == "GH_TOKEN")
{
return Ok(None);
}
if let Some(dir) = selected_config_dir(&cfg)
&& !crate::core::gh_account::config_dir_has_credential(&dir)
{
let who = pin.who();
let dir = dir.display();
return Err(format!(
"this repository is pinned to {who} via gh config dir {dir}, which holds no \
github.com credential ({dir}/hosts.yml is missing or names no account) — \
refusing to fall back to this machine's global gh account (#5850). Run \
`GH_CONFIG_DIR={dir} gh auth login` to authenticate inside it."
));
}
Ok(Some(env))
}
fn unset_token_env_refusal(pin: &RegistryPin, var: &str) -> String {
let who = pin.who();
format!(
"this repository is pinned to {who} via `github.token_env` '{var}', which is \
unset or empty in this process — refusing to fall back to this machine's \
global gh account (#5850). Export {var} where the daemon runs, or set \
`github.config_dir` for this project."
)
}
fn account_only_refusal(pin: &RegistryPin, account: &str, failures: &[String]) -> String {
let (name, url) = pin
.record
.as_ref()
.map_or(("<name>", "<url>"), |(n, u)| (n.as_str(), u.as_str()));
let url = crate::core::remote_url_redact::redact_stored_url(url);
let checked = if failures.is_empty() {
String::new()
} else {
format!(
" No candidate token is proven to belong to '{account}': {}.",
failures.join("; ")
)
};
format!(
"this repository is pinned to gh account '{account}' with no `github.config_dir`, \
and a gh token is used for it only once `GET /user` proves it belongs to '{account}' \
(#5851) — refusing to probe it as whichever account is globally active.{checked} \
Pin a gh config dir logged in as '{account}': `tm projects register {name} \
--repo-url {url} --gh-account {account} --gh-config-dir <dir>` (#8510)."
)
}
fn host_mismatch_refusal(account: &str, host: &str, proven: &str) -> String {
format!(
"this repository is pinned to gh account '{account}' with `github.host` '{host}', \
but the account's token was proven on '{proven}', the repository's own host — \
refusing to point gh at a host the token was not proven on (#8510). Remove \
`github.host` or make it '{proven}'."
)
}
pub(crate) fn selected_config_dir(cfg: &GithubConfig) -> Option<PathBuf> {
cfg.config_dir
.as_deref()
.map(|p| p.to_string_lossy().trim().to_string())
.filter(|s| !s.is_empty())
.map(PathBuf::from)
}
fn names_identity(cfg: &GithubConfig) -> bool {
selected_config_dir(cfg).is_some()
|| named_token_env(cfg).is_some()
|| cfg.account.as_deref().is_some_and(|a| !a.trim().is_empty())
}
fn named_token_env(cfg: &GithubConfig) -> Option<&str> {
cfg.token_env
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
}
fn unanswerable(path: &Path, what: &str) -> String {
format!(
"the project registry at {} {what}, so whether this repository pins a gh account \
is unknown — refusing to probe it as this machine's global gh account (#5850)",
path.display()
)
}
#[cfg(test)]
#[path = "gh_account_registry_tests.rs"]
mod gh_account_registry_tests;