use super::check_name::CheckName;
use super::report::Finding;
use super::target::AuditTarget;
use crate::config::Level;
use crate::domain::action::identity::CommitSha;
use crate::domain::action::uses_ref::RefType;
use crate::infra::github::TagResolver;
const SHORT_SHA: usize = 8;
const TAG_MOVED_SEVERITY: Level = Level::Error;
const TAG_UNVERIFIED_SEVERITY: Level = Level::Error;
fn short(sha: &str) -> &str {
sha.get(..SHORT_SHA).unwrap_or(sha)
}
fn is_eligible(target: &AuditTarget<'_>) -> bool {
matches!(target.ref_type, Some(&RefType::Tag | &RefType::Release))
&& !CommitSha::is_valid(target.version)
}
pub fn tag_moved(target: &AuditTarget<'_>, resolver: &dyn TagResolver) -> Option<Finding> {
if !is_eligible(target) {
return None;
}
let current = match resolver.tag_commit(target.id, target.version) {
Ok(sha) => sha,
Err(error) => {
return Some(Finding::new(
CheckName::TagUnverified,
TAG_UNVERIFIED_SEVERITY,
format!(
"{} {} could not be verified: {error}",
target.id, target.version
),
));
}
};
if current == *target.sha {
return None;
}
Some(Finding::new(
CheckName::TagMoved,
TAG_MOVED_SEVERITY,
format!(
"{} {} now points to a different commit than when it was pinned (lock {}, now {}) — the tag was moved",
target.id,
target.version,
short(target.sha.as_str()),
short(current.as_str()),
),
))
}
#[cfg(test)]
#[expect(clippy::expect_used, reason = "tests use expect freely")]
mod tests {
use super::{CheckName, Level, tag_moved};
use crate::audit::target::{AuditTarget, targets};
use crate::domain::action::identity::{ActionId, CommitDate, CommitSha, Repository, Version};
use crate::domain::action::resolved::{Commit, ResolvedRef};
use crate::domain::action::spec::Spec;
use crate::domain::action::specifier::Specifier;
use crate::domain::action::uses_ref::RefType;
use crate::domain::lock::Lock;
use crate::infra::github::FakeTags;
const PINNED: &str = "a284dc18b1d2b1a5c1e5e5f0e5e5e5e5e5e5e5e5";
const MOVED: &str = "0e58ed86baaa5f0e5e5e5e5e5e5e5e5e5e5e5e5e";
fn lock_with(action: &str, label: &str, sha: &str, ref_type: Option<RefType>) -> Lock {
let mut lock = Lock::default();
let spec = Spec::new(ActionId::from(action), Specifier::parse(label));
lock.set(
&spec,
ResolvedRef::from_stored(Version::from(label), ref_type.as_ref()),
Commit {
sha: CommitSha::from(sha),
repository: Repository::from(action),
ref_type,
date: CommitDate::from("2026-01-01T00:00:00Z"),
},
);
lock
}
fn only(lock: &Lock) -> AuditTarget<'_> {
let mut found = targets(lock);
assert_eq!(found.len(), 1);
found.remove(0)
}
#[test]
fn unmoved_tag_produces_no_finding() {
let lock = lock_with("actions/checkout", "v4.2.1", PINNED, Some(RefType::Tag));
let fake = FakeTags::failing().with("actions/checkout", "v4.2.1", PINNED);
assert!(tag_moved(&only(&lock), &fake).is_none());
assert_eq!(fake.lookups().len(), 1);
}
#[test]
fn moved_tag_is_reported_with_both_shas() {
let lock = lock_with("actions/checkout", "v4.2.1", PINNED, Some(RefType::Tag));
let fake = FakeTags::failing().with("actions/checkout", "v4.2.1", MOVED);
let finding = tag_moved(&only(&lock), &fake).expect("a moved tag must be reported");
assert_eq!(finding.rule, CheckName::TagMoved);
assert_eq!(finding.level, Level::Error);
assert!(finding.message.contains("actions/checkout"));
assert!(finding.message.contains("v4.2.1"));
assert!(
finding.message.contains(super::short(PINNED)),
"{}",
finding.message
);
assert!(
finding.message.contains(super::short(MOVED)),
"{}",
finding.message
);
assert!(finding.message.contains("the tag was moved"));
}
#[test]
fn release_entry_is_checked_like_a_tag() {
let lock = lock_with("actions/checkout", "v4.2.1", PINNED, Some(RefType::Release));
let fake = FakeTags::failing().with("actions/checkout", "v4.2.1", MOVED);
let finding = tag_moved(&only(&lock), &fake).expect("a release entry must be checked");
assert_eq!(finding.rule, CheckName::TagMoved);
}
#[test]
fn ineligible_entries_are_skipped_without_a_lookup() {
let cases = [
(
"main",
"abc123def456789012345678901234567890abcd",
Some(RefType::Branch),
),
(PINNED, PINNED, Some(RefType::Commit)),
("v4.2.1", PINNED, None),
];
for (label, sha, ref_type) in cases {
let kind = format!("{ref_type:?}");
let lock = lock_with("actions/checkout", label, sha, ref_type);
let fake = FakeTags::failing().with("actions/checkout", label, MOVED);
assert!(
tag_moved(&only(&lock), &fake).is_none(),
"{kind} must not be reported"
);
assert!(
fake.lookups().is_empty(),
"{kind} must not be looked up, got {:?}",
fake.lookups()
);
}
}
#[test]
fn sha_shaped_label_on_a_tag_row_is_skipped_without_a_lookup() {
let lock = lock_with("actions/checkout", PINNED, PINNED, Some(RefType::Tag));
let fake = FakeTags::failing();
assert!(tag_moved(&only(&lock), &fake).is_none());
assert!(fake.lookups().is_empty(), "no tag named by a SHA exists");
}
#[test]
fn failed_lookup_is_reported_under_its_own_name() {
let lock = lock_with("actions/checkout", "v4.2.1", PINNED, Some(RefType::Tag));
let fake = FakeTags::failing();
let finding = tag_moved(&only(&lock), &fake).expect("a failed lookup must be reported");
assert_eq!(finding.rule, CheckName::TagUnverified);
assert_eq!(finding.level, Level::Error);
assert!(finding.message.contains("could not be verified"));
assert!(finding.message.contains("actions/checkout"));
}
}