use serde::{Deserialize, Serialize};
use crate::error::{ForgeError, Result};
use crate::forge::Forge;
use crate::model::ForgeAccount;
use crate::resource::Resource;
pub const DEFAULT_REQUIRED_CHECK: &str = "Verify commit trust";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct VgiConfig {
pub trust_registry_did: String,
pub vtc_did: String,
pub verify_trust_action: String,
pub verify_trust_version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verify_trust_sha256: Option<String>,
pub required_check: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub platform_keyring: Option<Vec<u8>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extra_files: Vec<ExtraFile>,
}
impl VgiConfig {
pub fn new(
trust_registry_did: impl Into<String>,
vtc_did: impl Into<String>,
verify_trust_action: impl Into<String>,
verify_trust_version: impl Into<String>,
) -> Self {
VgiConfig {
trust_registry_did: trust_registry_did.into(),
vtc_did: vtc_did.into(),
verify_trust_action: verify_trust_action.into(),
verify_trust_version: verify_trust_version.into(),
verify_trust_sha256: None,
required_check: DEFAULT_REQUIRED_CHECK.into(),
platform_keyring: None,
extra_files: Vec::new(),
}
}
pub fn with_verify_trust_sha256(mut self, sha256: impl Into<String>) -> Self {
self.verify_trust_sha256 = Some(sha256.into());
self
}
pub fn with_platform_keyring(mut self, armored: impl Into<Vec<u8>>) -> Self {
self.platform_keyring = Some(armored.into());
self
}
pub fn with_extra_file(
mut self,
path: impl Into<String>,
contents: impl Into<Vec<u8>>,
) -> Self {
self.extra_files.push(ExtraFile {
path: path.into(),
contents: contents.into(),
});
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExtraFile {
pub path: String,
pub contents: Vec<u8>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub enum BootstrapComponent {
Workflow,
Keyring,
Variables,
RequiredCheck,
Extra,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ProtectionSpec {
pub required_check: String,
pub require_pull_request: bool,
pub block_force_push: bool,
pub block_deletion: bool,
#[serde(default = "yes")]
pub require_status_check: bool,
#[serde(default)]
pub require_code_owner_review: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub protected_paths: Vec<String>,
}
fn yes() -> bool {
true
}
impl ProtectionSpec {
pub fn standard(check: impl Into<String>) -> Self {
ProtectionSpec {
required_check: check.into(),
require_pull_request: true,
block_force_push: true,
block_deletion: true,
require_status_check: true,
require_code_owner_review: false,
protected_paths: Vec::new(),
}
}
pub fn with_protected_paths<I, S>(mut self, paths: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.protected_paths = paths.into_iter().map(Into::into).collect();
self
}
pub fn with_check_enforced_by_namespace(mut self) -> Self {
self.require_status_check = false;
self
}
pub fn with_code_owner_review(mut self) -> Self {
self.require_code_owner_review = true;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub enum MergeMethod {
FastForward,
MergeCommit,
Rebase,
RebaseMerge,
Squash,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct RepoSettings {
pub merge_methods: Vec<MergeMethod>,
pub enable_ci: bool,
}
impl RepoSettings {
pub fn merge_methods(methods: impl Into<Vec<MergeMethod>>) -> Self {
RepoSettings {
merge_methods: methods.into(),
enable_ci: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
#[non_exhaustive]
pub enum StepAction {
WriteFile {
path: String,
contents: Vec<u8>,
message: String,
},
SetVariable {
name: String,
value: String,
},
ProtectDefaultBranch(ProtectionSpec),
RequireNamespaceWorkflow {
contents: Vec<u8>,
check: String,
message: String,
},
RequireOwnerReview {
paths: Vec<String>,
owners: Vec<ForgeAccount>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
community_rules: Vec<u8>,
message: String,
},
RemoveFile {
path: String,
message: String,
},
RemoveVariable {
name: String,
},
ConfigureRepo(RepoSettings),
RefreshProtectedFiles {
files: Vec<ExtraFile>,
message: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct BootstrapStep {
pub id: String,
pub component: BootstrapComponent,
pub action: StepAction,
}
impl BootstrapStep {
pub fn new(id: impl Into<String>, component: BootstrapComponent, action: StepAction) -> Self {
BootstrapStep {
id: id.into(),
component,
action,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub enum StepOutcome {
Unchanged,
Created,
Updated,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct BootstrapReport {
pub completed: Vec<(String, StepOutcome)>,
pub failed: Option<(String, ForgeError)>,
pub not_run: Vec<String>,
}
impl BootstrapReport {
pub fn is_complete(&self) -> bool {
self.failed.is_none()
}
}
pub async fn run_plan(
forge: &dyn Forge,
repo: &Resource,
steps: &[BootstrapStep],
) -> BootstrapReport {
let mut report = BootstrapReport::default();
for (i, step) in steps.iter().enumerate() {
match forge.run_step(repo, step).await {
Ok(outcome) => report.completed.push((step.id.clone(), outcome)),
Err(e) => {
report.failed = Some((step.id.clone(), e));
report.not_run = steps[i + 1..].iter().map(|s| s.id.clone()).collect();
break;
}
}
}
report
}
pub fn validate_repo_path(path: &str) -> Result<()> {
let bad = path.is_empty()
|| path.starts_with('/')
|| path.contains('\\')
|| path
.split('/')
.any(|s| s.is_empty() || s == "." || s == ".." || s.chars().any(char::is_control));
if bad {
return Err(ForgeError::Config(format!(
"`{path}` is not a clean repository-relative path (no leading `/`, no empty, `.` or \
`..` segments)"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn repo_paths_are_checked() {
assert!(validate_repo_path(".github/workflows/verify-trust.yml").is_ok());
assert!(validate_repo_path("CODEOWNERS").is_ok());
for bad in ["", "/etc/x", "a//b", "a/../b", "./a", "a\\b", "a/\n"] {
assert!(validate_repo_path(bad).is_err(), "{bad:?}");
}
}
}