use std::path::PathBuf;
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
pub const TOKEN_ENV_VAR: &str = "TRUSTY_BUGREPORT_GITHUB_TOKEN";
pub const TOKEN_FILE_ENV_VAR: &str = "TRUSTY_BUGREPORT_TOKEN_FILE";
const TOKEN_FILE_RELATIVE: &str = ".config/trusty-mpm/bugreport-token";
pub const APP_ID_ENV_VAR: &str = "TRUSTY_BUGREPORT_GH_APP_ID";
pub const APP_INSTALL_ID_ENV_VAR: &str = "TRUSTY_BUGREPORT_GH_INSTALL_ID";
pub const APP_KEY_FILE_ENV_VAR: &str = "TRUSTY_BUGREPORT_GH_APP_KEY_FILE";
pub trait TokenProvider: Send + Sync {
fn token(&self) -> Option<String>;
}
pub struct EnvFileTokenProvider;
impl TokenProvider for EnvFileTokenProvider {
fn token(&self) -> Option<String> {
if let Ok(val) = std::env::var(TOKEN_ENV_VAR) {
let trimmed = val.trim().to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
let file_path: PathBuf = if let Ok(override_path) = std::env::var(TOKEN_FILE_ENV_VAR) {
PathBuf::from(override_path.trim())
} else if let Some(home) = dirs::home_dir() {
home.join(TOKEN_FILE_RELATIVE)
} else {
return None;
};
std::fs::read_to_string(&file_path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
}
#[derive(Debug, Clone)]
struct CachedToken {
value: String,
expires_at_secs: i64,
}
impl CachedToken {
fn is_valid(&self, now_secs: i64) -> bool {
now_secs < self.expires_at_secs - 300
}
}
#[derive(Debug, Clone)]
pub struct GithubAppConfig {
pub app_id: u64,
pub installation_id: u64,
pub private_key_path: PathBuf,
}
impl GithubAppConfig {
pub fn from_env() -> Option<Self> {
let app_id: u64 = std::env::var(APP_ID_ENV_VAR)
.ok()
.and_then(|s| s.trim().parse().ok())?;
let installation_id: u64 = std::env::var(APP_INSTALL_ID_ENV_VAR)
.ok()
.and_then(|s| s.trim().parse().ok())?;
let private_key_path: PathBuf = std::env::var(APP_KEY_FILE_ENV_VAR)
.ok()
.map(|s| PathBuf::from(s.trim()))?;
Some(Self {
app_id,
installation_id,
private_key_path,
})
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AppJwtClaims {
pub iss: String,
pub iat: i64,
pub exp: i64,
}
impl AppJwtClaims {
pub fn new(app_id: u64, now_secs: i64) -> Self {
Self {
iss: app_id.to_string(),
iat: now_secs,
exp: now_secs + 600,
}
}
}
#[derive(Debug, Deserialize)]
struct InstallationTokenResponse {
token: String,
expires_at: String,
}
type ExchangeFn = Box<dyn Fn(&str, u64) -> anyhow::Result<(String, i64)> + Send + Sync>;
pub struct GithubAppTokenProvider {
config: GithubAppConfig,
cache: Mutex<Option<CachedToken>>,
now_fn: Box<dyn Fn() -> i64 + Send + Sync>,
exchange_fn: ExchangeFn,
}
impl GithubAppTokenProvider {
pub fn new(config: GithubAppConfig) -> Self {
Self {
config,
cache: Mutex::new(None),
now_fn: Box::new(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}),
exchange_fn: Box::new(real_exchange_installation_token),
}
}
pub fn with_injected(
config: GithubAppConfig,
now_fn: impl Fn() -> i64 + Send + Sync + 'static,
exchange_fn: impl Fn(&str, u64) -> anyhow::Result<(String, i64)> + Send + Sync + 'static,
) -> Self {
Self {
config,
cache: Mutex::new(None),
now_fn: Box::new(now_fn),
exchange_fn: Box::new(exchange_fn),
}
}
pub fn mint_jwt(&self, now_secs: i64) -> anyhow::Result<String> {
let pem_data = std::fs::read_to_string(&self.config.private_key_path)
.map_err(|e| anyhow::anyhow!("failed to read App private key: {e}"))?;
let claims = AppJwtClaims::new(self.config.app_id, now_secs);
encode_jwt_rs256(&pem_data, &claims)
}
fn get_token_cached(&self) -> anyhow::Result<String> {
let now = (self.now_fn)();
{
let guard = self.cache.lock().expect("token cache lock not poisoned");
if let Some(ref cached) = *guard
&& cached.is_valid(now)
{
return Ok(cached.value.clone());
}
}
let jwt = self.mint_jwt(now)?;
let (token_value, expires_at_secs) = (self.exchange_fn)(&jwt, self.config.installation_id)
.map_err(|e| anyhow::anyhow!("GitHub App token exchange failed: {e}"))?;
{
let mut guard = self.cache.lock().expect("token cache lock not poisoned");
*guard = Some(CachedToken {
value: token_value.clone(),
expires_at_secs,
});
}
Ok(token_value)
}
}
impl TokenProvider for GithubAppTokenProvider {
fn token(&self) -> Option<String> {
self.get_token_cached()
.map_err(|e| {
tracing::warn!("GitHub App token provider failed: {e}");
})
.ok()
}
}
pub fn encode_jwt_rs256(pem: &str, claims: &AppJwtClaims) -> anyhow::Result<String> {
use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
let key = EncodingKey::from_rsa_pem(pem.as_bytes())
.map_err(|e| anyhow::anyhow!("invalid RSA PEM for App JWT: {e}"))?;
let header = Header::new(Algorithm::RS256);
encode(&header, claims, &key).map_err(|e| anyhow::anyhow!("JWT encode failed: {e}"))
}
fn real_exchange_installation_token(
jwt: &str,
installation_id: u64,
) -> anyhow::Result<(String, i64)> {
let url = format!("https://api.github.com/app/installations/{installation_id}/access_tokens");
let client = reqwest::blocking::Client::builder()
.user_agent(concat!("trusty-mpm/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| anyhow::anyhow!("reqwest client build: {e}"))?;
let resp = client
.post(&url)
.header(reqwest::header::AUTHORIZATION, format!("Bearer {jwt}"))
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.map_err(|e| anyhow::anyhow!("token exchange request: {e}"))?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().unwrap_or_default();
return Err(anyhow::anyhow!(
"GitHub App token exchange failed: HTTP {status}: {body}"
));
}
let parsed: InstallationTokenResponse = resp
.json()
.map_err(|e| anyhow::anyhow!("parse installation token response: {e}"))?;
let expires_at_secs = parse_iso8601_to_unix(&parsed.expires_at)?;
Ok((parsed.token, expires_at_secs))
}
fn parse_iso8601_to_unix(s: &str) -> anyhow::Result<i64> {
use chrono::DateTime;
let dt = DateTime::parse_from_rfc3339(s)
.map_err(|e| anyhow::anyhow!("parse expires_at '{s}': {e}"))?;
Ok(dt.timestamp())
}
pub fn resolve_token() -> Option<String> {
if let Some(tok) = EnvFileTokenProvider.token() {
return Some(tok);
}
if let Some(config) = GithubAppConfig::from_env() {
let provider = GithubAppTokenProvider::new(config);
if let Some(tok) = provider.token() {
return Some(tok);
}
}
None
}
pub struct ResolvedProvider;
impl TokenProvider for ResolvedProvider {
fn token(&self) -> Option<String> {
resolve_token()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
fn config_with_test_key() -> Option<GithubAppConfig> {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("src/daemon/bug_report/test_fixtures/test_rsa_key.pem");
if !path.exists() {
return None;
}
Some(GithubAppConfig {
app_id: 12345,
installation_id: 67890,
private_key_path: path,
})
}
#[test]
#[serial]
fn resolution_order_env_wins_over_file() {
let sentinel = "ghp_test_env_wins_phase4_unique"; unsafe { std::env::set_var(TOKEN_ENV_VAR, sentinel) };
let tok = EnvFileTokenProvider.token();
unsafe { std::env::remove_var(TOKEN_ENV_VAR) };
assert!(tok.is_some(), "expected Some when env var is set: {tok:?}");
}
#[test]
#[serial]
fn resolution_order_file_used_when_env_absent() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), "ghp_from_file_phase4\n").unwrap();
unsafe {
std::env::remove_var(TOKEN_ENV_VAR);
std::env::set_var(TOKEN_FILE_ENV_VAR, tmp.path().as_os_str());
}
let tok = EnvFileTokenProvider.token();
unsafe { std::env::remove_var(TOKEN_FILE_ENV_VAR) };
assert!(
tok.is_some(),
"expected Some from file when env var absent: {tok:?}"
);
}
#[test]
#[serial]
fn resolve_token_prefers_pat_env() {
let sentinel = "ghp_pat_env_wins_over_app";
unsafe {
std::env::set_var(TOKEN_ENV_VAR, sentinel);
std::env::remove_var(TOKEN_FILE_ENV_VAR);
std::env::set_var(APP_ID_ENV_VAR, "12345");
std::env::set_var(APP_INSTALL_ID_ENV_VAR, "67890");
std::env::set_var(
APP_KEY_FILE_ENV_VAR,
"/tmp/trusty-test-nonexistent-pem-pattest.pem",
);
}
let tok = resolve_token();
unsafe {
std::env::remove_var(TOKEN_ENV_VAR);
std::env::remove_var(APP_ID_ENV_VAR);
std::env::remove_var(APP_INSTALL_ID_ENV_VAR);
std::env::remove_var(APP_KEY_FILE_ENV_VAR);
}
assert_eq!(
tok.as_deref(),
Some(sentinel),
"resolve_token must prefer PAT env over GitHub App env vars: {tok:?}"
);
}
#[test]
fn resolution_order_returns_none_when_all_absent() {
struct NoneProvider;
impl TokenProvider for NoneProvider {
fn token(&self) -> Option<String> {
None
}
}
assert!(NoneProvider.token().is_none());
}
#[test]
fn jwt_claims_iss_and_window() {
let now = 1_700_000_000i64;
let claims = AppJwtClaims::new(42, now);
assert_eq!(claims.iss, "42");
assert_eq!(claims.iat, now);
assert_eq!(claims.exp, now + 600, "exp must be iat + 600s");
}
#[test]
fn cache_returns_valid_token() {
let config = match config_with_test_key() {
Some(c) => c,
None => {
eprintln!("SKIP cache_returns_valid_token: test RSA key fixture not found");
return;
}
};
let now_secs = 1_700_000_000i64;
let expiry = now_secs + 3600;
let provider = GithubAppTokenProvider::with_injected(
config,
move || now_secs,
move |_jwt, _install_id| Ok(("ghs_cached_token".to_string(), expiry)),
);
let tok1 = provider.get_token_cached().unwrap();
assert_eq!(tok1, "ghs_cached_token");
let tok2 = provider.get_token_cached().unwrap();
assert_eq!(tok2, "ghs_cached_token", "second call must return cached");
}
#[test]
fn cache_refreshes_before_expiry() {
let now_secs = 1_700_000_000i64;
let almost_expired = now_secs + 200;
let cached = CachedToken {
value: "old_token".to_string(),
expires_at_secs: almost_expired,
};
assert!(
!cached.is_valid(now_secs),
"token with <300s remaining must be invalid"
);
let valid = CachedToken {
value: "new_token".to_string(),
expires_at_secs: now_secs + 3600,
};
assert!(
valid.is_valid(now_secs),
"token with 3600s remaining must be valid"
);
}
#[test]
fn cache_is_valid_exactly_at_margin() {
let now_secs = 1_700_000_000i64;
let at_margin = CachedToken {
value: "edge".to_string(),
expires_at_secs: now_secs + 300,
};
assert!(
!at_margin.is_valid(now_secs),
"token at exactly 300s margin should be invalid"
);
let just_past = CachedToken {
value: "edge+1".to_string(),
expires_at_secs: now_secs + 301,
};
assert!(
just_past.is_valid(now_secs),
"token at 301s margin should be valid"
);
}
#[test]
#[serial]
fn resolved_provider_uses_pat_env() {
let sentinel = "ghp_resolved_provider_pat_test"; unsafe { std::env::set_var(TOKEN_ENV_VAR, sentinel) };
let tok = ResolvedProvider.token();
unsafe { std::env::remove_var(TOKEN_ENV_VAR) };
assert_eq!(
tok.as_deref(),
Some(sentinel),
"ResolvedProvider must return the PAT env value"
);
}
#[test]
#[serial]
fn resolved_provider_returns_none_without_sources() {
unsafe {
std::env::remove_var(TOKEN_ENV_VAR);
std::env::remove_var(APP_ID_ENV_VAR);
std::env::remove_var(APP_INSTALL_ID_ENV_VAR);
std::env::remove_var(APP_KEY_FILE_ENV_VAR);
std::env::set_var(
TOKEN_FILE_ENV_VAR,
"/tmp/trusty-test-nonexistent-token-file-abc123",
);
}
let tok = ResolvedProvider.token();
unsafe { std::env::remove_var(TOKEN_FILE_ENV_VAR) };
assert!(
tok.is_none(),
"ResolvedProvider must return None when all sources absent"
);
}
#[test]
#[serial]
fn resolve_token_selects_app_when_only_app_env_set() {
unsafe {
std::env::remove_var(TOKEN_ENV_VAR);
std::env::remove_var(TOKEN_FILE_ENV_VAR);
std::env::set_var(APP_ID_ENV_VAR, "12345");
std::env::set_var(APP_INSTALL_ID_ENV_VAR, "67890");
std::env::set_var(
APP_KEY_FILE_ENV_VAR,
"/tmp/trusty-test-nonexistent-pem-abc123.pem",
);
}
let tok = resolve_token();
unsafe {
std::env::remove_var(APP_ID_ENV_VAR);
std::env::remove_var(APP_INSTALL_ID_ENV_VAR);
std::env::remove_var(APP_KEY_FILE_ENV_VAR);
}
assert!(
tok.is_none(),
"resolve_token with missing PEM must return None gracefully"
);
}
#[test]
fn parse_iso8601_roundtrip() {
let ts = parse_iso8601_to_unix("2024-01-01T00:00:00Z").unwrap();
assert_eq!(ts, 1_704_067_200, "unexpected Unix timestamp: {ts}");
}
#[test]
fn parse_iso8601_invalid_returns_err() {
let result = parse_iso8601_to_unix("not-a-date");
assert!(result.is_err());
}
#[test]
fn jwt_claims_sign_verify() {
let fixture_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("src/daemon/bug_report/test_fixtures/test_rsa_key.pem");
if !fixture_path.exists() {
eprintln!("SKIP jwt_claims_sign_verify: fixture not found at {fixture_path:?}");
return;
}
let pem = std::fs::read_to_string(&fixture_path).unwrap();
let now = 1_700_000_000i64;
let claims = AppJwtClaims::new(999, now);
let jwt_str = encode_jwt_rs256(&pem, &claims).expect("encode should succeed");
let decoded = jsonwebtoken::decode::<AppJwtClaims>(
&jwt_str,
&jsonwebtoken::DecodingKey::from_secret(b""),
&{
let mut v = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256);
v.insecure_disable_signature_validation();
v.validate_exp = false;
v
},
)
.expect("decode should succeed with disabled validation");
assert_eq!(decoded.claims.iss, "999");
assert_eq!(decoded.claims.iat, now);
assert_eq!(decoded.claims.exp, now + 600);
}
}