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.bypass_actors = rule.bypass_actors();
628        p.bypass_actors
629            .extend(shadowing.iter().map(|n| format!("shadowing-rule:{n}")));
630        p
631    }
632
633    fn allowed_merge_methods(&self) -> Vec<MergeMethod> {
634        let probed = self.probed();
635        if !probed.info.features.fast_forward_only
636            && self.config.merge_fallback == MergeFallback::InstanceSigningKey
637        {
638            vec![MergeMethod::MergeCommit]
639        } else {
640            vec![MergeMethod::FastForward]
641        }
642    }
643
644    /// Gaps in what the protection and settings must add up to on Forgejo,
645    /// beyond the neutral ones: protected workflow paths, merge methods, CI.
646    fn forgejo_gaps(&self, p: &ProtectionState) -> Vec<ProtectionGap> {
647        let mut gaps = Vec::new();
648        if p.present {
649            let missing: Vec<String> = PROTECTED_PATHS
650                .iter()
651                .filter(|want| !p.protected_paths.iter().any(|have| have == *want))
652                .map(|s| s.to_string())
653                .collect();
654            if !missing.is_empty() {
655                gaps.push(ProtectionGap::UnprotectedPaths { paths: missing });
656            }
657        }
658        let allowed = self.allowed_merge_methods();
659        if let Some(methods) = &p.merge_methods {
660            for m in methods {
661                if !allowed.contains(m) {
662                    gaps.push(ProtectionGap::MergeMethodAllowed { method: *m });
663                }
664            }
665        }
666        if p.ci_enabled == Some(false) {
667            gaps.push(ProtectionGap::CiDisabled);
668        }
669        gaps
670    }
671
672    // ── bootstrap steps ──────────────────────────────────────────────────
673
674    async fn write_file(
675        &self,
676        repo: &Resource,
677        path: &str,
678        contents: &[u8],
679        message: &str,
680    ) -> Result<StepOutcome> {
681        validate_repo_path(path)?;
682        let (token, owner, name) = self.repo_token(repo)?;
683        let mut segments = vec!["repos", owner, name, "contents"];
684        segments.extend(path.split('/'));
685        let url = self.api.url(&segments);
686
687        let sha = match self.current_file(&token, owner, name, path).await? {
688            Some((_, current)) if current == contents => return Ok(StepOutcome::Unchanged),
689            Some((sha, _)) => Some(sha),
690            None => None,
691        };
692
693        let mut body = json!({ "message": message, "content": STANDARD.encode(contents) });
694        let method = match &sha {
695            Some(sha) => {
696                body["sha"] = json!(sha);
697                Method::PUT
698            }
699            None => Method::POST,
700        };
701        self.api
702            .send(method, url, Auth::Token(&token), Some(&body), path)
703            .await
704            .map_err(|e| match e {
705                ForgeError::Rejected { status, message } => ForgeError::Rejected {
706                    status,
707                    message: format!("{message}{PROTECTED_HINT}"),
708                },
709                ForgeError::Forbidden(message) => {
710                    ForgeError::Forbidden(format!("{message}{PROTECTED_HINT}"))
711                }
712                e => e,
713            })?;
714        Ok(if sha.is_some() {
715            StepOutcome::Updated
716        } else {
717            StepOutcome::Created
718        })
719    }
720
721    /// The file at `path` on the default branch: `None` when absent, its
722    /// blob sha and contents otherwise. A directory, or a file too large to
723    /// return inline, is refused.
724    async fn current_file(
725        &self,
726        token: &Secret,
727        owner: &str,
728        name: &str,
729        path: &str,
730    ) -> Result<Option<(String, Vec<u8>)>> {
731        let mut segments = vec!["repos", owner, name, "contents"];
732        segments.extend(path.split('/'));
733        let existing: Option<Value> = self
734            .api
735            .get_opt(self.api.url(&segments), Auth::Token(token), path)
736            .await?;
737        match existing {
738            Some(Value::Array(_)) => Err(ForgeError::Rejected {
739                status: 409,
740                message: format!("`{path}` exists and is a directory, not a file"),
741            }),
742            Some(v) => {
743                let c: ContentJson = serde_json::from_value(v)
744                    .map_err(|e| ForgeError::Protocol(format!("{path}: {e}")))?;
745                if c.kind != "file" {
746                    return Err(ForgeError::Rejected {
747                        status: 409,
748                        message: format!("`{path}` exists and is a {}, not a file", c.kind),
749                    });
750                }
751                let contents = decode_content(&c)?;
752                Ok(Some((c.sha, contents)))
753            }
754            None => Ok(None),
755        }
756    }
757
758    /// The Forgejo job for [`StepAction::RefreshProtectedFiles`]: the one
759    /// sanctioned way the bridge changes a protected path (the managed
760    /// workflow, the keyring) after bootstrap.
761    ///
762    /// If every file already matches, nothing is touched. Otherwise the
763    /// managed rule is opened for the bot alone — pushes enabled with a push
764    /// allow-list of just the bot, the protected-file patterns cleared,
765    /// since Forgejo refuses protected files even to an allowed pusher —
766    /// the files are written, and the rule's exact prior push and
767    /// protected-file settings are restored and read back. The restore is
768    /// attempted (twice) whatever happened to the writes. While open, an
769    /// `inspect` reports the bot as a bypass actor and the paths as
770    /// unprotected: critical drift, so a restore that failed is re-applied
771    /// by the next sweep's protection step.
772    pub async fn refresh_managed_files(
773        &self,
774        repo: &Resource,
775        files: &[vgi_forge::ExtraFile],
776        message: &str,
777    ) -> Result<RefreshReport> {
778        let (token, owner, name) = self.repo_token(repo)?;
779        let r = self.get_repo(&token, owner, name).await?;
780        let branch = r.default_branch().ok_or_else(|| ForgeError::Rejected {
781            status: 409,
782            message: format!("{repo} is empty: nothing to refresh"),
783        })?;
784        self.refresh_on_branch(repo, &branch, files, message).await
785    }
786
787    /// [`ForgejoForge::refresh_managed_files`] on `branch`, the repository's
788    /// default branch, already read.
789    async fn refresh_on_branch(
790        &self,
791        repo: &Resource,
792        branch: &str,
793        files: &[vgi_forge::ExtraFile],
794        message: &str,
795    ) -> Result<RefreshReport> {
796        for f in files {
797            validate_repo_path(&f.path)?;
798        }
799        let (token, owner, name) = self.repo_token(repo)?;
800        let mut stale = Vec::new();
801        for f in files {
802            let current = self.current_file(&token, owner, name, &f.path).await?;
803            if current.map(|(_, c)| c) != Some(f.contents.clone()) {
804                stale.push(f);
805            }
806        }
807        let mut report = RefreshReport {
808            outcome: StepOutcome::Unchanged,
809            files: files
810                .iter()
811                .map(|f| (f.path.clone(), StepOutcome::Unchanged))
812                .collect(),
813            opened: false,
814            detail: format!("{repo}: managed files already current"),
815        };
816        if stale.is_empty() {
817            return Ok(report);
818        }
819
820        let (rule, shadowing) = self.protection_rule(&token, owner, name, branch).await?;
821        if !shadowing.is_empty() {
822            return Err(ForgeError::Rejected {
823                status: 409,
824                message: format!(
825                    "{repo}: rule(s) {} shadow the managed protection; resolve that first",
826                    shadowing.join(", ")
827                ),
828            });
829        }
830        let bot = self.bot();
831        let rule_url = |rule_name: &str| {
832            self.api
833                .url(&["repos", owner, name, "branch_protections", rule_name])
834        };
835        let prior = rule.as_ref().map(|r| {
836            (
837                r.name().unwrap_or(branch).to_string(),
838                json!({
839                    "enable_push": r.enable_push,
840                    "enable_push_whitelist": r.enable_push_whitelist,
841                    "push_whitelist_usernames": r.push_whitelist_usernames,
842                    "push_whitelist_teams": r.push_whitelist_teams,
843                    "push_whitelist_deploy_keys": r.push_whitelist_deploy_keys,
844                    "protected_file_patterns": r.protected_file_patterns,
845                }),
846                r.clone(),
847            )
848        });
849        let mut open_error = None;
850        if let Some((rule_name, _, _)) = &prior {
851            tracing::warn!(
852                repo = %repo,
853                bot = %bot.login,
854                files = ?stale.iter().map(|f| &f.path).collect::<Vec<_>>(),
855                "opening the default-branch protection to the bridge alone to refresh managed files"
856            );
857            let open = json!({
858                "enable_push": true,
859                "enable_push_whitelist": true,
860                "push_whitelist_usernames": [bot.login],
861                "push_whitelist_teams": [],
862                "push_whitelist_deploy_keys": false,
863                "protected_file_patterns": "",
864            });
865            // A failed open may still have applied on the server (a timeout
866            // after the write, a 5xx from a proxy): nothing is written then,
867            // but the restore below is attempted all the same.
868            match self
869                .api
870                .send(
871                    Method::PATCH,
872                    rule_url(rule_name),
873                    Auth::Token(&token),
874                    Some(&open),
875                    "branch protection (open for refresh)",
876                )
877                .await
878            {
879                Ok(_) => report.opened = true,
880                Err(e) => open_error = Some(e),
881            }
882        }
883
884        let mut write_error = None;
885        for (i, f) in files.iter().enumerate() {
886            if open_error.is_some() {
887                break;
888            }
889            if !stale.iter().any(|s| s.path == f.path) {
890                continue;
891            }
892            match self.write_file(repo, &f.path, &f.contents, message).await {
893                Ok(o) => report.files[i].1 = o,
894                Err(e) => {
895                    write_error = Some((f.path.clone(), e));
896                    break;
897                }
898            }
899        }
900
901        let mut restore_error = None;
902        if let Some((rule_name, body, before)) = &prior {
903            for _ in 0..2 {
904                let result: Result<ProtectionJson> = self
905                    .api
906                    .json(
907                        Method::PATCH,
908                        rule_url(rule_name),
909                        Auth::Token(&token),
910                        Some(body),
911                        "branch protection (restore after refresh)",
912                    )
913                    .await;
914                restore_error = match result {
915                    Ok(after) if same_push_settings(&after, before) => None,
916                    Ok(_) => Some(ForgeError::Rejected {
917                        status: 200,
918                        message: "the restored protection does not read back as it was".into(),
919                    }),
920                    Err(e) => Some(e),
921                };
922                if restore_error.is_none() {
923                    break;
924                }
925            }
926        }
927
928        let written: Vec<&str> = report
929            .files
930            .iter()
931            .filter(|(_, o)| *o != StepOutcome::Unchanged)
932            .map(|(p, _)| p.as_str())
933            .collect();
934        report.detail = format!(
935            "{repo}: protection {} for `{}`; wrote {:?}; {}",
936            match (&prior, &open_error) {
937                (None, _) => "absent, not opened".to_string(),
938                (Some(_), None) => "opened".to_string(),
939                (Some(_), Some(e)) => format!("open failed ({e})"),
940            },
941            bot.login,
942            written,
943            match (&restore_error, prior.is_some()) {
944                (None, true) => "protection restored and verified".to_string(),
945                (None, false) => "nothing to restore".to_string(),
946                (Some(e), _) => format!("PROTECTION LEFT OPEN: {e}"),
947            }
948        );
949        if let Some(e) = &restore_error {
950            tracing::error!(repo = %repo, error = %e, "refresh could not restore the protection");
951            return Err(ForgeError::Rejected {
952                status: 500,
953                message: report.detail,
954            });
955        }
956        if let Some(e) = open_error {
957            tracing::warn!(repo = %repo, detail = %report.detail, "refresh could not open the protection");
958            return Err(match e {
959                ForgeError::Rejected { status, message } => ForgeError::Rejected {
960                    status,
961                    message: format!("{message} (nothing written; protection restored)"),
962                },
963                other => other,
964            });
965        }
966        tracing::info!(repo = %repo, detail = %report.detail, "managed files refreshed");
967        if let Some((path, e)) = write_error {
968            return Err(match e {
969                ForgeError::Rejected { status, message } => ForgeError::Rejected {
970                    status,
971                    message: format!("{path}: {message} (protection restored)"),
972                },
973                other => other,
974            });
975        }
976        report.outcome = if written.is_empty() {
977            StepOutcome::Unchanged
978        } else {
979            StepOutcome::Updated
980        };
981        Ok(report)
982    }
983
984    /// The bootstrap's write of a managed file (the workflow, the keyring).
985    ///
986    /// On a repository that is not protected yet this is a plain write. On
987    /// one bootstrapped before, the file is a protected path no pull request
988    /// may change, so a rendering that moved on (a new verify-trust release,
989    /// the namespace fallback) would otherwise fail every later bootstrap:
990    /// it goes through the audited [`ForgejoForge::refresh_managed_files`]
991    /// instead, which does nothing when the file is current. An empty
992    /// repository has no branch to protect and is written directly.
993    async fn write_managed_file(
994        &self,
995        repo: &Resource,
996        path: &str,
997        contents: &[u8],
998        message: &str,
999    ) -> Result<StepOutcome> {
1000        let (token, owner, name) = self.repo_token(repo)?;
1001        let Some(branch) = self.get_repo(&token, owner, name).await?.default_branch() else {
1002            return self.write_file(repo, path, contents, message).await;
1003        };
1004        let file = vgi_forge::ExtraFile {
1005            path: path.to_string(),
1006            contents: contents.to_vec(),
1007        };
1008        let report = self
1009            .refresh_on_branch(repo, &branch, std::slice::from_ref(&file), message)
1010            .await?;
1011        Ok(report
1012            .files
1013            .first()
1014            .map_or(report.outcome, |(_, outcome)| *outcome))
1015    }
1016
1017    /// The single maintenance step that brings the managed workflow (and, in
1018    /// the signing-key fallback, the keyring) up to date on a bootstrapped
1019    /// repository — see [`ForgejoForge::refresh_managed_files`].
1020    pub fn refresh_plan(&self, repo: &RepoSpec, cfg: &VgiConfig) -> Result<Vec<BootstrapStep>> {
1021        let files: Vec<vgi_forge::ExtraFile> = self
1022            .bootstrap_plan(repo, cfg)?
1023            .into_iter()
1024            .filter_map(|s| match s.action {
1025                StepAction::WriteFile { path, contents, .. }
1026                    if path == crate::plan::WORKFLOW_PATH || path == crate::plan::KEYRING_PATH =>
1027                {
1028                    Some(vgi_forge::ExtraFile { path, contents })
1029                }
1030                _ => None,
1031            })
1032            .collect();
1033        Ok(vec![BootstrapStep::new(
1034            "refresh-managed-files",
1035            vgi_forge::BootstrapComponent::Workflow,
1036            StepAction::RefreshProtectedFiles {
1037                files,
1038                message: "ci: update the VGI commit-trust check".into(),
1039            },
1040        )])
1041    }
1042
1043    async fn set_variable(&self, repo: &Resource, var: &str, value: &str) -> Result<StepOutcome> {
1044        if var.is_empty()
1045            || !var
1046                .bytes()
1047                .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
1048        {
1049            return Err(ForgeError::Config(format!(
1050                "variable name `{var}` must be [A-Z0-9_]"
1051            )));
1052        }
1053        if !self.probed().info.features.actions_variables {
1054            return Err(ForgeError::Unsupported {
1055                operation: "Actions variables".into(),
1056                hint: "this instance has no variables API; the plan writes the DIDs into the \
1057                       workflow instead — rebuild the plan"
1058                    .into(),
1059            });
1060        }
1061        let (token, owner, name) = self.repo_token(repo)?;
1062        let url = self
1063            .api
1064            .url(&["repos", owner, name, "actions", "variables", var]);
1065        match self
1066            .api
1067            .get_opt::<VariableJson>(url.clone(), Auth::Token(&token), var)
1068            .await?
1069        {
1070            Some(v) if v.data == value => Ok(StepOutcome::Unchanged),
1071            Some(_) => {
1072                let body = json!({ "name": var, "value": value });
1073                self.api
1074                    .send(Method::PUT, url, Auth::Token(&token), Some(&body), var)
1075                    .await?;
1076                Ok(StepOutcome::Updated)
1077            }
1078            None => {
1079                let body = json!({ "value": value });
1080                self.api
1081                    .send(Method::POST, url, Auth::Token(&token), Some(&body), var)
1082                    .await?;
1083                Ok(StepOutcome::Created)
1084            }
1085        }
1086    }
1087
1088    async fn configure_repo(&self, repo: &Resource, s: &RepoSettings) -> Result<StepOutcome> {
1089        let (token, owner, name) = self.repo_token(repo)?;
1090        let r = self.get_repo(&token, owner, name).await?;
1091        let ff_wanted = s.merge_methods.contains(&MergeMethod::FastForward);
1092        let ff_available = self.probed().info.features.fast_forward_only
1093            && r.allow_fast_forward_only_merge.is_some();
1094        if ff_wanted && !ff_available {
1095            return Err(ForgeError::Unsupported {
1096                operation: "fast-forward-only merges".into(),
1097                hint: match self.config.merge_fallback {
1098                    MergeFallback::Fail => format!(
1099                        "`{}` ({}) cannot restrict merges to fast-forward only, and every web \
1100                         merge would land a commit the check never saw. Upgrade to Forgejo 7 \
1101                         or Gitea 1.22, or configure the signing-key merge fallback \
1102                         (the instance must sign merges)",
1103                        self.config.host,
1104                        self.probed().info.version
1105                    ),
1106                    _ => "the plan was built for fast-forward-only merges but the instance \
1107                          does not offer them; rebuild the plan"
1108                        .into(),
1109                },
1110            });
1111        }
1112        if satisfies_settings(&r, s) {
1113            return Ok(StepOutcome::Unchanged);
1114        }
1115
1116        let body = settings_request(&r, s);
1117        let after: RepoJson = self
1118            .api
1119            .json(
1120                Method::PATCH,
1121                self.api.url(&["repos", owner, name]),
1122                Auth::Token(&token),
1123                Some(&body),
1124                repo.as_str(),
1125            )
1126            .await?;
1127        if !satisfies_settings(&after, s) {
1128            return Err(ForgeError::Rejected {
1129                status: 200,
1130                message: format!(
1131                    "{repo}: the instance accepted the settings but did not apply them all \
1132                     (are Actions or pull requests disabled instance-wide?)"
1133                ),
1134            });
1135        }
1136        Ok(StepOutcome::Updated)
1137    }
1138
1139    async fn protect(&self, repo: &Resource, spec: &ProtectionSpec) -> Result<StepOutcome> {
1140        let (token, owner, name) = self.repo_token(repo)?;
1141        let r = self.get_repo(&token, owner, name).await?;
1142        let branch = r.default_branch().ok_or_else(|| ForgeError::Rejected {
1143            status: 409,
1144            message: format!("{repo} is empty: there is no default branch to protect yet"),
1145        })?;
1146        if is_glob(&branch) {
1147            return Err(ForgeError::Unsupported {
1148                operation: "protecting the default branch".into(),
1149                hint: format!(
1150                    "the default branch `{branch}` contains glob characters, so Forgejo would \
1151                     read a rule for it as a pattern; rename the branch"
1152                ),
1153            });
1154        }
1155        let (existing, shadowing) = self.protection_rule(&token, owner, name, &branch).await?;
1156        if !shadowing.is_empty() {
1157            // Never adopt or rely on a rule Forgejo may not apply; deleting
1158            // someone else's rule is a human's decision.
1159            return Err(ForgeError::Rejected {
1160                status: 409,
1161                message: format!(
1162                    "{repo}: branch protection rule(s) {} also match `{branch}` (Forgejo compares \
1163                     rule names case-insensitively and applies the oldest), so the managed rule \
1164                     may never apply; remove them and re-run",
1165                    shadowing.join(", ")
1166                ),
1167            });
1168        }
1169        if let Some(rule) = &existing
1170            && satisfies_protection(rule, spec)
1171        {
1172            return Ok(StepOutcome::Unchanged);
1173        }
1174
1175        // Seed the merge allow-list with the repository's admins: once it is
1176        // enabled, even an admin cannot merge without a place on it (and an
1177        // admin can edit the rule anyway, so this grants nothing new).
1178        // Maintainers are added by `apply_roles`.
1179        let admins: Vec<String> = self
1180            .collaborators(&token, owner, name)
1181            .await?
1182            .into_iter()
1183            .filter(|(_, perm)| *perm == Perm::Admin)
1184            .map(|(account, _)| account.login)
1185            .collect();
1186        let mut body = protection_request(existing.as_ref(), &admins, spec);
1187        let (method, url, outcome) = match &existing {
1188            Some(rule) => (
1189                Method::PATCH,
1190                self.api.url(&[
1191                    "repos",
1192                    owner,
1193                    name,
1194                    "branch_protections",
1195                    rule.name().unwrap_or(&branch),
1196                ]),
1197                StepOutcome::Updated,
1198            ),
1199            None => {
1200                body["rule_name"] = json!(branch);
1201                // Pre-1.22 instances know only `branch_name`.
1202                body["branch_name"] = json!(branch);
1203                (
1204                    Method::POST,
1205                    self.api.url(&["repos", owner, name, "branch_protections"]),
1206                    StepOutcome::Created,
1207                )
1208            }
1209        };
1210        let after: ProtectionJson = self
1211            .api
1212            .json(
1213                method,
1214                url,
1215                Auth::Token(&token),
1216                Some(&body),
1217                "branch protection",
1218            )
1219            .await?;
1220        if !satisfies_protection(&after, spec) {
1221            return Err(ForgeError::Rejected {
1222                status: 200,
1223                message: format!(
1224                    "{repo}: the instance accepted the branch protection but it does not read \
1225                     back as requested"
1226                ),
1227            });
1228        }
1229        Ok(outcome)
1230    }
1231
1232    // ── roles ────────────────────────────────────────────────────────────
1233
1234    /// Drop assignments Forgejo cannot express: the owner of a personal
1235    /// namespace owns every repository in it and cannot be a collaborator.
1236    fn expressible(&self, ns: &Namespace, desired: &[RoleAssignment]) -> Vec<RoleAssignment> {
1237        desired
1238            .iter()
1239            .filter(|a| !is_personal_owner(ns, a.account.id))
1240            .cloned()
1241            .collect()
1242    }
1243
1244    async fn login_for(&self, token: &Secret, id: u64) -> Result<String> {
1245        // The numeric id is the binding; the login is looked up fresh so a
1246        // renamed-and-re-registered login never receives the role. Forgejo
1247        // has no `/user/{id}`; search by `uid` is its lookup by id.
1248        let mut url = self.api.url(&["users", "search"]);
1249        url.query_pairs_mut().append_pair("uid", &id.to_string());
1250        let found: SearchJson = self
1251            .api
1252            .json(Method::GET, url, Auth::Token(token), None, "user")
1253            .await?;
1254        let user =
1255            found
1256                .data
1257                .into_iter()
1258                .find(|u| u.id == id)
1259                .ok_or_else(|| ForgeError::NotFound {
1260                    what: format!("user {id}"),
1261                })?;
1262        check_login(&user.login)?;
1263        Ok(user.login)
1264    }
1265
1266    /// Where `login`'s access to an organisation's repository comes from,
1267    /// other than a direct role: owning the organisation, and the teams
1268    /// with access to the repository that they are in. Best effort: a
1269    /// lookup Forgejo refuses leaves that source out, and the access is
1270    /// still reported.
1271    async fn access_sources(
1272        &self,
1273        token: &Secret,
1274        owner: &str,
1275        name: &str,
1276        login: &str,
1277    ) -> Vec<AccessSource> {
1278        let auth = Auth::Token(token);
1279        let mut via = Vec::new();
1280        let org: Option<OrgPermissionsJson> = self
1281            .api
1282            .get_opt(
1283                self.api
1284                    .url(&["users", login, "orgs", owner, "permissions"]),
1285                auth,
1286                "organisation permissions",
1287            )
1288            .await
1289            .ok()
1290            .flatten();
1291        if org.is_some_and(|o| o.is_owner) {
1292            via.push(AccessSource::OrgOwner(owner.to_string()));
1293        }
1294        let teams: Vec<TeamJson> = self
1295            .api
1296            .get_all(
1297                self.api.url(&["repos", owner, name, "teams"]),
1298                auth,
1299                "repository teams",
1300            )
1301            .await
1302            .unwrap_or_default();
1303        for t in teams {
1304            let url = self
1305                .api
1306                .url(&["teams", &t.id.to_string(), "members", login]);
1307            if self
1308                .api
1309                .exists(url, auth, "team member")
1310                .await
1311                .unwrap_or(false)
1312            {
1313                via.push(AccessSource::Team(t.name));
1314            }
1315        }
1316        via
1317    }
1318
1319    async fn set_collaborator(
1320        &self,
1321        token: &Secret,
1322        owner: &str,
1323        name: &str,
1324        login: &str,
1325        perm: Option<Perm>,
1326    ) -> Result<()> {
1327        let url = self
1328            .api
1329            .url(&["repos", owner, name, "collaborators", login]);
1330        match perm {
1331            Some(p) => {
1332                let body = json!({ "permission": p.as_str() });
1333                self.api
1334                    .send(
1335                        Method::PUT,
1336                        url,
1337                        Auth::Token(token),
1338                        Some(&body),
1339                        "collaborator",
1340                    )
1341                    .await?;
1342            }
1343            None => {
1344                self.api
1345                    .send(
1346                        Method::DELETE,
1347                        url,
1348                        Auth::Token(token),
1349                        None,
1350                        "collaborator",
1351                    )
1352                    .await?;
1353            }
1354        }
1355        Ok(())
1356    }
1357}
1358
1359/// The instance's version, the bot's identity, and (when needed) its
1360/// signing key.
1361async fn probe(api: &Api, config: &ForgejoConfig, token: &Secret) -> Result<Probed> {
1362    #[derive(Deserialize)]
1363    struct Version {
1364        version: String,
1365    }
1366    let v: Version = api
1367        .json(
1368            Method::GET,
1369            api.url(&["version"]),
1370            Auth::Token(token),
1371            None,
1372            "instance version",
1373        )
1374        .await?;
1375    let info = InstanceInfo::from_version(&v.version);
1376    let bot = whoami(api, Auth::Token(token)).await?;
1377    if !bot.login.eq_ignore_ascii_case(&config.bot_login) {
1378        return Err(ForgeError::Config(format!(
1379            "the bot token belongs to `{}`, not the configured bot `{}`",
1380            bot.login, config.bot_login
1381        )));
1382    }
1383    let signing_key = if !info.features.fast_forward_only
1384        && config.merge_fallback == MergeFallback::InstanceSigningKey
1385    {
1386        Some(fetch_signing_key(api, token).await?)
1387    } else {
1388        None
1389    };
1390    Ok(Probed {
1391        info,
1392        bot,
1393        signing_key,
1394    })
1395}
1396
1397async fn whoami(api: &Api, auth: Auth<'_>) -> Result<ForgeAccount> {
1398    let u: UserJson = api
1399        .json(
1400            Method::GET,
1401            api.url(&["user"]),
1402            auth,
1403            None,
1404            "authenticated user",
1405        )
1406        .await?;
1407    Ok(ForgeAccount::new(u.id, u.login))
1408}
1409
1410async fn fetch_signing_key(api: &Api, token: &Secret) -> Result<Vec<u8>> {
1411    let resp = api
1412        .send(
1413            Method::GET,
1414            api.url(&["signing-key.gpg"]),
1415            Auth::Token(token),
1416            None,
1417            "instance signing key",
1418        )
1419        .await?;
1420    resp.bytes()
1421        .await
1422        .map(|b| b.to_vec())
1423        .map_err(|e| ForgeError::Unavailable(e.without_url().to_string()))
1424}
1425
1426const PROTECTED_HINT: &str = " — if the default branch is already protected, this file can only \
1427                              change through a pull request, and the workflow and keyring not \
1428                              even then (they are protected paths, by design): update those \
1429                              with the audited refresh-managed-files step";
1430
1431#[async_trait]
1432impl Forge for ForgejoForge {
1433    fn kind(&self) -> ForgeKind {
1434        ForgeKind::Forgejo
1435    }
1436
1437    fn host(&self) -> &str {
1438        &self.config.host
1439    }
1440
1441    fn capabilities(&self, ns: &Namespace) -> Capabilities {
1442        let automated = ns.installation_id.is_some();
1443        let mut c = Capabilities::default();
1444        c.automation = automated;
1445        c.required_checks = RequiredCheckKind::BranchProtection;
1446        c.account_link = LinkMethod::AuthorizationCodePkce;
1447        // The org webhook announces only repository creation and deletion;
1448        // role, protection, rename and archive drift must be swept for.
1449        c.webhooks = false;
1450        // One bot token for everything: it cannot be narrowed per job.
1451        c.per_repo_tokens = false;
1452        c.role_levels = LADDER.to_vec();
1453        c.bot_can_create_repos = automated && ns.kind == NamespaceKind::Organization;
1454        c
1455    }
1456
1457    /// The owner, and the bot every automated action goes through.
1458    fn is_protected_account(&self, ns: &Namespace, account: u64) -> bool {
1459        ns.owner_id == Some(account) || self.bot().id == account
1460    }
1461
1462    async fn begin_bind(&self, req: BindRequest) -> Result<BindStep> {
1463        if req.namespace.host() != self.config.host || !req.namespace.is_namespace() {
1464            return Err(ForgeError::WrongResource {
1465                resource: req.namespace.to_string(),
1466                expected: format!("a namespace on `{}`", self.config.host),
1467            });
1468        }
1469        if req.state.len() < MIN_STATE_LEN
1470            || !req
1471                .state
1472                .bytes()
1473                .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
1474        {
1475            return Err(ForgeError::Config(format!(
1476                "bind state must be at least {MIN_STATE_LEN} base64url characters from a CSPRNG \
1477                 (see ForgejoForge::new_state)"
1478            )));
1479        }
1480        let verifier = self.oauth_keys.verifier(Purpose::Bind, &req.state);
1481        Ok(BindStep::Redirect {
1482            url: self
1483                .authorize_url(&self.config.bind_redirect_uri, &req.state, &verifier)
1484                .to_string(),
1485        })
1486    }
1487
1488    async fn complete_bind(&self, cb: BindCallback) -> Result<NamespaceBinding> {
1489        let reject = |m: String| Err(ForgeError::BindRejected(m));
1490        let state = cb.params.get("state").map(String::as_str).unwrap_or("");
1491        if cb.expected_state.len() < MIN_STATE_LEN
1492            || aws_lc_rs::constant_time::verify_slices_are_equal(
1493                state.as_bytes(),
1494                cb.expected_state.as_bytes(),
1495            )
1496            .is_err()
1497        {
1498            return reject("the `state` does not match a bind this VTC started".into());
1499        }
1500        if let Some(err) = cb.params.get("error") {
1501            return reject(format!(
1502                "the admin did not authorise the bridge: {err} {}",
1503                cb.params
1504                    .get("error_description")
1505                    .map(String::as_str)
1506                    .unwrap_or("")
1507            ));
1508        }
1509        let ns = &cb.expected_namespace;
1510        if ns.host() != self.config.host || !ns.is_namespace() {
1511            return reject(format!(
1512                "`{ns}` is not a namespace on `{}`",
1513                self.config.host
1514            ));
1515        }
1516        let owner = ns.owner();
1517        let code = match cb.params.get("code") {
1518            Some(c) if !c.is_empty() => c,
1519            _ => return reject("missing authorisation `code`".into()),
1520        };
1521
1522        let verifier = self.oauth_keys.verifier(Purpose::Bind, state);
1523        let admin_token = self
1524            .exchange_code(
1525                code,
1526                &self.config.bind_redirect_uri,
1527                &verifier,
1528                ForgeError::BindRejected,
1529            )
1530            .await?;
1531        let result = self.bind_as_admin(ns, owner, &admin_token).await;
1532        // The one-time admin token is wiped here, whatever happened: the
1533        // bridge never keeps an admin credential.
1534        drop(admin_token);
1535        result
1536    }
1537
1538    async fn begin_account_link(&self, member: &str) -> Result<LinkStep> {
1539        tracing::debug!(member, "starting Forgejo account link");
1540        let state = self.oauth_keys.issue_link_state(member, unix_now())?;
1541        let verifier = self.oauth_keys.verifier(Purpose::Link, &state);
1542        Ok(LinkStep::Redirect {
1543            url: self
1544                .authorize_url(&self.config.link_redirect_uri, &state, &verifier)
1545                .to_string(),
1546        })
1547    }
1548
1549    async fn complete_account_link(&self, cb: LinkCallback) -> Result<ForgeAccount> {
1550        let LinkCallback::Redirect { params, member, .. } = cb else {
1551            return Err(ForgeError::Unsupported {
1552                operation: "device-flow account link".into(),
1553                hint: "Forgejo has no device flow; members link through the browser \
1554                       (authorisation code + PKCE)"
1555                    .into(),
1556            });
1557        };
1558        let state = params.get("state").map(String::as_str).unwrap_or("");
1559        let member = member.ok_or_else(|| {
1560            ForgeError::LinkFailed(
1561                "the callback does not say which member started this link (build it with \
1562                 LinkCallback::redirect and the member from the caller's session)"
1563                    .into(),
1564            )
1565        })?;
1566        self.oauth_keys
1567            .check_link_state(state, &member, unix_now(), self.config.link_state_ttl)?;
1568        if let Some(err) = params.get("error") {
1569            return Err(ForgeError::LinkFailed(format!(
1570                "the member did not authorise the bridge: {err}"
1571            )));
1572        }
1573        let code = match params.get("code") {
1574            Some(c) if !c.is_empty() => c,
1575            _ => {
1576                return Err(ForgeError::LinkFailed(
1577                    "missing authorisation `code`".into(),
1578                ));
1579            }
1580        };
1581        let verifier = self.oauth_keys.verifier(Purpose::Link, state);
1582        let token = self
1583            .exchange_code(
1584                code,
1585                &self.config.link_redirect_uri,
1586                &verifier,
1587                ForgeError::LinkFailed,
1588            )
1589            .await?;
1590        let account = whoami(&self.api, Auth::Bearer(&token)).await;
1591        // `token` is dropped (and wiped) here: the bridge needs the id, not
1592        // a standing credential for the member's account.
1593        drop(token);
1594        account
1595    }
1596
1597    async fn inspect(&self, repo: &Resource) -> Result<RepoState> {
1598        let (token, owner, name) = self.repo_token(repo)?;
1599        let ns = self.namespace(&repo.namespace())?;
1600        let r = self.get_repo(&token, owner, name).await?;
1601        let mut state = self.repo_state(&r)?;
1602        let (rule, shadowing) = match r.default_branch() {
1603            Some(branch) => self.protection_rule(&token, owner, name, &branch).await?,
1604            None => (None, Vec::new()),
1605        };
1606        let allow = rule
1607            .as_ref()
1608            .filter(|r| r.enable_merge_whitelist)
1609            .map(|r| r.merge_whitelist_usernames.clone())
1610            .unwrap_or_default();
1611        for (account, perm) in self.collaborators(&token, owner, name).await? {
1612            if is_personal_owner(&ns, account.id) {
1613                continue;
1614            }
1615            let role = perm.observed(contains_login(&allow, &account.login));
1616            state.collaborators.push(Collaborator::new(account, role));
1617        }
1618        state.protection = self.protection_state(rule.as_ref(), &shadowing, &r);
1619        Ok(state)
1620    }
1621
1622    async fn create_repo(&self, spec: &RepoSpec) -> Result<RepoState> {
1623        let (ns, owner, name) = self.locate(&spec.resource)?;
1624        if !self.capabilities(&ns).bot_can_create_repos {
1625            return Err(ForgeError::Unsupported {
1626                operation: "repository creation".into(),
1627                hint: format!(
1628                    "the bridge cannot create repositories in `{}`; the account holder creates \
1629                     `{owner}/{name}`, adds `{}` as an admin collaborator, runs `vgi repo init`, \
1630                     and the repo is adopted",
1631                    ns.resource, self.config.bot_login
1632                ),
1633            });
1634        }
1635        let private = match spec.visibility {
1636            Visibility::Public => false,
1637            Visibility::Private => true,
1638            _ => {
1639                return Err(ForgeError::Unsupported {
1640                    operation: "internal visibility".into(),
1641                    hint: "Forgejo repositories are public or private".into(),
1642                });
1643            }
1644        };
1645        let token = self.token();
1646        if let Some(existing) = self
1647            .api
1648            .get_opt::<RepoJson>(
1649                self.api.url(&["repos", owner, name]),
1650                Auth::Token(&token),
1651                spec.resource.as_str(),
1652            )
1653            .await?
1654        {
1655            return Err(ForgeError::AlreadyExists {
1656                resource: spec.resource.to_string(),
1657                forge_id: Some(existing.id),
1658            });
1659        }
1660        let mut body = json!({
1661            "name": name,
1662            "private": private,
1663            // A first commit gives the repo a default branch for the
1664            // bootstrap to commit to and the protection to cover.
1665            "auto_init": true,
1666            "readme": "Default",
1667            "default_branch": "main",
1668        });
1669        if let Some(d) = &spec.description {
1670            body["description"] = json!(d);
1671        }
1672        let created: RepoJson = self
1673            .api
1674            .json(
1675                Method::POST,
1676                self.api.url(&["orgs", owner, "repos"]),
1677                Auth::Token(&token),
1678                Some(&body),
1679                spec.resource.as_str(),
1680            )
1681            .await
1682            .map_err(|e| match e {
1683                ForgeError::Rejected { status: 409, .. } => ForgeError::AlreadyExists {
1684                    resource: spec.resource.to_string(),
1685                    forge_id: None,
1686                },
1687                e => e,
1688            })?;
1689        self.repo_state(&created)
1690    }
1691
1692    async fn archive_repo(&self, repo: &Resource) -> Result<()> {
1693        let (token, owner, name) = self.repo_token(repo)?;
1694        let r = self.get_repo(&token, owner, name).await?;
1695        if r.archived {
1696            return Ok(());
1697        }
1698        self.api
1699            .send(
1700                Method::PATCH,
1701                self.api.url(&["repos", owner, name]),
1702                Auth::Token(&token),
1703                Some(&json!({ "archived": true })),
1704                repo.as_str(),
1705            )
1706            .await?;
1707        Ok(())
1708    }
1709
1710    async fn apply_roles(
1711        &self,
1712        repo: &Resource,
1713        desired: &[RoleAssignment],
1714        unlisted: Unlisted,
1715    ) -> Result<ApplyReport> {
1716        let (ns, owner, name) = self.locate(repo)?;
1717        self.automated(&ns)?;
1718        let desired = self.expressible(&ns, desired);
1719        let mut wanted: BTreeMap<u64, (ForgeAccount, ForgeRole)> = BTreeMap::new();
1720        for a in &desired {
1721            // Round down onto the ladder again: never trust the caller to
1722            // have done it.
1723            let role = collapse_to_ladder(a.role, &LADDER);
1724            if let Some((_, prev)) = wanted.insert(a.account.id, (a.account.clone(), role))
1725                && prev != role
1726            {
1727                return Err(ForgeError::Config(format!(
1728                    "account {} is assigned two different roles",
1729                    a.account.id
1730                )));
1731            }
1732        }
1733
1734        let token = self.token();
1735        let r = self.get_repo(&token, owner, name).await?;
1736        let rule = match r.default_branch() {
1737            Some(branch) => self.protection_rule(&token, owner, name, &branch).await?.0,
1738            None => None,
1739        };
1740        let allow: Vec<String> = rule
1741            .as_ref()
1742            .filter(|r| r.enable_merge_whitelist)
1743            .map(|r| r.merge_whitelist_usernames.clone())
1744            .unwrap_or_default();
1745        let mut current: BTreeMap<u64, Have> = BTreeMap::new();
1746        for (account, perm) in self.collaborators(&token, owner, name).await? {
1747            if is_personal_owner(&ns, account.id) {
1748                // Never reported as unlisted, never removed.
1749                continue;
1750            }
1751            let listed = contains_login(&allow, &account.login);
1752            current.insert(
1753                account.id,
1754                Have {
1755                    account,
1756                    perm,
1757                    listed,
1758                },
1759            );
1760        }
1761
1762        let fatal = |e: &ForgeError| {
1763            matches!(
1764                e,
1765                ForgeError::Unauthorized(_) | ForgeError::RateLimited { .. }
1766            )
1767        };
1768        let mut report = ApplyReport::default();
1769        // Allow-list edits, applied in one write at the end: logins to add
1770        // (fresh), ids whose entries go, and the changes that depend on it.
1771        let mut list_add: Vec<String> = Vec::new();
1772        let mut list_drop: BTreeSet<u64> = BTreeSet::new();
1773        let mut list_dependent: Vec<usize> = Vec::new();
1774        let mut keep_listed: BTreeSet<u64> = BTreeSet::new();
1775
1776        let bot = self.bot().id;
1777        for (id, (account, role)) in &wanted {
1778            let have = current.get(id);
1779            if *id == bot
1780                && *role == ForgeRole::None
1781                && let Some(h) = have
1782            {
1783                // Whatever the caller asked: the bot losing its role would
1784                // end every automated action here.
1785                report.changes.push(RoleChange::new(
1786                    account.clone(),
1787                    h.perm.observed(h.listed),
1788                    ForgeRole::None,
1789                    RoleOutcome::Failed("the bridge's own bot is never removed".into()),
1790                ));
1791                continue;
1792            }
1793            let need_perm = Perm::for_role(*role);
1794            let need_listed = rule.is_some() && *role >= ForgeRole::Maintain;
1795            let have_perm = have.map(|h| h.perm);
1796            let have_listed = have.is_some_and(|h| h.listed);
1797            // `Maintain` is write *plus* the allow-list; before the bootstrap
1798            // there is no rule to put anyone on.
1799            let unexpressible = *role == ForgeRole::Maintain && rule.is_none();
1800            if need_listed {
1801                keep_listed.insert(*id);
1802            }
1803            if have_perm == need_perm && have_listed == need_listed && !unexpressible {
1804                if *role != ForgeRole::None {
1805                    report.unchanged.push(account.clone());
1806                }
1807                continue;
1808            }
1809            let from = have.map_or(ForgeRole::None, |h| h.perm.observed(h.listed));
1810            let mut outcome = RoleOutcome::Applied;
1811            let mut fresh_login = None;
1812            if have_perm != need_perm {
1813                let result = match need_perm {
1814                    Some(p) => match self.login_for(&token, *id).await {
1815                        Ok(login) => {
1816                            let r = self
1817                                .set_collaborator(&token, owner, name, &login, Some(p))
1818                                .await;
1819                            fresh_login = Some(login);
1820                            r
1821                        }
1822                        Err(e) => Err(e),
1823                    },
1824                    None => {
1825                        let login = &have.expect("have_perm differs from None").account.login;
1826                        self.set_collaborator(&token, owner, name, login, None)
1827                            .await
1828                    }
1829                };
1830                if let Err(e) = result {
1831                    if fatal(&e) {
1832                        return Err(e);
1833                    }
1834                    report.changes.push(RoleChange::new(
1835                        account.clone(),
1836                        from,
1837                        *role,
1838                        RoleOutcome::Failed(e.to_string()),
1839                    ));
1840                    continue;
1841                }
1842            }
1843            if need_listed && !have_listed {
1844                let login = match fresh_login {
1845                    Some(l) => Ok(l),
1846                    None => self.login_for(&token, *id).await,
1847                };
1848                match login {
1849                    Ok(l) => {
1850                        list_add.push(l);
1851                        list_dependent.push(report.changes.len());
1852                    }
1853                    Err(e) if fatal(&e) => return Err(e),
1854                    Err(e) => outcome = RoleOutcome::Failed(e.to_string()),
1855                }
1856            } else if !need_listed && have_listed {
1857                list_drop.insert(*id);
1858                list_dependent.push(report.changes.len());
1859            }
1860            if unexpressible {
1861                outcome = RoleOutcome::Failed(
1862                    "granted `write`; `maintain` also needs a place on the default branch's merge \
1863                     allow-list, which exists once the repository is bootstrapped"
1864                        .into(),
1865                );
1866            }
1867            report
1868                .changes
1869                .push(RoleChange::new(account.clone(), from, *role, outcome));
1870        }
1871
1872        for (id, have) in &current {
1873            if wanted.contains_key(id) {
1874                continue;
1875            }
1876            let observed = have.perm.observed(have.listed);
1877            match unlisted {
1878                Unlisted::Remove => {
1879                    let outcome = match self
1880                        .set_collaborator(&token, owner, name, &have.account.login, None)
1881                        .await
1882                    {
1883                        Ok(()) => RoleOutcome::Applied,
1884                        Err(e) if fatal(&e) => return Err(e),
1885                        Err(e) => RoleOutcome::Failed(e.to_string()),
1886                    };
1887                    if have.listed {
1888                        list_drop.insert(*id);
1889                    }
1890                    report.changes.push(RoleChange::new(
1891                        have.account.clone(),
1892                        observed,
1893                        ForgeRole::None,
1894                        outcome,
1895                    ));
1896                }
1897                _ => {
1898                    if have.listed {
1899                        keep_listed.insert(*id);
1900                    }
1901                    report
1902                        .kept_unlisted
1903                        .push(Collaborator::new(have.account.clone(), observed));
1904                }
1905            }
1906        }
1907
1908        if let Some(rule) = &rule {
1909            let id_of = |login: &str| {
1910                current
1911                    .values()
1912                    .find(|h| h.account.login.eq_ignore_ascii_case(login))
1913                    .map(|h| h.account.id)
1914            };
1915            let mut next: Vec<String> = allow
1916                .iter()
1917                .filter(|login| match id_of(login) {
1918                    Some(id) => !list_drop.contains(&id) && keep_listed.contains(&id),
1919                    // Someone on the list who is not a collaborator: kept in
1920                    // report mode, removed in enforce mode.
1921                    None => unlisted != Unlisted::Remove,
1922                })
1923                .cloned()
1924                .collect();
1925            for login in list_add {
1926                if !contains_login(&next, &login) {
1927                    next.push(login);
1928                }
1929            }
1930            let same = next.len() == allow.len()
1931                && next.iter().all(|l| contains_login(&allow, l))
1932                && rule.enable_merge_whitelist;
1933            if !same {
1934                let branch = rule.name().unwrap_or_default().to_string();
1935                let body = json!({
1936                    "enable_merge_whitelist": true,
1937                    "merge_whitelist_usernames": next,
1938                });
1939                let result = self
1940                    .api
1941                    .send(
1942                        Method::PATCH,
1943                        self.api
1944                            .url(&["repos", owner, name, "branch_protections", &branch]),
1945                        Auth::Token(&token),
1946                        Some(&body),
1947                        "merge allow-list",
1948                    )
1949                    .await;
1950                if let Err(e) = result {
1951                    if fatal(&e) {
1952                        return Err(e);
1953                    }
1954                    for i in list_dependent {
1955                        if let Some(c) = report.changes.get_mut(i)
1956                            && c.outcome == RoleOutcome::Applied
1957                        {
1958                            c.outcome = RoleOutcome::Failed(format!("merge allow-list: {e}"));
1959                        }
1960                    }
1961                }
1962            }
1963        }
1964        Ok(report)
1965    }
1966
1967    /// Forgejo's effective permission for the account
1968    /// (`/collaborators/{collaborator}/permission` counts teams and
1969    /// organisation ownership), then where it comes from. Forgejo has no
1970    /// organisation-wide base permission: an organisation's members reach
1971    /// its repositories through teams (the owners through the Owners team).
1972    async fn indirect_access(
1973        &self,
1974        repo: &Resource,
1975        account: &ForgeAccount,
1976    ) -> Result<Option<IndirectAccess>> {
1977        let (ns, owner, name) = self.locate(repo)?;
1978        self.automated(&ns)?;
1979        let token = self.token();
1980        let login = match self.login_for(&token, account.id).await {
1981            Ok(l) => l,
1982            // No such account any more: it has no access.
1983            Err(ForgeError::NotFound { .. }) => return Ok(None),
1984            Err(e) => return Err(e),
1985        };
1986        let url = self
1987            .api
1988            .url(&["repos", owner, name, "collaborators", &login, "permission"]);
1989        let Some(p) = self
1990            .api
1991            .get_opt::<PermissionJson>(url, Auth::Token(&token), "collaborator permission")
1992            .await?
1993        else {
1994            return Ok(None);
1995        };
1996        let Some(perm) = Perm::parse(&p.permission) else {
1997            // `none`.
1998            return Ok(None);
1999        };
2000        // Everyone reads a public repository: that is no access to report.
2001        if perm == Perm::Read && !self.get_repo(&token, owner, name).await?.private {
2002            return Ok(None);
2003        }
2004        let via = if ns.kind == NamespaceKind::Organization {
2005            self.access_sources(&token, owner, name, &login).await
2006        } else {
2007            Vec::new()
2008        };
2009        Ok(Some(IndirectAccess::new(perm.observed(false), via)))
2010    }
2011
2012    fn bootstrap_plan(&self, repo: &RepoSpec, cfg: &VgiConfig) -> Result<Vec<BootstrapStep>> {
2013        if repo.resource.host() != self.config.host {
2014            return Err(ForgeError::WrongResource {
2015                resource: repo.resource.to_string(),
2016                expected: format!("a repository on `{}`", self.config.host),
2017            });
2018        }
2019        repo.resource.require_owner_repo()?;
2020        let probed = self.probed();
2021        let key: Option<Vec<u8>> = if probed.info.features.fast_forward_only {
2022            None
2023        } else {
2024            match self.config.merge_fallback {
2025                // The step will fail, naming the upgrade; the plan still
2026                // asks for fast-forward only.
2027                MergeFallback::Fail => None,
2028                _ => Some(
2029                    cfg.platform_keyring
2030                        .clone()
2031                        .or(probed.signing_key.clone())
2032                        .ok_or_else(|| {
2033                            ForgeError::Config(
2034                                "the signing-key merge fallback needs the instance's signing \
2035                                 key; refresh the adapter or supply it as the platform keyring"
2036                                    .into(),
2037                            )
2038                        })?,
2039                ),
2040            }
2041        };
2042        let opts = PlanOptions {
2043            checkout_action: &self.config.checkout_action,
2044            actions_base: &self.config.actions_base,
2045            runs_on: &self.config.runs_on,
2046            status_context: self.config.status_context(&cfg.required_check),
2047            inline_variables: !(self.config.use_actions_variables
2048                && probed.info.features.actions_variables),
2049            merges: match &key {
2050                Some(k) => MergePlan::SigningKey(k),
2051                None => MergePlan::FastForwardOnly,
2052            },
2053        };
2054        forgejo_plan(repo, cfg, &opts)
2055    }
2056
2057    async fn run_step(&self, repo: &Resource, step: &BootstrapStep) -> Result<StepOutcome> {
2058        match &step.action {
2059            StepAction::WriteFile {
2060                path,
2061                contents,
2062                message,
2063            } if path == crate::plan::WORKFLOW_PATH || path == crate::plan::KEYRING_PATH => {
2064                self.write_managed_file(repo, path, contents, message).await
2065            }
2066            StepAction::WriteFile {
2067                path,
2068                contents,
2069                message,
2070            } => self.write_file(repo, path, contents, message).await,
2071            StepAction::SetVariable { name, value } => self.set_variable(repo, name, value).await,
2072            StepAction::ProtectDefaultBranch(spec) => self.protect(repo, spec).await,
2073            StepAction::ConfigureRepo(settings) => self.configure_repo(repo, settings).await,
2074            StepAction::RefreshProtectedFiles { files, message } => self
2075                .refresh_managed_files(repo, files, message)
2076                .await
2077                .map(|r| r.outcome),
2078            other => Err(ForgeError::Unsupported {
2079                operation: format!("bootstrap step {other:?}"),
2080                hint: "this Forgejo adapter does not know that step".into(),
2081            }),
2082        }
2083    }
2084
2085    fn parse_event(&self, headers: &HeaderMap, body: &[u8]) -> Result<Option<ForgeEvent>> {
2086        webhook::parse(&self.webhook_secret, &self.config.host, headers, body)
2087    }
2088
2089    /// The neutral comparison, with the check named as Forgejo reports it
2090    /// (`<workflow> / <job> (pull_request)`), plus what makes the check mean
2091    /// something on Forgejo: the protected workflow paths, fast-forward-only
2092    /// merges, and Actions being on.
2093    fn diff(&self, observed: &RepoState, desired: &Projection) -> Vec<Drift> {
2094        let mut want = desired.clone();
2095        want.required_check = desired
2096            .required_check
2097            .as_deref()
2098            .map(|c| self.config.status_context(c));
2099        let mut drift = default_diff(observed, &want);
2100        if desired.required_check.is_none()
2101            || drift.iter().any(|d| matches!(d, Drift::Replaced { .. }))
2102        {
2103            return drift;
2104        }
2105        let extra = self.forgejo_gaps(&observed.protection);
2106        if extra.is_empty() {
2107            return drift;
2108        }
2109        match drift
2110            .iter_mut()
2111            .find(|d| matches!(d, Drift::ProtectionWeakened { .. }))
2112        {
2113            Some(Drift::ProtectionWeakened { gaps }) => {
2114                if !gaps.contains(&ProtectionGap::Missing) {
2115                    gaps.extend(extra);
2116                } else {
2117                    gaps.extend(
2118                        extra
2119                            .into_iter()
2120                            .filter(|g| !matches!(g, ProtectionGap::UnprotectedPaths { .. })),
2121                    );
2122                }
2123            }
2124            _ => drift.push(Drift::ProtectionWeakened { gaps: extra }),
2125        }
2126        drift
2127    }
2128}
2129
2130impl ForgejoForge {
2131    fn authorize_url(&self, redirect: &url::Url, state: &str, verifier: &Secret) -> url::Url {
2132        let mut url = self.api.web_url(&["login", "oauth", "authorize"]);
2133        {
2134            let mut q = url.query_pairs_mut();
2135            q.append_pair("client_id", &self.config.oauth_client_id)
2136                .append_pair("redirect_uri", redirect.as_str())
2137                .append_pair("response_type", "code")
2138                .append_pair("state", state)
2139                .append_pair("code_challenge", &OAuthKeys::challenge(verifier))
2140                .append_pair("code_challenge_method", "S256");
2141            if let Some(scope) = &self.config.oauth_scope {
2142                q.append_pair("scope", scope);
2143            }
2144        }
2145        url
2146    }
2147
2148    async fn exchange_code(
2149        &self,
2150        code: &str,
2151        redirect: &url::Url,
2152        verifier: &Secret,
2153        fail: fn(String) -> ForgeError,
2154    ) -> Result<Secret> {
2155        let url = self.api.web_url(&["login", "oauth", "access_token"]);
2156        let t: TokenJson = self
2157            .api
2158            .oauth_token(
2159                url,
2160                &[
2161                    ("grant_type", "authorization_code"),
2162                    ("code", code),
2163                    ("redirect_uri", redirect.as_str()),
2164                    ("client_id", &self.config.oauth_client_id),
2165                    ("client_secret", self.oauth_secret.expose()),
2166                    ("code_verifier", verifier.expose()),
2167                ],
2168            )
2169            .await?;
2170        t.into_token(fail)
2171    }
2172
2173    /// The bind, with the admin's one-time token: confirm ownership, set up
2174    /// the team and the bot, and the webhook.
2175    async fn bind_as_admin(
2176        &self,
2177        ns: &Resource,
2178        owner: &str,
2179        admin_token: &Secret,
2180    ) -> Result<NamespaceBinding> {
2181        let reject = |m: String| Err(ForgeError::BindRejected(m));
2182        let admin_auth = Auth::Bearer(admin_token);
2183        let admin = whoami(&self.api, admin_auth).await?;
2184        let bot = self.bot();
2185        if admin.id == bot.id {
2186            return reject(
2187                "the bot cannot bind a namespace: an owner must sign in as themselves".into(),
2188            );
2189        }
2190        check_login(owner)?;
2191        let org: Option<OrgJson> = self
2192            .api
2193            .get_opt(self.api.url(&["orgs", owner]), admin_auth, "organisation")
2194            .await?;
2195        let Some(org) = org else {
2196            // A personal namespace: only its holder can bind it.
2197            if !admin.login.eq_ignore_ascii_case(owner) {
2198                return reject(format!(
2199                    "`{owner}` is not an organisation, and `{}` signed in — only the account \
2200                     holder can bind a personal namespace",
2201                    admin.login
2202                ));
2203            }
2204            let namespace = Namespace::new(ns.clone(), NamespaceKind::User)
2205                .with_owner_id(admin.id)
2206                .with_installation(bot.id);
2207            return Ok(NamespaceBinding::new(namespace, Vec::new()));
2208        };
2209
2210        // Owning the org is the binding proof.
2211        let perms: OrgPermsJson = self
2212            .api
2213            .json(
2214                Method::GET,
2215                self.api
2216                    .url(&["users", &admin.login, "orgs", owner, "permissions"]),
2217                admin_auth,
2218                None,
2219                "organisation permissions",
2220            )
2221            .await?;
2222        if !perms.is_owner {
2223            return reject(format!(
2224                "`{}` is not an owner of `{owner}`; an owner must bind the namespace",
2225                admin.login
2226            ));
2227        }
2228
2229        let team = self.ensure_team(admin_token, owner).await?;
2230        let member = self
2231            .api
2232            .url(&["teams", &team.id.to_string(), "members", &bot.login]);
2233        if !self
2234            .api
2235            .exists(member.clone(), admin_auth, "team member")
2236            .await?
2237        {
2238            self.api
2239                .send(Method::PUT, member, admin_auth, None, "team member")
2240                .await?;
2241        }
2242
2243        let mut missing = Vec::new();
2244        // Confirm from the bot's side that the team gives it what it needs.
2245        let bot_perms: OrgPermsJson = self
2246            .api
2247            .json(
2248                Method::GET,
2249                self.api
2250                    .url(&["users", &bot.login, "orgs", owner, "permissions"]),
2251                Auth::Token(&self.token()),
2252                None,
2253                "bot organisation permissions",
2254            )
2255            .await?;
2256        if !bot_perms.can_create_repository {
2257            missing.push(format!(
2258                "create repositories in `{owner}` (team `{}`)",
2259                self.config.team_name
2260            ));
2261        }
2262        if let Some(hook_url) = &self.config.webhook_url {
2263            match self.ensure_hook(admin_token, owner, hook_url).await {
2264                Ok(()) => {}
2265                Err(ForgeError::Forbidden(m) | ForgeError::Rejected { message: m, .. }) => {
2266                    missing.push(format!("org webhook: {m}"));
2267                }
2268                Err(ForgeError::NotFound { .. }) => {
2269                    missing.push("org webhook: webhooks are disabled on the instance".into());
2270                }
2271                Err(e) => return Err(e),
2272            }
2273        }
2274        let namespace = Namespace::new(ns.clone(), NamespaceKind::Organization)
2275            .with_owner_id(org.id)
2276            .with_installation(team.id);
2277        Ok(NamespaceBinding::new(namespace, missing))
2278    }
2279
2280    async fn ensure_team(&self, admin_token: &Secret, org: &str) -> Result<TeamJson> {
2281        let auth = Auth::Bearer(admin_token);
2282        let teams: Vec<TeamJson> = self
2283            .api
2284            .get_all(self.api.url(&["orgs", org, "teams"]), auth, "teams")
2285            .await?;
2286        let body = json!({
2287            "name": self.config.team_name,
2288            "description": "VGI bridge bot: creates repositories and enforces the VTC's roles \
2289                            and commit-trust protection. Managed by the bridge.",
2290            "permission": "admin",
2291            "can_create_org_repo": true,
2292            "includes_all_repositories": true,
2293            "units": TEAM_UNITS,
2294        });
2295        let existing = teams
2296            .into_iter()
2297            .find(|t| t.name.eq_ignore_ascii_case(&self.config.team_name));
2298        if let Some(t) = &existing {
2299            // The team is granted admin on every repository. Adopting one
2300            // someone else already uses would hand that to its members, so
2301            // only a team that is empty or holds just the bot is taken over.
2302            let bot = self.bot();
2303            let members: Vec<UserJson> = self
2304                .api
2305                .get_all(
2306                    self.api.url(&["teams", &t.id.to_string(), "members"]),
2307                    auth,
2308                    "team members",
2309                )
2310                .await?;
2311            let others: Vec<String> = members
2312                .into_iter()
2313                .filter(|m| m.id != bot.id)
2314                .map(|m| m.login)
2315                .collect();
2316            if !others.is_empty() {
2317                return Err(ForgeError::BindRejected(format!(
2318                    "`{org}` already has a team named `{}` with other members ({}); the bridge \
2319                     will not adopt it and grant them admin on every repository. Rename that \
2320                     team or configure another team name",
2321                    t.name,
2322                    others.join(", ")
2323                )));
2324            }
2325        }
2326        match existing {
2327            Some(t)
2328                if t.permission == "admin"
2329                    && t.can_create_org_repo
2330                    && t.includes_all_repositories =>
2331            {
2332                Ok(t)
2333            }
2334            Some(t) => {
2335                self.api
2336                    .json(
2337                        Method::PATCH,
2338                        self.api.url(&["teams", &t.id.to_string()]),
2339                        auth,
2340                        Some(&body),
2341                        "team",
2342                    )
2343                    .await
2344            }
2345            None => {
2346                self.api
2347                    .json(
2348                        Method::POST,
2349                        self.api.url(&["orgs", org, "teams"]),
2350                        auth,
2351                        Some(&body),
2352                        "team",
2353                    )
2354                    .await
2355            }
2356        }
2357    }
2358
2359    async fn ensure_hook(&self, admin_token: &Secret, org: &str, url: &url::Url) -> Result<()> {
2360        let auth = Auth::Bearer(admin_token);
2361        let hooks: Vec<HookJson> = self
2362            .api
2363            .get_all(self.api.url(&["orgs", org, "hooks"]), auth, "org webhooks")
2364            .await?;
2365        let config = json!({
2366            "url": url.as_str(),
2367            "content_type": "json",
2368            "secret": self.webhook_secret.expose(),
2369        });
2370        let existing = hooks.into_iter().find(|h| {
2371            h.config.get("url").map(String::as_str) == Some(url.as_str())
2372                || h.url.as_deref() == Some(url.as_str())
2373        });
2374        match existing {
2375            // The secret cannot be read back, so an existing hook is always
2376            // rewritten: that is what makes a re-bind repair a changed one.
2377            Some(h) => {
2378                let body = json!({ "config": config, "events": HOOK_EVENTS, "active": true });
2379                self.api
2380                    .send(
2381                        Method::PATCH,
2382                        self.api.url(&["orgs", org, "hooks", &h.id.to_string()]),
2383                        auth,
2384                        Some(&body),
2385                        "org webhook",
2386                    )
2387                    .await?;
2388            }
2389            None => {
2390                let kind = if self.probed().info.features.forgejo_webhooks {
2391                    "forgejo"
2392                } else {
2393                    "gitea"
2394                };
2395                let body = json!({
2396                    "type": kind,
2397                    "config": config,
2398                    "events": HOOK_EVENTS,
2399                    "active": true,
2400                });
2401                self.api
2402                    .send(
2403                        Method::POST,
2404                        self.api.url(&["orgs", org, "hooks"]),
2405                        auth,
2406                        Some(&body),
2407                        "org webhook",
2408                    )
2409                    .await?;
2410            }
2411        }
2412        Ok(())
2413    }
2414}
2415
2416impl ForgeHooks for ForgejoForge {
2417    /// The holder of a personal namespace owns every repository in it and
2418    /// cannot be added as a collaborator, so they are dropped from the
2419    /// desired set before it reaches Forgejo (and before the core reports
2420    /// their "missing" role as drift).
2421    fn before_apply_roles(
2422        &self,
2423        repo: &Resource,
2424        desired: &[RoleAssignment],
2425    ) -> HookDecision<Vec<RoleAssignment>> {
2426        let Ok(ns) = self.namespace(&repo.namespace()) else {
2427            return HookDecision::Continue;
2428        };
2429        let kept = self.expressible(&ns, desired);
2430        if kept.len() == desired.len() {
2431            HookDecision::Continue
2432        } else {
2433            HookDecision::Modify(kept)
2434        }
2435    }
2436}
2437
2438// ── helpers ──────────────────────────────────────────────────────────────
2439
2440/// A collaborator's repository permission, as Forgejo reports it.
2441#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2442enum Perm {
2443    Read,
2444    Write,
2445    Admin,
2446}
2447
2448impl Perm {
2449    fn parse(s: &str) -> Option<Perm> {
2450        match s {
2451            "read" => Some(Perm::Read),
2452            "write" => Some(Perm::Write),
2453            "admin" | "owner" => Some(Perm::Admin),
2454            _ => None,
2455        }
2456    }
2457
2458    fn as_str(self) -> &'static str {
2459        match self {
2460            Perm::Read => "read",
2461            Perm::Write => "write",
2462            Perm::Admin => "admin",
2463        }
2464    }
2465
2466    /// The collaborator permission a role needs; `None` for no role.
2467    fn for_role(role: ForgeRole) -> Option<Perm> {
2468        match role {
2469            ForgeRole::Admin => Some(Perm::Admin),
2470            ForgeRole::Maintain | ForgeRole::Write => Some(Perm::Write),
2471            ForgeRole::None => None,
2472            _ => Some(Perm::Read),
2473        }
2474    }
2475
2476    /// The role this permission (and allow-list place) amounts to.
2477    fn observed(self, listed: bool) -> ForgeRole {
2478        match self {
2479            Perm::Admin => ForgeRole::Admin,
2480            Perm::Write if listed => ForgeRole::Maintain,
2481            Perm::Write => ForgeRole::Write,
2482            Perm::Read => ForgeRole::Read,
2483        }
2484    }
2485}
2486
2487/// Someone's standing on a repository before a change.
2488struct Have {
2489    account: ForgeAccount,
2490    perm: Perm,
2491    listed: bool,
2492}
2493
2494/// The holder of a personal namespace: owns every repository in it, is
2495/// never a collaborator.
2496fn is_personal_owner(ns: &Namespace, id: u64) -> bool {
2497    ns.kind == NamespaceKind::User && ns.owner_id == Some(id)
2498}
2499
2500/// Whether Forgejo reads `name` as a glob pattern rather than a plain name
2501/// (gobwas `syntax.Special`). The same characters in a required status
2502/// context make it a pattern too — and an invalid pattern there matches as
2503/// if nothing were required, so the plan refuses them.
2504pub(crate) fn is_glob(name: &str) -> bool {
2505    name.contains(['*', '?', '[', ']', '{', '}', '\\'])
2506}
2507
2508fn contains_login(list: &[String], login: &str) -> bool {
2509    list.iter().any(|l| l.eq_ignore_ascii_case(login))
2510}
2511
2512/// Forgejo's `;`-separated pattern list, as it compiles it: trimmed and
2513/// lowercased, empties dropped.
2514fn patterns(s: &str) -> Vec<String> {
2515    s.split(';')
2516        .map(|p| p.trim().to_ascii_lowercase())
2517        .filter(|p| !p.is_empty())
2518        .collect()
2519}
2520
2521fn last_eight(token: &str) -> Option<String> {
2522    (token.len() >= 8).then(|| token[token.len() - 8..].to_string())
2523}
2524
2525fn merge_style(m: MergeMethod) -> &'static str {
2526    match m {
2527        MergeMethod::FastForward => "fast-forward-only",
2528        MergeMethod::Rebase => "rebase",
2529        MergeMethod::RebaseMerge => "rebase-merge",
2530        MergeMethod::Squash => "squash",
2531        _ => "merge",
2532    }
2533}
2534
2535/// The managed rule among `rules` — the one named exactly `branch` — and
2536/// the names of any other plain rules Forgejo may apply to the branch in its
2537/// place (a case-insensitive name match; see `protection_rule`).
2538fn select_rule(rules: &[ProtectionJson], branch: &str) -> (Option<usize>, Vec<String>) {
2539    let folded = branch.to_lowercase();
2540    let mut managed = None;
2541    let mut shadowing = Vec::new();
2542    for (i, rule) in rules.iter().enumerate() {
2543        match rule.name() {
2544            Some(n) if n == branch => managed = Some(i),
2545            Some(n) if !is_glob(n) && n.to_lowercase() == folded => shadowing.push(n.to_string()),
2546            _ => {}
2547        }
2548    }
2549    (managed, shadowing)
2550}
2551
2552/// The `PATCH /repos/{owner}/{repo}` body that makes the repository's merge
2553/// and CI settings `s`.
2554fn settings_request(r: &RepoJson, s: &RepoSettings) -> Value {
2555    let has = |m| s.merge_methods.contains(&m);
2556    let mut body = json!({});
2557    if !s.merge_methods.is_empty() {
2558        // Forgejo applies merge settings only alongside
2559        // `has_pull_requests`.
2560        body = json!({
2561            "has_pull_requests": true,
2562            "allow_merge_commits": has(MergeMethod::MergeCommit),
2563            "allow_rebase": has(MergeMethod::Rebase),
2564            "allow_rebase_explicit": has(MergeMethod::RebaseMerge),
2565            "allow_squash_merge": has(MergeMethod::Squash),
2566            "default_merge_style": merge_style(s.merge_methods[0]),
2567        });
2568        if r.allow_fast_forward_only_merge.is_some() {
2569            body["allow_fast_forward_only_merge"] = json!(has(MergeMethod::FastForward));
2570        }
2571    }
2572    if s.enable_ci {
2573        body["has_actions"] = json!(true);
2574    }
2575    body
2576}
2577
2578/// The branch-protection body for `spec`, keeping what an `existing` rule
2579/// already has (its merge allow-list, contexts and protected paths) and
2580/// seeding the allow-list with `admins`. A new rule also needs its
2581/// `rule_name` / `branch_name`, which the caller adds.
2582fn protection_request(
2583    existing: Option<&ProtectionJson>,
2584    admins: &[String],
2585    spec: &ProtectionSpec,
2586) -> Value {
2587    let mut allow: Vec<String> = existing
2588        .filter(|r| r.enable_merge_whitelist)
2589        .map(|r| r.merge_whitelist_usernames.clone())
2590        .unwrap_or_default();
2591    for login in admins {
2592        if !contains_login(&allow, login) {
2593            allow.push(login.clone());
2594        }
2595    }
2596    let mut contexts = existing
2597        .map(|r| r.status_check_contexts.clone())
2598        .unwrap_or_default();
2599    if !contexts.contains(&spec.required_check) {
2600        contexts.push(spec.required_check.clone());
2601    }
2602    let mut paths = existing
2603        .map(|r| patterns(&r.protected_file_patterns))
2604        .unwrap_or_default();
2605    for p in &spec.protected_paths {
2606        let p = p.to_ascii_lowercase();
2607        if !paths.contains(&p) {
2608            paths.push(p);
2609        }
2610    }
2611    json!({
2612        "enable_push": !spec.require_pull_request,
2613        "enable_push_whitelist": false,
2614        "push_whitelist_usernames": [],
2615        "push_whitelist_teams": [],
2616        "push_whitelist_deploy_keys": false,
2617        "enable_merge_whitelist": true,
2618        "merge_whitelist_usernames": allow,
2619        "merge_whitelist_teams": [],
2620        "enable_status_check": true,
2621        "status_check_contexts": contexts,
2622        "protected_file_patterns": paths.join(";"),
2623        "unprotected_file_patterns": "",
2624        "apply_to_admins": true,
2625    })
2626}
2627
2628// ── the same shapes, for a client acting as the repository's admin ───────
2629//
2630// `vgi repo init` runs a bootstrap plan as the account holder, through their
2631// own token, where no bridge exists. These let it ask Forgejo for exactly
2632// what `run_step` asks for, and skip exactly what `run_step` would skip.
2633
2634fn parse<T: serde::de::DeserializeOwned>(what: &str, v: &Value) -> Result<T> {
2635    serde_json::from_value(v.clone()).map_err(|e| ForgeError::Protocol(format!("{what}: {e}")))
2636}
2637
2638/// From `GET /repos/{owner}/{repo}/branch_protections`: the managed rule for
2639/// `branch`, and the names of rules that could shadow it (a plan must refuse
2640/// to rely on the managed rule while any exist).
2641pub fn managed_protection_rule(
2642    rules: &Value,
2643    branch: &str,
2644) -> Result<(Option<Value>, Vec<String>)> {
2645    let mut list: Vec<Value> = parse("branch protections", rules)?;
2646    let typed = list
2647        .iter()
2648        .map(|v| parse::<ProtectionJson>("branch protection", v))
2649        .collect::<Result<Vec<_>>>()?;
2650    let (managed, shadowing) = select_rule(&typed, branch);
2651    Ok((managed.map(|i| list.swap_remove(i)), shadowing))
2652}
2653
2654/// Whether a rule (as Forgejo returns it) already is what the protection
2655/// step would write for `spec`.
2656pub fn protection_satisfies(rule: &Value, spec: &ProtectionSpec) -> Result<bool> {
2657    Ok(satisfies_protection(
2658        &parse("branch protection", rule)?,
2659        spec,
2660    ))
2661}
2662
2663/// The protection body for `spec` over an `existing` rule, the allow-list
2664/// seeded with `admins`. For a new rule, add `rule_name` and `branch_name`.
2665pub fn protection_body(
2666    existing: Option<&Value>,
2667    admins: &[String],
2668    spec: &ProtectionSpec,
2669) -> Result<Value> {
2670    let existing: Option<ProtectionJson> = existing
2671        .map(|v| parse("branch protection", v))
2672        .transpose()?;
2673    Ok(protection_request(existing.as_ref(), admins, spec))
2674}
2675
2676/// Whether a repository (as `GET /repos/{owner}/{repo}` returns it) already
2677/// has the settings `s`.
2678pub fn settings_satisfied(repo: &Value, s: &RepoSettings) -> Result<bool> {
2679    Ok(satisfies_settings(&parse("repository", repo)?, s))
2680}
2681
2682/// The `PATCH /repos/{owner}/{repo}` body for `s`.
2683pub fn settings_body(repo: &Value, s: &RepoSettings) -> Result<Value> {
2684    Ok(settings_request(&parse("repository", repo)?, s))
2685}
2686
2687fn satisfies_settings(r: &RepoJson, s: &RepoSettings) -> bool {
2688    if s.enable_ci && r.has_actions != Some(true) {
2689        return false;
2690    }
2691    if s.merge_methods.is_empty() {
2692        return true;
2693    }
2694    r.has_pull_requests != Some(false)
2695        && r.merge_methods() == {
2696            let mut want = s.merge_methods.clone();
2697            want.sort();
2698            want.dedup();
2699            want
2700        }
2701        && r.default_merge_style.as_deref() == Some(merge_style(s.merge_methods[0]))
2702}
2703
2704/// Whether two readings of a rule agree on everything a refresh opens.
2705fn same_push_settings(a: &ProtectionJson, b: &ProtectionJson) -> bool {
2706    let set = |v: &[String]| {
2707        let mut v: Vec<String> = v.iter().map(|s| s.to_lowercase()).collect();
2708        v.sort();
2709        v
2710    };
2711    a.enable_push == b.enable_push
2712        && (!a.enable_push
2713            || (a.enable_push_whitelist == b.enable_push_whitelist
2714                && set(&a.push_whitelist_usernames) == set(&b.push_whitelist_usernames)
2715                && set(&a.push_whitelist_teams) == set(&b.push_whitelist_teams)
2716                && a.push_whitelist_deploy_keys == b.push_whitelist_deploy_keys))
2717        && patterns(&a.protected_file_patterns) == patterns(&b.protected_file_patterns)
2718}
2719
2720/// Whether an existing rule already is what the protection step writes.
2721fn satisfies_protection(rule: &ProtectionJson, spec: &ProtectionSpec) -> bool {
2722    let paths = patterns(&rule.protected_file_patterns);
2723    (!spec.require_pull_request || !rule.enable_push)
2724        && rule.enable_status_check
2725        && rule.status_check_contexts.contains(&spec.required_check)
2726        && rule.enable_merge_whitelist
2727        && rule.merge_whitelist_teams.is_empty()
2728        && patterns(&rule.unprotected_file_patterns).is_empty()
2729        // `None`: an instance without the setting, where it cannot be had.
2730        && rule.apply_to_admins != Some(false)
2731        && rule.enable_force_push != Some(true)
2732        && spec
2733            .protected_paths
2734            .iter()
2735            .all(|p| paths.contains(&p.to_ascii_lowercase()))
2736}
2737
2738fn decode_content(c: &ContentJson) -> Result<Vec<u8>> {
2739    match c.encoding.as_deref() {
2740        Some("base64") => {
2741            let compact: String = c
2742                .content
2743                .as_deref()
2744                .unwrap_or("")
2745                .chars()
2746                .filter(|ch| !ch.is_whitespace())
2747                .collect();
2748            STANDARD
2749                .decode(compact)
2750                .map_err(|e| ForgeError::Protocol(format!("file content: {e}")))
2751        }
2752        // Forgejo returns no inline content for a blob over its API size
2753        // limit. Nothing the bootstrap writes is that large, so a file that
2754        // is was put there by someone else: refuse rather than overwrite
2755        // what we cannot see.
2756        None => Err(ForgeError::Rejected {
2757            status: 409,
2758            message: "the existing file is too large for the instance to return inline; it was \
2759                      not written by the bootstrap — remove or rename it"
2760                .into(),
2761        }),
2762        other => Err(ForgeError::Protocol(format!(
2763            "file content in unknown encoding {other:?}"
2764        ))),
2765    }
2766}
2767
2768/// `null` (which Forgejo sends for an empty list) as the default.
2769fn nullable<'de, D, T>(d: D) -> std::result::Result<T, D::Error>
2770where
2771    D: Deserializer<'de>,
2772    T: Default + Deserialize<'de>,
2773{
2774    Ok(Option::<T>::deserialize(d)?.unwrap_or_default())
2775}
2776
2777// ── wire shapes ──────────────────────────────────────────────────────────
2778
2779#[derive(Deserialize)]
2780struct RepoJson {
2781    id: u64,
2782    full_name: String,
2783    #[serde(default)]
2784    private: bool,
2785    #[serde(default)]
2786    archived: bool,
2787    #[serde(default)]
2788    empty: bool,
2789    #[serde(default)]
2790    default_branch: Option<String>,
2791    #[serde(default)]
2792    has_pull_requests: Option<bool>,
2793    #[serde(default)]
2794    has_actions: Option<bool>,
2795    #[serde(default)]
2796    allow_fast_forward_only_merge: Option<bool>,
2797    #[serde(default)]
2798    allow_merge_commits: Option<bool>,
2799    #[serde(default)]
2800    allow_rebase: Option<bool>,
2801    #[serde(default)]
2802    allow_rebase_explicit: Option<bool>,
2803    #[serde(default)]
2804    allow_squash_merge: Option<bool>,
2805    #[serde(default)]
2806    default_merge_style: Option<String>,
2807}
2808
2809impl RepoJson {
2810    fn default_branch(&self) -> Option<String> {
2811        self.default_branch
2812            .clone()
2813            .filter(|b| !b.is_empty() && !self.empty)
2814    }
2815
2816    /// Allowed merge methods, sorted. None at all when pull requests are off.
2817    fn merge_methods(&self) -> Vec<MergeMethod> {
2818        if self.has_pull_requests == Some(false) {
2819            return Vec::new();
2820        }
2821        let mut m: Vec<MergeMethod> = [
2822            (self.allow_fast_forward_only_merge, MergeMethod::FastForward),
2823            (self.allow_merge_commits, MergeMethod::MergeCommit),
2824            (self.allow_rebase, MergeMethod::Rebase),
2825            (self.allow_rebase_explicit, MergeMethod::RebaseMerge),
2826            (self.allow_squash_merge, MergeMethod::Squash),
2827        ]
2828        .into_iter()
2829        .filter(|(on, _)| *on == Some(true))
2830        .map(|(_, m)| m)
2831        .collect();
2832        m.sort();
2833        m
2834    }
2835}
2836
2837#[derive(Deserialize)]
2838struct UserJson {
2839    id: u64,
2840    login: String,
2841}
2842
2843#[derive(Deserialize)]
2844struct SearchJson {
2845    #[serde(default, deserialize_with = "nullable")]
2846    data: Vec<UserJson>,
2847}
2848
2849#[derive(Deserialize)]
2850struct PermissionJson {
2851    permission: String,
2852}
2853
2854#[derive(Deserialize)]
2855struct OrgPermissionsJson {
2856    #[serde(default)]
2857    is_owner: bool,
2858}
2859
2860#[derive(Deserialize)]
2861struct OrgJson {
2862    id: u64,
2863}
2864
2865#[derive(Deserialize, Default)]
2866#[serde(default)]
2867struct OrgPermsJson {
2868    is_owner: bool,
2869    can_create_repository: bool,
2870}
2871
2872#[derive(Deserialize)]
2873struct TeamJson {
2874    id: u64,
2875    name: String,
2876    #[serde(default)]
2877    permission: String,
2878    #[serde(default)]
2879    can_create_org_repo: bool,
2880    #[serde(default)]
2881    includes_all_repositories: bool,
2882}
2883
2884#[derive(Deserialize)]
2885struct HookJson {
2886    id: u64,
2887    #[serde(default)]
2888    url: Option<String>,
2889    #[serde(default, deserialize_with = "nullable")]
2890    config: BTreeMap<String, String>,
2891}
2892
2893#[derive(Deserialize)]
2894struct VariableJson {
2895    #[serde(default)]
2896    data: String,
2897}
2898
2899#[derive(Deserialize)]
2900struct ContentJson {
2901    #[serde(default)]
2902    sha: String,
2903    #[serde(rename = "type")]
2904    kind: String,
2905    #[serde(default)]
2906    content: Option<String>,
2907    #[serde(default)]
2908    encoding: Option<String>,
2909}
2910
2911#[derive(Deserialize)]
2912struct NewTokenJson {
2913    id: u64,
2914    sha1: String,
2915}
2916
2917impl Drop for NewTokenJson {
2918    fn drop(&mut self) {
2919        use zeroize::Zeroize;
2920        self.sha1.zeroize();
2921    }
2922}
2923
2924#[derive(Deserialize)]
2925struct TokenInfoJson {
2926    id: u64,
2927    name: String,
2928    #[serde(default)]
2929    token_last_eight: Option<String>,
2930}
2931
2932#[derive(Deserialize, Default, Clone)]
2933#[serde(default)]
2934struct ProtectionJson {
2935    rule_name: Option<String>,
2936    branch_name: Option<String>,
2937    enable_push: bool,
2938    enable_push_whitelist: bool,
2939    #[serde(deserialize_with = "nullable")]
2940    push_whitelist_usernames: Vec<String>,
2941    #[serde(deserialize_with = "nullable")]
2942    push_whitelist_teams: Vec<String>,
2943    push_whitelist_deploy_keys: bool,
2944    enable_merge_whitelist: bool,
2945    #[serde(deserialize_with = "nullable")]
2946    merge_whitelist_usernames: Vec<String>,
2947    #[serde(deserialize_with = "nullable")]
2948    merge_whitelist_teams: Vec<String>,
2949    enable_status_check: bool,
2950    #[serde(deserialize_with = "nullable")]
2951    status_check_contexts: Vec<String>,
2952    #[serde(deserialize_with = "nullable")]
2953    protected_file_patterns: String,
2954    #[serde(deserialize_with = "nullable")]
2955    unprotected_file_patterns: String,
2956    /// Absent on instances without the setting — where repository admins
2957    /// can always merge past the check.
2958    apply_to_admins: Option<bool>,
2959    /// Gitea 1.23+; Forgejo refuses force-pushes to protected branches
2960    /// without a setting.
2961    enable_force_push: Option<bool>,
2962}
2963
2964impl ProtectionJson {
2965    fn name(&self) -> Option<&str> {
2966        self.rule_name
2967            .as_deref()
2968            .filter(|n| !n.is_empty())
2969            .or(self.branch_name.as_deref())
2970    }
2971
2972    /// Everyone who can land a change on the branch without the check.
2973    fn bypass_actors(&self) -> Vec<String> {
2974        let mut out = Vec::new();
2975        if self.apply_to_admins != Some(true) {
2976            out.push("repository admins (the rule does not apply to admins)".into());
2977        }
2978        if self.enable_push {
2979            if !self.enable_push_whitelist {
2980                out.push("push: everyone with write access".into());
2981            } else {
2982                out.extend(
2983                    self.push_whitelist_usernames
2984                        .iter()
2985                        .map(|u| format!("push:{u}")),
2986                );
2987                out.extend(
2988                    self.push_whitelist_teams
2989                        .iter()
2990                        .map(|t| format!("push-team:{t}")),
2991                );
2992                if self.push_whitelist_deploy_keys {
2993                    out.push("push:deploy-keys".into());
2994                }
2995            }
2996        }
2997        let unprotected = patterns(&self.unprotected_file_patterns);
2998        if !unprotected.is_empty() {
2999            // Pushes touching only these files skip the protection.
3000            out.push(format!("unprotected-files:{}", unprotected.join(";")));
3001        }
3002        // Without the allow-list, everyone with write access may merge.
3003        if !self.enable_merge_whitelist {
3004            out.push("merge: everyone with write access".into());
3005        }
3006        // A team on the merge allow-list lets people the VTC never made
3007        // maintainers merge.
3008        out.extend(
3009            self.merge_whitelist_teams
3010                .iter()
3011                .map(|t| format!("merge-team:{t}")),
3012        );
3013        out
3014    }
3015}
3016
3017#[cfg(test)]
3018mod tests {
3019    use super::*;
3020
3021    #[test]
3022    fn roles_map_both_ways() {
3023        for role in LADDER {
3024            let perm = Perm::for_role(role).unwrap();
3025            assert_eq!(perm.observed(role >= ForgeRole::Maintain), role);
3026            assert_eq!(Perm::parse(perm.as_str()), Some(perm));
3027        }
3028        assert_eq!(Perm::for_role(ForgeRole::None), None);
3029        assert_eq!(Perm::parse("owner"), Some(Perm::Admin));
3030        assert_eq!(Perm::parse("none"), None);
3031        assert_eq!(
3032            collapse_to_ladder(ForgeRole::Triage, &LADDER),
3033            ForgeRole::Read
3034        );
3035    }
3036
3037    #[test]
3038    fn patterns_are_read_as_forgejo_compiles_them() {
3039        assert_eq!(
3040            patterns(" .Forgejo/workflows/** ;;x.asc; "),
3041            [".forgejo/workflows/**", "x.asc"]
3042        );
3043        assert!(patterns("").is_empty());
3044    }
3045
3046    #[test]
3047    fn null_lists_deserialise_as_empty() {
3048        let p: ProtectionJson = serde_json::from_value(json!({
3049            "rule_name": "main",
3050            "merge_whitelist_usernames": null,
3051            "status_check_contexts": null,
3052            "protected_file_patterns": null,
3053        }))
3054        .unwrap();
3055        assert!(p.merge_whitelist_usernames.is_empty() && p.status_check_contexts.is_empty());
3056        assert_eq!(p.apply_to_admins, None);
3057        assert_eq!(
3058            p.bypass_actors(),
3059            [
3060                "repository admins (the rule does not apply to admins)",
3061                "merge: everyone with write access",
3062            ]
3063        );
3064    }
3065
3066    #[test]
3067    fn glob_characters_are_forgejos() {
3068        for g in ["main*", "rel?", "[ab]", "{a,b}", "a\\b"] {
3069            assert!(is_glob(g), "{g}");
3070        }
3071        for plain in ["main", "release/1.0", "feature-x_y", "Verify commit trust"] {
3072            assert!(!is_glob(plain), "{plain}");
3073        }
3074    }
3075}