1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct ExecutableMatchOptions {
13 pub resolve_existing_symlinks: bool,
15 pub normalize_case: bool,
17 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 pub fn new() -> Self {
34 Self::default()
35 }
36}
37
38pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct CommandLinePathMatchOptions {
79 pub case_insensitive: bool,
81 pub normalize_separators: bool,
83 pub support_quotes: bool,
85 pub support_wsl_translation: bool,
87 pub match_basename: bool,
89 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 pub fn new() -> Self {
109 Self::default()
110 }
111}
112
113pub 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 }
176
177 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 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
260fn 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
291fn 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 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}