use crate::config::Level;
use crate::domain::file::parsed::ParsedWorkflow;
use crate::lint::{Context, Diagnostic, Rule, RuleName};
pub struct MissingPermissionsRule;
impl MissingPermissionsRule {
pub fn check_workflow(workflow: &ParsedWorkflow) -> Option<Diagnostic> {
if workflow.permissions.is_some() {
return None;
}
let msg = "workflow has no top-level `permissions:` block — declare one (typically `permissions: { contents: read }`) to make the token scope explicit".to_owned();
Some(
Diagnostic::new(RuleName::MissingPermissions, Level::Error, msg)
.with_workflow(workflow.path.clone()),
)
}
}
impl Rule for MissingPermissionsRule {
fn name(&self) -> RuleName {
RuleName::MissingPermissions
}
fn default_level(&self) -> Level {
Level::Error
}
fn check(&self, ctx: &Context) -> Vec<Diagnostic> {
ctx.workflows_full
.iter()
.filter_map(Self::check_workflow)
.collect()
}
}
#[cfg(test)]
#[expect(clippy::unwrap_used, reason = "tests use unwrap freely")]
mod tests {
use super::*;
use crate::domain::file::parsed::Parsed;
use crate::domain::file::site::WorkflowPath;
fn parse(content: &str) -> ParsedWorkflow {
Parsed::from_yaml(WorkflowPath::new(".github/workflows/ci.yml"), content).unwrap()
}
#[test]
fn rule_metadata() {
let r = MissingPermissionsRule;
assert_eq!(r.name(), RuleName::MissingPermissions);
assert_eq!(r.default_level(), Level::Error);
}
#[test]
fn workflow_with_top_level_permissions_is_clean() {
let p = parse("on: push\npermissions:\n contents: read\njobs: {}\n");
assert!(MissingPermissionsRule::check_workflow(&p).is_none());
}
#[test]
fn workflow_without_permissions_fails() {
let p = parse("on: push\njobs: {}\n");
let diag = MissingPermissionsRule::check_workflow(&p).unwrap();
assert_eq!(diag.rule, RuleName::MissingPermissions);
assert_eq!(diag.level, Level::Error);
assert!(diag.workflow.is_some());
}
}