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