use std::fmt;
use std::time::Duration;
use url::Url;
use vgi_forge::{ForgeError, Result};
use crate::secret::Secret;
pub const DEFAULT_CHECKOUT_ACTION: &str =
"https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262";
pub const DEFAULT_ACTIONS_BASE: &str = "https://github.com";
pub const DEFAULT_RUNS_ON: &str = "docker";
pub const DEFAULT_TEAM: &str = "vgi-bridge";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum MergeFallback {
#[default]
Fail,
InstanceSigningKey,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ForgejoConfig {
pub host: String,
pub base_url: Url,
pub bot_login: String,
pub oauth_client_id: String,
pub bind_redirect_uri: Url,
pub link_redirect_uri: Url,
pub oauth_scope: Option<String>,
pub webhook_url: Option<Url>,
pub team_name: String,
pub checkout_action: String,
pub actions_base: Url,
pub runs_on: String,
pub status_check_context: Option<String>,
pub merge_fallback: MergeFallback,
pub use_actions_variables: bool,
pub request_timeout: Duration,
pub link_state_ttl: Duration,
}
impl ForgejoConfig {
pub fn new(
base_url: Url,
bot_login: impl Into<String>,
oauth_client_id: impl Into<String>,
bind_redirect_uri: Url,
link_redirect_uri: Url,
) -> Result<Self> {
let host = base_url
.host_str()
.ok_or_else(|| ForgeError::Config(format!("instance URL `{base_url}` has no host")))?
.to_ascii_lowercase();
let loopback = matches!(host.as_str(), "localhost" | "127.0.0.1" | "[::1]");
match base_url.scheme() {
"https" => {}
"http" if loopback => {}
s => {
return Err(ForgeError::Config(format!(
"instance URL `{base_url}`: `{s}` is not allowed (https, or http on loopback)"
)));
}
}
let mut base_url = base_url;
if !base_url.path().ends_with('/') {
let path = format!("{}/", base_url.path());
base_url.set_path(&path);
}
base_url.set_query(None);
base_url.set_fragment(None);
let bot_login = bot_login.into();
check_login(&bot_login)?;
Ok(ForgejoConfig {
host: host.trim_start_matches('[').trim_end_matches(']').into(),
base_url,
bot_login,
oauth_client_id: oauth_client_id.into(),
bind_redirect_uri,
link_redirect_uri,
oauth_scope: None,
webhook_url: None,
team_name: DEFAULT_TEAM.into(),
checkout_action: DEFAULT_CHECKOUT_ACTION.into(),
actions_base: Url::parse(DEFAULT_ACTIONS_BASE).expect("static URL"),
runs_on: DEFAULT_RUNS_ON.into(),
status_check_context: None,
merge_fallback: MergeFallback::Fail,
use_actions_variables: false,
request_timeout: Duration::from_secs(30),
link_state_ttl: Duration::from_secs(15 * 60),
})
}
pub fn with_host(mut self, host: impl Into<String>) -> Self {
self.host = host.into().to_ascii_lowercase();
self
}
pub fn with_webhook_url(mut self, url: Url) -> Self {
self.webhook_url = Some(url);
self
}
pub fn with_status_check_context(mut self, context: impl Into<String>) -> Self {
self.status_check_context = Some(context.into());
self
}
pub fn with_runs_on(mut self, label: impl Into<String>) -> Result<Self> {
let label = label.into();
crate::plan::check_runs_on(&label)?;
self.runs_on = label;
Ok(self)
}
pub fn with_merge_fallback(mut self, fallback: MergeFallback) -> Self {
self.merge_fallback = fallback;
self
}
pub fn with_actions_variables(mut self) -> Self {
self.use_actions_variables = true;
self
}
pub(crate) fn api_base(&self) -> Url {
self.base_url.join("api/v1").expect("relative join")
}
pub fn status_context(&self, check: &str) -> String {
self.status_check_context
.clone()
.unwrap_or_else(|| crate::plan::default_status_context(check))
}
}
#[non_exhaustive]
pub enum TokenRotation {
Manual,
WithPassword(Secret),
}
impl fmt::Debug for TokenRotation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TokenRotation::Manual => f.write_str("Manual"),
TokenRotation::WithPassword(_) => f.write_str("WithPassword(<redacted>)"),
}
}
}
#[non_exhaustive]
pub struct Credentials {
pub bot_token: Secret,
pub rotation: TokenRotation,
pub oauth_client_secret: Secret,
pub webhook_secret: Secret,
}
impl Credentials {
pub fn new(bot_token: Secret, oauth_client_secret: Secret, webhook_secret: Secret) -> Self {
Credentials {
bot_token,
rotation: TokenRotation::Manual,
oauth_client_secret,
webhook_secret,
}
}
pub fn with_bot_password(mut self, password: Secret) -> Self {
self.rotation = TokenRotation::WithPassword(password);
self
}
}
impl fmt::Debug for Credentials {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Credentials")
.field("bot_token", &self.bot_token)
.field("rotation", &self.rotation)
.field("oauth_client_secret", &self.oauth_client_secret)
.field("webhook_secret", &self.webhook_secret)
.finish()
}
}
pub(crate) fn check_login(login: &str) -> Result<()> {
let ok = !login.is_empty()
&& login.len() <= 40
&& login
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
&& !login.starts_with('.')
&& login != "..";
if ok {
Ok(())
} else {
Err(ForgeError::Protocol(format!(
"`{login}` is not a valid Forgejo login"
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn url(s: &str) -> Url {
Url::parse(s).unwrap()
}
#[test]
fn host_and_api_come_from_the_base_url() {
let c = ForgejoConfig::new(
url("https://Git.Example.org/forgejo"),
"acme-vgi-bot",
"cid",
url("https://bridge/bind"),
url("https://bridge/link"),
)
.unwrap();
assert_eq!(c.host, "git.example.org");
assert_eq!(
c.api_base().as_str(),
"https://git.example.org/forgejo/api/v1"
);
assert_eq!(
c.status_context("Verify commit trust"),
"Verify commit trust / Verify commit trust (pull_request)"
);
}
#[test]
fn the_runner_label_defaults_and_is_checked() {
let c = ForgejoConfig::new(
url("https://codeberg.org/"),
"bot",
"cid",
url("https://b/1"),
url("https://b/2"),
)
.unwrap();
assert_eq!(c.runs_on, DEFAULT_RUNS_ON);
assert_eq!(
c.clone().with_runs_on("ubuntu-24.04").unwrap().runs_on,
"ubuntu-24.04"
);
for bad in ["", "docker\nevil: 1", "a b", "${{ x }}"] {
assert!(c.clone().with_runs_on(bad).is_err(), "{bad:?}");
}
}
#[test]
fn plain_http_only_on_loopback() {
let new =
|u| ForgejoConfig::new(url(u), "bot", "cid", url("https://b/1"), url("https://b/2"));
assert!(new("http://git.example.org").is_err());
assert!(new("http://127.0.0.1:3000").is_ok());
assert!(new("http://localhost:3000").is_ok());
assert!(new("ftp://git.example.org").is_err());
}
#[test]
fn credentials_never_print() {
let c = Credentials::new(Secret::new("t0k"), Secret::new("cs"), Secret::new("wh"))
.with_bot_password(Secret::new("pw"));
let shown = format!("{c:?}");
for s in ["t0k", "cs", "wh", "pw"] {
assert!(!shown.contains(s), "{shown}");
}
}
}