use super::report::Report;
use crate::config::{IgnoreTarget, Level, Lint as LintConfig};
use crate::domain::lock::Lock;
use crate::domain::manifest::Manifest;
use crate::domain::workflow_actions::{
ActionSet as WorkflowActionSet, JobId, Located as LocatedAction, StepIndex, WorkflowPath,
};
use crate::domain::workflow_parsed::Parsed as ParsedWorkflow;
use serde::{Deserialize, Serialize};
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RuleName {
ShaMismatch,
Unpinned,
StaleComment,
UnsyncedManifest,
MissingPermissions,
ExcessivePermissions,
DangerousTrigger,
PrHeadCheckout,
MissingConcurrency,
UnprotectedSecrets,
DanglingReference,
InvalidExpression,
RunShellcheck,
}
impl std::fmt::Display for RuleName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ShaMismatch => write!(f, "sha-mismatch"),
Self::Unpinned => write!(f, "unpinned"),
Self::StaleComment => write!(f, "stale-comment"),
Self::UnsyncedManifest => write!(f, "unsynced-manifest"),
Self::MissingPermissions => write!(f, "missing-permissions"),
Self::ExcessivePermissions => write!(f, "excessive-permissions"),
Self::DangerousTrigger => write!(f, "dangerous-trigger"),
Self::PrHeadCheckout => write!(f, "pr-head-checkout"),
Self::MissingConcurrency => write!(f, "missing-concurrency"),
Self::UnprotectedSecrets => write!(f, "unprotected-secrets"),
Self::DanglingReference => write!(f, "dangling-reference"),
Self::InvalidExpression => write!(f, "invalid-expression"),
Self::RunShellcheck => write!(f, "run-shellcheck"),
}
}
}
impl FromStr for RuleName {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"sha-mismatch" => Ok(Self::ShaMismatch),
"unpinned" => Ok(Self::Unpinned),
"stale-comment" => Ok(Self::StaleComment),
"unsynced-manifest" => Ok(Self::UnsyncedManifest),
"missing-permissions" => Ok(Self::MissingPermissions),
"excessive-permissions" => Ok(Self::ExcessivePermissions),
"dangerous-trigger" => Ok(Self::DangerousTrigger),
"pr-head-checkout" => Ok(Self::PrHeadCheckout),
"missing-concurrency" => Ok(Self::MissingConcurrency),
"unprotected-secrets" => Ok(Self::UnprotectedSecrets),
"dangling-reference" => Ok(Self::DanglingReference),
"invalid-expression" => Ok(Self::InvalidExpression),
"run-shellcheck" => Ok(Self::RunShellcheck),
other => Err(format!("unrecognized rule name: {other}")),
}
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct Diagnostic {
pub rule: RuleName,
pub level: Level,
pub message: String,
pub workflow: Option<WorkflowPath>,
pub job: Option<JobId>,
pub step: Option<StepIndex>,
}
impl Diagnostic {
pub fn new<S: Into<String>>(rule: RuleName, level: Level, message: S) -> Self {
Self {
rule,
level,
message: message.into(),
workflow: None,
job: None,
step: None,
}
}
#[must_use]
pub fn with_workflow(mut self, workflow: WorkflowPath) -> Self {
self.workflow = Some(workflow);
self
}
#[must_use]
pub fn with_job(mut self, job: JobId) -> Self {
self.job = Some(job);
self
}
#[must_use]
pub fn with_step(mut self, step: StepIndex) -> Self {
self.step = Some(step);
self
}
}
pub struct Context<'ctx> {
pub manifest: &'ctx Manifest,
pub lock: &'ctx Lock,
pub workflows: &'ctx [LocatedAction],
pub workflows_full: &'ctx [ParsedWorkflow],
pub action_set: &'ctx WorkflowActionSet,
}
pub trait Rule {
fn name(&self) -> RuleName;
fn default_level(&self) -> Level;
fn check(&self, ctx: &Context) -> Vec<Diagnostic>;
}
#[must_use]
pub fn format_and_report(diagnostics: Vec<Diagnostic>) -> Report {
Report::from_diagnostics(diagnostics)
}
pub(super) fn run_workflow_rule<R: Rule>(
rule: &R,
default_level: Level,
ctx: &Context<'_>,
lint_config: &LintConfig,
out: &mut Vec<Diagnostic>,
) {
let configured = lint_config.get_rule(rule.name(), default_level);
if configured.level == Level::Off {
return;
}
for mut diag in rule.check(ctx) {
diag.level = configured.level;
let ignored = configured
.ignore
.iter()
.any(|target| matches_ignore_workflow(&diag, target));
if !ignored {
out.push(diag);
}
}
}
fn workflow_matches(diag_workflow: Option<&WorkflowPath>, target: &IgnoreTarget) -> bool {
let Some(target_workflow) = &target.workflow else {
return true;
};
diag_workflow.is_some_and(|w| w.as_str().ends_with(target_workflow.as_str()))
}
fn matches_ignore_workflow(diag: &Diagnostic, target: &IgnoreTarget) -> bool {
if target.action.is_some() {
return false;
}
if !workflow_matches(diag.workflow.as_ref(), target) {
return false;
}
if let Some(target_job) = &target.job {
let Some(diag_job) = &diag.job else {
return false;
};
if diag_job.as_str() != target_job.as_str() {
return false;
}
}
true
}
pub(super) fn is_ignored(
diag: &Diagnostic,
rule_name: RuleName,
default_level: Level,
lint_config: &LintConfig,
action: &LocatedAction,
) -> bool {
lint_config
.get_rule(rule_name, default_level)
.ignore
.iter()
.any(|target| matches_ignore_action(diag, target, action))
}
fn matches_ignore_action(diag: &Diagnostic, target: &IgnoreTarget, action: &LocatedAction) -> bool {
if diag.workflow.is_none() {
return false;
}
if let Some(target_action) = &target.action
&& action.action.id.as_str() != target_action.as_str()
{
return false;
}
if !workflow_matches(diag.workflow.as_ref(), target) {
return false;
}
if target.job.is_some() {
return false;
}
true
}
pub(super) fn matches_ignore(
diag: &Diagnostic,
target: &IgnoreTarget,
located_actions: &[LocatedAction],
) -> bool {
let Some(diag_workflow) = &diag.workflow else {
return false;
};
let diag_action = located_actions
.iter()
.find(|loc| loc.location.workflow == *diag_workflow)
.map(|loc| &loc.action.id);
if let Some(target_action) = &target.action {
if let Some(matched_action) = diag_action {
if matched_action.as_str() != target_action.as_str() {
return false;
}
} else {
return false;
}
}
if !workflow_matches(Some(diag_workflow), target) {
return false;
}
if target.job.is_some() {
return false;
}
true
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
reason = "tests use unwrap, indexing, and other patterns freely"
)]
mod tests {
use super::{Diagnostic, Level, RuleName, WorkflowPath};
use std::str::FromStr as _;
#[test]
fn diagnostic_can_be_created() {
let diag = Diagnostic::new(RuleName::ShaMismatch, Level::Error, "test message");
assert_eq!(diag.rule, RuleName::ShaMismatch);
assert_eq!(diag.level, Level::Error);
assert_eq!(diag.message, "test message");
assert!(diag.workflow.is_none());
}
#[test]
fn diagnostic_with_workflow() {
let diag = Diagnostic::new(RuleName::Unpinned, Level::Warn, "test")
.with_workflow(WorkflowPath::new(".github/workflows/ci.yml"));
assert_eq!(
diag.workflow,
Some(WorkflowPath::new(".github/workflows/ci.yml"))
);
}
#[test]
fn rule_name_display_roundtrip() {
for name in [
RuleName::ShaMismatch,
RuleName::Unpinned,
RuleName::StaleComment,
RuleName::UnsyncedManifest,
RuleName::MissingPermissions,
RuleName::ExcessivePermissions,
RuleName::DangerousTrigger,
RuleName::PrHeadCheckout,
RuleName::MissingConcurrency,
RuleName::UnprotectedSecrets,
RuleName::DanglingReference,
RuleName::InvalidExpression,
RuleName::RunShellcheck,
] {
let s = name.to_string();
assert_eq!(RuleName::from_str(&s), Ok(name));
}
}
#[test]
fn rule_name_from_str_valid() {
assert_eq!(
RuleName::from_str("sha-mismatch"),
Ok(RuleName::ShaMismatch)
);
assert_eq!(RuleName::from_str("unpinned"), Ok(RuleName::Unpinned));
assert_eq!(
RuleName::from_str("stale-comment"),
Ok(RuleName::StaleComment)
);
assert_eq!(
RuleName::from_str("unsynced-manifest"),
Ok(RuleName::UnsyncedManifest)
);
assert_eq!(
RuleName::from_str("missing-permissions"),
Ok(RuleName::MissingPermissions)
);
assert_eq!(
RuleName::from_str("excessive-permissions"),
Ok(RuleName::ExcessivePermissions)
);
assert_eq!(
RuleName::from_str("dangerous-trigger"),
Ok(RuleName::DangerousTrigger)
);
assert_eq!(
RuleName::from_str("pr-head-checkout"),
Ok(RuleName::PrHeadCheckout)
);
assert_eq!(
RuleName::from_str("missing-concurrency"),
Ok(RuleName::MissingConcurrency)
);
assert_eq!(
RuleName::from_str("unprotected-secrets"),
Ok(RuleName::UnprotectedSecrets)
);
assert_eq!(
RuleName::from_str("dangling-reference"),
Ok(RuleName::DanglingReference)
);
assert_eq!(
RuleName::from_str("invalid-expression"),
Ok(RuleName::InvalidExpression)
);
assert_eq!(
RuleName::from_str("run-shellcheck"),
Ok(RuleName::RunShellcheck)
);
}
#[test]
fn rule_name_from_str_invalid() {
RuleName::from_str("nonexistent-rule").unwrap_err();
}
}