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 .with_required_approvals(cfg.required_approvals),
237 ),
238 ));
239 steps.extend(cleanup_variables());
244 for (id, path) in [
245 ("cleanup:workflow", WORKFLOW_PATH),
246 ("cleanup:keyring", KEYRING_PATH),
247 ] {
248 steps.push(BootstrapStep::new(
249 id,
250 BootstrapComponent::Extra,
251 StepAction::RemoveFile {
252 path: path.into(),
253 message: "ci: the VGI check now runs as the org's required workflow".into(),
254 },
255 ));
256 }
257 }
258 CheckGuard::OwnerReview { owners } => {
259 if owners.len() < 2 {
260 return Err(ForgeError::Config(format!(
261 "`{}`: owner review needs at least two owners with linked GitHub accounts \
262 (one owner is a solo repository, none cannot be planned)",
263 repo.resource
264 )));
265 }
266 let community_rules = community_owners.map(<[u8]>::to_vec).unwrap_or_default();
267 if std::str::from_utf8(&community_rules).is_err() {
268 return Err(ForgeError::Config(
269 "community CODEOWNERS is not UTF-8".into(),
270 ));
271 }
272 steps.push(BootstrapStep::new(
273 "codeowners",
274 BootstrapComponent::Workflow,
275 StepAction::RequireOwnerReview {
276 paths: vec![GUARDED_PATH.into()],
277 owners: owners.clone(),
278 community_rules,
279 message: "ci: require an owner's review for workflow changes".into(),
280 },
281 ));
282 steps.push(BootstrapStep::new(
283 "ruleset",
284 BootstrapComponent::RequiredCheck,
285 StepAction::ProtectDefaultBranch(
286 ProtectionSpec::standard(cfg.required_check.clone())
287 .with_code_owner_review()
288 .with_required_approvals(cfg.required_approvals),
289 ),
290 ));
291 steps.extend(cleanup_variables());
292 }
293 CheckGuard::SoloOwner => {
294 steps.push(BootstrapStep::new(
295 "ruleset",
296 BootstrapComponent::RequiredCheck,
297 StepAction::ProtectDefaultBranch(
298 ProtectionSpec::standard(cfg.required_check.clone())
299 .with_required_approvals(cfg.required_approvals),
300 ),
301 ));
302 steps.extend(cleanup_variables());
303 }
304 CheckGuard::BridgePosted => {
305 steps.push(BootstrapStep::new(
309 "ruleset",
310 BootstrapComponent::RequiredCheck,
311 StepAction::ProtectDefaultBranch(
312 ProtectionSpec::standard(cfg.required_check.clone())
313 .with_required_approvals(cfg.required_approvals),
314 ),
315 ));
316 steps.extend(cleanup_variables());
317 steps.push(BootstrapStep::new(
322 "cleanup:workflow",
323 BootstrapComponent::Extra,
324 StepAction::RemoveFile {
325 path: WORKFLOW_PATH.into(),
326 message: "ci: the VGI check is now posted by the community's bridge".into(),
327 },
328 ));
329 }
330 }
331 Ok(steps)
332}
333
334fn cleanup_variables() -> Vec<BootstrapStep> {
338 [VAR_REGISTRY, VAR_VTC]
339 .into_iter()
340 .map(|name| {
341 BootstrapStep::new(
342 format!("cleanup:variable:{name}"),
343 BootstrapComponent::Variables,
344 StepAction::RemoveVariable { name: name.into() },
345 )
346 })
347 .collect()
348}
349
350pub const MANAGED_BEGIN: &str = "# BEGIN VGI managed owner rules";
352pub const MANAGED_END: &str = "# END VGI managed owner rules";
354
355pub fn render_codeowners(community_rules: &str, paths: &[String], logins: &[String]) -> String {
360 let mut out = String::new();
361 let community = strip_managed(community_rules);
362 if !community.trim().is_empty() {
363 out.push_str(community.trim_end());
364 out.push_str("\n\n");
365 }
366 out.push_str(MANAGED_BEGIN);
367 out.push_str(
368 "\n# Kept last by this community's VGI bridge: every change to the repository's\n\
369 # workflows (and to this file) needs another owner's approving review, so the\n\
370 # commit-trust check is not editable by the pull request it checks.\n",
371 );
372 let owners: Vec<String> = logins.iter().map(|l| format!("@{l}")).collect();
373 for p in paths {
374 out.push_str(&format!("{p} {}\n", owners.join(" ")));
375 }
376 out.push_str(MANAGED_END);
377 out.push('\n');
378 out
379}
380
381pub fn strip_managed(text: &str) -> String {
384 let mut out = Vec::new();
385 let mut inside = false;
386 for line in text.lines() {
387 match line.trim() {
388 l if l == MANAGED_BEGIN => inside = true,
389 l if l == MANAGED_END && inside => inside = false,
390 _ if inside => {}
391 _ => out.push(line),
392 }
393 }
394 let mut s = out.join("\n");
395 if !s.is_empty() {
396 s.push('\n');
397 }
398 s
399}
400
401pub fn managed_rules(text: &str) -> Option<Vec<(String, Vec<String>, usize)>> {
404 let lines: Vec<&str> = text.lines().collect();
405 let begin = lines.iter().rposition(|l| l.trim() == MANAGED_BEGIN)?;
406 let end = begin
407 + lines[begin..]
408 .iter()
409 .position(|l| l.trim() == MANAGED_END)?;
410 let is_rule = |l: &&str| {
411 let t = l.trim();
412 !t.is_empty() && !t.starts_with('#')
413 };
414 if lines[end + 1..].iter().any(is_rule) {
415 return None;
416 }
417 let rules = lines[begin + 1..end]
418 .iter()
419 .enumerate()
420 .filter(|(_, l)| is_rule(l))
421 .map(|(i, l)| {
422 let mut parts = l.split('#').next().unwrap_or("").split_whitespace();
423 let pattern = parts.next().unwrap_or("").to_string();
424 (pattern, parts.map(str::to_string).collect(), begin + 2 + i)
425 })
426 .collect();
427 Some(rules)
428}
429
430pub fn render_workflow(cfg: &VgiConfig, checkout_action: &str, repo: &Resource) -> String {
441 format!(
442 r#"# Managed by this community's VGI bridge. It is rewritten on bootstrap;
443# propose changes to the VTC rather than editing it here.
444name: verify-trust
445
446on:
447 pull_request:
448 # A merge queue runs required checks on its own merge commits; without
449 # this trigger the check never reports there and queued merges stall.
450 merge_group:
451
452# Reads the repository and downloads a public release; writes nothing.
453permissions:
454 contents: read
455
456jobs:
457 verify:
458 name: {job_name}
459 runs-on: ubuntu-latest
460 steps:
461 - uses: {checkout}
462 with:
463 # The base ref must be present so `origin/<base>..HEAD` resolves.
464 fetch-depth: 0
465 persist-credentials: false
466
467 - name: verify-trust
468 uses: {action}
469 with:
470 # merge_group events have no base_ref; the queue names its base
471 # commit instead.
472 range: ${{{{ github.event_name == 'merge_group' && github.event.merge_group.base_sha || format('origin/{{0}}', github.base_ref) }}}}..HEAD
473 # Literals, not `vars.*`: repository variables are any
474 # repository admin's to change.
475 registry-did: {registry}
476 vtc-did: {vtc}
477{transport} resource-format: qualified
478{fallback} # GitHub web-UI merge/squash commits are PGP-signed by web-flow;
479 # they pass only via this committed keyring.
480 exempt-keyring: {keyring}
481 version: {version}
482"#,
483 job_name = yaml_single_quoted(&cfg.required_check),
484 checkout = checkout_action,
485 action = cfg.verify_trust_action,
486 registry = yaml_single_quoted(&cfg.trust_registry_did),
487 vtc = yaml_single_quoted(&cfg.vtc_did),
488 transport = cfg.verify_trust_transport.workflow_input_line(" "),
489 fallback = fallback_block(repo),
490 keyring = KEYRING_PATH,
491 version = cfg.verify_trust_version,
492 )
493}
494
495pub fn render_required_workflow(
510 cfg: &VgiConfig,
511 checkout_action: &str,
512 repo: &Resource,
513 keyring: &[u8],
514) -> Result<String> {
515 let keyring = std::str::from_utf8(keyring)
516 .map_err(|_| ForgeError::Config("platform keyring is not ASCII armor".into()))?;
517 let mut block = String::new();
518 for line in keyring.lines() {
519 if line.is_empty() {
520 block.push('\n');
521 } else {
522 block.push_str(" ");
523 block.push_str(line);
524 block.push('\n');
525 }
526 }
527 Ok(format!(
528 r#"# Managed by this community's VGI bridge, and required on the community's
529# repositories by the org ruleset "{ruleset}" at a pinned commit. A change
530# here takes effect only when the bridge pins it; propose changes to the VTC.
531name: verify-trust
532
533on:
534 pull_request:
535 # A merge queue runs required workflows on its own merge commits; without
536 # this trigger the check never reports there and queued merges stall.
537 merge_group:
538
539# Reads the repository and downloads a public release; writes nothing.
540permissions:
541 contents: read
542
543jobs:
544 verify:
545 name: {job_name}
546 runs-on: ubuntu-latest
547 steps:
548 - uses: {checkout}
549 with:
550 # The base ref must be present so `origin/<base>..HEAD` resolves.
551 fetch-depth: 0
552 persist-credentials: false
553
554 # GitHub web-UI merge/squash commits are PGP-signed by web-flow and pass
555 # only via this keyring. It is part of this pinned file, not of the
556 # repository under test, which a pull request could edit.
557 - name: exempt platform keyring
558 env:
559 VGI_PLATFORM_KEYRING: |
560{keyring} run: printf '%s' "$VGI_PLATFORM_KEYRING" > "$RUNNER_TEMP/vgi-platform-keys.asc"
561
562 - name: verify-trust
563 uses: {action}
564 with:
565 # merge_group events have no base_ref; the queue names its base
566 # commit instead.
567 range: ${{{{ github.event_name == 'merge_group' && github.event.merge_group.base_sha || format('origin/{{0}}', github.base_ref) }}}}..HEAD
568 # Literals, not `vars.*`: a repository variable of the same name
569 # would override an organisation one.
570 registry-did: {registry}
571 vtc-did: {vtc}
572{transport} resource-format: qualified
573{fallback} exempt-keyring: ${{{{ runner.temp }}}}/vgi-platform-keys.asc
574 version: {version}
575"#,
576 ruleset = ORG_RULESET_NAME,
577 job_name = yaml_single_quoted(&cfg.required_check),
578 checkout = checkout_action,
579 keyring = block,
580 action = cfg.verify_trust_action,
581 registry = yaml_single_quoted(&cfg.trust_registry_did),
582 vtc = yaml_single_quoted(&cfg.vtc_did),
583 transport = cfg.verify_trust_transport.workflow_input_line(" "),
584 fallback = fallback_block(repo),
585 version = cfg.verify_trust_version,
586 ))
587}
588
589pub fn fallback_resource(repo: &Resource) -> String {
612 format!("{}/${{{{ github.repository_owner }}}}", repo.host())
613}
614
615fn fallback_block(repo: &Resource) -> String {
616 format!(
617 " # The namespace: where the VTC publishes namespace-wide commit rights.\n \
618 fallback-resource: {}\n",
619 fallback_resource(repo)
620 )
621}
622
623fn yaml_single_quoted(s: &str) -> String {
624 format!("'{}'", s.replace('\'', "''"))
625}
626
627fn check_did(field: &str, did: &str) -> Result<()> {
628 let ok = did.starts_with("did:")
631 && !did.contains("${{")
632 && did.len() <= 2048
633 && did
634 .bytes()
635 .all(|b| b.is_ascii_graphic() && b != b'\'' && b != b'"');
636 if ok {
637 Ok(())
638 } else {
639 Err(ForgeError::Config(format!(
640 "{field} `{did}` is not a DID (expected `did:<method>:…`, no spaces or quotes)"
641 )))
642 }
643}
644
645fn check_pinned(what: &str, reference: &str) -> Result<()> {
647 let pinned = reference.rsplit_once('@').is_some_and(|(path, sha)| {
648 sha.len() == 40
649 && sha.bytes().all(|b| b.is_ascii_hexdigit())
650 && path.split('/').count() >= 2
651 && path.split('/').all(|s| {
652 !s.is_empty()
653 && s != ".."
654 && s.bytes()
655 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
656 })
657 });
658 if pinned {
659 Ok(())
660 } else {
661 Err(ForgeError::Config(format!(
662 "{what} `{reference}` must be pinned to a commit: `owner/repo[/path]@<40-hex sha>`"
663 )))
664 }
665}
666
667fn check_version(v: &str) -> Result<()> {
668 let ok = !v.is_empty()
669 && v.len() <= 64
670 && v.bytes()
671 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_'));
672 if ok {
673 Ok(())
674 } else {
675 Err(ForgeError::Config(format!(
676 "verify-trust version `{v}` must be a release tag like `v0.5.0` or `latest`"
677 )))
678 }
679}
680
681fn check_check_name(name: &str) -> Result<()> {
682 if name.trim().is_empty()
684 || name.len() > 100
685 || name.chars().any(char::is_control)
686 || name.contains("${{")
687 {
688 return Err(ForgeError::Config(format!(
689 "required check name `{name}` must be 1–100 printable characters"
690 )));
691 }
692 Ok(())
693}
694
695fn check_keyring(bytes: &[u8]) -> Result<()> {
696 let text = std::str::from_utf8(bytes)
697 .map_err(|_| ForgeError::Config("platform keyring is not ASCII armor".into()))?;
698 if text.contains("PRIVATE KEY") {
699 return Err(ForgeError::Config(
701 "platform keyring contains a PRIVATE key block; supply the public key only".into(),
702 ));
703 }
704 if !text.contains("-----BEGIN PGP PUBLIC KEY BLOCK-----") {
705 return Err(ForgeError::Config(
706 "platform keyring is not an armored PGP public key block".into(),
707 ));
708 }
709 Ok(())
710}
711
712fn check_embeddable_keyring(bytes: &[u8]) -> Result<()> {
716 let text = std::str::from_utf8(bytes)
717 .map_err(|_| ForgeError::Config("platform keyring is not ASCII armor".into()))?;
718 let lone_cr = text
721 .char_indices()
722 .any(|(i, c)| c == '\r' && !text[i + 1..].starts_with('\n'));
723 if text.contains("${{")
724 || lone_cr
725 || text
726 .chars()
727 .any(|c| c.is_control() && c != '\n' && c != '\r')
728 {
729 return Err(ForgeError::Config(
730 "platform keyring holds characters that cannot be embedded in the required workflow"
731 .into(),
732 ));
733 }
734 Ok(())
735}
736
737#[cfg(test)]
738mod tests {
739 use super::*;
740 use vgi_forge::Resource;
741
742 const SHA: &str = "0123456789abcdef0123456789abcdef01234567";
743
744 fn cfg() -> VgiConfig {
745 VgiConfig::new(
746 "did:webvh:reg",
747 "did:webvh:vtc",
748 format!("OpenVTC/verifiable-git-infrastructure/.github/actions/verify-trust@{SHA}"),
749 "v0.5.0",
750 )
751 .with_platform_keyring(
752 "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nx\n-----END PGP PUBLIC KEY BLOCK-----\n",
753 )
754 }
755
756 fn spec() -> RepoSpec {
757 RepoSpec::new(Resource::parse("github.com/acme/gadgets").unwrap())
758 }
759
760 fn owner_review() -> CheckGuard {
761 CheckGuard::OwnerReview {
762 owners: vec![ForgeAccount::new(7, "bob"), ForgeAccount::new(9, "carol")],
763 }
764 }
765
766 const CHECKOUT: &str = crate::config::DEFAULT_CHECKOUT_ACTION;
767
768 fn ids(plan: &[BootstrapStep]) -> Vec<&str> {
769 plan.iter().map(|s| s.id.as_str()).collect()
770 }
771
772 #[test]
775 fn every_guard_carries_the_required_approvals() {
776 let cfg = cfg().with_required_approvals(2);
777 for guard in [
778 CheckGuard::RequiredWorkflow,
779 owner_review(),
780 CheckGuard::SoloOwner,
781 CheckGuard::BridgePosted,
782 ] {
783 let plan = github_plan(&spec(), &cfg, CHECKOUT, &guard).unwrap();
784 let protection = plan
785 .iter()
786 .find_map(|s| match &s.action {
787 StepAction::ProtectDefaultBranch(p) => Some(p.clone()),
788 _ => None,
789 })
790 .expect("a ruleset step");
791 assert_eq!(protection.required_approvals, 2, "{guard:?}");
792 }
793 }
794
795 #[test]
796 fn the_guard_follows_the_owner_count() {
797 let bob = ForgeAccount::new(7, "bob");
798 assert_eq!(
799 CheckGuard::for_owners(std::slice::from_ref(&bob)),
800 CheckGuard::SoloOwner
801 );
802 assert_eq!(
803 CheckGuard::for_owners(&[bob.clone(), ForgeAccount::new(7, "bob-renamed")]),
804 CheckGuard::SoloOwner,
805 "one id, twice, is one owner"
806 );
807 assert!(matches!(
808 CheckGuard::for_owners(&[bob, ForgeAccount::new(9, "carol")]),
809 CheckGuard::OwnerReview { owners } if owners.len() == 2
810 ));
811 }
812
813 #[test]
814 fn owner_review_plans_files_then_codeowners_then_ruleset_then_cleanup() {
815 let plan = github_plan(
816 &spec(),
817 &cfg()
818 .with_extra_file("LICENSE", "MIT\n")
819 .with_extra_file("CODEOWNERS", "* @acme/owners\n"),
820 CHECKOUT,
821 &owner_review(),
822 )
823 .unwrap();
824 assert_eq!(
825 ids(&plan),
826 [
827 "workflow",
828 "keyring",
829 "file:LICENSE",
830 "codeowners",
831 "ruleset",
832 "cleanup:variable:TRUST_REGISTRY_DID",
833 "cleanup:variable:VTC_DID",
834 ]
835 );
836 let StepAction::RequireOwnerReview {
839 paths,
840 owners,
841 community_rules,
842 ..
843 } = &plan[3].action
844 else {
845 panic!("{:?}", plan[3]);
846 };
847 assert_eq!(paths, &["/.github/"]);
848 assert_eq!(owners.len(), 2);
849 assert_eq!(community_rules, b"* @acme/owners\n");
850 let StepAction::ProtectDefaultBranch(p) = &plan[4].action else {
851 panic!()
852 };
853 assert!(p.require_code_owner_review && p.require_status_check);
854
855 for owners in [vec![], vec![ForgeAccount::new(7, "bob")]] {
856 let e = github_plan(
857 &spec(),
858 &cfg(),
859 CHECKOUT,
860 &CheckGuard::OwnerReview { owners },
861 )
862 .unwrap_err();
863 assert!(e.to_string().contains("at least two owners"), "{e}");
864 }
865 let two = cfg()
866 .with_extra_file("CODEOWNERS", "")
867 .with_extra_file("docs/CODEOWNERS", "");
868 assert!(github_plan(&spec(), &two, CHECKOUT, &owner_review()).is_err());
869 }
870
871 #[test]
872 fn a_solo_repository_gets_the_check_and_no_review() {
873 let plan = github_plan(
874 &spec(),
875 &cfg().with_extra_file("CODEOWNERS", "* @acme/owners\n"),
876 CHECKOUT,
877 &CheckGuard::SoloOwner,
878 )
879 .unwrap();
880 assert_eq!(
881 ids(&plan),
882 [
883 "workflow",
884 "keyring",
885 "file:CODEOWNERS",
886 "ruleset",
887 "cleanup:variable:TRUST_REGISTRY_DID",
888 "cleanup:variable:VTC_DID",
889 ]
890 );
891 let StepAction::ProtectDefaultBranch(p) = &plan[3].action else {
892 panic!()
893 };
894 assert!(p.require_status_check && !p.require_code_owner_review);
895 }
896
897 #[test]
898 fn required_workflow_plans_no_repo_workflow_keyring_or_variables() {
899 let plan = github_plan(
900 &spec(),
901 &cfg().with_extra_file("CODEOWNERS", "* @acme/owners\n"),
902 CHECKOUT,
903 &CheckGuard::RequiredWorkflow,
904 )
905 .unwrap();
906 assert_eq!(
907 ids(&plan),
908 [
909 "file:CODEOWNERS",
910 "required-workflow",
911 "ruleset",
912 "cleanup:variable:TRUST_REGISTRY_DID",
913 "cleanup:variable:VTC_DID",
914 "cleanup:workflow",
915 "cleanup:keyring"
916 ]
917 );
918 let StepAction::ProtectDefaultBranch(p) = &plan[2].action else {
919 panic!()
920 };
921 assert!(!p.require_status_check && !p.require_code_owner_review);
922 }
923
924 #[test]
925 fn the_required_workflow_fixes_everything_the_pr_could_touch() {
926 let wf = render_required_workflow(
927 &cfg(),
928 CHECKOUT,
929 &spec().resource,
930 cfg().platform_keyring.as_deref().unwrap(),
931 )
932 .unwrap();
933 assert!(wf.contains(" name: 'Verify commit trust'\n"));
934 assert!(wf.contains(" pull_request:\n") && wf.contains(" merge_group:\n"));
935 assert!(wf.contains("registry-did: 'did:webvh:reg'\n"));
936 assert!(wf.contains("vtc-did: 'did:webvh:vtc'\n"));
937 assert!(
938 !wf.contains("${{ vars"),
939 "no repository-overridable variables"
940 );
941 assert!(wf.contains("exempt-keyring: ${{ runner.temp }}/vgi-platform-keys.asc\n"));
942 assert!(
943 !wf.contains(KEYRING_PATH),
944 "the keyring is not read from the repo"
945 );
946 assert!(wf.contains(
947 " VGI_PLATFORM_KEYRING: |\n -----BEGIN PGP PUBLIC KEY BLOCK-----\n\n x\n -----END PGP PUBLIC KEY BLOCK-----\n run: "
948 ));
949 assert!(wf.contains(FALLBACK_LINES), "{wf}");
950 assert!(!wf.contains("if:"));
951
952 let mut c = cfg();
953 c.vtc_did = "did:x:${{github.token}}".into();
954 assert!(github_plan(&spec(), &c, CHECKOUT, &CheckGuard::RequiredWorkflow).is_err());
955 let c =
956 cfg().with_platform_keyring("-----BEGIN PGP PUBLIC KEY BLOCK-----\n${{ secrets.X }}\n");
957 assert!(github_plan(&spec(), &c, CHECKOUT, &CheckGuard::RequiredWorkflow).is_err());
958 let c = cfg().with_platform_keyring(
960 "-----BEGIN PGP PUBLIC KEY BLOCK-----\rrun: evil\n-----END PGP PUBLIC KEY BLOCK-----\n",
961 );
962 assert!(github_plan(&spec(), &c, CHECKOUT, &CheckGuard::RequiredWorkflow).is_err());
963 let c = cfg().with_platform_keyring(
965 "-----BEGIN PGP PUBLIC KEY BLOCK-----\r\n\r\nx\r\n-----END PGP PUBLIC KEY BLOCK-----\r\n",
966 );
967 assert!(github_plan(&spec(), &c, CHECKOUT, &CheckGuard::RequiredWorkflow).is_ok());
968 }
969
970 #[test]
971 fn codeowners_puts_the_managed_block_last_and_rerenders_stably() {
972 let out = render_codeowners(
973 "* @acme/owners\n",
974 &["/.github/".into()],
975 &["alice".into(), "bob".into()],
976 );
977 assert!(out.starts_with("* @acme/owners\n\n# BEGIN VGI managed owner rules\n"));
978 assert!(
979 out.ends_with("/.github/ @alice @bob\n# END VGI managed owner rules\n"),
980 "{out}"
981 );
982 let rules = managed_rules(&out).unwrap();
983 assert_eq!(rules.len(), 1);
984 assert_eq!(rules[0].0, "/.github/");
985 assert_eq!(rules[0].1, ["@alice", "@bob"]);
986 assert_eq!(
987 out.lines().nth(rules[0].2 - 1),
988 Some("/.github/ @alice @bob")
989 );
990 assert_eq!(
992 render_codeowners(&out, &["/.github/".into()], &["alice".into(), "bob".into()]),
993 out
994 );
995 assert!(managed_rules(&format!("{out}* @mallory\n")).is_none());
997 assert!(managed_rules(&format!("{out}# just a comment\n")).is_some());
998 assert!(managed_rules("* @acme/owners\n").is_none());
999 }
1000
1001 const FALLBACK_LINES: &str = " resource-format: qualified\n \
1005 # The namespace: where the VTC publishes namespace-wide commit rights.\n \
1006 fallback-resource: github.com/${{ github.repository_owner }}\n";
1007
1008 #[test]
1009 fn the_fallback_is_the_running_repositorys_own_namespace_on_this_forge() {
1010 let a = Resource::parse("github.com/acme/gadgets").unwrap();
1014 let b = Resource::parse("github.com/acme/widgets").unwrap();
1015 assert_eq!(
1016 fallback_resource(&a),
1017 "github.com/${{ github.repository_owner }}"
1018 );
1019 assert_eq!(fallback_resource(&a), fallback_resource(&b));
1020 let keyring = cfg().platform_keyring.unwrap();
1021 assert_eq!(
1022 render_required_workflow(&cfg(), CHECKOUT, &a, &keyring).unwrap(),
1023 render_required_workflow(&cfg(), CHECKOUT, &b, &keyring).unwrap(),
1024 "the org's required workflow must not differ per repository"
1025 );
1026 let ghes = Resource::parse("ghe.example.com/acme/gadgets").unwrap();
1028 let wf = render_workflow(&cfg(), CHECKOUT, &ghes);
1029 assert!(
1030 wf.contains("fallback-resource: ghe.example.com/${{ github.repository_owner }}\n"),
1031 "{wf}"
1032 );
1033 for wf in [
1035 render_workflow(&cfg(), CHECKOUT, &a),
1036 render_required_workflow(&cfg(), CHECKOUT, &a, &keyring).unwrap(),
1037 ] {
1038 assert_eq!(wf.matches("fallback-resource:").count(), 1, "{wf}");
1039 assert!(wf.contains(FALLBACK_LINES), "{wf}");
1040 assert!(!wf.contains("resource-format: legacy"));
1041 }
1042 }
1043
1044 #[test]
1045 fn the_transport_input_is_written_only_when_pinned() {
1046 let wf = render_workflow(&cfg(), CHECKOUT, &spec().resource);
1048 assert!(!wf.contains("transport:"), "{wf}");
1049 let keyring = b"k".to_vec();
1050 let rw = render_required_workflow(&cfg(), CHECKOUT, &spec().resource, &keyring).unwrap();
1051 assert!(!rw.contains("transport:"));
1052
1053 let pinned = cfg().with_verify_trust_transport(vgi_forge::VerifyTransport::Https);
1054 let wf = render_workflow(&pinned, CHECKOUT, &spec().resource);
1055 assert!(
1056 wf.contains("vtc-did: 'did:webvh:vtc'\n transport: https\n"),
1057 "{wf}"
1058 );
1059 let rw = render_required_workflow(&pinned, CHECKOUT, &spec().resource, &keyring).unwrap();
1060 assert!(rw.contains(" transport: https\n"), "{rw}");
1061 }
1062
1063 #[test]
1064 fn the_workflow_is_pinned_qualified_and_unguarded() {
1065 let wf = render_workflow(&cfg(), CHECKOUT, &spec().resource);
1066 assert!(wf.contains("registry-did: 'did:webvh:reg'\n"));
1067 assert!(wf.contains("vtc-did: 'did:webvh:vtc'\n"));
1068 assert!(
1069 !wf.contains("${{ vars"),
1070 "no repository-overridable variables"
1071 );
1072 assert!(wf.contains(" name: 'Verify commit trust'\n"));
1073 assert!(wf.contains(FALLBACK_LINES), "{wf}");
1074 assert!(wf.contains(" merge_group:\n"));
1075 assert!(wf.contains(
1076 "range: ${{ github.event_name == 'merge_group' && github.event.merge_group.base_sha \
1077 || format('origin/{0}', github.base_ref) }}..HEAD"
1078 ));
1079 assert!(wf.contains(&format!("verify-trust@{SHA}")));
1080 assert!(wf.contains("uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"));
1081 assert!(!wf.contains("if:"));
1082 let mut quoted = cfg();
1083 quoted.required_check = "it's".into();
1084 assert!(render_workflow("ed, "a/b@x", &spec().resource).contains("name: 'it''s'"));
1085 }
1086
1087 #[test]
1088 fn bad_config_fails_before_any_step_runs() {
1089 for guard in [
1090 owner_review(),
1091 CheckGuard::SoloOwner,
1092 CheckGuard::RequiredWorkflow,
1093 ] {
1094 let plan = |c: &VgiConfig| github_plan(&spec(), c, CHECKOUT, &guard);
1095 let mut c = cfg();
1096 c.verify_trust_action =
1097 "OpenVTC/verifiable-git-infrastructure/.github/actions/verify-trust@v0.5.0".into();
1098 assert!(plan(&c).unwrap_err().to_string().contains("pinned"));
1099
1100 let mut c = cfg();
1101 c.platform_keyring = None;
1102 assert!(plan(&c).unwrap_err().to_string().contains("web-flow"));
1103
1104 let c = cfg().with_platform_keyring("-----BEGIN PGP PRIVATE KEY BLOCK-----");
1105 assert!(plan(&c).unwrap_err().to_string().contains("PRIVATE"));
1106
1107 let mut c = cfg();
1108 c.vtc_did = "did:web:x\n evil: true".into();
1109 assert!(plan(&c).is_err());
1110
1111 let mut c = cfg();
1112 c.verify_trust_version = "v1\nx".into();
1113 assert!(plan(&c).is_err());
1114
1115 assert!(plan(&cfg().with_extra_file("../x", "")).is_err());
1116 assert!(plan(&cfg().with_extra_file(WORKFLOW_PATH, "")).is_err());
1117
1118 assert!(github_plan(&spec(), &cfg(), "actions/checkout@v4", &guard).is_err());
1119 let ns = RepoSpec::new(Resource::parse("github.com/acme").unwrap());
1120 assert!(github_plan(&ns, &cfg(), CHECKOUT, &guard).is_err());
1121 }
1122 }
1123}