use std::path::Path;
use serde::Serialize;
use git_attest::spec::{at, plan_exempt, PlanExempt};
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct AttestState {
pub deprecated_opt_out: bool,
pub spec_with_gates: bool,
pub pins: Vec<AttestPin>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct AttestPin {
pub file: String,
pub action: String,
pub reference: String,
pub version: Option<String>,
}
impl AttestState {
pub fn pins_before(&self, major: u64, minor: u64) -> bool {
self.pins.iter().any(|p| match &p.version {
Some(v) => parse(v).is_some_and(|(a, b)| (a, b) < (major, minor)),
None => !is_moving_major(&p.reference),
})
}
}
pub fn read(repo: &Path) -> AttestState {
let tree = "HEAD^{tree}";
AttestState {
deprecated_opt_out: plan_exempt(repo, tree) == PlanExempt::OffByAmontConf,
spec_with_gates: matches!(at(repo, tree), Ok(Some((_, spec))) if !spec.gates.is_empty()),
pins: pins(repo),
}
}
fn pins(repo: &Path) -> Vec<AttestPin> {
let mut out = Vec::new();
for dir in [".github/workflows", ".forgejo/workflows"] {
let Ok(entries) = std::fs::read_dir(repo.join(dir)) else {
continue;
};
let mut files: Vec<_> = entries
.flatten()
.map(|e| e.path())
.filter(|p| matches!(p.extension().and_then(|e| e.to_str()), Some("yml" | "yaml")))
.collect();
files.sort();
for path in files {
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let file = format!(
"{dir}/{}",
path.file_name().unwrap_or_default().to_string_lossy()
);
out.extend(text.lines().filter_map(|l| pin_on(l, &file)));
}
}
out
}
fn pin_on(line: &str, file: &str) -> Option<AttestPin> {
let rest = line
.trim_start()
.strip_prefix("- ")
.unwrap_or(line.trim_start());
let rest = rest.strip_prefix("uses:")?.trim_start();
let rest = rest.strip_prefix("https://github.com/").unwrap_or(rest);
let rest = rest.strip_prefix("fredericrous/attest")?;
let (action, rest) = match rest.strip_prefix("/sign@") {
Some(r) => ("attest/sign", r),
None => ("attest", rest.strip_prefix('@')?),
};
let mut words = rest.split_whitespace();
let reference = words.next()?.trim_matches(|c| c == '"' || c == '\'');
let comment = match (words.next(), words.next()) {
(Some("#"), Some(tag)) => tag.strip_prefix('v').filter(|v| parse(v).is_some()),
_ => None,
};
let version = match reference.strip_prefix('v') {
Some(v) if is_release(v) => Some(v.to_string()),
_ => comment.map(str::to_string),
};
Some(AttestPin {
file: file.to_string(),
action: action.to_string(),
reference: reference.to_string(),
version,
})
}
fn is_release(v: &str) -> bool {
let parts: Vec<&str> = v.split('.').collect();
parts.len() == 3
&& parts
.iter()
.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
}
fn is_moving_major(reference: &str) -> bool {
reference
.strip_prefix('v')
.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
}
fn parse(v: &str) -> Option<(u64, u64)> {
if !is_release(v) {
return None;
}
let mut parts = v.split('.').map(|p| p.parse::<u64>().ok());
Some((parts.next()??, parts.next()??))
}
#[cfg(test)]
mod tests {
use super::*;
fn pin(line: &str) -> Option<AttestPin> {
pin_on(line, ".github/workflows/ci.yaml")
}
#[test]
fn every_pin_shape_is_read() {
let p = pin(" - uses: fredericrous/attest@v1").unwrap();
assert_eq!(
(p.action.as_str(), p.reference.as_str(), p.version),
("attest", "v1", None)
);
let p = pin(" uses: fredericrous/attest/sign@v1.7.0").unwrap();
assert_eq!(
(p.action.as_str(), p.version.as_deref()),
("attest/sign", Some("1.7.0"))
);
let sha = "0123456789abcdef0123456789abcdef01234567";
let p = pin(&format!(" - uses: fredericrous/attest@{sha} # v1.6.0")).unwrap();
assert_eq!(
(p.reference.as_str(), p.version.as_deref()),
(sha, Some("1.6.0"))
);
let p = pin(&format!(
"uses: https://github.com/fredericrous/attest@{sha}"
))
.unwrap();
assert_eq!(p.version, None);
assert_eq!(pin("uses: fredericrous/attestation@v1"), None);
assert_eq!(pin("uses: actions/checkout@v4"), None);
assert_eq!(pin("# uses: fredericrous/attest@v1"), None);
}
#[test]
fn a_moving_major_is_current_and_a_bare_sha_is_not() {
let state = |line: &str| AttestState {
pins: vec![pin(line).unwrap()],
..AttestState::default()
};
assert!(!state("uses: fredericrous/attest@v1").pins_before(1, 7));
assert!(state("uses: fredericrous/attest@v1.6.0").pins_before(1, 7));
assert!(!state("uses: fredericrous/attest@v1.7.0").pins_before(1, 7));
assert!(
state("uses: fredericrous/attest@0123456789abcdef0123456789abcdef01234567")
.pins_before(1, 7)
);
assert!(!AttestState::default().pins_before(1, 7));
}
fn git(dir: &Path, args: &[&str]) {
let ok = std::process::Command::new("git")
.args([
"-c",
"core.hooksPath=/dev/null",
"-c",
"user.email=t@example.org",
"-c",
"user.name=t",
])
.args(args)
.current_dir(dir)
.status()
.expect("git")
.success();
assert!(ok, "git {args:?}");
}
#[test]
fn the_old_opt_out_and_a_declared_spec_are_read_from_head() {
let dir = std::env::temp_dir().join(format!("amont-fleet-attest-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".github/workflows")).unwrap();
git(&dir, &["init", "-q"]);
std::fs::write(dir.join("amont.conf"), "set planExempt off\n").unwrap();
std::fs::write(dir.join(".github/attest-inputs"), "ci-test src\n").unwrap();
std::fs::write(
dir.join(".github/workflows/ci.yaml"),
"steps:\n - uses: fredericrous/attest@v1\n",
)
.unwrap();
git(&dir, &["add", "-A"]);
git(&dir, &["commit", "-qm", "init"]);
let s = read(&dir);
assert!(s.deprecated_opt_out);
assert!(s.spec_with_gates);
assert_eq!(s.pins.len(), 1);
std::fs::write(
dir.join(".github/attest-inputs"),
"@plan-exempt off\nci-test src\n",
)
.unwrap();
git(&dir, &["commit", "-qam", "directive"]);
let s = read(&dir);
assert!(!s.deprecated_opt_out);
assert!(s.spec_with_gates);
let _ = std::fs::remove_dir_all(&dir);
}
}