use std::collections::BTreeMap;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use url::Url;
use vgi_forge::{DEFAULT_REQUIRED_CHECK, ForgeRole, Resource, RoleMap, VerifyTransport};
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct BridgeConfig {
pub vtc_did: String,
#[serde(default)]
pub event_version: EventVersion,
pub trust_registry_did: String,
pub mediator_did: String,
pub public_url: Url,
#[serde(default = "default_listen")]
pub listen: SocketAddr,
#[serde(default = "default_data_dir")]
pub data_dir: PathBuf,
#[serde(default)]
pub master_key_file: Option<PathBuf>,
#[serde(default)]
pub master_key_env: Option<String>,
#[serde(default)]
pub vta: Option<VtaConfig>,
#[serde(default = "default_max_age")]
pub max_job_age_secs: u64,
#[serde(default = "default_body_limit")]
pub max_body_bytes: usize,
#[serde(default = "default_sweep")]
pub drift_sweep_secs: u64,
#[serde(default = "default_resend")]
pub resend_secs: u64,
#[serde(default = "default_did_cache_ttl")]
pub did_cache_ttl_secs: u64,
#[serde(default = "default_flow_ttl")]
pub flow_ttl_secs: u64,
pub verify_trust: VerifyTrustConfig,
#[serde(default)]
pub checks: CheckConfig,
#[serde(default)]
pub resign: ResignConfig,
#[serde(default)]
pub role_map: RoleMapConfig,
#[serde(default)]
pub github: Vec<GitHubForgeConfig>,
#[serde(default)]
pub forgejo: Vec<ForgejoForgeConfig>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct VtaConfig {
pub context: String,
#[serde(default)]
pub credential_file: Option<PathBuf>,
#[serde(default)]
pub credential_env: Option<String>,
#[serde(default)]
pub mediator_did: Option<String>,
#[serde(default)]
pub url: Option<Url>,
#[serde(default)]
pub did: Option<String>,
#[serde(default = "default_vta_start_timeout")]
pub start_timeout_secs: u64,
#[serde(default = "default_key_refresh")]
pub key_refresh_secs: u64,
#[serde(default = "default_signing_switch_after")]
pub signing_switch_after_secs: u64,
}
fn default_signing_switch_after() -> u64 {
86_400
}
fn default_key_refresh() -> u64 {
60
}
fn default_vta_start_timeout() -> u64 {
300
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
#[non_exhaustive]
pub enum EventVersion {
#[serde(rename = "0.1")]
V0_1,
#[serde(rename = "0.2")]
V0_2,
#[default]
#[serde(rename = "0.3")]
V0_3,
#[serde(rename = "0.4")]
V0_4,
}
impl EventVersion {
pub fn reports_role_map(self) -> bool {
self >= EventVersion::V0_3
}
pub fn reports_pull_requests(self) -> bool {
self >= EventVersion::V0_4
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct VerifyTrustConfig {
pub action: String,
pub version: String,
#[serde(default)]
pub sha256: Option<String>,
#[serde(default = "default_check")]
pub required_check: String,
#[serde(default)]
pub transport: VerifyTransport,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct CheckConfig {
#[serde(default = "default_max_commits")]
pub max_commits: usize,
#[serde(default = "default_max_signers")]
pub max_signers: usize,
#[serde(default = "default_git")]
pub git: PathBuf,
#[serde(default = "default_fetch_timeout")]
pub fetch_timeout_secs: u64,
#[serde(default = "default_max_fetch_bytes")]
pub max_fetch_bytes: u64,
#[serde(default = "default_concurrency")]
pub concurrency: usize,
}
impl Default for CheckConfig {
fn default() -> Self {
CheckConfig {
max_commits: default_max_commits(),
max_signers: default_max_signers(),
git: default_git(),
fetch_timeout_secs: default_fetch_timeout(),
max_fetch_bytes: default_max_fetch_bytes(),
concurrency: default_concurrency(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct ResignConfig {
#[serde(default = "default_committer_name")]
pub committer_name: String,
#[serde(default = "default_committer_email")]
pub committer_email: String,
}
impl Default for ResignConfig {
fn default() -> Self {
ResignConfig {
committer_name: default_committer_name(),
committer_email: default_committer_email(),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct RoleMapConfig {
#[serde(default)]
pub own: Option<ForgeRole>,
#[serde(default)]
pub maintain: Option<ForgeRole>,
#[serde(default)]
pub commit: Option<ForgeRole>,
}
impl RoleMapConfig {
fn over(self, base: RoleMapConfig) -> RoleMapConfig {
RoleMapConfig {
own: self.own.or(base.own),
maintain: self.maintain.or(base.maintain),
commit: self.commit.or(base.commit),
}
}
fn resolve(layers: &[Option<RoleMapConfig>]) -> Result<RoleMap> {
let merged = layers
.iter()
.flatten()
.fold(RoleMapConfig::default(), |acc, l| l.over(acc));
let d = RoleMap::default();
RoleMap::new(
merged.own.unwrap_or(d.own()),
merged.maintain.unwrap_or(d.maintain()),
merged.commit.unwrap_or(d.commit()),
)
.map_err(|e| anyhow::anyhow!(e))
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct RepoConfig {
#[serde(default)]
pub role_map: Option<RoleMapConfig>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct GitHubNamespaceConfig {
#[serde(default = "yes")]
pub resign_dependabot: bool,
#[serde(default)]
pub role_map: Option<RoleMapConfig>,
#[serde(default)]
pub repos: BTreeMap<String, RepoConfig>,
}
impl Default for GitHubNamespaceConfig {
fn default() -> Self {
GitHubNamespaceConfig {
resign_dependabot: true,
role_map: None,
repos: BTreeMap::new(),
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct ForgejoNamespaceConfig {
#[serde(default)]
pub role_map: Option<RoleMapConfig>,
#[serde(default)]
pub repos: BTreeMap<String, RepoConfig>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct GitHubForgeConfig {
#[serde(default = "default_github_host")]
pub host: String,
pub app_name: String,
pub app_owner: String,
#[serde(default)]
pub app_owner_is_user: bool,
#[serde(default)]
pub platform_keyring_file: Option<PathBuf>,
#[serde(default = "yes")]
pub bridge_checks: bool,
#[serde(default = "default_dependabot_login")]
pub dependabot_login: String,
#[serde(default = "default_dependabot_id")]
pub dependabot_id: u64,
#[serde(default)]
pub role_map: Option<RoleMapConfig>,
#[serde(default)]
pub namespaces: BTreeMap<String, GitHubNamespaceConfig>,
#[serde(default)]
pub api_base: Option<Url>,
#[serde(default)]
pub web_base: Option<Url>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct ForgejoForgeConfig {
pub base_url: Url,
pub bot_login: String,
pub oauth_client_id: String,
#[serde(default)]
pub rotate_token_days: Option<u64>,
#[serde(default)]
pub instance_signing_key_fallback: bool,
#[serde(default)]
pub status_check_context: Option<String>,
#[serde(default)]
pub runs_on: Option<String>,
#[serde(default)]
pub role_map: Option<RoleMapConfig>,
#[serde(default)]
pub namespaces: BTreeMap<String, ForgejoNamespaceConfig>,
}
impl ForgejoForgeConfig {
pub fn host(&self) -> Result<String> {
Ok(self
.base_url
.host_str()
.context("Forgejo base_url has no host")?
.to_ascii_lowercase())
}
}
fn default_listen() -> SocketAddr {
"0.0.0.0:8080".parse().expect("static")
}
fn default_data_dir() -> PathBuf {
PathBuf::from("/var/lib/vgi-bridge")
}
fn default_max_age() -> u64 {
300
}
fn default_body_limit() -> usize {
2 * 1024 * 1024
}
fn default_sweep() -> u64 {
3600
}
fn default_resend() -> u64 {
60
}
fn default_did_cache_ttl() -> u64 {
60
}
fn default_flow_ttl() -> u64 {
900
}
fn default_check() -> String {
DEFAULT_REQUIRED_CHECK.into()
}
fn default_max_commits() -> usize {
250
}
fn default_max_signers() -> usize {
16
}
fn default_git() -> PathBuf {
PathBuf::from("git")
}
fn default_max_fetch_bytes() -> u64 {
64 * 1024 * 1024
}
fn default_fetch_timeout() -> u64 {
120
}
fn default_concurrency() -> usize {
4
}
fn default_github_host() -> String {
"github.com".into()
}
fn default_dependabot_login() -> String {
"dependabot[bot]".into()
}
fn default_dependabot_id() -> u64 {
49_699_333
}
fn default_committer_name() -> String {
"VGI bridge".into()
}
fn default_committer_email() -> String {
"vgi-bridge@noreply.invalid".into()
}
fn url_segment_ok(login: &str) -> bool {
!login.is_empty()
&& login.len() <= 39
&& login
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
&& !login.starts_with('-')
}
impl GitHubForgeConfig {
pub fn owner_key(&self) -> String {
self.app_owner.to_ascii_lowercase()
}
pub fn resign_dependabot(&self, owner: &str) -> bool {
self.namespaces
.get(&owner.to_ascii_lowercase())
.is_none_or(|n| n.resign_dependabot)
}
}
type NsLayers<'a> = (Option<RoleMapConfig>, &'a BTreeMap<String, RepoConfig>);
impl BridgeConfig {
pub fn github_for(&self, host: &str, owner: &str) -> Option<&GitHubForgeConfig> {
self.github
.iter()
.find(|g| g.host == host && g.app_owner.eq_ignore_ascii_case(owner))
}
pub fn github_on(&self, host: &str) -> impl Iterator<Item = &GitHubForgeConfig> {
self.github.iter().filter(move |g| g.host == host)
}
pub fn role_map(&self, repo: &Resource) -> RoleMap {
let host = repo.host();
let owner = repo.owner().to_ascii_lowercase();
let name = repo.repo_name().map(str::to_ascii_lowercase);
let (forge, ns): (Option<RoleMapConfig>, Option<NsLayers<'_>>) =
if let Some(g) = self.github_for(host, &owner) {
(
g.role_map,
g.namespaces.get(&owner).map(|n| (n.role_map, &n.repos)),
)
} else if let Some(f) = self
.forgejo
.iter()
.find(|f| f.host().is_ok_and(|h| h == host))
{
(
f.role_map,
f.namespaces.get(&owner).map(|n| (n.role_map, &n.repos)),
)
} else {
(None, None)
};
let ns_layer = ns.and_then(|(l, _)| l);
let repo_layer = ns
.zip(name)
.and_then(|((_, repos), n)| repos.get(&n))
.and_then(|r| r.role_map);
RoleMapConfig::resolve(&[Some(self.role_map), forge, ns_layer, repo_layer])
.unwrap_or_default()
}
pub fn role_map_repos(&self, ns: &Resource) -> Vec<Resource> {
let host = ns.host();
let owner = ns.owner().to_ascii_lowercase();
let repos: Option<&BTreeMap<String, RepoConfig>> =
if let Some(g) = self.github_for(host, &owner) {
g.namespaces.get(&owner).map(|n| &n.repos)
} else if let Some(f) = self
.forgejo
.iter()
.find(|f| f.host().is_ok_and(|h| h == host))
{
f.namespaces.get(&owner).map(|n| &n.repos)
} else {
None
};
repos
.into_iter()
.flatten()
.filter(|(_, r)| r.role_map.is_some())
.filter_map(|(name, _)| ns.namespace().join(name).ok())
.collect()
}
fn validate_role_maps(&self) -> Result<()> {
let base = Some(self.role_map);
RoleMapConfig::resolve(&[base]).context("`role_map`")?;
let check = |what: String,
forge: Option<RoleMapConfig>,
namespaces: Vec<(&String, NsLayers<'_>)>|
-> Result<()> {
RoleMapConfig::resolve(&[base, forge]).with_context(|| format!("`{what}.role_map`"))?;
for (owner, (ns, repos)) in namespaces {
if *owner != owner.to_ascii_lowercase() {
bail!("`{what}.namespaces` keys are lowercase owner logins; got `{owner}`");
}
RoleMapConfig::resolve(&[base, forge, ns])
.with_context(|| format!("`{what}.namespaces.{owner}.role_map`"))?;
for (name, repo) in repos {
if *name != name.to_ascii_lowercase() {
bail!(
"`{what}.namespaces.{owner}.repos` keys are lowercase repository \
names; got `{name}`"
);
}
RoleMapConfig::resolve(&[base, forge, ns, repo.role_map]).with_context(
|| format!("`{what}.namespaces.{owner}.repos.{name}.role_map`"),
)?;
}
}
Ok(())
};
for g in &self.github {
check(
format!("github ({}, {})", g.host, g.app_owner),
g.role_map,
g.namespaces
.iter()
.map(|(k, n)| (k, (n.role_map, &n.repos)))
.collect(),
)?;
}
for f in &self.forgejo {
check(
format!("forgejo ({})", f.host()?),
f.role_map,
f.namespaces
.iter()
.map(|(k, n)| (k, (n.role_map, &n.repos)))
.collect(),
)?;
}
Ok(())
}
}
fn check_runs_on(label: &str) -> Result<()> {
#[cfg(feature = "forge-forgejo")]
vgi_forge_forgejo::plan::check_runs_on(label)?;
#[cfg(not(feature = "forge-forgejo"))]
let _ = label;
Ok(())
}
fn check_ident(what: &str, v: &str) -> Result<()> {
if v.trim().is_empty()
|| v.chars()
.any(|c| matches!(c, '<' | '>' | '\n' | '\r' | '\0'))
{
bail!("`{what}` must be non-empty and hold no `<`, `>` or line break; got `{v}`");
}
Ok(())
}
fn yes() -> bool {
true
}
impl BridgeConfig {
pub fn parse(text: &str) -> Result<Self> {
let mut cfg: BridgeConfig = toml::from_str(text).context("parsing the bridge config")?;
cfg.fill_defaults();
cfg.validate()?;
Ok(cfg)
}
pub fn load(path: &Path) -> Result<Self> {
let text =
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
let mut cfg: BridgeConfig =
toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
cfg.apply_env()?;
cfg.fill_defaults();
cfg.validate()?;
Ok(cfg)
}
fn fill_defaults(&mut self) {
if let Some(v) = self.vta.as_mut()
&& v.mediator_did.is_none()
{
v.mediator_did = Some(self.mediator_did.clone());
}
}
fn apply_env(&mut self) -> Result<()> {
if let Ok(v) = std::env::var("VGI_BRIDGE_LISTEN") {
self.listen = v.parse().context("VGI_BRIDGE_LISTEN")?;
}
if let Ok(v) = std::env::var("VGI_BRIDGE_DATA_DIR") {
self.data_dir = v.into();
}
if let Ok(v) = std::env::var("VGI_BRIDGE_PUBLIC_URL") {
self.public_url = v.parse().context("VGI_BRIDGE_PUBLIC_URL")?;
}
if let Ok(v) = std::env::var("VGI_BRIDGE_MASTER_KEY_FILE") {
self.master_key_file = Some(v.into());
}
if let Ok(v) = std::env::var("VGI_BRIDGE_VTA_CREDENTIAL_FILE")
&& let Some(vta) = self.vta.as_mut()
{
vta.credential_file = Some(v.into());
}
Ok(())
}
pub fn listen_warning(&self) -> Option<String> {
if self.listen.ip().is_loopback() {
return None;
}
Some(format!(
"the bridge serves plain HTTP on {}, which is reachable from other hosts: only the \
TLS proxy in front of it may reach this port (in a container, publish it to the \
proxy's network only). Set `listen` to a loopback address when the proxy runs on \
this host",
self.listen
))
}
pub fn validate(&self) -> Result<()> {
for (what, did) in [
("vtc_did", &self.vtc_did),
("trust_registry_did", &self.trust_registry_did),
("mediator_did", &self.mediator_did),
] {
if !did.starts_with("did:") || did.chars().any(char::is_whitespace) {
bail!("`{what}` must be a DID, got `{did}`");
}
}
match self.public_url.scheme() {
"https" => {}
"http"
if matches!(
self.public_url.host_str(),
Some("localhost" | "127.0.0.1" | "[::1]")
) => {}
s => bail!(
"`public_url` must be https (the forges send secrets-bearing redirects and \
webhooks to it); got `{s}`"
),
}
if let Some(v) = &self.vta {
if self.master_key_file.is_some() || self.master_key_env.is_some() {
bail!(
"`[vta]` and a master key are both set: in VTA mode nothing is sealed into the \
local store (secrets live in the VTA), so remove `master_key_file` / \
`master_key_env`"
);
}
if v.signing_switch_after_secs < self.did_cache_ttl_secs {
bail!(
"`vta.signing_switch_after_secs` ({}) must be at least `did_cache_ttl_secs` \
({}): a verifier's cached DID document must have the new key before the \
bridge signs with it",
v.signing_switch_after_secs,
self.did_cache_ttl_secs
);
}
if v.context.trim().is_empty() {
bail!("`vta.context` must name the bridge's context in the VTA");
}
if v.credential_file.is_none() && v.credential_env.is_none() {
bail!("`[vta]` needs `credential_file` or `credential_env`");
}
for (what, did) in [("vta.mediator_did", &v.mediator_did), ("vta.did", &v.did)] {
if let Some(did) = did
&& (!did.starts_with("did:") || did.chars().any(char::is_whitespace))
{
bail!("`{what}` must be a DID, got `{did}`");
}
}
if let Some(u) = &v.url
&& u.scheme() != "https"
&& !matches!(u.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"))
{
bail!("`vta.url` must be https (the credential handshake travels over it)");
}
}
if !(10..=3600).contains(&self.did_cache_ttl_secs) {
bail!("`did_cache_ttl_secs` must be between 10 and 3600");
}
if self.max_body_bytes < 64 * 1024 {
bail!("`max_body_bytes` below 64 KiB would refuse ordinary webhooks");
}
if self.checks.max_commits == 0 || self.checks.max_signers == 0 {
bail!("`checks.max_commits` and `checks.max_signers` must be at least 1");
}
check_ident("resign.committer_name", &self.resign.committer_name)?;
check_ident("resign.committer_email", &self.resign.committer_email)?;
if self.resign.committer_email.starts_with("did:") {
bail!(
"`resign.committer_email` must not be a DID: the re-signed commits claim the \
bridge's DID in their `Signed-by-DID:` trailer"
);
}
let mut apps = std::collections::BTreeSet::new();
let mut names = std::collections::BTreeSet::new();
for g in &self.github {
Resource::namespace_of(&g.host, &g.app_owner).map_err(|e| {
anyhow::anyhow!("github `{}` / app_owner `{}`: {e}", g.host, g.app_owner)
})?;
if !apps.insert((g.host.clone(), g.owner_key())) {
bail!(
"two `[[github]]` entries for `{}` on `{}`: one App per organisation (or \
account), each its own entry",
g.app_owner,
g.host
);
}
if !names.insert((g.host.clone(), g.app_name.to_ascii_lowercase())) {
bail!(
"two `[[github]]` entries on `{}` name the App `{}`: GitHub App names are unique \
per instance, so give each organisation's App its own `app_name`",
g.host,
g.app_name
);
}
if !url_segment_ok(&g.app_owner) {
bail!("`app_owner` `{}` is not a GitHub login", g.app_owner);
}
if g.dependabot_login.is_empty() || g.dependabot_id == 0 {
bail!("`dependabot_login` and `dependabot_id` must be set");
}
}
let github_hosts: std::collections::BTreeSet<&str> =
self.github.iter().map(|g| g.host.as_str()).collect();
let mut hosts = std::collections::BTreeSet::new();
for f in &self.forgejo {
let host = f.host()?;
if github_hosts.contains(host.as_str()) || !hosts.insert(host.clone()) {
bail!("forge host `{host}` is configured twice");
}
if let Some(label) = &f.runs_on {
check_runs_on(label).with_context(|| format!("forgejo ({host}) `runs_on`"))?;
}
}
self.validate_role_maps()
}
pub fn url(&self, path: &str) -> Url {
let mut base = self.public_url.clone();
if !base.path().ends_with('/') {
let p = format!("{}/", base.path());
base.set_path(&p);
}
base.join(path.trim_start_matches('/'))
.expect("relative path joins")
}
pub fn flow_ttl(&self) -> Duration {
Duration::from_secs(self.flow_ttl_secs)
}
pub fn store_path(&self) -> PathBuf {
self.data_dir.join("state.redb")
}
}
#[cfg(test)]
pub(crate) fn tests_example() -> String {
tests::EXAMPLE.replace(
"master_key_file = \"/run/secrets/vgi-bridge-master-key\"\n",
"",
)
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
pub(crate) const EXAMPLE: &str = r#"
vtc_did = "did:webvh:QmVtc:acme-vtc.example"
trust_registry_did = "did:webvh:QmReg:registry.acme.example"
mediator_did = "did:web:mediator.acme.example"
public_url = "https://bridge.acme.example/"
master_key_file = "/run/secrets/vgi-bridge-master-key"
[verify_trust]
action = "OpenVTC/verifiable-git-infrastructure/.github/actions/verify-trust@0123456789abcdef0123456789abcdef01234567"
version = "v0.5.0"
[[github]]
app_name = "acme-vgi-bridge"
app_owner = "acme"
platform_keyring_file = "/etc/vgi-bridge/web-flow.asc"
[[forgejo]]
base_url = "https://codeberg.org/"
bot_login = "acme-vgi-bot"
oauth_client_id = "0b6e3a0c"
"#;
#[test]
fn a_listener_beyond_loopback_is_warned_about() {
let c = BridgeConfig::parse(EXAMPLE).unwrap();
let warning = c
.listen_warning()
.expect("0.0.0.0 is reachable from other hosts");
assert!(warning.contains("0.0.0.0:8080"), "{warning}");
for listen in ["192.0.2.10:8080", "[::]:8080"] {
let c = BridgeConfig::parse(&format!("listen = \"{listen}\"\n{EXAMPLE}")).unwrap();
assert!(c.listen_warning().is_some(), "{listen}");
}
for listen in ["127.0.0.1:8080", "[::1]:8080"] {
let c = BridgeConfig::parse(&format!("listen = \"{listen}\"\n{EXAMPLE}")).unwrap();
assert_eq!(c.listen_warning(), None, "{listen}");
}
}
#[test]
fn the_example_parses_with_defaults() {
let c = BridgeConfig::parse(EXAMPLE).unwrap();
assert_eq!(c.github[0].host, "github.com");
assert!(c.github[0].bridge_checks, "on unless turned off");
assert_eq!(c.forgejo[0].host().unwrap(), "codeberg.org");
assert_eq!(c.verify_trust.required_check, "Verify commit trust");
assert_eq!(
c.url("github/github.com/webhook").as_str(),
"https://bridge.acme.example/github/github.com/webhook"
);
assert_eq!(c.checks.max_commits, 250);
assert_eq!(c.event_version, EventVersion::V0_3, "0.3 unless set");
}
#[test]
fn the_workflow_transport_defaults_to_auto_and_is_carried_into_the_plan() {
let c = BridgeConfig::parse(EXAMPLE).unwrap();
assert_eq!(c.verify_trust.transport, VerifyTransport::Auto);
let pinned = EXAMPLE.replace(
"version = \"v0.5.0\"\n",
"version = \"v0.5.0\"\ntransport = \"https\"\n",
);
let c = BridgeConfig::parse(&pinned).unwrap();
assert_eq!(c.verify_trust.transport, VerifyTransport::Https);
assert_eq!(
crate::registry::vgi_config(&c, None).verify_trust_transport,
VerifyTransport::Https,
"the written workflows carry the pin"
);
let bogus = EXAMPLE.replace(
"version = \"v0.5.0\"\n",
"version = \"v0.5.0\"\ntransport = \"carrier-pigeon\"\n",
);
assert!(BridgeConfig::parse(&bogus).is_err());
let check = EXAMPLE.replace(
"version = \"v0.5.0\"\n",
"version = \"v0.5.0\"\n\n[checks]\ntransport = \"didcomm\"\n",
);
assert!(BridgeConfig::parse(&check).is_err());
}
#[test]
fn tsp_is_taken_while_the_bridge_posts_checks() {
let tsp = EXAMPLE.replace(
"version = \"v0.5.0\"\n",
"version = \"v0.5.0\"\ntransport = \"tsp\"\n",
);
let c = BridgeConfig::parse(&tsp).unwrap();
assert_eq!(c.verify_trust.transport, VerifyTransport::Tsp);
assert!(c.github.iter().any(|g| g.bridge_checks));
}
#[test]
fn the_event_version_is_0_1_to_0_4() {
let with = |v: &str| {
BridgeConfig::parse(&EXAMPLE.replacen(
"public_url",
&format!("event_version = \"{v}\"\npublic_url"),
1,
))
};
assert_eq!(with("0.1").unwrap().event_version, EventVersion::V0_1);
assert_eq!(with("0.2").unwrap().event_version, EventVersion::V0_2);
assert_eq!(with("0.3").unwrap().event_version, EventVersion::V0_3);
assert_eq!(with("0.4").unwrap().event_version, EventVersion::V0_4);
assert!(!EventVersion::V0_3.reports_pull_requests());
assert!(EventVersion::V0_4.reports_pull_requests());
assert!(EventVersion::V0_4.reports_role_map());
assert!(!EventVersion::V0_2.reports_role_map());
assert!(EventVersion::V0_3.reports_role_map());
for bad in ["0.5", "1.0", "", "v0.2"] {
assert!(with(bad).is_err(), "`{bad}` is refused");
}
}
#[test]
fn the_app_owner_is_required_and_the_keyring_is_not() {
let no_owner = EXAMPLE.replace("app_owner = \"acme\"\n", "");
assert!(BridgeConfig::parse(&no_owner).is_err());
let no_keyring = EXAMPLE.replace(
"platform_keyring_file = \"/etc/vgi-bridge/web-flow.asc\"\n",
"",
);
let c = BridgeConfig::parse(&no_keyring).unwrap();
assert!(c.github[0].platform_keyring_file.is_none());
}
#[test]
fn vta_mode_parses_and_refuses_a_master_key_beside_it() {
let base = EXAMPLE.replace(
"master_key_file = \"/run/secrets/vgi-bridge-master-key\"\n",
"",
);
let with = |vta: &str| BridgeConfig::parse(&format!("{base}\n[vta]\n{vta}"));
let c = with("context = \"vgi-bridge\"\ncredential_file = \"/run/secrets/c\"").unwrap();
let v = c.vta.clone().unwrap();
assert_eq!(v.context, "vgi-bridge");
assert_eq!(v.key_refresh_secs, 60);
assert_eq!(c.did_cache_ttl_secs, 60);
assert_eq!(
v.mediator_did.as_deref(),
Some("did:web:mediator.acme.example"),
"the VTA is reached through the bridge's mediator by default"
);
assert!(
with("context = \"vgi-bridge\"").is_err(),
"a credential source is required"
);
assert!(with("context = \"\"\ncredential_env = \"X\"").is_err());
assert!(
with("context = \"b\"\ncredential_env = \"X\"\nurl = \"http://vta.example\"").is_err(),
"cleartext only to loopback"
);
assert!(
with("context = \"b\"\ncredential_env = \"X\"\nmediator_did = \"mediator\"").is_err()
);
let err = BridgeConfig::parse(&format!(
"{EXAMPLE}\n[vta]\ncontext = \"b\"\ncredential_env = \"X\""
))
.unwrap_err();
assert!(err.to_string().contains("master key"), "{err}");
assert!(BridgeConfig::parse(&format!("did_cache_ttl_secs = 5\n{EXAMPLE}")).is_err());
}
#[test]
fn several_apps_on_one_host_are_keyed_by_owner() {
let second = |owner: &str, name: &str| {
format!("{EXAMPLE}\n[[github]]\napp_name = \"{name}\"\napp_owner = \"{owner}\"\n")
};
let c = BridgeConfig::parse(&second("globex", "globex-vgi-bridge")).unwrap();
assert_eq!(
c.github_for("github.com", "ACME").unwrap().app_owner,
"acme"
);
assert_eq!(
c.github_for("github.com", "globex").unwrap().app_name,
"globex-vgi-bridge"
);
assert!(
c.github_for("github.com", "initech").is_none(),
"no fallback with several"
);
assert!(c.github_for("ghe.example", "acme").is_none());
assert!(BridgeConfig::parse(&second("Acme", "other-name")).is_err());
assert!(BridgeConfig::parse(&second("globex", "ACME-vgi-bridge")).is_err());
assert!(BridgeConfig::parse(&second("glo/bex", "x")).is_err());
let one = BridgeConfig::parse(EXAMPLE).unwrap();
assert!(one.github_for("github.com", "initech").is_none());
}
#[test]
fn role_map_repos_are_the_owners_own() {
let text = format!(
"{EXAMPLE}\n[[github]]\napp_name = \"globex-vgi-bridge\"\napp_owner = \"globex\"\n\n\
[github.namespaces.globex.repos.w.role_map]\ncommit = \"write\"\n"
);
let c = BridgeConfig::parse(&text).unwrap();
let listed = |ns: &str| -> Vec<String> {
c.role_map_repos(&res(ns))
.into_iter()
.map(|r| r.to_string())
.collect()
};
assert_eq!(listed("github.com/globex"), vec!["github.com/globex/w"]);
assert!(listed("github.com/acme").is_empty());
}
#[test]
fn the_shipped_example_parses() {
let c = BridgeConfig::parse(include_str!("../bridge.example.toml")).unwrap();
assert_eq!(c.github.len(), 1);
assert!(c.forgejo.is_empty());
}
#[test]
fn the_dependabot_resign_is_on_by_default_and_off_per_namespace() {
let c = BridgeConfig::parse(EXAMPLE).unwrap();
let g = &c.github[0];
assert_eq!(g.dependabot_login, "dependabot[bot]");
assert_eq!(g.dependabot_id, 49_699_333);
assert!(g.resign_dependabot("acme"));
assert_eq!(c.resign.committer_name, "VGI bridge");
let off = EXAMPLE.replace(
"[[forgejo]]",
"[github.namespaces.acme]\nresign_dependabot = false\n\n[[forgejo]]",
);
let c = BridgeConfig::parse(&off).unwrap();
assert!(!c.github[0].resign_dependabot("Acme"));
assert!(c.github[0].resign_dependabot("other"));
let upper = off.replace("namespaces.acme]", "namespaces.Acme]");
assert!(BridgeConfig::parse(&upper).is_err());
for bad in ["\"a<b\"", "\"did:key:z6Mk\"", "\"\""] {
let t = format!("{EXAMPLE}\n[resign]\ncommitter_email = {bad}\n");
assert!(BridgeConfig::parse(&t).is_err(), "{bad}");
}
}
#[test]
fn the_forgejo_runner_label_defaults_and_is_checked() {
let c = BridgeConfig::parse(EXAMPLE).unwrap();
assert_eq!(c.forgejo[0].runs_on, None, "the adapter's default");
let with = |label: &str| {
BridgeConfig::parse(&EXAMPLE.replace(
"oauth_client_id = \"0b6e3a0c\"",
&format!("oauth_client_id = \"0b6e3a0c\"\nruns_on = {label}"),
))
};
assert_eq!(
with("\"ubuntu-24.04\"").unwrap().forgejo[0]
.runs_on
.as_deref(),
Some("ubuntu-24.04")
);
for bad in ["\"\"", "\"a b\"", "\"x\\nevil: 1\""] {
assert!(with(bad).is_err(), "{bad}");
}
}
fn res(s: &str) -> Resource {
Resource::parse(s).unwrap()
}
#[test]
fn the_role_map_defaults_to_the_design_projection() {
let c = BridgeConfig::parse(EXAMPLE).unwrap();
for r in [
"github.com/acme/widgets",
"codeberg.org/acme/w",
"elsewhere.org/x/y",
] {
assert_eq!(c.role_map(&res(r)), RoleMap::default(), "{r}");
}
}
#[test]
fn role_map_layers_apply_most_specific_first() {
let text = format!(
"{}\n{}",
EXAMPLE.replace("[[github]]", "[role_map]\ncommit = \"read\"\n\n[[github]]"),
r#"
[forgejo.role_map]
maintain = "write"
[forgejo.namespaces.acme.role_map]
maintain = "maintain"
[forgejo.namespaces.acme.repos.widgets.role_map]
commit = "write"
"#
);
let c = BridgeConfig::parse(&text).unwrap();
use ForgeRole::*;
let got = |r| {
let m = c.role_map(&res(r));
(m.own(), m.maintain(), m.commit())
};
assert_eq!(got("github.com/acme/widgets"), (Admin, Maintain, Read));
assert_eq!(got("codeberg.org/other/widgets"), (Admin, Write, Read));
assert_eq!(got("codeberg.org/acme/gadgets"), (Admin, Maintain, Read));
assert_eq!(got("codeberg.org/Acme/Widgets"), (Admin, Maintain, Write));
assert_eq!(
c.role_map_repos(&res("codeberg.org/acme")),
vec![res("codeberg.org/acme/widgets")]
);
assert!(c.role_map_repos(&res("codeberg.org/other")).is_empty());
assert!(c.role_map_repos(&res("github.com/acme")).is_empty());
}
#[test]
fn a_role_map_cannot_name_a_namespace_admin_or_be_out_of_order() {
let with = |extra: &str| BridgeConfig::parse(&format!("{EXAMPLE}\n{extra}"));
for bad in [
"[role_map]\nns_admin = \"admin\"",
"[role_map]\nadmin = \"admin\"",
"[forgejo.namespaces.acme.role_map]\nnsAdmin = \"admin\"",
"[role_map]\nown = \"write\"",
"[role_map]\ncommit = \"maintain\"",
"[forgejo.role_map]\nown = \"write\"\n\
[forgejo.namespaces.acme.role_map]\nmaintain = \"maintain\"",
"[forgejo.namespaces.acme.repos.w.role_map]\ncommit = \"triage\"\nmaintain = \"read\"",
"[forgejo.namespaces.Acme.role_map]\nmaintain = \"write\"",
"[forgejo.namespaces.acme.repos.W.role_map]\ncommit = \"write\"",
"[role_map]\nmaintain = \"superuser\"",
] {
assert!(with(bad).is_err(), "{bad}");
}
assert!(with("[role_map]\nmaintain = \"write\"\ncommit = \"write\"").is_ok());
}
#[test]
fn only_an_owner_may_map_to_admin_at_any_layer() {
let with = |extra: &str| BridgeConfig::parse(&format!("{EXAMPLE}\n{extra}"));
for (bad, layer) in [
("[role_map]\nmaintain = \"admin\"", "`role_map`"),
(
"[role_map]\nmaintain = \"admin\"\ncommit = \"write\"",
"`role_map`",
),
("[forgejo.role_map]\nmaintain = \"admin\"", "forgejo"),
("[forgejo.role_map]\ncommit = \"admin\"", "forgejo"),
(
"[forgejo.namespaces.acme.role_map]\nmaintain = \"admin\"",
"acme",
),
(
"[forgejo.namespaces.acme.repos.w.role_map]\nmaintain = \"admin\"",
"acme",
),
] {
let err = format!("{:#}", with(bad).unwrap_err());
assert!(err.contains("only an owner"), "{bad}: {err}");
assert!(err.contains(layer), "{bad} names its layer: {err}");
}
assert!(with("[role_map]\nown = \"admin\"").is_ok());
assert!(with("[role_map]\nown = \"write\"\nmaintain = \"write\"").is_ok());
}
#[test]
fn typos_and_unsafe_values_fail_the_start() {
let typo = EXAMPLE.replace("mediator_did", "mediatr_did");
assert!(BridgeConfig::parse(&typo).is_err());
let http = EXAMPLE.replace("https://bridge", "http://bridge");
assert!(BridgeConfig::parse(&http).is_err());
let not_did = EXAMPLE.replace("did:webvh:QmVtc:acme-vtc.example", "acme");
assert!(BridgeConfig::parse(¬_did).is_err());
let twice = format!("{EXAMPLE}\n[[github]]\napp_name = \"x\"\napp_owner = \"acme\"\n");
assert!(BridgeConfig::parse(&twice).is_err());
}
}