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}
119
120impl VgiConfig {
121 /// A config with the default check name and no keyring or extra files.
122 pub fn new(
123 trust_registry_did: impl Into<String>,
124 vtc_did: impl Into<String>,
125 verify_trust_action: impl Into<String>,
126 verify_trust_version: impl Into<String>,
127 ) -> Self {
128 VgiConfig {
129 trust_registry_did: trust_registry_did.into(),
130 vtc_did: vtc_did.into(),
131 verify_trust_action: verify_trust_action.into(),
132 verify_trust_version: verify_trust_version.into(),
133 verify_trust_sha256: None,
134 required_check: DEFAULT_REQUIRED_CHECK.into(),
135 platform_keyring: None,
136 extra_files: Vec::new(),
137 verify_trust_transport: VerifyTransport::Auto,
138 }
139 }
140
141 /// Pin the registry binding the workflow uses.
142 pub fn with_verify_trust_transport(mut self, transport: VerifyTransport) -> Self {
143 self.verify_trust_transport = transport;
144 self
145 }
146
147 /// Pin the release tarball's SHA-256.
148 pub fn with_verify_trust_sha256(mut self, sha256: impl Into<String>) -> Self {
149 self.verify_trust_sha256 = Some(sha256.into());
150 self
151 }
152
153 /// Set the platform keyring.
154 pub fn with_platform_keyring(mut self, armored: impl Into<Vec<u8>>) -> Self {
155 self.platform_keyring = Some(armored.into());
156 self
157 }
158
159 /// Add a community file.
160 pub fn with_extra_file(
161 mut self,
162 path: impl Into<String>,
163 contents: impl Into<Vec<u8>>,
164 ) -> Self {
165 self.extra_files.push(ExtraFile {
166 path: path.into(),
167 contents: contents.into(),
168 });
169 self
170 }
171}
172
173/// A community-supplied file to commit during bootstrap.
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "camelCase")]
176pub struct ExtraFile {
177 /// Repository-relative path.
178 pub path: String,
179 /// Contents.
180 pub contents: Vec<u8>,
181}
182
183/// Which part of the VTC's bootstrap status (§4.3 `bootstrap`) a step
184/// satisfies — the four dots on the Repos page.
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
186#[serde(rename_all = "camelCase")]
187#[non_exhaustive]
188pub enum BootstrapComponent {
189 /// The verify-trust workflow.
190 Workflow,
191 /// The exempt platform keyring.
192 Keyring,
193 /// The `TRUST_REGISTRY_DID` / `VTC_DID` variables.
194 Variables,
195 /// The protection that requires the check.
196 RequiredCheck,
197 /// Anything else (community files, forge-specific settings).
198 Extra,
199}
200
201/// Branch protection to enforce on the default branch.
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(rename_all = "camelCase")]
204#[non_exhaustive]
205pub struct ProtectionSpec {
206 /// The status check that must pass.
207 pub required_check: String,
208 /// Changes must come through a pull request.
209 pub require_pull_request: bool,
210 /// Block force-pushes.
211 pub block_force_push: bool,
212 /// Block deletion.
213 pub block_deletion: bool,
214 /// Require [`ProtectionSpec::required_check`] in this rule. `false` when
215 /// the check is enforced at the namespace level instead (a required
216 /// workflow, [`StepAction::RequireNamespaceWorkflow`]), so this rule
217 /// carries only the PR, force-push and deletion parts.
218 #[serde(default = "yes")]
219 pub require_status_check: bool,
220 /// Pull requests need an approving review, including a code owner's for
221 /// files that have one ([`StepAction::RequireOwnerReview`]).
222 #[serde(default)]
223 pub require_code_owner_review: bool,
224 /// Paths (forge glob syntax) a pull request may not change and still
225 /// merge: the workflows and the exempt keyring. Without this a PR could
226 /// rewrite the check it is judged by — CI runs the PR's own copy of the
227 /// workflow — and pass itself. Empty where the forge enforces this some
228 /// other way or not at all.
229 #[serde(default, skip_serializing_if = "Vec::is_empty")]
230 pub protected_paths: Vec<String>,
231}
232
233fn yes() -> bool {
234 true
235}
236
237impl ProtectionSpec {
238 /// The §5.3 protection: PR required, `check` required, no force-push, no
239 /// deletion. There is deliberately no bypass field — the design allows
240 /// no bypass actors, so there is nothing to configure.
241 pub fn standard(check: impl Into<String>) -> Self {
242 ProtectionSpec {
243 required_check: check.into(),
244 require_pull_request: true,
245 block_force_push: true,
246 block_deletion: true,
247 require_status_check: true,
248 require_code_owner_review: false,
249 protected_paths: Vec::new(),
250 }
251 }
252
253 /// Also forbid pull requests that change `paths`.
254 pub fn with_protected_paths<I, S>(mut self, paths: I) -> Self
255 where
256 I: IntoIterator<Item = S>,
257 S: Into<String>,
258 {
259 self.protected_paths = paths.into_iter().map(Into::into).collect();
260 self
261 }
262
263 /// Leave the check out of this rule: a namespace-level required workflow
264 /// enforces it.
265 pub fn with_check_enforced_by_namespace(mut self) -> Self {
266 self.require_status_check = false;
267 self
268 }
269
270 /// Require an approving review, and a code owner's where one is named.
271 pub fn with_code_owner_review(mut self) -> Self {
272 self.require_code_owner_review = true;
273 self
274 }
275}
276
277/// A way a pull request can land on the default branch.
278#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
279#[serde(rename_all = "camelCase")]
280#[non_exhaustive]
281pub enum MergeMethod {
282 /// Fast-forward only: the PR's own commits land unchanged, DID
283 /// signatures and all. The one method that needs no platform key.
284 FastForward,
285 /// A merge commit, made (and signed, if at all) by the forge.
286 MergeCommit,
287 /// The PR's commits re-created on the base by the forge.
288 Rebase,
289 /// Rebase, then a merge commit (Forgejo's `rebase-merge`).
290 RebaseMerge,
291 /// One new commit, made by the forge.
292 Squash,
293}
294
295/// Repository settings a bootstrap enforces alongside the protection.
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
297#[serde(rename_all = "camelCase")]
298#[non_exhaustive]
299pub struct RepoSettings {
300 /// The only merge methods to allow; the first is the default. Empty
301 /// leaves the forge's merge settings alone.
302 pub merge_methods: Vec<MergeMethod>,
303 /// Turn the forge's CI on for the repository. Off, the required check
304 /// never reports and nothing can merge.
305 pub enable_ci: bool,
306}
307
308impl RepoSettings {
309 /// Allow exactly `methods` (the first the default) and enable CI.
310 pub fn merge_methods(methods: impl Into<Vec<MergeMethod>>) -> Self {
311 RepoSettings {
312 merge_methods: methods.into(),
313 enable_ci: true,
314 }
315 }
316}
317
318/// What a step does.
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320#[serde(rename_all = "camelCase", tag = "type")]
321#[non_exhaustive]
322pub enum StepAction {
323 /// Make a file on the default branch have exactly these contents.
324 WriteFile {
325 /// Repository-relative path.
326 path: String,
327 /// Desired contents.
328 contents: Vec<u8>,
329 /// Commit message if a commit is needed.
330 message: String,
331 },
332 /// Make a CI variable have this value.
333 SetVariable {
334 /// Variable name.
335 name: String,
336 /// Desired value.
337 value: String,
338 },
339 /// Enforce protection on the default branch.
340 ProtectDefaultBranch(ProtectionSpec),
341 /// Run the check from a workflow the namespace holds outside the
342 /// repository, pinned to a revision, and require it on this repository's
343 /// default branch. The change under test cannot alter what checks it
344 /// (§9: the PR must not be able to satisfy its own check).
345 RequireNamespaceWorkflow {
346 /// The workflow's contents.
347 contents: Vec<u8>,
348 /// The check (job) name it reports, for inspection.
349 check: String,
350 /// Commit message if the workflow has to be (re)written.
351 message: String,
352 },
353 /// Make every change to `paths` need an approving review from one of
354 /// `owners` — the fallback where no namespace-level workflow is
355 /// available (§9). The adapter resolves each account's current login at
356 /// run time; the numeric id is what is planned.
357 RequireOwnerReview {
358 /// Repository paths (directories end in `/`), e.g. `/.github/`.
359 paths: Vec<String>,
360 /// Who may approve. Never empty.
361 owners: Vec<ForgeAccount>,
362 /// The community's own owner rules, in the forge's format, kept
363 /// ahead of the managed rule (which therefore wins for `paths`).
364 /// Rules already in the repository take their place when present.
365 #[serde(default, skip_serializing_if = "Vec::is_empty")]
366 community_rules: Vec<u8>,
367 /// Commit message if the rules have to be (re)written.
368 message: String,
369 },
370 /// Make sure a file is absent from the default branch (clean-up after
371 /// a change of guard).
372 RemoveFile {
373 /// Repository-relative path.
374 path: String,
375 /// Commit message if a commit is needed.
376 message: String,
377 },
378 /// Make sure a CI variable is absent.
379 RemoveVariable {
380 /// Variable name.
381 name: String,
382 },
383 /// Make the repository's settings (merge methods, CI) match.
384 ConfigureRepo(RepoSettings),
385 /// Rewrite files the default branch's protection forbids changing — the
386 /// managed workflow, the exempt keyring — through a temporary exception
387 /// for the bridge alone, restoring the protection exactly afterwards
388 /// (and attempting to even when a write failed). A maintenance job, not
389 /// part of a bootstrap: it is the one sanctioned way the bridge changes
390 /// a protected path, so it runs as one audited step.
391 RefreshProtectedFiles {
392 /// The files, each with its desired contents.
393 files: Vec<ExtraFile>,
394 /// Commit message for each file that changes.
395 message: String,
396 },
397}
398
399/// One step of a bootstrap plan.
400#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
401#[serde(rename_all = "camelCase")]
402#[non_exhaustive]
403pub struct BootstrapStep {
404 /// Stable id for progress reporting and retries, e.g. `workflow`,
405 /// `variable:VTC_DID`.
406 pub id: String,
407 /// The status component it satisfies.
408 pub component: BootstrapComponent,
409 /// What to do.
410 pub action: StepAction,
411}
412
413impl BootstrapStep {
414 /// A step.
415 pub fn new(id: impl Into<String>, component: BootstrapComponent, action: StepAction) -> Self {
416 BootstrapStep {
417 id: id.into(),
418 component,
419 action,
420 }
421 }
422}
423
424/// What running one step did.
425#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
426#[serde(rename_all = "camelCase")]
427#[non_exhaustive]
428pub enum StepOutcome {
429 /// Already as desired; nothing written.
430 Unchanged,
431 /// Did not exist; created.
432 Created,
433 /// Existed but differed; corrected.
434 Updated,
435}
436
437/// Result of [`run_plan`]. In-process only (it carries [`ForgeError`]); the
438/// bridge reports it to the VTC in its own job-result shape.
439#[derive(Debug, Clone, Default, PartialEq, Eq)]
440#[non_exhaustive]
441pub struct BootstrapReport {
442 /// Steps that ran, with their outcome, in order.
443 pub completed: Vec<(String, StepOutcome)>,
444 /// The step that failed, and why; the rest did not run.
445 pub failed: Option<(String, ForgeError)>,
446 /// Ids of steps not attempted because an earlier one failed.
447 pub not_run: Vec<String>,
448}
449
450impl BootstrapReport {
451 /// Whether every step completed.
452 pub fn is_complete(&self) -> bool {
453 self.failed.is_none()
454 }
455}
456
457/// Run `steps` in order against `repo`, stopping at the first failure.
458///
459/// Stopping is the point: a later step (protection) can lock out an earlier
460/// one (files), so running past a failure could leave a repo protected
461/// before its workflow exists — a required check that can never report.
462pub async fn run_plan(
463 forge: &dyn Forge,
464 repo: &Resource,
465 steps: &[BootstrapStep],
466) -> BootstrapReport {
467 let mut report = BootstrapReport::default();
468 for (i, step) in steps.iter().enumerate() {
469 match forge.run_step(repo, step).await {
470 Ok(outcome) => report.completed.push((step.id.clone(), outcome)),
471 Err(e) => {
472 report.failed = Some((step.id.clone(), e));
473 report.not_run = steps[i + 1..].iter().map(|s| s.id.clone()).collect();
474 break;
475 }
476 }
477 }
478 report
479}
480
481/// Validate a repository-relative path for a [`StepAction::WriteFile`]: no
482/// absolute paths, no empty, `.` or `..` segments, no backslashes. Adapters
483/// call this before building a URL from it.
484pub fn validate_repo_path(path: &str) -> Result<()> {
485 let bad = path.is_empty()
486 || path.starts_with('/')
487 || path.contains('\\')
488 || path
489 .split('/')
490 .any(|s| s.is_empty() || s == "." || s == ".." || s.chars().any(char::is_control));
491 if bad {
492 return Err(ForgeError::Config(format!(
493 "`{path}` is not a clean repository-relative path (no leading `/`, no empty, `.` or \
494 `..` segments)"
495 )));
496 }
497 Ok(())
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503
504 #[test]
505 fn repo_paths_are_checked() {
506 assert!(validate_repo_path(".github/workflows/verify-trust.yml").is_ok());
507 assert!(validate_repo_path("CODEOWNERS").is_ok());
508 for bad in ["", "/etc/x", "a//b", "a/../b", "./a", "a\\b", "a/\n"] {
509 assert!(validate_repo_path(bad).is_err(), "{bad:?}");
510 }
511 }
512}