From c9bc5c29440c37218d0087987e06d3fc492079dc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:41:33 +0000 Subject: [PATCH 1/2] glob: recognize a trailing-slash globstar as a globstar in scan() make_component() classified the syntax hint before detecting the trailing separator, so the pattern-final component "**/" (len 3) missed the len == 2 => SyntaxHint::Double arm and was left as a generic one-segment pattern. The walker only recurses on SyntaxHint::Double, so scan("**/") and scan("prefix/**/") stopped at a single directory level while match() on the same Glob object (which consults pattern_slice(), already stripping the separator) accepted arbitrarily deep paths. Detect trailing_sep first and classify on the same slice pattern_slice() returns. "**/" now recurses like "**" while still filtering out files via the existing trailing_sep check in match_pattern_file(). --- src/glob/GlobWalker.rs | 29 ++++++++++++++++------------ test/js/bun/glob/scan.test.ts | 36 +++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/src/glob/GlobWalker.rs b/src/glob/GlobWalker.rs index ae40d3fcb5ea..90d4e4035073 100644 --- a/src/glob/GlobWalker.rs +++ b/src/glob/GlobWalker.rs @@ -2081,9 +2081,24 @@ impl GlobWalker { return None; } + // The final component of a pattern may carry its trailing `/` inside + // `len`. Classify the syntax hint on the same slice `pattern_slice()` + // returns (i.e. without that separator) so `**/` is recognized as a + // globstar and recurses like `**` during scan(). + let last_idx = (component.start + component.len - 1) as usize; + if pattern[last_idx] == b'/' { + component.trailing_sep = true; + } else { + #[cfg(windows)] + { + component.trailing_sep = pattern[last_idx] == b'\\'; + } + } + let effective_len = component.len - u32::from(component.trailing_sep); + 'out: { let comp_slice = - &pattern[component.start as usize..(component.start + component.len) as usize]; + &pattern[component.start as usize..(component.start + effective_len) as usize]; if comp_slice == b"." { component.syntax_hint = SyntaxHint::Dot; break 'out; @@ -2098,7 +2113,7 @@ impl GlobWalker { break 'out; } - match component.len { + match effective_len { 1 => { if pattern[component.start as usize] == b'*' { component.syntax_hint = SyntaxHint::Single; @@ -2147,16 +2162,6 @@ impl GlobWalker { } } - let last_idx = (component.start + component.len).saturating_sub(1) as usize; - if pattern[last_idx] == b'/' { - component.trailing_sep = true; - } else { - #[cfg(windows)] - { - component.trailing_sep = pattern[last_idx] == b'\\'; - } - } - Some(component) } diff --git a/test/js/bun/glob/scan.test.ts b/test/js/bun/glob/scan.test.ts index 541049c42c1c..8e6617277f68 100644 --- a/test/js/bun/glob/scan.test.ts +++ b/test/js/bun/glob/scan.test.ts @@ -637,6 +637,42 @@ describe("trailing directory separator", async () => { const entries = await Array.fromAsync(glob.scan({ onlyFiles: false, cwd: tmpdir })); expect(entries.sort()).toEqual(files.slice(2, 3).sort()); }); + + describe("globstar recurses into nested directories", () => { + const files = { + "d1/d2/d3/deep.txt": "x", + "d1/f.txt": "x", + "e1/g.txt": "x", + }; + const j = (...p: string[]) => p.join(path.sep); + const scan = (dir: string, p: string) => [...new Glob(p).scanSync({ cwd: dir, onlyFiles: false })].sort(); + + test("**/ yields every directory at every depth and no files", () => { + using dir = tempDir("glob-trailing-globstar", files); + expect(scan(String(dir), "**/")).toEqual(["d1", j("d1", "d2"), j("d1", "d2", "d3"), "e1"]); + }); + + test("prefix/**/ yields every directory under prefix", () => { + using dir = tempDir("glob-trailing-globstar-prefix", files); + expect(scan(String(dir), "d1/**/")).toEqual([j("d1", "d2"), j("d1", "d2", "d3")]); + }); + + test("**/ and match() agree on nested directory paths", () => { + using dir = tempDir("glob-trailing-globstar-match", files); + const g = new Glob("d1/**/"); + const entries = scan(String(dir), "d1/**/"); + expect(entries).toEqual([j("d1", "d2"), j("d1", "d2", "d3")]); + for (const e of entries) { + expect(g.match(e.replaceAll(path.sep, "/") + "/")).toBe(true); + } + }); + + test("async scan agrees with sync", async () => { + using dir = tempDir("glob-trailing-globstar-async", files); + const entries = (await Array.fromAsync(new Glob("**/").scan({ cwd: String(dir), onlyFiles: false }))).sort(); + expect(entries).toEqual(["d1", j("d1", "d2"), j("d1", "d2", "d3"), "e1"]); + }); + }); }); describe("absolute path pattern", async () => { From ceadce469d7477169a6ed0ef782443e71924341c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 15 Jul 2026 03:09:38 +0000 Subject: [PATCH 2/2] glob: honour trailing_sep on adjacent file-matching paths Widen the trailing-separator fix to the two sibling sites that still admitted files: - match_pattern_file's `**/X` peek-ahead checked pattern.trailing_sep (the globstar's) but not next_pattern.trailing_sep, so "**/foo/" and "**/*/" yielded regular files. - The Literal-tail statat() fast path accepted S::ISREG without consulting trailing_sep, so "foo/" and "sub/foo/" yielded a file. --- src/glob/GlobWalker.rs | 17 +++++++++----- test/js/bun/glob/scan.test.ts | 42 +++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/glob/GlobWalker.rs b/src/glob/GlobWalker.rs index 90d4e4035073..85d8679d5dd7 100644 --- a/src/glob/GlobWalker.rs +++ b/src/glob/GlobWalker.rs @@ -697,9 +697,14 @@ impl<'a, A: Accessor, const SENTINEL: bool> Iterator<'a, A, SENTINEL> { }; self.close_disallowing_cwd(fd); let mode = stat_result.st_mode as u32; - let matches = (S::ISDIR(mode) && !self.walker.only_files) - || S::ISREG(mode) - || !self.walker.only_files; + let trailing_sep = self.walker.pattern_components[idx as usize].trailing_sep; + let matches = if trailing_sep { + S::ISDIR(mode) && !self.walker.only_files + } else { + (S::ISDIR(mode) && !self.walker.only_files) + || S::ISREG(mode) + || !self.walker.only_files + }; if matches { if let Some(path) = self .walker @@ -1761,10 +1766,12 @@ impl GlobWalker { // Handle case b) if !is_last { + let next = next_pattern.unwrap(); return pattern.syntax_hint == SyntaxHint::Double && (component_idx + 1) as usize == self.pattern_components.len().saturating_sub(1) - && next_pattern.unwrap().syntax_hint != SyntaxHint::Double - && self.match_pattern_impl(next_pattern.unwrap(), entry_name); + && next.syntax_hint != SyntaxHint::Double + && !next.trailing_sep + && self.match_pattern_impl(next, entry_name); } // Handle case a) diff --git a/test/js/bun/glob/scan.test.ts b/test/js/bun/glob/scan.test.ts index 8e6617277f68..2d924fd434e1 100644 --- a/test/js/bun/glob/scan.test.ts +++ b/test/js/bun/glob/scan.test.ts @@ -673,6 +673,48 @@ describe("trailing directory separator", async () => { expect(entries).toEqual(["d1", j("d1", "d2"), j("d1", "d2", "d3"), "e1"]); }); }); + + describe("excludes files when the final component carries a trailing separator", () => { + const files = { + "foo": "x", + "file.txt": "x", + "sub/foo": "x", + "bar/placeholder": "x", + }; + const j = (...p: string[]) => p.join(path.sep); + const scan = (dir: string, p: string) => [...new Glob(p).scanSync({ cwd: dir, onlyFiles: false })].sort(); + + test("**/foo/ does not yield a file named foo", () => { + using dir = tempDir("glob-trailing-sep-peek", files); + expect(scan(String(dir), "**/foo/")).toEqual([]); + }); + + test("**/*/ yields only directories", () => { + using dir = tempDir("glob-trailing-sep-star", files); + expect(scan(String(dir), "**/*/")).toEqual(["bar", "sub"]); + }); + + test("literal/ does not yield a file via the statat fast path", () => { + using dir = tempDir("glob-trailing-sep-literal", files); + expect(scan(String(dir), "foo/")).toEqual([]); + expect(scan(String(dir), "bar/")).toEqual(["bar"]); + }); + + test("prefix/literal/ does not yield a file", () => { + using dir = tempDir("glob-trailing-sep-prefix-literal", files); + expect(scan(String(dir), "sub/foo/")).toEqual([]); + expect(scan(String(dir), "*/foo/")).toEqual([]); + }); + + test("**/foo/ yields a directory named foo", () => { + using dir = tempDir("glob-trailing-sep-dir-named-foo", { + "foo/placeholder": "x", + "sub/foo/placeholder": "x", + "sub/bar": "x", + }); + expect(scan(String(dir), "**/foo/")).toEqual(["foo", j("sub", "foo")]); + }); + }); }); describe("absolute path pattern", async () => {