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