Skip to main content

vgi_forge_forgejo/
forge.rs

1//! [`ForgejoForge`]: the `Forge` implementation.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::sync::{Arc, RwLock};
5
6use base64::Engine;
7use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
8use http::HeaderMap;
9use reqwest::Method;
10use serde::{Deserialize, Deserializer};
11use serde_json::{Value, json};
12use vgi_forge::{
13    AccessSource, ApplyReport, BindCallback, BindRequest, BindStep, BootstrapStep, Capabilities,
14    Collaborator, Drift, Forge, ForgeAccount, ForgeError, ForgeEvent, ForgeHooks, ForgeKind,
15    ForgeRole, HookDecision, IndirectAccess, LinkCallback, LinkMethod, LinkStep, MergeMethod,
16    Namespace, NamespaceBinding, NamespaceKind, Projection, ProtectionGap, ProtectionSpec,
17    ProtectionState, RepoSettings, RepoSpec, RepoState, RequiredCheckKind, Resource, Result,
18    RoleAssignment, RoleChange, RoleOutcome, StepAction, StepOutcome, Unlisted, VgiConfig,
19    Visibility, async_trait, collapse_to_ladder, default_diff, validate_repo_path,
20};
21
22use crate::api::{Api, Auth};
23use crate::config::{Credentials, ForgejoConfig, MergeFallback, TokenRotation, check_login};
24use crate::oauth::{OAuthKeys, Purpose, TokenJson, unix_now};
25use crate::plan::{MergePlan, PROTECTED_PATHS, PlanOptions, forgejo_plan};
26use crate::secret::Secret;
27use crate::version::InstanceInfo;
28use crate::webhook::{self, HOOK_EVENTS};
29
30/// Scopes the bot's access token needs, and no more:
31///
32/// - `write:organization` — create repositories in the org (with
33///   `write:repository`), read the bot's own org permissions;
34/// - `write:repository` — contents, collaborators, branch protection,
35///   Actions variables, archive;
36/// - `read:user` — look a person's login up by numeric id before every role
37///   change (`/users/search?uid=`), and confirm a token is the bot's
38///   (`/user`) before trusting it. Without it the adapter would have to act
39///   on logins the VTC recorded, which a rename can hand to someone else.
40pub const BOT_TOKEN_SCOPES: [&str; 3] = ["write:organization", "write:repository", "read:user"];
41
42/// Name prefix of the tokens [`ForgejoForge::mint_token`] mints. Only for
43/// recognising them in the bot's token list; nothing is deleted by prefix.
44pub const TOKEN_NAME_PREFIX: &str = "vgi-bridge-";
45
46/// The role ladder. Forgejo has `read`, `write` and `admin` collaborators;
47/// `Maintain` is the adapter's own rung — `write` **and** a place on the
48/// default branch's merge allow-list (§5.9) — because `write` alone cannot
49/// separate "may merge" from "may push a branch".
50const LADDER: [ForgeRole; 4] = [
51    ForgeRole::Read,
52    ForgeRole::Write,
53    ForgeRole::Maintain,
54    ForgeRole::Admin,
55];
56
57/// Shortest bind `state` accepted: 128 bits of base64url.
58const MIN_STATE_LEN: usize = 22;
59
60/// Team units, for instances old enough to read them (an `admin` team gets
61/// every unit regardless on current Forgejo).
62const TEAM_UNITS: [&str; 3] = ["repo.code", "repo.pulls", "repo.actions"];
63
64/// What the adapter learned about the instance and its bot.
65#[derive(Debug, Clone)]
66struct Probed {
67    info: InstanceInfo,
68    bot: ForgeAccount,
69    signing_key: Option<Vec<u8>>,
70}
71
72/// What [`ForgejoForge::refresh_managed_files`] did, for the audit log.
73#[derive(Debug, Clone, PartialEq, Eq)]
74#[non_exhaustive]
75pub struct RefreshReport {
76    /// The step's outcome: `Updated` if any file was written.
77    pub outcome: StepOutcome,
78    /// Each file and what happened to it.
79    pub files: Vec<(String, StepOutcome)>,
80    /// Whether the protection was opened for the bridge at all.
81    pub opened: bool,
82    /// One line for the audit log: what was opened, written and restored.
83    pub detail: String,
84}
85
86/// One of the bot's access tokens, by the id and name Forgejo lists it
87/// under. Holds no secret.
88#[derive(Debug, Clone, PartialEq, Eq)]
89#[non_exhaustive]
90pub struct TokenRef {
91    /// Forgejo's id for the token.
92    pub id: u64,
93    /// Its name.
94    pub name: String,
95}
96
97/// What [`ForgejoForge::mint_token`] minted, now in use.
98#[derive(Debug)]
99#[non_exhaustive]
100pub struct MintedToken {
101    /// The new token.
102    pub token: TokenRef,
103    /// Its secret, for the caller to persist (sealed) before retiring the
104    /// old one. Never printed.
105    pub secret: Secret,
106    /// The token it replaced, when it could be identified — what to pass to
107    /// [`ForgejoForge::retire_token`] once the new one is persisted
108    /// everywhere it is used. `None`: find and delete it by hand.
109    pub previous: Option<TokenRef>,
110}
111
112/// The Forgejo adapter: one bot user on one Forgejo (or Gitea) instance.
113///
114/// Holds the bot's token (swappable, for rotation), the OAuth client secret,
115/// the webhook secret, and the namespaces the core has bound. Build it with
116/// [`ForgejoForge::connect`], which probes the instance's version and
117/// confirms the token is the bot's.
118pub struct ForgejoForge {
119    config: ForgejoConfig,
120    api: Api,
121    token: RwLock<Arc<Secret>>,
122    /// The token in use, when this adapter minted it.
123    current_token: RwLock<Option<TokenRef>>,
124    rotation: TokenRotation,
125    oauth_secret: Secret,
126    oauth_keys: OAuthKeys,
127    webhook_secret: Secret,
128    namespaces: RwLock<BTreeMap<Resource, Namespace>>,
129    probed: RwLock<Probed>,
130}
131
132impl std::fmt::Debug for ForgejoForge {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        f.debug_struct("ForgejoForge")
135            .field("host", &self.config.host)
136            .field("bot", &self.config.bot_login)
137            .field("token", &"<redacted>")
138            .field("rotation", &self.rotation)
139            .field("oauth_secret", &self.oauth_secret)
140            .field("webhook_secret", &self.webhook_secret)
141            .finish_non_exhaustive()
142    }
143}
144
145impl ForgejoForge {
146    /// Connect to `config`'s instance: probe `/api/v1/version` (switching off
147    /// what the instance lacks), confirm `credentials.bot_token` belongs to
148    /// `config.bot_login`, and — in the signing-key merge fallback on an
149    /// instance without fast-forward-only merges — fetch the instance's
150    /// signing key for the plan.
151    pub async fn connect(config: ForgejoConfig, credentials: Credentials) -> Result<Self> {
152        let Credentials {
153            bot_token,
154            rotation,
155            oauth_client_secret,
156            webhook_secret,
157        } = credentials;
158        for (what, s) in [
159            ("bot token", &bot_token),
160            ("OAuth client secret", &oauth_client_secret),
161            ("webhook secret", &webhook_secret),
162        ] {
163            if s.expose().is_empty() {
164                return Err(ForgeError::Config(format!("empty {what}")));
165            }
166        }
167        if config.oauth_client_id.is_empty() {
168            return Err(ForgeError::Config("empty OAuth client id".into()));
169        }
170        if let Some(context) = &config.status_check_context {
171            crate::plan::check_check_name(context)?;
172        }
173        check_login(&config.team_name)
174            .map_err(|_| ForgeError::Config(format!("bad team name `{}`", config.team_name)))?;
175        vgi_forge::Resource::namespace_of(&config.host, "x").map_err(|e| {
176            ForgeError::Config(format!("`{}` is not a forge host: {e}", config.host))
177        })?;
178        let api = Api::new(
179            config.api_base(),
180            config.base_url.clone(),
181            config.request_timeout,
182        )?;
183        let probed = probe(&api, &config, &bot_token).await?;
184        let oauth_keys = OAuthKeys::new(&oauth_client_secret);
185        Ok(ForgejoForge {
186            config,
187            api,
188            token: RwLock::new(Arc::new(bot_token)),
189            current_token: RwLock::new(None),
190            rotation,
191            oauth_secret: oauth_client_secret,
192            oauth_keys,
193            webhook_secret,
194            namespaces: RwLock::new(BTreeMap::new()),
195            probed: RwLock::new(probed),
196        })
197    }
198
199    /// The configuration.
200    pub fn config(&self) -> &ForgejoConfig {
201        &self.config
202    }
203
204    /// What the last probe found.
205    pub fn instance(&self) -> InstanceInfo {
206        self.probed().info
207    }
208
209    /// The bot's account.
210    pub fn bot(&self) -> ForgeAccount {
211        self.probed().bot
212    }
213
214    /// Probe the instance again (after an upgrade, say).
215    pub async fn refresh(&self) -> Result<InstanceInfo> {
216        let token = self.token();
217        let probed = probe(&self.api, &self.config, &token).await?;
218        let info = probed.info.clone();
219        *self.probed.write().expect("probe lock poisoned") = probed;
220        Ok(info)
221    }
222
223    fn probed(&self) -> Probed {
224        self.probed.read().expect("probe lock poisoned").clone()
225    }
226
227    /// Tell the adapter about a bound namespace (from the VTC's store).
228    /// Operations on repositories in a namespace never registered are
229    /// refused with [`ForgeError::NotBound`].
230    pub fn register_namespace(&self, ns: Namespace) -> Result<()> {
231        if ns.resource.host() != self.config.host || !ns.resource.is_namespace() {
232            return Err(ForgeError::WrongResource {
233                resource: ns.resource.to_string(),
234                expected: format!("a namespace on `{}`", self.config.host),
235            });
236        }
237        self.namespaces
238            .write()
239            .expect("namespace lock poisoned")
240            .insert(ns.resource.clone(), ns);
241        Ok(())
242    }
243
244    /// Forget a namespace (unbind).
245    pub fn unregister_namespace(&self, ns: &Resource) {
246        self.namespaces
247            .write()
248            .expect("namespace lock poisoned")
249            .remove(ns);
250    }
251
252    /// A fresh bind `state` nonce: 256 bits from the system CSPRNG,
253    /// base64url. The caller stores it with its expiry and hands it back to
254    /// [`Forge::complete_bind`].
255    pub fn new_state() -> Result<String> {
256        let mut bytes = [0u8; 32];
257        aws_lc_rs::rand::fill(&mut bytes)
258            .map_err(|_| ForgeError::Config("system RNG unavailable".into()))?;
259        Ok(URL_SAFE_NO_PAD.encode(bytes))
260    }
261
262    /// The instance's merge-signing public key (`/api/v1/signing-key.gpg`).
263    pub async fn fetch_signing_key(&self) -> Result<Vec<u8>> {
264        fetch_signing_key(&self.api, &self.token()).await
265    }
266
267    // ── the bot token ────────────────────────────────────────────────────
268
269    fn token(&self) -> Arc<Secret> {
270        self.token.read().expect("token lock poisoned").clone()
271    }
272
273    /// Swap in a token an operator minted (manual rotation). It is checked
274    /// to be the bot's before it replaces the current one; the old token is
275    /// not deleted — that is the operator's to do.
276    pub async fn replace_token(&self, new: Secret) -> Result<()> {
277        let bot = self.bot();
278        let who = whoami(&self.api, Auth::Token(&new)).await?;
279        if who.id != bot.id {
280            return Err(ForgeError::Config(format!(
281                "the new token belongs to `{}`, not the bot `{}`",
282                who.login, bot.login
283            )));
284        }
285        *self.token.write().expect("token lock poisoned") = Arc::new(new);
286        *self.current_token.write().expect("token lock poisoned") = None;
287        Ok(())
288    }
289
290    /// Rotation, phase 1: mint a new bot token (basic auth with the bot's
291    /// password), verify it is the bot's, and put it in use. Needs
292    /// [`TokenRotation::WithPassword`].
293    ///
294    /// The new secret is returned: **persist it (sealed) before calling
295    /// [`ForgejoForge::retire_token`]**, or a restart after the old token is
296    /// deleted comes back with a dead credential. Nothing is deleted here —
297    /// other bridge replicas using the old token keep working until the
298    /// caller has distributed the new one and retires the old. The token it
299    /// replaced is identified before anything is minted (by the id this
300    /// adapter recorded when it minted it, or else by its last eight
301    /// characters in the bot's token list), so once the new token is in use
302    /// nothing is left that can fail.
303    pub async fn mint_token(&self) -> Result<MintedToken> {
304        let password = self.bot_password()?;
305        let bot = self.bot();
306        let basic = Auth::Basic {
307            user: &bot.login,
308            password,
309        };
310        let tokens_url = self.api.url(&["users", &bot.login, "tokens"]);
311        let tracked = self
312            .current_token
313            .read()
314            .expect("token lock poisoned")
315            .clone();
316        let previous = match tracked {
317            Some(t) => Some(t),
318            None => {
319                let tail = last_eight(self.token().expose());
320                let listed: Vec<TokenInfoJson> = self
321                    .api
322                    .get_all(tokens_url.clone(), basic, "bot access tokens")
323                    .await?;
324                let mut matching = listed
325                    .into_iter()
326                    .filter(|t| tail.is_some() && t.token_last_eight.as_deref() == tail.as_deref());
327                // Only an unambiguous match is named; two tokens sharing
328                // their last eight characters are left for a human.
329                match (matching.next(), matching.next()) {
330                    (Some(t), None) => Some(TokenRef {
331                        id: t.id,
332                        name: t.name,
333                    }),
334                    _ => None,
335                }
336            }
337        };
338
339        let mut suffix = [0u8; 4];
340        aws_lc_rs::rand::fill(&mut suffix)
341            .map_err(|_| ForgeError::Config("system RNG unavailable".into()))?;
342        let name = format!("{TOKEN_NAME_PREFIX}{}-{}", unix_now(), hex::encode(suffix));
343        let created: NewTokenJson = self
344            .api
345            .json_secret(
346                Method::POST,
347                tokens_url,
348                basic,
349                Some(&json!({ "name": name, "scopes": BOT_TOKEN_SCOPES })),
350                "bot access token",
351            )
352            .await?;
353        let minted = TokenRef {
354            id: created.id,
355            name: name.clone(),
356        };
357        let for_caller = Secret::new(created.sha1.clone());
358        let in_use = Secret::new(created.sha1.clone());
359        drop(created);
360
361        match whoami(&self.api, Auth::Token(&in_use)).await {
362            Ok(who) if who.id == bot.id => {}
363            other => {
364                // Best effort: the error that matters is the one below.
365                let _ = self.delete_token(&bot.login, password, minted.id).await;
366                return Err(match other {
367                    Ok(who) => ForgeError::Protocol(format!(
368                        "the new token authenticates as `{}`, not the bot",
369                        who.login
370                    )),
371                    Err(e) => e,
372                });
373            }
374        }
375        *self.token.write().expect("token lock poisoned") = Arc::new(in_use);
376        *self.current_token.write().expect("token lock poisoned") = Some(minted.clone());
377        Ok(MintedToken {
378            token: minted,
379            secret: for_caller,
380            previous,
381        })
382    }
383
384    /// Rotation, phase 2: delete a token this bridge replaced — exactly the
385    /// one named, never a pattern, so another bridge's (or a person's)
386    /// tokens on the same bot are never touched. Refuses the token in use.
387    /// A token already gone is not an error.
388    pub async fn retire_token(&self, old: &TokenRef) -> Result<()> {
389        let password = self.bot_password()?;
390        if self
391            .current_token
392            .read()
393            .expect("token lock poisoned")
394            .as_ref()
395            .is_some_and(|t| t.id == old.id)
396        {
397            return Err(ForgeError::Config(format!(
398                "token `{}` is the one in use; mint a new one first",
399                old.name
400            )));
401        }
402        let bot = self.bot();
403        match self.delete_token(&bot.login, password, old.id).await {
404            Ok(()) | Err(ForgeError::NotFound { .. }) => Ok(()),
405            Err(e) => Err(e),
406        }
407    }
408
409    fn bot_password(&self) -> Result<&Secret> {
410        match &self.rotation {
411            TokenRotation::WithPassword(p) => Ok(p),
412            _ => Err(ForgeError::Unsupported {
413                operation: "bot token rotation".into(),
414                hint: format!(
415                    "Forgejo mints and deletes tokens only under basic auth and this bridge \
416                     holds no bot password: create a token for `{}` with scopes {}, pass it to \
417                     `replace_token`, and delete the old one yourself",
418                    self.config.bot_login,
419                    BOT_TOKEN_SCOPES.join(", ")
420                ),
421            }),
422        }
423    }
424
425    async fn delete_token(&self, login: &str, password: &Secret, id: u64) -> Result<()> {
426        let url = self.api.url(&["users", login, "tokens", &id.to_string()]);
427        self.api
428            .send(
429                Method::DELETE,
430                url,
431                Auth::Basic {
432                    user: login,
433                    password,
434                },
435                None,
436                "bot access token",
437            )
438            .await?;
439        Ok(())
440    }
441
442    // ── locating ─────────────────────────────────────────────────────────
443
444    fn namespace(&self, ns: &Resource) -> Result<Namespace> {
445        self.namespaces
446            .read()
447            .expect("namespace lock poisoned")
448            .get(ns)
449            .cloned()
450            .ok_or_else(|| ForgeError::NotBound {
451                namespace: ns.to_string(),
452            })
453    }
454
455    /// Check `repo` is exactly `host/owner/repo` on this forge and return its
456    /// namespace, owner and name. A deeper path — which a deserialised
457    /// resource can carry — is refused, not truncated.
458    fn locate<'r>(&self, repo: &'r Resource) -> Result<(Namespace, &'r str, &'r str)> {
459        if repo.host() != self.config.host {
460            return Err(ForgeError::WrongResource {
461                resource: repo.to_string(),
462                expected: format!("a repository on `{}`", self.config.host),
463            });
464        }
465        // A `Resource` from a bridge job was validated against the general
466        // grammar (any depth). Splitting `codeberg.org/acme/evil/widgets`
467        // into first and last segment would act on `acme/widgets`.
468        repo.require_owner_repo()?;
469        let name = repo.repo_name().ok_or_else(|| ForgeError::WrongResource {
470            resource: repo.to_string(),
471            expected: "a repository (`<host>/<owner>/<repo>`), not a namespace".into(),
472        })?;
473        Ok((self.namespace(&repo.namespace())?, repo.owner(), name))
474    }
475
476    fn automated(&self, ns: &Namespace) -> Result<()> {
477        if ns.installation_id.is_none() {
478            return Err(ForgeError::Unsupported {
479                operation: "forge automation".into(),
480                hint: format!(
481                    "namespace `{}` is in manual mode (no bot binding); run the steps by hand \
482                     with `vgi repo init`",
483                    ns.resource
484                ),
485            });
486        }
487        Ok(())
488    }
489
490    /// Locate `repo` and return the bot token for it.
491    fn repo_token<'r>(&self, repo: &'r Resource) -> Result<(Arc<Secret>, &'r str, &'r str)> {
492        let (ns, owner, name) = self.locate(repo)?;
493        self.automated(&ns)?;
494        Ok((self.token(), owner, name))
495    }
496
497    // ── reads ────────────────────────────────────────────────────────────
498
499    async fn get_repo(&self, token: &Secret, owner: &str, name: &str) -> Result<RepoJson> {
500        self.api
501            .json(
502                Method::GET,
503                self.api.url(&["repos", owner, name]),
504                Auth::Token(token),
505                None,
506                &format!("{}/{owner}/{name}", self.config.host),
507            )
508            .await
509    }
510
511    fn repo_state(&self, r: &RepoJson) -> Result<RepoState> {
512        let resource =
513            Resource::parse_owner_repo(&format!("{}/{}", self.config.host, r.full_name))?;
514        resource.require_owner_repo()?;
515        let mut state = RepoState::new(resource, r.id);
516        state.visibility = if r.private {
517            Visibility::Private
518        } else {
519            Visibility::Public
520        };
521        state.archived = r.archived;
522        state.default_branch = r.default_branch();
523        Ok(state)
524    }
525
526    /// Direct collaborators with their repository permission.
527    async fn collaborators(
528        &self,
529        token: &Secret,
530        owner: &str,
531        name: &str,
532    ) -> Result<Vec<(ForgeAccount, Perm)>> {
533        let users: Vec<UserJson> = self
534            .api
535            .get_all(
536                self.api.url(&["repos", owner, name, "collaborators"]),
537                Auth::Token(token),
538                "collaborators",
539            )
540            .await?;
541        let mut out = Vec::with_capacity(users.len());
542        for u in users {
543            check_login(&u.login)?;
544            let p: PermissionJson = self
545                .api
546                .json(
547                    Method::GET,
548                    self.api.url(&[
549                        "repos",
550                        owner,
551                        name,
552                        "collaborators",
553                        &u.login,
554                        "permission",
555                    ]),
556                    Auth::Token(token),
557                    None,
558                    "collaborator permission",
559                )
560                .await?;
561            if let Some(perm) = Perm::parse(&p.permission) {
562                out.push((ForgeAccount::new(u.id, u.login), perm));
563            }
564        }
565        Ok(out)
566    }
567
568    /// The branch protection rule named exactly `branch` — the managed rule
569    /// — and the names of any other rules that could apply to the branch
570    /// in its place.
571    ///
572    /// Forgejo applies the *first* matching rule: plain-name rules before
573    /// glob rules, and among those the oldest, with a plain name matching
574    /// the branch case-insensitively. So another plain rule whose name
575    /// equals the branch ignoring case (`Main` for `main`) may win over the
576    /// managed one and is reported; a glob rule never outranks a plain one
577    /// and is not.
578    async fn protection_rule(
579        &self,
580        token: &Secret,
581        owner: &str,
582        name: &str,
583        branch: &str,
584    ) -> Result<(Option<ProtectionJson>, Vec<String>)> {
585        let mut rules: Vec<ProtectionJson> = self
586            .api
587            .json(
588                Method::GET,
589                self.api.url(&["repos", owner, name, "branch_protections"]),
590                Auth::Token(token),
591                None,
592                "branch protections",
593            )
594            .await?;
595        let (managed, shadowing) = select_rule(&rules, branch);
596        Ok((managed.map(|i| rules.swap_remove(i)), shadowing))
597    }
598
599    fn protection_state(
600        &self,
601        rule: Option<&ProtectionJson>,
602        shadowing: &[String],
603        repo: &RepoJson,
604    ) -> ProtectionState {
605        let mut p = ProtectionState::default();
606        p.merge_methods = Some(repo.merge_methods());
607        p.ci_enabled = repo.has_actions;
608        let Some(rule) = rule else {
609            return p;
610        };
611        p.present = true;
612        // A Forgejo rule has no disabled state, and it is only ever looked
613        // up by the default branch's exact name — but another rule that
614        // Forgejo may apply first means it cannot be relied on to cover it.
615        p.enforced = true;
616        p.covers_default_branch = shadowing.is_empty();
617        p.requires_pull_request = !rule.enable_push;
618        if rule.enable_status_check {
619            p.required_checks = rule.status_check_contexts.clone();
620        }
621        // Forgejo refuses force-pushes to, and deletion of, any protected
622        // branch outright; there is no setting to weaken. (Gitea 1.23 added
623        // `enable_force_push`; honour it where an instance reports it.)
624        p.blocks_force_push = rule.enable_force_push != Some(true);
625        p.blocks_deletion = true;
626        p.protected_paths = patterns(&rule.protected_file_patterns);
627        p.required_approvals = approvals_of(rule);
628        p.bypass_actors = rule.bypass_actors();
629        p.bypass_actors
630            .extend(shadowing.iter().map(|n| format!("shadowing-rule:{n}")));
631        p
632    }
633
634    fn allowed_merge_methods(&self) -> Vec<MergeMethod> {
635        let probed = self.probed();
636        if !probed.info.features.fast_forward_only
637            && self.config.merge_fallback == MergeFallback::InstanceSigningKey
638        {
639            vec![MergeMethod::MergeCommit]
640        } else {
641            vec![MergeMethod::FastForward]
642        }
643    }
644
645    /// Gaps in what the protection and settings must add up to on Forgejo,
646    /// beyond the neutral ones: protected workflow paths, merge methods, CI.
647    fn forgejo_gaps(&self, p: &ProtectionState) -> Vec<ProtectionGap> {
648        let mut gaps = Vec::new();
649        if p.present {
650            let missing: Vec<String> = PROTECTED_PATHS
651                .iter()
652                .filter(|want| !p.protected_paths.iter().any(|have| have == *want))
653                .map(|s| s.to_string())
654                .collect();
655            if !missing.is_empty() {
656                gaps.push(ProtectionGap::UnprotectedPaths { paths: missing });
657            }
658        }
659        let allowed = self.allowed_merge_methods();
660        if let Some(methods) = &p.merge_methods {
661            for m in methods {
662                if !allowed.contains(m) {
663                    gaps.push(ProtectionGap::MergeMethodAllowed { method: *m });
664                }
665            }
666        }
667        if p.ci_enabled == Some(false) {
668            gaps.push(ProtectionGap::CiDisabled);
669        }
670        gaps
671    }
672
673    // ── bootstrap steps ──────────────────────────────────────────────────
674
675    async fn write_file(
676        &self,
677        repo: &Resource,
678        path: &str,
679        contents: &[u8],
680        message: &str,
681    ) -> Result<StepOutcome> {
682        validate_repo_path(path)?;
683        let (token, owner, name) = self.repo_token(repo)?;
684        let mut segments = vec!["repos", owner, name, "contents"];
685        segments.extend(path.split('/'));
686        let url = self.api.url(&segments);
687
688        let sha = match self.current_file(&token, owner, name, path).await? {
689            Some((_, current)) if current == contents => return Ok(StepOutcome::Unchanged),
690            Some((sha, _)) => Some(sha),
691            None => None,
692        };
693
694        let mut body = json!({ "message": message, "content": STANDARD.encode(contents) });
695        let method = match &sha {
696            Some(sha) => {
697                body["sha"] = json!(sha);
698                Method::PUT
699            }
700            None => Method::POST,
701        };
702        self.api
703            .send(method, url, Auth::Token(&token), Some(&body), path)
704            .await
705            .map_err(|e| match e {
706                ForgeError::Rejected { status, message } => ForgeError::Rejected {
707                    status,
708                    message: format!("{message}{PROTECTED_HINT}"),
709                },
710                ForgeError::Forbidden(message) => {
711                    ForgeError::Forbidden(format!("{message}{PROTECTED_HINT}"))
712                }
713                e => e,
714            })?;
715        Ok(if sha.is_some() {
716            StepOutcome::Updated
717        } else {
718            StepOutcome::Created
719        })
720    }
721
722    /// The file at `path` on the default branch: `None` when absent, its
723    /// blob sha and contents otherwise. A directory, or a file too large to
724    /// return inline, is refused.
725    async fn current_file(
726        &self,
727        token: &Secret,
728        owner: &str,
729        name: &str,
730        path: &str,
731    ) -> Result<Option<(String, Vec<u8>)>> {
732        let mut segments = vec!["repos", owner, name, "contents"];
733        segments.extend(path.split('/'));
734        let existing: Option<Value> = self
735            .api
736            .get_opt(self.api.url(&segments), Auth::Token(token), path)
737            .await?;
738        match existing {
739            Some(Value::Array(_)) => Err(ForgeError::Rejected {
740                status: 409,
741                message: format!("`{path}` exists and is a directory, not a file"),
742            }),
743            Some(v) => {
744                let c: ContentJson = serde_json::from_value(v)
745                    .map_err(|e| ForgeError::Protocol(format!("{path}: {e}")))?;
746                if c.kind != "file" {
747                    return Err(ForgeError::Rejected {
748                        status: 409,
749                        message: format!("`{path}` exists and is a {}, not a file", c.kind),
750                    });
751                }
752                let contents = decode_content(&c)?;
753                Ok(Some((c.sha, contents)))
754            }
755            None => Ok(None),
756        }
757    }
758
759    /// The Forgejo job for [`StepAction::RefreshProtectedFiles`]: the one
760    /// sanctioned way the bridge changes a protected path (the managed
761    /// workflow, the keyring) after bootstrap.
762    ///
763    /// If every file already matches, nothing is touched. Otherwise the
764    /// managed rule is opened for the bot alone — pushes enabled with a push
765    /// allow-list of just the bot, the protected-file patterns cleared,
766    /// since Forgejo refuses protected files even to an allowed pusher —
767    /// the files are written, and the rule's exact prior push and
768    /// protected-file settings are restored and read back. The restore is
769    /// attempted (twice) whatever happened to the writes. While open, an
770    /// `inspect` reports the bot as a bypass actor and the paths as
771    /// unprotected: critical drift, so a restore that failed is re-applied
772    /// by the next sweep's protection step.
773    pub async fn refresh_managed_files(
774        &self,
775        repo: &Resource,
776        files: &[vgi_forge::ExtraFile],
777        message: &str,
778    ) -> Result<RefreshReport> {
779        let (token, owner, name) = self.repo_token(repo)?;
780        let r = self.get_repo(&token, owner, name).await?;
781        let branch = r.default_branch().ok_or_else(|| ForgeError::Rejected {
782            status: 409,
783            message: format!("{repo} is empty: nothing to refresh"),
784        })?;
785        self.refresh_on_branch(repo, &branch, files, message).await
786    }
787
788    /// [`ForgejoForge::refresh_managed_files`] on `branch`, the repository's
789    /// default branch, already read.
790    async fn refresh_on_branch(
791        &self,
792        repo: &Resource,
793        branch: &str,
794        files: &[vgi_forge::ExtraFile],
795        message: &str,
796    ) -> Result<RefreshReport> {
797        for f in files {
798            validate_repo_path(&f.path)?;
799        }
800        let (token, owner, name) = self.repo_token(repo)?;
801        let mut stale = Vec::new();
802        for f in files {
803            let current = self.current_file(&token, owner, name, &f.path).await?;
804            if current.map(|(_, c)| c) != Some(f.contents.clone()) {
805                stale.push(f);
806            }
807        }
808        let mut report = RefreshReport {
809            outcome: StepOutcome::Unchanged,
810            files: files
811                .iter()
812                .map(|f| (f.path.clone(), StepOutcome::Unchanged))
813                .collect(),
814            opened: false,
815            detail: format!("{repo}: managed files already current"),
816        };
817        if stale.is_empty() {
818            return Ok(report);
819        }
820
821        let (rule, shadowing) = self.protection_rule(&token, owner, name, branch).await?;
822        if !shadowing.is_empty() {
823            return Err(ForgeError::Rejected {
824                status: 409,
825                message: format!(
826                    "{repo}: rule(s) {} shadow the managed protection; resolve that first",
827                    shadowing.join(", ")
828                ),
829            });
830        }
831        let bot = self.bot();
832        let rule_url = |rule_name: &str| {
833            self.api
834                .url(&["repos", owner, name, "branch_protections", rule_name])
835        };
836        let prior = rule.as_ref().map(|r| {
837            (
838                r.name().unwrap_or(branch).to_string(),
839                json!({
840                    "enable_push": r.enable_push,
841                    "enable_push_whitelist": r.enable_push_whitelist,
842                    "push_whitelist_usernames": r.push_whitelist_usernames,
843                    "push_whitelist_teams": r.push_whitelist_teams,
844                    "push_whitelist_deploy_keys": r.push_whitelist_deploy_keys,
845                    "protected_file_patterns": r.protected_file_patterns,
846                }),
847                r.clone(),
848            )
849        });
850        let mut open_error = None;
851        if let Some((rule_name, _, _)) = &prior {
852            tracing::warn!(
853                repo = %repo,
854                bot = %bot.login,
855                files = ?stale.iter().map(|f| &f.path).collect::<Vec<_>>(),
856                "opening the default-branch protection to the bridge alone to refresh managed files"
857            );
858            let open = json!({
859                "enable_push": true,
860                "enable_push_whitelist": true,
861                "push_whitelist_usernames": [bot.login],
862                "push_whitelist_teams": [],
863                "push_whitelist_deploy_keys": false,
864                "protected_file_patterns": "",
865            });
866            // A failed open may still have applied on the server (a timeout
867            // after the write, a 5xx from a proxy): nothing is written then,
868            // but the restore below is attempted all the same.
869            match self
870                .api
871                .send(
872                    Method::PATCH,
873                    rule_url(rule_name),
874                    Auth::Token(&token),
875                    Some(&open),
876                    "branch protection (open for refresh)",
877                )
878                .await
879            {
880                Ok(_) => report.opened = true,
881                Err(e) => open_error = Some(e),
882            }
883        }
884
885        let mut write_error = None;
886        for (i, f) in files.iter().enumerate() {
887            if open_error.is_some() {
888                break;
889            }
890            if !stale.iter().any(|s| s.path == f.path) {
891                continue;
892            }
893            match self.write_file(repo, &f.path, &f.contents, message).await {
894                Ok(o) => report.files[i].1 = o,
895                Err(e) => {
896                    write_error = Some((f.path.clone(), e));
897                    break;
898                }
899            }
900        }
901
902        let mut restore_error = None;
903        if let Some((rule_name, body, before)) = &prior {
904            for _ in 0..2 {
905                let result: Result<ProtectionJson> = self
906                    .api
907                    .json(
908                        Method::PATCH,
909                        rule_url(rule_name),
910                        Auth::Token(&token),
911                        Some(body),
912                        "branch protection (restore after refresh)",
913                    )
914                    .await;
915                restore_error = match result {
916                    Ok(after) if same_push_settings(&after, before) => None,
917                    Ok(_) => Some(ForgeError::Rejected {
918                        status: 200,
919                        message: "the restored protection does not read back as it was".into(),
920                    }),
921                    Err(e) => Some(e),
922                };
923                if restore_error.is_none() {
924                    break;
925                }
926            }
927        }
928
929        let written: Vec<&str> = report
930            .files
931            .iter()
932            .filter(|(_, o)| *o != StepOutcome::Unchanged)
933            .map(|(p, _)| p.as_str())
934            .collect();
935        report.detail = format!(
936            "{repo}: protection {} for `{}`; wrote {:?}; {}",
937            match (&prior, &open_error) {
938                (None, _) => "absent, not opened".to_string(),
939                (Some(_), None) => "opened".to_string(),
940                (Some(_), Some(e)) => format!("open failed ({e})"),
941            },
942            bot.login,
943            written,
944            match (&restore_error, prior.is_some()) {
945                (None, true) => "protection restored and verified".to_string(),
946                (None, false) => "nothing to restore".to_string(),
947                (Some(e), _) => format!("PROTECTION LEFT OPEN: {e}"),
948            }
949        );
950        if let Some(e) = &restore_error {
951            tracing::error!(repo = %repo, error = %e, "refresh could not restore the protection");
952            return Err(ForgeError::Rejected {
953                status: 500,
954                message: report.detail,
955            });
956        }
957        if let Some(e) = open_error {
958            tracing::warn!(repo = %repo, detail = %report.detail, "refresh could not open the protection");
959            return Err(match e {
960                ForgeError::Rejected { status, message } => ForgeError::Rejected {
961                    status,
962                    message: format!("{message} (nothing written; protection restored)"),
963                },
964                other => other,
965            });
966        }
967        tracing::info!(repo = %repo, detail = %report.detail, "managed files refreshed");
968        if let Some((path, e)) = write_error {
969            return Err(match e {
970                ForgeError::Rejected { status, message } => ForgeError::Rejected {
971                    status,
972                    message: format!("{path}: {message} (protection restored)"),
973                },
974                other => other,
975            });
976        }
977        report.outcome = if written.is_empty() {
978            StepOutcome::Unchanged
979        } else {
980            StepOutcome::Updated
981        };
982        Ok(report)
983    }
984
985    /// The bootstrap's write of a managed file (the workflow, the keyring).
986    ///
987    /// On a repository that is not protected yet this is a plain write. On
988    /// one bootstrapped before, the file is a protected path no pull request
989    /// may change, so a rendering that moved on (a new verify-trust release,
990    /// the namespace fallback) would otherwise fail every later bootstrap:
991    /// it goes through the audited [`ForgejoForge::refresh_managed_files`]
992    /// instead, which does nothing when the file is current. An empty
993    /// repository has no branch to protect and is written directly.
994    async fn write_managed_file(
995        &self,
996        repo: &Resource,
997        path: &str,
998        contents: &[u8],
999        message: &str,
1000    ) -> Result<StepOutcome> {
1001        let (token, owner, name) = self.repo_token(repo)?;
1002        let Some(branch) = self.get_repo(&token, owner, name).await?.default_branch() else {
1003            return self.write_file(repo, path, contents, message).await;
1004        };
1005        let file = vgi_forge::ExtraFile {
1006            path: path.to_string(),
1007            contents: contents.to_vec(),
1008        };
1009        let report = self
1010            .refresh_on_branch(repo, &branch, std::slice::from_ref(&file), message)
1011            .await?;
1012        Ok(report
1013            .files
1014            .first()
1015            .map_or(report.outcome, |(_, outcome)| *outcome))
1016    }
1017
1018    /// The single maintenance step that brings the managed workflow (and, in
1019    /// the signing-key fallback, the keyring) up to date on a bootstrapped
1020    /// repository — see [`ForgejoForge::refresh_managed_files`].
1021    pub fn refresh_plan(&self, repo: &RepoSpec, cfg: &VgiConfig) -> Result<Vec<BootstrapStep>> {
1022        let files: Vec<vgi_forge::ExtraFile> = self
1023            .bootstrap_plan(repo, cfg)?
1024            .into_iter()
1025            .filter_map(|s| match s.action {
1026                StepAction::WriteFile { path, contents, .. }
1027                    if path == crate::plan::WORKFLOW_PATH || path == crate::plan::KEYRING_PATH =>
1028                {
1029                    Some(vgi_forge::ExtraFile { path, contents })
1030                }
1031                _ => None,
1032            })
1033            .collect();
1034        Ok(vec![BootstrapStep::new(
1035            "refresh-managed-files",
1036            vgi_forge::BootstrapComponent::Workflow,
1037            StepAction::RefreshProtectedFiles {
1038                files,
1039                message: "ci: update the VGI commit-trust check".into(),
1040            },
1041        )])
1042    }
1043
1044    async fn set_variable(&self, repo: &Resource, var: &str, value: &str) -> Result<StepOutcome> {
1045        if var.is_empty()
1046            || !var
1047                .bytes()
1048                .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
1049        {
1050            return Err(ForgeError::Config(format!(
1051                "variable name `{var}` must be [A-Z0-9_]"
1052            )));
1053        }
1054        if !self.probed().info.features.actions_variables {
1055            return Err(ForgeError::Unsupported {
1056                operation: "Actions variables".into(),
1057                hint: "this instance has no variables API; the plan writes the DIDs into the \
1058                       workflow instead — rebuild the plan"
1059                    .into(),
1060            });
1061        }
1062        let (token, owner, name) = self.repo_token(repo)?;
1063        let url = self
1064            .api
1065            .url(&["repos", owner, name, "actions", "variables", var]);
1066        match self
1067            .api
1068            .get_opt::<VariableJson>(url.clone(), Auth::Token(&token), var)
1069            .await?
1070        {
1071            Some(v) if v.data == value => Ok(StepOutcome::Unchanged),
1072            Some(_) => {
1073                let body = json!({ "name": var, "value": value });
1074                self.api
1075                    .send(Method::PUT, url, Auth::Token(&token), Some(&body), var)
1076                    .await?;
1077                Ok(StepOutcome::Updated)
1078            }
1079            None => {
1080                let body = json!({ "value": value });
1081                self.api
1082                    .send(Method::POST, url, Auth::Token(&token), Some(&body), var)
1083                    .await?;
1084                Ok(StepOutcome::Created)
1085            }
1086        }
1087    }
1088
1089    async fn configure_repo(&self, repo: &Resource, s: &RepoSettings) -> Result<StepOutcome> {
1090        let (token, owner, name) = self.repo_token(repo)?;
1091        let r = self.get_repo(&token, owner, name).await?;
1092        let ff_wanted = s.merge_methods.contains(&MergeMethod::FastForward);
1093        let ff_available = self.probed().info.features.fast_forward_only
1094            && r.allow_fast_forward_only_merge.is_some();
1095        if ff_wanted && !ff_available {
1096            return Err(ForgeError::Unsupported {
1097                operation: "fast-forward-only merges".into(),
1098                hint: match self.config.merge_fallback {
1099                    MergeFallback::Fail => format!(
1100                        "`{}` ({}) cannot restrict merges to fast-forward only, and every web \
1101                         merge would land a commit the check never saw. Upgrade to Forgejo 7 \
1102                         or Gitea 1.22, or configure the signing-key merge fallback \
1103                         (the instance must sign merges)",
1104                        self.config.host,
1105                        self.probed().info.version
1106                    ),
1107                    _ => "the plan was built for fast-forward-only merges but the instance \
1108                          does not offer them; rebuild the plan"
1109                        .into(),
1110                },
1111            });
1112        }
1113        if satisfies_settings(&r, s) {
1114            return Ok(StepOutcome::Unchanged);
1115        }
1116
1117        let body = settings_request(&r, s);
1118        let after: RepoJson = self
1119            .api
1120            .json(
1121                Method::PATCH,
1122                self.api.url(&["repos", owner, name]),
1123                Auth::Token(&token),
1124                Some(&body),
1125                repo.as_str(),
1126            )
1127            .await?;
1128        if !satisfies_settings(&after, s) {
1129            return Err(ForgeError::Rejected {
1130                status: 200,
1131                message: format!(
1132                    "{repo}: the instance accepted the settings but did not apply them all \
1133                     (are Actions or pull requests disabled instance-wide?)"
1134                ),
1135            });
1136        }
1137        Ok(StepOutcome::Updated)
1138    }
1139
1140    async fn protect(&self, repo: &Resource, spec: &ProtectionSpec) -> Result<StepOutcome> {
1141        let (token, owner, name) = self.repo_token(repo)?;
1142        let r = self.get_repo(&token, owner, name).await?;
1143        let branch = r.default_branch().ok_or_else(|| ForgeError::Rejected {
1144            status: 409,
1145            message: format!("{repo} is empty: there is no default branch to protect yet"),
1146        })?;
1147        if is_glob(&branch) {
1148            return Err(ForgeError::Unsupported {
1149                operation: "protecting the default branch".into(),
1150                hint: format!(
1151                    "the default branch `{branch}` contains glob characters, so Forgejo would \
1152                     read a rule for it as a pattern; rename the branch"
1153                ),
1154            });
1155        }
1156        let (existing, shadowing) = self.protection_rule(&token, owner, name, &branch).await?;
1157        if !shadowing.is_empty() {
1158            // Never adopt or rely on a rule Forgejo may not apply; deleting
1159            // someone else's rule is a human's decision.
1160            return Err(ForgeError::Rejected {
1161                status: 409,
1162                message: format!(
1163                    "{repo}: branch protection rule(s) {} also match `{branch}` (Forgejo compares \
1164                     rule names case-insensitively and applies the oldest), so the managed rule \
1165                     may never apply; remove them and re-run",
1166                    shadowing.join(", ")
1167                ),
1168            });
1169        }
1170        if let Some(rule) = &existing
1171            && satisfies_protection(rule, spec)
1172        {
1173            return Ok(StepOutcome::Unchanged);
1174        }
1175
1176        // Seed the merge allow-list with the repository's admins: once it is
1177        // enabled, even an admin cannot merge without a place on it (and an
1178        // admin can edit the rule anyway, so this grants nothing new).
1179        // Maintainers are added by `apply_roles`.
1180        let admins: Vec<String> = self
1181            .collaborators(&token, owner, name)
1182            .await?
1183            .into_iter()
1184            .filter(|(_, perm)| *perm == Perm::Admin)
1185            .map(|(account, _)| account.login)
1186            .collect();
1187        let mut body = protection_request(existing.as_ref(), &admins, spec);
1188        let (method, url, outcome) = match &existing {
1189            Some(rule) => (
1190                Method::PATCH,
1191                self.api.url(&[
1192                    "repos",
1193                    owner,
1194                    name,
1195                    "branch_protections",
1196                    rule.name().unwrap_or(&branch),
1197                ]),
1198                StepOutcome::Updated,
1199            ),
1200            None => {
1201                body["rule_name"] = json!(branch);
1202                // Pre-1.22 instances know only `branch_name`.
1203                body["branch_name"] = json!(branch);
1204                (
1205                    Method::POST,
1206                    self.api.url(&["repos", owner, name, "branch_protections"]),
1207                    StepOutcome::Created,
1208                )
1209            }
1210        };
1211        let after: ProtectionJson = self
1212            .api
1213            .json(
1214                method,
1215                url,
1216                Auth::Token(&token),
1217                Some(&body),
1218                "branch protection",
1219            )
1220            .await?;
1221        if !satisfies_protection(&after, spec) {
1222            return Err(ForgeError::Rejected {
1223                status: 200,
1224                message: format!(
1225                    "{repo}: the instance accepted the branch protection but it does not read \
1226                     back as requested"
1227                ),
1228            });
1229        }
1230        Ok(outcome)
1231    }
1232
1233    // ── roles ────────────────────────────────────────────────────────────
1234
1235    /// Drop assignments Forgejo cannot express: the owner of a personal
1236    /// namespace owns every repository in it and cannot be a collaborator.
1237    fn expressible(&self, ns: &Namespace, desired: &[RoleAssignment]) -> Vec<RoleAssignment> {
1238        desired
1239            .iter()
1240            .filter(|a| !is_personal_owner(ns, a.account.id))
1241            .cloned()
1242            .collect()
1243    }
1244
1245    async fn login_for(&self, token: &Secret, id: u64) -> Result<String> {
1246        // The numeric id is the binding; the login is looked up fresh so a
1247        // renamed-and-re-registered login never receives the role. Forgejo
1248        // has no `/user/{id}`; search by `uid` is its lookup by id.
1249        let mut url = self.api.url(&["users", "search"]);
1250        url.query_pairs_mut().append_pair("uid", &id.to_string());
1251        let found: SearchJson = self
1252            .api
1253            .json(Method::GET, url, Auth::Token(token), None, "user")
1254            .await?;
1255        let user =
1256            found
1257                .data
1258                .into_iter()
1259                .find(|u| u.id == id)
1260                .ok_or_else(|| ForgeError::NotFound {
1261                    what: format!("user {id}"),
1262                })?;
1263        check_login(&user.login)?;
1264        Ok(user.login)
1265    }
1266
1267    /// Where `login`'s access to an organisation's repository comes from,
1268    /// other than a direct role: owning the organisation, and the teams
1269    /// with access to the repository that they are in. Best effort: a
1270    /// lookup Forgejo refuses leaves that source out, and the access is
1271    /// still reported.
1272    async fn access_sources(
1273        &self,
1274        token: &Secret,
1275        owner: &str,
1276        name: &str,
1277        login: &str,
1278    ) -> Vec<AccessSource> {
1279        let auth = Auth::Token(token);
1280        let mut via = Vec::new();
1281        let org: Option<OrgPermissionsJson> = self
1282            .api
1283            .get_opt(
1284                self.api
1285                    .url(&["users", login, "orgs", owner, "permissions"]),
1286                auth,
1287                "organisation permissions",
1288            )
1289            .await
1290            .ok()
1291            .flatten();
1292        if org.is_some_and(|o| o.is_owner) {
1293            via.push(AccessSource::OrgOwner(owner.to_string()));
1294        }
1295        let teams: Vec<TeamJson> = self
1296            .api
1297            .get_all(
1298                self.api.url(&["repos", owner, name, "teams"]),
1299                auth,
1300                "repository teams",
1301            )
1302            .await
1303            .unwrap_or_default();
1304        for t in teams {
1305            let url = self
1306                .api
1307                .url(&["teams", &t.id.to_string(), "members", login]);
1308            if self
1309                .api
1310                .exists(url, auth, "team member")
1311                .await
1312                .unwrap_or(false)
1313            {
1314                via.push(AccessSource::Team(t.name));
1315            }
1316        }
1317        via
1318    }
1319
1320    async fn set_collaborator(
1321        &self,
1322        token: &Secret,
1323        owner: &str,
1324        name: &str,
1325        login: &str,
1326        perm: Option<Perm>,
1327    ) -> Result<()> {
1328        let url = self
1329            .api
1330            .url(&["repos", owner, name, "collaborators", login]);
1331        match perm {
1332            Some(p) => {
1333                let body = json!({ "permission": p.as_str() });
1334                self.api
1335                    .send(
1336                        Method::PUT,
1337                        url,
1338                        Auth::Token(token),
1339                        Some(&body),
1340                        "collaborator",
1341                    )
1342                    .await?;
1343            }
1344            None => {
1345                self.api
1346                    .send(
1347                        Method::DELETE,
1348                        url,
1349                        Auth::Token(token),
1350                        None,
1351                        "collaborator",
1352                    )
1353                    .await?;
1354            }
1355        }
1356        Ok(())
1357    }
1358}
1359
1360/// The instance's version, the bot's identity, and (when needed) its
1361/// signing key.
1362async fn probe(api: &Api, config: &ForgejoConfig, token: &Secret) -> Result<Probed> {
1363    #[derive(Deserialize)]
1364    struct Version {
1365        version: String,
1366    }
1367    let v: Version = api
1368        .json(
1369            Method::GET,
1370            api.url(&["version"]),
1371            Auth::Token(token),
1372            None,
1373            "instance version",
1374        )
1375        .await?;
1376    let info = InstanceInfo::from_version(&v.version);
1377    let bot = whoami(api, Auth::Token(token)).await?;
1378    if !bot.login.eq_ignore_ascii_case(&config.bot_login) {
1379        return Err(ForgeError::Config(format!(
1380            "the bot token belongs to `{}`, not the configured bot `{}`",
1381            bot.login, config.bot_login
1382        )));
1383    }
1384    let signing_key = if !info.features.fast_forward_only
1385        && config.merge_fallback == MergeFallback::InstanceSigningKey
1386    {
1387        Some(fetch_signing_key(api, token).await?)
1388    } else {
1389        None
1390    };
1391    Ok(Probed {
1392        info,
1393        bot,
1394        signing_key,
1395    })
1396}
1397
1398async fn whoami(api: &Api, auth: Auth<'_>) -> Result<ForgeAccount> {
1399    let u: UserJson = api
1400        .json(
1401            Method::GET,
1402            api.url(&["user"]),
1403            auth,
1404            None,
1405            "authenticated user",
1406        )
1407        .await?;
1408    Ok(ForgeAccount::new(u.id, u.login))
1409}
1410
1411async fn fetch_signing_key(api: &Api, token: &Secret) -> Result<Vec<u8>> {
1412    let resp = api
1413        .send(
1414            Method::GET,
1415            api.url(&["signing-key.gpg"]),
1416            Auth::Token(token),
1417            None,
1418            "instance signing key",
1419        )
1420        .await?;
1421    resp.bytes()
1422        .await
1423        .map(|b| b.to_vec())
1424        .map_err(|e| ForgeError::Unavailable(e.without_url().to_string()))
1425}
1426
1427const PROTECTED_HINT: &str = " — if the default branch is already protected, this file can only \
1428                              change through a pull request, and the workflow and keyring not \
1429                              even then (they are protected paths, by design): update those \
1430                              with the audited refresh-managed-files step";
1431
1432#[async_trait]
1433impl Forge for ForgejoForge {
1434    fn kind(&self) -> ForgeKind {
1435        ForgeKind::Forgejo
1436    }
1437
1438    fn host(&self) -> &str {
1439        &self.config.host
1440    }
1441
1442    fn capabilities(&self, ns: &Namespace) -> Capabilities {
1443        let automated = ns.installation_id.is_some();
1444        let mut c = Capabilities::default();
1445        c.automation = automated;
1446        c.required_checks = RequiredCheckKind::BranchProtection;
1447        c.account_link = LinkMethod::AuthorizationCodePkce;
1448        // The org webhook announces only repository creation and deletion;
1449        // role, protection, rename and archive drift must be swept for.
1450        c.webhooks = false;
1451        // One bot token for everything: it cannot be narrowed per job.
1452        c.per_repo_tokens = false;
1453        c.role_levels = LADDER.to_vec();
1454        c.bot_can_create_repos = automated && ns.kind == NamespaceKind::Organization;
1455        c
1456    }
1457
1458    /// The owner, and the bot every automated action goes through.
1459    fn is_protected_account(&self, ns: &Namespace, account: u64) -> bool {
1460        ns.owner_id == Some(account) || self.bot().id == account
1461    }
1462
1463    async fn begin_bind(&self, req: BindRequest) -> Result<BindStep> {
1464        if req.namespace.host() != self.config.host || !req.namespace.is_namespace() {
1465            return Err(ForgeError::WrongResource {
1466                resource: req.namespace.to_string(),
1467                expected: format!("a namespace on `{}`", self.config.host),
1468            });
1469        }
1470        if req.state.len() < MIN_STATE_LEN
1471            || !req
1472                .state
1473                .bytes()
1474                .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
1475        {
1476            return Err(ForgeError::Config(format!(
1477                "bind state must be at least {MIN_STATE_LEN} base64url characters from a CSPRNG \
1478                 (see ForgejoForge::new_state)"
1479            )));
1480        }
1481        let verifier = self.oauth_keys.verifier(Purpose::Bind, &req.state);
1482        Ok(BindStep::Redirect {
1483            url: self
1484                .authorize_url(&self.config.bind_redirect_uri, &req.state, &verifier)
1485                .to_string(),
1486        })
1487    }
1488
1489    async fn complete_bind(&self, cb: BindCallback) -> Result<NamespaceBinding> {
1490        let reject = |m: String| Err(ForgeError::BindRejected(m));
1491        let state = cb.params.get("state").map(String::as_str).unwrap_or("");
1492        if cb.expected_state.len() < MIN_STATE_LEN
1493            || aws_lc_rs::constant_time::verify_slices_are_equal(
1494                state.as_bytes(),
1495                cb.expected_state.as_bytes(),
1496            )
1497            .is_err()
1498        {
1499            return reject("the `state` does not match a bind this VTC started".into());
1500        }
1501        if let Some(err) = cb.params.get("error") {
1502            return reject(format!(
1503                "the admin did not authorise the bridge: {err} {}",
1504                cb.params
1505                    .get("error_description")
1506                    .map(String::as_str)
1507                    .unwrap_or("")
1508            ));
1509        }
1510        let ns = &cb.expected_namespace;
1511        if ns.host() != self.config.host || !ns.is_namespace() {
1512            return reject(format!(
1513                "`{ns}` is not a namespace on `{}`",
1514                self.config.host
1515            ));
1516        }
1517        let owner = ns.owner();
1518        let code = match cb.params.get("code") {
1519            Some(c) if !c.is_empty() => c,
1520            _ => return reject("missing authorisation `code`".into()),
1521        };
1522
1523        let verifier = self.oauth_keys.verifier(Purpose::Bind, state);
1524        let admin_token = self
1525            .exchange_code(
1526                code,
1527                &self.config.bind_redirect_uri,
1528                &verifier,
1529                ForgeError::BindRejected,
1530            )
1531            .await?;
1532        let result = self.bind_as_admin(ns, owner, &admin_token).await;
1533        // The one-time admin token is wiped here, whatever happened: the
1534        // bridge never keeps an admin credential.
1535        drop(admin_token);
1536        result
1537    }
1538
1539    async fn begin_account_link(&self, member: &str) -> Result<LinkStep> {
1540        tracing::debug!(member, "starting Forgejo account link");
1541        let state = self.oauth_keys.issue_link_state(member, unix_now())?;
1542        let verifier = self.oauth_keys.verifier(Purpose::Link, &state);
1543        Ok(LinkStep::Redirect {
1544            url: self
1545                .authorize_url(&self.config.link_redirect_uri, &state, &verifier)
1546                .to_string(),
1547        })
1548    }
1549
1550    async fn complete_account_link(&self, cb: LinkCallback) -> Result<ForgeAccount> {
1551        let LinkCallback::Redirect { params, member, .. } = cb else {
1552            return Err(ForgeError::Unsupported {
1553                operation: "device-flow account link".into(),
1554                hint: "Forgejo has no device flow; members link through the browser \
1555                       (authorisation code + PKCE)"
1556                    .into(),
1557            });
1558        };
1559        let state = params.get("state").map(String::as_str).unwrap_or("");
1560        let member = member.ok_or_else(|| {
1561            ForgeError::LinkFailed(
1562                "the callback does not say which member started this link (build it with \
1563                 LinkCallback::redirect and the member from the caller's session)"
1564                    .into(),
1565            )
1566        })?;
1567        self.oauth_keys
1568            .check_link_state(state, &member, unix_now(), self.config.link_state_ttl)?;
1569        if let Some(err) = params.get("error") {
1570            return Err(ForgeError::LinkFailed(format!(
1571                "the member did not authorise the bridge: {err}"
1572            )));
1573        }
1574        let code = match params.get("code") {
1575            Some(c) if !c.is_empty() => c,
1576            _ => {
1577                return Err(ForgeError::LinkFailed(
1578                    "missing authorisation `code`".into(),
1579                ));
1580            }
1581        };
1582        let verifier = self.oauth_keys.verifier(Purpose::Link, state);
1583        let token = self
1584            .exchange_code(
1585                code,
1586                &self.config.link_redirect_uri,
1587                &verifier,
1588                ForgeError::LinkFailed,
1589            )
1590            .await?;
1591        let account = whoami(&self.api, Auth::Bearer(&token)).await;
1592        // `token` is dropped (and wiped) here: the bridge needs the id, not
1593        // a standing credential for the member's account.
1594        drop(token);
1595        account
1596    }
1597
1598    async fn inspect(&self, repo: &Resource) -> Result<RepoState> {
1599        let (token, owner, name) = self.repo_token(repo)?;
1600        let ns = self.namespace(&repo.namespace())?;
1601        let r = self.get_repo(&token, owner, name).await?;
1602        let mut state = self.repo_state(&r)?;
1603        let (rule, shadowing) = match r.default_branch() {
1604            Some(branch) => self.protection_rule(&token, owner, name, &branch).await?,
1605            None => (None, Vec::new()),
1606        };
1607        let allow = rule
1608            .as_ref()
1609            .filter(|r| r.enable_merge_whitelist)
1610            .map(|r| r.merge_whitelist_usernames.clone())
1611            .unwrap_or_default();
1612        for (account, perm) in self.collaborators(&token, owner, name).await? {
1613            if is_personal_owner(&ns, account.id) {
1614                continue;
1615            }
1616            let role = perm.observed(contains_login(&allow, &account.login));
1617            state.collaborators.push(Collaborator::new(account, role));
1618        }
1619        state.protection = self.protection_state(rule.as_ref(), &shadowing, &r);
1620        Ok(state)
1621    }
1622
1623    async fn create_repo(&self, spec: &RepoSpec) -> Result<RepoState> {
1624        let (ns, owner, name) = self.locate(&spec.resource)?;
1625        if !self.capabilities(&ns).bot_can_create_repos {
1626            return Err(ForgeError::Unsupported {
1627                operation: "repository creation".into(),
1628                hint: format!(
1629                    "the bridge cannot create repositories in `{}`; the account holder creates \
1630                     `{owner}/{name}`, adds `{}` as an admin collaborator, runs `vgi repo init`, \
1631                     and the repo is adopted",
1632                    ns.resource, self.config.bot_login
1633                ),
1634            });
1635        }
1636        let private = match spec.visibility {
1637            Visibility::Public => false,
1638            Visibility::Private => true,
1639            _ => {
1640                return Err(ForgeError::Unsupported {
1641                    operation: "internal visibility".into(),
1642                    hint: "Forgejo repositories are public or private".into(),
1643                });
1644            }
1645        };
1646        let token = self.token();
1647        if let Some(existing) = self
1648            .api
1649            .get_opt::<RepoJson>(
1650                self.api.url(&["repos", owner, name]),
1651                Auth::Token(&token),
1652                spec.resource.as_str(),
1653            )
1654            .await?
1655        {
1656            return Err(ForgeError::AlreadyExists {
1657                resource: spec.resource.to_string(),
1658                forge_id: Some(existing.id),
1659            });
1660        }
1661        let mut body = json!({
1662            "name": name,
1663            "private": private,
1664            // A first commit gives the repo a default branch for the
1665            // bootstrap to commit to and the protection to cover.
1666            "auto_init": true,
1667            "readme": "Default",
1668            "default_branch": "main",
1669        });
1670        if let Some(d) = &spec.description {
1671            body["description"] = json!(d);
1672        }
1673        let created: RepoJson = self
1674            .api
1675            .json(
1676                Method::POST,
1677                self.api.url(&["orgs", owner, "repos"]),
1678                Auth::Token(&token),
1679                Some(&body),
1680                spec.resource.as_str(),
1681            )
1682            .await
1683            .map_err(|e| match e {
1684                ForgeError::Rejected { status: 409, .. } => ForgeError::AlreadyExists {
1685                    resource: spec.resource.to_string(),
1686                    forge_id: None,
1687                },
1688                e => e,
1689            })?;
1690        self.repo_state(&created)
1691    }
1692
1693    async fn archive_repo(&self, repo: &Resource) -> Result<()> {
1694        let (token, owner, name) = self.repo_token(repo)?;
1695        let r = self.get_repo(&token, owner, name).await?;
1696        if r.archived {
1697            return Ok(());
1698        }
1699        self.api
1700            .send(
1701                Method::PATCH,
1702                self.api.url(&["repos", owner, name]),
1703                Auth::Token(&token),
1704                Some(&json!({ "archived": true })),
1705                repo.as_str(),
1706            )
1707            .await?;
1708        Ok(())
1709    }
1710
1711    async fn apply_roles(
1712        &self,
1713        repo: &Resource,
1714        desired: &[RoleAssignment],
1715        unlisted: Unlisted,
1716    ) -> Result<ApplyReport> {
1717        let (ns, owner, name) = self.locate(repo)?;
1718        self.automated(&ns)?;
1719        let desired = self.expressible(&ns, desired);
1720        let mut wanted: BTreeMap<u64, (ForgeAccount, ForgeRole)> = BTreeMap::new();
1721        for a in &desired {
1722            // Round down onto the ladder again: never trust the caller to
1723            // have done it.
1724            let role = collapse_to_ladder(a.role, &LADDER);
1725            if let Some((_, prev)) = wanted.insert(a.account.id, (a.account.clone(), role))
1726                && prev != role
1727            {
1728                return Err(ForgeError::Config(format!(
1729                    "account {} is assigned two different roles",
1730                    a.account.id
1731                )));
1732            }
1733        }
1734
1735        let token = self.token();
1736        let r = self.get_repo(&token, owner, name).await?;
1737        let rule = match r.default_branch() {
1738            Some(branch) => self.protection_rule(&token, owner, name, &branch).await?.0,
1739            None => None,
1740        };
1741        let allow: Vec<String> = rule
1742            .as_ref()
1743            .filter(|r| r.enable_merge_whitelist)
1744            .map(|r| r.merge_whitelist_usernames.clone())
1745            .unwrap_or_default();
1746        let mut current: BTreeMap<u64, Have> = BTreeMap::new();
1747        for (account, perm) in self.collaborators(&token, owner, name).await? {
1748            if is_personal_owner(&ns, account.id) {
1749                // Never reported as unlisted, never removed.
1750                continue;
1751            }
1752            let listed = contains_login(&allow, &account.login);
1753            current.insert(
1754                account.id,
1755                Have {
1756                    account,
1757                    perm,
1758                    listed,
1759                },
1760            );
1761        }
1762
1763        let fatal = |e: &ForgeError| {
1764            matches!(
1765                e,
1766                ForgeError::Unauthorized(_) | ForgeError::RateLimited { .. }
1767            )
1768        };
1769        let mut report = ApplyReport::default();
1770        // Allow-list edits, applied in one write at the end: logins to add
1771        // (fresh), ids whose entries go, and the changes that depend on it.
1772        let mut list_add: Vec<String> = Vec::new();
1773        let mut list_drop: BTreeSet<u64> = BTreeSet::new();
1774        let mut list_dependent: Vec<usize> = Vec::new();
1775        let mut keep_listed: BTreeSet<u64> = BTreeSet::new();
1776
1777        let bot = self.bot().id;
1778        for (id, (account, role)) in &wanted {
1779            let have = current.get(id);
1780            if *id == bot
1781                && *role == ForgeRole::None
1782                && let Some(h) = have
1783            {
1784                // Whatever the caller asked: the bot losing its role would
1785                // end every automated action here.
1786                report.changes.push(RoleChange::new(
1787                    account.clone(),
1788                    h.perm.observed(h.listed),
1789                    ForgeRole::None,
1790                    RoleOutcome::Failed("the bridge's own bot is never removed".into()),
1791                ));
1792                continue;
1793            }
1794            let need_perm = Perm::for_role(*role);
1795            let need_listed = rule.is_some() && *role >= ForgeRole::Maintain;
1796            let have_perm = have.map(|h| h.perm);
1797            let have_listed = have.is_some_and(|h| h.listed);
1798            // `Maintain` is write *plus* the allow-list; before the bootstrap
1799            // there is no rule to put anyone on.
1800            let unexpressible = *role == ForgeRole::Maintain && rule.is_none();
1801            if need_listed {
1802                keep_listed.insert(*id);
1803            }
1804            if have_perm == need_perm && have_listed == need_listed && !unexpressible {
1805                if *role != ForgeRole::None {
1806                    report.unchanged.push(account.clone());
1807                }
1808                continue;
1809            }
1810            let from = have.map_or(ForgeRole::None, |h| h.perm.observed(h.listed));
1811            let mut outcome = RoleOutcome::Applied;
1812            let mut fresh_login = None;
1813            if have_perm != need_perm {
1814                let result = match need_perm {
1815                    Some(p) => match self.login_for(&token, *id).await {
1816                        Ok(login) => {
1817                            let r = self
1818                                .set_collaborator(&token, owner, name, &login, Some(p))
1819                                .await;
1820                            fresh_login = Some(login);
1821                            r
1822                        }
1823                        Err(e) => Err(e),
1824                    },
1825                    None => {
1826                        let login = &have.expect("have_perm differs from None").account.login;
1827                        self.set_collaborator(&token, owner, name, login, None)
1828                            .await
1829                    }
1830                };
1831                if let Err(e) = result {
1832                    if fatal(&e) {
1833                        return Err(e);
1834                    }
1835                    report.changes.push(RoleChange::new(
1836                        account.clone(),
1837                        from,
1838                        *role,
1839                        RoleOutcome::Failed(e.to_string()),
1840                    ));
1841                    continue;
1842                }
1843            }
1844            if need_listed && !have_listed {
1845                let login = match fresh_login {
1846                    Some(l) => Ok(l),
1847                    None => self.login_for(&token, *id).await,
1848                };
1849                match login {
1850                    Ok(l) => {
1851                        list_add.push(l);
1852                        list_dependent.push(report.changes.len());
1853                    }
1854                    Err(e) if fatal(&e) => return Err(e),
1855                    Err(e) => outcome = RoleOutcome::Failed(e.to_string()),
1856                }
1857            } else if !need_listed && have_listed {
1858                list_drop.insert(*id);
1859                list_dependent.push(report.changes.len());
1860            }
1861            if unexpressible {
1862                outcome = RoleOutcome::Failed(
1863                    "granted `write`; `maintain` also needs a place on the default branch's merge \
1864                     allow-list, which exists once the repository is bootstrapped"
1865                        .into(),
1866                );
1867            }
1868            report
1869                .changes
1870                .push(RoleChange::new(account.clone(), from, *role, outcome));
1871        }
1872
1873        for (id, have) in &current {
1874            if wanted.contains_key(id) {
1875                continue;
1876            }
1877            let observed = have.perm.observed(have.listed);
1878            match unlisted {
1879                Unlisted::Remove => {
1880                    let outcome = match self
1881                        .set_collaborator(&token, owner, name, &have.account.login, None)
1882                        .await
1883                    {
1884                        Ok(()) => RoleOutcome::Applied,
1885                        Err(e) if fatal(&e) => return Err(e),
1886                        Err(e) => RoleOutcome::Failed(e.to_string()),
1887                    };
1888                    if have.listed {
1889                        list_drop.insert(*id);
1890                    }
1891                    report.changes.push(RoleChange::new(
1892                        have.account.clone(),
1893                        observed,
1894                        ForgeRole::None,
1895                        outcome,
1896                    ));
1897                }
1898                _ => {
1899                    if have.listed {
1900                        keep_listed.insert(*id);
1901                    }
1902                    report
1903                        .kept_unlisted
1904                        .push(Collaborator::new(have.account.clone(), observed));
1905                }
1906            }
1907        }
1908
1909        if let Some(rule) = &rule {
1910            let id_of = |login: &str| {
1911                current
1912                    .values()
1913                    .find(|h| h.account.login.eq_ignore_ascii_case(login))
1914                    .map(|h| h.account.id)
1915            };
1916            let mut next: Vec<String> = allow
1917                .iter()
1918                .filter(|login| match id_of(login) {
1919                    Some(id) => !list_drop.contains(&id) && keep_listed.contains(&id),
1920                    // Someone on the list who is not a collaborator: kept in
1921                    // report mode, removed in enforce mode.
1922                    None => unlisted != Unlisted::Remove,
1923                })
1924                .cloned()
1925                .collect();
1926            for login in list_add {
1927                if !contains_login(&next, &login) {
1928                    next.push(login);
1929                }
1930            }
1931            let same = next.len() == allow.len()
1932                && next.iter().all(|l| contains_login(&allow, l))
1933                && rule.enable_merge_whitelist;
1934            if !same {
1935                let branch = rule.name().unwrap_or_default().to_string();
1936                let body = json!({
1937                    "enable_merge_whitelist": true,
1938                    "merge_whitelist_usernames": next,
1939                });
1940                let result = self
1941                    .api
1942                    .send(
1943                        Method::PATCH,
1944                        self.api
1945                            .url(&["repos", owner, name, "branch_protections", &branch]),
1946                        Auth::Token(&token),
1947                        Some(&body),
1948                        "merge allow-list",
1949                    )
1950                    .await;
1951                if let Err(e) = result {
1952                    if fatal(&e) {
1953                        return Err(e);
1954                    }
1955                    for i in list_dependent {
1956                        if let Some(c) = report.changes.get_mut(i)
1957                            && c.outcome == RoleOutcome::Applied
1958                        {
1959                            c.outcome = RoleOutcome::Failed(format!("merge allow-list: {e}"));
1960                        }
1961                    }
1962                }
1963            }
1964        }
1965        Ok(report)
1966    }
1967
1968    /// Forgejo's effective permission for the account
1969    /// (`/collaborators/{collaborator}/permission` counts teams and
1970    /// organisation ownership), then where it comes from. Forgejo has no
1971    /// organisation-wide base permission: an organisation's members reach
1972    /// its repositories through teams (the owners through the Owners team).
1973    async fn indirect_access(
1974        &self,
1975        repo: &Resource,
1976        account: &ForgeAccount,
1977    ) -> Result<Option<IndirectAccess>> {
1978        let (ns, owner, name) = self.locate(repo)?;
1979        self.automated(&ns)?;
1980        let token = self.token();
1981        let login = match self.login_for(&token, account.id).await {
1982            Ok(l) => l,
1983            // No such account any more: it has no access.
1984            Err(ForgeError::NotFound { .. }) => return Ok(None),
1985            Err(e) => return Err(e),
1986        };
1987        let url = self
1988            .api
1989            .url(&["repos", owner, name, "collaborators", &login, "permission"]);
1990        let Some(p) = self
1991            .api
1992            .get_opt::<PermissionJson>(url, Auth::Token(&token), "collaborator permission")
1993            .await?
1994        else {
1995            return Ok(None);
1996        };
1997        let Some(perm) = Perm::parse(&p.permission) else {
1998            // `none`.
1999            return Ok(None);
2000        };
2001        // Everyone reads a public repository: that is no access to report.
2002        if perm == Perm::Read && !self.get_repo(&token, owner, name).await?.private {
2003            return Ok(None);
2004        }
2005        let via = if ns.kind == NamespaceKind::Organization {
2006            self.access_sources(&token, owner, name, &login).await
2007        } else {
2008            Vec::new()
2009        };
2010        Ok(Some(IndirectAccess::new(perm.observed(false), via)))
2011    }
2012
2013    fn bootstrap_plan(&self, repo: &RepoSpec, cfg: &VgiConfig) -> Result<Vec<BootstrapStep>> {
2014        if repo.resource.host() != self.config.host {
2015            return Err(ForgeError::WrongResource {
2016                resource: repo.resource.to_string(),
2017                expected: format!("a repository on `{}`", self.config.host),
2018            });
2019        }
2020        repo.resource.require_owner_repo()?;
2021        let probed = self.probed();
2022        let key: Option<Vec<u8>> = if probed.info.features.fast_forward_only {
2023            None
2024        } else {
2025            match self.config.merge_fallback {
2026                // The step will fail, naming the upgrade; the plan still
2027                // asks for fast-forward only.
2028                MergeFallback::Fail => None,
2029                _ => Some(
2030                    cfg.platform_keyring
2031                        .clone()
2032                        .or(probed.signing_key.clone())
2033                        .ok_or_else(|| {
2034                            ForgeError::Config(
2035                                "the signing-key merge fallback needs the instance's signing \
2036                                 key; refresh the adapter or supply it as the platform keyring"
2037                                    .into(),
2038                            )
2039                        })?,
2040                ),
2041            }
2042        };
2043        let opts = PlanOptions {
2044            checkout_action: &self.config.checkout_action,
2045            actions_base: &self.config.actions_base,
2046            runs_on: &self.config.runs_on,
2047            status_context: self.config.status_context(&cfg.required_check),
2048            inline_variables: !(self.config.use_actions_variables
2049                && probed.info.features.actions_variables),
2050            merges: match &key {
2051                Some(k) => MergePlan::SigningKey(k),
2052                None => MergePlan::FastForwardOnly,
2053            },
2054        };
2055        forgejo_plan(repo, cfg, &opts)
2056    }
2057
2058    async fn run_step(&self, repo: &Resource, step: &BootstrapStep) -> Result<StepOutcome> {
2059        match &step.action {
2060            StepAction::WriteFile {
2061                path,
2062                contents,
2063                message,
2064            } if path == crate::plan::WORKFLOW_PATH || path == crate::plan::KEYRING_PATH => {
2065                self.write_managed_file(repo, path, contents, message).await
2066            }
2067            StepAction::WriteFile {
2068                path,
2069                contents,
2070                message,
2071            } => self.write_file(repo, path, contents, message).await,
2072            StepAction::SetVariable { name, value } => self.set_variable(repo, name, value).await,
2073            StepAction::ProtectDefaultBranch(spec) => self.protect(repo, spec).await,
2074            StepAction::ConfigureRepo(settings) => self.configure_repo(repo, settings).await,
2075            StepAction::RefreshProtectedFiles { files, message } => self
2076                .refresh_managed_files(repo, files, message)
2077                .await
2078                .map(|r| r.outcome),
2079            other => Err(ForgeError::Unsupported {
2080                operation: format!("bootstrap step {other:?}"),
2081                hint: "this Forgejo adapter does not know that step".into(),
2082            }),
2083        }
2084    }
2085
2086    fn parse_event(&self, headers: &HeaderMap, body: &[u8]) -> Result<Option<ForgeEvent>> {
2087        webhook::parse(&self.webhook_secret, &self.config.host, headers, body)
2088    }
2089
2090    /// The neutral comparison, with the check named as Forgejo reports it
2091    /// (`<workflow> / <job> (pull_request)`), plus what makes the check mean
2092    /// something on Forgejo: the protected workflow paths, fast-forward-only
2093    /// merges, and Actions being on.
2094    fn diff(&self, observed: &RepoState, desired: &Projection) -> Vec<Drift> {
2095        let mut want = desired.clone();
2096        want.required_check = desired
2097            .required_check
2098            .as_deref()
2099            .map(|c| self.config.status_context(c));
2100        let mut drift = default_diff(observed, &want);
2101        if desired.required_check.is_none()
2102            || drift.iter().any(|d| matches!(d, Drift::Replaced { .. }))
2103        {
2104            return drift;
2105        }
2106        let extra = self.forgejo_gaps(&observed.protection);
2107        if extra.is_empty() {
2108            return drift;
2109        }
2110        match drift
2111            .iter_mut()
2112            .find(|d| matches!(d, Drift::ProtectionWeakened { .. }))
2113        {
2114            Some(Drift::ProtectionWeakened { gaps }) => {
2115                if !gaps.contains(&ProtectionGap::Missing) {
2116                    gaps.extend(extra);
2117                } else {
2118                    gaps.extend(
2119                        extra
2120                            .into_iter()
2121                            .filter(|g| !matches!(g, ProtectionGap::UnprotectedPaths { .. })),
2122                    );
2123                }
2124            }
2125            _ => drift.push(Drift::ProtectionWeakened { gaps: extra }),
2126        }
2127        drift
2128    }
2129}
2130
2131impl ForgejoForge {
2132    fn authorize_url(&self, redirect: &url::Url, state: &str, verifier: &Secret) -> url::Url {
2133        let mut url = self.api.web_url(&["login", "oauth", "authorize"]);
2134        {
2135            let mut q = url.query_pairs_mut();
2136            q.append_pair("client_id", &self.config.oauth_client_id)
2137                .append_pair("redirect_uri", redirect.as_str())
2138                .append_pair("response_type", "code")
2139                .append_pair("state", state)
2140                .append_pair("code_challenge", &OAuthKeys::challenge(verifier))
2141                .append_pair("code_challenge_method", "S256");
2142            if let Some(scope) = &self.config.oauth_scope {
2143                q.append_pair("scope", scope);
2144            }
2145        }
2146        url
2147    }
2148
2149    async fn exchange_code(
2150        &self,
2151        code: &str,
2152        redirect: &url::Url,
2153        verifier: &Secret,
2154        fail: fn(String) -> ForgeError,
2155    ) -> Result<Secret> {
2156        let url = self.api.web_url(&["login", "oauth", "access_token"]);
2157        let t: TokenJson = self
2158            .api
2159            .oauth_token(
2160                url,
2161                &[
2162                    ("grant_type", "authorization_code"),
2163                    ("code", code),
2164                    ("redirect_uri", redirect.as_str()),
2165                    ("client_id", &self.config.oauth_client_id),
2166                    ("client_secret", self.oauth_secret.expose()),
2167                    ("code_verifier", verifier.expose()),
2168                ],
2169            )
2170            .await?;
2171        t.into_token(fail)
2172    }
2173
2174    /// The bind, with the admin's one-time token: confirm ownership, set up
2175    /// the team and the bot, and the webhook.
2176    async fn bind_as_admin(
2177        &self,
2178        ns: &Resource,
2179        owner: &str,
2180        admin_token: &Secret,
2181    ) -> Result<NamespaceBinding> {
2182        let reject = |m: String| Err(ForgeError::BindRejected(m));
2183        let admin_auth = Auth::Bearer(admin_token);
2184        let admin = whoami(&self.api, admin_auth).await?;
2185        let bot = self.bot();
2186        if admin.id == bot.id {
2187            return reject(
2188                "the bot cannot bind a namespace: an owner must sign in as themselves".into(),
2189            );
2190        }
2191        check_login(owner)?;
2192        let org: Option<OrgJson> = self
2193            .api
2194            .get_opt(self.api.url(&["orgs", owner]), admin_auth, "organisation")
2195            .await?;
2196        let Some(org) = org else {
2197            // A personal namespace: only its holder can bind it.
2198            if !admin.login.eq_ignore_ascii_case(owner) {
2199                return reject(format!(
2200                    "`{owner}` is not an organisation, and `{}` signed in — only the account \
2201                     holder can bind a personal namespace",
2202                    admin.login
2203                ));
2204            }
2205            let namespace = Namespace::new(ns.clone(), NamespaceKind::User)
2206                .with_owner_id(admin.id)
2207                .with_installation(bot.id);
2208            return Ok(NamespaceBinding::new(namespace, Vec::new()));
2209        };
2210
2211        // Owning the org is the binding proof.
2212        let perms: OrgPermsJson = self
2213            .api
2214            .json(
2215                Method::GET,
2216                self.api
2217                    .url(&["users", &admin.login, "orgs", owner, "permissions"]),
2218                admin_auth,
2219                None,
2220                "organisation permissions",
2221            )
2222            .await?;
2223        if !perms.is_owner {
2224            return reject(format!(
2225                "`{}` is not an owner of `{owner}`; an owner must bind the namespace",
2226                admin.login
2227            ));
2228        }
2229
2230        let team = self.ensure_team(admin_token, owner).await?;
2231        let member = self
2232            .api
2233            .url(&["teams", &team.id.to_string(), "members", &bot.login]);
2234        if !self
2235            .api
2236            .exists(member.clone(), admin_auth, "team member")
2237            .await?
2238        {
2239            self.api
2240                .send(Method::PUT, member, admin_auth, None, "team member")
2241                .await?;
2242        }
2243
2244        let mut missing = Vec::new();
2245        // Confirm from the bot's side that the team gives it what it needs.
2246        let bot_perms: OrgPermsJson = self
2247            .api
2248            .json(
2249                Method::GET,
2250                self.api
2251                    .url(&["users", &bot.login, "orgs", owner, "permissions"]),
2252                Auth::Token(&self.token()),
2253                None,
2254                "bot organisation permissions",
2255            )
2256            .await?;
2257        if !bot_perms.can_create_repository {
2258            missing.push(format!(
2259                "create repositories in `{owner}` (team `{}`)",
2260                self.config.team_name
2261            ));
2262        }
2263        if let Some(hook_url) = &self.config.webhook_url {
2264            match self.ensure_hook(admin_token, owner, hook_url).await {
2265                Ok(()) => {}
2266                Err(ForgeError::Forbidden(m) | ForgeError::Rejected { message: m, .. }) => {
2267                    missing.push(format!("org webhook: {m}"));
2268                }
2269                Err(ForgeError::NotFound { .. }) => {
2270                    missing.push("org webhook: webhooks are disabled on the instance".into());
2271                }
2272                Err(e) => return Err(e),
2273            }
2274        }
2275        let namespace = Namespace::new(ns.clone(), NamespaceKind::Organization)
2276            .with_owner_id(org.id)
2277            .with_installation(team.id);
2278        Ok(NamespaceBinding::new(namespace, missing))
2279    }
2280
2281    async fn ensure_team(&self, admin_token: &Secret, org: &str) -> Result<TeamJson> {
2282        let auth = Auth::Bearer(admin_token);
2283        let teams: Vec<TeamJson> = self
2284            .api
2285            .get_all(self.api.url(&["orgs", org, "teams"]), auth, "teams")
2286            .await?;
2287        let body = json!({
2288            "name": self.config.team_name,
2289            "description": "VGI bridge bot: creates repositories and enforces the VTC's roles \
2290                            and commit-trust protection. Managed by the bridge.",
2291            "permission": "admin",
2292            "can_create_org_repo": true,
2293            "includes_all_repositories": true,
2294            "units": TEAM_UNITS,
2295        });
2296        let existing = teams
2297            .into_iter()
2298            .find(|t| t.name.eq_ignore_ascii_case(&self.config.team_name));
2299        if let Some(t) = &existing {
2300            // The team is granted admin on every repository. Adopting one
2301            // someone else already uses would hand that to its members, so
2302            // only a team that is empty or holds just the bot is taken over.
2303            let bot = self.bot();
2304            let members: Vec<UserJson> = self
2305                .api
2306                .get_all(
2307                    self.api.url(&["teams", &t.id.to_string(), "members"]),
2308                    auth,
2309                    "team members",
2310                )
2311                .await?;
2312            let others: Vec<String> = members
2313                .into_iter()
2314                .filter(|m| m.id != bot.id)
2315                .map(|m| m.login)
2316                .collect();
2317            if !others.is_empty() {
2318                return Err(ForgeError::BindRejected(format!(
2319                    "`{org}` already has a team named `{}` with other members ({}); the bridge \
2320                     will not adopt it and grant them admin on every repository. Rename that \
2321                     team or configure another team name",
2322                    t.name,
2323                    others.join(", ")
2324                )));
2325            }
2326        }
2327        match existing {
2328            Some(t)
2329                if t.permission == "admin"
2330                    && t.can_create_org_repo
2331                    && t.includes_all_repositories =>
2332            {
2333                Ok(t)
2334            }
2335            Some(t) => {
2336                self.api
2337                    .json(
2338                        Method::PATCH,
2339                        self.api.url(&["teams", &t.id.to_string()]),
2340                        auth,
2341                        Some(&body),
2342                        "team",
2343                    )
2344                    .await
2345            }
2346            None => {
2347                self.api
2348                    .json(
2349                        Method::POST,
2350                        self.api.url(&["orgs", org, "teams"]),
2351                        auth,
2352                        Some(&body),
2353                        "team",
2354                    )
2355                    .await
2356            }
2357        }
2358    }
2359
2360    async fn ensure_hook(&self, admin_token: &Secret, org: &str, url: &url::Url) -> Result<()> {
2361        let auth = Auth::Bearer(admin_token);
2362        let hooks: Vec<HookJson> = self
2363            .api
2364            .get_all(self.api.url(&["orgs", org, "hooks"]), auth, "org webhooks")
2365            .await?;
2366        let config = json!({
2367            "url": url.as_str(),
2368            "content_type": "json",
2369            "secret": self.webhook_secret.expose(),
2370        });
2371        let existing = hooks.into_iter().find(|h| {
2372            h.config.get("url").map(String::as_str) == Some(url.as_str())
2373                || h.url.as_deref() == Some(url.as_str())
2374        });
2375        match existing {
2376            // The secret cannot be read back, so an existing hook is always
2377            // rewritten: that is what makes a re-bind repair a changed one.
2378            Some(h) => {
2379                let body = json!({ "config": config, "events": HOOK_EVENTS, "active": true });
2380                self.api
2381                    .send(
2382                        Method::PATCH,
2383                        self.api.url(&["orgs", org, "hooks", &h.id.to_string()]),
2384                        auth,
2385                        Some(&body),
2386                        "org webhook",
2387                    )
2388                    .await?;
2389            }
2390            None => {
2391                let kind = if self.probed().info.features.forgejo_webhooks {
2392                    "forgejo"
2393                } else {
2394                    "gitea"
2395                };
2396                let body = json!({
2397                    "type": kind,
2398                    "config": config,
2399                    "events": HOOK_EVENTS,
2400                    "active": true,
2401                });
2402                self.api
2403                    .send(
2404                        Method::POST,
2405                        self.api.url(&["orgs", org, "hooks"]),
2406                        auth,
2407                        Some(&body),
2408                        "org webhook",
2409                    )
2410                    .await?;
2411            }
2412        }
2413        Ok(())
2414    }
2415}
2416
2417impl ForgeHooks for ForgejoForge {
2418    /// The holder of a personal namespace owns every repository in it and
2419    /// cannot be added as a collaborator, so they are dropped from the
2420    /// desired set before it reaches Forgejo (and before the core reports
2421    /// their "missing" role as drift).
2422    fn before_apply_roles(
2423        &self,
2424        repo: &Resource,
2425        desired: &[RoleAssignment],
2426    ) -> HookDecision<Vec<RoleAssignment>> {
2427        let Ok(ns) = self.namespace(&repo.namespace()) else {
2428            return HookDecision::Continue;
2429        };
2430        let kept = self.expressible(&ns, desired);
2431        if kept.len() == desired.len() {
2432            HookDecision::Continue
2433        } else {
2434            HookDecision::Modify(kept)
2435        }
2436    }
2437}
2438
2439// ── helpers ──────────────────────────────────────────────────────────────
2440
2441/// A collaborator's repository permission, as Forgejo reports it.
2442#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2443enum Perm {
2444    Read,
2445    Write,
2446    Admin,
2447}
2448
2449impl Perm {
2450    fn parse(s: &str) -> Option<Perm> {
2451        match s {
2452            "read" => Some(Perm::Read),
2453            "write" => Some(Perm::Write),
2454            "admin" | "owner" => Some(Perm::Admin),
2455            _ => None,
2456        }
2457    }
2458
2459    fn as_str(self) -> &'static str {
2460        match self {
2461            Perm::Read => "read",
2462            Perm::Write => "write",
2463            Perm::Admin => "admin",
2464        }
2465    }
2466
2467    /// The collaborator permission a role needs; `None` for no role.
2468    fn for_role(role: ForgeRole) -> Option<Perm> {
2469        match role {
2470            ForgeRole::Admin => Some(Perm::Admin),
2471            ForgeRole::Maintain | ForgeRole::Write => Some(Perm::Write),
2472            ForgeRole::None => None,
2473            _ => Some(Perm::Read),
2474        }
2475    }
2476
2477    /// The role this permission (and allow-list place) amounts to.
2478    fn observed(self, listed: bool) -> ForgeRole {
2479        match self {
2480            Perm::Admin => ForgeRole::Admin,
2481            Perm::Write if listed => ForgeRole::Maintain,
2482            Perm::Write => ForgeRole::Write,
2483            Perm::Read => ForgeRole::Read,
2484        }
2485    }
2486}
2487
2488/// Someone's standing on a repository before a change.
2489struct Have {
2490    account: ForgeAccount,
2491    perm: Perm,
2492    listed: bool,
2493}
2494
2495/// The holder of a personal namespace: owns every repository in it, is
2496/// never a collaborator.
2497fn is_personal_owner(ns: &Namespace, id: u64) -> bool {
2498    ns.kind == NamespaceKind::User && ns.owner_id == Some(id)
2499}
2500
2501/// Whether Forgejo reads `name` as a glob pattern rather than a plain name
2502/// (gobwas `syntax.Special`). The same characters in a required status
2503/// context make it a pattern too — and an invalid pattern there matches as
2504/// if nothing were required, so the plan refuses them.
2505pub(crate) fn is_glob(name: &str) -> bool {
2506    name.contains(['*', '?', '[', ']', '{', '}', '\\'])
2507}
2508
2509fn contains_login(list: &[String], login: &str) -> bool {
2510    list.iter().any(|l| l.eq_ignore_ascii_case(login))
2511}
2512
2513/// Forgejo's `;`-separated pattern list, as it compiles it: trimmed and
2514/// lowercased, empties dropped.
2515fn patterns(s: &str) -> Vec<String> {
2516    s.split(';')
2517        .map(|p| p.trim().to_ascii_lowercase())
2518        .filter(|p| !p.is_empty())
2519        .collect()
2520}
2521
2522fn last_eight(token: &str) -> Option<String> {
2523    (token.len() >= 8).then(|| token[token.len() - 8..].to_string())
2524}
2525
2526fn merge_style(m: MergeMethod) -> &'static str {
2527    match m {
2528        MergeMethod::FastForward => "fast-forward-only",
2529        MergeMethod::Rebase => "rebase",
2530        MergeMethod::RebaseMerge => "rebase-merge",
2531        MergeMethod::Squash => "squash",
2532        _ => "merge",
2533    }
2534}
2535
2536/// The managed rule among `rules` — the one named exactly `branch` — and
2537/// the names of any other plain rules Forgejo may apply to the branch in its
2538/// place (a case-insensitive name match; see `protection_rule`).
2539fn select_rule(rules: &[ProtectionJson], branch: &str) -> (Option<usize>, Vec<String>) {
2540    let folded = branch.to_lowercase();
2541    let mut managed = None;
2542    let mut shadowing = Vec::new();
2543    for (i, rule) in rules.iter().enumerate() {
2544        match rule.name() {
2545            Some(n) if n == branch => managed = Some(i),
2546            Some(n) if !is_glob(n) && n.to_lowercase() == folded => shadowing.push(n.to_string()),
2547            _ => {}
2548        }
2549    }
2550    (managed, shadowing)
2551}
2552
2553/// The `PATCH /repos/{owner}/{repo}` body that makes the repository's merge
2554/// and CI settings `s`.
2555fn settings_request(r: &RepoJson, s: &RepoSettings) -> Value {
2556    let has = |m| s.merge_methods.contains(&m);
2557    let mut body = json!({});
2558    if !s.merge_methods.is_empty() {
2559        // Forgejo applies merge settings only alongside
2560        // `has_pull_requests`.
2561        body = json!({
2562            "has_pull_requests": true,
2563            "allow_merge_commits": has(MergeMethod::MergeCommit),
2564            "allow_rebase": has(MergeMethod::Rebase),
2565            "allow_rebase_explicit": has(MergeMethod::RebaseMerge),
2566            "allow_squash_merge": has(MergeMethod::Squash),
2567            "default_merge_style": merge_style(s.merge_methods[0]),
2568        });
2569        if r.allow_fast_forward_only_merge.is_some() {
2570            body["allow_fast_forward_only_merge"] = json!(has(MergeMethod::FastForward));
2571        }
2572    }
2573    if s.enable_ci {
2574        body["has_actions"] = json!(true);
2575    }
2576    body
2577}
2578
2579/// The branch-protection body for `spec`, keeping what an `existing` rule
2580/// already has (its merge allow-list, contexts and protected paths) and
2581/// seeding the allow-list with `admins`. A new rule also needs its
2582/// `rule_name` / `branch_name`, which the caller adds.
2583fn protection_request(
2584    existing: Option<&ProtectionJson>,
2585    admins: &[String],
2586    spec: &ProtectionSpec,
2587) -> Value {
2588    let mut allow: Vec<String> = existing
2589        .filter(|r| r.enable_merge_whitelist)
2590        .map(|r| r.merge_whitelist_usernames.clone())
2591        .unwrap_or_default();
2592    for login in admins {
2593        if !contains_login(&allow, login) {
2594            allow.push(login.clone());
2595        }
2596    }
2597    let mut contexts = existing
2598        .map(|r| r.status_check_contexts.clone())
2599        .unwrap_or_default();
2600    if !contexts.contains(&spec.required_check) {
2601        contexts.push(spec.required_check.clone());
2602    }
2603    let mut paths = existing
2604        .map(|r| patterns(&r.protected_file_patterns))
2605        .unwrap_or_default();
2606    for p in &spec.protected_paths {
2607        let p = p.to_ascii_lowercase();
2608        if !paths.contains(&p) {
2609            paths.push(p);
2610        }
2611    }
2612    let mut body = json!({
2613        "enable_push": !spec.require_pull_request,
2614        "enable_push_whitelist": false,
2615        "push_whitelist_usernames": [],
2616        "push_whitelist_teams": [],
2617        "push_whitelist_deploy_keys": false,
2618        "enable_merge_whitelist": true,
2619        "merge_whitelist_usernames": allow,
2620        "merge_whitelist_teams": [],
2621        "enable_status_check": true,
2622        "status_check_contexts": contexts,
2623        "protected_file_patterns": paths.join(";"),
2624        "unprotected_file_patterns": "",
2625        "apply_to_admins": true,
2626    });
2627    // Approvals from anyone with write access (no approvals whitelist), which
2628    // under the bridge is who its rights project to; dismissed by a later push
2629    // so an approval cannot outlive what it approved. Sent only when asked
2630    // for, so a bridge without `required_approvals` asks what it always has.
2631    if spec.approvals_needed() > 0 {
2632        body["required_approvals"] = json!(spec.approvals_needed());
2633        body["enable_approvals_whitelist"] = json!(false);
2634        body["dismiss_stale_approvals"] = json!(true);
2635    }
2636    body
2637}
2638
2639// ── the same shapes, for a client acting as the repository's admin ───────
2640//
2641// `vgi repo init` runs a bootstrap plan as the account holder, through their
2642// own token, where no bridge exists. These let it ask Forgejo for exactly
2643// what `run_step` asks for, and skip exactly what `run_step` would skip.
2644
2645fn parse<T: serde::de::DeserializeOwned>(what: &str, v: &Value) -> Result<T> {
2646    serde_json::from_value(v.clone()).map_err(|e| ForgeError::Protocol(format!("{what}: {e}")))
2647}
2648
2649/// From `GET /repos/{owner}/{repo}/branch_protections`: the managed rule for
2650/// `branch`, and the names of rules that could shadow it (a plan must refuse
2651/// to rely on the managed rule while any exist).
2652pub fn managed_protection_rule(
2653    rules: &Value,
2654    branch: &str,
2655) -> Result<(Option<Value>, Vec<String>)> {
2656    let mut list: Vec<Value> = parse("branch protections", rules)?;
2657    let typed = list
2658        .iter()
2659        .map(|v| parse::<ProtectionJson>("branch protection", v))
2660        .collect::<Result<Vec<_>>>()?;
2661    let (managed, shadowing) = select_rule(&typed, branch);
2662    Ok((managed.map(|i| list.swap_remove(i)), shadowing))
2663}
2664
2665/// Whether a rule (as Forgejo returns it) already is what the protection
2666/// step would write for `spec`.
2667pub fn protection_satisfies(rule: &Value, spec: &ProtectionSpec) -> Result<bool> {
2668    Ok(satisfies_protection(
2669        &parse("branch protection", rule)?,
2670        spec,
2671    ))
2672}
2673
2674/// The protection body for `spec` over an `existing` rule, the allow-list
2675/// seeded with `admins`. For a new rule, add `rule_name` and `branch_name`.
2676pub fn protection_body(
2677    existing: Option<&Value>,
2678    admins: &[String],
2679    spec: &ProtectionSpec,
2680) -> Result<Value> {
2681    let existing: Option<ProtectionJson> = existing
2682        .map(|v| parse("branch protection", v))
2683        .transpose()?;
2684    Ok(protection_request(existing.as_ref(), admins, spec))
2685}
2686
2687/// Whether a repository (as `GET /repos/{owner}/{repo}` returns it) already
2688/// has the settings `s`.
2689pub fn settings_satisfied(repo: &Value, s: &RepoSettings) -> Result<bool> {
2690    Ok(satisfies_settings(&parse("repository", repo)?, s))
2691}
2692
2693/// The `PATCH /repos/{owner}/{repo}` body for `s`.
2694pub fn settings_body(repo: &Value, s: &RepoSettings) -> Result<Value> {
2695    Ok(settings_request(&parse("repository", repo)?, s))
2696}
2697
2698fn satisfies_settings(r: &RepoJson, s: &RepoSettings) -> bool {
2699    if s.enable_ci && r.has_actions != Some(true) {
2700        return false;
2701    }
2702    if s.merge_methods.is_empty() {
2703        return true;
2704    }
2705    r.has_pull_requests != Some(false)
2706        && r.merge_methods() == {
2707            let mut want = s.merge_methods.clone();
2708            want.sort();
2709            want.dedup();
2710            want
2711        }
2712        && r.default_merge_style.as_deref() == Some(merge_style(s.merge_methods[0]))
2713}
2714
2715/// Whether two readings of a rule agree on everything a refresh opens.
2716fn same_push_settings(a: &ProtectionJson, b: &ProtectionJson) -> bool {
2717    let set = |v: &[String]| {
2718        let mut v: Vec<String> = v.iter().map(|s| s.to_lowercase()).collect();
2719        v.sort();
2720        v
2721    };
2722    a.enable_push == b.enable_push
2723        && (!a.enable_push
2724            || (a.enable_push_whitelist == b.enable_push_whitelist
2725                && set(&a.push_whitelist_usernames) == set(&b.push_whitelist_usernames)
2726                && set(&a.push_whitelist_teams) == set(&b.push_whitelist_teams)
2727                && a.push_whitelist_deploy_keys == b.push_whitelist_deploy_keys))
2728        && patterns(&a.protected_file_patterns) == patterns(&b.protected_file_patterns)
2729}
2730
2731/// Whether an existing rule already is what the protection step writes.
2732fn satisfies_protection(rule: &ProtectionJson, spec: &ProtectionSpec) -> bool {
2733    let paths = patterns(&rule.protected_file_patterns);
2734    (!spec.require_pull_request || !rule.enable_push)
2735        && rule.enable_status_check
2736        && rule.status_check_contexts.contains(&spec.required_check)
2737        && rule.enable_merge_whitelist
2738        && rule.merge_whitelist_teams.is_empty()
2739        && patterns(&rule.unprotected_file_patterns).is_empty()
2740        // `None`: an instance without the setting, where it cannot be had.
2741        && rule.apply_to_admins != Some(false)
2742        && rule.enable_force_push != Some(true)
2743        && approvals_of(rule) >= spec.approvals_needed()
2744        && spec
2745            .protected_paths
2746            .iter()
2747            .all(|p| paths.contains(&p.to_ascii_lowercase()))
2748}
2749
2750fn decode_content(c: &ContentJson) -> Result<Vec<u8>> {
2751    match c.encoding.as_deref() {
2752        Some("base64") => {
2753            let compact: String = c
2754                .content
2755                .as_deref()
2756                .unwrap_or("")
2757                .chars()
2758                .filter(|ch| !ch.is_whitespace())
2759                .collect();
2760            STANDARD
2761                .decode(compact)
2762                .map_err(|e| ForgeError::Protocol(format!("file content: {e}")))
2763        }
2764        // Forgejo returns no inline content for a blob over its API size
2765        // limit. Nothing the bootstrap writes is that large, so a file that
2766        // is was put there by someone else: refuse rather than overwrite
2767        // what we cannot see.
2768        None => Err(ForgeError::Rejected {
2769            status: 409,
2770            message: "the existing file is too large for the instance to return inline; it was \
2771                      not written by the bootstrap — remove or rename it"
2772                .into(),
2773        }),
2774        other => Err(ForgeError::Protocol(format!(
2775            "file content in unknown encoding {other:?}"
2776        ))),
2777    }
2778}
2779
2780/// `null` (which Forgejo sends for an empty list) as the default.
2781fn nullable<'de, D, T>(d: D) -> std::result::Result<T, D::Error>
2782where
2783    D: Deserializer<'de>,
2784    T: Default + Deserialize<'de>,
2785{
2786    Ok(Option::<T>::deserialize(d)?.unwrap_or_default())
2787}
2788
2789// ── wire shapes ──────────────────────────────────────────────────────────
2790
2791#[derive(Deserialize)]
2792struct RepoJson {
2793    id: u64,
2794    full_name: String,
2795    #[serde(default)]
2796    private: bool,
2797    #[serde(default)]
2798    archived: bool,
2799    #[serde(default)]
2800    empty: bool,
2801    #[serde(default)]
2802    default_branch: Option<String>,
2803    #[serde(default)]
2804    has_pull_requests: Option<bool>,
2805    #[serde(default)]
2806    has_actions: Option<bool>,
2807    #[serde(default)]
2808    allow_fast_forward_only_merge: Option<bool>,
2809    #[serde(default)]
2810    allow_merge_commits: Option<bool>,
2811    #[serde(default)]
2812    allow_rebase: Option<bool>,
2813    #[serde(default)]
2814    allow_rebase_explicit: Option<bool>,
2815    #[serde(default)]
2816    allow_squash_merge: Option<bool>,
2817    #[serde(default)]
2818    default_merge_style: Option<String>,
2819}
2820
2821impl RepoJson {
2822    fn default_branch(&self) -> Option<String> {
2823        self.default_branch
2824            .clone()
2825            .filter(|b| !b.is_empty() && !self.empty)
2826    }
2827
2828    /// Allowed merge methods, sorted. None at all when pull requests are off.
2829    fn merge_methods(&self) -> Vec<MergeMethod> {
2830        if self.has_pull_requests == Some(false) {
2831            return Vec::new();
2832        }
2833        let mut m: Vec<MergeMethod> = [
2834            (self.allow_fast_forward_only_merge, MergeMethod::FastForward),
2835            (self.allow_merge_commits, MergeMethod::MergeCommit),
2836            (self.allow_rebase, MergeMethod::Rebase),
2837            (self.allow_rebase_explicit, MergeMethod::RebaseMerge),
2838            (self.allow_squash_merge, MergeMethod::Squash),
2839        ]
2840        .into_iter()
2841        .filter(|(on, _)| *on == Some(true))
2842        .map(|(_, m)| m)
2843        .collect();
2844        m.sort();
2845        m
2846    }
2847}
2848
2849#[derive(Deserialize)]
2850struct UserJson {
2851    id: u64,
2852    login: String,
2853}
2854
2855#[derive(Deserialize)]
2856struct SearchJson {
2857    #[serde(default, deserialize_with = "nullable")]
2858    data: Vec<UserJson>,
2859}
2860
2861#[derive(Deserialize)]
2862struct PermissionJson {
2863    permission: String,
2864}
2865
2866#[derive(Deserialize)]
2867struct OrgPermissionsJson {
2868    #[serde(default)]
2869    is_owner: bool,
2870}
2871
2872#[derive(Deserialize)]
2873struct OrgJson {
2874    id: u64,
2875}
2876
2877#[derive(Deserialize, Default)]
2878#[serde(default)]
2879struct OrgPermsJson {
2880    is_owner: bool,
2881    can_create_repository: bool,
2882}
2883
2884#[derive(Deserialize)]
2885struct TeamJson {
2886    id: u64,
2887    name: String,
2888    #[serde(default)]
2889    permission: String,
2890    #[serde(default)]
2891    can_create_org_repo: bool,
2892    #[serde(default)]
2893    includes_all_repositories: bool,
2894}
2895
2896#[derive(Deserialize)]
2897struct HookJson {
2898    id: u64,
2899    #[serde(default)]
2900    url: Option<String>,
2901    #[serde(default, deserialize_with = "nullable")]
2902    config: BTreeMap<String, String>,
2903}
2904
2905#[derive(Deserialize)]
2906struct VariableJson {
2907    #[serde(default)]
2908    data: String,
2909}
2910
2911#[derive(Deserialize)]
2912struct ContentJson {
2913    #[serde(default)]
2914    sha: String,
2915    #[serde(rename = "type")]
2916    kind: String,
2917    #[serde(default)]
2918    content: Option<String>,
2919    #[serde(default)]
2920    encoding: Option<String>,
2921}
2922
2923#[derive(Deserialize)]
2924struct NewTokenJson {
2925    id: u64,
2926    sha1: String,
2927}
2928
2929impl Drop for NewTokenJson {
2930    fn drop(&mut self) {
2931        use zeroize::Zeroize;
2932        self.sha1.zeroize();
2933    }
2934}
2935
2936#[derive(Deserialize)]
2937struct TokenInfoJson {
2938    id: u64,
2939    name: String,
2940    #[serde(default)]
2941    token_last_eight: Option<String>,
2942}
2943
2944#[derive(Deserialize, Default, Clone)]
2945#[serde(default)]
2946struct ProtectionJson {
2947    rule_name: Option<String>,
2948    branch_name: Option<String>,
2949    enable_push: bool,
2950    enable_push_whitelist: bool,
2951    #[serde(deserialize_with = "nullable")]
2952    push_whitelist_usernames: Vec<String>,
2953    #[serde(deserialize_with = "nullable")]
2954    push_whitelist_teams: Vec<String>,
2955    push_whitelist_deploy_keys: bool,
2956    enable_merge_whitelist: bool,
2957    #[serde(deserialize_with = "nullable")]
2958    merge_whitelist_usernames: Vec<String>,
2959    #[serde(deserialize_with = "nullable")]
2960    merge_whitelist_teams: Vec<String>,
2961    enable_status_check: bool,
2962    #[serde(deserialize_with = "nullable")]
2963    status_check_contexts: Vec<String>,
2964    #[serde(deserialize_with = "nullable")]
2965    protected_file_patterns: String,
2966    #[serde(deserialize_with = "nullable")]
2967    unprotected_file_patterns: String,
2968    /// Absent on instances without the setting — where repository admins
2969    /// can always merge past the check.
2970    apply_to_admins: Option<bool>,
2971    /// Gitea 1.23+; Forgejo refuses force-pushes to protected branches
2972    /// without a setting.
2973    enable_force_push: Option<bool>,
2974    #[serde(default)]
2975    required_approvals: Option<i64>,
2976    #[serde(default)]
2977    dismiss_stale_approvals: Option<bool>,
2978}
2979
2980/// The approvals a rule requires, counted only when a later push dismisses
2981/// them (otherwise an approval can outlive the change it approved).
2982fn approvals_of(rule: &ProtectionJson) -> u8 {
2983    if rule.dismiss_stale_approvals != Some(true) {
2984        return 0;
2985    }
2986    rule.required_approvals
2987        .map_or(0, |n| u8::try_from(n.max(0)).unwrap_or(u8::MAX))
2988}
2989
2990impl ProtectionJson {
2991    fn name(&self) -> Option<&str> {
2992        self.rule_name
2993            .as_deref()
2994            .filter(|n| !n.is_empty())
2995            .or(self.branch_name.as_deref())
2996    }
2997
2998    /// Everyone who can land a change on the branch without the check.
2999    fn bypass_actors(&self) -> Vec<String> {
3000        let mut out = Vec::new();
3001        if self.apply_to_admins != Some(true) {
3002            out.push("repository admins (the rule does not apply to admins)".into());
3003        }
3004        if self.enable_push {
3005            if !self.enable_push_whitelist {
3006                out.push("push: everyone with write access".into());
3007            } else {
3008                out.extend(
3009                    self.push_whitelist_usernames
3010                        .iter()
3011                        .map(|u| format!("push:{u}")),
3012                );
3013                out.extend(
3014                    self.push_whitelist_teams
3015                        .iter()
3016                        .map(|t| format!("push-team:{t}")),
3017                );
3018                if self.push_whitelist_deploy_keys {
3019                    out.push("push:deploy-keys".into());
3020                }
3021            }
3022        }
3023        let unprotected = patterns(&self.unprotected_file_patterns);
3024        if !unprotected.is_empty() {
3025            // Pushes touching only these files skip the protection.
3026            out.push(format!("unprotected-files:{}", unprotected.join(";")));
3027        }
3028        // Without the allow-list, everyone with write access may merge.
3029        if !self.enable_merge_whitelist {
3030            out.push("merge: everyone with write access".into());
3031        }
3032        // A team on the merge allow-list lets people the VTC never made
3033        // maintainers merge.
3034        out.extend(
3035            self.merge_whitelist_teams
3036                .iter()
3037                .map(|t| format!("merge-team:{t}")),
3038        );
3039        out
3040    }
3041}
3042
3043#[cfg(test)]
3044mod tests {
3045    use super::*;
3046
3047    /// The community's `required_approvals` reaches Forgejo's protection (no
3048    /// approvals whitelist, so they count from anyone with write access, and
3049    /// dismissed by a later push), and a rule asking for fewer, or keeping
3050    /// stale approvals, does not satisfy it.
3051    #[test]
3052    fn required_approvals_reach_forgejo_protection() {
3053        let spec = ProtectionSpec::standard("c / verify (pull_request)").with_required_approvals(2);
3054        let body = protection_body(None, &["alice".to_string()], &spec).unwrap();
3055        assert_eq!(body["required_approvals"], 2);
3056        assert_eq!(body["dismiss_stale_approvals"], true);
3057        assert_eq!(body["enable_approvals_whitelist"], false);
3058        assert!(protection_satisfies(&body, &spec).unwrap());
3059
3060        let mut stale = body.clone();
3061        stale["dismiss_stale_approvals"] = json!(false);
3062        assert!(!protection_satisfies(&stale, &spec).unwrap());
3063        let mut fewer = body;
3064        fewer["required_approvals"] = json!(1);
3065        assert!(!protection_satisfies(&fewer, &spec).unwrap());
3066    }
3067
3068    #[test]
3069    fn roles_map_both_ways() {
3070        for role in LADDER {
3071            let perm = Perm::for_role(role).unwrap();
3072            assert_eq!(perm.observed(role >= ForgeRole::Maintain), role);
3073            assert_eq!(Perm::parse(perm.as_str()), Some(perm));
3074        }
3075        assert_eq!(Perm::for_role(ForgeRole::None), None);
3076        assert_eq!(Perm::parse("owner"), Some(Perm::Admin));
3077        assert_eq!(Perm::parse("none"), None);
3078        assert_eq!(
3079            collapse_to_ladder(ForgeRole::Triage, &LADDER),
3080            ForgeRole::Read
3081        );
3082    }
3083
3084    #[test]
3085    fn patterns_are_read_as_forgejo_compiles_them() {
3086        assert_eq!(
3087            patterns(" .Forgejo/workflows/** ;;x.asc; "),
3088            [".forgejo/workflows/**", "x.asc"]
3089        );
3090        assert!(patterns("").is_empty());
3091    }
3092
3093    #[test]
3094    fn null_lists_deserialise_as_empty() {
3095        let p: ProtectionJson = serde_json::from_value(json!({
3096            "rule_name": "main",
3097            "merge_whitelist_usernames": null,
3098            "status_check_contexts": null,
3099            "protected_file_patterns": null,
3100        }))
3101        .unwrap();
3102        assert!(p.merge_whitelist_usernames.is_empty() && p.status_check_contexts.is_empty());
3103        assert_eq!(p.apply_to_admins, None);
3104        assert_eq!(
3105            p.bypass_actors(),
3106            [
3107                "repository admins (the rule does not apply to admins)",
3108                "merge: everyone with write access",
3109            ]
3110        );
3111    }
3112
3113    #[test]
3114    fn glob_characters_are_forgejos() {
3115        for g in ["main*", "rel?", "[ab]", "{a,b}", "a\\b"] {
3116            assert!(is_glob(g), "{g}");
3117        }
3118        for plain in ["main", "release/1.0", "feature-x_y", "Verify commit trust"] {
3119            assert!(!is_glob(plain), "{plain}");
3120        }
3121    }
3122}