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