Skip to main content

weavatrix_scan/
ignore.rs

1use crate::hash::FingerprintHasher;
2use crate::path::normalized_relative_path;
3use crate::report::{IgnoreSourceEvidence, IgnoreSourceKind};
4use std::collections::HashMap;
5use std::fmt;
6use std::fs;
7use std::io;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11mod git;
12mod matcher;
13mod overrides;
14mod parser;
15mod repository;
16mod repository_source;
17mod rules;
18#[cfg(test)]
19mod tests;
20
21#[cfg(test)]
22use git::{expand_home, read_excludes_setting, read_excludes_setting_for, resolve_git_directory};
23use matcher::RuleMatcher;
24use parser::parse_file;
25pub use repository::RepositoryMatcher;
26#[cfg(test)]
27use repository_source::{add_rule_file, find_repository_root};
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct IgnoreFile {
31    pub name: String,
32}
33
34/// Highest-precedence selection decision for a repository path.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum RepositoryMatch {
37    None,
38    Ignore,
39    Include,
40    OverrideIgnore,
41    OverrideInclude,
42    Hidden,
43}
44
45impl RepositoryMatch {
46    #[must_use]
47    pub const fn is_ignored(self) -> bool {
48        matches!(self, Self::Ignore | Self::OverrideIgnore | Self::Hidden)
49    }
50}
51
52const SOURCE_COUNT: usize = 6;
53
54#[derive(Debug, Clone, Default)]
55pub(crate) struct IgnoreRules {
56    layers: [Option<Arc<IgnoreLayer>>; SOURCE_COUNT],
57}
58
59#[derive(Debug)]
60struct IgnoreLayer {
61    base: String,
62    rules: RuleSet,
63    parent: Option<Arc<IgnoreLayer>>,
64}
65
66#[derive(Debug, Clone, Default)]
67struct RuleSet {
68    rules: Vec<IgnoreRule>,
69    exact_anywhere: HashMap<String, Vec<usize>>,
70    prefixes: HashMap<u8, Vec<usize>>,
71    suffixes: HashMap<u8, Vec<usize>>,
72    generic: Vec<usize>,
73}
74
75#[derive(Debug, Clone)]
76struct IgnoreRule {
77    pattern: String,
78    action: RuleAction,
79    target: RuleTarget,
80    scope: RuleScope,
81    matcher: RuleMatcher,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85enum RuleAction {
86    Ignore,
87    Include,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91enum RuleTarget {
92    Any,
93    Directory,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97enum RuleScope {
98    Anywhere,
99    Path,
100    Anchored,
101}
102
103#[derive(Debug, Clone, Copy)]
104enum RuleMatch {
105    Exact(RuleAction),
106    Ancestor(RuleAction),
107}
108
109#[derive(Debug, Clone, Copy)]
110enum SourceRank {
111    GitGlobal = 0,
112    GitExclude = 1,
113    GitIgnore = 2,
114    DotIgnore = 3,
115    Custom = 4,
116    Explicit = 5,
117}
118
119impl SourceRank {
120    const fn index(self) -> usize {
121        self as usize
122    }
123}
124
125#[derive(Debug)]
126pub(crate) struct IgnoreError {
127    kind: io::ErrorKind,
128    path: PathBuf,
129    message: String,
130}
131
132impl IgnoreError {
133    pub(crate) const fn kind(&self) -> io::ErrorKind {
134        self.kind
135    }
136}
137
138impl fmt::Display for IgnoreError {
139    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
140        write!(formatter, "{}: {}", self.path.display(), self.message)
141    }
142}
143
144impl std::error::Error for IgnoreError {}
145
146pub(crate) fn build_child_rules(
147    directory: &Path,
148    base: &str,
149    ignore_files: &[String],
150    case_insensitive: bool,
151    inherited: &IgnoreRules,
152    evidence_root: &Path,
153) -> (IgnoreRules, Vec<IgnoreError>, Vec<IgnoreSourceEvidence>) {
154    let mut result = inherited.clone();
155    let mut rules_by_source: [RuleSet; SOURCE_COUNT] = Default::default();
156    let mut errors = Vec::new();
157    let mut evidence = Vec::new();
158    for name in ignore_files {
159        let path = directory.join(name);
160        let bytes = match read_local_rule_file(&path) {
161            Ok(bytes) => bytes,
162            Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
163            Err(error) => {
164                errors.push(IgnoreError {
165                    kind: error.kind(),
166                    path,
167                    message: error.to_string(),
168                });
169                continue;
170            }
171        };
172        let (rank, kind) = source_for_name(name);
173        parser::parse_file_bytes(
174            &path,
175            &bytes,
176            case_insensitive,
177            &mut rules_by_source[rank.index()],
178            &mut errors,
179        );
180        evidence.push(source_evidence(
181            kind,
182            normalized_evidence_location(&path, evidence_root),
183            &bytes,
184        ));
185    }
186    for (index, rules) in rules_by_source.into_iter().enumerate() {
187        if rules.rules.is_empty() {
188            continue;
189        }
190        result.layers[index] = Some(Arc::new(IgnoreLayer {
191            base: base.to_owned(),
192            rules,
193            parent: result.layers[index].clone(),
194        }));
195    }
196    (result, errors, evidence)
197}
198
199fn match_rules(path: &str, is_directory: bool, rules: &IgnoreRules) -> Option<RuleAction> {
200    let mut ancestor_included = false;
201    for source in rules.layers.iter().rev() {
202        let mut layer = source.as_deref();
203        while let Some(current) = layer {
204            if let Some(candidate) = candidate_for_base(path, &current.base)
205                && let Some(rule_match) = current.rules.matches(candidate, is_directory)
206            {
207                match rule_match {
208                    RuleMatch::Exact(action) => return Some(action),
209                    RuleMatch::Ancestor(RuleAction::Include) => ancestor_included = true,
210                    RuleMatch::Ancestor(RuleAction::Ignore) if !ancestor_included => {
211                        return Some(RuleAction::Ignore);
212                    }
213                    RuleMatch::Ancestor(RuleAction::Ignore) => {}
214                }
215            }
216            layer = current.parent.as_deref();
217        }
218    }
219    ancestor_included.then_some(RuleAction::Include)
220}
221
222fn candidate_for_base<'a>(path: &'a str, base: &str) -> Option<&'a str> {
223    if base.is_empty() {
224        Some(path)
225    } else {
226        path.strip_prefix(base)?.strip_prefix('/')
227    }
228}
229
230fn source_for_name(name: &str) -> (SourceRank, IgnoreSourceKind) {
231    match name {
232        ".gitignore" => (SourceRank::GitIgnore, IgnoreSourceKind::GitIgnore),
233        ".ignore" => (SourceRank::DotIgnore, IgnoreSourceKind::DotIgnore),
234        _ => (SourceRank::Custom, IgnoreSourceKind::Custom),
235    }
236}
237
238fn source_evidence(
239    kind: IgnoreSourceKind,
240    location: String,
241    contents: &[u8],
242) -> IgnoreSourceEvidence {
243    let mut hash = FingerprintHasher::new();
244    hash.write(contents);
245    IgnoreSourceEvidence {
246        kind,
247        location,
248        content_hash: hash.finish(),
249    }
250}
251
252fn normalized_evidence_location(path: &Path, root: &Path) -> String {
253    path.strip_prefix(root).map_or_else(
254        |_| path.to_string_lossy().replace('\\', "/"),
255        normalized_relative_path,
256    )
257}
258
259fn read_local_rule_file(path: &Path) -> io::Result<Vec<u8>> {
260    let metadata = fs::symlink_metadata(path)?;
261    if metadata.file_type().is_symlink() {
262        return Err(io::Error::new(
263            io::ErrorKind::InvalidInput,
264            "ignore file is a symbolic link",
265        ));
266    }
267    fs::read(path)
268}