Skip to main content

nodejs/stdlib/
path.rs

1//! Node `path` module — both flavors.
2//!
3//! A faithful port of Node's `lib/path.js` (v26): the POSIX flavor (which is
4//! what `require('path')` and `path.posix` yield on a POSIX host) and the
5//! Windows flavor (`path.win32` / `require('path/win32')`), sharing the same
6//! `normalize_string` core. The algorithms mirror the JS line-for-line —
7//! index arithmetic included — so edge cases (UNC roots, device roots, reserved
8//! device names, the CVE-2024-36139 relative-drive guard, trailing-separator
9//! retention) behave exactly as Node's do rather than approximately.
10//!
11//! Node scans paths by UTF-16 code unit; this port scans by `char`. Every
12//! comparison is against ASCII (`/`, `\`, `.`, `:`, drive letters) and every
13//! slice boundary is derived from those comparisons, so the produced strings
14//! are identical — only the intermediate index values differ on astral input.
15
16use super::arg_str;
17use crate::host::{invalid_arg_type, with_host};
18use fusevm::Value;
19use indexmap::IndexMap;
20
21pub const METHODS: &[&str] = &[
22    "join",
23    "resolve",
24    "normalize",
25    "basename",
26    "dirname",
27    "extname",
28    "isAbsolute",
29    "relative",
30    "parse",
31    "format",
32    "matchesGlob",
33    "toNamespacedPath",
34    // Legacy internal alias for `toNamespacedPath` (docs-only deprecated DEP0080).
35    "_makeLong",
36];
37
38/// Which separator flavor a `path` call runs under.
39#[derive(Clone, Copy, PartialEq, Eq)]
40pub enum Flavor {
41    Posix,
42    Win32,
43}
44
45impl Flavor {
46    /// Node's per-flavor `isPathSeparator`: win32 accepts both slashes.
47    fn is_sep(self, c: char) -> bool {
48        c == '/' || (self == Flavor::Win32 && c == '\\')
49    }
50
51    fn sep(self) -> char {
52        match self {
53            Flavor::Posix => '/',
54            Flavor::Win32 => '\\',
55        }
56    }
57
58    fn delimiter(self) -> &'static str {
59        match self {
60            Flavor::Posix => ":",
61            Flavor::Win32 => ";",
62        }
63    }
64}
65
66/// `path.sep` / `path.delimiter` constants for `flavor`.
67pub fn constant(flavor: Flavor, name: &str) -> Option<Value> {
68    match name {
69        "sep" => Some(with_host(|h| h.new_str(flavor.sep().to_string()))),
70        "delimiter" => Some(with_host(|h| h.new_str(flavor.delimiter()))),
71        _ => None,
72    }
73}
74
75/// `lib/path.js`'s argument validation, run before any work and in the same
76/// order node runs it, so the first bad argument is the one named.
77///
78/// Every method takes strings (`validateString`), except `format` (an object,
79/// `validateObject`) and `toNamespacedPath` (anything; a non-string comes back
80/// unchanged, handled at its call site). `basename` checks its suffix before its
81/// path, and only when the suffix is not `undefined`. `resolve` walks its
82/// arguments right to left and stops at the first one that settles the result —
83/// posix: an absolute path; win32: an absolute path carrying a device (`C:\`,
84/// `\\server\share`) — so an argument to the left of that is never looked at
85/// (`path.resolve(1, '/a')` is `'/a'` in node).
86fn validate_args(flavor: Flavor, method: &str, args: &[Value]) -> Result<(), String> {
87    let arg = |i: usize| args.get(i).cloned().unwrap_or(Value::Undef);
88    let string = |i: usize, name: &str| -> Result<(), String> {
89        let v = arg(i);
90        if with_host(|h| h.type_of(&v)) == "string" {
91            Ok(())
92        } else {
93            Err(invalid_arg_type(name, "argument", "string", &v))
94        }
95    };
96    match method {
97        "join" => (0..args.len()).try_for_each(|i| string(i, "path")),
98        "resolve" => {
99            for i in (0..args.len()).rev() {
100                string(i, &format!("paths[{i}]"))?;
101                let p = chars(&with_host(|h| h.str_of(&args[i])));
102                let settles = match flavor {
103                    Flavor::Posix => p.first() == Some(&'/'),
104                    Flavor::Win32 => {
105                        let unc = p.len() > 1 && flavor.is_sep(p[0]) && flavor.is_sep(p[1]);
106                        let drive_abs = p.len() > 2
107                            && is_device_root(p[0])
108                            && p[1] == ':'
109                            && flavor.is_sep(p[2]);
110                        unc || drive_abs
111                    }
112                };
113                if settles {
114                    break;
115                }
116            }
117            Ok(())
118        }
119        "relative" => string(0, "from").and_then(|_| string(1, "to")),
120        "basename" => {
121            if !matches!(arg(1), Value::Undef) {
122                string(1, "suffix")?;
123            }
124            string(0, "path")
125        }
126        "matchesGlob" => string(0, "path").and_then(|_| string(1, "pattern")),
127        "format" => {
128            let v = arg(0);
129            let is_object = with_host(|h| {
130                !h.is_null(&v)
131                    && h.type_of(&v) == "object"
132                    && !matches!(h.get(&v), Some(crate::host::JsObj::Array(_)))
133            });
134            if is_object {
135                Ok(())
136            } else {
137                Err(invalid_arg_type("pathObject", "argument", "object", &v))
138            }
139        }
140        "normalize" | "dirname" | "extname" | "isAbsolute" | "parse" => string(0, "path"),
141        _ => Ok(()),
142    }
143}
144
145pub fn call(flavor: Flavor, method: &str, args: &[Value]) -> Option<Result<Value, String>> {
146    if let Err(e) = validate_args(flavor, method, args) {
147        return Some(Err(e));
148    }
149    if matches!(method, "toNamespacedPath" | "_makeLong") {
150        let v = args.first().cloned().unwrap_or(Value::Undef);
151        if with_host(|h| h.type_of(&v)) != "string" {
152            return Some(Ok(v));
153        }
154    }
155    let parts: Vec<String> = (0..args.len()).map(|i| arg_str(args, i)).collect();
156    let s = |v: String| Ok(with_host(|h| h.new_str(v)));
157    let one = |i: usize| chars(parts.get(i).map(String::as_str).unwrap_or(""));
158    Some(match method {
159        "join" => s(join(flavor, &parts)),
160        "resolve" => s(resolve(flavor, &parts)),
161        "normalize" => s(normalize(flavor, &one(0))),
162        "basename" => s(basename(
163            flavor,
164            &one(0),
165            // An absent 2nd arg is `undefined`, not `""` — Node skips the
166            // suffix-matching branch entirely in that case.
167            parts.get(1).map(|x| chars(x)).as_deref(),
168        )),
169        "dirname" => s(dirname(flavor, &one(0))),
170        "extname" => s(extname(flavor, &one(0))),
171        "isAbsolute" => Ok(Value::Bool(is_absolute(flavor, &one(0)))),
172        "relative" => s(relative(flavor, &one(0), &one(1))),
173        "parse" => Ok(parse(flavor, &one(0))),
174        "format" => s(format(flavor, args.first())),
175        "matchesGlob" => Ok(Value::Bool(matches_glob(
176            flavor,
177            parts.first().map(String::as_str).unwrap_or(""),
178            parts.get(1).map(|x| x.as_str()).unwrap_or(""),
179        ))),
180        "toNamespacedPath" | "_makeLong" => s(to_namespaced_path(
181            flavor,
182            parts.first().map(String::as_str).unwrap_or(""),
183        )),
184        _ => return None,
185    })
186}
187
188fn chars(s: &str) -> Vec<char> {
189    s.chars().collect()
190}
191
192fn str_of(c: &[char]) -> String {
193    c.iter().collect()
194}
195
196/// Node's `isWindowsDeviceRoot`: an ASCII letter usable as a drive letter.
197fn is_device_root(c: char) -> bool {
198    c.is_ascii_alphabetic()
199}
200
201/// Device names Windows reserves regardless of directory (`CON`, `LPT3`, …).
202const WINDOWS_RESERVED_NAMES: &[&str] = &[
203    "CON",
204    "PRN",
205    "AUX",
206    "NUL",
207    "COM1",
208    "COM2",
209    "COM3",
210    "COM4",
211    "COM5",
212    "COM6",
213    "COM7",
214    "COM8",
215    "COM9",
216    "LPT1",
217    "LPT2",
218    "LPT3",
219    "LPT4",
220    "LPT5",
221    "LPT6",
222    "LPT7",
223    "LPT8",
224    "LPT9",
225    "COM\u{b9}",
226    "COM\u{b2}",
227    "COM\u{b3}",
228    "LPT\u{b9}",
229    "LPT\u{b2}",
230    "LPT\u{b3}",
231];
232
233/// Node's `isWindowsReservedName(path, colonIndex)` — is `path.slice(0,
234/// colonIndex)` a reserved device name? `colon_index` is an `indexOf` result and
235/// may be negative, which in JS counts back from the end (`"CON/".slice(0, -1)`
236/// is `"CON"`), so a colon-less `"CON/"` IS reserved. Reproducing that is what
237/// makes `path.win32.normalize("CON/")` return `.\CON\` rather than `CON\`.
238fn is_reserved_name(p: &[char], colon_index: isize) -> bool {
239    let end = if colon_index < 0 {
240        (p.len() as isize + colon_index).max(0) as usize
241    } else {
242        (colon_index as usize).min(p.len())
243    };
244    let device: String = p[..end].iter().collect::<String>().to_uppercase();
245    WINDOWS_RESERVED_NAMES.contains(&device.as_str())
246}
247
248/// `String.prototype.indexOf(ch, from)` over a char slice, `-1` when absent.
249fn index_of(p: &[char], ch: char, from: usize) -> isize {
250    p.iter()
251        .skip(from)
252        .position(|&c| c == ch)
253        .map(|i| (i + from) as isize)
254        .unwrap_or(-1)
255}
256
257/// Port of Node's `normalizeString`: resolve `.`/`..` and collapse repeated
258/// separators, emitting `separator`-joined segments.
259fn normalize_string(
260    path: &[char],
261    allow_above_root: bool,
262    flavor: Flavor,
263    separator: char,
264) -> String {
265    let mut res: Vec<char> = Vec::new();
266    let mut last_segment_length: isize = 0;
267    let mut last_slash: isize = -1;
268    let mut dots: isize = 0;
269    let mut code: char = '\0';
270    let len = path.len() as isize;
271    let mut i: isize = 0;
272    while i <= len {
273        if i < len {
274            code = path[i as usize];
275        } else if flavor.is_sep(code) {
276            break;
277        } else {
278            code = '/';
279        }
280
281        if flavor.is_sep(code) {
282            if last_slash == i - 1 || dots == 1 {
283                // NOOP — an empty segment or a bare `.`.
284            } else if dots == 2 {
285                let rl = res.len() as isize;
286                if rl < 2
287                    || last_segment_length != 2
288                    || res[res.len() - 1] != '.'
289                    || res[res.len() - 2] != '.'
290                {
291                    if rl > 2 {
292                        let last_slash_index = rl - last_segment_length - 1;
293                        if last_slash_index == -1 {
294                            res.clear();
295                            last_segment_length = 0;
296                        } else {
297                            res.truncate(last_slash_index as usize);
298                            let li = res
299                                .iter()
300                                .rposition(|&c| c == separator)
301                                .map(|p| p as isize)
302                                .unwrap_or(-1);
303                            last_segment_length = res.len() as isize - 1 - li;
304                        }
305                        last_slash = i;
306                        dots = 0;
307                        i += 1;
308                        continue;
309                    } else if rl != 0 {
310                        res.clear();
311                        last_segment_length = 0;
312                        last_slash = i;
313                        dots = 0;
314                        i += 1;
315                        continue;
316                    }
317                }
318                if allow_above_root {
319                    if !res.is_empty() {
320                        res.push(separator);
321                    }
322                    res.push('.');
323                    res.push('.');
324                    last_segment_length = 2;
325                }
326            } else {
327                if !res.is_empty() {
328                    res.push(separator);
329                }
330                res.extend_from_slice(&path[(last_slash + 1) as usize..i as usize]);
331                last_segment_length = i - last_slash - 1;
332            }
333            last_slash = i;
334            dots = 0;
335        } else if code == '.' && dots != -1 {
336            dots += 1;
337        } else {
338            dots = -1;
339        }
340        i += 1;
341    }
342    res.into_iter().collect()
343}
344
345fn cwd() -> String {
346    std::env::current_dir()
347        .map(|d| d.to_string_lossy().into_owned())
348        .unwrap_or_else(|_| "/".into())
349}
350
351// ---------------------------------------------------------------------------
352// resolve
353// ---------------------------------------------------------------------------
354
355/// `path.resolve(p)` against the current directory, for callers outside the JS
356/// dispatcher. `process.argv[1]` is the resolved entry script, not the spelling
357/// the user typed (`node ./x.js` reports `/cwd/x.js`), and reusing the ported
358/// resolver keeps that agreeing with what `path.resolve` reports in-language.
359pub(crate) fn resolve_one(p: &str) -> String {
360    resolve_posix(&[p.to_string()])
361}
362
363fn resolve(flavor: Flavor, args: &[String]) -> String {
364    match flavor {
365        Flavor::Posix => resolve_posix(args),
366        Flavor::Win32 => resolve_win32(args),
367    }
368}
369
370fn resolve_posix(args: &[String]) -> String {
371    if args.is_empty() || (args.len() == 1 && (args[0].is_empty() || args[0] == ".")) {
372        let c = cwd();
373        if c.starts_with('/') {
374            return c;
375        }
376    }
377    let mut resolved = String::new();
378    let mut absolute = false;
379    for p in args.iter().rev() {
380        if absolute {
381            break;
382        }
383        if p.is_empty() {
384            continue;
385        }
386        resolved = std::format!("{p}/{resolved}");
387        absolute = p.starts_with('/');
388    }
389    if !absolute {
390        let c = cwd();
391        resolved = std::format!("{c}/{resolved}");
392        absolute = c.starts_with('/');
393    }
394    let out = normalize_string(&chars(&resolved), !absolute, Flavor::Posix, '/');
395    if absolute {
396        std::format!("/{out}")
397    } else if out.is_empty() {
398        ".".into()
399    } else {
400        out
401    }
402}
403
404fn resolve_win32(args: &[String]) -> String {
405    let f = Flavor::Win32;
406    let mut resolved_device = String::new();
407    let mut resolved_tail = String::new();
408    let mut resolved_absolute = false;
409
410    let mut i = args.len() as isize - 1;
411    while i >= -1 {
412        let path: Vec<char> = if i >= 0 {
413            let p = &args[i as usize];
414            if p.is_empty() {
415                i -= 1;
416                continue;
417            }
418            chars(p)
419        } else if resolved_device.is_empty() {
420            let c = cwd();
421            // Fast path for the current directory. On a POSIX host Node
422            // converts the cwd's forward slashes to backslashes here.
423            if args.is_empty()
424                || (args.len() == 1 && (args[0].is_empty() || args[0] == ".") && c.starts_with('/'))
425            {
426                return c.replace('/', "\\");
427            }
428            chars(&c)
429        } else {
430            // Windows keeps a per-drive cwd in a `=C:` env var; off Windows that
431            // never exists, so Node falls back to `process.cwd()` and only
432            // rewrites it to the bare drive root when the cwd names a DIFFERENT
433            // drive (i.e. it has a `\` at index 2). A POSIX cwd has no such `\`,
434            // so it is used verbatim — which is what `path.win32.resolve('C:')`
435            // reports on this host.
436            let c = cwd();
437            let cc = chars(&c);
438            let drive_mismatch = str_of(&cc[..cc.len().min(2)]).to_lowercase()
439                != resolved_device.to_lowercase()
440                && cc.get(2) == Some(&'\\');
441            if drive_mismatch {
442                chars(&std::format!("{resolved_device}\\"))
443            } else {
444                cc
445            }
446        };
447
448        let len = path.len();
449        let mut root_end: usize = 0;
450        let mut device = String::new();
451        let mut is_absolute = false;
452        let code = *path.first().unwrap_or(&'\0');
453
454        if len == 1 {
455            if f.is_sep(code) {
456                root_end = 1;
457                is_absolute = true;
458            }
459        } else if f.is_sep(code) {
460            is_absolute = true;
461            if f.is_sep(path[1]) {
462                let mut j = 2usize;
463                let mut last = j;
464                while j < len && !f.is_sep(path[j]) {
465                    j += 1;
466                }
467                if j < len && j != last {
468                    let first_part = str_of(&path[last..j]);
469                    last = j;
470                    while j < len && f.is_sep(path[j]) {
471                        j += 1;
472                    }
473                    if j < len && j != last {
474                        last = j;
475                        while j < len && !f.is_sep(path[j]) {
476                            j += 1;
477                        }
478                        if j == len || j != last {
479                            if first_part != "." && first_part != "?" {
480                                device =
481                                    std::format!("\\\\{first_part}\\{}", str_of(&path[last..j]));
482                                root_end = j;
483                            } else {
484                                device = std::format!("\\\\{first_part}");
485                                root_end = 4;
486                            }
487                        }
488                    }
489                }
490            } else {
491                root_end = 1;
492            }
493        } else if is_device_root(code) && path[1] == ':' {
494            device = str_of(&path[..2]);
495            root_end = 2;
496            if len > 2 && f.is_sep(path[2]) {
497                is_absolute = true;
498                root_end = 3;
499            }
500        }
501
502        if !device.is_empty() {
503            if !resolved_device.is_empty() {
504                if device.to_lowercase() != resolved_device.to_lowercase() {
505                    i -= 1;
506                    continue;
507                }
508            } else {
509                resolved_device = device;
510            }
511        }
512
513        if resolved_absolute {
514            if !resolved_device.is_empty() {
515                break;
516            }
517        } else {
518            resolved_tail = std::format!("{}\\{resolved_tail}", str_of(&path[root_end.min(len)..]));
519            resolved_absolute = is_absolute;
520            if is_absolute && !resolved_device.is_empty() {
521                break;
522            }
523        }
524        i -= 1;
525    }
526
527    let tail = normalize_string(&chars(&resolved_tail), !resolved_absolute, f, '\\');
528    if resolved_absolute {
529        std::format!("{resolved_device}{}{tail}", '\\')
530    } else {
531        let joined = std::format!("{resolved_device}{tail}");
532        if joined.is_empty() {
533            ".".into()
534        } else {
535            joined
536        }
537    }
538}
539
540// ---------------------------------------------------------------------------
541// normalize
542// ---------------------------------------------------------------------------
543
544fn normalize(flavor: Flavor, path: &[char]) -> String {
545    match flavor {
546        Flavor::Posix => normalize_posix(path),
547        Flavor::Win32 => normalize_win32(path),
548    }
549}
550
551fn normalize_posix(path: &[char]) -> String {
552    if path.is_empty() {
553        return ".".into();
554    }
555    let is_absolute = path[0] == '/';
556    let trailing = path[path.len() - 1] == '/';
557    let out = normalize_string(path, !is_absolute, Flavor::Posix, '/');
558    if out.is_empty() {
559        return if is_absolute {
560            "/".into()
561        } else if trailing {
562            "./".into()
563        } else {
564            ".".into()
565        };
566    }
567    let out = if trailing {
568        std::format!("{out}/")
569    } else {
570        out
571    };
572    if is_absolute {
573        std::format!("/{out}")
574    } else {
575        out
576    }
577}
578
579fn normalize_win32(path: &[char]) -> String {
580    let f = Flavor::Win32;
581    let len = path.len();
582    if len == 0 {
583        return ".".into();
584    }
585    let mut root_end: usize = 0;
586    let mut device: Option<String> = None;
587    let mut is_absolute = false;
588    let code = path[0];
589
590    if len == 1 {
591        return if code == '/' {
592            "\\".into()
593        } else {
594            str_of(path)
595        };
596    }
597    if f.is_sep(code) {
598        is_absolute = true;
599        if f.is_sep(path[1]) {
600            let mut j = 2usize;
601            let mut last = j;
602            while j < len && !f.is_sep(path[j]) {
603                j += 1;
604            }
605            if j < len && j != last {
606                let first_part = str_of(&path[last..j]);
607                last = j;
608                while j < len && f.is_sep(path[j]) {
609                    j += 1;
610                }
611                if j < len && j != last {
612                    last = j;
613                    while j < len && !f.is_sep(path[j]) {
614                        j += 1;
615                    }
616                    if j == len || j != last {
617                        if first_part == "." || first_part == "?" {
618                            device = Some(std::format!("\\\\{first_part}"));
619                            root_end = 4;
620                            let colon_index = index_of(path, ':', 0);
621                            let end = (colon_index + 1).max(0) as usize;
622                            let possible: Vec<char> = if end >= 4 && end <= len {
623                                path[4..end].to_vec()
624                            } else {
625                                Vec::new()
626                            };
627                            if is_reserved_name(&possible, possible.len() as isize - 1) {
628                                device = Some(std::format!("\\\\?\\{}", str_of(&possible)));
629                                root_end = 4 + possible.len();
630                            }
631                        } else if j == len {
632                            return std::format!("\\\\{first_part}\\{}\\", str_of(&path[last..]));
633                        } else {
634                            device =
635                                Some(std::format!("\\\\{first_part}\\{}", str_of(&path[last..j])));
636                            root_end = j;
637                        }
638                    }
639                }
640            }
641        } else {
642            root_end = 1;
643        }
644    } else {
645        let colon_index = index_of(path, ':', 0);
646        if colon_index > 0 {
647            if is_device_root(code) && colon_index == 1 {
648                device = Some(str_of(&path[..2]));
649                root_end = 2;
650                if len > 2 && f.is_sep(path[2]) {
651                    is_absolute = true;
652                    root_end = 3;
653                }
654            } else if is_reserved_name(path, colon_index) {
655                device = Some(str_of(&path[..(colon_index + 1) as usize]));
656                root_end = (colon_index + 1) as usize;
657            }
658        }
659    }
660
661    let mut tail = if root_end < len {
662        normalize_string(&path[root_end..], !is_absolute, f, '\\')
663    } else {
664        String::new()
665    };
666    if tail.is_empty() && !is_absolute {
667        tail = ".".into();
668    }
669    if !tail.is_empty() && f.is_sep(path[len - 1]) {
670        tail.push('\\');
671    }
672    if !is_absolute && device.is_none() && path.contains(&':') {
673        // CVE-2024-36139: a relative path must never normalize into something
674        // Windows would read as drive-absolute.
675        let tc = chars(&tail);
676        if tc.len() >= 2 && is_device_root(tc[0]) && tc[1] == ':' {
677            return std::format!(".\\{tail}");
678        }
679        let mut index = index_of(path, ':', 0);
680        while index != -1 {
681            if index == len as isize - 1 || f.is_sep(path[(index + 1) as usize]) {
682                return std::format!(".\\{tail}");
683            }
684            index = index_of(path, ':', (index + 1) as usize);
685        }
686    }
687    if is_reserved_name(path, index_of(path, ':', 0)) {
688        return std::format!(".\\{}{tail}", device.unwrap_or_default());
689    }
690    match device {
691        None => {
692            if is_absolute {
693                std::format!("\\{tail}")
694            } else {
695                tail
696            }
697        }
698        Some(d) => {
699            if is_absolute {
700                std::format!("{d}\\{tail}")
701            } else {
702                std::format!("{d}{tail}")
703            }
704        }
705    }
706}
707
708// ---------------------------------------------------------------------------
709// isAbsolute / join
710// ---------------------------------------------------------------------------
711
712fn is_absolute(flavor: Flavor, path: &[char]) -> bool {
713    let len = path.len();
714    if len == 0 {
715        return false;
716    }
717    match flavor {
718        Flavor::Posix => path[0] == '/',
719        Flavor::Win32 => {
720            flavor.is_sep(path[0])
721                || (len > 2 && is_device_root(path[0]) && path[1] == ':' && flavor.is_sep(path[2]))
722        }
723    }
724}
725
726fn join(flavor: Flavor, args: &[String]) -> String {
727    let parts: Vec<&String> = args.iter().filter(|a| !a.is_empty()).collect();
728    if parts.is_empty() {
729        return ".".into();
730    }
731    match flavor {
732        Flavor::Posix => {
733            let joined = parts
734                .iter()
735                .map(|s| s.as_str())
736                .collect::<Vec<_>>()
737                .join("/");
738            normalize_posix(&chars(&joined))
739        }
740        Flavor::Win32 => join_win32(&parts),
741    }
742}
743
744fn join_win32(parts: &[&String]) -> String {
745    let f = Flavor::Win32;
746    let first_part = chars(parts[0]);
747    let mut joined: String = parts
748        .iter()
749        .map(|s| s.as_str())
750        .collect::<Vec<_>>()
751        .join("\\");
752
753    // Avoid turning a plain absolute path into a UNC path, but keep an
754    // intentional UNC prefix (`//server`) intact.
755    let mut needs_replace = true;
756    let mut slash_count = 0usize;
757    if f.is_sep(*first_part.first().unwrap_or(&'\0')) {
758        slash_count += 1;
759        let first_len = first_part.len();
760        if first_len > 1 && f.is_sep(first_part[1]) {
761            slash_count += 1;
762            if first_len > 2 {
763                if f.is_sep(first_part[2]) {
764                    slash_count += 1;
765                } else {
766                    needs_replace = false;
767                }
768            }
769        }
770    }
771    if needs_replace {
772        let jc = chars(&joined);
773        while slash_count < jc.len() && f.is_sep(jc[slash_count]) {
774            slash_count += 1;
775        }
776        if slash_count >= 2 {
777            joined = std::format!("\\{}", str_of(&jc[slash_count..]));
778        }
779    }
780
781    // A reserved device name anywhere in the path suppresses normalization.
782    let jc = chars(&joined);
783    let mut segments: Vec<String> = Vec::new();
784    let mut part = String::new();
785    let mut i = 0usize;
786    while i < jc.len() {
787        if jc[i] == '\\' {
788            if !part.is_empty() {
789                segments.push(std::mem::take(&mut part));
790            }
791            part.clear();
792            while i + 1 < jc.len() && jc[i + 1] == '\\' {
793                i += 1;
794            }
795        } else {
796            part.push(jc[i]);
797        }
798        i += 1;
799    }
800    if !part.is_empty() {
801        segments.push(part);
802    }
803    if segments.iter().any(|p| {
804        let pc = chars(p);
805        let ci = index_of(&pc, ':', 0);
806        ci != -1 && is_reserved_name(&pc, ci)
807    }) {
808        return joined.replace('/', "\\");
809    }
810
811    normalize_win32(&chars(&joined))
812}
813
814// ---------------------------------------------------------------------------
815// relative
816// ---------------------------------------------------------------------------
817
818fn relative(flavor: Flavor, from: &[char], to: &[char]) -> String {
819    match flavor {
820        Flavor::Posix => relative_posix(from, to),
821        Flavor::Win32 => relative_win32(from, to),
822    }
823}
824
825fn relative_posix(from_in: &[char], to_in: &[char]) -> String {
826    if from_in == to_in {
827        return String::new();
828    }
829    let from = chars(&resolve_posix(&[str_of(from_in)]));
830    let to = chars(&resolve_posix(&[str_of(to_in)]));
831    if from == to {
832        return String::new();
833    }
834
835    let from_start = 1isize;
836    let from_end = from.len() as isize;
837    let from_len = from_end - from_start;
838    let to_start = 1isize;
839    let to_len = to.len() as isize - to_start;
840
841    let length = from_len.min(to_len);
842    let mut last_common_sep: isize = -1;
843    let mut i = 0isize;
844    while i < length {
845        let fc = from[(from_start + i) as usize];
846        if fc != to[(to_start + i) as usize] {
847            break;
848        } else if fc == '/' {
849            last_common_sep = i;
850        }
851        i += 1;
852    }
853    if i == length {
854        if to_len > length {
855            if to[(to_start + i) as usize] == '/' {
856                return str_of(&to[(to_start + i + 1) as usize..]);
857            }
858            if i == 0 {
859                return str_of(&to[(to_start + i) as usize..]);
860            }
861        } else if from_len > length {
862            if from[(from_start + i) as usize] == '/' {
863                last_common_sep = i;
864            } else if i == 0 {
865                last_common_sep = 0;
866            }
867        }
868    }
869
870    let mut out = String::new();
871    let mut k = from_start + last_common_sep + 1;
872    while k <= from_end {
873        if k == from_end || from[k as usize] == '/' {
874            out.push_str(if out.is_empty() { ".." } else { "/.." });
875        }
876        k += 1;
877    }
878    std::format!(
879        "{out}{}",
880        str_of(&to[(to_start + last_common_sep) as usize..])
881    )
882}
883
884fn relative_win32(from_in: &[char], to_in: &[char]) -> String {
885    if from_in == to_in {
886        return String::new();
887    }
888    let from_orig = resolve_win32(&[str_of(from_in)]);
889    let to_orig = resolve_win32(&[str_of(to_in)]);
890    if from_orig == to_orig {
891        return String::new();
892    }
893    let from_lc = from_orig.to_lowercase();
894    let to_lc = to_orig.to_lowercase();
895    if from_lc == to_lc {
896        return String::new();
897    }
898
899    let from_orig_c = chars(&from_orig);
900    let to_orig_c = chars(&to_orig);
901    let from = chars(&from_lc);
902    let to = chars(&to_lc);
903
904    // A case-fold that changed the length (e.g. `İ`) invalidates index-parallel
905    // scanning, so Node falls back to segment-wise comparison.
906    if from_orig_c.len() != from.len() || to_orig_c.len() != to.len() {
907        let mut from_split: Vec<String> = from_orig.split('\\').map(str::to_string).collect();
908        let mut to_split: Vec<String> = to_orig.split('\\').map(str::to_string).collect();
909        if from_split.last().is_some_and(String::is_empty) {
910            from_split.pop();
911        }
912        if to_split.last().is_some_and(String::is_empty) {
913            to_split.pop();
914        }
915        let from_len = from_split.len();
916        let to_len = to_split.len();
917        let length = from_len.min(to_len);
918        let mut i = 0usize;
919        while i < length {
920            if from_split[i].to_lowercase() != to_split[i].to_lowercase() {
921                break;
922            }
923            i += 1;
924        }
925        if i == 0 {
926            return to_orig;
927        } else if i == length {
928            if to_len > length {
929                return to_split[i..].join("\\");
930            }
931            if from_len > length {
932                return "..\\".repeat(from_len - 1 - i) + "..";
933            }
934            return String::new();
935        }
936        return "..\\".repeat(from_len - i) + &to_split[i..].join("\\");
937    }
938
939    let mut from_start = 0isize;
940    while (from_start as usize) < from.len() && from[from_start as usize] == '\\' {
941        from_start += 1;
942    }
943    let mut from_end = from.len() as isize;
944    while from_end - 1 > from_start && from[(from_end - 1) as usize] == '\\' {
945        from_end -= 1;
946    }
947    let from_len = from_end - from_start;
948
949    let mut to_start = 0isize;
950    while (to_start as usize) < to.len() && to[to_start as usize] == '\\' {
951        to_start += 1;
952    }
953    let mut to_end = to.len() as isize;
954    while to_end - 1 > to_start && to[(to_end - 1) as usize] == '\\' {
955        to_end -= 1;
956    }
957    let to_len = to_end - to_start;
958
959    let length = from_len.min(to_len);
960    let mut last_common_sep: isize = -1;
961    let mut i = 0isize;
962    while i < length {
963        let fc = from[(from_start + i) as usize];
964        if fc != to[(to_start + i) as usize] {
965            break;
966        } else if fc == '\\' {
967            last_common_sep = i;
968        }
969        i += 1;
970    }
971
972    if i != length {
973        if last_common_sep == -1 {
974            return to_orig;
975        }
976    } else {
977        if to_len > length {
978            if to[(to_start + i) as usize] == '\\' {
979                return str_of(&to_orig_c[(to_start + i + 1) as usize..]);
980            }
981            if i == 2 {
982                return str_of(&to_orig_c[(to_start + i) as usize..]);
983            }
984        }
985        if from_len > length {
986            if from[(from_start + i) as usize] == '\\' {
987                last_common_sep = i;
988            } else if i == 2 {
989                last_common_sep = 3;
990            }
991        }
992        if last_common_sep == -1 {
993            last_common_sep = 0;
994        }
995    }
996
997    let mut out = String::new();
998    let mut k = from_start + last_common_sep + 1;
999    while k <= from_end {
1000        if k == from_end || from[k as usize] == '\\' {
1001            out.push_str(if out.is_empty() { ".." } else { "\\.." });
1002        }
1003        k += 1;
1004    }
1005
1006    to_start += last_common_sep;
1007    if !out.is_empty() {
1008        return std::format!(
1009            "{out}{}",
1010            str_of(&to_orig_c[to_start as usize..to_end as usize])
1011        );
1012    }
1013    if to_orig_c.get(to_start as usize) == Some(&'\\') {
1014        to_start += 1;
1015    }
1016    str_of(&to_orig_c[to_start as usize..to_end as usize])
1017}
1018
1019// ---------------------------------------------------------------------------
1020// toNamespacedPath
1021// ---------------------------------------------------------------------------
1022
1023fn to_namespaced_path(flavor: Flavor, path: &str) -> String {
1024    if flavor == Flavor::Posix || path.is_empty() {
1025        return path.to_string();
1026    }
1027    let resolved = resolve_win32(&[path.to_string()]);
1028    let rc = chars(&resolved);
1029    if rc.len() <= 2 {
1030        return path.to_string();
1031    }
1032    if rc[0] == '\\' {
1033        if rc[1] == '\\' && rc[2] != '?' && rc[2] != '.' {
1034            return std::format!("\\\\?\\UNC\\{}", str_of(&rc[2..]));
1035        }
1036    } else if is_device_root(rc[0]) && rc[1] == ':' && rc[2] == '\\' {
1037        return std::format!("\\\\?\\{resolved}");
1038    }
1039    resolved
1040}
1041
1042// ---------------------------------------------------------------------------
1043// dirname / basename / extname
1044// ---------------------------------------------------------------------------
1045
1046fn dirname(flavor: Flavor, path: &[char]) -> String {
1047    match flavor {
1048        Flavor::Posix => dirname_posix(path),
1049        Flavor::Win32 => dirname_win32(path),
1050    }
1051}
1052
1053fn dirname_posix(path: &[char]) -> String {
1054    if path.is_empty() {
1055        return ".".into();
1056    }
1057    let has_root = path[0] == '/';
1058    let mut end: isize = -1;
1059    let mut matched_slash = true;
1060    let mut i = path.len() as isize - 1;
1061    while i >= 1 {
1062        if path[i as usize] == '/' {
1063            if !matched_slash {
1064                end = i;
1065                break;
1066            }
1067        } else {
1068            matched_slash = false;
1069        }
1070        i -= 1;
1071    }
1072    if end == -1 {
1073        return if has_root { "/".into() } else { ".".into() };
1074    }
1075    if has_root && end == 1 {
1076        return "//".into();
1077    }
1078    str_of(&path[..end as usize])
1079}
1080
1081fn dirname_win32(path: &[char]) -> String {
1082    let f = Flavor::Win32;
1083    let len = path.len();
1084    if len == 0 {
1085        return ".".into();
1086    }
1087    let mut root_end: isize = -1;
1088    let mut offset: usize = 0;
1089    let code = path[0];
1090
1091    if len == 1 {
1092        return if f.is_sep(code) {
1093            str_of(path)
1094        } else {
1095            ".".into()
1096        };
1097    }
1098
1099    if f.is_sep(code) {
1100        root_end = 1;
1101        offset = 1;
1102        if f.is_sep(path[1]) {
1103            let mut j = 2usize;
1104            let mut last = j;
1105            while j < len && !f.is_sep(path[j]) {
1106                j += 1;
1107            }
1108            if j < len && j != last {
1109                last = j;
1110                while j < len && f.is_sep(path[j]) {
1111                    j += 1;
1112                }
1113                if j < len && j != last {
1114                    last = j;
1115                    while j < len && !f.is_sep(path[j]) {
1116                        j += 1;
1117                    }
1118                    if j == len {
1119                        return str_of(path);
1120                    }
1121                    if j != last {
1122                        root_end = j as isize + 1;
1123                        offset = j + 1;
1124                    }
1125                }
1126            }
1127        }
1128    } else if is_device_root(code) && path[1] == ':' {
1129        root_end = if len > 2 && f.is_sep(path[2]) { 3 } else { 2 };
1130        offset = root_end as usize;
1131    }
1132
1133    let mut end: isize = -1;
1134    let mut matched_slash = true;
1135    let mut i = len as isize - 1;
1136    while i >= offset as isize {
1137        if f.is_sep(path[i as usize]) {
1138            if !matched_slash {
1139                end = i;
1140                break;
1141            }
1142        } else {
1143            matched_slash = false;
1144        }
1145        i -= 1;
1146    }
1147
1148    if end == -1 {
1149        if root_end == -1 {
1150            return ".".into();
1151        }
1152        end = root_end;
1153    }
1154    str_of(&path[..end as usize])
1155}
1156
1157fn basename(flavor: Flavor, path: &[char], suffix: Option<&[char]>) -> String {
1158    let mut start: isize = 0;
1159    let mut end: isize = -1;
1160    let mut matched_slash = true;
1161
1162    // A `C:` prefix is a root, not a trailing-separator candidate.
1163    if flavor == Flavor::Win32 && path.len() >= 2 && is_device_root(path[0]) && path[1] == ':' {
1164        start = 2;
1165    }
1166
1167    if let Some(sfx) = suffix.filter(|s| !s.is_empty() && s.len() <= path.len()) {
1168        if sfx == path {
1169            return String::new();
1170        }
1171        let mut ext_idx: isize = sfx.len() as isize - 1;
1172        let mut first_non_slash_end: isize = -1;
1173        let mut i = path.len() as isize - 1;
1174        while i >= start {
1175            let code = path[i as usize];
1176            if flavor.is_sep(code) {
1177                if !matched_slash {
1178                    start = i + 1;
1179                    break;
1180                }
1181            } else {
1182                if first_non_slash_end == -1 {
1183                    matched_slash = false;
1184                    first_non_slash_end = i + 1;
1185                }
1186                if ext_idx >= 0 {
1187                    if code == sfx[ext_idx as usize] {
1188                        ext_idx -= 1;
1189                        if ext_idx == -1 {
1190                            end = i;
1191                        }
1192                    } else {
1193                        ext_idx = -1;
1194                        end = first_non_slash_end;
1195                    }
1196                }
1197            }
1198            i -= 1;
1199        }
1200        if start == end {
1201            end = first_non_slash_end;
1202        } else if end == -1 {
1203            end = path.len() as isize;
1204        }
1205        return str_of(&path[start.max(0) as usize..end.max(0) as usize]);
1206    }
1207
1208    let mut i = path.len() as isize - 1;
1209    while i >= start {
1210        if flavor.is_sep(path[i as usize]) {
1211            if !matched_slash {
1212                start = i + 1;
1213                break;
1214            }
1215        } else if end == -1 {
1216            matched_slash = false;
1217            end = i + 1;
1218        }
1219        i -= 1;
1220    }
1221    if end == -1 {
1222        return String::new();
1223    }
1224    str_of(&path[start as usize..end as usize])
1225}
1226
1227fn extname(flavor: Flavor, path: &[char]) -> String {
1228    let mut start: isize = 0;
1229    let mut start_dot: isize = -1;
1230    let mut start_part: isize = 0;
1231    let mut end: isize = -1;
1232    let mut matched_slash = true;
1233    let mut pre_dot_state: isize = 0;
1234
1235    if flavor == Flavor::Win32 && path.len() >= 2 && path[1] == ':' && is_device_root(path[0]) {
1236        start = 2;
1237        start_part = 2;
1238    }
1239
1240    let mut i = path.len() as isize - 1;
1241    while i >= start {
1242        let code = path[i as usize];
1243        if flavor.is_sep(code) {
1244            if !matched_slash {
1245                start_part = i + 1;
1246                break;
1247            }
1248            i -= 1;
1249            continue;
1250        }
1251        if end == -1 {
1252            matched_slash = false;
1253            end = i + 1;
1254        }
1255        if code == '.' {
1256            if start_dot == -1 {
1257                start_dot = i;
1258            } else if pre_dot_state != 1 {
1259                pre_dot_state = 1;
1260            }
1261        } else if start_dot != -1 {
1262            pre_dot_state = -1;
1263        }
1264        i -= 1;
1265    }
1266
1267    if start_dot == -1
1268        || end == -1
1269        || pre_dot_state == 0
1270        || (pre_dot_state == 1 && start_dot == end - 1 && start_dot == start_part + 1)
1271    {
1272        return String::new();
1273    }
1274    str_of(&path[start_dot as usize..end as usize])
1275}
1276
1277// ---------------------------------------------------------------------------
1278// parse / format
1279// ---------------------------------------------------------------------------
1280
1281fn new_parsed(root: &str, dir: &str, base: &str, ext: &str, name: &str) -> Value {
1282    with_host(|h| {
1283        let mut m = IndexMap::new();
1284        m.insert("root".into(), h.new_str(root));
1285        m.insert("dir".into(), h.new_str(dir));
1286        m.insert("base".into(), h.new_str(base));
1287        m.insert("ext".into(), h.new_str(ext));
1288        m.insert("name".into(), h.new_str(name));
1289        h.new_object(m)
1290    })
1291}
1292
1293fn parse(flavor: Flavor, path: &[char]) -> Value {
1294    let (root, dir, base, ext, name) = match flavor {
1295        Flavor::Posix => parse_posix(path),
1296        Flavor::Win32 => parse_win32(path),
1297    };
1298    new_parsed(&root, &dir, &base, &ext, &name)
1299}
1300
1301type Parsed = (String, String, String, String, String);
1302
1303fn parse_posix(path: &[char]) -> Parsed {
1304    let mut root = String::new();
1305    let mut dir = String::new();
1306    let mut base = String::new();
1307    let mut ext = String::new();
1308    let mut name = String::new();
1309    if path.is_empty() {
1310        return (root, dir, base, ext, name);
1311    }
1312    let is_abs = path[0] == '/';
1313    let scan_start: isize = if is_abs {
1314        root = "/".into();
1315        1
1316    } else {
1317        0
1318    };
1319
1320    let mut start_dot: isize = -1;
1321    let mut start_part: isize = 0;
1322    let mut end: isize = -1;
1323    let mut matched_slash = true;
1324    let mut pre_dot_state: isize = 0;
1325    let mut i = path.len() as isize - 1;
1326    while i >= scan_start {
1327        let code = path[i as usize];
1328        if code == '/' {
1329            if !matched_slash {
1330                start_part = i + 1;
1331                break;
1332            }
1333            i -= 1;
1334            continue;
1335        }
1336        if end == -1 {
1337            matched_slash = false;
1338            end = i + 1;
1339        }
1340        if code == '.' {
1341            if start_dot == -1 {
1342                start_dot = i;
1343            } else if pre_dot_state != 1 {
1344                pre_dot_state = 1;
1345            }
1346        } else if start_dot != -1 {
1347            pre_dot_state = -1;
1348        }
1349        i -= 1;
1350    }
1351
1352    if end != -1 {
1353        let s = if start_part == 0 && is_abs {
1354            1
1355        } else {
1356            start_part
1357        };
1358        if start_dot == -1
1359            || pre_dot_state == 0
1360            || (pre_dot_state == 1 && start_dot == end - 1 && start_dot == start_part + 1)
1361        {
1362            base = str_of(&path[s as usize..end as usize]);
1363            name = base.clone();
1364        } else {
1365            name = str_of(&path[s as usize..start_dot as usize]);
1366            base = str_of(&path[s as usize..end as usize]);
1367            ext = str_of(&path[start_dot as usize..end as usize]);
1368        }
1369    }
1370
1371    if start_part > 0 {
1372        dir = str_of(&path[..(start_part - 1) as usize]);
1373    } else if is_abs {
1374        dir = "/".into();
1375    }
1376    (root, dir, base, ext, name)
1377}
1378
1379fn parse_win32(path: &[char]) -> Parsed {
1380    let f = Flavor::Win32;
1381    let mut root = String::new();
1382    let mut dir = String::new();
1383    let mut base = String::new();
1384    let mut ext = String::new();
1385    let mut name = String::new();
1386    let len = path.len();
1387    if len == 0 {
1388        return (root, dir, base, ext, name);
1389    }
1390
1391    let mut root_end: usize = 0;
1392    let code = path[0];
1393
1394    if len == 1 {
1395        if f.is_sep(code) {
1396            root = str_of(path);
1397            dir = root.clone();
1398            return (root, dir, base, ext, name);
1399        }
1400        base = str_of(path);
1401        name = base.clone();
1402        return (root, dir, base, ext, name);
1403    }
1404
1405    if f.is_sep(code) {
1406        root_end = 1;
1407        if f.is_sep(path[1]) {
1408            let mut j = 2usize;
1409            let mut last = j;
1410            while j < len && !f.is_sep(path[j]) {
1411                j += 1;
1412            }
1413            if j < len && j != last {
1414                last = j;
1415                while j < len && f.is_sep(path[j]) {
1416                    j += 1;
1417                }
1418                if j < len && j != last {
1419                    last = j;
1420                    while j < len && !f.is_sep(path[j]) {
1421                        j += 1;
1422                    }
1423                    if j == len {
1424                        root_end = j;
1425                    } else if j != last {
1426                        root_end = j + 1;
1427                    }
1428                }
1429            }
1430        }
1431    } else if is_device_root(code) && path[1] == ':' {
1432        if len <= 2 {
1433            root = str_of(path);
1434            dir = root.clone();
1435            return (root, dir, base, ext, name);
1436        }
1437        root_end = 2;
1438        if f.is_sep(path[2]) {
1439            if len == 3 {
1440                root = str_of(path);
1441                dir = root.clone();
1442                return (root, dir, base, ext, name);
1443            }
1444            root_end = 3;
1445        }
1446    }
1447    if root_end > 0 {
1448        root = str_of(&path[..root_end]);
1449    }
1450
1451    let mut start_dot: isize = -1;
1452    let mut start_part: isize = root_end as isize;
1453    let mut end: isize = -1;
1454    let mut matched_slash = true;
1455    let mut pre_dot_state: isize = 0;
1456    let mut i = len as isize - 1;
1457    while i >= root_end as isize {
1458        let c = path[i as usize];
1459        if f.is_sep(c) {
1460            if !matched_slash {
1461                start_part = i + 1;
1462                break;
1463            }
1464            i -= 1;
1465            continue;
1466        }
1467        if end == -1 {
1468            matched_slash = false;
1469            end = i + 1;
1470        }
1471        if c == '.' {
1472            if start_dot == -1 {
1473                start_dot = i;
1474            } else if pre_dot_state != 1 {
1475                pre_dot_state = 1;
1476            }
1477        } else if start_dot != -1 {
1478            pre_dot_state = -1;
1479        }
1480        i -= 1;
1481    }
1482
1483    if end != -1 {
1484        if start_dot == -1
1485            || pre_dot_state == 0
1486            || (pre_dot_state == 1 && start_dot == end - 1 && start_dot == start_part + 1)
1487        {
1488            base = str_of(&path[start_part as usize..end as usize]);
1489            name = base.clone();
1490        } else {
1491            name = str_of(&path[start_part as usize..start_dot as usize]);
1492            base = str_of(&path[start_part as usize..end as usize]);
1493            ext = str_of(&path[start_dot as usize..end as usize]);
1494        }
1495    }
1496
1497    if start_part > 0 && start_part != root_end as isize {
1498        dir = str_of(&path[..(start_part - 1) as usize]);
1499    } else {
1500        dir = root.clone();
1501    }
1502    (root, dir, base, ext, name)
1503}
1504
1505/// Port of Node's `_format(sep, pathObject)`.
1506fn format(flavor: Flavor, obj: Option<&Value>) -> String {
1507    let Some(obj) = obj else { return String::new() };
1508    let get = |k: &str| {
1509        with_host(|h| match h.get(obj) {
1510            Some(crate::host::JsObj::Object(p)) => match p.get(k) {
1511                Some(Value::Undef) | None => String::new(),
1512                Some(v) => h.str_of(v),
1513            },
1514            _ => String::new(),
1515        })
1516    };
1517    let root = get("root");
1518    let dir_raw = get("dir");
1519    let base_raw = get("base");
1520    let base = if !base_raw.is_empty() {
1521        base_raw
1522    } else {
1523        let ext = get("ext");
1524        let ext = if ext.is_empty() {
1525            String::new()
1526        } else if ext.starts_with('.') {
1527            ext
1528        } else {
1529            std::format!(".{ext}")
1530        };
1531        std::format!("{}{ext}", get("name"))
1532    };
1533    let dir = if !dir_raw.is_empty() {
1534        dir_raw
1535    } else {
1536        root.clone()
1537    };
1538    if dir.is_empty() {
1539        return base;
1540    }
1541    if dir == root {
1542        std::format!("{dir}{base}")
1543    } else {
1544        std::format!("{dir}{}{base}", flavor.sep())
1545    }
1546}
1547
1548/// `path.matchesGlob(path, pattern)` — whether `path` matches the glob `pattern`.
1549/// Supports `*` (within a segment), `**` (across `/`), `?`, `[...]` classes, and
1550/// top-level `{a,b}` brace alternatives — the minimatch-style subset Node uses.
1551/// Under the win32 flavor both slashes are separators, matching Node's
1552/// `matchGlobPattern(path, pattern, /* windows */ true)`.
1553fn matches_glob(flavor: Flavor, path: &str, pattern: &str) -> bool {
1554    let (path, pattern) = match flavor {
1555        Flavor::Posix => (path.to_string(), pattern.to_string()),
1556        Flavor::Win32 => (path.replace('\\', "/"), pattern.replace('\\', "/")),
1557    };
1558    let text: Vec<char> = path.chars().collect();
1559    expand_braces(&pattern)
1560        .iter()
1561        .any(|pat| glob_match(&text, &pat.chars().collect::<Vec<char>>()))
1562}
1563
1564/// Expand top-level `{a,b,c}` alternatives into concrete pattern strings.
1565fn expand_braces(pattern: &str) -> Vec<String> {
1566    let chars: Vec<char> = pattern.chars().collect();
1567    for (i, &c) in chars.iter().enumerate() {
1568        if c != '{' {
1569            continue;
1570        }
1571        let mut depth = 1;
1572        let mut commas: Vec<usize> = Vec::new();
1573        let mut close = None;
1574        for (j, &cj) in chars.iter().enumerate().skip(i + 1) {
1575            match cj {
1576                '{' => depth += 1,
1577                '}' => {
1578                    depth -= 1;
1579                    if depth == 0 {
1580                        close = Some(j);
1581                        break;
1582                    }
1583                }
1584                ',' if depth == 1 => commas.push(j),
1585                _ => {}
1586            }
1587        }
1588        let (Some(close), false) = (close, commas.is_empty()) else {
1589            continue;
1590        };
1591        let prefix: String = chars[..i].iter().collect();
1592        let suffix: String = chars[close + 1..].iter().collect();
1593        let mut bounds = vec![i];
1594        bounds.extend(&commas);
1595        bounds.push(close);
1596        let mut out = Vec::new();
1597        for w in bounds.windows(2) {
1598            let alt: String = chars[w[0] + 1..w[1]].iter().collect();
1599            out.extend(expand_braces(&std::format!("{prefix}{alt}{suffix}")));
1600        }
1601        return out;
1602    }
1603    vec![pattern.to_string()]
1604}
1605
1606/// Recursive glob matcher over char slices. `*` never crosses `/`, `**` does.
1607fn glob_match(t: &[char], p: &[char]) -> bool {
1608    if p.is_empty() {
1609        return t.is_empty();
1610    }
1611    match p[0] {
1612        '*' => {
1613            let double = p.len() >= 2 && p[1] == '*';
1614            let rest = {
1615                let mut k = 0;
1616                while k < p.len() && p[k] == '*' {
1617                    k += 1;
1618                }
1619                &p[k..]
1620            };
1621            if rest.is_empty() {
1622                return double || !t.contains(&'/');
1623            }
1624            let mut ti = 0;
1625            loop {
1626                if glob_match(&t[ti..], rest) {
1627                    return true;
1628                }
1629                if ti >= t.len() {
1630                    return false;
1631                }
1632                if !double && t[ti] == '/' {
1633                    return false;
1634                }
1635                ti += 1;
1636            }
1637        }
1638        '?' => !t.is_empty() && t[0] != '/' && glob_match(&t[1..], &p[1..]),
1639        '[' => match match_class(t.first().copied(), p) {
1640            Some((matched, plen)) => matched && glob_match(&t[1..], &p[plen..]),
1641            // Unterminated `[` is a literal bracket.
1642            None => !t.is_empty() && t[0] == '[' && glob_match(&t[1..], &p[1..]),
1643        },
1644        c => !t.is_empty() && t[0] == c && glob_match(&t[1..], &p[1..]),
1645    }
1646}
1647
1648/// Match `ch` against a `[...]` class starting at `p[0] == '['`. Returns
1649/// `(matched, chars_consumed)`, or `None` when the class is unterminated.
1650fn match_class(ch: Option<char>, p: &[char]) -> Option<(bool, usize)> {
1651    let mut i = 1;
1652    let mut negate = false;
1653    if matches!(p.get(i), Some('!') | Some('^')) {
1654        negate = true;
1655        i += 1;
1656    }
1657    let start = i;
1658    let mut matched = false;
1659    while i < p.len() && (p[i] != ']' || i == start) {
1660        if i + 2 < p.len() && p[i + 1] == '-' && p[i + 2] != ']' {
1661            if let Some(c) = ch {
1662                if p[i] <= c && c <= p[i + 2] {
1663                    matched = true;
1664                }
1665            }
1666            i += 3;
1667        } else {
1668            if ch == Some(p[i]) {
1669                matched = true;
1670            }
1671            i += 1;
1672        }
1673    }
1674    if i >= p.len() {
1675        return None;
1676    }
1677    // `ch` is None (empty text) or a `/` never matches a class.
1678    let ok = matches!(ch, Some(c) if c != '/') && (matched ^ negate);
1679    Some((ok, i + 1))
1680}