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 /// Ask for runner label `label` (`runs-on:`) in the workflow the
183 /// bootstrap writes, instead of [`DEFAULT_RUNS_ON`]. Checked here, so a
184 /// bad label fails at start-up rather than at the first bootstrap.
185 pub fn with_runs_on(mut self, label: impl Into<String>) -> Result<Self> {
186 let label = label.into();
187 crate::plan::check_runs_on(&label)?;
188 self.runs_on = label;
189 Ok(self)
190 }
191
192 /// Choose the fallback for instances without fast-forward-only merges.
193 pub fn with_merge_fallback(mut self, fallback: MergeFallback) -> Self {
194 self.merge_fallback = fallback;
195 self
196 }
197
198 /// Deliver the DIDs as Actions variables (see
199 /// [`ForgejoConfig::use_actions_variables`]: needs the bot to be an owner).
200 pub fn with_actions_variables(mut self) -> Self {
201 self.use_actions_variables = true;
202 self
203 }
204
205 /// The API root, `<base_url>/api/v1`.
206 pub(crate) fn api_base(&self) -> Url {
207 self.base_url.join("api/v1").expect("relative join")
208 }
209
210 /// The status context for a job named `check` in a workflow of the same
211 /// name, triggered by `pull_request`.
212 pub fn status_context(&self, check: &str) -> String {
213 self.status_check_context
214 .clone()
215 .unwrap_or_else(|| crate::plan::default_status_context(check))
216 }
217}
218
219/// How the bot token is rotated — an explicit choice, because Forgejo only
220/// mints and deletes access tokens under **basic** authentication (the
221/// `/users/{name}/tokens` endpoints refuse token auth), so rotation without a
222/// human means the bridge holds the bot's password too.
223#[non_exhaustive]
224pub enum TokenRotation {
225 /// The bridge holds only the token. Rotating is an operator's job: mint a
226 /// new token for the bot with [`crate::BOT_TOKEN_SCOPES`] and hand it to
227 /// [`crate::ForgejoForge::replace_token`], which verifies it before use.
228 Manual,
229 /// The bridge also holds the bot's password (sealed like the App key on
230 /// GitHub) and rotates on its own with
231 /// [`crate::ForgejoForge::mint_token`] and
232 /// [`crate::ForgejoForge::retire_token`]. The bot must not have 2FA
233 /// enabled, which Forgejo requires for basic auth.
234 WithPassword(Secret),
235}
236
237impl fmt::Debug for TokenRotation {
238 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239 match self {
240 TokenRotation::Manual => f.write_str("Manual"),
241 TokenRotation::WithPassword(_) => f.write_str("WithPassword(<redacted>)"),
242 }
243 }
244}
245
246/// Everything secret the adapter holds. Every field zeroizes on drop and
247/// prints as `<redacted>`.
248#[non_exhaustive]
249pub struct Credentials {
250 /// The bot's access token.
251 pub bot_token: Secret,
252 /// How it is rotated.
253 pub rotation: TokenRotation,
254 /// The OAuth application's client secret.
255 pub oauth_client_secret: Secret,
256 /// The org webhook's HMAC secret.
257 pub webhook_secret: Secret,
258}
259
260impl Credentials {
261 /// Credentials with manual token rotation.
262 pub fn new(bot_token: Secret, oauth_client_secret: Secret, webhook_secret: Secret) -> Self {
263 Credentials {
264 bot_token,
265 rotation: TokenRotation::Manual,
266 oauth_client_secret,
267 webhook_secret,
268 }
269 }
270
271 /// Let the adapter rotate the token itself with the bot's password.
272 pub fn with_bot_password(mut self, password: Secret) -> Self {
273 self.rotation = TokenRotation::WithPassword(password);
274 self
275 }
276}
277
278impl fmt::Debug for Credentials {
279 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
280 f.debug_struct("Credentials")
281 .field("bot_token", &self.bot_token)
282 .field("rotation", &self.rotation)
283 .field("oauth_client_secret", &self.oauth_client_secret)
284 .field("webhook_secret", &self.webhook_secret)
285 .finish()
286 }
287}
288
289/// A Forgejo login: what the instance allows in a user or org name. Checked
290/// before a login goes into a URL path segment or a protection allow-list.
291pub(crate) fn check_login(login: &str) -> Result<()> {
292 let ok = !login.is_empty()
293 && login.len() <= 40
294 && login
295 .bytes()
296 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
297 && !login.starts_with('.')
298 && login != "..";
299 if ok {
300 Ok(())
301 } else {
302 Err(ForgeError::Protocol(format!(
303 "`{login}` is not a valid Forgejo login"
304 )))
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 fn url(s: &str) -> Url {
313 Url::parse(s).unwrap()
314 }
315
316 #[test]
317 fn host_and_api_come_from_the_base_url() {
318 let c = ForgejoConfig::new(
319 url("https://Git.Example.org/forgejo"),
320 "acme-vgi-bot",
321 "cid",
322 url("https://bridge/bind"),
323 url("https://bridge/link"),
324 )
325 .unwrap();
326 assert_eq!(c.host, "git.example.org");
327 assert_eq!(
328 c.api_base().as_str(),
329 "https://git.example.org/forgejo/api/v1"
330 );
331 assert_eq!(
332 c.status_context("Verify commit trust"),
333 "Verify commit trust / Verify commit trust (pull_request)"
334 );
335 }
336
337 #[test]
338 fn the_runner_label_defaults_and_is_checked() {
339 let c = ForgejoConfig::new(
340 url("https://codeberg.org/"),
341 "bot",
342 "cid",
343 url("https://b/1"),
344 url("https://b/2"),
345 )
346 .unwrap();
347 assert_eq!(c.runs_on, DEFAULT_RUNS_ON);
348 assert_eq!(
349 c.clone().with_runs_on("ubuntu-24.04").unwrap().runs_on,
350 "ubuntu-24.04"
351 );
352 for bad in ["", "docker\nevil: 1", "a b", "${{ x }}"] {
353 assert!(c.clone().with_runs_on(bad).is_err(), "{bad:?}");
354 }
355 }
356
357 #[test]
358 fn plain_http_only_on_loopback() {
359 let new =
360 |u| ForgejoConfig::new(url(u), "bot", "cid", url("https://b/1"), url("https://b/2"));
361 assert!(new("http://git.example.org").is_err());
362 assert!(new("http://127.0.0.1:3000").is_ok());
363 assert!(new("http://localhost:3000").is_ok());
364 assert!(new("ftp://git.example.org").is_err());
365 }
366
367 #[test]
368 fn credentials_never_print() {
369 let c = Credentials::new(Secret::new("t0k"), Secret::new("cs"), Secret::new("wh"))
370 .with_bot_password(Secret::new("pw"));
371 let shown = format!("{c:?}");
372 for s in ["t0k", "cs", "wh", "pw"] {
373 assert!(!shown.contains(s), "{shown}");
374 }
375 }
376}