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