use std::{
fs,
path::{Path, PathBuf},
sync::Arc,
};
use super::{
DirectoryBackend, Listing, Walker, glob_path_bytes,
ignore_rules::{RuleSet, RuleSetBuilder},
read_bounded_file,
};
const IGNORE_FILES: [&str; 2] = [".gitignore", ".ignore"];
const REPOSITORY_EXCLUDE_FILE: &str = "info/exclude";
const MAX_IGNORE_RULES: usize = 100_000;
#[derive(Debug)]
pub(crate) struct IgnoreReadError {
pub(crate) path: PathBuf,
kind: std::io::ErrorKind,
message: String,
}
impl IgnoreReadError {
fn new(path: PathBuf, source: std::io::Error) -> Self {
Self {
path,
kind: source.kind(),
message: source.to_string(),
}
}
pub(crate) fn into_parts(self) -> (PathBuf, std::io::Error) {
(self.path, std::io::Error::new(self.kind, self.message))
}
}
#[derive(Debug, Clone, Copy, Default)]
struct GitIgnoreAdaptation {
case_insensitive: bool,
precompose_unicode: bool,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct IgnoreScope {
rules: Option<Arc<IgnoreNode>>,
adaptation: GitIgnoreAdaptation,
}
impl IgnoreScope {
pub(crate) fn for_root<B: DirectoryBackend + ?Sized>(
walker: &Walker,
backend: &B,
root: &Path,
) -> (Self, Vec<IgnoreReadError>) {
if !walker.respect_git_ignore {
return (Self::default(), Vec::new());
}
let layout = repository_layout(root);
let adaptation = GitIgnoreAdaptation::effective(walker, layout.as_ref());
let scope = Self {
rules: None,
adaptation,
};
let (rules, errors) = read_repository_rules(backend, root, layout.as_ref(), adaptation);
(scope.link(rules), errors)
}
pub(crate) fn enter<B: DirectoryBackend + ?Sized>(
self,
walker: &Walker,
backend: &B,
directory: &Path,
listing: &Listing,
) -> (Self, Vec<IgnoreReadError>) {
if !walker.respect_git_ignore {
return (self, Vec::new());
}
let present = IGNORE_FILES
.into_iter()
.filter(|file| listing.contains_git_ignore_name(file))
.collect::<Vec<_>>();
if present.is_empty() {
return (self, Vec::new());
}
let (rules, errors) = read_rules(backend, directory, &present, self.adaptation);
(self.link(rules), errors)
}
pub(crate) fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
let Some(node) = self.rules.as_ref() else {
return false;
};
let candidate = glob_path_bytes(path);
node.verdict(&candidate, is_dir).unwrap_or(false)
}
fn link(self, rules: RuleSet) -> Self {
if rules.is_empty() {
return self;
}
Self {
rules: Some(Arc::new(IgnoreNode {
rules,
parent: self.rules,
})),
adaptation: self.adaptation,
}
}
}
#[derive(Debug)]
struct IgnoreNode {
rules: RuleSet,
parent: Option<Arc<IgnoreNode>>,
}
impl IgnoreNode {
fn verdict(&self, candidate: &[u8], is_dir: bool) -> Option<bool> {
self.rules.matched(candidate, is_dir).or_else(|| {
self.parent
.as_ref()
.and_then(|parent| parent.verdict(candidate, is_dir))
})
}
}
fn read_rules<B: DirectoryBackend + ?Sized>(
backend: &B,
directory: &Path,
files: &[&str],
adaptation: GitIgnoreAdaptation,
) -> (RuleSet, Vec<IgnoreReadError>) {
let mut builder = RuleSetBuilder::new(
directory,
adaptation.case_insensitive,
adaptation.precompose_unicode,
);
let mut errors = Vec::new();
for file in files {
let path = directory.join(file);
match backend.read_ignore_file(&path) {
Ok(contents) => match validate_rule_count(&contents) {
Ok(()) => add_rules(&mut builder, &contents),
Err(source) => errors.push(IgnoreReadError::new(path, source)),
},
Err(source) if ignored_in_tree_read_error(&source) => {}
Err(source) => errors.push(IgnoreReadError::new(path, source)),
}
}
(builder.build(), errors)
}
fn read_repository_rules<B: DirectoryBackend + ?Sized>(
backend: &B,
root: &Path,
layout: Option<&RepositoryLayout>,
adaptation: GitIgnoreAdaptation,
) -> (RuleSet, Vec<IgnoreReadError>) {
let mut builder = RuleSetBuilder::new(
root,
adaptation.case_insensitive,
adaptation.precompose_unicode,
);
let Some(layout) = layout else {
return (builder.build(), Vec::new());
};
let path = layout.common_directory.join(REPOSITORY_EXCLUDE_FILE);
let mut errors = Vec::new();
match backend.read_repository_file(&path) {
Ok(contents) => match validate_rule_count(&contents) {
Ok(()) => add_rules(&mut builder, &contents),
Err(source) => errors.push(IgnoreReadError::new(path, source)),
},
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
Err(source) => errors.push(IgnoreReadError::new(path, source)),
}
(builder.build(), errors)
}
fn validate_rule_count(contents: &[u8]) -> std::io::Result<()> {
let terminated_lines = contents
.iter()
.filter(|&&byte| byte == b'\n')
.take(MAX_IGNORE_RULES + 1)
.count();
let line_count =
terminated_lines + usize::from(!contents.is_empty() && contents.last() != Some(&b'\n'));
if line_count > MAX_IGNORE_RULES {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"ignore file exceeds the 100,000-rule safety limit",
));
}
Ok(())
}
fn ignored_in_tree_read_error(error: &std::io::Error) -> bool {
if error.kind() == std::io::ErrorKind::NotFound {
return true;
}
#[cfg(not(unix))]
if error.kind() == std::io::ErrorKind::InvalidInput {
return true;
}
#[cfg(unix)]
if error.raw_os_error() == Some(libc::ELOOP) {
return true;
}
false
}
#[derive(Debug)]
struct RepositoryLayout {
private_directory: PathBuf,
common_directory: PathBuf,
}
fn repository_layout(root: &Path) -> Option<RepositoryLayout> {
let dot_git = root.join(".git");
let metadata = fs::metadata(&dot_git).ok()?;
if metadata.is_dir() {
return Some(RepositoryLayout {
private_directory: dot_git.clone(),
common_directory: dot_git,
});
}
if !metadata.is_file() {
return None;
}
let private_directory = resolve_metadata_path(
root,
parse_gitdir_pointer(&read_bounded_file(&dot_git).ok()?)?,
)?;
let common_dir = private_directory.join("commondir");
match read_bounded_file(&common_dir) {
Ok(contents) => Some(RepositoryLayout {
common_directory: resolve_metadata_path(
&private_directory,
parse_metadata_path(&contents)?,
)?,
private_directory,
}),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Some(RepositoryLayout {
common_directory: private_directory.clone(),
private_directory,
}),
Err(_) => None,
}
}
#[derive(Default)]
struct GitConfig {
ignore_case: Option<bool>,
precompose_unicode: Option<bool>,
worktree_config: Option<bool>,
}
#[derive(Clone, Copy)]
enum GitConfigSection {
Core,
Extensions,
}
impl GitIgnoreAdaptation {
fn effective(walker: &Walker, layout: Option<&RepositoryLayout>) -> Self {
let config = layout.map(read_repository_config).unwrap_or_default();
Self {
case_insensitive: walker
.git_ignore_case
.or(config.ignore_case)
.unwrap_or(false),
precompose_unicode: cfg!(target_os = "macos")
&& walker
.git_precompose_unicode
.or(config.precompose_unicode)
.unwrap_or(false),
}
}
}
fn read_repository_config(layout: &RepositoryLayout) -> GitConfig {
let mut config = GitConfig::default();
if let Ok(contents) = read_bounded_file(&layout.common_directory.join("config")) {
apply_config(&mut config, &contents);
}
if config.worktree_config == Some(true)
&& let Ok(contents) = read_bounded_file(&layout.private_directory.join("config.worktree"))
{
apply_config(&mut config, &contents);
}
config
}
fn apply_config(config: &mut GitConfig, contents: &[u8]) {
let mut section = None;
for line in logical_config_lines(contents) {
let line = trim_ascii(strip_config_comment(&line));
if line.is_empty() {
continue;
}
if line.starts_with(b"[") {
section = parse_top_level_section(line);
continue;
}
let Some(section) = section else {
continue;
};
let (key, value) =
line.iter()
.position(|byte| *byte == b'=')
.map_or((trim_ascii(line), None), |index| {
(
trim_ascii(&line[..index]),
Some(trim_ascii(&line[index + 1..])),
)
});
let Some(value) = parse_git_bool(value.unwrap_or(b"true")) else {
continue;
};
match section {
GitConfigSection::Core if key.eq_ignore_ascii_case(b"ignorecase") => {
config.ignore_case = Some(value);
}
GitConfigSection::Core if key.eq_ignore_ascii_case(b"precomposeunicode") => {
config.precompose_unicode = Some(value);
}
GitConfigSection::Extensions if key.eq_ignore_ascii_case(b"worktreeconfig") => {
config.worktree_config = Some(value);
}
_ => {}
}
}
}
fn logical_config_lines(contents: &[u8]) -> Vec<Vec<u8>> {
let mut physical = contents.split_inclusive(|byte| *byte == b'\n').map(|line| {
let line = line.strip_suffix(b"\n").unwrap_or(line);
line.strip_suffix(b"\r").unwrap_or(line)
});
let mut logical = Vec::new();
while let Some(line) = physical.next() {
let mut line = line.to_vec();
while config_value_continues(&line) {
line.pop();
let Some(next) = physical.next() else {
break;
};
line.extend_from_slice(next);
}
logical.push(line);
}
logical
}
fn config_value_continues(line: &[u8]) -> bool {
let line = trim_ascii_start(line);
let Some(first) = line.first() else {
return false;
};
if matches!(first, b'#' | b';' | b'[') || !first.is_ascii_alphabetic() {
return false;
}
let key_end = line
.iter()
.position(|byte| !(byte.is_ascii_alphanumeric() || *byte == b'-'))
.unwrap_or(line.len());
let Some(value) = trim_ascii_start(&line[key_end..]).strip_prefix(b"=") else {
return false;
};
let mut quoted = false;
let mut bytes = value.iter();
while let Some(byte) = bytes.next() {
if *byte == b'\\' {
if bytes.next().is_none() {
return true;
}
} else if *byte == b'"' {
quoted = !quoted;
} else if !quoted && matches!(*byte, b'#' | b';') {
return false;
}
}
false
}
fn parse_top_level_section(line: &[u8]) -> Option<GitConfigSection> {
let name = line.strip_prefix(b"[")?.strip_suffix(b"]")?;
if name.eq_ignore_ascii_case(b"core") {
Some(GitConfigSection::Core)
} else if name.eq_ignore_ascii_case(b"extensions") {
Some(GitConfigSection::Extensions)
} else {
None
}
}
fn strip_config_comment(line: &[u8]) -> &[u8] {
let mut quoted = false;
let mut escaped = false;
for (index, byte) in line.iter().enumerate() {
if escaped {
escaped = false;
} else if *byte == b'\\' {
escaped = true;
} else if *byte == b'"' {
quoted = !quoted;
} else if !quoted && matches!(*byte, b'#' | b';') {
return &line[..index];
}
}
line
}
fn parse_git_bool(value: &[u8]) -> Option<bool> {
let value = std::str::from_utf8(value).ok()?;
let value = decode_git_config_value(value.trim())?;
if value.is_empty() {
Some(false)
} else if value.eq_ignore_ascii_case("true")
|| value.eq_ignore_ascii_case("yes")
|| value.eq_ignore_ascii_case("on")
{
Some(true)
} else if value.eq_ignore_ascii_case("false")
|| value.eq_ignore_ascii_case("no")
|| value.eq_ignore_ascii_case("off")
{
Some(false)
} else {
parse_git_config_int(&value).map(|value| value != 0)
}
}
fn trim_ascii_start(bytes: &[u8]) -> &[u8] {
let start = bytes
.iter()
.position(|byte| !byte.is_ascii_whitespace())
.unwrap_or(bytes.len());
&bytes[start..]
}
fn trim_ascii(bytes: &[u8]) -> &[u8] {
let bytes = trim_ascii_start(bytes);
let end = bytes
.iter()
.rposition(|byte| !byte.is_ascii_whitespace())
.map_or(0, |index| index + 1);
&bytes[..end]
}
fn decode_git_config_value(value: &str) -> Option<String> {
let mut decoded = String::with_capacity(value.len());
let mut quoted = false;
let mut escaped = false;
for character in value.chars() {
if escaped {
decoded.push(match character {
't' => '\t',
'b' => '\u{8}',
'n' => '\n',
'\\' | '"' => character,
_ => return None,
});
escaped = false;
} else if character == '\\' {
escaped = true;
} else if character == '"' {
quoted = !quoted;
} else {
decoded.push(character);
}
}
(!quoted && !escaped).then_some(decoded)
}
fn parse_git_config_int(value: &str) -> Option<i32> {
let (negative, value) = match value.as_bytes().first() {
Some(b'+') => (false, &value[1..]),
Some(b'-') => (true, &value[1..]),
_ => (false, value),
};
let (digits, scale) = match value.as_bytes().last() {
Some(b'k' | b'K') => (&value[..value.len() - 1], 1024_i64),
Some(b'm' | b'M') => (&value[..value.len() - 1], 1024_i64.pow(2)),
Some(b'g' | b'G') => (&value[..value.len() - 1], 1024_i64.pow(3)),
_ => (value, 1),
};
let (radix, digits) = if let Some(digits) = digits
.strip_prefix("0x")
.or_else(|| digits.strip_prefix("0X"))
{
(16, digits)
} else if digits.len() > 1 && digits.starts_with('0') {
(8, &digits[1..])
} else {
(10, digits)
};
let magnitude = i64::from_str_radix(digits, radix)
.ok()?
.checked_mul(scale)?;
let value = if negative {
magnitude.checked_neg()?
} else {
magnitude
};
i32::try_from(value).ok()
}
fn parse_gitdir_pointer(contents: &[u8]) -> Option<PathBuf> {
parse_metadata_path(contents.strip_prefix(b"gitdir: ")?)
}
fn parse_metadata_path(contents: &[u8]) -> Option<PathBuf> {
let contents = contents.strip_suffix(b"\n").unwrap_or(contents);
let contents = contents.strip_suffix(b"\r").unwrap_or(contents);
if contents.is_empty()
|| contents
.iter()
.any(|byte| matches!(*byte, b'\0' | b'\n' | b'\r'))
{
return None;
}
path_from_git_bytes(contents)
}
#[cfg(unix)]
fn path_from_git_bytes(contents: &[u8]) -> Option<PathBuf> {
use std::os::unix::ffi::OsStringExt;
Some(PathBuf::from(std::ffi::OsString::from_vec(
contents.to_vec(),
)))
}
#[cfg(not(unix))]
fn path_from_git_bytes(contents: &[u8]) -> Option<PathBuf> {
std::str::from_utf8(contents).ok().map(PathBuf::from)
}
fn resolve_metadata_path(base: &Path, path: PathBuf) -> Option<PathBuf> {
if path.is_absolute() {
Some(path)
} else if path.as_os_str().is_empty() {
None
} else {
Some(base.join(path))
}
}
fn add_rules(builder: &mut RuleSetBuilder, contents: &[u8]) {
let contents = contents.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(contents);
for line in contents.split(|byte| *byte == b'\n') {
let line = line.strip_suffix(b"\r").unwrap_or(line);
let line = line.split(|byte| *byte == b'\0').next().unwrap_or(line);
builder.add_line(line);
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::{
GitConfig, MAX_IGNORE_RULES, RuleSetBuilder, add_rules, apply_config, parse_git_bool,
validate_rule_count,
};
#[test]
fn ignore_rule_count_has_a_hard_limit() {
let at_limit = vec![b'\n'; MAX_IGNORE_RULES];
assert!(validate_rule_count(&at_limit).is_ok());
let over_limit = vec![b'\n'; MAX_IGNORE_RULES + 1];
let error = validate_rule_count(&over_limit).expect_err("one extra rule is rejected");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn byte_lines_continue_after_invalid_utf8_and_match_byte_patterns() {
let root = Path::new("/fixture");
let mut builder = RuleSetBuilder::new(root, false, false);
add_rules(&mut builder, b"first.txt\n\xE9latin1.txt\nsecond.txt\n");
let rules = builder.build();
assert_eq!(rules.matched(b"/fixture/second.txt", false), Some(true));
assert_eq!(rules.matched(b"/fixture/\xE9latin1.txt", false), Some(true));
}
#[test]
fn nul_ends_a_rule_and_one_initial_bom_is_stripped() {
let root = Path::new("/fixture");
let mut builder = RuleSetBuilder::new(root, false, false);
add_rules(
&mut builder,
b"\xEF\xBB\xBF\xEF\xBB\xBFdouble.txt\r\nsec\0ret.txt\r\n",
);
let rules = builder.build();
assert_eq!(
rules.matched(b"/fixture/\xEF\xBB\xBFdouble.txt", false),
Some(true)
);
assert_eq!(rules.matched(b"/fixture/double.txt", false), None);
assert_eq!(rules.matched(b"/fixture/sec", false), Some(true));
assert_eq!(rules.matched(b"/fixture/secret.txt", false), None);
}
#[test]
fn local_config_is_case_insensitive_and_uses_the_last_boolean_value() {
let mut config = GitConfig::default();
apply_config(
&mut config,
b"[CoRe]\nignoreCase = on\nprecomposeUnicode = 0 # comment\n\
ignorecase = false\n[ExTeNsIoNs]\nworktreeConfig\n",
);
assert_eq!(config.ignore_case, Some(false));
assert_eq!(config.precompose_unicode, Some(false));
assert_eq!(config.worktree_config, Some(true));
}
#[test]
fn local_config_boolean_values_follow_git_grammar() {
for (value, expected) in [
("", Some(false)),
("\"\"", Some(false)),
("true", Some(true)),
("No", Some(false)),
("+0", Some(false)),
("-0", Some(false)),
("+2", Some(true)),
("-7", Some(true)),
("0x0", Some(false)),
("0x2", Some(true)),
("010", Some(true)),
("1G", Some(true)),
("2G", None),
("09", None),
("1.0", None),
("invalid", None),
] {
assert_eq!(parse_git_bool(value.as_bytes()), expected, "{value:?}");
}
}
#[test]
fn invalid_later_boolean_does_not_override_a_valid_value() {
let mut config = GitConfig::default();
apply_config(
&mut config,
b"[core]\nignorecase = false\nignorecase = not-a-bool\n",
);
assert_eq!(config.ignore_case, Some(false));
}
#[test]
fn config_subsections_and_continuations_follow_git_value_boundaries() {
let mut config = GitConfig::default();
apply_config(
&mut config,
b"[core \"unrelated\"]\nignorecase = false\nprecomposeunicode = false\n\
[extensions \"unrelated\"]\nworktreeconfig = false\n [CoRe] # top-level comment\nignorecase = f\\\nalse\nprecomposeunicode = \"tr\\\nue\" # comment\n\
[extensions]\nworktreeconfig = f\\\nalse\nworktreeconfig = t\\\nr\\\nue\n",
);
assert_eq!(config.ignore_case, Some(false));
assert_eq!(config.precompose_unicode, Some(true));
assert_eq!(config.worktree_config, Some(true));
apply_config(
&mut config,
b"[core]\nignorecase = true\nignorecase = f\\\n alse\n\
precomposeunicode = true\nprecomposeunicode = tr\\\n# comment\n\
[extensions]\nworktreeconfig = true\nworktreeconfig = tr\\",
);
assert_eq!(config.ignore_case, Some(true));
assert_eq!(config.precompose_unicode, Some(true));
assert_eq!(config.worktree_config, Some(true));
let mut continued_header = GitConfig::default();
apply_config(
&mut continued_header,
b"[core]\nignorecase = true\nignorecase = f\\\n[extensions]\nworktreeconfig = true\n",
);
assert_eq!(continued_header.ignore_case, Some(true));
assert_eq!(continued_header.worktree_config, None);
}
#[test]
fn config_parser_keeps_core_values_across_non_utf8_irrelevant_lines() {
let mut config = GitConfig::default();
apply_config(
&mut config,
b"# comment before \xff\n[user]\nname = Jos\xe9\nemail = user\\\n\xff@example.test\n\
[core]\nignorecase = true\nprecomposeunicode = false\n\
[remote]\nurl = ssh://\xff@example.test\n# comment after \xff\n",
);
assert_eq!(config.ignore_case, Some(true));
assert_eq!(config.precompose_unicode, Some(false));
}
#[test]
fn config_parser_rejects_non_utf8_relevant_boolean_values() {
let mut config = GitConfig::default();
apply_config(
&mut config,
b"[core]\nignorecase = false\nignorecase = tr\xffue\n\
precomposeunicode = true\nprecomposeunicode = fa\xfflse\n",
);
assert_eq!(config.ignore_case, Some(false));
assert_eq!(config.precompose_unicode, Some(true));
}
}