use std::collections::{HashMap, HashSet};
use super::Case;
use crate::ignore::pattern::Pattern;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub enum State<'a> {
Unspecified,
Set,
Unset,
Value(&'a [u8]),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum Source<'a> {
System,
Global,
Directory(&'a [u8]),
Info,
}
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct Limits {
pub bytes: usize,
pub sources: usize,
pub lines: usize,
pub line_bytes: usize,
pub path_bytes: usize,
pub work: usize,
}
impl Limits {
pub const fn trusted() -> Self {
Self {
bytes: usize::MAX / 4,
sources: usize::MAX / 4,
lines: usize::MAX / 4,
line_bytes: usize::MAX / 4,
path_bytes: usize::MAX / 4,
work: usize::MAX / 4,
}
}
#[must_use]
pub fn with_bytes(mut self, bytes: usize) -> Self {
self.bytes = bytes;
self
}
#[must_use]
pub fn with_sources(mut self, sources: usize) -> Self {
self.sources = sources;
self
}
#[must_use]
pub fn with_lines(mut self, lines: usize) -> Self {
self.lines = lines;
self
}
#[must_use]
pub fn with_line_bytes(mut self, line_bytes: usize) -> Self {
self.line_bytes = line_bytes;
self
}
#[must_use]
pub fn with_path_bytes(mut self, path_bytes: usize) -> Self {
self.path_bytes = path_bytes;
self
}
#[must_use]
pub fn with_work(mut self, work: usize) -> Self {
self.work = work;
self
}
}
impl Default for Limits {
fn default() -> Self {
Self {
bytes: 8 * 1024 * 1024,
sources: 4096,
lines: 100_000,
line_bytes: 4096,
path_bytes: 4096,
work: 100_000_000,
}
}
}
#[derive(Debug, thiserror::Error, Eq, PartialEq)]
#[non_exhaustive]
pub enum Error {
#[error("invalid repository-relative attribute path")]
Path,
#[error("attribute {0} limit exceeded")]
Limit(&'static str),
}
#[derive(Clone, Debug)]
pub struct Attributes {
case: Case,
limits: Limits,
sources: Vec<Rules>,
macros: HashMap<Vec<u8>, Vec<Assignment>>,
bytes: usize,
lines: usize,
}
#[derive(Clone, Debug)]
struct Rules {
base: Vec<u8>,
rank: (u8, usize, usize),
lines: Vec<Line>,
macros: Vec<Macro>,
}
#[derive(Clone, Debug)]
struct Line {
pattern: Pattern,
assignments: Vec<Assignment>,
}
#[derive(Clone, Debug)]
struct Macro {
name: Vec<u8>,
assignments: Vec<Assignment>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct Assignment {
name: Vec<u8>,
value: Value,
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum Value {
Set,
Unset,
Unspecified,
Text(Vec<u8>),
}
impl Value {
fn state(&self) -> State<'_> {
match self {
Self::Set => State::Set,
Self::Unset => State::Unset,
Self::Unspecified => State::Unspecified,
Self::Text(value) => State::Value(value),
}
}
}
impl Attributes {
pub fn new(case: Case, limits: Limits) -> Self {
Self {
case,
limits,
sources: Vec::new(),
macros: HashMap::new(),
bytes: 0,
lines: 0,
}
}
#[must_use]
pub fn case(&self) -> Case {
self.case
}
#[must_use]
pub fn limits(&self) -> Limits {
self.limits
}
pub(crate) fn remaining_bytes(&self) -> usize {
self.limits.bytes.saturating_sub(self.bytes)
}
pub fn add(&mut self, source: Source<'_>, content: &[u8]) -> Result<(), Error> {
let (level, base) = match source {
Source::System => (0, &b""[..]),
Source::Global => (1, &b""[..]),
Source::Directory(base) => (2, base),
Source::Info => (3, &b""[..]),
};
validate_path(base, true, self.limits.path_bytes)?;
let bytes = self
.bytes
.saturating_add(content.len())
.saturating_add(base.len());
if bytes > self.limits.bytes {
return Err(Error::Limit("source bytes"));
}
if self.sources.len() >= self.limits.sources {
return Err(Error::Limit("source count"));
}
let macros_allowed = !matches!(source, Source::Directory(base) if !base.is_empty());
let content = content.strip_prefix(b"\xef\xbb\xbf").unwrap_or(content);
let (mut lines, mut macros) = (Vec::new(), Vec::new());
for raw in content.split(|b| *b == b'\n') {
if raw.len() > self.limits.line_bytes {
return Err(Error::Limit("line bytes"));
}
let parsed = parse_line(raw, self.case);
if parsed.is_some() && self.lines + lines.len() + macros.len() >= self.limits.lines {
return Err(Error::Limit("line count"));
}
match parsed {
Some(Parsed::Line(line)) => lines.push(line),
Some(Parsed::Macro(definition)) if macros_allowed => macros.push(definition),
Some(Parsed::Macro(_)) | None => {}
}
}
let depth = if base.is_empty() {
0
} else {
base.split(|b| *b == b'/').count()
};
self.lines += lines.len() + macros.len();
self.bytes = bytes;
let index = self.sources.len();
self.sources.push(Rules {
base: base.to_vec(),
rank: (level, depth, index),
lines,
macros,
});
self.sources.sort_by_key(|rules| rules.rank);
self.macros = self
.sources
.iter()
.flat_map(|source| &source.macros)
.map(|definition| (definition.name.clone(), definition.assignments.clone()))
.collect();
Ok(())
}
pub fn get(&self, path: &[u8], name: &[u8]) -> Result<State<'_>, Error> {
validate_path(path, false, self.limits.path_bytes)?;
let mut work = self.limits.work;
let mut decided = HashSet::new();
for source in self.sources.iter().rev() {
charge(&mut work, source.base.len().saturating_add(1))?;
let Some(relative) = relative(path, &source.base, self.case) else {
continue;
};
for line in source.lines.iter().rev() {
charge(&mut work, 1)?;
let matched = line
.pattern
.matches(relative, false, &mut work)
.map_err(|_| Error::Limit("matching work"))?;
if matched
&& let Some(state) =
self.decide(&line.assignments, name, &mut decided, &mut work)?
{
return Ok(state);
}
}
}
Ok(State::Unspecified)
}
fn decide<'a>(
&'a self,
assignments: &'a [Assignment],
name: &[u8],
decided: &mut HashSet<&'a [u8]>,
work: &mut usize,
) -> Result<Option<State<'a>>, Error> {
let mut pending = vec![assignments.iter().rev()];
while let Some(top) = pending.last_mut() {
let Some(assignment) = top.next() else {
pending.pop();
continue;
};
charge(work, assignment.name.len().saturating_add(1))?;
if !decided.insert(&assignment.name) {
continue;
}
if assignment.name == name {
return Ok(Some(assignment.value.state()));
}
if assignment.value == Value::Set
&& let Some(expansion) = self.expansion(&assignment.name)
{
pending.push(expansion.iter().rev());
}
}
Ok(None)
}
fn expansion(&self, name: &[u8]) -> Option<&[Assignment]> {
self.macros
.get(name)
.map(Vec::as_slice)
.or_else(|| (name == b"binary").then_some(BINARY.as_slice()))
}
}
static BINARY: std::sync::LazyLock<[Assignment; 3]> = std::sync::LazyLock::new(|| {
[&b"diff"[..], b"merge", b"text"].map(|name| Assignment {
name: name.to_vec(),
value: Value::Unset,
})
});
enum Parsed {
Line(Line),
Macro(Macro),
}
fn is_blank(byte: u8) -> bool {
matches!(byte, b' ' | b'\t' | b'\r' | b'\x0b' | b'\x0c')
}
fn parse_line(raw: &[u8], case: Case) -> Option<Parsed> {
let raw = raw.split(|b| *b == 0).next().unwrap_or_default();
let start = raw.iter().position(|b| !is_blank(*b))?;
let line = raw.get(start..)?;
if line.starts_with(b"#") {
return None;
}
if let Some(rest) = line.strip_prefix(b"[attr]") {
let (name, rest) = token(rest);
if !valid_name(name) {
return None;
}
return Some(Parsed::Macro(Macro {
name: name.to_vec(),
assignments: assignments(rest)?,
}));
}
let (pattern, rest) = pattern(line);
if pattern.starts_with(b"!") {
return None;
}
Some(Parsed::Line(Line {
pattern: Pattern::glob(&pattern, case),
assignments: assignments(rest)?,
}))
}
fn token(bytes: &[u8]) -> (&[u8], &[u8]) {
let start = bytes
.iter()
.position(|b| !is_blank(*b))
.unwrap_or(bytes.len());
let bytes = bytes.get(start..).unwrap_or_default();
let end = bytes
.iter()
.position(|b| is_blank(*b))
.unwrap_or(bytes.len());
(
bytes.get(..end).unwrap_or_default(),
bytes.get(end..).unwrap_or_default(),
)
}
fn pattern(line: &[u8]) -> (Vec<u8>, &[u8]) {
if line.starts_with(b"\"")
&& let Some(unquoted) = unquote(line)
{
return unquoted;
}
let (pattern, rest) = token(line);
(pattern.to_vec(), rest)
}
fn unquote(line: &[u8]) -> Option<(Vec<u8>, &[u8])> {
let mut result = Vec::new();
let mut position = 1;
loop {
let byte = *line.get(position)?;
position += 1;
match byte {
b'"' => return Some((result, line.get(position..).unwrap_or_default())),
b'\\' => {
let escaped = *line.get(position)?;
position += 1;
result.push(match escaped {
b'a' => 7,
b'b' => 8,
b't' => b'\t',
b'n' => b'\n',
b'v' => 11,
b'f' => 12,
b'r' => b'\r',
b'\\' | b'"' => escaped,
b'0'..=b'3' => {
let digits = line.get(position..position + 2)?;
if !digits.iter().all(|b| (b'0'..=b'7').contains(b)) {
return None;
}
position += 2;
digits
.iter()
.fold(escaped - b'0', |value, digit| value * 8 + (digit - b'0'))
}
_ => return None,
});
}
_ => result.push(byte),
}
}
}
fn assignments(mut rest: &[u8]) -> Option<Vec<Assignment>> {
let mut assignments = Vec::new();
loop {
let (item, after) = token(rest);
if item.is_empty() {
return Some(assignments);
}
rest = after;
let (item, prefix) = match item.first() {
Some(b'-') => (item.get(1..)?, Some(Value::Unset)),
Some(b'!') => (item.get(1..)?, Some(Value::Unspecified)),
_ => (item, None),
};
let (name, value) = match crate::bytes::split_once(item, b'=') {
Some((name, value)) => (name, Value::Text(value.to_vec())),
None => (item, Value::Set),
};
if !valid_name(name) {
return None;
}
assignments.push(Assignment {
name: name.to_vec(),
value: prefix.unwrap_or(value),
});
}
}
pub(crate) fn valid_name(name: &[u8]) -> bool {
!name.is_empty()
&& !name.starts_with(b"-")
&& name
.iter()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_'))
}
fn charge(work: &mut usize, cost: usize) -> Result<(), Error> {
*work = work
.checked_sub(cost)
.ok_or(Error::Limit("matching work"))?;
Ok(())
}
pub(super) fn validate_path(path: &[u8], root: bool, limit: usize) -> Result<(), Error> {
if path.len() > limit {
return Err(Error::Limit("path bytes"));
}
if path.is_empty() && root {
return Ok(());
}
if path.is_empty()
|| path.contains(&0)
|| path
.split(|b| *b == b'/')
.any(|p| p.is_empty() || p == b"." || p == b"..")
{
return Err(Error::Path);
}
Ok(())
}
fn relative<'a>(path: &'a [u8], base: &[u8], case: Case) -> Option<&'a [u8]> {
if base.is_empty() {
return Some(path);
}
let prefix = path.get(..base.len())?;
let equal = match case {
Case::Sensitive => prefix == base,
Case::AsciiInsensitive => prefix.eq_ignore_ascii_case(base),
};
(equal && path.get(base.len()) == Some(&b'/'))
.then(|| path.get(base.len() + 1..))
.flatten()
}