mod attest;
mod git;
const USAGE: &str = "\
git-attest — what a signed attestation covers for the tree checked out here
git-attest covered [--signers PATH] [--principal ID] [--platform P|OS|any]
[--anywhere \"NAMES\"] [--json | --github-output]
git-attest explain [same flags]
covered print the covered gate names, or nothing. Always exits 0: every
failure means \"run the tests\", which is the caller's default
anyway, so there is no state a workflow author must remember to
handle.
explain print the same answer with the reasoning that produced it, on
stderr. Use it when a skip you expected did not happen.
--signers PATH default: .forgejo/allowed_signers, then
.github/allowed_signers. A relative PATH is resolved from
the REPOSITORY ROOT, not the working directory
--principal ID accept only a signature by this identity. Default: whoever
the signature says, if that key is in the signers file
--platform P default: this machine, as <arch>-<os>. An OS alone (`linux`)
accepts any architecture. `any` accepts an attestation from
anywhere, which is a claim that the suite's result does not
depend on where it ran.
--anywhere NAMES gate names whose result cannot depend on where they ran
(formatting, shell lint, secret scanning, dependency
audit): a verified attestation from any platform covers
them. Never a check that compiles or executes the product.
--json print a JSON array instead of a space-separated list
--github-output print `covered=` and `gates=` lines ready to append to
$GITHUB_OUTPUT
";
#[derive(Default, Debug, PartialEq)]
struct Opts {
signers: Option<String>,
principal: Option<String>,
platform: Option<String>,
anywhere: Vec<String>,
json: bool,
gha: bool,
}
fn parse(args: &[String]) -> Result<Opts, String> {
let mut o = Opts::default();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--json" => o.json = true,
"--github-output" => o.gha = true,
name @ ("--signers" | "--principal" | "--platform" | "--anywhere") => {
let value = args
.get(i + 1)
.filter(|v| !v.starts_with("--"))
.cloned()
.ok_or_else(|| format!("{name} needs a value"))?;
match name {
"--signers" => o.signers = Some(value),
"--principal" => o.principal = Some(value),
"--anywhere" => o
.anywhere
.extend(value.split_whitespace().map(String::from)),
_ => o.platform = Some(value),
}
i += 1;
}
other => return Err(format!("unknown argument {other}")),
}
i += 1;
}
Ok(o)
}
fn as_json(gates: &[String]) -> String {
let mut out = String::from("[");
for (i, g) in gates.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push('"');
for c in g.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
}
out.push(']');
out
}
fn main() -> std::process::ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let code = run(&args);
std::process::ExitCode::from(code)
}
fn run(args: &[String]) -> u8 {
let Some(verb) = args.first() else {
eprint!("{USAGE}");
return 2;
};
match verb.as_str() {
"-h" | "--help" | "help" => {
print!("{USAGE}");
return 0;
}
"-V" | "--version" | "version" => {
println!("git-attest {}", env!("CARGO_PKG_VERSION"));
return 0;
}
"covered" | "explain" => {}
other => {
eprintln!("git-attest: unknown subcommand `{other}`");
eprint!("{USAGE}");
return 2;
}
}
let explain = verb == "explain";
let Opts {
signers,
principal,
platform,
anywhere,
json,
gha,
} = match parse(&args[1..]) {
Ok(o) => o,
Err(m) => {
eprintln!("git-attest: {m}");
eprint!("{USAGE}");
return 2;
}
};
let done = |gates: Vec<String>, trail: Vec<String>| -> u8 {
if explain {
for step in &trail {
eprintln!(" {step}");
}
}
if gha {
println!("covered={}", gates.join(" "));
println!("gates={}", as_json(&gates));
} else if json {
println!("{}", as_json(&gates));
} else if !gates.is_empty() {
println!("{}", gates.join(" "));
}
0
};
let Some(signers) = signers
.as_deref()
.map(attest::resolve_signers)
.or_else(attest::default_signers)
else {
return done(
Vec::new(),
vec!["no allowed_signers found (.forgejo/ or .github/) at the repository root".into()],
);
};
let want = match platform.as_deref() {
Some("any") => None,
Some(explicit) => Some(explicit.to_string()),
None => Some(attest::platform()),
};
let verdict = attest::evaluate(&signers, principal.as_deref(), want.as_deref(), &anywhere);
done(verdict.gates, verdict.trail)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_escapes_rather_than_trusts_the_payload() {
assert_eq!(as_json(&[]), "[]");
assert_eq!(as_json(&["a".into(), "b".into()]), r#"["a","b"]"#);
assert_eq!(as_json(&[r#"a"b"#.into()]), r#"["a\"b"]"#);
assert_eq!(as_json(&["a\\b".into()]), r#"["a\\b"]"#);
assert_eq!(as_json(&["a\tb".into()]), r#"["a\u0009b"]"#);
}
#[test]
fn a_prefix_colliding_gate_stays_its_own_element() {
let json = as_json(&["pre-push-cargo-test-slow".into()]);
assert_eq!(json, r#"["pre-push-cargo-test-slow"]"#);
assert!(json.contains("pre-push-cargo-test")); assert_ne!(
vec!["pre-push-cargo-test-slow"],
vec!["pre-push-cargo-test"]
);
}
fn argv(a: &[&str]) -> Vec<String> {
a.iter().map(|s| s.to_string()).collect()
}
#[test]
fn flags_need_values_and_reject_a_following_flag() {
let o = parse(&argv(&["--signers", "p", "--json"])).unwrap();
assert_eq!(o.signers.as_deref(), Some("p"));
assert_eq!(o.principal, None);
assert!(o.json && !o.gha);
assert!(parse(&argv(&["--signers", "--json"])).is_err());
}
#[test]
fn anywhere_accumulates_whitespace_separated_names() {
let o = parse(&argv(&[
"--anywhere",
" ci-fmt\tci-shellcheck ",
"--anywhere",
"x",
]))
.unwrap();
assert_eq!(o.anywhere, ["ci-fmt", "ci-shellcheck", "x"]);
assert!(parse(&argv(&["--anywhere"])).is_err());
}
#[test]
fn an_unknown_flag_is_a_usage_error() {
assert_eq!(
parse(&argv(&["--platfrom", "any"])),
Err("unknown argument --platfrom".into())
);
assert_eq!(
parse(&argv(&["--quiet"])),
Err("unknown argument --quiet".into())
);
assert_eq!(parse(&[]), Ok(Opts::default()));
}
#[test]
fn split_note_restores_the_signed_trailing_newline() {
let body = "amont-attest-v2\ntree abc\n\n-----BEGIN SSH SIGNATURE-----\nx";
let (payload, sig) = attest::split_note(body).unwrap();
assert_eq!(payload, "amont-attest-v2\ntree abc\n");
assert!(sig.starts_with("-----BEGIN SSH SIGNATURE-----"));
assert!(attest::split_note("no blank line here").is_none());
assert!(attest::split_note("payload\n\nnot a signature").is_none());
}
}