Skip to main content

path_rs/
match_path.rs

1//! Generic executable and command-line path matching (no process or shell execution).
2
3use crate::error::PathError;
4use crate::identity::{PathIdentityOptions, path_identity_key};
5use crate::internal::validation::reject_nul_path;
6use crate::platform::{is_verbatim, simplify_for_display};
7use crate::text::CaseNormalization;
8use std::path::Path;
9
10/// Options for comparing two executable path strings/paths.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct ExecutableMatchOptions {
13    /// Attempt to canonicalize existing paths (resolve symlinks).
14    pub resolve_existing_symlinks: bool,
15    /// Apply platform-default case folding when true; preserve when false.
16    pub normalize_case: bool,
17    /// Strip Windows verbatim prefixes for comparison when true.
18    pub strip_verbatim_prefix: bool,
19}
20
21impl Default for ExecutableMatchOptions {
22    fn default() -> Self {
23        Self {
24            resolve_existing_symlinks: false,
25            normalize_case: true,
26            strip_verbatim_prefix: true,
27        }
28    }
29}
30
31impl ExecutableMatchOptions {
32    /// Create default options (`Self::default()`).
33    pub fn new() -> Self {
34        Self::default()
35    }
36}
37
38/// Returns true if two executable paths match under `options`.
39///
40/// Handles ordinary Windows paths, forward slashes, and optional verbatim prefixes.
41/// Does **not** inspect running processes.
42///
43/// # Filesystem access
44///
45/// Only when `resolve_existing_symlinks` is true.
46pub fn executable_paths_match(
47    expected: impl AsRef<Path>,
48    actual: impl AsRef<Path>,
49    options: ExecutableMatchOptions,
50) -> Result<bool, PathError> {
51    let expected = expected.as_ref();
52    let actual = actual.as_ref();
53    reject_nul_path(expected)?;
54    reject_nul_path(actual)?;
55
56    let case = if options.normalize_case {
57        CaseNormalization::PlatformDefault
58    } else {
59        CaseNormalization::Preserve
60    };
61
62    let identity = PathIdentityOptions {
63        normalize_lexically: true,
64        normalize_separators: true,
65        case,
66        resolve_existing_symlinks: options.resolve_existing_symlinks,
67        strip_windows_verbatim_prefix: options.strip_verbatim_prefix,
68        translate_wsl_paths: false,
69    };
70
71    let a = path_identity_key(expected, identity)?;
72    let b = path_identity_key(actual, identity)?;
73    Ok(a == b)
74}
75
76/// Options for detecting a path inside a command-line string.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct CommandLinePathMatchOptions {
79    /// Compare with ASCII case folding when true.
80    pub case_insensitive: bool,
81    /// Normalize `\` and `/` before matching.
82    pub normalize_separators: bool,
83    /// Parse double- and single-quoted tokens.
84    pub support_quotes: bool,
85    /// Translate `/mnt/<drive>/...` tokens before matching.
86    pub support_wsl_translation: bool,
87    /// Also match the path basename as a token (intentionally fuzzy).
88    pub match_basename: bool,
89    /// Require path separators at token boundaries (reduces `repo` vs `repository` false positives).
90    pub require_component_boundary: bool,
91}
92
93impl Default for CommandLinePathMatchOptions {
94    fn default() -> Self {
95        Self {
96            case_insensitive: cfg!(windows),
97            normalize_separators: true,
98            support_quotes: true,
99            support_wsl_translation: false,
100            match_basename: false,
101            require_component_boundary: true,
102        }
103    }
104}
105
106impl CommandLinePathMatchOptions {
107    /// Create default options (`Self::default()`).
108    pub fn new() -> Self {
109        Self::default()
110    }
111}
112
113/// Returns true if `command_line` appears to reference `path`.
114///
115/// This is a **heuristic** matcher for tooling, not a full shell parser.
116/// It does not invoke a shell or execute command-line content.
117///
118/// # Filesystem access
119///
120/// **No** (identity-key path uses lexical options only).
121///
122/// # Behavior
123///
124/// 1. Rejects empty target paths.
125/// 2. Tokenizes the command line (optional quotes).
126/// 3. Matches full path, separator-normalized form, and optional identity key.
127/// 4. Optionally matches basename (document as fuzzy when enabled).
128/// 5. Avoids pure substring matches when `require_component_boundary` is true.
129pub fn command_line_contains_path(
130    command_line: &str,
131    path: impl AsRef<Path>,
132    options: CommandLinePathMatchOptions,
133) -> Result<bool, PathError> {
134    let path = path.as_ref();
135    reject_nul_path(path)?;
136    if path.as_os_str().is_empty() {
137        return Err(PathError::EmptyInput);
138    }
139
140    let path_str = path.to_string_lossy();
141    if path_str.is_empty() {
142        return Err(PathError::EmptyInput);
143    }
144
145    let case = if options.case_insensitive {
146        CaseNormalization::AsciiLowercase
147    } else {
148        CaseNormalization::Preserve
149    };
150
151    let mut candidates = Vec::new();
152    candidates.push(path_str.as_ref().to_owned());
153    if options.normalize_separators {
154        candidates.push(path_str.replace('\\', "/"));
155        candidates.push(path_str.replace('/', "\\"));
156    }
157
158    if is_verbatim(path) {
159        let simplified = simplify_for_display(path);
160        let s = simplified.to_string_lossy().into_owned();
161        candidates.push(s.clone());
162        if options.normalize_separators {
163            candidates.push(s.replace('\\', "/"));
164        }
165    }
166
167    if options.support_wsl_translation {
168        if let Ok(Some(wsl)) = crate::platform::translate_wsl_path(path_str.as_ref()) {
169            let s = wsl.to_string_lossy().into_owned();
170            candidates.push(s.clone());
171            candidates.push(s.replace('\\', "/"));
172        }
173        // Also allow reverse: if path is Windows-like, skip; if path is /mnt/c/...
174        // translate_wsl already handled when path is WSL form.
175    }
176
177    // Lexical identity key (no FS).
178    let key = path_identity_key(
179        path,
180        PathIdentityOptions {
181            normalize_lexically: true,
182            normalize_separators: true,
183            case,
184            resolve_existing_symlinks: false,
185            strip_windows_verbatim_prefix: true,
186            translate_wsl_paths: options.support_wsl_translation,
187        },
188    )?;
189    candidates.push(key);
190
191    if options.match_basename {
192        if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
193            if !name.is_empty() && name != "." && name != ".." {
194                candidates.push(name.to_owned());
195            }
196        }
197    }
198
199    let tokens = if options.support_quotes {
200        tokenize_command_line(command_line)
201    } else {
202        command_line.split_whitespace().map(str::to_owned).collect()
203    };
204
205    for token in &tokens {
206        let token_cmp = prepare_token(token, options, case);
207        for cand in &candidates {
208            let cand_cmp = prepare_token(cand, options, case);
209            if cand_cmp.is_empty() {
210                continue;
211            }
212            if token_equals_or_contains(&token_cmp, &cand_cmp, options) {
213                return Ok(true);
214            }
215        }
216    }
217
218    // Also scan whole line with boundary-aware search when configured.
219    let line = prepare_token(command_line, options, case);
220    for cand in &candidates {
221        let cand_cmp = prepare_token(cand, options, case);
222        if cand_cmp.is_empty() {
223            continue;
224        }
225        if options.require_component_boundary {
226            if boundary_contains(&line, &cand_cmp) {
227                return Ok(true);
228            }
229        } else if line.contains(&cand_cmp) {
230            return Ok(true);
231        }
232    }
233
234    Ok(false)
235}
236
237fn prepare_token(s: &str, options: CommandLinePathMatchOptions, case: CaseNormalization) -> String {
238    let mut t = s.trim().trim_matches('"').trim_matches('\'').to_owned();
239    if options.normalize_separators {
240        t = t.replace('\\', "/");
241    }
242    case.apply(&t)
243}
244
245fn token_equals_or_contains(
246    token: &str,
247    candidate: &str,
248    options: CommandLinePathMatchOptions,
249) -> bool {
250    if token == candidate {
251        return true;
252    }
253    if options.require_component_boundary {
254        boundary_contains(token, candidate)
255    } else {
256        token.contains(candidate)
257    }
258}
259
260/// True when `needle` appears in `haystack` at a component-ish boundary.
261///
262/// Boundaries are start/end of string or a path separator / whitespace / quote.
263fn boundary_contains(haystack: &str, needle: &str) -> bool {
264    if needle.is_empty() {
265        return false;
266    }
267    let mut start = 0usize;
268    while let Some(rel) = haystack[start..].find(needle) {
269        let idx = start + rel;
270        let before_ok = idx == 0 || is_boundary_char(haystack.as_bytes()[idx - 1]);
271        let end = idx + needle.len();
272        let after_ok = end == haystack.len() || is_boundary_char(haystack.as_bytes()[end]);
273        if before_ok && after_ok {
274            return true;
275        }
276        start = idx + 1;
277        if start >= haystack.len() {
278            break;
279        }
280    }
281    false
282}
283
284fn is_boundary_char(b: u8) -> bool {
285    matches!(
286        b,
287        b'/' | b'\\' | b' ' | b'\t' | b'\n' | b'\r' | b'"' | b'\'' | b'=' | b':' | b',' | b';'
288    )
289}
290
291/// Minimal tokenizer: whitespace-separated tokens with `"..."` and `'...'` support.
292///
293/// Does not implement full shell syntax (escapes are best-effort for `\"` only).
294fn tokenize_command_line(input: &str) -> Vec<String> {
295    let mut tokens = Vec::new();
296    let mut cur = String::new();
297    let mut chars = input.chars().peekable();
298    let mut in_double = false;
299    let mut in_single = false;
300
301    while let Some(c) = chars.next() {
302        match c {
303            '\\' if in_double => {
304                if let Some(&next) = chars.peek() {
305                    if next == '"' || next == '\\' {
306                        cur.push(chars.next().unwrap_or(next));
307                        continue;
308                    }
309                }
310                cur.push('\\');
311            }
312            '"' if !in_single => {
313                in_double = !in_double;
314            }
315            '\'' if !in_double => {
316                in_single = !in_single;
317            }
318            c if c.is_whitespace() && !in_double && !in_single => {
319                if !cur.is_empty() {
320                    tokens.push(std::mem::take(&mut cur));
321                }
322            }
323            other => cur.push(other),
324        }
325    }
326    if !cur.is_empty() {
327        tokens.push(cur);
328    }
329    tokens
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn executable_slash_variants() {
338        let opts = ExecutableMatchOptions::default();
339        assert!(
340            executable_paths_match(
341                r"C:\Program Files\Tool\app.exe",
342                r"c:/program files/tool/app.exe",
343                opts
344            )
345            .unwrap()
346        );
347    }
348
349    #[test]
350    fn command_line_boundary_avoids_prefix_false_positive() {
351        let opts = CommandLinePathMatchOptions {
352            case_insensitive: true,
353            require_component_boundary: true,
354            match_basename: false,
355            ..CommandLinePathMatchOptions::default()
356        };
357        // `C:\repo` must not match inside `C:\repository`.
358        assert!(!command_line_contains_path(r"tool C:\repository\file", r"C:\repo", opts).unwrap());
359        assert!(command_line_contains_path(r"tool C:\repo\file", r"C:\repo", opts).unwrap());
360    }
361
362    #[test]
363    fn quoted_paths() {
364        let opts = CommandLinePathMatchOptions {
365            case_insensitive: true,
366            ..CommandLinePathMatchOptions::default()
367        };
368        assert!(
369            command_line_contains_path(
370                r#"tool "C:\Users\Floris\repo" --flag"#,
371                r"C:\Users\Floris\repo",
372                opts
373            )
374            .unwrap()
375        );
376    }
377}