Skip to main content

allow_core/
source_tree_path.rs

1use std::path::Path;
2
3use crate::AllowEntry;
4
5/// Normalize a path for source-tree identity and matching.
6///
7/// All backslashes are converted to forward slashes, Unicode is normalized to
8/// NFC (composed form), `.`/`..` segments are folded, and the Windows verbatim
9/// prefix (`\\?\`) is stripped. This is a lexical normalization only — it does
10/// not touch the filesystem.
11///
12/// # Unicode NFC normalization (#1823)
13///
14/// macOS (HFS+/APFS) and git may represent the same path in different Unicode
15/// normalization forms (NFC composed vs NFD decomposed). Without NFC
16/// normalization, `files.sort(); files.dedup()` in the inventory treats NFC
17/// and NFD forms of the same path as distinct, and finding→entry matching
18/// (`normalize_path(finding_path) == normalize_path(entry_path)`) produces
19/// **false positives/false negatives split across platforms** — a real
20/// `unwrap()` finding goes unreceipted on macOS but matched on Linux.
21/// Normalizing to NFC inside this function ensures all downstream matching,
22/// fingerprinting, and identity keying sees one canonical Unicode form.
23///
24/// # Windows absolute paths (#1821)
25///
26/// `normalize_path` handles three Windows absolute shapes:
27///
28/// - **Verbatim prefix** (`\\?\C:\...` or `\\?\UNC\server\share\...`):
29///   stripped so the path degrades to its non-verbatim form (`C:/...` or
30///   `//server/share/...`). This is the case that silently produced wrong
31///   identity keys because the `\\?\` prefix survived as path segments.
32/// - **Drive letters** (`C:\...`): preserved as `C:/...`. The drive letter
33///   is a meaningful absolute-path identity component, not a repo-relative
34///   segment, and several callers (e.g. migrate evidence diagnostics) pass
35///   absolute roots through this function. Stripping it would corrupt those
36///   identities.
37/// - **UNC roots** (`\\server\share\...`): preserved as `//server/share/...`.
38///   Two leading slashes fold to a single Unix-style absolute root (`/`)
39///   during the segment walk, so `//server/share/foo` → `/server/share/foo`.
40///
41/// The scanner resolves finding paths against the source-tree root before
42/// calling this function, so repo-relative paths are the normal input.
43pub fn normalize_path(path: impl AsRef<Path>) -> String {
44    let text = path.as_ref().to_string_lossy().replace('\\', "/");
45    // NFC-normalize the text so that composed/decomposed Unicode forms of the
46    // same path produce the same identity key (#1823). This prevents
47    // cross-platform (macOS NFD vs Linux NFC) matching divergence.
48    use unicode_normalization::UnicodeNormalization;
49    let nfc: String = text.nfc().collect();
50    // Strip the Windows verbatim prefix (\\?\) so it doesn't survive as path
51    // segments. Drive letters and plain UNC roots are preserved (see docs).
52    let (stripped, force_absolute) = strip_verbatim_prefix(&nfc);
53    let absolute = force_absolute || stripped.starts_with('/');
54    let mut parts = Vec::new();
55    for part in stripped.split('/') {
56        match part {
57            "" | "." => {}
58            ".." => {
59                if parts.last().is_some_and(|part| *part != "..") {
60                    parts.pop();
61                } else if !absolute {
62                    parts.push(part);
63                }
64            }
65            other => parts.push(other),
66        }
67    }
68    let normalized = parts.join("/");
69    if absolute {
70        format!("/{normalized}")
71    } else {
72        normalized
73    }
74}
75
76/// Strip the Windows verbatim prefix from a forward-slashed path string.
77///
78/// Returns the stripped path and a flag indicating whether the result should
79/// be treated as absolute (set for verbatim UNC paths where the `\\?\UNC\`
80/// prefix is stripped but the path is still absolute).
81///
82/// - `//?/C:/foo` → `("C:/foo", false)` — drive letter preserved.
83/// - `//?/UNC/server/share/foo` → `("server/share/foo", true)` — verbatim UNC
84///   stripped, force-absolute so the result is `/server/share/foo`.
85fn strip_verbatim_prefix(text: &str) -> (&str, bool) {
86    if let Some(rest) = text.strip_prefix("//?/UNC/") {
87        return (rest, true);
88    }
89    if let Some(rest) = text.strip_prefix("//?/") {
90        return (rest, false);
91    }
92    (text, false)
93}
94
95pub(crate) fn normalize_source_tree_scope(scope: &str) -> String {
96    scope.replace('\\', "/")
97}
98
99pub fn glob_matches(pattern: &str, path: &Path) -> bool {
100    let path = normalize_path(path);
101    glob_matches_str(pattern, &path)
102}
103
104/// Maximum recursive match steps for one glob evaluation.
105///
106/// Protects against exponential backtracking from pathological patterns such
107/// as many `*` / `**` tokens against long paths (#1924). When the budget is
108/// exhausted the match fails closed (returns `false`) instead of hanging.
109pub const GLOB_MATCH_MAX_STEPS: u32 = 10_000;
110
111pub fn glob_matches_str(pattern: &str, path: &str) -> bool {
112    let p = pattern.replace('\\', "/");
113    let mut steps = 0;
114    glob_match_tokens(&split_glob(&p), &split_glob(path), &mut steps)
115}
116
117pub fn source_tree_path_matches_filter(item_path: &str, filter_path: &str) -> bool {
118    let item_path = normalize_path(item_path);
119    let filter_path = normalize_path(filter_path);
120    let filter_path = filter_path.trim_end_matches('/');
121    if filter_path.is_empty() || filter_path == "." {
122        return true;
123    }
124    item_path == filter_path
125        || item_path
126            .strip_prefix(filter_path)
127            .map(|suffix| suffix.starts_with('/'))
128            .unwrap_or(false)
129        // #2776: support glob matching in BOTH directions. The filter may
130        // be a glob (e.g. `--path 'src/**/*.rs'` from CLI), or the item
131        // path may be a glob (e.g. a broad-scope allow entry's scope).
132        || (source_tree_scope_has_wildcard(filter_path)
133            && glob_matches_str(filter_path, &item_path))
134        || (source_tree_scope_has_wildcard(&item_path)
135            && glob_matches_str(&item_path, filter_path))
136}
137
138pub fn source_tree_path_is_ignored(path: impl AsRef<Path>, patterns: &[String]) -> bool {
139    let path = path.as_ref();
140    let normalized = normalize_path(path);
141    patterns.iter().any(|pattern| {
142        glob_matches(pattern, path)
143            || pattern
144                .strip_suffix("/**")
145                .map(|prefix| {
146                    let prefix = normalize_path(prefix);
147                    normalized == prefix || normalized.starts_with(&format!("{prefix}/"))
148                })
149                .unwrap_or(false)
150    })
151}
152
153pub fn source_tree_scope_has_wildcard(scope: &str) -> bool {
154    scope.chars().any(|ch| matches!(ch, '*' | '?'))
155}
156
157pub fn allow_entry_broad_scope(entry: &AllowEntry) -> Option<String> {
158    entry
159        .path
160        .as_ref()
161        .map(normalize_path)
162        .filter(|scope| source_tree_scope_has_wildcard(scope))
163        .or_else(|| {
164            entry
165                .glob
166                .as_deref()
167                .map(normalize_source_tree_scope)
168                .filter(|scope| source_tree_scope_has_wildcard(scope))
169        })
170        .or_else(|| {
171            entry
172                .selector
173                .glob
174                .as_deref()
175                .map(normalize_source_tree_scope)
176                .filter(|scope| source_tree_scope_has_wildcard(scope))
177        })
178}
179
180/// Strip Win32 verbatim path prefixes (`\\?\` and `\\?\UNC\`) from a path
181/// string for clean display in error messages and JSON output (#3180-#3187).
182///
183/// On non-Windows or paths without the prefix, this is a no-op.
184pub fn strip_win32_verbatim_prefix(path: &str) -> String {
185    // Handle both backslash and forward slash variants
186    let normalized = path.replace('\\', "/");
187    if let Some(rest) = normalized.strip_prefix("//?/UNC/") {
188        format!("/{rest}")
189    } else if let Some(rest) = normalized.strip_prefix("//?/") {
190        rest.to_string()
191    } else {
192        path.to_string()
193    }
194}
195
196fn split_glob(s: &str) -> Vec<&str> {
197    s.split('/').filter(|part| !part.is_empty()).collect()
198}
199
200fn take_glob_step(steps: &mut u32) -> bool {
201    if *steps >= GLOB_MATCH_MAX_STEPS {
202        return false;
203    }
204    *steps = steps.saturating_add(1);
205    true
206}
207
208fn glob_match_tokens(pattern: &[&str], path: &[&str], steps: &mut u32) -> bool {
209    if !take_glob_step(steps) {
210        return false;
211    }
212    let Some((pattern_head, pattern_tail)) = pattern.split_first() else {
213        return path.is_empty();
214    };
215    if *pattern_head == "**" {
216        if glob_match_tokens(pattern_tail, path, steps) {
217            return true;
218        }
219        return path
220            .split_first()
221            .is_some_and(|(_, path_tail)| glob_match_tokens(pattern, path_tail, steps));
222    }
223    path.split_first().is_some_and(|(path_head, path_tail)| {
224        segment_matches(pattern_head, path_head, steps)
225            && glob_match_tokens(pattern_tail, path_tail, steps)
226    })
227}
228
229fn segment_matches(pattern: &str, text: &str, steps: &mut u32) -> bool {
230    let pattern = pattern.chars().collect::<Vec<_>>();
231    let text = text.chars().collect::<Vec<_>>();
232    segment_match_chars(&pattern, &text, steps)
233}
234
235fn segment_match_chars(pattern: &[char], text: &[char], steps: &mut u32) -> bool {
236    if !take_glob_step(steps) {
237        return false;
238    }
239    let Some((&pattern_head, pattern_tail)) = pattern.split_first() else {
240        return text.is_empty();
241    };
242    match pattern_head {
243        '*' => {
244            segment_match_chars(pattern_tail, text, steps)
245                || text
246                    .split_first()
247                    .is_some_and(|(_, text_tail)| segment_match_chars(pattern, text_tail, steps))
248        }
249        '?' => text
250            .split_first()
251            .is_some_and(|(_, text_tail)| segment_match_chars(pattern_tail, text_tail, steps)),
252        ch => text.split_first().is_some_and(|(&text_head, text_tail)| {
253            ch == text_head && segment_match_chars(pattern_tail, text_tail, steps)
254        }),
255    }
256}