use super::{Context, Diagnostic, Rule, RuleName};
use crate::config::Level;
pub struct UnpinnedRule;
impl UnpinnedRule {
pub fn check_action(action: &crate::domain::file::actions::Located) -> Option<Diagnostic> {
if action.action.reference.pin_sha().is_some() {
return None;
}
let msg = format!(
"{} uses tag reference {} instead of SHA pin",
&action.action.id,
action.action.reference.label()
);
Some(
Diagnostic::new(RuleName::Unpinned, Level::Error, msg)
.with_workflow(action.site.file.clone())
.with_line(action.origin.line),
)
}
}
impl Rule for UnpinnedRule {
fn name(&self) -> RuleName {
RuleName::Unpinned
}
fn default_level(&self) -> Level {
Level::Error
}
fn check(&self, ctx: &Context) -> Vec<Diagnostic> {
ctx.workflows
.iter()
.filter_map(Self::check_action)
.collect()
}
}
#[cfg(test)]
#[expect(clippy::unwrap_used, reason = "tests use unwrap freely")]
mod tests {
use super::{Level, Rule as _, RuleName, UnpinnedRule};
use crate::domain::action::identity::{ActionId, CommitSha, Version};
use crate::domain::action::uses_ref::ParsedRef;
use crate::domain::file::actions::{Located, WorkflowAction};
use crate::domain::file::site::{Id, JobId, Origin, Slot, StepIndex, WorkflowPath};
const VALID_SHA: &str = "8e8c483db84b4bee98b60c0593521ed34d9990e8";
fn located(version: &str, sha: Option<&str>) -> Located {
located_at(version, sha, None)
}
fn located_at(version: &str, sha: Option<&str>, line: Option<u32>) -> Located {
let reference = match sha {
Some(sha_str) => ParsedRef::Pinned {
sha: CommitSha::from(sha_str),
comment: Version::from(version),
},
None if CommitSha::is_valid(version) => ParsedRef::Sha(CommitSha::from(version)),
None => ParsedRef::Ref(Version::from(version)),
};
Located {
action: WorkflowAction {
id: ActionId::from("actions/checkout"),
reference,
},
site: Id {
file: WorkflowPath::new(".github/workflows/ci.yml"),
slot: Slot::WorkflowStep {
job: JobId::from("build"),
step: StepIndex::from(0_u16),
},
},
origin: Origin { line },
}
}
#[test]
fn unpinned_rule_has_correct_metadata() {
let rule = UnpinnedRule;
assert_eq!(rule.name(), RuleName::Unpinned);
assert_eq!(rule.default_level(), Level::Error);
}
#[test]
fn sha_pin_with_version_comment_is_not_flagged() {
let action = located("v6.0.1", Some(VALID_SHA));
assert!(UnpinnedRule::check_action(&action).is_none());
}
#[test]
fn sha_pin_without_comment_is_not_flagged() {
let action = located(VALID_SHA, None);
assert!(UnpinnedRule::check_action(&action).is_none());
}
#[test]
fn tag_reference_is_flagged() {
let action = located("v4", None);
assert!(UnpinnedRule::check_action(&action).is_some());
}
#[test]
fn diagnostic_carries_source_line_when_known() {
let action = located_at("v4", None, Some(12));
let diag = UnpinnedRule::check_action(&action).unwrap();
assert_eq!(diag.line, Some(12));
}
#[test]
fn diagnostic_omits_line_when_unknown() {
let action = located_at("v4", None, None);
let diag = UnpinnedRule::check_action(&action).unwrap();
assert_eq!(diag.line, None);
}
#[test]
fn message_does_not_label_the_identifier_with_a_kind_noun() {
let action = located("v4", None);
let diag = UnpinnedRule::check_action(&action).unwrap();
assert!(
!diag.message.starts_with("action "),
"message should not prefix the identifier with `action `: {}",
diag.message
);
}
#[test]
fn message_does_not_embed_workflow_path() {
let action = located("v4", None);
let diag = UnpinnedRule::check_action(&action).unwrap();
assert!(
!diag.message.contains(".github/workflows/ci.yml"),
"message should not embed the workflow path: {}",
diag.message
);
}
}