diff --git a/src/glob/GlobWalker.rs b/src/glob/GlobWalker.rs index 86db88343764..9f6b5291c031 100644 --- a/src/glob/GlobWalker.rs +++ b/src/glob/GlobWalker.rs @@ -1863,7 +1863,9 @@ impl GlobWalker { !filepath.is_empty() && filepath[0] == b'.' } - const SYNTAX_TOKENS: &'static [u8] = b"*[{?!"; + // `\` is an escape on POSIX; on Windows it is a path separator and + // only reaches a component slice as a trailing sep, so skip it there. + const SYNTAX_TOKENS: &'static [u8] = if IS_WINDOWS { b"*[{?!" } else { b"*[{?!\\" }; fn check_special_syntax(pattern: &[u8]) -> bool { strings::index_of_any(pattern, Self::SYNTAX_TOKENS).is_some() @@ -1943,6 +1945,7 @@ impl GlobWalker { // because that only applies negation if at the // beginning of the string. b'[' | b'{' | b'?' | b'*' => break 'out_of_check_wildcard_filepath, + b'\\' if !IS_WINDOWS => break 'out_of_check_wildcard_filepath, _ => {} } } diff --git a/test/js/bun/glob/scan.test.ts b/test/js/bun/glob/scan.test.ts index c4fa1714f8b8..a4fe928402bb 100644 --- a/test/js/bun/glob/scan.test.ts +++ b/test/js/bun/glob/scan.test.ts @@ -23,7 +23,7 @@ import { Glob, GlobScanOptions } from "bun"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import fg from "fast-glob"; -import { bunEnv, bunExe, tempDir, tempDirWithFiles, tmpdirSync } from "harness"; +import { bunEnv, bunExe, isWindows, tempDir, tempDirWithFiles, tmpdirSync } from "harness"; import * as fs from "node:fs"; import * as path from "path"; import { createTempDirectoryWithBrokenSymlinks, prepareEntries, tempFixturesDir } from "./util"; @@ -618,6 +618,51 @@ describe("literal fast path", async () => { }); }); +// On Windows `\` is a path separator, not an escape character. +describe.skipIf(isWindows)("backslash escapes non-special characters", () => { + let cwd = ""; + beforeAll(() => { + cwd = tempDirWithFiles("glob-scan-backslash-escape", { + "sp ace.txt": "x", + "d r": { "f.txt": "x" }, + "foo.t s": "x", + }); + }); + afterAll(() => { + fs.rmSync(cwd, { recursive: true, force: true }); + }); + + // `\x` quotes `x` whether or not `x` is special, so scan must agree with match. + test.each([ + ["literal component", "sp\\ ace.txt", ["sp ace.txt"]], + ["*.ext component", "*.t\\ s", ["foo.t s"]], + ["directory component", "d\\ r/*.txt", ["d r/f.txt"]], + ["under **", "**/sp\\ ace.txt", ["sp ace.txt"]], + ["escaped backslash is literal", "sp\\\\ ace.txt", []], + ])("%s", async (_, pattern, expected) => { + const glob = new Glob(pattern); + const sync = Array.from(glob.scanSync({ cwd })).sort(); + const async = (await Array.fromAsync(glob.scan({ cwd }))).sort(); + expect({ sync, async }).toEqual({ sync: expected, async: expected }); + for (const name of expected) { + expect(glob.match(name)).toBe(true); + } + }); + + // `\\` is one literal `\`, so the on-disk name without a backslash must not match. + test("escaped backslash requires a literal backslash in the name", () => { + const glob = new Glob("sp\\\\ ace.txt"); + expect(glob.match("sp ace.txt")).toBe(false); + expect(glob.match("sp\\ ace.txt")).toBe(true); + }); + + test("absolute path", async () => { + const glob = new Glob(path.posix.join(cwd, "sp\\ ace.txt")); + const entries = await Array.fromAsync(glob.scan()); + expect(entries).toEqual([path.join(cwd, "sp ace.txt")]); + }); +}); + describe("trailing directory separator", async () => { test("matches directories absolute", async () => { const tmpdir = tmpdirSync();