use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use serde_json::{Map, Value};
use crate::cargo::Lints;
use crate::workflow::{Call, Reference, SharedWorkflow};
const BUILT_IN_RULESETS: &[(&str, &str)] = &[
(
"protect-default-branch",
include_str!("../rulesets/protect-default-branch.json"),
),
("require-pr", include_str!("../rulesets/require-pr.json")),
(
"require-pr-title",
include_str!("../rulesets/require-pr-title.json"),
),
(
"protect-release-tags",
include_str!("../rulesets/protect-release-tags.json"),
),
];
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawManifest {
owner: String,
#[serde(default)]
rulesets: Vec<String>,
#[serde(default)]
settings: Map<String, Value>,
#[serde(default)]
actions: Map<String, Value>,
release_tag_prefix: Option<String>,
#[serde(default)]
workflows: BTreeMap<String, String>,
#[serde(default)]
tags: BTreeMap<String, RawTag>,
#[serde(default)]
repos: BTreeMap<String, RawRepo>,
#[serde(default)]
stale: Stale,
}
#[derive(Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Stale {
#[serde(default)]
pub ignore_labels: Vec<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawTag {
#[serde(default)]
variables: BTreeMap<String, String>,
#[serde(default)]
secrets: BTreeMap<String, String>,
#[serde(default)]
forbidden_secrets: Vec<String>,
#[serde(default)]
environments: BTreeMap<String, Environment>,
#[serde(default)]
rulesets: Vec<String>,
#[serde(default)]
calls: Vec<String>,
#[serde(default)]
settings: Map<String, Value>,
#[serde(default)]
needs_public: bool,
#[serde(default)]
lints: Lints,
#[serde(default)]
dockerfile_runs: Vec<String>,
}
#[derive(Deserialize, Clone, Default)]
#[serde(deny_unknown_fields)]
pub struct Environment {
#[serde(default)]
pub deploy_tags: Vec<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawRepo {
#[serde(default)]
tags: Vec<String>,
rulesets: Option<Vec<String>>,
#[serde(default)]
release_targets: Vec<String>,
release_tag_prefix: Option<String>,
#[serde(default = "yes")]
releases: bool,
#[serde(default)]
settings: Map<String, Value>,
#[serde(default)]
actions: Map<String, Value>,
}
pub struct Manifest {
pub owner: String,
pub repos: Vec<Repo>,
pub workflows: Vec<SharedWorkflow>,
workflow_refs: Vec<(String, String)>,
pub stale: Stale,
tags: BTreeMap<String, Tag>,
}
pub struct Repo {
pub name: String,
pub tags: Vec<String>,
settings: Map<String, Value>,
own_settings: Map<String, Value>,
pub actions: Actions,
pub rulesets: Vec<Ruleset>,
pub release_tag_prefix: Option<String>,
pub release_targets: Vec<String>,
}
#[derive(Default)]
pub struct Actions {
pub permissions: Map<String, Value>,
pub workflow: Map<String, Value>,
pub allowlist: Map<String, Value>,
}
struct Tag {
variables: BTreeMap<String, String>,
secrets: BTreeMap<String, PathBuf>,
forbidden_secrets: Vec<String>,
environments: BTreeMap<String, Environment>,
rulesets: Vec<Ruleset>,
calls: Vec<String>,
settings: Map<String, Value>,
needs_public: bool,
lints: Lints,
dockerfile_runs: Vec<String>,
}
#[derive(Default)]
pub struct Needs {
pub tags: Vec<String>,
pub visibility_problems: Vec<String>,
pub settings: Map<String, Value>,
pub variables: BTreeMap<String, String>,
pub secrets: BTreeMap<String, PathBuf>,
pub forbidden_secrets: Vec<String>,
pub environments: BTreeMap<String, Environment>,
pub rulesets: Vec<Ruleset>,
pub calls: Vec<String>,
pub release_targets: Vec<String>,
pub lints: Lints,
pub dockerfile_runs: Vec<String>,
}
#[derive(Clone)]
pub struct Ruleset {
pub name: String,
pub body: Value,
}
impl Manifest {
pub fn load(path: &Path) -> Result<Self> {
let text =
fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
let raw: RawManifest =
toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
let dir = path.parent().unwrap_or_else(|| Path::new("."));
let rulesets_dir = dir.join("rulesets");
let mut loaded: BTreeMap<String, Ruleset> = BTreeMap::new();
let mut load_ruleset = |name: &str| -> Result<Ruleset> {
if let Some(ruleset) = loaded.get(name) {
return Ok(ruleset.clone());
}
let ruleset = Ruleset::read(&rulesets_dir, name)?;
loaded.insert(name.to_owned(), ruleset.clone());
Ok(ruleset)
};
let mut repos = Vec::new();
for (name, repo) in raw.repos {
let actions = Actions::merge(raw.actions.clone(), repo.actions)
.with_context(|| format!("repos.{name}.actions"))?;
let rulesets = repo
.rulesets
.as_ref()
.unwrap_or(&raw.rulesets)
.iter()
.map(|r| load_ruleset(r))
.collect::<Result<_>>()?;
repos.push(Repo {
name,
tags: repo.tags,
settings: raw.settings.clone(),
own_settings: repo.settings,
actions,
rulesets,
release_tag_prefix: if repo.releases {
repo.release_tag_prefix
.or_else(|| raw.release_tag_prefix.clone())
} else {
None
},
release_targets: if repo.releases {
repo.release_targets
} else {
Vec::new()
},
});
}
let tags: BTreeMap<String, Tag> = raw
.tags
.into_iter()
.map(|(name, tag)| {
let tag = Tag {
rulesets: tag
.rulesets
.iter()
.map(|r| load_ruleset(r))
.collect::<Result<_>>()?,
variables: tag.variables,
secrets: tag
.secrets
.iter()
.map(|(k, v)| (k.clone(), expand_home(v)))
.collect(),
forbidden_secrets: tag.forbidden_secrets,
environments: tag.environments,
calls: tag.calls,
settings: tag.settings,
needs_public: tag.needs_public,
lints: tag.lints,
dockerfile_runs: tag.dockerfile_runs,
};
Ok((name, tag))
})
.collect::<Result<_>>()?;
let names = raw
.workflows
.values()
.map(|uses| Ok(Reference::parse(uses)?.name().to_owned()))
.collect::<Result<Vec<_>>>()?;
for (name, tag) in &tags {
for call in &tag.calls {
if !names.contains(call) {
bail!("tag {name} calls {call}, which isn't in [workflows]");
}
}
}
Ok(Self {
owner: raw.owner,
repos,
workflows: Vec::new(),
workflow_refs: raw.workflows.into_iter().collect(),
stale: raw.stale,
tags,
})
}
pub fn fetch_workflows(
&mut self,
read: impl Fn(&str, &str) -> Result<SharedWorkflow>,
) -> Result<()> {
self.workflows = self
.workflow_refs
.iter()
.map(|(caller, uses)| read(caller, uses))
.collect::<Result<_>>()?;
Ok(())
}
pub fn needs(&self, repo: &Repo, calls: &[Option<Call>], public: bool) -> Needs {
let mut needs = Needs {
settings: repo.settings.clone(),
rulesets: repo.rulesets.clone(),
..Needs::default()
};
let add_tag = |needs: &mut Needs, tag: &str| {
if !needs.tags.iter().any(|t| t == tag) {
needs.tags.push(tag.to_owned());
}
};
for tag in &repo.tags {
add_tag(&mut needs, tag);
}
for (workflow, call) in self.workflows.iter().zip(calls) {
if let Some(call) = call {
add_tag(&mut needs, &workflow.name);
for input in call.enabled() {
add_tag(&mut needs, input);
}
}
}
for (name, _) in self.tags.iter().filter(|(_, t)| t.needs_public) {
let tagged = needs.tags.contains(name);
if tagged && !public {
needs.visibility_problems.push(format!(
"tag {name}: only works on public repos, and this one is private"
));
needs.tags.retain(|t| t != name);
} else if !tagged && public && !needs.tags.contains(&format!("no-{name}")) {
needs.visibility_problems.push(format!(
"public, so it can have {name}: add the {name} tag, or no-{name} to hide this"
));
}
}
for tag in needs.tags.clone().iter().filter_map(|t| self.tags.get(t)) {
needs.variables.extend(tag.variables.clone());
needs.secrets.extend(tag.secrets.clone());
push_new(&mut needs.forbidden_secrets, &tag.forbidden_secrets);
push_new(&mut needs.calls, &tag.calls);
push_new(&mut needs.dockerfile_runs, &tag.dockerfile_runs);
needs.settings.extend(tag.settings.clone());
for (name, environment) in &tag.environments {
let needed = needs.environments.entry(name.clone()).or_default();
push_new(&mut needed.deploy_tags, &environment.deploy_tags);
}
for ruleset in &tag.rulesets {
if !needs.rulesets.iter().any(|r| r.name == ruleset.name) {
needs.rulesets.push(ruleset.clone());
}
}
for (group, lints) in &tag.lints {
needs
.lints
.entry(group.clone())
.or_default()
.extend(lints.clone());
}
}
needs.settings.extend(repo.own_settings.clone());
needs
.calls
.retain(|call| !needs.tags.contains(&format!("no-{call}")));
if repo.release_tag_prefix.is_some() {
push_new(&mut needs.release_targets, &repo.release_targets);
for (workflow, call) in self.workflows.iter().zip(calls) {
if let Some(call) = call {
push_new(&mut needs.release_targets, &workflow.targets(call));
}
}
}
needs
}
}
impl Actions {
fn merge(top: Map<String, Value>, own: Map<String, Value>) -> Result<Self> {
let mut actions = Self::split(top)?;
let own = Self::split(own)?;
actions.permissions.extend(own.permissions);
actions.workflow.extend(own.workflow);
actions.allowlist.extend(own.allowlist);
Ok(actions)
}
fn split(mut permissions: Map<String, Value>) -> Result<Self> {
let mut table = |key: &str| match permissions.remove(key) {
None => Ok(Map::new()),
Some(Value::Object(table)) => Ok(table),
Some(_) => bail!("actions.{key} must be a table"),
};
let workflow = table("workflow")?;
let allowlist = table("selected-actions")?;
Ok(Self {
permissions,
workflow,
allowlist,
})
}
}
impl Ruleset {
fn read(dir: &Path, name: &str) -> Result<Self> {
let file = dir.join(format!("{name}.json"));
let (text, source) = if file.exists() {
let text =
fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;
(text, file.display().to_string())
} else if let Some((_, text)) = BUILT_IN_RULESETS.iter().find(|(n, _)| *n == name) {
((*text).to_owned(), format!("built-in ruleset {name}"))
} else {
let built_in: Vec<_> = BUILT_IN_RULESETS.iter().map(|(n, _)| *n).collect();
bail!(
"no ruleset {name}: not in {} and not built in ({})",
dir.display(),
built_in.join(", ")
);
};
let mut body: Value =
serde_json::from_str(&text).with_context(|| format!("parsing {source}"))?;
body.as_object_mut()
.with_context(|| format!("{source} is not a JSON object"))?
.insert("name".into(), name.into());
Ok(Self {
name: name.into(),
body,
})
}
}
fn push_new(list: &mut Vec<String>, items: &[String]) {
for item in items {
if !list.contains(item) {
list.push(item.clone());
}
}
}
fn expand_home(path: &str) -> PathBuf {
match (path.strip_prefix("~/"), std::env::var_os("HOME")) {
(Some(rest), Some(home)) => Path::new(&home).join(rest),
_ => PathBuf::from(path),
}
}
const fn yes() -> bool {
true
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn map(value: Value) -> Map<String, Value> {
match value {
Value::Object(map) => map,
_ => panic!("not an object"),
}
}
#[test]
fn reads_built_in_and_custom_rulesets() {
let dir = std::env::temp_dir().join(format!("forge-rulesets-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("mine.json"), r#"{"target": "tag"}"#).unwrap();
let mine = Ruleset::read(&dir, "mine").unwrap();
let built_in = Ruleset::read(&dir, "require-pr").unwrap();
let missing = Ruleset::read(&dir, "nope").map(|_| ()).unwrap_err();
fs::remove_dir_all(&dir).unwrap();
assert_eq!(mine.body, json!({ "target": "tag", "name": "mine" }));
assert_eq!(built_in.body["name"], "require-pr");
assert_eq!(built_in.body["target"], "branch");
assert!(
missing.to_string().contains("require-pr-title"),
"{missing}"
);
}
#[test]
fn splits_actions_by_endpoint() {
let top = map(json!({
"allowed_actions": "selected",
"workflow": { "default_workflow_permissions": "read" },
"selected-actions": { "patterns_allowed": ["a/*"] },
}));
let own = map(json!({ "workflow": { "can_approve_pull_request_reviews": true } }));
let actions = Actions::merge(top, own).unwrap();
assert_eq!(
actions.permissions,
map(json!({ "allowed_actions": "selected" }))
);
assert_eq!(
actions.workflow,
map(json!({
"default_workflow_permissions": "read",
"can_approve_pull_request_reviews": true,
}))
);
assert_eq!(actions.allowlist["patterns_allowed"], json!(["a/*"]));
}
}