use std::collections::BTreeMap;
use std::path::Path;
use std::sync::{Arc, RwLock};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use vgi_forge::{Forge, ForgeHooks, Resource, VgiConfig};
use zeroize::Zeroizing;
use crate::config::BridgeConfig;
#[cfg(feature = "forge-forgejo")]
use crate::config::ForgejoForgeConfig;
#[cfg(feature = "forge-github")]
use crate::config::GitHubForgeConfig;
use crate::store::{NamespaceRecord, NamespaceState, Store};
#[derive(Clone)]
#[non_exhaustive]
pub enum Adapter {
#[cfg(feature = "forge-github")]
GitHub(Arc<vgi_forge_github::GitHubForge>),
#[cfg(feature = "forge-forgejo")]
Forgejo(Arc<vgi_forge_forgejo::ForgejoForge>),
}
impl std::fmt::Debug for Adapter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Adapter({})", self.forge().host())
}
}
impl Adapter {
pub fn forge(&self) -> &dyn Forge {
match self {
#[cfg(feature = "forge-github")]
Adapter::GitHub(g) => g.as_ref(),
#[cfg(feature = "forge-forgejo")]
Adapter::Forgejo(f) => f.as_ref(),
}
}
pub fn hooks(&self) -> &dyn ForgeHooks {
match self {
#[cfg(feature = "forge-github")]
Adapter::GitHub(g) => g.as_ref(),
#[cfg(feature = "forge-forgejo")]
Adapter::Forgejo(f) => f.as_ref(),
}
}
#[cfg(feature = "forge-github")]
pub fn github(&self) -> Option<&Arc<vgi_forge_github::GitHubForge>> {
match self {
Adapter::GitHub(g) => Some(g),
#[allow(unreachable_patterns)]
_ => None,
}
}
#[cfg(feature = "forge-forgejo")]
pub fn forgejo(&self) -> Option<&Arc<vgi_forge_forgejo::ForgejoForge>> {
match self {
Adapter::Forgejo(f) => Some(f),
#[allow(unreachable_patterns)]
_ => None,
}
}
pub fn restore(&self, record: &NamespaceRecord) -> Result<()> {
if record.state != NamespaceState::Bound {
return Ok(());
}
let Some(binding) = &record.binding else {
return Ok(());
};
match self {
#[cfg(feature = "forge-github")]
Adapter::GitHub(g) => {
g.register_namespace(binding.namespace.clone())?;
let rw = record
.required_workflow
.or(record.capabilities.as_ref().map(|c| c.required_workflow));
if let Some(rw) = rw {
g.set_required_workflow(&record.resource, rw);
}
g.set_managed_repositories(&record.resource, record.managed.iter().copied());
if let Some(ready) = record.bridge_checks {
g.set_bridge_checks_ready(&record.resource, ready);
}
if let Some(pin) = &record.pin {
g.set_required_workflow_pin(
&record.resource,
vgi_forge_github::RequiredWorkflowPin::new(
pin.repository_id,
pin.sha.clone(),
pin.check.clone(),
),
);
}
}
#[cfg(feature = "forge-forgejo")]
Adapter::Forgejo(f) => {
f.register_namespace(binding.namespace.clone())?;
}
}
Ok(())
}
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StoredApp {
pub app_id: u64,
pub owner: String,
pub slug: String,
pub client_id: String,
pub client_secret: String,
pub webhook_secret: String,
pub pem: String,
}
impl Drop for StoredApp {
fn drop(&mut self) {
use zeroize::Zeroize;
self.client_secret.zeroize();
self.webhook_secret.zeroize();
self.pem.zeroize();
}
}
pub fn github_app_secret(host: &str, owner: &str) -> String {
format!("github/{host}/{}/app", owner.to_ascii_lowercase())
}
pub fn legacy_github_app_secret(host: &str) -> String {
format!("github/{host}/app")
}
pub fn refuse_single_app_layout(cfg: &BridgeConfig, store: &Store) -> Result<()> {
let hosts: std::collections::BTreeSet<&str> =
cfg.github.iter().map(|g| g.host.as_str()).collect();
for host in hosts {
if store.get_secret(&legacy_github_app_secret(host))?.is_some() {
anyhow::bail!(
"the store holds a GitHub App for `{host}` in the single-App layout \
(`github/{host}/app`), which this release does not use: every App is now one \
organisation's, with its own routes. Re-register the App: delete it on GitHub, \
start the bridge on a fresh store (an empty `data_dir`, or in VTA mode a fresh \
context), and register one App per organisation from the links it logs"
);
}
}
Ok(())
}
pub fn forgejo_secret(host: &str, what: &str) -> String {
format!("forgejo/{host}/{what}")
}
type Key = (String, Option<String>);
#[derive(Default)]
pub struct Adapters {
map: RwLock<BTreeMap<Key, Adapter>>,
vgi: RwLock<BTreeMap<Key, VgiConfig>>,
}
impl std::fmt::Debug for Adapters {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let keys: Vec<String> = self
.map
.read()
.expect("lock")
.keys()
.map(|(h, o)| match o {
Some(o) => format!("{h}/{o}"),
None => h.clone(),
})
.collect();
f.debug_struct("Adapters").field("adapters", &keys).finish()
}
}
impl Adapters {
pub fn new() -> Self {
Adapters::default()
}
pub fn insert(&self, adapter: Adapter, owner: Option<&str>, vgi: VgiConfig) {
let key = (
adapter.forge().host().to_string(),
owner.map(str::to_ascii_lowercase),
);
self.vgi.write().expect("lock").insert(key.clone(), vgi);
self.map.write().expect("lock").insert(key, adapter);
}
pub fn get(&self, host: &str) -> Option<Adapter> {
self.map
.read()
.expect("lock")
.get(&(host.to_string(), None))
.cloned()
}
pub fn get_github(&self, host: &str, owner: &str) -> Option<Adapter> {
self.map
.read()
.expect("lock")
.get(&(host.to_string(), Some(owner.to_ascii_lowercase())))
.cloned()
}
pub fn count_on(&self, host: &str) -> usize {
self.map
.read()
.expect("lock")
.keys()
.filter(|(h, _)| h == host)
.count()
}
fn key_of(&self, r: &Resource) -> Option<Key> {
let map = self.map.read().expect("lock");
[
(r.host().to_string(), None),
(r.host().to_string(), Some(r.owner().to_ascii_lowercase())),
]
.into_iter()
.find(|k| map.contains_key(k))
}
pub fn for_resource(&self, r: &Resource) -> Option<Adapter> {
let k = self.key_of(r)?;
self.map.read().expect("lock").get(&k).cloned()
}
pub fn vgi_for(&self, r: &Resource) -> Option<VgiConfig> {
let k = self.key_of(r)?;
self.vgi.read().expect("lock").get(&k).cloned()
}
pub fn all(&self) -> Vec<Adapter> {
self.map.read().expect("lock").values().cloned().collect()
}
}
pub fn vgi_config(cfg: &BridgeConfig, keyring: Option<Vec<u8>>) -> VgiConfig {
let v = &cfg.verify_trust;
let mut out = VgiConfig::new(
cfg.trust_registry_did.clone(),
cfg.vtc_did.clone(),
v.action.clone(),
v.version.clone(),
);
out.required_check = v.required_check.clone();
out = out
.with_verify_trust_transport(v.transport)
.with_required_approvals(cfg.required_approvals);
if let Some(sha) = &v.sha256 {
out = out.with_verify_trust_sha256(sha.clone());
}
if let Some(k) = keyring {
out = out.with_platform_keyring(k);
}
out
}
pub fn read_keyring(path: &Path) -> Result<Vec<u8>> {
std::fs::read(path).with_context(|| format!("reading the platform keyring {}", path.display()))
}
#[cfg(feature = "forge-github")]
pub fn build_github(
store: &Store,
g: &GitHubForgeConfig,
) -> Result<Option<Arc<vgi_forge_github::GitHubForge>>> {
use vgi_forge_github::{GitHubConfig, GitHubForge, InProcessKey, Secret};
let Some(bytes) = store.get_secret(&github_app_secret(&g.host, &g.app_owner))? else {
return Ok(None);
};
let app: StoredApp = serde_json::from_slice(&bytes).context("decoding the sealed App")?;
let owner = &app.owner;
if !owner.eq_ignore_ascii_case(&g.app_owner) {
anyhow::bail!(
"the App stored for `{}` on `{}` is owned by `{owner}`: refusing it",
g.app_owner,
g.host
);
}
let mut cfg = if g.host == "github.com" {
GitHubConfig::github_com(app.app_id, app.client_id.clone(), app.slug.clone())
} else {
GitHubConfig::enterprise(&g.host, app.app_id, app.client_id.clone(), app.slug.clone())?
};
if let (Some(api), Some(web)) = (&g.api_base, &g.web_base) {
cfg = cfg.with_endpoints(api.clone(), web.clone());
}
if g.bridge_checks {
cfg = cfg.with_bridge_checks();
}
let pem = Zeroizing::new(app.pem.clone());
let signer = Arc::new(InProcessKey::from_pem(&pem)?);
let forge = GitHubForge::new(cfg, signer, Secret::new(app.webhook_secret.clone()))?
.with_client_secret(Secret::new(app.client_secret.clone()));
Ok(Some(Arc::new(forge)))
}
#[cfg(feature = "forge-github")]
pub(crate) fn app_in_service_line(slug: &str, owner: &str, host: &str) -> String {
format!("the GitHub App `{slug}` for `{owner}` on {host} is in service")
}
#[cfg(feature = "forge-forgejo")]
pub async fn build_forgejo(
cfg: &BridgeConfig,
store: &Store,
f: &ForgejoForgeConfig,
) -> Result<Option<Arc<vgi_forge_forgejo::ForgejoForge>>> {
use vgi_forge_forgejo::{Credentials, ForgejoConfig, ForgejoForge, MergeFallback, Secret};
let host = f.host()?;
let secret = |what: &str| -> Result<Option<Secret>> {
Ok(store
.get_secret_string(&forgejo_secret(&host, what))?
.map(|s| Secret::new(s.as_str())))
};
let Some(token) = secret("bot-token")? else {
return Ok(None);
};
let oauth = secret("oauth-client-secret")?
.context("the Forgejo OAuth client secret is not stored (`vgi-bridge secret set`)")?;
let webhook = secret("webhook-secret")?
.context("the Forgejo webhook secret is not stored (`vgi-bridge secret set`)")?;
let mut fc = ForgejoConfig::new(
f.base_url.clone(),
f.bot_login.clone(),
f.oauth_client_id.clone(),
cfg.url(&format!("forgejo/{host}/bind")),
cfg.url(&format!("forgejo/{host}/link")),
)?
.with_webhook_url(cfg.url(&format!("forgejo/{host}/webhook")));
if f.instance_signing_key_fallback {
fc = fc.with_merge_fallback(MergeFallback::InstanceSigningKey);
}
if let Some(ctx) = &f.status_check_context {
fc = fc.with_status_check_context(ctx.clone());
}
if let Some(label) = &f.runs_on {
fc = fc.with_runs_on(label.clone())?;
}
let mut creds = Credentials::new(token, oauth, webhook);
if let Some(pw) = secret("bot-password")? {
creds = creds.with_bot_password(pw);
}
Ok(Some(Arc::new(ForgejoForge::connect(fc, creds).await?)))
}