vgi_forge_forgejo/config.rs
1//! Adapter configuration and credentials.
2
3use std::fmt;
4use std::time::Duration;
5
6use url::Url;
7use vgi_forge::{ForgeError, Result};
8
9use crate::secret::Secret;
10
11/// `actions/checkout` v4.4.0, by full URL and commit. Full URL because a
12/// Forgejo runner resolves a bare `owner/repo` against the instance's own
13/// default actions host; v4 because it runs on `node20`, which every
14/// forgejo-runner supports (v5+ needs `node24`).
15pub const DEFAULT_CHECKOUT_ACTION: &str =
16 "https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262";
17
18/// Where a bare `owner/repo[/path]@sha` action reference is resolved: the
19/// verify-trust action lives on GitHub.
20pub const DEFAULT_ACTIONS_BASE: &str = "https://github.com";
21
22/// The runner label the verify-trust job asks for.
23pub const DEFAULT_RUNS_ON: &str = "docker";
24
25/// The organisation team the bot is put in at bind.
26pub const DEFAULT_TEAM: &str = "vgi-bridge";
27
28/// What to do when the instance cannot restrict merges to fast-forward only
29/// (Forgejo before 7, Gitea before 1.22).
30///
31/// Fast-forward-only merges land the PR's own DID-signed commits unchanged,
32/// so no platform key is needed. Without it, every web merge makes a new
33/// commit that the check never saw, and the only way that commit passes a
34/// later verification is if the instance signs it and its key is exempted.
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
36#[non_exhaustive]
37pub enum MergeFallback {
38 /// Refuse: the merge-settings step fails with instructions to upgrade.
39 #[default]
40 Fail,
41 /// Allow instance-made merge commits only, commit the instance's signing
42 /// key (`GET /api/v1/signing-key.gpg`, fetched when the adapter probes
43 /// the instance) as the exempt keyring, and pass `exempt-keyring` to the
44 /// action. The instance must sign merges (`[repository.signing]`).
45 InstanceSigningKey,
46}
47
48/// How to reach one Forgejo (or Gitea) instance as one bot user.
49#[derive(Debug, Clone)]
50#[non_exhaustive]
51pub struct ForgejoConfig {
52 /// Forge host resources on this instance start with: `codeberg.org`,
53 /// `git.example.org`. Derived from `base_url`; override it only when
54 /// the bridge reaches the instance through another name.
55 pub host: String,
56 /// The instance's root URL, e.g. `https://codeberg.org/` (or
57 /// `https://example.org/git/` under a sub-path). The API is
58 /// `<base_url>/api/v1`; OAuth lives under `<base_url>/login/oauth`.
59 pub base_url: Url,
60 /// The bot user's login (`acme-vgi-bot`). The token must belong to it.
61 pub bot_login: String,
62 /// The bridge's OAuth2 application on this instance (confidential).
63 pub oauth_client_id: String,
64 /// Redirect URI for namespace binds (registered on the OAuth app).
65 pub bind_redirect_uri: Url,
66 /// Redirect URI for account links (registered on the OAuth app).
67 pub link_redirect_uri: Url,
68 /// OAuth `scope` to request, when the instance grants scoped OAuth
69 /// tokens (`[oauth2] ENABLE_ADDITIONAL_GRANT_SCOPES`). `None` asks for
70 /// no scope: such a token has the admin's (or member's) full access, so
71 /// the adapter uses it for a handful of calls and wipes it.
72 pub oauth_scope: Option<String>,
73 /// Where the instance delivers the org webhook (the bridge). `None`
74 /// creates no webhook; drift is then found by the scheduled sweep only.
75 pub webhook_url: Option<Url>,
76 /// The org team the bot is put in.
77 pub team_name: String,
78 /// `uses:` for checkout in the generated workflow: a full URL pinned to
79 /// a 40-hex commit.
80 pub checkout_action: String,
81 /// Base URL for a bare `owner/repo[/path]@sha` verify-trust reference.
82 pub actions_base: Url,
83 /// The runner label (`runs-on:`). The job image needs glibc 2.39+
84 /// (Ubuntu 24.04, Debian 13) for the verify-trust Linux binary.
85 pub runs_on: String,
86 /// The status-check context branch protection requires. Forgejo names a
87 /// job's status `<workflow name> / <job name> (<event>)`; `None` derives
88 /// it from the check name the plan gives both, i.e. `Verify commit trust
89 /// / Verify commit trust (pull_request)`. Set it when a completed run
90 /// reports something else.
91 pub status_check_context: Option<String>,
92 /// What to do on an instance without fast-forward-only merges.
93 pub merge_fallback: MergeFallback,
94 /// Deliver `TRUST_REGISTRY_DID` / `VTC_DID` as Actions variables rather
95 /// than writing them into the workflow. Off by default, for two reasons:
96 /// Forgejo lets only a repository *owner* (the org's Owners team) manage
97 /// variables, which the bot — an admin through its team — is not; and a
98 /// value in the workflow can only change through a pull request, which
99 /// the protected paths refuse, while an owner can re-point a variable at
100 /// another registry without one. Turn it on only if the bot is an owner.
101 pub use_actions_variables: bool,
102 /// Per-request timeout.
103 pub request_timeout: Duration,
104 /// How long an account-link `state` stays valid.
105 pub link_state_ttl: Duration,
106}
107
108impl ForgejoConfig {
109 /// An instance at `base_url`, with the bot and OAuth app named.
110 ///
111 /// Plain `http` is refused except on a loopback host: the bot token is
112 /// long-lived and travels on every request.
113 pub fn new(
114 base_url: Url,
115 bot_login: impl Into<String>,
116 oauth_client_id: impl Into<String>,
117 bind_redirect_uri: Url,
118 link_redirect_uri: Url,
119 ) -> Result<Self> {
120 let host = base_url
121 .host_str()
122 .ok_or_else(|| ForgeError::Config(format!("instance URL `{base_url}` has no host")))?
123 .to_ascii_lowercase();
124 let loopback = matches!(host.as_str(), "localhost" | "127.0.0.1" | "[::1]");
125 match base_url.scheme() {
126 "https" => {}
127 "http" if loopback => {}
128 s => {
129 return Err(ForgeError::Config(format!(
130 "instance URL `{base_url}`: `{s}` is not allowed (https, or http on loopback)"
131 )));
132 }
133 }
134 let mut base_url = base_url;
135 if !base_url.path().ends_with('/') {
136 let path = format!("{}/", base_url.path());
137 base_url.set_path(&path);
138 }
139 base_url.set_query(None);
140 base_url.set_fragment(None);
141 let bot_login = bot_login.into();
142 check_login(&bot_login)?;
143 Ok(ForgejoConfig {
144 host: host.trim_start_matches('[').trim_end_matches(']').into(),
145 base_url,
146 bot_login,
147 oauth_client_id: oauth_client_id.into(),
148 bind_redirect_uri,
149 link_redirect_uri,
150 oauth_scope: None,
151 webhook_url: None,
152 team_name: DEFAULT_TEAM.into(),
153 checkout_action: DEFAULT_CHECKOUT_ACTION.into(),
154 actions_base: Url::parse(DEFAULT_ACTIONS_BASE).expect("static URL"),
155 runs_on: DEFAULT_RUNS_ON.into(),
156 status_check_context: None,
157 merge_fallback: MergeFallback::Fail,
158 use_actions_variables: false,
159 request_timeout: Duration::from_secs(30),
160 link_state_ttl: Duration::from_secs(15 * 60),
161 })
162 }
163
164 /// Name resources on this instance with `host` instead of the URL's.
165 pub fn with_host(mut self, host: impl Into<String>) -> Self {
166 self.host = host.into().to_ascii_lowercase();
167 self
168 }
169
170 /// Have the bind create (or update) an org webhook to `url`.
171 pub fn with_webhook_url(mut self, url: Url) -> Self {
172 self.webhook_url = Some(url);
173 self
174 }
175
176 /// Require this status-check context instead of the derived one.
177 pub fn with_status_check_context(mut self, context: impl Into<String>) -> Self {
178 self.status_check_context = Some(context.into());
179 self
180 }
181
182 /// Choose the fallback for instances without fast-forward-only merges.
183 pub fn with_merge_fallback(mut self, fallback: MergeFallback) -> Self {
184 self.merge_fallback = fallback;
185 self
186 }
187
188 /// Deliver the DIDs as Actions variables (see
189 /// [`ForgejoConfig::use_actions_variables`]: needs the bot to be an owner).
190 pub fn with_actions_variables(mut self) -> Self {
191 self.use_actions_variables = true;
192 self
193 }
194
195 /// The API root, `<base_url>/api/v1`.
196 pub(crate) fn api_base(&self) -> Url {
197 self.base_url.join("api/v1").expect("relative join")
198 }
199
200 /// The status context for a job named `check` in a workflow of the same
201 /// name, triggered by `pull_request`.
202 pub fn status_context(&self, check: &str) -> String {
203 self.status_check_context
204 .clone()
205 .unwrap_or_else(|| crate::plan::default_status_context(check))
206 }
207}
208
209/// How the bot token is rotated — an explicit choice, because Forgejo only
210/// mints and deletes access tokens under **basic** authentication (the
211/// `/users/{name}/tokens` endpoints refuse token auth), so rotation without a
212/// human means the bridge holds the bot's password too.
213#[non_exhaustive]
214pub enum TokenRotation {
215 /// The bridge holds only the token. Rotating is an operator's job: mint a
216 /// new token for the bot with [`crate::BOT_TOKEN_SCOPES`] and hand it to
217 /// [`crate::ForgejoForge::replace_token`], which verifies it before use.
218 Manual,
219 /// The bridge also holds the bot's password (sealed like the App key on
220 /// GitHub) and rotates on its own with
221 /// [`crate::ForgejoForge::mint_token`] and
222 /// [`crate::ForgejoForge::retire_token`]. The bot must not have 2FA
223 /// enabled, which Forgejo requires for basic auth.
224 WithPassword(Secret),
225}
226
227impl fmt::Debug for TokenRotation {
228 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229 match self {
230 TokenRotation::Manual => f.write_str("Manual"),
231 TokenRotation::WithPassword(_) => f.write_str("WithPassword(<redacted>)"),
232 }
233 }
234}
235
236/// Everything secret the adapter holds. Every field zeroizes on drop and
237/// prints as `<redacted>`.
238#[non_exhaustive]
239pub struct Credentials {
240 /// The bot's access token.
241 pub bot_token: Secret,
242 /// How it is rotated.
243 pub rotation: TokenRotation,
244 /// The OAuth application's client secret.
245 pub oauth_client_secret: Secret,
246 /// The org webhook's HMAC secret.
247 pub webhook_secret: Secret,
248}
249
250impl Credentials {
251 /// Credentials with manual token rotation.
252 pub fn new(bot_token: Secret, oauth_client_secret: Secret, webhook_secret: Secret) -> Self {
253 Credentials {
254 bot_token,
255 rotation: TokenRotation::Manual,
256 oauth_client_secret,
257 webhook_secret,
258 }
259 }
260
261 /// Let the adapter rotate the token itself with the bot's password.
262 pub fn with_bot_password(mut self, password: Secret) -> Self {
263 self.rotation = TokenRotation::WithPassword(password);
264 self
265 }
266}
267
268impl fmt::Debug for Credentials {
269 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270 f.debug_struct("Credentials")
271 .field("bot_token", &self.bot_token)
272 .field("rotation", &self.rotation)
273 .field("oauth_client_secret", &self.oauth_client_secret)
274 .field("webhook_secret", &self.webhook_secret)
275 .finish()
276 }
277}
278
279/// A Forgejo login: what the instance allows in a user or org name. Checked
280/// before a login goes into a URL path segment or a protection allow-list.
281pub(crate) fn check_login(login: &str) -> Result<()> {
282 let ok = !login.is_empty()
283 && login.len() <= 40
284 && login
285 .bytes()
286 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
287 && !login.starts_with('.')
288 && login != "..";
289 if ok {
290 Ok(())
291 } else {
292 Err(ForgeError::Protocol(format!(
293 "`{login}` is not a valid Forgejo login"
294 )))
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 fn url(s: &str) -> Url {
303 Url::parse(s).unwrap()
304 }
305
306 #[test]
307 fn host_and_api_come_from_the_base_url() {
308 let c = ForgejoConfig::new(
309 url("https://Git.Example.org/forgejo"),
310 "acme-vgi-bot",
311 "cid",
312 url("https://bridge/bind"),
313 url("https://bridge/link"),
314 )
315 .unwrap();
316 assert_eq!(c.host, "git.example.org");
317 assert_eq!(
318 c.api_base().as_str(),
319 "https://git.example.org/forgejo/api/v1"
320 );
321 assert_eq!(
322 c.status_context("Verify commit trust"),
323 "Verify commit trust / Verify commit trust (pull_request)"
324 );
325 }
326
327 #[test]
328 fn plain_http_only_on_loopback() {
329 let new =
330 |u| ForgejoConfig::new(url(u), "bot", "cid", url("https://b/1"), url("https://b/2"));
331 assert!(new("http://git.example.org").is_err());
332 assert!(new("http://127.0.0.1:3000").is_ok());
333 assert!(new("http://localhost:3000").is_ok());
334 assert!(new("ftp://git.example.org").is_err());
335 }
336
337 #[test]
338 fn credentials_never_print() {
339 let c = Credentials::new(Secret::new("t0k"), Secret::new("cs"), Secret::new("wh"))
340 .with_bot_password(Secret::new("pw"));
341 let shown = format!("{c:?}");
342 for s in ["t0k", "cs", "wh", "pw"] {
343 assert!(!shown.contains(s), "{shown}");
344 }
345 }
346}