Skip to main content

vgi_forge_forgejo/
plan.rs

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