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 present: Vec<&'static str> = SPEC_PATHS
.iter()
.copied()
.filter(|p| crate::git::succeeds(&["cat-file", "-e", &format!("{tree}:{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))
}