use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use crate::git;
pub const FORMAT: &str = "amont-attest-v2";
pub const NOTES_REF: &str = "amont-attest";
pub const INPUTS_REF: &str = "amont-attest-inputs";
pub const LOCAL_PREFIX: &str = "attest-local/";
pub const NAMESPACE: &str = "amont-attest";
pub const MAX_BLOCKS: usize = 32;
pub const MAX_VERIFICATIONS: usize = 64;
pub const SPEC_PATHS: [&str; 2] = [".forgejo/attest-inputs", ".github/attest-inputs"];
pub const MAX_SPEC_BYTES: usize = 65536;
pub const MAX_SPEC_GATES: usize = 64;
pub const MAX_SPEC_PATHS: usize = 64;
const BEGIN: &str = "-----BEGIN SSH SIGNATURE-----";
const END: &str = "-----END SSH SIGNATURE-----";
pub fn platform() -> String {
format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS)
}
pub struct Verdict {
pub gates: Vec<String>,
pub trail: Vec<String>,
pub notes: Option<(Mirror, Mirror)>,
pub loud: Vec<String>,
}
impl Verdict {
fn nothing(reason: impl Into<String>) -> Self {
Verdict {
gates: Vec::new(),
trail: vec![reason.into()],
notes: None,
loud: Vec::new(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Mirror {
Fresh,
NoOrigin,
Deleted,
Undeletable,
Unreachable,
TimedOut,
Unwritable,
}
impl Mirror {
pub fn as_str(self) -> &'static str {
match self {
Mirror::Fresh => "fetched",
Mirror::NoOrigin => "no-origin",
Mirror::Deleted => "absent",
Mirror::Undeletable => "undeletable",
Mirror::Unreachable | Mirror::TimedOut => "unreachable",
Mirror::Unwritable => "unwritable",
}
}
fn judged(self) -> bool {
matches!(self, Mirror::Fresh | Mirror::NoOrigin)
}
}
fn push_reason(trail: &mut Vec<String>, reason: String) {
if trail.last() != Some(&reason) {
trail.push(reason);
}
}
struct SigFile(PathBuf);
impl SigFile {
fn new(sig: &str) -> Option<Self> {
use std::io::Write;
let path = std::env::temp_dir().join(format!(
"attest-verify-{}-{:p}.sig",
std::process::id(),
&sig
));
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.ok()?;
f.write_all(sig.as_bytes()).ok()?;
if !sig.ends_with('\n') {
f.write_all(b"\n").ok()?;
}
Some(SigFile(path))
}
}
impl Drop for SigFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
fn find_principal(sig_file: &Path, allowed_signers: &Path) -> Option<String> {
let out = Command::new("ssh-keygen")
.args(["-Y", "find-principals", "-s"])
.arg(sig_file)
.arg("-f")
.arg(allowed_signers)
.stderr(Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout)
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.map(str::to_string)
}
pub fn verify(
payload: &str,
sig: &str,
allowed_signers: &Path,
principal: Option<&str>,
) -> Result<String, String> {
use std::io::Write;
let Some(sig_file) = SigFile::new(sig) else {
return Err("cannot create a temporary file for the signature".into());
};
let signer = match principal {
Some(p) => p.to_string(),
None => find_principal(&sig_file.0, allowed_signers).ok_or_else(|| {
format!(
"signature was not made by any key in {}",
allowed_signers.display()
)
})?,
};
let ok = (|| {
let mut child = Command::new("ssh-keygen")
.args(["-Y", "verify", "-n", NAMESPACE, "-I", &signer, "-f"])
.arg(allowed_signers)
.arg("-s")
.arg(&sig_file.0)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.ok()?;
child.stdin.take()?.write_all(payload.as_bytes()).ok()?;
child.wait().ok().map(|s| s.success())
})()
.unwrap_or(false);
if ok {
Ok(signer)
} else {
Err(format!(
"signature does not verify as {signer} against {}",
allowed_signers.display()
))
}
}
fn drop_mirror(loud: &mut Vec<String>, notes_ref: &str) -> Mirror {
let full = format!("refs/notes/{notes_ref}");
let Some(oid) = git::stdout(&["rev-parse", "--verify", "--quiet", &full]) else {
return Mirror::Deleted;
};
if git::succeeds(&["update-ref", "-d", &full, &oid]) {
loud.push(format!(
"origin has no {full}; deleted the local mirror (was {oid}); restore: git update-ref {full} {oid}"
));
Mirror::Deleted
} else {
Mirror::Undeletable
}
}
pub const TMP_NS: &str = "refs/attest-tmp/";
fn sweep_tmp_refs() {
let Some(refs) = git::stdout(&["for-each-ref", "--format=%(refname)", TMP_NS]) else {
return;
};
let me = std::process::id().to_string();
for r in refs.lines() {
let Some(pid) = r
.strip_prefix(TMP_NS)
.and_then(|rest| rest.split('/').next())
.filter(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
else {
continue;
};
if pid != me && process_gone(pid) {
let _ = git::succeeds(&["update-ref", "-d", r]);
}
}
}
fn process_gone(pid: &str) -> bool {
if !cfg!(unix) {
return false;
}
Command::new("kill")
.args(["-0", pid])
.env("LC_ALL", "C")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output()
.is_ok_and(|o| {
!o.status.success()
&& String::from_utf8_lossy(&o.stderr)
.to_lowercase()
.contains("no such process")
})
}
fn adopt(notes_ref: &str, tmp: &str, old: Option<&str>) -> Mirror {
let full = format!("refs/notes/{notes_ref}");
let Some(new) = git::stdout(&["rev-parse", "--verify", "--quiet", tmp]) else {
return Mirror::Unwritable;
};
if git::succeeds(&["update-ref", &full, &new, old.unwrap_or("")]) {
return Mirror::Fresh;
}
match git::stdout(&["rev-parse", "--verify", "--quiet", &full]) {
Some(now) if now == new => Mirror::Fresh,
_ => Mirror::Unwritable,
}
}
fn sync_mirrors(loud: &mut Vec<String>) -> (Mirror, Mirror) {
if !git::succeeds(&["remote", "get-url", "origin"]) {
return (Mirror::NoOrigin, Mirror::NoOrigin);
}
sweep_tmp_refs();
let pid = std::process::id();
let full = |r: &str| format!("refs/notes/{r}");
let tmp = |r: &str| format!("{TMP_NS}{pid}/{r}");
let spec = |r: &str| format!("+{}:{}", full(r), tmp(r));
for r in [NOTES_REF, INPUTS_REF] {
let _ = git::succeeds(&["update-ref", "-d", &tmp(r)]);
if let Some(lock) = git::stdout(&["rev-parse", "--git-path", &format!("{}.lock", tmp(r))]) {
let _ = std::fs::remove_file(lock);
}
}
let old_main = git::stdout(&["rev-parse", "--verify", "--quiet", &full(NOTES_REF)]);
let old_inputs = git::stdout(&["rev-parse", "--verify", "--quiet", &full(INPUTS_REF)]);
let adopt_both = || {
(
adopt(NOTES_REF, &tmp(NOTES_REF), old_main.as_deref()),
adopt(INPUTS_REF, &tmp(INPUTS_REF), old_inputs.as_deref()),
)
};
let states = match git::remote(&[
"fetch",
"--quiet",
"origin",
&spec(NOTES_REF),
&spec(INPUTS_REF),
]) {
git::Remote::Done(0, _) => adopt_both(),
git::Remote::TimedOut => (Mirror::TimedOut, Mirror::TimedOut),
_ => match git::remote(&[
"ls-remote",
"--exit-code",
"origin",
&full(NOTES_REF),
&full(INPUTS_REF),
]) {
git::Remote::Done(0, listing) => {
let listed = |r: &str| {
let f = full(r);
listing
.lines()
.any(|l| l.split_whitespace().nth(1) == Some(f.as_str()))
};
let (has_main, has_inputs) = (listed(NOTES_REF), listed(INPUTS_REF));
let mut want = Vec::new();
if has_main {
want.push(spec(NOTES_REF));
}
if has_inputs {
want.push(spec(INPUTS_REF));
}
let fetched = if want.is_empty() {
None
} else {
let mut args = vec!["fetch", "--quiet", "origin"];
args.extend(want.iter().map(String::as_str));
Some(git::remote(&args))
};
let one = |present: bool, r: &str, old: Option<&str>, loud: &mut Vec<String>| {
if !present {
return drop_mirror(loud, r);
}
match &fetched {
Some(git::Remote::Done(0, _)) => adopt(r, &tmp(r), old),
Some(git::Remote::TimedOut) => Mirror::TimedOut,
_ => Mirror::Unreachable,
}
};
let m = one(has_main, NOTES_REF, old_main.as_deref(), loud);
let i = one(has_inputs, INPUTS_REF, old_inputs.as_deref(), loud);
(m, i)
}
git::Remote::Done(2, _) => {
(drop_mirror(loud, NOTES_REF), drop_mirror(loud, INPUTS_REF))
}
git::Remote::TimedOut => (Mirror::TimedOut, Mirror::TimedOut),
_ => (Mirror::Unreachable, Mirror::Unreachable),
},
};
for r in [NOTES_REF, INPUTS_REF] {
let _ = git::succeeds(&["update-ref", "-d", &tmp(r)]);
}
states
}
fn lock_reason(notes_ref: &str) -> Option<String> {
let lock = format!("refs/notes/{notes_ref}.lock");
let path = git::stdout(&["rev-parse", "--git-path", &lock])?;
std::path::Path::new(&path).exists().then(|| {
format!("a git that was killed left a lock on refs/notes/{notes_ref}; if no git is running: rm {path}")
})
}
fn state_reason(notes_ref: &str, state: Mirror) -> Option<String> {
match state {
Mirror::Unreachable => Some(format!(
"cannot fetch refs/notes/{notes_ref} from origin (no credentials? persist-credentials: false?); local mirror not judged, running everything"
)),
Mirror::TimedOut => Some(format!(
"origin did not answer within {} s for refs/notes/{notes_ref}; local mirror not judged, running everything",
git::REMOTE_BUDGET_SECS
)),
Mirror::Unwritable => Some(format!(
"cannot update refs/notes/{notes_ref} to origin's copy; local mirror not judged"
)),
Mirror::Undeletable => Some(format!(
"origin has no refs/notes/{notes_ref} but the local mirror could not be deleted (read-only .git?); local mirror not judged"
)),
_ => None,
}
}
pub fn default_signers() -> Option<PathBuf> {
let root = git::stdout(&["rev-parse", "--show-toplevel"]).map(PathBuf::from);
[".forgejo/allowed_signers", ".github/allowed_signers"]
.into_iter()
.map(|rel| match &root {
Some(root) => root.join(rel),
None => PathBuf::from(rel),
})
.find(|p| p.exists())
}
pub fn resolve_signers(given: &str) -> PathBuf {
let path = PathBuf::from(given);
if path.is_absolute() {
return path;
}
match git::stdout(&["rev-parse", "--show-toplevel"]) {
Some(root) => PathBuf::from(root).join(path),
None => path,
}
}
#[derive(Debug, PartialEq)]
pub struct Block {
pub payload: String,
pub signature: String,
}
pub fn split_blocks(body: &str) -> (Vec<Block>, bool) {
enum State {
Between,
Payload,
WaitingForSignature,
Signature,
}
let mut blocks = Vec::new();
let mut state = State::Between;
let mut payload = String::new();
let mut signature = String::new();
let close = |blocks: &mut Vec<Block>, payload: &str, signature: &str| -> bool {
if blocks.len() == MAX_BLOCKS {
return true;
}
blocks.push(Block {
payload: payload.to_string(),
signature: signature.to_string(),
});
false
};
for line in body.split('\n') {
match state {
State::Between => {
if line.is_empty() {
continue;
}
payload.clear();
payload.push_str(line);
payload.push('\n');
state = State::Payload;
}
State::Payload => {
if line.is_empty() {
state = State::WaitingForSignature;
} else {
payload.push_str(line);
payload.push('\n');
}
}
State::WaitingForSignature => {
if line.is_empty() {
continue;
}
if line != BEGIN {
return (blocks, false);
}
signature.clear();
signature.push_str(line);
signature.push('\n');
state = State::Signature;
}
State::Signature => {
signature.push_str(line);
signature.push('\n');
if line == END {
if close(&mut blocks, &payload, &signature) {
return (blocks, true);
}
state = State::Between;
}
}
}
}
if let State::Signature = state {
if close(&mut blocks, &payload, &signature) {
return (blocks, true);
}
}
(blocks, false)
}
#[cfg(test)]
pub fn split_note(body: &str) -> Option<(String, String)> {
split_blocks(body)
.0
.into_iter()
.next()
.map(|b| (b.payload, b.signature))
}
pub fn platform_matches(want: Option<&str>, ran_on: &str) -> bool {
match want {
None => true,
Some(want) if want.contains('-') => want == ran_on,
Some(want) => ran_on.rsplit_once('-').is_some_and(|(_, os)| os == want),
}
}
fn union<'a>(into: &mut Vec<String>, names: impl Iterator<Item = &'a str>) {
for name in names {
if !into.iter().any(|g| g == name) {
into.push(name.to_string());
}
}
}
fn field<'a>(payload: &'a str, name: &str) -> Option<&'a str> {
payload
.split('\n')
.find_map(|l| l.strip_prefix(name).and_then(|r| r.strip_prefix(' ')))
.map(|v| v.trim_start_matches(' '))
}
#[derive(Debug, PartialEq, Clone)]
pub struct PathTok {
pub path: String,
pub optional: bool,
}
#[derive(Debug, PartialEq)]
pub struct Spec {
pub gates: Vec<(String, Vec<PathTok>)>,
}
impl Spec {
pub fn paths_of(&self, gate: &str) -> Option<&[PathTok]> {
self.gates
.iter()
.find(|(g, _)| g == gate)
.map(|(_, p)| p.as_slice())
}
}
fn valid_gate(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_alphanumeric() => {}
_ => return false,
}
name.len() <= 64 && chars.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
}
fn path_error(tok: &str) -> Option<String> {
let tok = match tok.strip_prefix('?') {
Some("") => return Some("path `?` names nothing".into()),
Some(rest) => rest,
None => tok,
};
let bad = |why: &str| Some(format!("path `{tok}` {why}"));
if tok.starts_with(':') {
return bad("starts with `:` (pathspec magic is not allowed)");
}
if tok.starts_with('/') {
return bad("is absolute; paths are relative to the repository root");
}
if tok.starts_with("./") || tok.starts_with("../") {
return bad("starts with `./` or `../`");
}
if tok.ends_with('/') {
return bad("ends with `/`; name the directory without it");
}
if let Some(c) = tok
.chars()
.find(|c| matches!(c, '*' | '?' | '[' | ']' | '\\'))
{
return bad(&format!(
"contains `{c}`; git ls-tree does not glob, only literal paths are accepted"
));
}
if tok
.split('/')
.any(|c| c.is_empty() || c == "." || c == "..")
{
return bad("has an empty, `.` or `..` component");
}
None
}
pub fn parse_spec(bytes: &[u8]) -> Result<Spec, String> {
if bytes.len() > MAX_SPEC_BYTES {
return Err(format!("is larger than {MAX_SPEC_BYTES} bytes"));
}
if let Some(b) = bytes
.iter()
.find(|&&b| !(b == b' ' || b == b'\t' || b == b'\n' || (0x21..=0x7e).contains(&b)))
{
return Err(format!(
"contains byte 0x{b:02x}; only printable ASCII, space, tab and LF are allowed"
));
}
let text = std::str::from_utf8(bytes).map_err(|e| e.to_string())?;
let mut gates: Vec<(String, Vec<PathTok>)> = Vec::new();
for (n, line) in text.split('\n').enumerate() {
let n = n + 1;
let mut toks = line.split([' ', '\t']).filter(|t| !t.is_empty());
let Some(gate) = toks.next() else { continue };
if gate.starts_with('#') {
continue;
}
if !valid_gate(gate) {
return Err(format!("line {n}: gate name `{gate}` is not [A-Za-z0-9][A-Za-z0-9._-]* of at most 64 characters"));
}
if gates.iter().any(|(g, _)| g == gate) {
return Err(format!("line {n}: gate `{gate}` is declared twice"));
}
let paths: Vec<String> = toks.map(String::from).collect();
if paths.is_empty() {
return Err(format!("line {n}: gate `{gate}` declares no paths"));
}
if paths.len() > MAX_SPEC_PATHS {
return Err(format!(
"line {n}: gate `{gate}` declares more than {MAX_SPEC_PATHS} paths"
));
}
for p in &paths {
if let Some(why) = path_error(p) {
return Err(format!("line {n}: {why}"));
}
}
let paths = paths
.into_iter()
.map(|p| match p.strip_prefix('?') {
Some(rest) => PathTok {
path: rest.to_string(),
optional: true,
},
None => PathTok {
path: p,
optional: false,
},
})
.collect();
gates.push((gate.to_string(), paths));
if gates.len() > MAX_SPEC_GATES {
return Err(format!("declares more than {MAX_SPEC_GATES} gates"));
}
}
Ok(Spec { gates })
}
pub fn implicit_inputs(tokens: &[String]) -> Vec<String> {
let mut attrs: Vec<String> = vec![".gitattributes".to_string()];
for t in tokens {
let comps: Vec<&str> = t.split('/').collect();
let mut prefix = String::new();
for c in &comps[..comps.len().saturating_sub(1)] {
if !prefix.is_empty() {
prefix.push('/');
}
prefix.push_str(c);
attrs.push(format!("{prefix}/.gitattributes"));
}
}
attrs.sort();
attrs.dedup();
let mut out: Vec<String> = SPEC_PATHS.iter().map(|s| s.to_string()).collect();
out.push(".gitmodules".to_string());
out.extend(attrs);
out
}
fn is_oid(s: &str) -> bool {
(s.len() == 40 || s.len() == 64)
&& s.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
}
pub fn fingerprint(root: &Path, tree: &str, spec: &Spec, gate: &str) -> Option<String> {
let toks = spec.paths_of(gate)?;
let paths: Vec<String> = toks.iter().map(|t| t.path.clone()).collect();
let required: Vec<&str> = toks
.iter()
.filter(|t| !t.optional)
.map(|t| t.path.as_str())
.collect();
if !required.is_empty() {
let names: String = required.iter().map(|p| format!("{tree}:{p}\n")).collect();
let answers =
git::stdout_with_input_in(root, &["cat-file", "--batch-check"], names.as_bytes())?;
if answers.lines().count() != required.len()
|| answers.lines().any(|l| l.ends_with(" missing"))
{
return None;
}
}
let mut args: Vec<String> = vec![
"ls-tree".into(),
"-r".into(),
"-z".into(),
"--full-tree".into(),
tree.to_string(),
"--".into(),
];
args.extend(implicit_inputs(&paths));
args.extend(paths.iter().cloned());
let argv: Vec<&str> = args.iter().map(String::as_str).collect();
let listing = git::stdout_bytes_in(root, &argv)?;
if listing.is_empty() {
return None;
}
git::stdout_with_input_in(root, &["hash-object", "--stdin"], &listing).filter(|s| is_oid(s))
}
pub fn key_preimage(gate: &str, fp: &str) -> String {
format!("amont-attest-input {gate} {fp}\n")
}
pub fn key(root: &Path, gate: &str, fp: &str) -> Option<String> {
git::stdout_with_input_in(
root,
&["hash-object", "--stdin"],
key_preimage(gate, fp).as_bytes(),
)
.filter(|s| is_oid(s))
}
pub fn input_fp<'a>(payload: &'a str, gate: &str) -> Option<&'a str> {
payload.split('\n').find_map(|line| {
let f: Vec<&str> = line.split([' ', '\t']).filter(|t| !t.is_empty()).collect();
(f.len() == 3 && f[0] == "input" && f[1] == gate && is_oid(f[2])).then(|| f[2])
})
}
pub fn conf_says_plan_exempt_off(bytes: &[u8]) -> bool {
bytes.split(|&b| b == b'\n').any(|line| {
let line: Vec<u8> = line.iter().copied().filter(|&b| b != b'\r').collect();
let words: Vec<&[u8]> = line
.split(|&b| b == b' ' || b == b'\t')
.filter(|w| !w.is_empty())
.collect();
words.len() == 3
&& words[0].eq_ignore_ascii_case(b"set")
&& words[1].eq_ignore_ascii_case(b"planExempt")
&& words[2].eq_ignore_ascii_case(b"off")
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlanExempt {
On,
Off,
Unreadable,
}
pub fn plan_exempt(root: &Path, tree: &str) -> PlanExempt {
let Some(listing) =
git::stdout_bytes_in(root, &["ls-tree", "--full-tree", tree, "--", "amont.conf"])
else {
return PlanExempt::Unreadable;
};
if listing.is_empty() {
return PlanExempt::On;
}
match git::stdout_bytes_in(root, &["cat-file", "blob", &format!("{tree}:amont.conf")]) {
Some(bytes) if conf_says_plan_exempt_off(&bytes) => PlanExempt::Off,
Some(_) => PlanExempt::On,
None => PlanExempt::Unreadable,
}
}
pub fn canon_text(listing: &str) -> Option<String> {
let mut out = String::with_capacity(listing.len());
for line in listing.lines() {
let (head, path) = line.split_once('\t')?;
let mut f = head.split(' ');
let (mode, kind, oid) = (f.next()?, f.next()?, f.next()?);
if f.next().is_some() {
return None;
}
let under_plans = path.starts_with("docs/plans/") || path.starts_with("\"docs/plans/");
if matches!(mode, "100644" | "100755") && under_plans {
out.push_str(&format!(
"{mode} {kind} {}\t{path}\n",
"0".repeat(oid.len())
));
} else {
out.push_str(line);
out.push('\n');
}
}
(!out.is_empty()).then_some(out)
}
pub fn canon(root: &Path, tree: &str) -> Option<String> {
let listing = git::stdout_bytes_in(
root,
&[
"-c",
"core.quotePath=true",
"ls-tree",
"-r",
"--full-tree",
tree,
],
)?;
let text = canon_text(std::str::from_utf8(&listing).ok()?)?;
git::stdout_with_input_in(root, &["hash-object", "--stdin"], text.as_bytes())
.filter(|s| is_oid(s))
}
pub fn canon_key_preimage(canon: &str) -> String {
format!("amont-attest-canon {canon}\n")
}
pub fn canon_key(root: &Path, canon: &str) -> Option<String> {
git::stdout_with_input_in(
root,
&["hash-object", "--stdin"],
canon_key_preimage(canon).as_bytes(),
)
.filter(|s| is_oid(s))
}
pub fn canon_claim(payload: &str) -> Option<&str> {
payload.split('\n').find_map(|line| {
let f: Vec<&str> = line.split([' ', '\t']).filter(|t| !t.is_empty()).collect();
(f.len() == 2 && f[0] == "canon" && is_oid(f[1])).then(|| f[1])
})
}
pub fn covered_gates<'a>(
tree_matches: bool,
canon_matches: bool,
gates: &'a str,
fp_of_block: impl Fn(&str) -> Option<&'a str>,
mut fp_head: impl FnMut(&str) -> Option<String>,
) -> Vec<String> {
let mut out = Vec::new();
for g in gates.split_whitespace() {
let ok = tree_matches
|| canon_matches
|| match (fp_of_block(g), fp_head(g)) {
(Some(claimed), Some(here)) => claimed == here,
_ => false,
};
if ok {
union(&mut out, std::iter::once(g));
}
}
out
}
struct Judge<'a> {
root: PathBuf,
head_tree: String,
signers: &'a Path,
principal: Option<&'a str>,
require_platform: Option<&'a str>,
anywhere: &'a [String],
spec: Option<Spec>,
fp_memo: Vec<(String, Option<String>)>,
canon_memo: Option<Option<String>>,
canon_used: bool,
covered: Vec<String>,
trail: Vec<String>,
seen_notes: Vec<String>,
seen_blocks: Vec<u64>,
verifications: usize,
exhausted: bool,
tried: bool,
}
impl Judge<'_> {
fn fp_head(&mut self, gate: &str) -> Option<String> {
if let Some((_, fp)) = self.fp_memo.iter().find(|(g, _)| g == gate) {
return fp.clone();
}
let fp = self
.spec
.as_ref()
.and_then(|s| fingerprint(&self.root, &self.head_tree, s, gate));
self.fp_memo.push((gate.to_string(), fp.clone()));
fp
}
fn canon_here(&mut self) -> Option<String> {
if let Some(c) = &self.canon_memo {
return c.clone();
}
let c = match plan_exempt(&self.root, &self.head_tree) {
PlanExempt::Off => {
push_reason(
&mut self.trail,
"planExempt is off here; canonical trees are not used".into(),
);
None
}
PlanExempt::Unreadable => {
push_reason(
&mut self.trail,
"cannot read amont.conf; canonical trees are not used".into(),
);
None
}
PlanExempt::On => {
let c = canon(&self.root, &self.head_tree);
if c.is_none() {
push_reason(
&mut self.trail,
"cannot compute the canonical tree; canonical trees are not used".into(),
);
}
c
}
};
self.canon_memo = Some(c.clone());
c
}
fn judge(&mut self, notes_ref: &str, object: &str, at: &str) {
if self.exhausted {
return;
}
let Some(listing) = git::stdout(&["notes", "--ref", notes_ref, "list", object]) else {
return;
};
let note_oid = listing.split_whitespace().next().unwrap_or("").to_string();
if self.seen_notes.contains(¬e_oid) {
return;
}
self.seen_notes.push(note_oid);
let Some(body) = git::stdout(&["notes", "--ref", notes_ref, "show", object]) else {
return;
};
self.tried = true;
if body.contains('\r') {
push_reason(
&mut self.trail,
format!("note on {at} contains carriage returns; the format is LF-only"),
);
return;
}
let (blocks, truncated) = split_blocks(&body);
if truncated {
push_reason(
&mut self.trail,
format!("note on {at} has more than {MAX_BLOCKS} blocks; the rest were ignored"),
);
}
if blocks.is_empty() {
push_reason(
&mut self.trail,
format!("note on {at} carries no signature block"),
);
return;
}
let several = blocks.len() > 1;
for (i, block) in blocks.iter().enumerate() {
let where_ = if several {
format!("{at} block {}", i + 1)
} else {
at.to_string()
};
let payload = &block.payload;
if payload.split('\n').next() != Some(FORMAT) {
push_reason(
&mut self.trail,
format!("note on {where_} is not {FORMAT} — a newer producer wrote it"),
);
continue;
}
let (Some(tree), Some(gates), Some(ran_on)) = (
field(payload, "tree"),
field(payload, "gates"),
field(payload, "platform"),
) else {
push_reason(
&mut self.trail,
format!("note on {where_} is missing a required field"),
);
continue;
};
if gates.is_empty() {
push_reason(
&mut self.trail,
"attestation lists no gates — a signed way of saying nothing".into(),
);
continue;
}
let mut h = std::collections::hash_map::DefaultHasher::new();
block.payload.hash(&mut h);
block.signature.hash(&mut h);
let bh = h.finish();
if self.seen_blocks.contains(&bh) {
continue;
}
self.seen_blocks.push(bh);
if self.verifications >= MAX_VERIFICATIONS {
push_reason(
&mut self.trail,
format!("the budget of {MAX_VERIFICATIONS} signature verifications is spent; remaining candidates were not read"),
);
self.exhausted = true;
return;
}
self.verifications += 1;
let signer = match verify(payload, &block.signature, self.signers, self.principal) {
Ok(signer) => signer,
Err(why) => {
push_reason(&mut self.trail, format!("note on {where_}: {why}"));
continue;
}
};
let tree_matches = tree == self.head_tree;
let canon_matches = !tree_matches
&& match (
canon_claim(payload),
canon_claim(payload).and_then(|_| self.canon_here()),
) {
(Some(claimed), Some(here)) => claimed == here,
_ => false,
};
let kept = covered_gates(
tree_matches,
canon_matches,
gates,
|g| input_fp(payload, g),
|g| self.fp_head(g),
);
if kept.is_empty() {
push_reason(
&mut self.trail,
format!(
"attested tree {tree} is not the checked-out tree {} and neither its canonical tree nor an input fingerprint of {where_} matches",
self.head_tree
),
);
continue;
}
let how = if tree_matches {
""
} else if canon_matches {
" (by canonical tree)"
} else {
" (by input fingerprint)"
};
let kept_str = kept.join(" ");
if platform_matches(self.require_platform, ran_on) {
union(&mut self.covered, kept.iter().map(String::as_str));
self.canon_used |= canon_matches;
push_reason(
&mut self.trail,
format!("covered by {signer} on {ran_on}{how}: {kept_str}"),
);
continue;
}
let accepted: Vec<&str> = kept
.iter()
.map(String::as_str)
.filter(|g| self.anywhere.iter().any(|a| a == g))
.collect();
union(&mut self.covered, accepted.iter().copied());
self.canon_used |= canon_matches && !accepted.is_empty();
let want = self.require_platform.unwrap_or("any");
let mut reason = format!("attested on {ran_on} by {signer}{how}, this leg is {want}");
if !accepted.is_empty() {
reason.push_str("; accepted anywhere: ");
reason.push_str(&accepted.join(" "));
}
push_reason(&mut self.trail, reason);
}
}
}
fn load_spec(root: &Path, head_tree: &str, trail: &mut Vec<String>) -> Option<Spec> {
let present: Vec<&str> = SPEC_PATHS
.iter()
.copied()
.filter(|p| git::succeeds_in(root, &["cat-file", "-e", &format!("{head_tree}:{p}")]))
.collect();
let path = match present.as_slice() {
[] => return None,
[one] => *one,
_ => {
push_reason(
trail,
format!(
"both {} and {} exist; input fingerprints disabled",
SPEC_PATHS[0], SPEC_PATHS[1]
),
);
return None;
}
};
let Some(bytes) =
git::stdout_bytes_in(root, &["cat-file", "blob", &format!("{head_tree}:{path}")])
else {
push_reason(
trail,
format!("cannot read {path} from HEAD's tree; input fingerprints disabled"),
);
return None;
};
match parse_spec(&bytes) {
Ok(spec) => Some(spec),
Err(why) => {
push_reason(trail, format!("{path} {why}; input fingerprints disabled"));
None
}
}
}
pub fn evaluate(
signers: &Path,
principal: Option<&str>,
require_platform: Option<&str>,
anywhere: &[String],
include_local: bool,
) -> Verdict {
let mut trail = Vec::new();
let mut loud = Vec::new();
if !signers.is_file() {
return Verdict::nothing(format!("{} does not exist", signers.display()));
}
let Some(root) = git::stdout(&["rev-parse", "--show-toplevel"]).map(PathBuf::from) else {
push_reason(&mut trail, "not a git repository".into());
return Verdict {
gates: Vec::new(),
trail,
notes: None,
loud,
};
};
let (st_main, st_inputs) = sync_mirrors(&mut loud);
let notes = Some((st_main, st_inputs));
for (r, st) in [(NOTES_REF, st_main), (INPUTS_REF, st_inputs)] {
if let Some(why) = state_reason(r, st) {
push_reason(&mut trail, why);
}
if let Some(why) = lock_reason(r) {
push_reason(&mut trail, why);
}
}
if !include_local {
for r in [NOTES_REF, INPUTS_REF] {
let local = format!("refs/notes/{LOCAL_PREFIX}{r}");
if git::stdout(&["notes", "--ref", &format!("{LOCAL_PREFIX}{r}"), "list"]).is_some() {
push_reason(
&mut trail,
format!("{local} holds unpushed blocks, ignored without --include-local"),
);
}
}
}
let refs = |mirror: &str, st: Mirror| -> Vec<String> {
let mut v = Vec::new();
if st.judged() {
v.push(mirror.to_string());
}
if include_local {
v.push(format!("{LOCAL_PREFIX}{mirror}"));
}
v
};
let refs_main = refs(NOTES_REF, st_main);
let refs_inputs = refs(INPUTS_REF, st_inputs);
let Some(head_tree) = git::stdout(&["rev-parse", "HEAD^{tree}"]) else {
push_reason(
&mut trail,
"cannot resolve HEAD^{tree} — not a git repository?".into(),
);
return Verdict {
gates: Vec::new(),
trail,
notes,
loud,
};
};
let spec = load_spec(&root, &head_tree, &mut trail);
let mut judge = Judge {
root,
head_tree: head_tree.clone(),
signers,
principal,
require_platform,
anywhere,
spec,
fp_memo: Vec::new(),
canon_memo: None,
canon_used: false,
covered: Vec::new(),
trail,
seen_notes: Vec::new(),
seen_blocks: Vec::new(),
verifications: 0,
exhausted: false,
tried: false,
};
for candidate in [head_tree.as_str(), "HEAD", "HEAD^2"] {
let Some(object) = git::stdout(&["rev-parse", "--verify", "--quiet", candidate]) else {
continue;
};
for r in &refs_main {
judge.judge(r, &object, &label(r, candidate));
}
}
let spec_gates: Vec<String> = judge
.spec
.as_ref()
.map(|s| s.gates.iter().map(|(g, _)| g.clone()).collect())
.unwrap_or_default();
for gate in spec_gates {
if judge.exhausted || judge.covered.contains(&gate) {
continue;
}
let Some(fp) = judge.fp_head(&gate) else {
push_reason(
&mut judge.trail,
format!("gate {gate}: no fingerprint here (a declared path does not exist in this tree)"),
);
continue;
};
let Some(k) = key(&judge.root, &gate, &fp) else {
continue;
};
let at = format!("input {gate} {}", &fp[..12]);
for r in &refs_inputs {
judge.judge(r, &k, &label(r, &at));
}
}
if !judge.exhausted {
if let Some(c) = judge.canon_here() {
if let Some(k) = canon_key(&judge.root, &c) {
let at = format!("canon {}", &c[..12]);
for r in &refs_inputs {
judge.judge(r, &k, &label(r, &at));
}
if judge.canon_used {
push_reason(&mut judge.trail, format!("canonical tree {c}, key {k}"));
}
}
}
}
if !judge.tried {
push_reason(
&mut judge.trail,
format!("no attestation found for tree {head_tree}"),
);
}
Verdict {
gates: judge.covered,
trail: judge.trail,
notes,
loud,
}
}
fn label(notes_ref: &str, at: &str) -> String {
if notes_ref.starts_with(LOCAL_PREFIX) {
format!("{at} (unpushed)")
} else {
at.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
const SIG: &str = "-----BEGIN SSH SIGNATURE-----\nU1NIU0lH\n-----END SSH SIGNATURE-----";
fn block(n: u32) -> String {
format!("amont-attest-v2\ntree t{n}\ngates g{n}\nplatform p{n}\n\n{SIG}")
}
#[test]
fn platform_matching_is_exact_or_os_only_never_a_substring() {
assert!(platform_matches(None, "s390x-aix"));
assert!(platform_matches(Some("x86_64-linux"), "x86_64-linux"));
assert!(!platform_matches(Some("x86_64-linux"), "aarch64-linux"));
assert!(platform_matches(Some("linux"), "x86_64-linux"));
assert!(platform_matches(Some("linux"), "aarch64-linux"));
assert!(!platform_matches(Some("inux"), "x86_64-linux"));
assert!(!platform_matches(Some("x86_64"), "x86_64-linux"));
assert!(!platform_matches(Some("linux"), "linux"));
}
#[test]
fn one_block_restores_the_signed_trailing_newline() {
let (blocks, truncated) = split_blocks(&block(1));
assert!(!truncated);
assert_eq!(blocks.len(), 1);
assert_eq!(
blocks[0].payload,
"amont-attest-v2\ntree t1\ngates g1\nplatform p1\n"
);
assert_eq!(blocks[0].signature, format!("{SIG}\n"));
}
#[test]
fn two_blocks_as_git_notes_append_writes_them() {
let body = format!("{}\n\n{}", block(1), block(2));
let (blocks, _) = split_blocks(&body);
assert_eq!(blocks.len(), 2);
assert!(blocks[1].payload.starts_with("amont-attest-v2\ntree t2\n"));
let body = format!("{}\n\n\n\n{}\n", block(1), block(2));
assert_eq!(split_blocks(&body).0.len(), 2);
}
#[test]
fn end_of_input_closes_an_open_signature() {
let body = "amont-attest-v2\ntree abc\n\n-----BEGIN SSH SIGNATURE-----\nx";
let (payload, sig) = split_note(body).unwrap();
assert_eq!(payload, "amont-attest-v2\ntree abc\n");
assert_eq!(sig, "-----BEGIN SSH SIGNATURE-----\nx\n");
}
#[test]
fn framing_errors_stop_parsing_and_keep_what_came_before() {
assert!(split_note("no blank line here").is_none());
assert!(split_note("payload\n\nnot a signature").is_none());
let body = format!("{}\n\npayload2\n\nnot a signature", block(1));
assert_eq!(split_blocks(&body).0.len(), 1);
let body = format!("p\n\n-----BEGIN SSH SIGNATURE-----\nx\n\n{}", block(2));
let (blocks, _) = split_blocks(&body);
assert_eq!(blocks.len(), 1);
assert!(blocks[0].signature.contains("tree t2"));
let body = format!("{}\n\ngarbage", block(1));
assert_eq!(split_blocks(&body).0.len(), 1);
}
#[test]
fn a_crlf_note_has_no_blank_line_and_therefore_no_block() {
let body = block(1).replace('\n', "\r\n");
assert!(split_blocks(&body).0.is_empty());
}
#[test]
fn at_most_max_blocks_are_read() {
let body: Vec<String> = (0..40).map(block).collect();
let (blocks, truncated) = split_blocks(&body.join("\n\n"));
assert_eq!(blocks.len(), MAX_BLOCKS);
assert!(truncated);
let body: Vec<String> = (0..MAX_BLOCKS as u32).map(block).collect();
let (blocks, truncated) = split_blocks(&body.join("\n\n"));
assert_eq!(blocks.len(), MAX_BLOCKS);
assert!(!truncated);
}
#[test]
fn union_keeps_first_appearance_order_without_duplicates() {
let mut v = vec!["a".to_string()];
union(&mut v, "b a c b".split_whitespace());
assert_eq!(v, ["a", "b", "c"]);
}
fn spec_of(s: &str) -> Result<Spec, String> {
parse_spec(s.as_bytes())
}
#[test]
fn spec_parses_gates_comments_tabs_and_a_missing_final_newline() {
let s =
spec_of("# a comment\n\nci-fmt\tsrc Cargo.toml\n # indented comment\ntest src tests")
.unwrap();
let req = |p: &str| PathTok {
path: p.to_string(),
optional: false,
};
assert_eq!(
s.gates,
vec![
("ci-fmt".to_string(), vec![req("src"), req("Cargo.toml")]),
("test".to_string(), vec![req("src"), req("tests")]),
]
);
assert_eq!(s.paths_of("test").unwrap().len(), 2);
assert!(s.paths_of("nope").is_none());
}
#[test]
fn spec_rejects_every_byte_outside_printable_ascii_first() {
for bad in [
b"g src\r\n".as_slice(),
b"g src\0",
b"g sr\xc3\xa9",
b"g\xc2\xa0src",
b"g s\x01rc",
] {
assert!(parse_spec(bad).unwrap_err().contains("byte 0x"), "{bad:?}");
}
assert!(parse_spec(&vec![b'\n'; MAX_SPEC_BYTES + 1])
.unwrap_err()
.contains("larger"));
let mut ok = b"g src".to_vec();
ok.resize(MAX_SPEC_BYTES, b'\n');
assert!(parse_spec(&ok).is_ok());
}
#[test]
fn spec_rejects_bad_gates_paths_duplicates_and_caps() {
assert!(spec_of("g").unwrap_err().contains("no paths"));
assert!(spec_of("g src\ng tests").unwrap_err().contains("twice"));
assert!(spec_of("-g src").unwrap_err().contains("gate name"));
assert!(spec_of("g/x src").unwrap_err().contains("gate name"));
assert!(spec_of(&format!("{} src", "a".repeat(65)))
.unwrap_err()
.contains("gate name"));
for p in [
"tests/*.sh",
"a?",
"a[b]",
"a\\b",
":!x",
":(glob)x",
":/x",
"./x",
"../x",
"/x",
"x/",
"a/../b",
"a/./b",
"a//b",
] {
assert!(spec_of(&format!("g {p}")).is_err(), "{p} should be invalid");
}
let many: String = (0..65).map(|i| format!("g{i} src\n")).collect();
assert!(spec_of(&many).unwrap_err().contains("more than 64 gates"));
let wide = format!("g {}", vec!["src"; 65].join(" "));
assert!(spec_of(&wide).unwrap_err().contains("more than 64 paths"));
let many: String = (0..64).map(|i| format!("g{i} src\n")).collect();
assert!(spec_of(&many).is_ok());
}
#[test]
fn optional_paths_are_marked_once_and_stripped() {
let s = spec_of("g src ?build.rs ?a/b/c").unwrap();
let toks = s.paths_of("g").unwrap();
assert_eq!(
toks.iter()
.map(|t| (t.path.as_str(), t.optional))
.collect::<Vec<_>>(),
[("src", false), ("build.rs", true), ("a/b/c", true)]
);
assert!(spec_of("g ?nope").is_ok());
for bad in [
"?", "??x", "?/x", "?./x", "?x/", "?:x", "x?", "?a//b", "?a/../b",
] {
assert!(
spec_of(&format!("g {bad}")).is_err(),
"{bad} should be invalid"
);
}
assert!(spec_of("g ?").unwrap_err().contains("names nothing"));
}
#[test]
fn implicit_inputs_cover_both_specs_gitmodules_and_ancestor_attributes() {
let v = implicit_inputs(&[
"src".into(),
"crates/foo/src".into(),
"crates/bar/Cargo.toml".into(),
]);
assert_eq!(
v,
vec![
".forgejo/attest-inputs",
".github/attest-inputs",
".gitmodules",
".gitattributes",
"crates/.gitattributes",
"crates/bar/.gitattributes",
"crates/foo/.gitattributes",
]
);
}
#[test]
fn input_lines_need_exactly_three_fields_and_a_real_oid() {
let fp = "a".repeat(40);
let p = format!("amont-attest-v2\ninput g1 {fp}\ninput g2 {fp} junk\ninput g3 abc\ninput\tg4\t{fp}\ninput g1 {}\n", "b".repeat(40));
assert_eq!(input_fp(&p, "g1"), Some(fp.as_str()));
assert_eq!(input_fp(&p, "g2"), None);
assert_eq!(input_fp(&p, "g3"), None);
assert_eq!(input_fp(&p, "g4"), Some(fp.as_str()));
assert_eq!(input_fp(&p, "g"), None);
assert_eq!(input_fp(&p, "g10"), None);
let sha256 = "c".repeat(64);
assert_eq!(
input_fp(&format!("input g {sha256}\n"), "g"),
Some(sha256.as_str())
);
assert_eq!(
input_fp(&format!("input g {}\n", "C".repeat(40)), "g"),
None
);
}
#[test]
fn key_preimage_is_exact() {
assert_eq!(
key_preimage("ci-fmt", "abc"),
"amont-attest-input ci-fmt abc\n"
);
}
#[test]
fn the_uniform_rule_per_gate() {
let fp = |g: &str| match g {
"g1" => Some("f1"),
"g2" => Some("f2"),
_ => None,
};
let head = |g: &str| match g {
"g1" => Some("f1".to_string()),
"g2" => Some("other".to_string()),
_ => None,
};
assert_eq!(
covered_gates(true, false, "g1 g2 g3", fp, head),
["g1", "g2", "g3"]
);
assert_eq!(covered_gates(false, false, "g1 g2 g3", fp, head), ["g1"]);
assert_eq!(
covered_gates(false, false, "g2 g3", fp, head),
Vec::<String>::new()
);
assert_eq!(covered_gates(true, false, "g1 g1", fp, head), ["g1"]);
assert_eq!(
covered_gates(false, true, "g1 g2 g3", fp, head),
["g1", "g2", "g3"]
);
}
const O1: &str = "1111111111111111111111111111111111111111";
const O2: &str = "2222222222222222222222222222222222222222";
const Z: &str = "0000000000000000000000000000000000000000";
#[test]
fn canon_text_neutralises_plan_content_and_keeps_everything_else() {
let a = format!(
"100644 blob {O1}\tREADME.md\n100644 blob {O1}\tdocs/plans/a.md\n100755 blob {O1}\tdocs/plans/sub/run.sh\n"
);
let edited = a.replace(
&format!("{O1}\tdocs/plans/a.md"),
&format!("{O2}\tdocs/plans/a.md"),
);
assert_eq!(canon_text(&a), canon_text(&edited));
assert!(canon_text(&a)
.unwrap()
.contains(&format!("100644 blob {Z}\tdocs/plans/a.md\n")));
let other = a.replace(&format!("{O1}\tREADME.md"), &format!("{O2}\tREADME.md"));
assert_ne!(canon_text(&a), canon_text(&other));
let added = format!("{a}100644 blob {O1}\tdocs/plans/b.md\n");
assert_ne!(canon_text(&a), canon_text(&added));
let renamed = a.replace("docs/plans/a.md", "docs/plans/c.md");
assert_ne!(canon_text(&a), canon_text(&renamed));
}
#[test]
fn canon_text_exempts_only_the_top_level_docs_plans_of_regular_files() {
let nested = format!("100644 blob {O1}\tx/docs/plans/a.md\n");
assert!(canon_text(&nested).unwrap().contains(O1));
let near = format!("100644 blob {O1}\tdocs/plans-old/a.md\n100644 blob {O1}\tdocs/plans\n");
assert!(canon_text(&near).unwrap().contains(O1));
let link = format!("120000 blob {O1}\tdocs/plans/l\n160000 commit {O1}\tdocs/plans/m\n");
assert_eq!(canon_text(&link).unwrap(), link);
let quoted = format!("100644 blob {O1}\t\"docs/plans/a\\nb.md\"\n");
assert!(canon_text("ed).unwrap().contains(Z));
let long = format!("100644 blob {}\tdocs/plans/a.md\n", "a".repeat(64));
assert!(canon_text(&long).unwrap().contains(&"0".repeat(64)));
assert_eq!(canon_text(""), None);
assert_eq!(canon_text("garbage\n"), None);
}
#[test]
fn the_opt_out_is_one_tolerant_reading() {
for yes in [
"set planExempt off\n",
"SET PLANEXEMPT OFF",
" set\tplanExempt off \r\n",
"gate x\nset planExempt off\ngate y\n",
"set planExempt on\nset planExempt off\n",
"set planExempt off\nset planExempt on\n",
] {
assert!(conf_says_plan_exempt_off(yes.as_bytes()), "{yes:?}");
}
for no in [
"",
"set planExempt on\n",
"set planExempt off # trailing comment\n",
"# set planExempt off\n",
"set planExempt\n",
"set planExempt off off\n",
"setplanExempt off\n",
"set planexempt of\n",
] {
assert!(!conf_says_plan_exempt_off(no.as_bytes()), "{no:?}");
}
}
#[test]
fn a_canon_claim_is_two_fields_and_a_real_oid() {
let p = format!("amont-attest-v2\ncanon {O1}\namont x\n");
assert_eq!(canon_claim(&p), Some(O1));
assert_eq!(canon_claim(&format!("canon {O1} extra\n")), None);
assert_eq!(canon_claim("canon xyz\n"), None);
assert_eq!(canon_claim("canon\n"), None);
assert_eq!(canon_claim(&format!("canon {O1}\ncanon {O2}\n")), Some(O1));
assert_eq!(canon_key_preimage(O1), format!("amont-attest-canon {O1}\n"));
}
}