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