mod gitignore;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use gitignore::Gitignore;
pub const CONTROL_FILE_NAME: &str = ".gitignore";
pub const DEFAULT_CONTROL_BUDGET: usize = 4 * 1024 * 1024;
pub const DEFAULT_CONTROL_LINE_LIMIT: usize = 16 * 1024;
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
pub struct ControlLimits {
pub budget: Option<usize>,
pub line_limit: Option<usize>,
}
impl Default for ControlLimits {
fn default() -> Self {
Self { budget: Some(DEFAULT_CONTROL_BUDGET), line_limit: Some(DEFAULT_CONTROL_LINE_LIMIT) }
}
}
impl ControlLimits {
pub const fn limit_for(self, reason: ControlRefusalReason) -> Option<usize> {
match reason {
ControlRefusalReason::Budget => self.budget,
ControlRefusalReason::LineLimit => self.line_limit,
}
}
}
impl std::fmt::Display for ControlLimits {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"budget {}, line limit {}",
limit_display(self.budget),
limit_display(self.line_limit)
)
}
}
pub(crate) fn limit_display(limit: Option<usize>) -> String {
limit.map_or_else(
|| "all".to_string(),
|bytes| crate::report_format::human_bytes(u64::try_from(bytes).unwrap_or(u64::MAX)),
)
}
pub(crate) const CONTROL_SOURCE_OVERHEAD: usize = 64;
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
pub struct ControlIdentity {
pub bytes: u64,
pub fingerprint: u64,
}
#[derive(Debug)]
struct SharedContent {
bytes: Vec<u8>,
identity: ControlIdentity,
matcher: Gitignore,
content_cost: usize,
}
#[derive(Clone, Debug)]
struct Holding {
content: Arc<SharedContent>,
holders: usize,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
pub enum ControlRefusalReason {
Budget,
LineLimit,
}
impl ControlRefusalReason {
pub const fn label(self) -> &'static str {
match self {
Self::Budget => "budget",
Self::LineLimit => "line_limit",
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ControlAdmission {
Retained {
changed: bool,
},
Refused(ControlRefusalReason),
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Verdict {
Unchanged,
Admit { retained_cost: usize },
Refuse(ControlRefusalReason),
}
#[derive(Clone, PartialEq, Eq, Debug, Hash)]
pub struct RefusedControl {
pub path: PathBuf,
pub reason: ControlRefusalReason,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum ControlCoverage {
NotObserved,
Observed(ControlObservation),
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct ControlObservation {
pub limits: ControlLimits,
pub applied: u64,
pub rules: u64,
pub refused: u64,
pub refusals: Vec<RefusedControl>,
}
impl ControlObservation {
pub const fn is_complete(&self) -> bool {
self.refused == 0
}
pub fn lists_every_refusal(&self) -> bool {
u64::try_from(self.refusals.len()).is_ok_and(|listed| listed == self.refused)
}
}
#[derive(Clone, Debug)]
pub struct ControlTable {
by_directory: BTreeMap<PathBuf, Arc<SharedContent>>,
by_directory_bytes: HashMap<std::ffi::OsString, Arc<SharedContent>>,
shared: HashMap<ControlIdentity, Vec<Holding>>,
refused: BTreeMap<PathBuf, ControlRefusalReason>,
limits: ControlLimits,
source_bytes: usize,
retained_cost: usize,
}
impl Default for ControlTable {
fn default() -> Self {
Self::with_limits(ControlLimits::default())
}
}
impl ControlTable {
pub(crate) fn with_limits(limits: ControlLimits) -> Self {
Self {
by_directory: BTreeMap::new(),
by_directory_bytes: HashMap::new(),
shared: HashMap::new(),
refused: BTreeMap::new(),
limits,
source_bytes: 0,
retained_cost: 0,
}
}
pub fn upsert(&mut self, path: &Path, source: Vec<u8>) -> crate::Result<ControlAdmission> {
let identity = identity(&source);
self.upsert_identified(path, source, identity)
}
fn upsert_identified(
&mut self,
path: &Path,
source: Vec<u8>,
identity: ControlIdentity,
) -> crate::Result<ControlAdmission> {
let directory = control_directory(path)?;
match self.verdict(directory, &source, identity) {
Verdict::Unchanged => Ok(ControlAdmission::Retained { changed: false }),
Verdict::Refuse(reason) => {
crate::counters::bump(|counts| {
counts.control_refused = counts.control_refused.saturating_add(1);
});
Ok(self.refuse(directory, reason))
}
Verdict::Admit { retained_cost } => {
self.detach(directory);
self.attach(directory, source, identity);
self.refused.remove(directory);
debug_assert_eq!(self.retained_cost, retained_cost);
Ok(ControlAdmission::Retained { changed: true })
}
}
}
fn verdict(&self, directory: &Path, source: &[u8], identity: ControlIdentity) -> Verdict {
if self.by_directory.get(directory).is_some_and(|current| current.bytes == source) {
return Verdict::Unchanged;
}
if self.limits.line_limit.is_some_and(|line_limit| {
source.split(|byte| *byte == b'\n').any(|line| line.len() > line_limit)
}) {
return Verdict::Refuse(ControlRefusalReason::LineLimit);
}
let content_charge =
if self.holding(identity, source).is_some() { 0 } else { content_cost(source) };
let retained_cost = self
.retained_cost
.saturating_sub(self.release_charge(directory))
.saturating_add(directory_cost(directory))
.saturating_add(content_charge);
if self.limits.budget.is_some_and(|budget| retained_cost > budget) {
return Verdict::Refuse(ControlRefusalReason::Budget);
}
Verdict::Admit { retained_cost }
}
pub(crate) fn upsert_is_inert(&self, path: &Path, source: &[u8]) -> bool {
let Ok(directory) = control_directory(path) else {
return false;
};
match self.verdict(directory, source, identity(source)) {
Verdict::Unchanged => true,
Verdict::Refuse(reason) => self.refused.get(directory) == Some(&reason),
Verdict::Admit { .. } => false,
}
}
pub(crate) fn remove_is_inert(&self, path: &Path) -> bool {
control_directory(path).is_ok_and(|directory| {
!self.by_directory.contains_key(directory) && !self.refused.contains_key(directory)
})
}
pub(crate) fn has_record_at_or_below(&self, subtree: &Path) -> bool {
has_key_at_or_below(&self.by_directory, subtree)
|| has_key_at_or_below(&self.refused, subtree)
}
pub(crate) fn record_refusal(
&mut self,
path: &Path,
reason: ControlRefusalReason,
) -> crate::Result<()> {
let directory = control_directory(path)?;
self.refuse(directory, reason);
Ok(())
}
fn refuse(&mut self, directory: &Path, reason: ControlRefusalReason) -> ControlAdmission {
self.detach(directory);
self.refused.insert(directory.to_path_buf(), reason);
ControlAdmission::Refused(reason)
}
pub fn remove(&mut self, path: &Path) -> crate::Result<bool> {
let directory = control_directory(path)?;
let retained = self.detach(directory);
let refused = self.refused.remove(directory).is_some();
Ok(retained || refused)
}
pub(crate) fn remove_subtree(&mut self, subtree: &Path) {
let directories: Vec<PathBuf> = self
.by_directory
.keys()
.filter(|directory| directory.starts_with(subtree))
.cloned()
.collect();
for directory in directories {
self.detach(&directory);
}
self.refused.retain(|directory, _| !directory.starts_with(subtree));
}
fn holding(&self, identity: ControlIdentity, source: &[u8]) -> Option<&Holding> {
self.shared
.get(&identity)?
.iter()
.find(|holding| holding.content.bytes.as_slice() == source)
}
fn release_charge(&self, directory: &Path) -> usize {
let Some(content) = self.by_directory.get(directory) else {
return 0;
};
let last_holder = self.shared.get(&content.identity).is_some_and(|holdings| {
holdings
.iter()
.any(|holding| Arc::ptr_eq(&holding.content, content) && holding.holders == 1)
});
directory_cost(directory).saturating_add(if last_holder { content.content_cost } else { 0 })
}
fn detach(&mut self, directory: &Path) -> bool {
let Some(content) = self.by_directory.remove(directory) else {
return false;
};
self.by_directory_bytes.remove(directory.as_os_str());
let holdings = self.shared.get_mut(&content.identity).expect("a retained content is held");
let position = holdings
.iter()
.position(|holding| Arc::ptr_eq(&holding.content, &content))
.expect("a retained content is listed under its own identity");
holdings[position].holders -= 1;
if holdings[position].holders == 0 {
holdings.swap_remove(position);
self.retained_cost -= content.content_cost;
if holdings.is_empty() {
self.shared.remove(&content.identity);
}
}
self.retained_cost -= directory_cost(directory);
self.source_bytes -= content.bytes.len();
true
}
fn attach(&mut self, directory: &Path, source: Vec<u8>, identity: ControlIdentity) {
let holdings = self.shared.entry(identity).or_default();
let content = if let Some(holding) =
holdings.iter_mut().find(|holding| holding.content.bytes == source)
{
holding.holders += 1;
crate::counters::bump(|counts| {
counts.control_sources_shared = counts.control_sources_shared.saturating_add(1);
});
Arc::clone(&holding.content)
} else {
let content_cost = content_cost(&source);
let matcher = Gitignore::parse(&source);
let content =
Arc::new(SharedContent { bytes: source, identity, matcher, content_cost });
holdings.push(Holding { content: Arc::clone(&content), holders: 1 });
self.retained_cost += content_cost;
content
};
self.retained_cost += directory_cost(directory);
self.source_bytes += content.bytes.len();
self.by_directory_bytes.insert(directory.as_os_str().to_os_string(), Arc::clone(&content));
self.by_directory.insert(directory.to_path_buf(), content);
}
pub fn matcher_for<'a>(&'a self, path: &'a Path) -> ControlMatcher<'a> {
ControlMatcher { table: self, path }
}
pub(crate) fn chain_for(&self, directory: &Path) -> ControlChain {
debug_assert!(
directory.components().all(|component| matches!(component, Component::Normal(_))),
"control chains are resolved for normalized relative directories: {}",
directory.display()
);
let mut governing = Vec::new();
if !self.by_directory_bytes.is_empty() {
let depth = gitignore::with_components(directory, None, |components| components.len());
for (up, ancestor) in ancestors(directory.as_os_str()).enumerate() {
if let Some(source) = self.by_directory_bytes.get(ancestor) {
governing.push((depth.saturating_sub(up), Arc::clone(source)));
}
}
}
ControlChain { governing }
}
pub(crate) fn chain_below(
&self,
above: &Arc<ControlChain>,
directory: &Path,
) -> Arc<ControlChain> {
let Some(source) = self.by_directory_bytes.get(directory.as_os_str()) else {
return Arc::clone(above);
};
let depth = gitignore::with_components(directory, None, |components| components.len());
let mut governing = Vec::with_capacity(above.governing.len() + 1);
governing.push((depth, Arc::clone(source)));
governing.extend(above.governing.iter().cloned());
Arc::new(ControlChain { governing })
}
pub fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
let components: Vec<_> = path.components().collect();
let mut current = PathBuf::new();
let mut parent_ignored = false;
for (position, component) in components.iter().enumerate() {
current.push(component.as_os_str());
if parent_ignored {
return true;
}
let current_is_dir = position + 1 < components.len() || is_dir;
parent_ignored = self.matcher_for(¤t).is_ignored(current_is_dir);
}
parent_ignored
}
pub fn affected_subtree(path: &Path) -> crate::Result<PathBuf> {
Ok(control_directory(path)?.to_path_buf())
}
pub(crate) fn changes_from(
&self,
previous: &Self,
) -> Vec<(PathBuf, Option<ControlIdentity>, Option<ControlIdentity>)> {
let directories: BTreeSet<&Path> = previous
.by_directory
.keys()
.chain(self.by_directory.keys())
.map(PathBuf::as_path)
.collect();
directories
.into_iter()
.filter_map(|directory| {
let before = previous.by_directory.get(directory);
let after = self.by_directory.get(directory);
let changed = match (before, after) {
(Some(before), Some(after)) => {
!Arc::ptr_eq(before, after) && before.bytes != after.bytes
}
(None, None) => false,
(Some(_), None) | (None, Some(_)) => true,
};
changed.then(|| {
(
control_path(directory),
before.map(|source| source.identity),
after.map(|source| source.identity),
)
})
})
.collect()
}
pub(crate) fn refusal_changes_from(
&self,
previous: &Self,
) -> Vec<(PathBuf, Option<ControlRefusalReason>, Option<ControlRefusalReason>)> {
let directories: BTreeSet<&Path> =
previous.refused.keys().chain(self.refused.keys()).map(PathBuf::as_path).collect();
directories
.into_iter()
.filter_map(|directory| {
let before = previous.refused.get(directory).copied();
let after = self.refused.get(directory).copied();
(before != after).then(|| (control_path(directory), before, after))
})
.collect()
}
pub(crate) fn sources(&self) -> impl ExactSizeIterator<Item = (PathBuf, &[u8])> {
self.by_directory
.iter()
.map(|(directory, source)| (control_path(directory), source.bytes.as_slice()))
}
pub fn refusals(&self) -> impl ExactSizeIterator<Item = RefusedControl> + '_ {
self.refused.iter().map(|(directory, reason)| RefusedControl {
path: control_path(directory),
reason: *reason,
})
}
pub(crate) fn classification_known(&self, path: &Path) -> bool {
path.parent().is_none_or(|directory| self.children_classification_known(directory))
}
pub(crate) fn children_classification_known(&self, directory: &Path) -> bool {
!directory.ancestors().any(|ancestor| self.refused.contains_key(ancestor))
}
pub fn refused_len(&self) -> usize {
self.refused.len()
}
pub const fn limits(&self) -> ControlLimits {
self.limits
}
pub fn observation(&self) -> ControlObservation {
ControlObservation {
limits: self.limits,
applied: u64::try_from(self.len()).unwrap_or(u64::MAX),
rules: self
.by_directory
.values()
.fold(0_u64, |total, content| total.saturating_add(content.matcher.rule_count())),
refused: u64::try_from(self.refused_len()).unwrap_or(u64::MAX),
refusals: self.refusals().take(crate::MAX_RETAINED_ISSUES).collect(),
}
}
pub const fn source_bytes(&self) -> usize {
self.source_bytes
}
pub const fn retained_cost(&self) -> usize {
self.retained_cost
}
pub fn source_is(&self, path: &Path, source: &[u8]) -> bool {
control_directory(path)
.ok()
.and_then(|directory| self.by_directory.get(directory))
.is_some_and(|current| current.bytes == source)
}
pub(crate) fn contains(&self, path: &Path) -> bool {
control_directory(path).ok().is_some_and(|directory| {
self.by_directory.contains_key(directory) || self.refused.contains_key(directory)
})
}
pub fn len(&self) -> usize {
self.by_directory.len()
}
pub fn is_empty(&self) -> bool {
self.by_directory.is_empty()
}
pub(crate) fn is_vacant(&self) -> bool {
self.by_directory.is_empty() && self.refused.is_empty()
}
}
pub struct ControlMatcher<'a> {
table: &'a ControlTable,
path: &'a Path,
}
impl ControlMatcher<'_> {
pub fn is_ignored(&self, is_dir: bool) -> bool {
if self.table.by_directory.is_empty() {
return false;
}
for directory in self.path.parent().into_iter().flat_map(Path::ancestors) {
let Some(source) = self.table.by_directory.get(directory) else {
continue;
};
let relative = self.path.strip_prefix(directory).unwrap_or(self.path);
if let Some(ignored) = source.matcher.matches(relative, is_dir) {
return ignored;
}
}
false
}
}
#[derive(Clone, Debug, Default)]
pub(crate) struct ControlChain {
governing: Vec<(usize, Arc<SharedContent>)>,
}
impl ControlChain {
pub(crate) fn is_empty(&self) -> bool {
self.governing.is_empty()
}
pub(crate) fn same_as(&self, other: &Self) -> bool {
self.governing.len() == other.governing.len()
&& self
.governing
.iter()
.zip(&other.governing)
.all(|(left, right)| left.0 == right.0 && Arc::ptr_eq(&left.1, &right.1))
}
#[cfg(test)]
pub(crate) fn is_ignored(&self, directory: &Path, name: &[u8], is_dir: bool) -> bool {
with_directory_components(directory, |components| {
self.is_ignored_within(components, name, is_dir)
})
}
pub(crate) fn is_ignored_within(&self, directory: &[&[u8]], name: &[u8], is_dir: bool) -> bool {
if self.governing.is_empty() {
return false;
}
let name = gitignore::Name::new(name);
let mut tally = gitignore::Tally::default();
let ignored = self
.governing
.iter()
.find_map(|(leading, source)| {
let relative = directory.get(*leading..).unwrap_or_default();
source.matcher.decide(relative, &name, is_dir, &mut tally)
})
.unwrap_or(false);
tally.record();
ignored
}
}
pub(crate) fn with_directory_components<R>(
directory: &Path,
each: impl FnOnce(&[&[u8]]) -> R,
) -> R {
gitignore::with_components(directory, None, each)
}
#[derive(Debug)]
pub(crate) struct SplitDirectory {
bytes: Vec<u8>,
ends: Vec<usize>,
}
impl SplitDirectory {
pub(crate) fn new(directory: &Path) -> Self {
with_directory_components(directory, |components| {
let mut split = Self {
bytes: Vec::with_capacity(components.iter().map(|component| component.len()).sum()),
ends: Vec::with_capacity(components.len()),
};
for component in components {
split.bytes.extend_from_slice(component);
split.ends.push(split.bytes.len());
}
split
})
}
pub(crate) fn with_components<R>(&self, each: impl FnOnce(&[&[u8]]) -> R) -> R {
let components = self.ends.iter().scan(0, |start, &end| {
let component = &self.bytes[*start..end];
*start = end;
Some(component)
});
gitignore::with_collected(components, each)
}
}
pub(crate) fn split_parent(path: &std::path::Path) -> (&std::ffi::OsStr, &std::ffi::OsStr) {
let parsed = || {
(
path.parent().map_or(std::ffi::OsStr::new(""), std::path::Path::as_os_str),
path.file_name().unwrap_or(std::ffi::OsStr::new("")),
)
};
#[cfg(unix)]
let split = {
use std::os::unix::ffi::OsStrExt as _;
let bytes = path.as_os_str().as_bytes();
match bytes.iter().rposition(|byte| *byte == b'/') {
Some(at) => (
std::ffi::OsStr::from_bytes(&bytes[..at]),
std::ffi::OsStr::from_bytes(&bytes[at + 1..]),
),
None => (std::ffi::OsStr::new(""), path.as_os_str()),
}
};
#[cfg(not(unix))]
let split = parsed();
debug_assert_eq!(split, parsed(), "a walked path is normalized and relative");
split
}
#[cfg(unix)]
pub(crate) fn ancestors(directory: &std::ffi::OsStr) -> impl Iterator<Item = &std::ffi::OsStr> {
use std::os::unix::ffi::OsStrExt as _;
let bytes = directory.as_bytes();
let mut next = Some(bytes.len());
std::iter::from_fn(move || {
let end = next?;
next = (end > 0).then(|| bytes[..end].iter().rposition(|byte| *byte == b'/').unwrap_or(0));
Some(std::ffi::OsStr::from_bytes(&bytes[..end]))
})
}
#[cfg(not(unix))]
pub(crate) fn ancestors(directory: &std::ffi::OsStr) -> impl Iterator<Item = &std::ffi::OsStr> {
std::path::Path::new(directory).ancestors().map(std::path::Path::as_os_str)
}
fn has_key_at_or_below<V>(directories: &BTreeMap<PathBuf, V>, subtree: &Path) -> bool {
directories
.range::<Path, _>((std::ops::Bound::Included(subtree), std::ops::Bound::Unbounded))
.next()
.is_some_and(|(directory, _)| directory.starts_with(subtree))
}
pub fn is_control_file(path: &Path) -> bool {
path.file_name().is_some_and(|name| name == CONTROL_FILE_NAME)
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum ControlSpelling {
Exact,
Variant,
}
pub(crate) fn control_spelling(name: &std::ffi::OsStr) -> Option<ControlSpelling> {
let bytes = name.as_encoded_bytes();
let exact = CONTROL_FILE_NAME.as_bytes();
if bytes.len() != exact.len() {
None
} else if bytes == exact {
Some(ControlSpelling::Exact)
} else if bytes.eq_ignore_ascii_case(exact) {
Some(ControlSpelling::Variant)
} else {
None
}
}
pub(crate) fn path_control_spelling(path: &Path) -> Option<ControlSpelling> {
path.file_name().and_then(control_spelling)
}
pub(crate) fn sibling_control_path(path: &Path) -> PathBuf {
control_path(path.parent().unwrap_or_else(|| Path::new("")))
}
pub(crate) fn unreadable_control(root: &Path, error: &crate::Error) -> Option<PathBuf> {
let crate::Error::Io { .. } = error else {
return None;
};
crate::Issue::from_error_under(root, error).path.and_then(|path| governing_control(&path))
}
pub(crate) fn governing_control(path: &Path) -> Option<PathBuf> {
path_control_spelling(path).map(|_| sibling_control_path(path))
}
fn control_directory(path: &Path) -> crate::Result<&Path> {
if !is_control_file(path) {
return Err(crate::Error::InvalidControlPath(path.to_path_buf()));
}
Ok(path.parent().unwrap_or_else(|| Path::new("")))
}
fn control_path(directory: &Path) -> PathBuf {
directory.join(CONTROL_FILE_NAME)
}
fn identity(bytes: &[u8]) -> ControlIdentity {
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x100_0000_01b3;
let mut fingerprint = FNV_OFFSET_BASIS;
for byte in bytes {
fingerprint ^= u64::from(*byte);
fingerprint = fingerprint.wrapping_mul(FNV_PRIME);
}
ControlIdentity { bytes: u64::try_from(bytes.len()).unwrap_or(u64::MAX), fingerprint }
}
fn directory_cost(directory: &Path) -> usize {
CONTROL_SOURCE_OVERHEAD.saturating_add(directory.as_os_str().as_encoded_bytes().len())
}
fn content_cost(source: &[u8]) -> usize {
let (newlines, segment_shells) = source.iter().fold((0usize, 0usize), |counts, byte| {
(counts.0 + usize::from(*byte == b'\n'), counts.1 + usize::from(*byte == b'/'))
});
let pattern_shells = newlines.saturating_add(1);
source
.len()
.saturating_mul(2)
.saturating_add(pattern_shells.saturating_mul(64))
.saturating_add(segment_shells.saturating_mul(24))
}
#[cfg(test)]
fn retained_source_cost(directory: &Path, source: &[u8]) -> usize {
directory_cost(directory).saturating_add(content_cost(source))
}
#[cfg(test)]
pub(crate) fn source_at_test_limit() -> Vec<u8> {
let mut source = Vec::new();
loop {
let previous_len = source.len();
source.extend(std::iter::repeat_n(b'a', DEFAULT_CONTROL_LINE_LIMIT));
source.push(b'\n');
if retained_source_cost(Path::new(""), &source) > DEFAULT_CONTROL_BUDGET {
source.truncate(previous_len);
break;
}
}
let remaining = DEFAULT_CONTROL_BUDGET - retained_source_cost(Path::new(""), &source);
source.extend(std::iter::repeat_n(b'a', (remaining / 2).min(DEFAULT_CONTROL_LINE_LIMIT)));
assert_eq!(retained_source_cost(Path::new(""), &source), DEFAULT_CONTROL_BUDGET);
source
}
#[cfg(test)]
impl ControlTable {
fn assert_consistent(&self) {
let mut distinct: Vec<&Arc<SharedContent>> = Vec::new();
let mut directory_charges = 0;
let mut source_bytes = 0;
for (directory, content) in &self.by_directory {
directory_charges += directory_cost(directory);
source_bytes += content.bytes.len();
if !distinct.iter().any(|seen| Arc::ptr_eq(seen, content)) {
distinct.push(content);
}
}
let content_charges: usize = distinct.iter().map(|content| content.content_cost).sum();
assert_eq!(self.retained_cost, directory_charges + content_charges, "retained cost");
assert_eq!(self.source_bytes, source_bytes, "source bytes");
let holdings: usize = self.shared.values().map(Vec::len).sum();
assert_eq!(holdings, distinct.len(), "one holding per distinct content");
for (identity, holdings) in &self.shared {
assert!(!holdings.is_empty(), "no empty identity list survives");
for holding in holdings {
assert_eq!(holding.content.identity, *identity);
let holders = self
.by_directory
.values()
.filter(|content| Arc::ptr_eq(content, &holding.content))
.count();
assert_eq!(holding.holders, holders, "holder count");
}
for (index, left) in holdings.iter().enumerate() {
for right in &holdings[index + 1..] {
assert_ne!(left.content.bytes, right.content.bytes, "equal bytes are shared");
}
}
}
assert!(
self.refused.keys().all(|directory| !self.by_directory.contains_key(directory)),
"a refused directory retains no source"
);
assert_eq!(
self.by_directory_bytes.len(),
self.by_directory.len(),
"one byte key per directory"
);
for (directory, content) in &self.by_directory {
let by_bytes = self
.by_directory_bytes
.get(directory.as_os_str())
.expect("every directory is keyed by its bytes");
assert!(
Arc::ptr_eq(by_bytes, content),
"{}: the byte-keyed index shares the directory's content",
directory.display()
);
}
assert!(
self.limits.budget.is_none_or(|budget| self.retained_cost <= budget),
"within budget"
);
assert!(
self.limits.line_limit.is_none_or(|line_limit| {
distinct.iter().all(|content| {
content.bytes.split(|byte| *byte == b'\n').all(|line| line.len() <= line_limit)
})
}),
"within the line limit"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
struct SplitMix(u64);
impl SplitMix {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
let mut value = self.0;
value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
value ^ (value >> 31)
}
fn below(&mut self, bound: usize) -> usize {
usize::try_from(self.next() % u64::try_from(bound).expect("small bound")).expect("fits")
}
}
#[test]
fn rule_totals_count_accepted_patterns_per_governing_location() {
let mut table = ControlTable::default();
let source = b"# comment\n\n*.log\n!important.log\n*.log\n[bad\n";
table.upsert(Path::new(".gitignore"), source.to_vec()).expect("root control");
table.upsert(Path::new("nested/.gitignore"), source.to_vec()).expect("nested control");
assert_eq!(table.observation().applied, 2);
assert_eq!(table.observation().rules, 6);
table
.upsert(Path::new("nested/.gitignore"), b"# empty\n".to_vec())
.expect("replacement control");
assert_eq!(table.observation().applied, 2);
assert_eq!(table.observation().rules, 3);
}
#[test]
fn charges_and_refusals_stay_exact_through_random_upserts_and_removals() {
const DIRECTORIES: [&str; 6] = ["", "a", "a/b", "b", "b/c/d", "c"];
let long_line = [vec![b'x'; DEFAULT_CONTROL_LINE_LIMIT + 1], b"\n".to_vec()].concat();
let large = b"pattern/\n".repeat(40);
let contents: [&[u8]; 6] =
[b"*.log\n", b"target/\n", b"!keep\n*.tmp\n", b"", &large, &long_line];
let budget = 2 * retained_source_cost(Path::new("b/c/d"), &large);
for seed in 0..64 {
let mut random = SplitMix(seed);
let limits = ControlLimits {
budget: (seed % 2 == 0).then_some(budget),
line_limit: (seed % 4 < 2).then_some(DEFAULT_CONTROL_LINE_LIMIT),
};
let mut table = ControlTable::with_limits(limits);
let mut holds_record: BTreeMap<&Path, bool> = BTreeMap::new();
for step in 0..300 {
let directory = Path::new(DIRECTORIES[random.below(DIRECTORIES.len())]);
let path = directory.join(CONTROL_FILE_NAME);
match random.below(8) {
0 => {
table.remove_subtree(directory);
for (held, record) in &mut holds_record {
if held.starts_with(directory) {
*record = false;
}
}
}
1 | 2 => {
table.remove(&path).expect("control path");
holds_record.insert(directory, false);
}
_ => {
let content = contents[random.below(contents.len())].to_vec();
let admission = table.upsert(&path, content.clone()).expect("control path");
if content == long_line && limits.line_limit.is_some() {
assert_eq!(
admission,
ControlAdmission::Refused(ControlRefusalReason::LineLimit)
);
}
if limits.budget.is_none() && limits.line_limit.is_none() {
assert!(
matches!(admission, ControlAdmission::Retained { .. }),
"an unbounded table refuses nothing"
);
}
holds_record.insert(directory, true);
}
}
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
table.assert_consistent();
for (directory, record) in &holds_record {
let path = directory.join(CONTROL_FILE_NAME);
assert_eq!(table.contains(&path), *record, "{}", path.display());
}
let records = holds_record.values().filter(|record| **record).count();
assert_eq!(table.len() + table.refused_len(), records);
}))
.unwrap_or_else(|_| panic!("seed {seed}, step {step}: inconsistent table"));
}
for directory in DIRECTORIES {
table.remove(&Path::new(directory).join(CONTROL_FILE_NAME)).expect("control path");
}
assert_eq!(table.retained_cost(), 0, "seed {seed}");
assert_eq!(table.source_bytes(), 0, "seed {seed}");
assert!(table.shared.is_empty(), "seed {seed}");
assert!(table.is_vacant(), "seed {seed}");
}
}
#[test]
fn a_resolved_chain_answers_as_the_per_entry_matcher_does() {
let mut table = ControlTable::default();
for (path, source) in [
(".gitignore", &b"*.log\n/build/\n!keep.log\nsub/*.tmp\n"[..]),
("a/.gitignore", b"!*.log\n*.o\n/deep/**\n"),
("a/b/.gitignore", b"*.log\n!x.o\n"),
("c/.gitignore", b"# comment only\n"),
] {
table.upsert(Path::new(path), source.to_vec()).expect("fixture control");
}
let directories =
["", "a", "a/b", "a/b/c", "a/deep", "a/deep/er", "build", "c", "c/sub", "sub", "x/y"];
let names = ["x.log", "keep.log", "x.o", "y.o", "build", "deep", "t.tmp", "plain"];
for directory in directories {
let chain = table.chain_for(Path::new(directory));
for name in names {
for is_dir in [false, true] {
let path = Path::new(directory).join(name);
assert_eq!(
chain.is_ignored(Path::new(directory), name.as_bytes(), is_dir),
table.matcher_for(&path).is_ignored(is_dir),
"{} (dir {is_dir})",
path.display()
);
}
}
}
assert!(!ControlTable::default().chain_for(Path::new("a")).is_ignored(
Path::new("a"),
b"x.log",
false
));
}
#[test]
fn chains_derived_parent_first_are_the_ones_the_table_resolves() {
let long_line = [vec![b'x'; DEFAULT_CONTROL_LINE_LIMIT + 1], b"\n".to_vec()].concat();
let large = b"pattern/\n".repeat(40);
let contents: [&[u8]; 6] =
[b"*.log\n", b"!keep\n*.tmp\n", b"", b"/a/x.log\n!*.log\n", &large, &long_line];
let budget = 3 * retained_source_cost(Path::new("a/b/c"), &large);
for seed in 0..200 {
let mut random = SplitMix(seed);
let mut table = ControlTable::with_limits(ControlLimits {
budget: Some(budget),
..ControlLimits::default()
});
let mut listings = std::collections::VecDeque::from([(
PathBuf::new(),
Arc::<ControlChain>::default(),
)]);
while let Some((directory, above)) = listings.pop_front() {
let control = directory.join(CONTROL_FILE_NAME);
let listed = match random.below(4) {
0 | 1 => {
let content = contents[random.below(contents.len())].to_vec();
table.upsert(&control, content).expect("control path");
true
}
2 => {
table.remove(&control).expect("control path");
true
}
_ => false,
};
let chain = if listed { table.chain_below(&above, &directory) } else { above };
let resolved = table.chain_for(&directory);
assert!(chain.same_as(&resolved), "seed {seed}: {}", directory.display());
for name in ["x.log", "keep", "x.tmp", "pattern", "a"] {
for is_dir in [false, true] {
assert_eq!(
chain.is_ignored(&directory, name.as_bytes(), is_dir),
resolved.is_ignored(&directory, name.as_bytes(), is_dir),
"seed {seed}: {}/{name}",
directory.display()
);
}
}
if directory.components().count() < 4 {
for name in ["a", "b", "c"] {
if random.below(3) > 0 {
listings.push_back((directory.join(name), Arc::clone(&chain)));
}
}
}
}
table.assert_consistent();
}
}
#[test]
fn a_chain_agrees_past_the_inline_buffers_and_beside_unrelated_controls() {
let mut table = ControlTable::default();
table.upsert(Path::new("z/.gitignore"), b"*.log\n".to_vec()).expect("unrelated");
let unrelated = table.chain_for(Path::new("a/b"));
assert!(!unrelated.is_ignored(Path::new("a/b"), b"x.log", false));
assert!(!table.matcher_for(Path::new("a/b/x.log")).is_ignored(false));
let deep: PathBuf = (0..33).map(|at| format!("d{at}")).collect();
table.upsert(Path::new(".gitignore"), b"**/x.log\n/d0/**/y.log\n".to_vec()).expect("root");
table.upsert(&deep.join(".gitignore"), b"!x.log\n*.tmp\n".to_vec()).expect("deep");
for depth in [31usize, 32, 33, 34] {
let directory: PathBuf = (0..depth).map(|at| format!("d{at}")).collect();
let chain = table.chain_for(&directory);
for name in ["x.log", "y.log", "z.tmp", "plain"] {
let path = directory.join(name);
assert_eq!(
chain.is_ignored(&directory, name.as_bytes(), false),
table.matcher_for(&path).is_ignored(false),
"{name} at depth {depth}"
);
}
}
}
#[test]
fn matching_counts_lookups_hits_and_rules_tested() {
let _serial = crate::counters::test_serial();
crate::counters::enable(true);
crate::counters::test_thread_reset();
let mut table = ControlTable::default();
table
.upsert(Path::new(".gitignore"), b"*.o\nMakefile\n/build\n*.c.[01]*\n".to_vec())
.expect("root control");
let chain = table.chain_for(Path::new(""));
assert!(chain.is_ignored_within(&[], b"main.o", false));
assert!(chain.is_ignored_within(&[], b"a.c.0x", false));
let counts = crate::counters::test_thread_snapshot();
crate::counters::enable(false);
assert_eq!(
(counts.ignore_bucket_probes, counts.ignore_bucket_hits, counts.ignore_patterns_tested),
(4, 1, 1)
);
}
#[test]
fn a_fingerprint_collision_never_shares_a_matcher() {
let collision = ControlIdentity { bytes: 6, fingerprint: 7 };
let mut table = ControlTable::default();
table
.upsert_identified(Path::new("a/.gitignore"), b"*.log\n".to_vec(), collision)
.expect("first");
table
.upsert_identified(Path::new("b/.gitignore"), b"*.tmp\n".to_vec(), collision)
.expect("second");
table.assert_consistent();
assert_eq!(table.shared[&collision].len(), 2);
assert!(table.is_ignored(Path::new("a/x.log"), false));
assert!(!table.is_ignored(Path::new("a/x.tmp"), false));
assert!(table.is_ignored(Path::new("b/x.tmp"), false));
assert!(!table.is_ignored(Path::new("b/x.log"), false));
assert_eq!(
table.retained_cost(),
retained_source_cost(Path::new("a"), b"*.log\n")
+ retained_source_cost(Path::new("b"), b"*.tmp\n")
);
table.remove(Path::new("a/.gitignore")).expect("remove");
table.assert_consistent();
assert!(table.is_ignored(Path::new("b/x.tmp"), false));
}
const CHANGED: ControlAdmission = ControlAdmission::Retained { changed: true };
const UNCHANGED: ControlAdmission = ControlAdmission::Retained { changed: false };
const OVER_BUDGET: ControlAdmission = ControlAdmission::Refused(ControlRefusalReason::Budget);
const OVER_LINE_LIMIT: ControlAdmission =
ControlAdmission::Refused(ControlRefusalReason::LineLimit);
fn budgeted(budget: Option<usize>) -> ControlTable {
ControlTable::with_limits(ControlLimits { budget, ..ControlLimits::default() })
}
#[test]
fn replacing_the_last_holder_releases_its_content_for_the_bound() {
let mut table = ControlTable::default();
let first = source_at_test_limit();
assert_eq!(
table.upsert(Path::new(".gitignore"), first.clone()).expect("control path"),
CHANGED
);
assert_eq!(table.upsert(Path::new("copy/.gitignore"), first).expect("path"), OVER_BUDGET);
assert_eq!(
table.upsert(Path::new(".gitignore"), b"small\n".to_vec()).expect("control path"),
CHANGED
);
table.assert_consistent();
assert_eq!(table.retained_cost(), retained_source_cost(Path::new(""), b"small\n"));
}
#[test]
fn creation_edit_and_last_removal_are_exact() {
let mut table = ControlTable::default();
assert_eq!(
table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("control path"),
CHANGED
);
let original = table.clone();
assert_eq!(
table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("control path"),
UNCHANGED
);
assert_eq!(
table.upsert(Path::new(".gitignore"), b"*.tmp\n".to_vec()).expect("control path"),
CHANGED
);
assert_eq!(table.changes_from(&original).len(), 1);
assert!(table.remove(Path::new(".gitignore")).expect("remove"));
assert!(table.is_empty());
assert_eq!(table.source_bytes(), 0);
assert!(!table.remove(Path::new(".gitignore")).expect("missing is a no-op"));
}
#[test]
fn the_budget_admits_its_own_size_and_refuses_one_byte_over_it() {
let source = b"*.log\n".to_vec();
let exact = retained_source_cost(Path::new("a"), &source);
let mut at_budget = budgeted(Some(exact));
assert_eq!(
at_budget.upsert(Path::new("a/.gitignore"), source.clone()).expect("control path"),
CHANGED
);
assert_eq!(at_budget.retained_cost(), exact);
let mut under_budget = budgeted(Some(exact - 1));
assert_eq!(
under_budget.upsert(Path::new("a/.gitignore"), source).expect("control path"),
OVER_BUDGET
);
assert_eq!(under_budget.retained_cost(), 0);
assert!(under_budget.contains(Path::new("a/.gitignore")));
assert_eq!(
under_budget.observation(),
ControlObservation {
limits: ControlLimits {
budget: Some(exact - 1),
line_limit: Some(DEFAULT_CONTROL_LINE_LIMIT),
},
applied: 0,
rules: 0,
refused: 1,
refusals: vec![RefusedControl {
path: PathBuf::from("a/.gitignore"),
reason: ControlRefusalReason::Budget,
}],
}
);
}
#[test]
fn a_refused_source_drops_the_rules_it_replaces_and_freed_budget_admits_it_later() {
let mut table = ControlTable::default();
let first = source_at_test_limit();
assert_eq!(
table.upsert(Path::new(".gitignore"), first.clone()).expect("control path"),
CHANGED
);
assert_eq!(
table
.upsert(Path::new("nested/.gitignore"), b"*.log\n".to_vec())
.expect("control path"),
OVER_BUDGET
);
let before = table.clone();
let mut grown = first;
grown.push(b'\n');
assert_eq!(
table.upsert(Path::new(".gitignore"), grown).expect("control path"),
OVER_BUDGET
);
assert!(table.is_empty());
assert_eq!(table.changes_from(&before).len(), 1);
assert_eq!(
table.refusal_changes_from(&before),
vec![(PathBuf::from(".gitignore"), None, Some(ControlRefusalReason::Budget))]
);
assert_eq!(
table
.upsert(Path::new("nested/.gitignore"), b"*.log\n".to_vec())
.expect("control path"),
CHANGED
);
assert_eq!(table.refused_len(), 1);
assert!(table.remove(Path::new(".gitignore")).expect("remove refused"));
assert_eq!(table.refused_len(), 0);
table.assert_consistent();
}
#[test]
fn identical_sources_share_one_content_charge() {
let source = b"target/\n*.log\nnode_modules/\n".to_vec();
let mut table = ControlTable::default();
table.upsert(Path::new("a/.gitignore"), source.clone()).expect("first holder");
let one = table.retained_cost();
table.upsert(Path::new("bb/.gitignore"), source.clone()).expect("second holder");
assert_eq!(table.retained_cost() - one, CONTROL_SOURCE_OVERHEAD + "bb".len());
assert_eq!(table.source_bytes(), 2 * source.len());
assert!(table.is_ignored(Path::new("a/debug.log"), false));
assert!(table.is_ignored(Path::new("bb/debug.log"), false));
}
#[test]
fn the_line_limit_admits_its_own_length_and_refuses_one_byte_over_it() {
let mut table = ControlTable::default();
let at_limit = vec![b'a'; DEFAULT_CONTROL_LINE_LIMIT];
assert_eq!(
table.upsert(Path::new("a/.gitignore"), at_limit).expect("control path"),
CHANGED
);
let over = [b"*.log\n".as_slice(), &vec![b'a'; DEFAULT_CONTROL_LINE_LIMIT + 1]].concat();
assert_eq!(
table.upsert(Path::new("b/.gitignore"), over).expect("control path"),
OVER_LINE_LIMIT
);
assert!(!table.is_ignored(Path::new("b/debug.log"), false), "no rule of it applies");
assert_eq!(
table.refusals().collect::<Vec<_>>(),
vec![RefusedControl {
path: PathBuf::from("b/.gitignore"),
reason: ControlRefusalReason::LineLimit,
}]
);
}
#[test]
fn the_budget_and_the_line_limit_lift_independently() {
let long = [b"*.log\n".as_slice(), &vec![b'a'; DEFAULT_CONTROL_LINE_LIMIT + 1]].concat();
let large = source_at_test_limit();
let mut no_budget = budgeted(None);
assert_eq!(
no_budget.upsert(Path::new("big/.gitignore"), large.clone()).expect("path"),
CHANGED
);
assert_eq!(
no_budget.upsert(Path::new("c/.gitignore"), b"*.tmp\n".to_vec()).expect("path"),
CHANGED
);
assert!(no_budget.retained_cost() > DEFAULT_CONTROL_BUDGET);
assert_eq!(
no_budget.upsert(Path::new(".gitignore"), long.clone()).expect("path"),
OVER_LINE_LIMIT
);
let mut raised_budget = budgeted(Some(16 * DEFAULT_CONTROL_BUDGET));
assert_eq!(
raised_budget.upsert(Path::new(".gitignore"), long.clone()).expect("path"),
OVER_LINE_LIMIT
);
let mut no_line_limit = ControlTable::with_limits(ControlLimits {
line_limit: None,
..ControlLimits::default()
});
assert_eq!(no_line_limit.upsert(Path::new(".gitignore"), long).expect("path"), CHANGED);
assert!(no_line_limit.is_ignored(Path::new("debug.log"), false));
assert_eq!(
no_line_limit.upsert(Path::new("big/.gitignore"), large).expect("path"),
OVER_BUDGET
);
no_line_limit.assert_consistent();
}
#[test]
fn a_listing_of_refusals_is_bounded_and_says_when_it_is_truncated() {
let mut table = budgeted(Some(0));
let refused = crate::MAX_RETAINED_ISSUES + 1;
for directory in 0..refused {
let path = PathBuf::from(format!("d{directory:03}/.gitignore"));
assert_eq!(table.upsert(&path, b"*\n".to_vec()).expect("control path"), OVER_BUDGET);
}
let observation = table.observation();
assert_eq!(observation.refused, u64::try_from(refused).expect("small"));
assert_eq!(observation.refusals.len(), crate::MAX_RETAINED_ISSUES);
assert_eq!(observation.refusals[0].path, Path::new("d000/.gitignore"));
assert!(!observation.lists_every_refusal());
assert!(!observation.is_complete());
}
#[test]
fn control_identity_uses_standard_fnv1a_vectors() {
assert_eq!(identity(b"").fingerprint, 0xcbf2_9ce4_8422_2325);
assert_eq!(identity(b"a").fingerprint, 0xaf63_dc4c_8601_ec8c);
assert_eq!(identity(b"foobar").fingerprint, 0x8594_4171_f739_67e8);
}
#[test]
fn nested_negation_and_control_removal_change_the_governed_subtree() {
let mut table = ControlTable::default();
table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("root");
table.upsert(Path::new("docs/.gitignore"), b"!keep.log\n".to_vec()).expect("nested");
assert!(table.is_ignored(Path::new("debug.log"), false));
assert!(table.is_ignored(Path::new("docs/other.log"), false));
assert!(!table.is_ignored(Path::new("docs/keep.log"), false));
assert_eq!(
ControlTable::affected_subtree(Path::new("docs/.gitignore")).expect("scope"),
Path::new("docs")
);
table.remove(Path::new("docs/.gitignore")).expect("remove nested");
assert!(table.is_ignored(Path::new("docs/keep.log"), false));
}
#[test]
fn ignored_parent_cannot_be_reincluded_from_inside_it() {
let mut table = ControlTable::default();
table.upsert(Path::new(".gitignore"), b"vendor/\n".to_vec()).expect("root");
table
.upsert(Path::new("vendor/.gitignore"), b"!keep.txt\n".to_vec())
.expect("retained but inactive nested control");
assert!(table.is_ignored(Path::new("vendor"), true));
assert!(table.is_ignored(Path::new("vendor/keep.txt"), false));
}
#[test]
fn a_control_name_is_spelled_in_any_ascii_case_and_recorded_by_one_path() {
use std::ffi::OsStr;
assert_eq!(control_spelling(OsStr::new(".gitignore")), Some(ControlSpelling::Exact));
for variant in [".GITIGNORE", ".GitIgnore", ".gitIGNORE", ".gitignorE"] {
assert_eq!(control_spelling(OsStr::new(variant)), Some(ControlSpelling::Variant));
}
for other in [".gıtıgnore", ".gitignor", ".gitignore~", "gitignore.", ".gitignor3", ""] {
assert_eq!(control_spelling(OsStr::new(other)), None, "{other:?}");
}
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt as _;
assert_eq!(control_spelling(OsStr::from_bytes(b".gitignor\xff")), None);
}
assert_eq!(
governing_control(Path::new("a/.GITIGNORE")),
Some(PathBuf::from("a/.gitignore"))
);
assert_eq!(governing_control(Path::new(".gitignore")), Some(PathBuf::from(".gitignore")));
assert_eq!(governing_control(Path::new("a/README")), None);
assert!(is_control_file(Path::new("a/.gitignore")));
assert!(!is_control_file(Path::new("a/.GITIGNORE")), "operations name one path");
let mut table = ControlTable::default();
assert!(table.upsert(Path::new("a/.GITIGNORE"), b"*.log\n".to_vec()).is_err());
}
}