use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
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>,
}
pub(crate) fn pinned_gh_env_in(registry_dir: &Path, origin: &str) -> Result<Option<GhEnv>, String> {
match read_pin(registry_dir, origin)? {
None => Ok(None),
Some(pin) => resolve_pin(&pin),
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct RegistryPin {
pub(crate) account: Option<String>,
pub(crate) github: Option<GithubConfig>,
}
impl RegistryPin {
pub(crate) fn from_project(project: &Project) -> Self {
Self {
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))
}
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(),
}
}
}
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) 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) -> Result<Option<GhEnv>, String> {
let mut cfg = pin.github.clone().unwrap_or_default();
if cfg.account.is_none() {
cfg.account = pin.account.clone();
}
let env = match gh_identity::resolve_gh_env(Some(&cfg)) {
Ok(env) => env,
Err(GhIdentityError::AccountStrategyUnsupported(account)) => {
return Err(format!(
"this repository is pinned to gh account '{account}' with no \
`github.config_dir`, and `gh auth token -u {account}` does not \
discriminate between logged-in accounts on a keyring-backed host \
(#5851) — refusing to probe it as whichever account is globally \
active. Re-run `tm --user {account} <url>` once to build this \
project's scoped gh config dir, or set `github.config_dir` for it."
));
}
};
if let Some(var) = named_token_env(&cfg)
&& selected_config_dir(&cfg).is_none()
&& !env.vars().iter().any(|(key, _)| key == "GH_TOKEN")
{
let who = pin.who();
return Err(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."
));
}
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 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;