Skip to main content

vgi_forge_github/
plan.rs

1//! GitHub's bootstrap plan (§5.3) and the files it commits.
2//!
3//! The order is load-bearing: files go in first, then the variables, then
4//! the protection. The ruleset has no bypass actors — the bridge included —
5//! so after it exists every change to the default branch goes through a pull
6//! request and the check.
7//!
8//! **The pull request must not be able to satisfy its own check** (§9). A
9//! `pull_request` workflow runs from the pull request's own files, so the
10//! plan takes one of two guards, chosen per namespace ([`CheckGuard`]):
11//!
12//! - [`CheckGuard::RequiredWorkflow`] (organisations with org rulesets): the
13//!   check is not committed to the repository at all. It lives in the
14//!   bridge-managed `<org>/.vgi` repository and an org ruleset requires it
15//!   at a **pinned commit**, so nothing in the pull request changes what
16//!   runs. The DIDs and the exempt keyring are written into that workflow,
17//!   not read from the repository under test.
18//! - Elsewhere (personal accounts, organisations without org rulesets) the
19//!   workflow is committed to the repository, with the DIDs as literals:
20//!   - [`CheckGuard::OwnerReview`], two or more owners: `CODEOWNERS` makes
21//!     every change under `.github/` — the workflow, the keyring,
22//!     `CODEOWNERS` itself — need another owner's approving review, which
23//!     the ruleset requires (dismissed by later pushes, never the last
24//!     pusher's own).
25//!   - [`CheckGuard::SoloOwner`], one owner: no review requirement, only the
26//!     check. The owner could weaken their own workflow; accepted (the
27//!     user's decision), since they control the repository anyway.
28//!
29//!   In both, repository writers are trusted not to forge a "Verify commit
30//!   trust" check run from a workflow on another branch; only the required
31//!   workflow closes that.
32//! - [`CheckGuard::BridgePosted`] (the same namespaces, when the adapter is
33//!   configured with [`crate::GitHubConfig::bridge_checks`]): no workflow at
34//!   all. The bridge runs verify-trust itself on every pull request and
35//!   merge group and posts the check as the community's App; the ruleset
36//!   requires the check **from that App's integration id**, which no
37//!   workflow can post as. This is what closes the forged-check gap outside
38//!   a required workflow (§9, decided 2026-09-23).
39
40use vgi_forge::{
41    BootstrapComponent, BootstrapStep, ForgeAccount, ForgeError, ProtectionSpec, RepoSpec,
42    Resource, Result, StepAction, VgiConfig, validate_repo_path,
43};
44
45/// Where the workflow is committed (in the repository, or in `.vgi`).
46pub const WORKFLOW_PATH: &str = ".github/workflows/verify-trust.yml";
47/// Where the exempt platform keyring is committed.
48pub const KEYRING_PATH: &str = ".github/trusted-platform-keys.asc";
49/// Where the managed code-owner rules are committed. GitHub reads
50/// `.github/CODEOWNERS` ahead of `CODEOWNERS` and `docs/CODEOWNERS`.
51pub const CODEOWNERS_PATH: &str = ".github/CODEOWNERS";
52/// Every place GitHub looks for a `CODEOWNERS` file; the first one found is
53/// the only one used.
54pub const CODEOWNERS_LOCATIONS: [&str; 3] = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"];
55/// The paths owner review guards: everything a workflow run reads from the
56/// repository under test to decide how to check it.
57pub const GUARDED_PATH: &str = "/.github/";
58/// Name of the ruleset the adapter manages. Found by name on re-runs.
59pub const RULESET_NAME: &str = "VGI commit trust";
60/// Name of the org ruleset that requires the namespace workflow.
61pub const ORG_RULESET_NAME: &str = "VGI required workflow";
62/// The bridge-managed repository in an organisation that holds the required
63/// workflow. Public, so the workflow may run on repositories of any
64/// visibility (a private source repository's workflow may run only on
65/// private repositories); it holds nothing secret.
66pub const CENTRAL_REPO: &str = ".vgi";
67/// Registry DID variable.
68pub const VAR_REGISTRY: &str = "TRUST_REGISTRY_DID";
69/// VTC DID variable.
70pub const VAR_VTC: &str = "VTC_DID";
71
72/// How the plan keeps the pull request away from its own check (§9).
73#[derive(Debug, Clone, PartialEq, Eq)]
74#[non_exhaustive]
75pub enum CheckGuard {
76    /// An org ruleset requires the workflow in `<org>/.vgi` at a pinned
77    /// commit.
78    RequiredWorkflow,
79    /// Two or more owners: `CODEOWNERS` names them for `.github/`, and the
80    /// ruleset requires one approval — a code owner's, not the last
81    /// pusher's, dismissed by later pushes.
82    OwnerReview {
83        /// Who may approve workflow changes. At least two.
84        owners: Vec<ForgeAccount>,
85    },
86    /// Exactly one owner: no review requirement, only the required check.
87    /// The owner could weaken their own workflow — accepted, since they
88    /// control the repository anyway (the user's decision). Re-plan when a
89    /// second owner arrives.
90    SoloOwner,
91    /// The bridge posts the check under its own App, and the ruleset pins
92    /// the required check to that App. No workflow is committed; one left
93    /// from an earlier guard is removed.
94    BridgePosted,
95}
96
97impl CheckGuard {
98    /// The guard for a repository with these (linked) owners, outside a
99    /// required-workflow namespace. Duplicate ids count once.
100    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
115/// Build the plan. Validates everything that ends up in a file or a
116/// variable, so a bad config fails here rather than half-way through.
117pub 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    // The bridge-posted check reads no keyring from the repository (the
136    // bridge holds its own, optionally), so only the workflow guards need
137    // one to commit or embed.
138    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            // The managed `.github/CODEOWNERS` would shadow a community one
190            // anywhere else (GitHub reads only the first it finds), so the
191            // community's rules are folded into it instead, ahead of the
192            // managed rule.
193            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            // A repository that had the owner-review guard before its org
239            // gained org rulesets: its own workflow, keyring and variables
240            // are no longer what runs. The ruleset step above already drops
241            // its status-check rule.
242            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            // The adapter pins this rule's check to its own App when it runs
302            // the step: the plan is the same ruleset, what differs is who
303            // may satisfy it.
304            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            // A workflow from an earlier guard would still run and post an
313            // Actions check that no longer counts; remove it so nothing
314            // suggests it matters. After the ruleset, like the
315            // required-workflow clean-up: the protection comes first.
316            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
329/// The DIDs are literals in the workflow now (a repository variable could
330/// be changed by any repository admin, and overrides an org one): the old
331/// variables are removed so nothing suggests they still matter.
332fn 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
345/// First line of the managed block in a `CODEOWNERS` file.
346pub const MANAGED_BEGIN: &str = "# BEGIN VGI managed owner rules";
347/// Last line of the managed block.
348pub const MANAGED_END: &str = "# END VGI managed owner rules";
349
350/// A `CODEOWNERS` file: `community_rules` (the file's own rules, the managed
351/// block removed), then the managed block last, so it wins for `paths` (the
352/// last matching pattern takes precedence). `logins` are resolved from
353/// numeric ids at run time.
354pub 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
376/// `text` without the managed block (so re-rendering keeps only the
377/// community's own rules).
378pub 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
396/// The managed block of a `CODEOWNERS` file, if it is there and nothing but
397/// comments follows it: `(pattern, owners, 1-based line)` per rule.
398pub 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
425/// The workflow committed to the repository (outside a required-workflow
426/// namespace). Differs from the dormant one in the runbook: there is no
427/// `if: vars.TRUST_REGISTRY_DID != ''` guard (a *skipped* required job
428/// counts as passing), and the DIDs are literals rather than `vars.*`,
429/// which any repository admin could change.
430///
431/// The namespace is the fallback resource ([`fallback_resource`]): the VTC
432/// publishes namespace-wide commit rights — a `git.ns.admin`'s implied
433/// `git.commit.sign`, the bridge's service grant — on it, not on each
434/// repository.
435pub 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
490/// The required workflow held in `<org>/.vgi`. It runs in the context of
491/// the repository under test (its `GITHUB_REPOSITORY`, its pull request),
492/// but everything that decides *how* to check is fixed here, at the pinned
493/// commit:
494///
495/// - the DIDs are literals, not `vars.*` — a repository variable overrides
496///   an organisation one of the same name, and repository admins set those;
497/// - the exempt keyring is written from this file to the runner's temp
498///   directory, not read from the repository, where the pull request could
499///   add its own key to it.
500///
501/// It is shared by every managed repository of the organisation, so the
502/// fallback resource names the namespace of the repository it runs for, read
503/// at run time ([`fallback_resource`]) rather than written in.
504pub 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
584/// The `fallback-resource` value both workflows pass:
585/// `<forge-host>/${{ github.repository_owner }}`.
586///
587/// The VTC publishes a namespace's commit rights — every `git.ns.admin`'s
588/// implied `git.commit.sign`, a namespace-wide grant, the bridge's service
589/// grant that its re-signed Dependabot commits rely on — on the namespace
590/// resource (`github.com/acme`), and a bridge that sets up a repository's
591/// check must make the namespace its fallback (git-ns `right/grant` 0.1).
592///
593/// Exactly the repository's own namespace, never broader:
594///
595/// - the owner is the one GitHub runs the job for, read at run time, so one
596///   file serves every repository of an organisation (the required
597///   workflow) and a copy in another owner's repository names that owner,
598///   never this one;
599/// - the host is the forge's, fixed here (a resource names no scheme, so
600///   `github.server_url` cannot be used as is);
601/// - the value reaches verify-trust through the action's environment, never
602///   a script, and verify-trust refuses a fallback that does not contain the
603///   repository's own resource (another owner, another forge);
604/// - it is only ever written next to `resource-format: qualified` — a legacy
605///   run takes no forge-qualified fallback.
606pub 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    // `${{` would make the value an Actions expression where it is written
624    // into a workflow as a literal.
625    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
640/// `owner/repo[/path]@<40 hex>` — the only form the workflow will `uses:`.
641fn 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    // `${{` would make the job name an Actions expression.
678    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        // Committing it would publish it.
695        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
707/// The keyring is written into the required workflow as a YAML block
708/// scalar inside `env:`, where Actions evaluates expressions: refuse
709/// anything that could become one, or that is not plain armor text.
710fn 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    // A lone `\r` is a line break to a YAML parser but not to `lines()`, so
714    // it could end the block scalar early: only `\r\n` is allowed.
715    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        // The community's CODEOWNERS is folded into the managed one, which
809        // GitHub would otherwise read instead of it.
810        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        // A lone CR is a YAML line break: it could end the block scalar.
931        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        // CRLF armor is fine.
936        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        // Re-rendering over its own output keeps the community rules once.
963        assert_eq!(
964            render_codeowners(&out, &["/.github/".into()], &["alice".into(), "bob".into()]),
965            out
966        );
967        // A rule after the block means the block no longer wins.
968        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    /// What both workflows pass verify-trust, byte for byte: the qualified
974    /// form, then the namespace of the repository the job runs for as the
975    /// fallback (git-ns `right/grant` 0.1, the namespace projection).
976    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        // One value for every repository of the namespace — the required
983        // workflow is shared — with the owner read at run time: never a
984        // literal another owner's repository could inherit.
985        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        // An Enterprise Server names its own host.
999        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        // Only ever next to the qualified form, and once.
1006        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        // The default writes nothing, so existing workflows do not drift.
1019        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(&quoted, "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}