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