Skip to main content

vgi_forge/
bootstrap.rs

1//! Bootstrap plans: the steps that turn commit trust on for a repository.
2//!
3//! An adapter turns a [`VgiConfig`] into an ordered list of
4//! [`BootstrapStep`]s (§5.3 is GitHub's list), and runs them one at a time.
5//! Every step is check-then-apply, so a plan that failed half-way is retried
6//! from the top and the steps already done report
7//! [`StepOutcome::Unchanged`].
8//!
9//! Order matters and is the adapter's to get right: files must land before
10//! the protection that forbids direct pushes, because the protection has no
11//! bypass actors — not even the bridge.
12
13use serde::{Deserialize, Serialize};
14
15use crate::error::{ForgeError, Result};
16use crate::forge::Forge;
17use crate::model::ForgeAccount;
18use crate::resource::Resource;
19
20/// The required status check's default name: the verify-trust job's `name`.
21pub const DEFAULT_REQUIRED_CHECK: &str = "Verify commit trust";
22
23/// Which Trust Registry binding the verify-trust workflow uses — the
24/// action's `transport` input.
25///
26/// `Auto` (the default) is verify-trust's strict preference: TSP, then
27/// DIDComm, then HTTPS, whichever the registry's DID document advertises,
28/// with no fallback when the chosen one fails. A community whose registry
29/// mediator does not yet admit a CI run's throwaway DID pins `Https`.
30/// A closed set, so a value can be written into a workflow as-is.
31#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
32#[serde(rename_all = "lowercase")]
33#[non_exhaustive]
34pub enum VerifyTransport {
35    /// Strict preference: TSP, then DIDComm, then HTTPS.
36    #[default]
37    Auto,
38    /// TSP only.
39    Tsp,
40    /// DIDComm only.
41    Didcomm,
42    /// HTTPS (the registry's `#rest` endpoint) only.
43    Https,
44}
45
46impl VerifyTransport {
47    /// The action input's value.
48    pub fn as_str(self) -> &'static str {
49        match self {
50            VerifyTransport::Auto => "auto",
51            VerifyTransport::Tsp => "tsp",
52            VerifyTransport::Didcomm => "didcomm",
53            VerifyTransport::Https => "https",
54        }
55    }
56
57    /// Whether this is the default.
58    pub fn is_auto(&self) -> bool {
59        *self == VerifyTransport::Auto
60    }
61
62    /// The workflow's `transport:` input line (indented for the action's
63    /// `with:` block), or nothing for the default — so a workflow written
64    /// before this option existed is unchanged byte for byte.
65    pub fn workflow_input_line(self, indent: &str) -> String {
66        if self.is_auto() {
67            String::new()
68        } else {
69            format!("{indent}transport: {}\n", self.as_str())
70        }
71    }
72}
73
74impl std::fmt::Display for VerifyTransport {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        f.write_str(self.as_str())
77    }
78}
79
80/// Forge-neutral inputs to a bootstrap plan.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "camelCase")]
83#[non_exhaustive]
84pub struct VgiConfig {
85    /// DID of the Trust Registry (`TRUST_REGISTRY_DID`).
86    pub trust_registry_did: String,
87    /// DID of this VTC (`VTC_DID`) — the only authority a bootstrapped repo
88    /// trusts (§4.1).
89    pub vtc_did: String,
90    /// The verify-trust action reference the workflow `uses:`, pinned to a
91    /// commit, e.g. `OpenVTC/verifiable-git-infrastructure/.github/actions/verify-trust@<sha>`.
92    pub verify_trust_action: String,
93    /// The VGI release the action downloads (`version:` input), e.g. `v0.5.0`.
94    pub verify_trust_version: String,
95    /// SHA-256 of the release tarball the runner downloads (`sha256:` input,
96    /// 64 lowercase hex). Where the runner cannot verify the release's build
97    /// attestation (Forgejo), this pin in the reviewed workflow is what
98    /// survives a replaced release asset; an adapter for such a forge refuses
99    /// a plan without it.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub verify_trust_sha256: Option<String>,
102    /// Name of the required status check. The workflow's job is given this
103    /// name, so the two cannot disagree.
104    pub required_check: String,
105    /// Armored PGP keyring of the forge's platform keys (GitHub's `web-flow`)
106    /// for the exempt keyring. Supplied by configuration; adapters do not
107    /// fetch it on their own.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub platform_keyring: Option<Vec<u8>>,
110    /// Extra files a community commits to every new repo (§5.8 layer 3:
111    /// a `CODEOWNERS`, a licence). Committed before protection is enabled.
112    #[serde(default, skip_serializing_if = "Vec::is_empty")]
113    pub extra_files: Vec<ExtraFile>,
114    /// The registry binding the workflow's verify-trust uses (`transport:`
115    /// input); the default writes no input.
116    #[serde(default, skip_serializing_if = "VerifyTransport::is_auto")]
117    pub verify_trust_transport: VerifyTransport,
118    /// Approving reviews every governed repository's pull requests need
119    /// ([`ProtectionSpec::required_approvals`]). `0`: none required.
120    #[serde(default, skip_serializing_if = "is_zero")]
121    pub required_approvals: u8,
122}
123
124impl VgiConfig {
125    /// A config with the default check name and no keyring or extra files.
126    pub fn new(
127        trust_registry_did: impl Into<String>,
128        vtc_did: impl Into<String>,
129        verify_trust_action: impl Into<String>,
130        verify_trust_version: impl Into<String>,
131    ) -> Self {
132        VgiConfig {
133            trust_registry_did: trust_registry_did.into(),
134            vtc_did: vtc_did.into(),
135            verify_trust_action: verify_trust_action.into(),
136            verify_trust_version: verify_trust_version.into(),
137            verify_trust_sha256: None,
138            required_check: DEFAULT_REQUIRED_CHECK.into(),
139            platform_keyring: None,
140            extra_files: Vec::new(),
141            verify_trust_transport: VerifyTransport::Auto,
142            required_approvals: 0,
143        }
144    }
145
146    /// Require `n` approving reviews on every governed repository.
147    pub fn with_required_approvals(mut self, n: u8) -> Self {
148        self.required_approvals = n;
149        self
150    }
151
152    /// Pin the registry binding the workflow uses.
153    pub fn with_verify_trust_transport(mut self, transport: VerifyTransport) -> Self {
154        self.verify_trust_transport = transport;
155        self
156    }
157
158    /// Pin the release tarball's SHA-256.
159    pub fn with_verify_trust_sha256(mut self, sha256: impl Into<String>) -> Self {
160        self.verify_trust_sha256 = Some(sha256.into());
161        self
162    }
163
164    /// Set the platform keyring.
165    pub fn with_platform_keyring(mut self, armored: impl Into<Vec<u8>>) -> Self {
166        self.platform_keyring = Some(armored.into());
167        self
168    }
169
170    /// Add a community file.
171    pub fn with_extra_file(
172        mut self,
173        path: impl Into<String>,
174        contents: impl Into<Vec<u8>>,
175    ) -> Self {
176        self.extra_files.push(ExtraFile {
177            path: path.into(),
178            contents: contents.into(),
179        });
180        self
181    }
182}
183
184/// A community-supplied file to commit during bootstrap.
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(rename_all = "camelCase")]
187pub struct ExtraFile {
188    /// Repository-relative path.
189    pub path: String,
190    /// Contents.
191    pub contents: Vec<u8>,
192}
193
194/// Which part of the VTC's bootstrap status (§4.3 `bootstrap`) a step
195/// satisfies — the four dots on the Repos page.
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
197#[serde(rename_all = "camelCase")]
198#[non_exhaustive]
199pub enum BootstrapComponent {
200    /// The verify-trust workflow.
201    Workflow,
202    /// The exempt platform keyring.
203    Keyring,
204    /// The `TRUST_REGISTRY_DID` / `VTC_DID` variables.
205    Variables,
206    /// The protection that requires the check.
207    RequiredCheck,
208    /// Anything else (community files, forge-specific settings).
209    Extra,
210}
211
212/// Branch protection to enforce on the default branch.
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214#[serde(rename_all = "camelCase")]
215#[non_exhaustive]
216pub struct ProtectionSpec {
217    /// The status check that must pass.
218    pub required_check: String,
219    /// Changes must come through a pull request.
220    pub require_pull_request: bool,
221    /// Block force-pushes.
222    pub block_force_push: bool,
223    /// Block deletion.
224    pub block_deletion: bool,
225    /// Require [`ProtectionSpec::required_check`] in this rule. `false` when
226    /// the check is enforced at the namespace level instead (a required
227    /// workflow, [`StepAction::RequireNamespaceWorkflow`]), so this rule
228    /// carries only the PR, force-push and deletion parts.
229    #[serde(default = "yes")]
230    pub require_status_check: bool,
231    /// Pull requests need an approving review, including a code owner's for
232    /// files that have one ([`StepAction::RequireOwnerReview`]).
233    #[serde(default)]
234    pub require_code_owner_review: bool,
235    /// Approving reviews a pull request needs before it can merge, from
236    /// people the forge lets write to the repository — which, under a bridge,
237    /// are the accounts its rights project to (owners and maintainers). An
238    /// approval is dismissed by a later push, and the last push must be
239    /// approved by someone other than its pusher, so a reviewed change cannot
240    /// be swapped after review or approved by its own author. `0`: none
241    /// beyond what [`ProtectionSpec::require_code_owner_review`] asks.
242    #[serde(default, skip_serializing_if = "is_zero")]
243    pub required_approvals: u8,
244    /// Paths (forge glob syntax) a pull request may not change and still
245    /// merge: the workflows and the exempt keyring. Without this a PR could
246    /// rewrite the check it is judged by — CI runs the PR's own copy of the
247    /// workflow — and pass itself. Empty where the forge enforces this some
248    /// other way or not at all.
249    #[serde(default, skip_serializing_if = "Vec::is_empty")]
250    pub protected_paths: Vec<String>,
251}
252
253fn yes() -> bool {
254    true
255}
256
257fn is_zero(n: &u8) -> bool {
258    *n == 0
259}
260
261impl ProtectionSpec {
262    /// The §5.3 protection: PR required, `check` required, no force-push, no
263    /// deletion. There is deliberately no bypass field — the design allows
264    /// no bypass actors, so there is nothing to configure.
265    pub fn standard(check: impl Into<String>) -> Self {
266        ProtectionSpec {
267            required_check: check.into(),
268            require_pull_request: true,
269            block_force_push: true,
270            block_deletion: true,
271            require_status_check: true,
272            require_code_owner_review: false,
273            required_approvals: 0,
274            protected_paths: Vec::new(),
275        }
276    }
277
278    /// Also forbid pull requests that change `paths`.
279    pub fn with_protected_paths<I, S>(mut self, paths: I) -> Self
280    where
281        I: IntoIterator<Item = S>,
282        S: Into<String>,
283    {
284        self.protected_paths = paths.into_iter().map(Into::into).collect();
285        self
286    }
287
288    /// Leave the check out of this rule: a namespace-level required workflow
289    /// enforces it.
290    pub fn with_check_enforced_by_namespace(mut self) -> Self {
291        self.require_status_check = false;
292        self
293    }
294
295    /// Require an approving review, and a code owner's where one is named.
296    pub fn with_code_owner_review(mut self) -> Self {
297        self.require_code_owner_review = true;
298        self
299    }
300
301    /// Require `n` approving reviews ([`ProtectionSpec::required_approvals`]).
302    pub fn with_required_approvals(mut self, n: u8) -> Self {
303        self.required_approvals = n;
304        self
305    }
306
307    /// The approving reviews a pull request needs: the configured count, and
308    /// at least one where an owner's review is required.
309    pub fn approvals_needed(&self) -> u8 {
310        self.required_approvals
311            .max(u8::from(self.require_code_owner_review))
312    }
313}
314
315/// A way a pull request can land on the default branch.
316#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
317#[serde(rename_all = "camelCase")]
318#[non_exhaustive]
319pub enum MergeMethod {
320    /// Fast-forward only: the PR's own commits land unchanged, DID
321    /// signatures and all. The one method that needs no platform key.
322    FastForward,
323    /// A merge commit, made (and signed, if at all) by the forge.
324    MergeCommit,
325    /// The PR's commits re-created on the base by the forge.
326    Rebase,
327    /// Rebase, then a merge commit (Forgejo's `rebase-merge`).
328    RebaseMerge,
329    /// One new commit, made by the forge.
330    Squash,
331}
332
333/// Repository settings a bootstrap enforces alongside the protection.
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(rename_all = "camelCase")]
336#[non_exhaustive]
337pub struct RepoSettings {
338    /// The only merge methods to allow; the first is the default. Empty
339    /// leaves the forge's merge settings alone.
340    pub merge_methods: Vec<MergeMethod>,
341    /// Turn the forge's CI on for the repository. Off, the required check
342    /// never reports and nothing can merge.
343    pub enable_ci: bool,
344}
345
346impl RepoSettings {
347    /// Allow exactly `methods` (the first the default) and enable CI.
348    pub fn merge_methods(methods: impl Into<Vec<MergeMethod>>) -> Self {
349        RepoSettings {
350            merge_methods: methods.into(),
351            enable_ci: true,
352        }
353    }
354}
355
356/// What a step does.
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
358#[serde(rename_all = "camelCase", tag = "type")]
359#[non_exhaustive]
360pub enum StepAction {
361    /// Make a file on the default branch have exactly these contents.
362    WriteFile {
363        /// Repository-relative path.
364        path: String,
365        /// Desired contents.
366        contents: Vec<u8>,
367        /// Commit message if a commit is needed.
368        message: String,
369    },
370    /// Make a CI variable have this value.
371    SetVariable {
372        /// Variable name.
373        name: String,
374        /// Desired value.
375        value: String,
376    },
377    /// Enforce protection on the default branch.
378    ProtectDefaultBranch(ProtectionSpec),
379    /// Run the check from a workflow the namespace holds outside the
380    /// repository, pinned to a revision, and require it on this repository's
381    /// default branch. The change under test cannot alter what checks it
382    /// (§9: the PR must not be able to satisfy its own check).
383    RequireNamespaceWorkflow {
384        /// The workflow's contents.
385        contents: Vec<u8>,
386        /// The check (job) name it reports, for inspection.
387        check: String,
388        /// Commit message if the workflow has to be (re)written.
389        message: String,
390    },
391    /// Make every change to `paths` need an approving review from one of
392    /// `owners` — the fallback where no namespace-level workflow is
393    /// available (§9). The adapter resolves each account's current login at
394    /// run time; the numeric id is what is planned.
395    RequireOwnerReview {
396        /// Repository paths (directories end in `/`), e.g. `/.github/`.
397        paths: Vec<String>,
398        /// Who may approve. Never empty.
399        owners: Vec<ForgeAccount>,
400        /// The community's own owner rules, in the forge's format, kept
401        /// ahead of the managed rule (which therefore wins for `paths`).
402        /// Rules already in the repository take their place when present.
403        #[serde(default, skip_serializing_if = "Vec::is_empty")]
404        community_rules: Vec<u8>,
405        /// Commit message if the rules have to be (re)written.
406        message: String,
407    },
408    /// Make sure a file is absent from the default branch (clean-up after
409    /// a change of guard).
410    RemoveFile {
411        /// Repository-relative path.
412        path: String,
413        /// Commit message if a commit is needed.
414        message: String,
415    },
416    /// Make sure a CI variable is absent.
417    RemoveVariable {
418        /// Variable name.
419        name: String,
420    },
421    /// Make the repository's settings (merge methods, CI) match.
422    ConfigureRepo(RepoSettings),
423    /// Rewrite files the default branch's protection forbids changing — the
424    /// managed workflow, the exempt keyring — through a temporary exception
425    /// for the bridge alone, restoring the protection exactly afterwards
426    /// (and attempting to even when a write failed). A maintenance job, not
427    /// part of a bootstrap: it is the one sanctioned way the bridge changes
428    /// a protected path, so it runs as one audited step.
429    RefreshProtectedFiles {
430        /// The files, each with its desired contents.
431        files: Vec<ExtraFile>,
432        /// Commit message for each file that changes.
433        message: String,
434    },
435}
436
437/// One step of a bootstrap plan.
438#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
439#[serde(rename_all = "camelCase")]
440#[non_exhaustive]
441pub struct BootstrapStep {
442    /// Stable id for progress reporting and retries, e.g. `workflow`,
443    /// `variable:VTC_DID`.
444    pub id: String,
445    /// The status component it satisfies.
446    pub component: BootstrapComponent,
447    /// What to do.
448    pub action: StepAction,
449}
450
451impl BootstrapStep {
452    /// A step.
453    pub fn new(id: impl Into<String>, component: BootstrapComponent, action: StepAction) -> Self {
454        BootstrapStep {
455            id: id.into(),
456            component,
457            action,
458        }
459    }
460}
461
462/// What running one step did.
463#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
464#[serde(rename_all = "camelCase")]
465#[non_exhaustive]
466pub enum StepOutcome {
467    /// Already as desired; nothing written.
468    Unchanged,
469    /// Did not exist; created.
470    Created,
471    /// Existed but differed; corrected.
472    Updated,
473}
474
475/// Result of [`run_plan`]. In-process only (it carries [`ForgeError`]); the
476/// bridge reports it to the VTC in its own job-result shape.
477#[derive(Debug, Clone, Default, PartialEq, Eq)]
478#[non_exhaustive]
479pub struct BootstrapReport {
480    /// Steps that ran, with their outcome, in order.
481    pub completed: Vec<(String, StepOutcome)>,
482    /// The step that failed, and why; the rest did not run.
483    pub failed: Option<(String, ForgeError)>,
484    /// Ids of steps not attempted because an earlier one failed.
485    pub not_run: Vec<String>,
486}
487
488impl BootstrapReport {
489    /// Whether every step completed.
490    pub fn is_complete(&self) -> bool {
491        self.failed.is_none()
492    }
493}
494
495/// Run `steps` in order against `repo`, stopping at the first failure.
496///
497/// Stopping is the point: a later step (protection) can lock out an earlier
498/// one (files), so running past a failure could leave a repo protected
499/// before its workflow exists — a required check that can never report.
500pub async fn run_plan(
501    forge: &dyn Forge,
502    repo: &Resource,
503    steps: &[BootstrapStep],
504) -> BootstrapReport {
505    let mut report = BootstrapReport::default();
506    for (i, step) in steps.iter().enumerate() {
507        match forge.run_step(repo, step).await {
508            Ok(outcome) => report.completed.push((step.id.clone(), outcome)),
509            Err(e) => {
510                report.failed = Some((step.id.clone(), e));
511                report.not_run = steps[i + 1..].iter().map(|s| s.id.clone()).collect();
512                break;
513            }
514        }
515    }
516    report
517}
518
519/// Validate a repository-relative path for a [`StepAction::WriteFile`]: no
520/// absolute paths, no empty, `.` or `..` segments, no backslashes. Adapters
521/// call this before building a URL from it.
522pub fn validate_repo_path(path: &str) -> Result<()> {
523    let bad = path.is_empty()
524        || path.starts_with('/')
525        || path.contains('\\')
526        || path
527            .split('/')
528            .any(|s| s.is_empty() || s == "." || s == ".." || s.chars().any(char::is_control));
529    if bad {
530        return Err(ForgeError::Config(format!(
531            "`{path}` is not a clean repository-relative path (no leading `/`, no empty, `.` or \
532             `..` segments)"
533        )));
534    }
535    Ok(())
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    #[test]
543    fn repo_paths_are_checked() {
544        assert!(validate_repo_path(".github/workflows/verify-trust.yml").is_ok());
545        assert!(validate_repo_path("CODEOWNERS").is_ok());
546        for bad in ["", "/etc/x", "a//b", "a/../b", "./a", "a\\b", "a/\n"] {
547            assert!(validate_repo_path(bad).is_err(), "{bad:?}");
548        }
549    }
550}