1use url::Url;
12use vgi_forge::{
13 BootstrapComponent, BootstrapStep, ForgeError, MergeMethod, ProtectionSpec, RepoSettings,
14 RepoSpec, Resource, Result, StepAction, VgiConfig, validate_repo_path,
15};
16
17pub const WORKFLOW_PATH: &str = ".forgejo/workflows/verify-trust.yml";
19pub const KEYRING_PATH: &str = ".forgejo/trusted-platform-keys.asc";
22pub const VAR_REGISTRY: &str = "TRUST_REGISTRY_DID";
24pub const VAR_VTC: &str = "VTC_DID";
26
27pub const PROTECTED_PATHS: [&str; 4] = [
33 ".forgejo/workflows/**",
34 ".gitea/workflows/**",
35 ".github/workflows/**",
36 KEYRING_PATH,
37];
38
39pub fn default_status_context(check: &str) -> String {
42 format!("{check} / {check} (pull_request)")
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum MergePlan<'a> {
48 FastForwardOnly,
50 SigningKey(&'a [u8]),
53}
54
55impl MergePlan<'_> {
56 pub fn methods(&self) -> Vec<MergeMethod> {
58 match self {
59 MergePlan::FastForwardOnly => vec![MergeMethod::FastForward],
60 MergePlan::SigningKey(_) => vec![MergeMethod::MergeCommit],
61 }
62 }
63}
64
65#[derive(Debug, Clone)]
67pub struct PlanOptions<'a> {
68 pub checkout_action: &'a str,
70 pub actions_base: &'a Url,
72 pub runs_on: &'a str,
74 pub status_context: String,
76 pub inline_variables: bool,
79 pub merges: MergePlan<'a>,
81}
82
83pub fn forgejo_plan(
86 repo: &RepoSpec,
87 cfg: &VgiConfig,
88 opts: &PlanOptions<'_>,
89) -> Result<Vec<BootstrapStep>> {
90 repo.resource.require_owner_repo()?;
91 check_did("trust_registry_did", &cfg.trust_registry_did)?;
92 check_did("vtc_did", &cfg.vtc_did)?;
93 check_full_url_pin("checkout action", opts.checkout_action)?;
94 let action = resolve_action(&cfg.verify_trust_action, opts.actions_base)?;
95 check_version(&cfg.verify_trust_version)?;
96 let sha256 = cfg.verify_trust_sha256.as_deref().ok_or_else(|| {
97 ForgeError::Config(
98 "no verify_trust_sha256: a Forgejo runner cannot verify the release's build \
99 attestation, so the tarball's SHA-256 must be pinned in the workflow (see the \
100 runbook's Forgejo Actions runners section for how to take it)"
101 .into(),
102 )
103 })?;
104 check_sha256(sha256)?;
105 check_check_name(&cfg.required_check)?;
106 check_check_name(&opts.status_context)?;
107 check_runs_on(opts.runs_on)?;
108 if let MergePlan::SigningKey(key) = opts.merges {
109 check_keyring(key)?;
110 }
111
112 let mut steps = vec![BootstrapStep::new(
113 "merge-styles",
114 BootstrapComponent::Keyring,
117 StepAction::ConfigureRepo(RepoSettings::merge_methods(opts.merges.methods())),
118 )];
119 steps.push(BootstrapStep::new(
120 "workflow",
121 BootstrapComponent::Workflow,
122 StepAction::WriteFile {
123 path: WORKFLOW_PATH.into(),
124 contents: render_workflow(cfg, opts, &repo.resource, &action, sha256).into_bytes(),
125 message: "ci: add the VGI commit-trust check".into(),
126 },
127 ));
128 if let MergePlan::SigningKey(key) = opts.merges {
129 steps.push(BootstrapStep::new(
130 "keyring",
131 BootstrapComponent::Keyring,
132 StepAction::WriteFile {
133 path: KEYRING_PATH.into(),
134 contents: key.to_vec(),
135 message: "ci: add the instance signing key as the exempt keyring".into(),
136 },
137 ));
138 }
139 for file in &cfg.extra_files {
140 validate_repo_path(&file.path)?;
141 if file.path == WORKFLOW_PATH || file.path == KEYRING_PATH {
142 return Err(ForgeError::Config(format!(
143 "extra file `{}` would overwrite a file the bootstrap manages",
144 file.path
145 )));
146 }
147 steps.push(BootstrapStep::new(
148 format!("file:{}", file.path),
149 BootstrapComponent::Extra,
150 StepAction::WriteFile {
151 path: file.path.clone(),
152 contents: file.contents.clone(),
153 message: format!("chore: add {}", file.path),
154 },
155 ));
156 }
157 if !opts.inline_variables {
158 for (name, value) in [
159 (VAR_REGISTRY, &cfg.trust_registry_did),
160 (VAR_VTC, &cfg.vtc_did),
161 ] {
162 steps.push(BootstrapStep::new(
163 format!("variable:{name}"),
164 BootstrapComponent::Variables,
165 StepAction::SetVariable {
166 name: name.into(),
167 value: value.clone(),
168 },
169 ));
170 }
171 }
172 steps.push(BootstrapStep::new(
173 "protection",
174 BootstrapComponent::RequiredCheck,
175 StepAction::ProtectDefaultBranch(
176 ProtectionSpec::standard(opts.status_context.clone())
177 .with_protected_paths(PROTECTED_PATHS)
178 .with_required_approvals(cfg.required_approvals),
179 ),
180 ));
181 Ok(steps)
182}
183
184pub fn render_workflow(
195 cfg: &VgiConfig,
196 opts: &PlanOptions<'_>,
197 repo: &Resource,
198 action: &str,
199 sha256: &str,
200) -> String {
201 let name = yaml_single_quoted(&cfg.required_check);
202 let (registry, vtc) = if opts.inline_variables {
203 (
204 yaml_single_quoted(&cfg.trust_registry_did),
205 yaml_single_quoted(&cfg.vtc_did),
206 )
207 } else {
208 (
209 "${{ vars.TRUST_REGISTRY_DID }}".to_string(),
210 "${{ vars.VTC_DID }}".to_string(),
211 )
212 };
213 let keyring = match opts.merges {
214 MergePlan::SigningKey(_) => format!(
215 " # Web merges are signed by the instance; they pass only via its\n \
216 # key, committed here.\n exempt-keyring: {KEYRING_PATH}\n"
217 ),
218 MergePlan::FastForwardOnly => String::new(),
219 };
220 format!(
221 r#"# Managed by this community's VGI bridge. Branch protection refuses
222# pull requests that change it; the bridge updates it only through its
223# audited refresh-managed-files step. Propose changes to the VTC.
224name: {name}
225
226on:
227 pull_request:
228
229permissions:
230 contents: read
231
232jobs:
233 verify:
234 name: {name}
235 runs-on: "{runs_on}"
236 steps:
237 - uses: {checkout}
238 with:
239 # The base ref must be present so `origin/<base>..HEAD` resolves.
240 fetch-depth: 0
241 persist-credentials: false
242
243 - name: verify-trust
244 uses: {action}
245 with:
246 range: origin/${{{{ github.base_ref }}}}..HEAD
247 registry-did: {registry}
248 vtc-did: {vtc}
249{transport} resource-format: qualified
250 # The namespace: where the VTC publishes namespace-wide commit rights.
251 fallback-resource: {fallback}
252{keyring} # A Forgejo runner cannot check the release's attestation; the
253 # pinned version and checksum are what hold if a release is replaced.
254 version: {version}
255 sha256: {sha256}
256"#,
257 runs_on = opts.runs_on,
258 transport = cfg.verify_trust_transport.workflow_input_line(" "),
259 checkout = opts.checkout_action,
260 fallback = fallback_resource(repo),
261 version = cfg.verify_trust_version,
262 )
263}
264
265pub fn fallback_resource(repo: &Resource) -> String {
282 format!("{}/${{{{ github.repository_owner }}}}", repo.host())
283}
284
285fn yaml_single_quoted(s: &str) -> String {
286 format!("'{}'", s.replace('\'', "''"))
287}
288
289fn check_did(field: &str, did: &str) -> Result<()> {
290 let ok = did.starts_with("did:")
291 && did.len() <= 2048
292 && did
293 .bytes()
294 .all(|b| b.is_ascii_graphic() && b != b'\'' && b != b'"');
295 if ok {
296 Ok(())
297 } else {
298 Err(ForgeError::Config(format!(
299 "{field} `{did}` is not a DID (expected `did:<method>:…`, no spaces or quotes)"
300 )))
301 }
302}
303
304fn is_pinned_path(reference: &str) -> bool {
306 reference.rsplit_once('@').is_some_and(|(path, sha)| {
307 sha.len() == 40
308 && sha.bytes().all(|b| b.is_ascii_hexdigit())
309 && path.split('/').count() >= 2
310 && path.split('/').all(|s| {
311 !s.is_empty()
312 && s != ".."
313 && s.bytes()
314 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
315 })
316 })
317}
318
319fn check_full_url_pin(what: &str, reference: &str) -> Result<()> {
321 let ok = reference.strip_prefix("https://").is_some_and(|rest| {
322 rest.split_once('/').is_some_and(|(host, path)| {
323 !host.is_empty()
324 && host
325 .bytes()
326 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':'))
327 && is_pinned_path(path)
328 })
329 });
330 if ok {
331 Ok(())
332 } else {
333 Err(ForgeError::Config(format!(
334 "{what} `{reference}` must be a full URL pinned to a commit: \
335 `https://<host>/owner/repo[/path]@<40-hex sha>`"
336 )))
337 }
338}
339
340pub fn resolve_action(reference: &str, base: &Url) -> Result<String> {
345 if reference.starts_with("https://") {
346 check_full_url_pin("verify-trust action", reference)?;
347 return Ok(reference.to_string());
348 }
349 if !is_pinned_path(reference) {
350 return Err(ForgeError::Config(format!(
351 "verify-trust action `{reference}` must be pinned to a commit: \
352 `[https://<host>/]owner/repo[/path]@<40-hex sha>`"
353 )));
354 }
355 let full = format!("{}/{reference}", base.as_str().trim_end_matches('/'));
356 check_full_url_pin("verify-trust action", &full)?;
357 Ok(full)
358}
359
360fn check_version(v: &str) -> Result<()> {
361 let ok = !v.is_empty()
362 && v != "latest"
363 && v.len() <= 64
364 && v.bytes()
365 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_'));
366 if ok {
367 Ok(())
368 } else {
369 Err(ForgeError::Config(format!(
370 "verify-trust version `{v}` must be a release tag like `v0.5.0` — pinned, not \
371 `latest`, since the checksum is pinned with it"
372 )))
373 }
374}
375
376fn check_sha256(s: &str) -> Result<()> {
377 if s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
378 Ok(())
379 } else {
380 Err(ForgeError::Config(format!(
381 "verify_trust_sha256 `{s}` must be 64 lowercase hex digits"
382 )))
383 }
384}
385
386pub(crate) fn check_check_name(name: &str) -> Result<()> {
387 if crate::forge::is_glob(name) {
391 return Err(ForgeError::Config(format!(
392 "check name `{name}` contains a glob character (`*?[]{{}}\\`); Forgejo would \
393 read the required context as a pattern"
394 )));
395 }
396 if name.trim().is_empty()
398 || name.len() > 200
399 || name.chars().any(char::is_control)
400 || name.contains("${{")
401 {
402 return Err(ForgeError::Config(format!(
403 "check name `{name}` must be 1–200 printable characters"
404 )));
405 }
406 Ok(())
407}
408
409pub fn check_runs_on(label: &str) -> Result<()> {
412 let ok = !label.is_empty()
413 && label.len() <= 100
414 && label
415 .bytes()
416 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'));
417 if ok {
418 Ok(())
419 } else {
420 Err(ForgeError::Config(format!(
421 "runner label `{label}` must be letters, digits, `-`, `_` or `.`"
422 )))
423 }
424}
425
426pub(crate) fn check_keyring(bytes: &[u8]) -> Result<()> {
427 let text = std::str::from_utf8(bytes)
428 .map_err(|_| ForgeError::Config("platform keyring is not ASCII armor".into()))?;
429 if text.contains("PRIVATE KEY") {
430 return Err(ForgeError::Config(
432 "platform keyring contains a PRIVATE key block; supply the public key only".into(),
433 ));
434 }
435 if !text.contains("-----BEGIN PGP PUBLIC KEY BLOCK-----") {
436 return Err(ForgeError::Config(
437 "the instance's signing key is not an armored PGP public key block — does the \
438 instance sign merges (`[repository.signing]`)?"
439 .into(),
440 ));
441 }
442 Ok(())
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448 use vgi_forge::Resource;
449
450 const SHA: &str = "0123456789abcdef0123456789abcdef01234567";
451 const SUM: &str = "4f1c0a5e9d0b8b1f3c5f8a0d2e7b6c9a1d3e5f7a9b0c2d4e6f8a1b3c5d7e9f0a";
452 const KEY: &str =
453 "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\ninstance\n-----END PGP PUBLIC KEY BLOCK-----\n";
454
455 fn cfg() -> VgiConfig {
456 VgiConfig::new(
457 "did:webvh:reg",
458 "did:webvh:vtc",
459 format!("OpenVTC/verifiable-git-infrastructure/.github/actions/verify-trust@{SHA}"),
460 "v0.5.0",
461 )
462 .with_verify_trust_sha256(SUM)
463 }
464
465 fn base() -> Url {
466 Url::parse(crate::config::DEFAULT_ACTIONS_BASE).unwrap()
467 }
468
469 fn opts<'a>(base: &'a Url, merges: MergePlan<'static>, inline: bool) -> PlanOptions<'a> {
470 PlanOptions {
471 checkout_action: crate::config::DEFAULT_CHECKOUT_ACTION,
472 actions_base: base,
473 runs_on: "docker",
474 status_context: "Verify commit trust / Verify commit trust (pull_request)".into(),
475 inline_variables: inline,
476 merges,
477 }
478 }
479
480 fn spec() -> RepoSpec {
481 RepoSpec::new(Resource::parse("codeberg.org/acme/gadgets").unwrap())
482 }
483
484 fn ids(plan: &[BootstrapStep]) -> Vec<&str> {
485 plan.iter().map(|s| s.id.as_str()).collect()
486 }
487
488 #[test]
489 fn the_runner_label_is_always_a_yaml_string() {
490 use yaml_rust2::{Yaml, YamlLoader};
491 let b = base();
492 for label in [
496 "docker",
497 "-",
498 "null",
499 "true",
500 "1",
501 "1.5",
502 "~",
503 "ubuntu-24.04",
504 ] {
505 if label == "~" {
506 assert!(check_runs_on(label).is_err());
507 continue;
508 }
509 let mut o = opts(&b, MergePlan::FastForwardOnly, false);
510 o.runs_on = label;
511 let plan = forgejo_plan(&spec(), &cfg(), &o).unwrap();
512 let StepAction::WriteFile { contents, .. } = &plan[1].action else {
513 panic!("{:?}", plan[1].action)
514 };
515 let text = std::str::from_utf8(contents).unwrap();
516 let doc = &YamlLoader::load_from_str(text).unwrap()[0];
517 assert_eq!(
518 doc["jobs"]["verify"]["runs-on"],
519 Yaml::String(label.into()),
520 "{label}"
521 );
522 }
523 }
524
525 #[test]
526 fn the_plan_is_merges_files_variables_protection() {
527 let b = base();
528 let plan = forgejo_plan(
529 &spec(),
530 &cfg().with_extra_file("CODEOWNERS", "* @acme/owners\n"),
531 &opts(&b, MergePlan::FastForwardOnly, false),
532 )
533 .unwrap();
534 assert_eq!(
535 ids(&plan),
536 [
537 "merge-styles",
538 "workflow",
539 "file:CODEOWNERS",
540 "variable:TRUST_REGISTRY_DID",
541 "variable:VTC_DID",
542 "protection"
543 ]
544 );
545 let StepAction::ConfigureRepo(settings) = &plan[0].action else {
546 panic!()
547 };
548 assert_eq!(settings.merge_methods, [MergeMethod::FastForward]);
549 let StepAction::ProtectDefaultBranch(p) = &plan[5].action else {
550 panic!()
551 };
552 assert_eq!(
553 p.required_check,
554 "Verify commit trust / Verify commit trust (pull_request)"
555 );
556 assert_eq!(p.protected_paths, PROTECTED_PATHS);
557
558 let plan = forgejo_plan(
561 &spec(),
562 &cfg(),
563 &opts(&b, MergePlan::SigningKey(KEY.as_bytes()), true),
564 )
565 .unwrap();
566 assert_eq!(
567 ids(&plan),
568 ["merge-styles", "workflow", "keyring", "protection"]
569 );
570 let StepAction::ConfigureRepo(settings) = &plan[0].action else {
571 panic!()
572 };
573 assert_eq!(settings.merge_methods, [MergeMethod::MergeCommit]);
574 }
575
576 #[test]
577 fn the_workflow_is_pinned_by_full_url_qualified_and_unguarded() {
578 let b = base();
579 let o = opts(&b, MergePlan::FastForwardOnly, false);
580 let action = resolve_action(&cfg().verify_trust_action, &b).unwrap();
581 let wf = render_workflow(&cfg(), &o, &spec().resource, &action, SUM);
582 assert!(wf.contains("name: 'Verify commit trust'\n"));
583 assert!(wf.contains(" name: 'Verify commit trust'\n"));
584 assert!(wf.contains(&format!(
585 "uses: https://github.com/OpenVTC/verifiable-git-infrastructure/.github/actions/verify-trust@{SHA}"
586 )));
587 assert!(wf.contains(&format!("uses: {}", crate::config::DEFAULT_CHECKOUT_ACTION)));
588 assert!(
589 wf.contains(
590 " resource-format: qualified\n \
591 # The namespace: where the VTC publishes namespace-wide commit rights.\n \
592 fallback-resource: codeberg.org/${{ github.repository_owner }}\n"
593 ),
594 "{wf}"
595 );
596 assert_eq!(wf.matches("fallback-resource:").count(), 1);
597 assert!(wf.contains("version: v0.5.0\n"));
598 assert!(wf.contains(&format!("sha256: {SUM}\n")));
599 assert!(wf.contains("registry-did: ${{ vars.TRUST_REGISTRY_DID }}"));
600 assert!(wf.contains("range: origin/${{ github.base_ref }}..HEAD"));
601 assert!(!wf.contains("if:"));
602 assert!(!wf.contains("exempt-keyring"));
603
604 let o = opts(&b, MergePlan::SigningKey(KEY.as_bytes()), true);
605 let wf = render_workflow(&cfg(), &o, &spec().resource, &action, SUM);
606 assert!(wf.contains("exempt-keyring: .forgejo/trusted-platform-keys.asc\n"));
607 assert!(wf.contains("registry-did: 'did:webvh:reg'"));
608 assert!(wf.contains("vtc-did: 'did:webvh:vtc'"));
609 assert!(!wf.contains("vars."));
610 }
611
612 #[test]
613 fn the_transport_input_is_written_only_when_pinned() {
614 let b = base();
615 let o = opts(&b, MergePlan::FastForwardOnly, false);
616 let action = resolve_action(&cfg().verify_trust_action, &b).unwrap();
617 let wf = render_workflow(&cfg(), &o, &spec().resource, &action, SUM);
618 assert!(!wf.contains("transport:"), "{wf}");
619 let pinned = cfg().with_verify_trust_transport(vgi_forge::VerifyTransport::Didcomm);
620 let wf = render_workflow(&pinned, &o, &spec().resource, &action, SUM);
621 assert!(
622 wf.contains(" transport: didcomm\n resource-format"),
623 "{wf}"
624 );
625 }
626
627 #[test]
628 fn the_fallback_is_the_running_repositorys_own_namespace_on_this_instance() {
629 let b = base();
630 let o = opts(&b, MergePlan::FastForwardOnly, false);
631 let action = resolve_action(&cfg().verify_trust_action, &b).unwrap();
632 let here = Resource::parse("git.example.org/acme/gadgets").unwrap();
633 assert_eq!(
634 fallback_resource(&here),
635 "git.example.org/${{ github.repository_owner }}"
636 );
637 let wf = render_workflow(&cfg(), &o, &here, &action, SUM);
638 assert!(
639 wf.contains("fallback-resource: git.example.org/${{ github.repository_owner }}\n"),
640 "{wf}"
641 );
642 let other = Resource::parse("git.example.org/acme/widgets").unwrap();
645 assert_eq!(wf, render_workflow(&cfg(), &o, &other, &action, SUM));
646 assert!(!wf.contains("acme"));
647 }
648
649 #[test]
650 fn bad_config_fails_before_any_step_runs() {
651 let b = base();
652 let o = opts(&b, MergePlan::FastForwardOnly, false);
653 let err = |c: &VgiConfig| forgejo_plan(&spec(), c, &o).unwrap_err().to_string();
654
655 let mut c = cfg();
656 c.verify_trust_sha256 = None;
657 assert!(err(&c).contains("verify_trust_sha256"));
658 assert!(err(&cfg().with_verify_trust_sha256("ABC")).contains("64 lowercase hex"));
659 let mut c = cfg();
660 c.verify_trust_version = "latest".into();
661 assert!(err(&c).contains("not `latest`"));
662 let mut c = cfg();
663 c.verify_trust_action =
664 "OpenVTC/verifiable-git-infrastructure/.github/actions/verify-trust@v1".into();
665 assert!(err(&c).contains("pinned"));
666 let mut c = cfg();
667 c.verify_trust_action = format!("http://github.com/o/r@{SHA}");
668 assert!(err(&c).contains("pinned"));
669 for bad in ["Verify [trust]", "Verify *", "a{b}", "a?b", "a\\b"] {
670 let mut c = cfg();
671 c.required_check = bad.into();
672 assert!(err(&c).contains("glob"), "{bad}");
673 let mut o2 = o.clone();
674 o2.status_context = format!("{bad} / x (pull_request)");
675 assert!(forgejo_plan(&spec(), &cfg(), &o2).is_err(), "{bad}");
676 }
677 let mut c = cfg();
678 c.vtc_did = "did:web:x\n evil: true".into();
679 assert!(forgejo_plan(&spec(), &c, &o).is_err());
680 assert!(forgejo_plan(&spec(), &cfg().with_extra_file(WORKFLOW_PATH, ""), &o).is_err());
681 assert!(forgejo_plan(&spec(), &cfg().with_extra_file("../x", ""), &o).is_err());
682
683 let mut bad = o.clone();
684 bad.checkout_action = "actions/checkout@11d5960a326750d5838078e36cf38b85af677262";
685 assert!(
686 forgejo_plan(&spec(), &cfg(), &bad).is_err(),
687 "checkout must be a full URL"
688 );
689 let mut bad = o.clone();
690 bad.runs_on = "docker\nevil: 1";
691 assert!(forgejo_plan(&spec(), &cfg(), &bad).is_err());
692 let bad = opts(
693 &b,
694 MergePlan::SigningKey(b"-----BEGIN PGP PRIVATE KEY BLOCK-----"),
695 false,
696 );
697 assert!(
698 forgejo_plan(&spec(), &cfg(), &bad)
699 .unwrap_err()
700 .to_string()
701 .contains("PRIVATE")
702 );
703 let bad = opts(&b, MergePlan::SigningKey(b""), false);
704 assert!(
705 forgejo_plan(&spec(), &cfg(), &bad)
706 .unwrap_err()
707 .to_string()
708 .contains("sign merges")
709 );
710
711 let ns = RepoSpec::new(Resource::parse("codeberg.org/acme").unwrap());
712 assert!(forgejo_plan(&ns, &cfg(), &o).is_err());
713 let deep = RepoSpec::new(Resource::parse("codeberg.org/acme/a/b").unwrap());
714 assert!(forgejo_plan(&deep, &cfg(), &o).is_err());
715 }
716}