Skip to main content

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}