pub(crate) const SPEC_PATHS: [&str; 2] = [".forgejo/attest-inputs", ".github/attest-inputs"];
const MAX_SPEC_BYTES: usize = 65536;
const MAX_SPEC_GATES: usize = 64;
const MAX_SPEC_PATHS: usize = 64;
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 == '-')
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct PathTok {
pub(crate) path: String,
pub(crate) optional: bool,
}
fn bad_path(tok: &str) -> bool {
let tok = match tok.strip_prefix('?') {
Some("") => return true,
Some(rest) => rest,
None => tok,
};
tok.starts_with(':')
|| tok.starts_with('/')
|| tok.starts_with("./")
|| tok.starts_with("../")
|| tok.ends_with('/')
|| tok
.chars()
.any(|c| matches!(c, '*' | '?' | '[' | ']' | '\\'))
|| tok
.split('/')
.any(|c| c.is_empty() || c == "." || c == "..")
}
pub(crate) fn parse_spec(bytes: &[u8]) -> Option<Vec<(String, Vec<PathTok>)>> {
if bytes.len() > MAX_SPEC_BYTES
|| bytes
.iter()
.any(|&b| !(b == b' ' || b == b'\t' || b == b'\n' || (0x21..=0x7e).contains(&b)))
{
return None;
}
let text = std::str::from_utf8(bytes).ok()?;
let mut gates: Vec<(String, Vec<PathTok>)> = Vec::new();
for line in text.split('\n') {
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) || gates.iter().any(|(g, _)| g == gate) {
return None;
}
let paths: Vec<String> = toks.map(String::from).collect();
if paths.is_empty() || paths.len() > MAX_SPEC_PATHS || paths.iter().any(|p| bad_path(p)) {
return None;
}
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 None;
}
}
Some(gates)
}
pub(crate) fn spec_at(tree: &str) -> Option<Vec<(String, Vec<PathTok>)>> {
read_spec(tree).ok().map(|(_, spec)| spec)
}
#[derive(Debug, PartialEq)]
pub enum SpecError {
Missing,
Ambiguous,
Invalid(&'static str),
Unlisted,
}
pub(crate) fn read_spec(tree: &str) -> Result<(&'static str, Spec), SpecError> {
let listing = crate::git::stdout_raw(&[
"ls-tree",
"--full-tree",
tree,
"--",
SPEC_PATHS[0],
SPEC_PATHS[1],
])
.ok_or(SpecError::Unlisted)?;
let listing = String::from_utf8_lossy(&listing);
let present: Vec<&'static str> = SPEC_PATHS
.iter()
.copied()
.filter(|p| {
listing
.lines()
.any(|l| l.split_once('\t').is_some_and(|(_, name)| name == *p))
})
.collect();
let path = match present.as_slice() {
[] => return Err(SpecError::Missing),
[path] => *path,
_ => return Err(SpecError::Ambiguous),
};
let bytes = crate::git::stdout_raw(&["cat-file", "blob", &format!("{tree}:{path}")])
.ok_or(SpecError::Invalid(path))?;
parse_spec(&bytes)
.map(|spec| (path, spec))
.ok_or(SpecError::Invalid(path))
}
pub(crate) type Spec = Vec<(String, Vec<PathTok>)>;
pub const EXEMPT: [&str; 1] = ["docs/plans/"];
#[derive(Debug, Default, PartialEq)]
pub struct Findings {
pub undeclared: Vec<String>,
pub unfingerprintable: Vec<String>,
pub uncovered: Vec<(String, Vec<String>)>,
}
const LIST_CAP: usize = 50;
impl SpecError {
pub fn render(&self, tree: &str) -> String {
match self {
SpecError::Missing => format!(
"check-attest-inputs: no {} or {} on {tree}",
SPEC_PATHS[0], SPEC_PATHS[1]
),
SpecError::Ambiguous => format!(
"check-attest-inputs: both {} and {} exist on {tree}; that disables the route",
SPEC_PATHS[0], SPEC_PATHS[1]
),
SpecError::Invalid(path) => format!(
"check-attest-inputs: {path} is invalid on {tree} (one violation invalidates the whole spec; see attest SPEC, \"Input fingerprints\")"
),
SpecError::Unlisted => {
format!("check-attest-inputs: git could not list {tree}, or it is empty")
}
}
}
}
impl Findings {
pub fn render(&self) -> String {
let mut out = String::new();
for g in &self.undeclared {
out.push_str(&format!(
"check-attest-inputs: {g}: not declared in the spec\n"
));
}
for g in &self.unfingerprintable {
out.push_str(&format!(
"check-attest-inputs: {g}: cannot be fingerprinted (a required path is missing)\n"
));
}
for (g, paths) in &self.uncovered {
out.push_str(&format!("check-attest-inputs: {g}: not covered:\n"));
for p in paths.iter().take(LIST_CAP) {
out.push_str(&format!(" {p}\n"));
}
if paths.len() > LIST_CAP {
out.push_str(&format!(" ... and {} more\n", paths.len() - LIST_CAP));
}
}
out.push_str(
"check-attest-inputs: fix: declare each path above for that gate (a directory \
covers everything under it); only a path no gate reads may be exempt, and that \
is a code-review decision. An undeclared path makes CI and the hooks skip \
wrongly the day it changes.\n",
);
out
}
pub fn is_empty(&self) -> bool {
self.undeclared.is_empty() && self.unfingerprintable.is_empty() && self.uncovered.is_empty()
}
}
fn is_implicit(file: &[u8]) -> bool {
SPEC_PATHS.iter().any(|p| p.as_bytes() == file)
|| file == b".gitmodules"
|| file == b".gitattributes"
}
fn covers(decl: &str, file: &[u8]) -> bool {
let d = decl.as_bytes();
file == d || (file.len() > d.len() && file.starts_with(d) && file[d.len()] == b'/')
}
pub(crate) fn declares(toks: &[PathTok], file: &str) -> bool {
toks.iter().any(|t| covers(&t.path, file.as_bytes()))
}
pub(crate) fn coverage(
spec: &Spec,
required: &[String],
files: &[Vec<u8>],
can_fingerprint: impl Fn(&[PathTok]) -> bool,
) -> Findings {
let mut out = Findings::default();
for gate in required {
let Some((_, toks)) = spec.iter().find(|(g, _)| g == gate) else {
out.undeclared.push(gate.clone());
continue;
};
if !can_fingerprint(toks) {
out.unfingerprintable.push(gate.clone());
}
let missing: Vec<String> = files
.iter()
.filter(|f| !EXEMPT.iter().any(|e| f.starts_with(e.as_bytes())))
.filter(|f| !is_implicit(f))
.filter(|f| !toks.iter().any(|t| covers(&t.path, f)))
.map(|f| String::from_utf8_lossy(f).into_owned())
.collect();
if !missing.is_empty() {
out.uncovered.push((gate.clone(), missing));
}
}
out
}
pub fn check(tree: &str, required: &[String]) -> Result<Findings, SpecError> {
let (_, spec) = read_spec(tree)?;
let listing =
crate::git::stdout_raw(&["ls-tree", "-r", "-z", "--full-tree", "--name-only", tree])
.filter(|l| !l.is_empty())
.ok_or(SpecError::Unlisted)?;
let files: Vec<Vec<u8>> = listing
.split(|&b| b == 0)
.filter(|f| !f.is_empty())
.map(<[u8]>::to_vec)
.collect();
Ok(coverage(&spec, required, &files, |toks| {
fingerprint(tree, toks).is_some()
}))
}
pub(crate) 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
}
pub(crate) 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(crate) fn fingerprint(tree: &str, toks: &[PathTok]) -> Option<String> {
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 = crate::git::stdout_piped(&["cat-file", "--batch-check"], &names)?;
if answers.lines().count() != required.len()
|| answers.lines().any(|l| l.ends_with(" missing"))
{
return None;
}
}
let paths: Vec<String> = toks.iter().map(|t| t.path.clone()).collect();
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 = crate::git::stdout_raw(&argv)?;
if listing.is_empty() {
return None;
}
crate::git::stdout_piped_in(
std::path::Path::new("."),
&["hash-object", "--stdin"],
&listing,
)
.filter(|s| is_oid(s))
}
pub(crate) fn input_key(gate: &str, fp: &str) -> Option<String> {
let pre = format!("amont-attest-input {gate} {fp}\n");
crate::git::stdout_piped_in(
std::path::Path::new("."),
&["hash-object", "--stdin"],
pre.as_bytes(),
)
.filter(|s| is_oid(s))
}
pub(crate) 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")
})
}
pub(crate) fn plan_exempt_off(tree: &str) -> bool {
let manifest = crate::manifest::MANIFEST;
let Some(listing) = crate::git::stdout_raw(&["ls-tree", "--full-tree", tree, "--", manifest])
else {
return true;
};
if listing.is_empty() {
return false;
}
match crate::git::stdout_raw(&["cat-file", "blob", &format!("{tree}:{manifest}")]) {
Some(bytes) => conf_says_plan_exempt_off(&bytes),
None => true,
}
}
pub(crate) 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(crate) fn canon(tree: &str) -> Option<String> {
let listing = crate::git::stdout_raw(&[
"-c",
"core.quotePath=true",
"ls-tree",
"-r",
"--full-tree",
tree,
])?;
let text = canon_text(std::str::from_utf8(&listing).ok()?)?;
crate::git::stdout_piped_in(
std::path::Path::new("."),
&["hash-object", "--stdin"],
text.as_bytes(),
)
.filter(|s| is_oid(s))
}
fn hashed(preimage: &str) -> Option<String> {
crate::git::stdout_piped_in(
std::path::Path::new("."),
&["hash-object", "--stdin"],
preimage.as_bytes(),
)
.filter(|s| is_oid(s))
}
pub(crate) fn canon_key(canon: &str) -> Option<String> {
hashed(&format!("amont-attest-canon {canon}\n"))
}
pub(crate) fn canon_gate_key(canon: &str) -> Option<String> {
hashed(&format!("amont-gate-canon {canon}\n"))
}
pub(crate) fn canonical_route(tree: &str) -> Option<String> {
match read_spec(tree) {
Err(SpecError::Missing) => {}
_ => return None,
}
canon_unless_off(tree)
}
pub(crate) fn canon_for_attest(tree: &str) -> Option<String> {
match read_spec(tree) {
Ok(_) | Err(SpecError::Missing) => {}
Err(_) => return None,
}
canon_unless_off(tree)
}
fn canon_unless_off(tree: &str) -> Option<String> {
if plan_exempt_off(tree) {
return None;
}
canon(tree)
}
#[cfg(test)]
mod tests {
use super::*;
const O1: &str = "1111111111111111111111111111111111111111";
const O2: &str = "2222222222222222222222222222222222222222";
const Z: &str = "0000000000000000000000000000000000000000";
#[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 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));
}
type CanonCase = (String, String, Option<String>);
fn canon_vectors(text: &str) -> Vec<CanonCase> {
let mut cases = Vec::new();
let mut open: Option<(String, String, Option<String>, bool)> = None;
for (n, line) in text.split('\n').enumerate() {
let at = n + 1;
match open.as_mut() {
None if line.is_empty() || line.starts_with('#') => {}
None => {
let name = line
.strip_prefix("case ")
.unwrap_or_else(|| panic!("canon.txt:{at}: expected `case`: {line:?}"));
open = Some((name.to_string(), String::new(), None, false));
}
Some((_, listing, out, none)) => {
if line == "end" {
let (name, listing, out, none) = open.take().unwrap();
assert!(
out.is_some() != none,
"canon.txt:{at}: `{name}` needs `out` lines or one `none`"
);
cases.push((name, listing, out));
} else if line == "none" {
assert!(out.is_none() && !*none, "canon.txt:{at}: misplaced `none`");
*none = true;
} else if let Some(rec) = line.strip_prefix("in ") {
assert!(
out.is_none() && !*none,
"canon.txt:{at}: `in` after the answer"
);
listing.push_str(rec);
listing.push('\n');
} else if let Some(rec) = line.strip_prefix("out ") {
assert!(!*none, "canon.txt:{at}: `out` after `none`");
let o = out.get_or_insert_with(String::new);
o.push_str(rec);
o.push('\n');
} else {
panic!("canon.txt:{at}: not a directive: {line:?}");
}
}
}
}
assert!(open.is_none(), "canon.txt: the last case has no `end`");
cases
}
#[test]
fn canon_text_gives_every_known_answer() {
let cases = canon_vectors(include_str!("../tests/vectors/canon.txt"));
assert!(cases.iter().any(|c| c.2.is_some()) && cases.iter().any(|c| c.2.is_none()));
for (name, listing, want) in &cases {
assert_eq!(canon_text(listing).as_ref(), want.as_ref(), "{name}");
}
}
#[test]
fn the_vectors_reader_refuses_a_damaged_file() {
for bad in [
"stray\n",
"case x\nin a\tb\n",
"case x\nend\n",
"case x\nnone\nout y\nend\n",
"case x\nout y\nin a\tb\nend\n",
"case x\nwhat\nend\n",
] {
assert!(
std::panic::catch_unwind(|| canon_vectors(bad)).is_err(),
"{bad:?}"
);
}
}
#[test]
fn a_tree_git_cannot_list_has_no_spec_and_opens_no_route() {
let bogus = "0123456789012345678901234567890123456789";
assert_eq!(read_spec(bogus).err(), Some(SpecError::Unlisted));
assert_eq!(canonical_route(bogus), None);
assert_eq!(canon_for_attest(bogus), None);
assert!(plan_exempt_off(bogus));
}
}