use std::path::Path;
use crate::git;
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;
#[derive(Debug, PartialEq, Clone)]
pub struct PathTok {
pub path: String,
pub optional: bool,
}
#[derive(Debug, PartialEq, Default)]
#[non_exhaustive]
pub struct Spec {
pub gates: Vec<(String, Vec<PathTok>)>,
pub plan_exempt_off: bool,
}
pub const PLAN_EXEMPT_DIRECTIVE: [&str; 2] = ["@plan-exempt", "off"];
pub fn is_plan_exempt_directive(line: &[u8]) -> bool {
let words: Vec<&[u8]> = line
.split(|&b| b == b' ' || b == b'\t')
.filter(|w| !w.is_empty())
.collect();
words.len() == 2
&& words[0] == PLAN_EXEMPT_DIRECTIVE[0].as_bytes()
&& words[1] == PLAN_EXEMPT_DIRECTIVE[1].as_bytes()
}
impl Spec {
pub fn paths_of(&self, gate: &str) -> Option<&[PathTok]> {
self.gates
.iter()
.find(|(g, _)| g == gate)
.map(|(_, p)| p.as_slice())
}
}
pub(crate) 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, Eq)]
#[non_exhaustive]
pub enum PathProblem {
NamesNothing,
Magic,
Absolute,
Relative,
TrailingSlash,
Glob(char),
BadComponent,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Error {
TooLarge,
Byte(u8),
GateName { line: usize, gate: String },
Duplicate { line: usize, gate: String },
NoPaths { line: usize, gate: String },
TooManyPaths { line: usize, gate: String },
Path {
line: usize,
path: String,
problem: PathProblem,
},
TooManyGates,
Directive { line: usize, text: String },
}
impl std::fmt::Display for PathProblem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PathProblem::NamesNothing => write!(f, "names nothing"),
PathProblem::Magic => write!(f, "starts with `:` (pathspec magic is not allowed)"),
PathProblem::Absolute => {
write!(f, "is absolute; paths are relative to the repository root")
}
PathProblem::Relative => write!(f, "starts with `./` or `../`"),
PathProblem::TrailingSlash => write!(f, "ends with `/`; name the directory without it"),
PathProblem::Glob(c) => write!(
f,
"contains `{c}`; git ls-tree does not glob, only literal paths are accepted"
),
PathProblem::BadComponent => write!(f, "has an empty, `.` or `..` component"),
}
}
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::TooLarge => write!(f, "is larger than {MAX_SPEC_BYTES} bytes"),
Error::Byte(b) => write!(
f,
"contains byte 0x{b:02x}; only printable ASCII, space, tab and LF are allowed"
),
Error::GateName { line, gate } => write!(
f,
"line {line}: gate name `{gate}` is not [A-Za-z0-9][A-Za-z0-9._-]* of at most 64 characters"
),
Error::Duplicate { line, gate } => {
write!(f, "line {line}: gate `{gate}` is declared twice")
}
Error::NoPaths { line, gate } => {
write!(f, "line {line}: gate `{gate}` declares no paths")
}
Error::TooManyPaths { line, gate } => write!(
f,
"line {line}: gate `{gate}` declares more than {MAX_SPEC_PATHS} paths"
),
Error::Path {
line,
path,
problem,
} => write!(f, "line {line}: path `{path}` {problem}"),
Error::TooManyGates => write!(f, "declares more than {MAX_SPEC_GATES} gates"),
Error::Directive { line, text } => write!(
f,
"line {line}: `{text}` is not a directive; the only one is `@plan-exempt off`"
),
}
}
}
impl std::error::Error for Error {}
pub(crate) fn path_error(tok: &str) -> Option<(String, PathProblem)> {
let tok = match tok.strip_prefix('?') {
Some("") => return Some(("?".into(), PathProblem::NamesNothing)),
Some(rest) => rest,
None => tok,
};
let bad = |p: PathProblem| Some((tok.to_string(), p));
if tok.starts_with(':') {
return bad(PathProblem::Magic);
}
if tok.starts_with('/') {
return bad(PathProblem::Absolute);
}
if tok.starts_with("./") || tok.starts_with("../") {
return bad(PathProblem::Relative);
}
if tok.ends_with('/') {
return bad(PathProblem::TrailingSlash);
}
if let Some(c) = tok
.chars()
.find(|c| matches!(c, '*' | '?' | '[' | ']' | '\\'))
{
return bad(PathProblem::Glob(c));
}
if tok
.split('/')
.any(|c| c.is_empty() || c == "." || c == "..")
{
return bad(PathProblem::BadComponent);
}
None
}
pub fn parse_spec(bytes: &[u8]) -> Result<Spec, Error> {
if bytes.len() > MAX_SPEC_BYTES {
return Err(Error::TooLarge);
}
if let Some(&b) = bytes
.iter()
.find(|&&b| !(b == b' ' || b == b'\t' || b == b'\n' || (0x21..=0x7e).contains(&b)))
{
return Err(Error::Byte(b));
}
let text = std::str::from_utf8(bytes).expect("ASCII is UTF-8");
let mut gates: Vec<(String, Vec<PathTok>)> = Vec::new();
let mut plan_exempt_off = false;
for (n, line) in text.split('\n').enumerate() {
let line_no = 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 gate.starts_with('@') {
if is_plan_exempt_directive(line.as_bytes()) {
plan_exempt_off = true;
continue;
}
return Err(Error::Directive {
line: line_no,
text: line.trim().to_string(),
});
}
let at = |gate: &str| (line_no, gate.to_string());
if !valid_gate(gate) {
let (line, gate) = at(gate);
return Err(Error::GateName { line, gate });
}
if gates.iter().any(|(g, _)| g == gate) {
let (line, gate) = at(gate);
return Err(Error::Duplicate { line, gate });
}
let paths: Vec<String> = toks.map(String::from).collect();
if paths.is_empty() {
let (line, gate) = at(gate);
return Err(Error::NoPaths { line, gate });
}
if paths.len() > MAX_SPEC_PATHS {
let (line, gate) = at(gate);
return Err(Error::TooManyPaths { line, gate });
}
for p in &paths {
if let Some((path, problem)) = path_error(p) {
return Err(Error::Path {
line: line_no,
path,
problem,
});
}
}
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(Error::TooManyGates);
}
}
Ok(Spec {
gates,
plan_exempt_off,
})
}
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
}
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)]
#[non_exhaustive]
pub enum PlanExempt {
On,
Off,
OffByAmontConf,
Unreadable,
}
impl PlanExempt {
pub fn is_off(self) -> bool {
!matches!(self, PlanExempt::On)
}
}
pub const DEPRECATED_OPT_OUT: &str =
"deprecated: planExempt in amont.conf; also write @plan-exempt off in attest-inputs";
pub fn plan_exempt(root: &Path, tree: &str) -> PlanExempt {
for path in SPEC_PATHS {
let Some(listing) =
git::stdout_bytes_in(root, &["ls-tree", "--full-tree", tree, "--", path])
else {
return PlanExempt::Unreadable;
};
if listing.is_empty() {
continue;
}
match git::stdout_bytes_in(root, &["cat-file", "blob", &format!("{tree}:{path}")]) {
Some(bytes) if bytes.split(|&b| b == b'\n').any(is_plan_exempt_directive) => {
return PlanExempt::Off
}
Some(_) => {}
None => return PlanExempt::Unreadable,
}
}
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::OffByAmontConf,
Some(_) => PlanExempt::On,
None => PlanExempt::Unreadable,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum AtError {
Ambiguous,
Unreadable(&'static str),
Invalid(&'static str, Error),
Unlisted,
}
pub fn at(root: &Path, tree: &str) -> Result<Option<(&'static str, Spec)>, AtError> {
let listing = git::stdout_bytes_in(
root,
&[
"ls-tree",
"--full-tree",
tree,
"--",
SPEC_PATHS[0],
SPEC_PATHS[1],
],
)
.ok_or(AtError::Unlisted)?;
let present: Vec<&'static str> = SPEC_PATHS
.iter()
.copied()
.filter(|p| {
listing
.split(|&b| b == b'\n')
.filter_map(|l| l.iter().position(|&b| b == b'\t').map(|i| &l[i + 1..]))
.any(|name| name == p.as_bytes())
})
.collect();
let path = match present.as_slice() {
[] => return Ok(None),
[one] => *one,
_ => return Err(AtError::Ambiguous),
};
let bytes = git::stdout_bytes_in(root, &["cat-file", "blob", &format!("{tree}:{path}")])
.ok_or(AtError::Unreadable(path))?;
parse_spec(&bytes)
.map(|spec| Some((path, spec)))
.map_err(|e| AtError::Invalid(path, e))
}
pub const EXEMPT: [&str; 1] = ["docs/plans/"];
const LIST_CAP: usize = 50;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum CoverageError {
Missing,
Ambiguous,
Invalid(&'static str),
Unlisted,
}
impl CoverageError {
pub fn render(&self, tree: &str) -> String {
match self {
CoverageError::Missing => format!(
"check-attest-inputs: no {} or {} on {tree}",
SPEC_PATHS[0], SPEC_PATHS[1]
),
CoverageError::Ambiguous => format!(
"check-attest-inputs: both {} and {} exist on {tree}; that disables the route",
SPEC_PATHS[0], SPEC_PATHS[1]
),
CoverageError::Invalid(path) => format!(
"check-attest-inputs: {path} is invalid on {tree} (one violation invalidates the whole spec; see attest SPEC, \"Input fingerprints\")"
),
CoverageError::Unlisted => {
format!("check-attest-inputs: git could not list {tree}, or it is empty")
}
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Findings {
pub undeclared: Vec<String>,
pub unfingerprintable: Vec<String>,
pub uncovered: Vec<(String, Vec<String>)>,
}
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 fn coverage(
spec: &Spec,
required: &[String],
files: &[Vec<u8>],
can_fingerprint: impl Fn(&str) -> bool,
) -> Findings {
let mut out = Findings::default();
for gate in required {
let Some(toks) = spec.paths_of(gate) else {
out.undeclared.push(gate.clone());
continue;
};
if !can_fingerprint(gate) {
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_coverage(
root: &Path,
tree: &str,
required: &[String],
) -> Result<Findings, CoverageError> {
let spec = match at(root, tree) {
Ok(Some((_, spec))) => spec,
Ok(None) => return Err(CoverageError::Missing),
Err(AtError::Ambiguous) => return Err(CoverageError::Ambiguous),
Err(AtError::Unreadable(p)) | Err(AtError::Invalid(p, _)) => {
return Err(CoverageError::Invalid(p))
}
Err(AtError::Unlisted) => return Err(CoverageError::Unlisted),
};
let listing = git::stdout_bytes_in(
root,
&["ls-tree", "-r", "-z", "--full-tree", "--name-only", tree],
)
.filter(|l| !l.is_empty())
.ok_or(CoverageError::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, |gate| {
crate::fingerprint::fingerprint(root, tree, &spec, gate).is_ok()
}))
}