1use vgi_forge::{
41 BootstrapComponent, BootstrapStep, ForgeAccount, ForgeError, ProtectionSpec, RepoSpec,
42 Resource, Result, StepAction, VgiConfig, validate_repo_path,
43};
44
45pub const WORKFLOW_PATH: &str = ".github/workflows/verify-trust.yml";
47pub const KEYRING_PATH: &str = ".github/trusted-platform-keys.asc";
49pub const CODEOWNERS_PATH: &str = ".github/CODEOWNERS";
52pub const CODEOWNERS_LOCATIONS: [&str; 3] = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"];
55pub const GUARDED_PATH: &str = "/.github/";
58pub const RULESET_NAME: &str = "VGI commit trust";
60pub const ORG_RULESET_NAME: &str = "VGI required workflow";
62pub const CENTRAL_REPO: &str = ".vgi";
67pub const VAR_REGISTRY: &str = "TRUST_REGISTRY_DID";
69pub const VAR_VTC: &str = "VTC_DID";
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74#[non_exhaustive]
75pub enum CheckGuard {
76 RequiredWorkflow,
79 OwnerReview {
83 owners: Vec<ForgeAccount>,
85 },
86 SoloOwner,
91 BridgePosted,
95}
96
97impl CheckGuard {
98 pub fn for_owners(owners: &[ForgeAccount]) -> CheckGuard {
101 let mut distinct: Vec<ForgeAccount> = Vec::new();
102 for o in owners {
103 if !distinct.iter().any(|d| d.id == o.id) {
104 distinct.push(o.clone());
105 }
106 }
107 if distinct.len() == 1 {
108 CheckGuard::SoloOwner
109 } else {
110 CheckGuard::OwnerReview { owners: distinct }
111 }
112 }
113}
114
115pub fn github_plan(
118 repo: &RepoSpec,
119 cfg: &VgiConfig,
120 checkout_action: &str,
121 guard: &CheckGuard,
122) -> Result<Vec<BootstrapStep>> {
123 if repo.resource.is_namespace() {
124 return Err(ForgeError::WrongResource {
125 resource: repo.resource.to_string(),
126 expected: "a repository, not a namespace".into(),
127 });
128 }
129 check_did("trust_registry_did", &cfg.trust_registry_did)?;
130 check_did("vtc_did", &cfg.vtc_did)?;
131 check_pinned("checkout action", checkout_action)?;
132 check_pinned("verify-trust action", &cfg.verify_trust_action)?;
133 check_version(&cfg.verify_trust_version)?;
134 check_check_name(&cfg.required_check)?;
135 let keyring: &[u8] = match (cfg.platform_keyring.as_deref(), guard) {
139 (Some(k), _) => {
140 check_keyring(k)?;
141 k
142 }
143 (None, CheckGuard::BridgePosted) => &[],
144 (None, _) => {
145 return Err(ForgeError::Config(
146 "no platform keyring: GitHub web-UI merges are signed by `web-flow`, and without its key in the exempt keyring every merge commit fails the check. Supply it in the config (for github.com, the contents of https://github.com/web-flow.gpg)"
147 .into(),
148 ));
149 }
150 };
151
152 let mut steps = Vec::new();
153 if matches!(
154 guard,
155 CheckGuard::OwnerReview { .. } | CheckGuard::SoloOwner
156 ) {
157 steps.push(BootstrapStep::new(
158 "workflow",
159 BootstrapComponent::Workflow,
160 StepAction::WriteFile {
161 path: WORKFLOW_PATH.into(),
162 contents: render_workflow(cfg, checkout_action, &repo.resource).into_bytes(),
163 message: "ci: add the VGI commit-trust check".into(),
164 },
165 ));
166 steps.push(BootstrapStep::new(
167 "keyring",
168 BootstrapComponent::Keyring,
169 StepAction::WriteFile {
170 path: KEYRING_PATH.into(),
171 contents: keyring.to_vec(),
172 message: "ci: add the exempt platform keyring for web-flow merges".into(),
173 },
174 ));
175 }
176
177 let mut community_owners: Option<&[u8]> = None;
178 for file in &cfg.extra_files {
179 validate_repo_path(&file.path)?;
180 if file.path == WORKFLOW_PATH || file.path == KEYRING_PATH {
181 return Err(ForgeError::Config(format!(
182 "extra file `{}` would overwrite a file the bootstrap manages",
183 file.path
184 )));
185 }
186 if matches!(guard, CheckGuard::OwnerReview { .. })
187 && CODEOWNERS_LOCATIONS.contains(&file.path.as_str())
188 {
189 if community_owners.replace(&file.contents).is_some() {
194 return Err(ForgeError::Config(
195 "more than one CODEOWNERS among the extra files; GitHub would use only one"
196 .into(),
197 ));
198 }
199 continue;
200 }
201 steps.push(BootstrapStep::new(
202 format!("file:{}", file.path),
203 BootstrapComponent::Extra,
204 StepAction::WriteFile {
205 path: file.path.clone(),
206 contents: file.contents.clone(),
207 message: format!("chore: add {}", file.path),
208 },
209 ));
210 }
211
212 match guard {
213 CheckGuard::RequiredWorkflow => {
214 check_embeddable_keyring(keyring)?;
215 steps.push(BootstrapStep::new(
216 "required-workflow",
217 BootstrapComponent::Workflow,
218 StepAction::RequireNamespaceWorkflow {
219 contents: render_required_workflow(
220 cfg,
221 checkout_action,
222 &repo.resource,
223 keyring,
224 )?
225 .into_bytes(),
226 check: cfg.required_check.clone(),
227 message: "ci: pin the VGI commit-trust check".into(),
228 },
229 ));
230 steps.push(BootstrapStep::new(
231 "ruleset",
232 BootstrapComponent::RequiredCheck,
233 StepAction::ProtectDefaultBranch(
234 ProtectionSpec::standard(cfg.required_check.clone())
235 .with_check_enforced_by_namespace(),
236 ),
237 ));
238 steps.extend(cleanup_variables());
243 for (id, path) in [
244 ("cleanup:workflow", WORKFLOW_PATH),
245 ("cleanup:keyring", KEYRING_PATH),
246 ] {
247 steps.push(BootstrapStep::new(
248 id,
249 BootstrapComponent::Extra,
250 StepAction::RemoveFile {
251 path: path.into(),
252 message: "ci: the VGI check now runs as the org's required workflow".into(),
253 },
254 ));
255 }
256 }
257 CheckGuard::OwnerReview { owners } => {
258 if owners.len() < 2 {
259 return Err(ForgeError::Config(format!(
260 "`{}`: owner review needs at least two owners with linked GitHub accounts \
261 (one owner is a solo repository, none cannot be planned)",
262 repo.resource
263 )));
264 }
265 let community_rules = community_owners.map(<[u8]>::to_vec).unwrap_or_default();
266 if std::str::from_utf8(&community_rules).is_err() {
267 return Err(ForgeError::Config(
268 "community CODEOWNERS is not UTF-8".into(),
269 ));
270 }
271 steps.push(BootstrapStep::new(
272 "codeowners",
273 BootstrapComponent::Workflow,
274 StepAction::RequireOwnerReview {
275 paths: vec![GUARDED_PATH.into()],
276 owners: owners.clone(),
277 community_rules,
278 message: "ci: require an owner's review for workflow changes".into(),
279 },
280 ));
281 steps.push(BootstrapStep::new(
282 "ruleset",
283 BootstrapComponent::RequiredCheck,
284 StepAction::ProtectDefaultBranch(
285 ProtectionSpec::standard(cfg.required_check.clone()).with_code_owner_review(),
286 ),
287 ));
288 steps.extend(cleanup_variables());
289 }
290 CheckGuard::SoloOwner => {
291 steps.push(BootstrapStep::new(
292 "ruleset",
293 BootstrapComponent::RequiredCheck,
294 StepAction::ProtectDefaultBranch(ProtectionSpec::standard(
295 cfg.required_check.clone(),
296 )),
297 ));
298 steps.extend(cleanup_variables());
299 }
300 CheckGuard::BridgePosted => {
301 steps.push(BootstrapStep::new(
305 "ruleset",
306 BootstrapComponent::RequiredCheck,
307 StepAction::ProtectDefaultBranch(ProtectionSpec::standard(
308 cfg.required_check.clone(),
309 )),
310 ));
311 steps.extend(cleanup_variables());
312 steps.push(BootstrapStep::new(
317 "cleanup:workflow",
318 BootstrapComponent::Extra,
319 StepAction::RemoveFile {
320 path: WORKFLOW_PATH.into(),
321 message: "ci: the VGI check is now posted by the community's bridge".into(),
322 },
323 ));
324 }
325 }
326 Ok(steps)
327}
328
329fn cleanup_variables() -> Vec<BootstrapStep> {
333 [VAR_REGISTRY, VAR_VTC]
334 .into_iter()
335 .map(|name| {
336 BootstrapStep::new(
337 format!("cleanup:variable:{name}"),
338 BootstrapComponent::Variables,
339 StepAction::RemoveVariable { name: name.into() },
340 )
341 })
342 .collect()
343}
344
345pub const MANAGED_BEGIN: &str = "# BEGIN VGI managed owner rules";
347pub const MANAGED_END: &str = "# END VGI managed owner rules";
349
350pub fn render_codeowners(community_rules: &str, paths: &[String], logins: &[String]) -> String {
355 let mut out = String::new();
356 let community = strip_managed(community_rules);
357 if !community.trim().is_empty() {
358 out.push_str(community.trim_end());
359 out.push_str("\n\n");
360 }
361 out.push_str(MANAGED_BEGIN);
362 out.push_str(
363 "\n# Kept last by this community's VGI bridge: every change to the repository's\n\
364 # workflows (and to this file) needs another owner's approving review, so the\n\
365 # commit-trust check is not editable by the pull request it checks.\n",
366 );
367 let owners: Vec<String> = logins.iter().map(|l| format!("@{l}")).collect();
368 for p in paths {
369 out.push_str(&format!("{p} {}\n", owners.join(" ")));
370 }
371 out.push_str(MANAGED_END);
372 out.push('\n');
373 out
374}
375
376pub fn strip_managed(text: &str) -> String {
379 let mut out = Vec::new();
380 let mut inside = false;
381 for line in text.lines() {
382 match line.trim() {
383 l if l == MANAGED_BEGIN => inside = true,
384 l if l == MANAGED_END && inside => inside = false,
385 _ if inside => {}
386 _ => out.push(line),
387 }
388 }
389 let mut s = out.join("\n");
390 if !s.is_empty() {
391 s.push('\n');
392 }
393 s
394}
395
396pub fn managed_rules(text: &str) -> Option<Vec<(String, Vec<String>, usize)>> {
399 let lines: Vec<&str> = text.lines().collect();
400 let begin = lines.iter().rposition(|l| l.trim() == MANAGED_BEGIN)?;
401 let end = begin
402 + lines[begin..]
403 .iter()
404 .position(|l| l.trim() == MANAGED_END)?;
405 let is_rule = |l: &&str| {
406 let t = l.trim();
407 !t.is_empty() && !t.starts_with('#')
408 };
409 if lines[end + 1..].iter().any(is_rule) {
410 return None;
411 }
412 let rules = lines[begin + 1..end]
413 .iter()
414 .enumerate()
415 .filter(|(_, l)| is_rule(l))
416 .map(|(i, l)| {
417 let mut parts = l.split('#').next().unwrap_or("").split_whitespace();
418 let pattern = parts.next().unwrap_or("").to_string();
419 (pattern, parts.map(str::to_string).collect(), begin + 2 + i)
420 })
421 .collect();
422 Some(rules)
423}
424
425pub fn render_workflow(cfg: &VgiConfig, checkout_action: &str, repo: &Resource) -> String {
436 format!(
437 r#"# Managed by this community's VGI bridge. It is rewritten on bootstrap;
438# propose changes to the VTC rather than editing it here.
439name: verify-trust
440
441on:
442 pull_request:
443 # A merge queue runs required checks on its own merge commits; without
444 # this trigger the check never reports there and queued merges stall.
445 merge_group:
446
447# Reads the repository and downloads a public release; writes nothing.
448permissions:
449 contents: read
450
451jobs:
452 verify:
453 name: {job_name}
454 runs-on: ubuntu-latest
455 steps:
456 - uses: {checkout}
457 with:
458 # The base ref must be present so `origin/<base>..HEAD` resolves.
459 fetch-depth: 0
460 persist-credentials: false
461
462 - name: verify-trust
463 uses: {action}
464 with:
465 # merge_group events have no base_ref; the queue names its base
466 # commit instead.
467 range: ${{{{ github.event_name == 'merge_group' && github.event.merge_group.base_sha || format('origin/{{0}}', github.base_ref) }}}}..HEAD
468 # Literals, not `vars.*`: repository variables are any
469 # repository admin's to change.
470 registry-did: {registry}
471 vtc-did: {vtc}
472{transport} resource-format: qualified
473{fallback} # GitHub web-UI merge/squash commits are PGP-signed by web-flow;
474 # they pass only via this committed keyring.
475 exempt-keyring: {keyring}
476 version: {version}
477"#,
478 job_name = yaml_single_quoted(&cfg.required_check),
479 checkout = checkout_action,
480 action = cfg.verify_trust_action,
481 registry = yaml_single_quoted(&cfg.trust_registry_did),
482 vtc = yaml_single_quoted(&cfg.vtc_did),
483 transport = cfg.verify_trust_transport.workflow_input_line(" "),
484 fallback = fallback_block(repo),
485 keyring = KEYRING_PATH,
486 version = cfg.verify_trust_version,
487 )
488}
489
490pub fn render_required_workflow(
505 cfg: &VgiConfig,
506 checkout_action: &str,
507 repo: &Resource,
508 keyring: &[u8],
509) -> Result<String> {
510 let keyring = std::str::from_utf8(keyring)
511 .map_err(|_| ForgeError::Config("platform keyring is not ASCII armor".into()))?;
512 let mut block = String::new();
513 for line in keyring.lines() {
514 if line.is_empty() {
515 block.push('\n');
516 } else {
517 block.push_str(" ");
518 block.push_str(line);
519 block.push('\n');
520 }
521 }
522 Ok(format!(
523 r#"# Managed by this community's VGI bridge, and required on the community's
524# repositories by the org ruleset "{ruleset}" at a pinned commit. A change
525# here takes effect only when the bridge pins it; propose changes to the VTC.
526name: verify-trust
527
528on:
529 pull_request:
530 # A merge queue runs required workflows on its own merge commits; without
531 # this trigger the check never reports there and queued merges stall.
532 merge_group:
533
534# Reads the repository and downloads a public release; writes nothing.
535permissions:
536 contents: read
537
538jobs:
539 verify:
540 name: {job_name}
541 runs-on: ubuntu-latest
542 steps:
543 - uses: {checkout}
544 with:
545 # The base ref must be present so `origin/<base>..HEAD` resolves.
546 fetch-depth: 0
547 persist-credentials: false
548
549 # GitHub web-UI merge/squash commits are PGP-signed by web-flow and pass
550 # only via this keyring. It is part of this pinned file, not of the
551 # repository under test, which a pull request could edit.
552 - name: exempt platform keyring
553 env:
554 VGI_PLATFORM_KEYRING: |
555{keyring} run: printf '%s' "$VGI_PLATFORM_KEYRING" > "$RUNNER_TEMP/vgi-platform-keys.asc"
556
557 - name: verify-trust
558 uses: {action}
559 with:
560 # merge_group events have no base_ref; the queue names its base
561 # commit instead.
562 range: ${{{{ github.event_name == 'merge_group' && github.event.merge_group.base_sha || format('origin/{{0}}', github.base_ref) }}}}..HEAD
563 # Literals, not `vars.*`: a repository variable of the same name
564 # would override an organisation one.
565 registry-did: {registry}
566 vtc-did: {vtc}
567{transport} resource-format: qualified
568{fallback} exempt-keyring: ${{{{ runner.temp }}}}/vgi-platform-keys.asc
569 version: {version}
570"#,
571 ruleset = ORG_RULESET_NAME,
572 job_name = yaml_single_quoted(&cfg.required_check),
573 checkout = checkout_action,
574 keyring = block,
575 action = cfg.verify_trust_action,
576 registry = yaml_single_quoted(&cfg.trust_registry_did),
577 vtc = yaml_single_quoted(&cfg.vtc_did),
578 transport = cfg.verify_trust_transport.workflow_input_line(" "),
579 fallback = fallback_block(repo),
580 version = cfg.verify_trust_version,
581 ))
582}
583
584pub fn fallback_resource(repo: &Resource) -> String {
607 format!("{}/${{{{ github.repository_owner }}}}", repo.host())
608}
609
610fn fallback_block(repo: &Resource) -> String {
611 format!(
612 " # The namespace: where the VTC publishes namespace-wide commit rights.\n \
613 fallback-resource: {}\n",
614 fallback_resource(repo)
615 )
616}
617
618fn yaml_single_quoted(s: &str) -> String {
619 format!("'{}'", s.replace('\'', "''"))
620}
621
622fn check_did(field: &str, did: &str) -> Result<()> {
623 let ok = did.starts_with("did:")
626 && !did.contains("${{")
627 && did.len() <= 2048
628 && did
629 .bytes()
630 .all(|b| b.is_ascii_graphic() && b != b'\'' && b != b'"');
631 if ok {
632 Ok(())
633 } else {
634 Err(ForgeError::Config(format!(
635 "{field} `{did}` is not a DID (expected `did:<method>:…`, no spaces or quotes)"
636 )))
637 }
638}
639
640fn check_pinned(what: &str, reference: &str) -> Result<()> {
642 let pinned = reference.rsplit_once('@').is_some_and(|(path, sha)| {
643 sha.len() == 40
644 && sha.bytes().all(|b| b.is_ascii_hexdigit())
645 && path.split('/').count() >= 2
646 && path.split('/').all(|s| {
647 !s.is_empty()
648 && s != ".."
649 && s.bytes()
650 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
651 })
652 });
653 if pinned {
654 Ok(())
655 } else {
656 Err(ForgeError::Config(format!(
657 "{what} `{reference}` must be pinned to a commit: `owner/repo[/path]@<40-hex sha>`"
658 )))
659 }
660}
661
662fn check_version(v: &str) -> Result<()> {
663 let ok = !v.is_empty()
664 && v.len() <= 64
665 && v.bytes()
666 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_'));
667 if ok {
668 Ok(())
669 } else {
670 Err(ForgeError::Config(format!(
671 "verify-trust version `{v}` must be a release tag like `v0.5.0` or `latest`"
672 )))
673 }
674}
675
676fn check_check_name(name: &str) -> Result<()> {
677 if name.trim().is_empty()
679 || name.len() > 100
680 || name.chars().any(char::is_control)
681 || name.contains("${{")
682 {
683 return Err(ForgeError::Config(format!(
684 "required check name `{name}` must be 1–100 printable characters"
685 )));
686 }
687 Ok(())
688}
689
690fn check_keyring(bytes: &[u8]) -> Result<()> {
691 let text = std::str::from_utf8(bytes)
692 .map_err(|_| ForgeError::Config("platform keyring is not ASCII armor".into()))?;
693 if text.contains("PRIVATE KEY") {
694 return Err(ForgeError::Config(
696 "platform keyring contains a PRIVATE key block; supply the public key only".into(),
697 ));
698 }
699 if !text.contains("-----BEGIN PGP PUBLIC KEY BLOCK-----") {
700 return Err(ForgeError::Config(
701 "platform keyring is not an armored PGP public key block".into(),
702 ));
703 }
704 Ok(())
705}
706
707fn check_embeddable_keyring(bytes: &[u8]) -> Result<()> {
711 let text = std::str::from_utf8(bytes)
712 .map_err(|_| ForgeError::Config("platform keyring is not ASCII armor".into()))?;
713 let lone_cr = text
716 .char_indices()
717 .any(|(i, c)| c == '\r' && !text[i + 1..].starts_with('\n'));
718 if text.contains("${{")
719 || lone_cr
720 || text
721 .chars()
722 .any(|c| c.is_control() && c != '\n' && c != '\r')
723 {
724 return Err(ForgeError::Config(
725 "platform keyring holds characters that cannot be embedded in the required workflow"
726 .into(),
727 ));
728 }
729 Ok(())
730}
731
732#[cfg(test)]
733mod tests {
734 use super::*;
735 use vgi_forge::Resource;
736
737 const SHA: &str = "0123456789abcdef0123456789abcdef01234567";
738
739 fn cfg() -> VgiConfig {
740 VgiConfig::new(
741 "did:webvh:reg",
742 "did:webvh:vtc",
743 format!("OpenVTC/verifiable-git-infrastructure/.github/actions/verify-trust@{SHA}"),
744 "v0.5.0",
745 )
746 .with_platform_keyring(
747 "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nx\n-----END PGP PUBLIC KEY BLOCK-----\n",
748 )
749 }
750
751 fn spec() -> RepoSpec {
752 RepoSpec::new(Resource::parse("github.com/acme/gadgets").unwrap())
753 }
754
755 fn owner_review() -> CheckGuard {
756 CheckGuard::OwnerReview {
757 owners: vec![ForgeAccount::new(7, "bob"), ForgeAccount::new(9, "carol")],
758 }
759 }
760
761 const CHECKOUT: &str = crate::config::DEFAULT_CHECKOUT_ACTION;
762
763 fn ids(plan: &[BootstrapStep]) -> Vec<&str> {
764 plan.iter().map(|s| s.id.as_str()).collect()
765 }
766
767 #[test]
768 fn the_guard_follows_the_owner_count() {
769 let bob = ForgeAccount::new(7, "bob");
770 assert_eq!(
771 CheckGuard::for_owners(std::slice::from_ref(&bob)),
772 CheckGuard::SoloOwner
773 );
774 assert_eq!(
775 CheckGuard::for_owners(&[bob.clone(), ForgeAccount::new(7, "bob-renamed")]),
776 CheckGuard::SoloOwner,
777 "one id, twice, is one owner"
778 );
779 assert!(matches!(
780 CheckGuard::for_owners(&[bob, ForgeAccount::new(9, "carol")]),
781 CheckGuard::OwnerReview { owners } if owners.len() == 2
782 ));
783 }
784
785 #[test]
786 fn owner_review_plans_files_then_codeowners_then_ruleset_then_cleanup() {
787 let plan = github_plan(
788 &spec(),
789 &cfg()
790 .with_extra_file("LICENSE", "MIT\n")
791 .with_extra_file("CODEOWNERS", "* @acme/owners\n"),
792 CHECKOUT,
793 &owner_review(),
794 )
795 .unwrap();
796 assert_eq!(
797 ids(&plan),
798 [
799 "workflow",
800 "keyring",
801 "file:LICENSE",
802 "codeowners",
803 "ruleset",
804 "cleanup:variable:TRUST_REGISTRY_DID",
805 "cleanup:variable:VTC_DID",
806 ]
807 );
808 let StepAction::RequireOwnerReview {
811 paths,
812 owners,
813 community_rules,
814 ..
815 } = &plan[3].action
816 else {
817 panic!("{:?}", plan[3]);
818 };
819 assert_eq!(paths, &["/.github/"]);
820 assert_eq!(owners.len(), 2);
821 assert_eq!(community_rules, b"* @acme/owners\n");
822 let StepAction::ProtectDefaultBranch(p) = &plan[4].action else {
823 panic!()
824 };
825 assert!(p.require_code_owner_review && p.require_status_check);
826
827 for owners in [vec![], vec![ForgeAccount::new(7, "bob")]] {
828 let e = github_plan(
829 &spec(),
830 &cfg(),
831 CHECKOUT,
832 &CheckGuard::OwnerReview { owners },
833 )
834 .unwrap_err();
835 assert!(e.to_string().contains("at least two owners"), "{e}");
836 }
837 let two = cfg()
838 .with_extra_file("CODEOWNERS", "")
839 .with_extra_file("docs/CODEOWNERS", "");
840 assert!(github_plan(&spec(), &two, CHECKOUT, &owner_review()).is_err());
841 }
842
843 #[test]
844 fn a_solo_repository_gets_the_check_and_no_review() {
845 let plan = github_plan(
846 &spec(),
847 &cfg().with_extra_file("CODEOWNERS", "* @acme/owners\n"),
848 CHECKOUT,
849 &CheckGuard::SoloOwner,
850 )
851 .unwrap();
852 assert_eq!(
853 ids(&plan),
854 [
855 "workflow",
856 "keyring",
857 "file:CODEOWNERS",
858 "ruleset",
859 "cleanup:variable:TRUST_REGISTRY_DID",
860 "cleanup:variable:VTC_DID",
861 ]
862 );
863 let StepAction::ProtectDefaultBranch(p) = &plan[3].action else {
864 panic!()
865 };
866 assert!(p.require_status_check && !p.require_code_owner_review);
867 }
868
869 #[test]
870 fn required_workflow_plans_no_repo_workflow_keyring_or_variables() {
871 let plan = github_plan(
872 &spec(),
873 &cfg().with_extra_file("CODEOWNERS", "* @acme/owners\n"),
874 CHECKOUT,
875 &CheckGuard::RequiredWorkflow,
876 )
877 .unwrap();
878 assert_eq!(
879 ids(&plan),
880 [
881 "file:CODEOWNERS",
882 "required-workflow",
883 "ruleset",
884 "cleanup:variable:TRUST_REGISTRY_DID",
885 "cleanup:variable:VTC_DID",
886 "cleanup:workflow",
887 "cleanup:keyring"
888 ]
889 );
890 let StepAction::ProtectDefaultBranch(p) = &plan[2].action else {
891 panic!()
892 };
893 assert!(!p.require_status_check && !p.require_code_owner_review);
894 }
895
896 #[test]
897 fn the_required_workflow_fixes_everything_the_pr_could_touch() {
898 let wf = render_required_workflow(
899 &cfg(),
900 CHECKOUT,
901 &spec().resource,
902 cfg().platform_keyring.as_deref().unwrap(),
903 )
904 .unwrap();
905 assert!(wf.contains(" name: 'Verify commit trust'\n"));
906 assert!(wf.contains(" pull_request:\n") && wf.contains(" merge_group:\n"));
907 assert!(wf.contains("registry-did: 'did:webvh:reg'\n"));
908 assert!(wf.contains("vtc-did: 'did:webvh:vtc'\n"));
909 assert!(
910 !wf.contains("${{ vars"),
911 "no repository-overridable variables"
912 );
913 assert!(wf.contains("exempt-keyring: ${{ runner.temp }}/vgi-platform-keys.asc\n"));
914 assert!(
915 !wf.contains(KEYRING_PATH),
916 "the keyring is not read from the repo"
917 );
918 assert!(wf.contains(
919 " VGI_PLATFORM_KEYRING: |\n -----BEGIN PGP PUBLIC KEY BLOCK-----\n\n x\n -----END PGP PUBLIC KEY BLOCK-----\n run: "
920 ));
921 assert!(wf.contains(FALLBACK_LINES), "{wf}");
922 assert!(!wf.contains("if:"));
923
924 let mut c = cfg();
925 c.vtc_did = "did:x:${{github.token}}".into();
926 assert!(github_plan(&spec(), &c, CHECKOUT, &CheckGuard::RequiredWorkflow).is_err());
927 let c =
928 cfg().with_platform_keyring("-----BEGIN PGP PUBLIC KEY BLOCK-----\n${{ secrets.X }}\n");
929 assert!(github_plan(&spec(), &c, CHECKOUT, &CheckGuard::RequiredWorkflow).is_err());
930 let c = cfg().with_platform_keyring(
932 "-----BEGIN PGP PUBLIC KEY BLOCK-----\rrun: evil\n-----END PGP PUBLIC KEY BLOCK-----\n",
933 );
934 assert!(github_plan(&spec(), &c, CHECKOUT, &CheckGuard::RequiredWorkflow).is_err());
935 let c = cfg().with_platform_keyring(
937 "-----BEGIN PGP PUBLIC KEY BLOCK-----\r\n\r\nx\r\n-----END PGP PUBLIC KEY BLOCK-----\r\n",
938 );
939 assert!(github_plan(&spec(), &c, CHECKOUT, &CheckGuard::RequiredWorkflow).is_ok());
940 }
941
942 #[test]
943 fn codeowners_puts_the_managed_block_last_and_rerenders_stably() {
944 let out = render_codeowners(
945 "* @acme/owners\n",
946 &["/.github/".into()],
947 &["alice".into(), "bob".into()],
948 );
949 assert!(out.starts_with("* @acme/owners\n\n# BEGIN VGI managed owner rules\n"));
950 assert!(
951 out.ends_with("/.github/ @alice @bob\n# END VGI managed owner rules\n"),
952 "{out}"
953 );
954 let rules = managed_rules(&out).unwrap();
955 assert_eq!(rules.len(), 1);
956 assert_eq!(rules[0].0, "/.github/");
957 assert_eq!(rules[0].1, ["@alice", "@bob"]);
958 assert_eq!(
959 out.lines().nth(rules[0].2 - 1),
960 Some("/.github/ @alice @bob")
961 );
962 assert_eq!(
964 render_codeowners(&out, &["/.github/".into()], &["alice".into(), "bob".into()]),
965 out
966 );
967 assert!(managed_rules(&format!("{out}* @mallory\n")).is_none());
969 assert!(managed_rules(&format!("{out}# just a comment\n")).is_some());
970 assert!(managed_rules("* @acme/owners\n").is_none());
971 }
972
973 const FALLBACK_LINES: &str = " resource-format: qualified\n \
977 # The namespace: where the VTC publishes namespace-wide commit rights.\n \
978 fallback-resource: github.com/${{ github.repository_owner }}\n";
979
980 #[test]
981 fn the_fallback_is_the_running_repositorys_own_namespace_on_this_forge() {
982 let a = Resource::parse("github.com/acme/gadgets").unwrap();
986 let b = Resource::parse("github.com/acme/widgets").unwrap();
987 assert_eq!(
988 fallback_resource(&a),
989 "github.com/${{ github.repository_owner }}"
990 );
991 assert_eq!(fallback_resource(&a), fallback_resource(&b));
992 let keyring = cfg().platform_keyring.unwrap();
993 assert_eq!(
994 render_required_workflow(&cfg(), CHECKOUT, &a, &keyring).unwrap(),
995 render_required_workflow(&cfg(), CHECKOUT, &b, &keyring).unwrap(),
996 "the org's required workflow must not differ per repository"
997 );
998 let ghes = Resource::parse("ghe.example.com/acme/gadgets").unwrap();
1000 let wf = render_workflow(&cfg(), CHECKOUT, &ghes);
1001 assert!(
1002 wf.contains("fallback-resource: ghe.example.com/${{ github.repository_owner }}\n"),
1003 "{wf}"
1004 );
1005 for wf in [
1007 render_workflow(&cfg(), CHECKOUT, &a),
1008 render_required_workflow(&cfg(), CHECKOUT, &a, &keyring).unwrap(),
1009 ] {
1010 assert_eq!(wf.matches("fallback-resource:").count(), 1, "{wf}");
1011 assert!(wf.contains(FALLBACK_LINES), "{wf}");
1012 assert!(!wf.contains("resource-format: legacy"));
1013 }
1014 }
1015
1016 #[test]
1017 fn the_transport_input_is_written_only_when_pinned() {
1018 let wf = render_workflow(&cfg(), CHECKOUT, &spec().resource);
1020 assert!(!wf.contains("transport:"), "{wf}");
1021 let keyring = b"k".to_vec();
1022 let rw = render_required_workflow(&cfg(), CHECKOUT, &spec().resource, &keyring).unwrap();
1023 assert!(!rw.contains("transport:"));
1024
1025 let pinned = cfg().with_verify_trust_transport(vgi_forge::VerifyTransport::Https);
1026 let wf = render_workflow(&pinned, CHECKOUT, &spec().resource);
1027 assert!(
1028 wf.contains("vtc-did: 'did:webvh:vtc'\n transport: https\n"),
1029 "{wf}"
1030 );
1031 let rw = render_required_workflow(&pinned, CHECKOUT, &spec().resource, &keyring).unwrap();
1032 assert!(rw.contains(" transport: https\n"), "{rw}");
1033 }
1034
1035 #[test]
1036 fn the_workflow_is_pinned_qualified_and_unguarded() {
1037 let wf = render_workflow(&cfg(), CHECKOUT, &spec().resource);
1038 assert!(wf.contains("registry-did: 'did:webvh:reg'\n"));
1039 assert!(wf.contains("vtc-did: 'did:webvh:vtc'\n"));
1040 assert!(
1041 !wf.contains("${{ vars"),
1042 "no repository-overridable variables"
1043 );
1044 assert!(wf.contains(" name: 'Verify commit trust'\n"));
1045 assert!(wf.contains(FALLBACK_LINES), "{wf}");
1046 assert!(wf.contains(" merge_group:\n"));
1047 assert!(wf.contains(
1048 "range: ${{ github.event_name == 'merge_group' && github.event.merge_group.base_sha \
1049 || format('origin/{0}', github.base_ref) }}..HEAD"
1050 ));
1051 assert!(wf.contains(&format!("verify-trust@{SHA}")));
1052 assert!(wf.contains("uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"));
1053 assert!(!wf.contains("if:"));
1054 let mut quoted = cfg();
1055 quoted.required_check = "it's".into();
1056 assert!(render_workflow("ed, "a/b@x", &spec().resource).contains("name: 'it''s'"));
1057 }
1058
1059 #[test]
1060 fn bad_config_fails_before_any_step_runs() {
1061 for guard in [
1062 owner_review(),
1063 CheckGuard::SoloOwner,
1064 CheckGuard::RequiredWorkflow,
1065 ] {
1066 let plan = |c: &VgiConfig| github_plan(&spec(), c, CHECKOUT, &guard);
1067 let mut c = cfg();
1068 c.verify_trust_action =
1069 "OpenVTC/verifiable-git-infrastructure/.github/actions/verify-trust@v0.5.0".into();
1070 assert!(plan(&c).unwrap_err().to_string().contains("pinned"));
1071
1072 let mut c = cfg();
1073 c.platform_keyring = None;
1074 assert!(plan(&c).unwrap_err().to_string().contains("web-flow"));
1075
1076 let c = cfg().with_platform_keyring("-----BEGIN PGP PRIVATE KEY BLOCK-----");
1077 assert!(plan(&c).unwrap_err().to_string().contains("PRIVATE"));
1078
1079 let mut c = cfg();
1080 c.vtc_did = "did:web:x\n evil: true".into();
1081 assert!(plan(&c).is_err());
1082
1083 let mut c = cfg();
1084 c.verify_trust_version = "v1\nx".into();
1085 assert!(plan(&c).is_err());
1086
1087 assert!(plan(&cfg().with_extra_file("../x", "")).is_err());
1088 assert!(plan(&cfg().with_extra_file(WORKFLOW_PATH, "")).is_err());
1089
1090 assert!(github_plan(&spec(), &cfg(), "actions/checkout@v4", &guard).is_err());
1091 let ns = RepoSpec::new(Resource::parse("github.com/acme").unwrap());
1092 assert!(github_plan(&ns, &cfg(), CHECKOUT, &guard).is_err());
1093 }
1094 }
1095}