From 7b303bb1e0ae559ca14bdff62a4ffb3e6a40f651 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:40:02 +0000 Subject: [PATCH 1/8] glob: expand brace groups that span path separators before scanning Bun.Glob.scan() returned an empty result for brace patterns where an alternative contains a path separator (e.g. `svc/{src/env.ts,env.ts}`), even though match() handled the same pattern correctly. The scanner models a pattern as one component per directory level and split the pattern on every separator, including separators inside a brace group. `svc/{src/env.ts,env.ts}` was therefore cut into the bogus components `svc`, `{src` and `env.ts,env.ts}` and matched nothing. A brace alternative containing a separator spans multiple levels, which the one-component-per-level model cannot represent. Expand such patterns into separate brace-free patterns (the same set of strings match() evaluates the braces against) and walk each in turn, rebuilding the components per expansion and deduping results through the existing matched_paths set. Expansion honors backslash escapes, `[...]` bracket classes and nested braces, and is bounded so adversarial patterns cannot blow up. Patterns with no separator inside braces keep the existing single-pass behavior. The expansion drives the shared walker Iterator, so scan, scanSync and the other scan entry points (shell globbing, workspaces, --filter) all benefit. --- src/glob/GlobWalker.rs | 376 ++++++++++++++++++++++++++++++++-- test/js/bun/glob/scan.test.ts | 50 +++++ 2 files changed, 413 insertions(+), 13 deletions(-) diff --git a/src/glob/GlobWalker.rs b/src/glob/GlobWalker.rs index 86db88343764..d7c66636cc5b 100644 --- a/src/glob/GlobWalker.rs +++ b/src/glob/GlobWalker.rs @@ -246,6 +246,18 @@ pub struct GlobWalker { pub basename_excluding_special_syntax_component_idx: u32, pub pattern_components: Vec, + + /// Brace-free patterns produced by expanding brace groups that contain a + /// path separator (e.g. `svc/{src/env.ts,env.ts}` -> `svc/src/env.ts`, + /// `svc/env.ts`). Empty when the pattern needs no expansion, in which case + /// `pattern` / `pattern_components` describe the single pattern directly. + /// When non-empty, the walker walks each entry in turn (see `walk` and + /// `Iterator::try_advance_to_next_expansion`), rebuilding + /// `pattern_components` per entry and deduping results via `matched_paths`. + pub brace_expansions: Vec>, + /// Index into `brace_expansions` of the pattern currently being walked. + expansion_cursor: usize, + pub matched_paths: MatchedMap, pub i: u32, @@ -384,6 +396,12 @@ impl<'a, A: Accessor, const SENTINEL: bool> Iterator<'a, A, SENTINEL> { } pub fn init(&mut self) -> Result, Error> { + // For brace-expanded patterns, build the components for the expansion + // currently selected by `expansion_cursor`. This runs on the first call + // (cursor 0) and on every `try_advance_to_next_expansion` re-init. + if !self.walker.brace_expansions.is_empty() { + self.walker.rebuild_components_for_current_expansion()?; + } log!( "Iterator init pattern={}", bstr::BStr::new(&self.walker.pattern) @@ -564,6 +582,29 @@ impl<'a, A: Accessor, const SENTINEL: bool> Iterator<'a, A, SENTINEL> { } } + /// After a pattern's walk is exhausted, move to the next brace expansion + /// (if any), rebuild its components, and re-run `init` for it. Returns + /// `Ok(true)` when a further expansion was set up, `Ok(false)` when there + /// are no more. Results are deduped against earlier expansions via + /// `matched_paths`. + fn try_advance_to_next_expansion(&mut self) -> Result, Error> { + let next = self.walker.expansion_cursor + 1; + if next >= self.walker.brace_expansions.len() { + return Ok(Ok(false)); + } + self.walker.expansion_cursor = next; + + // Tear down the current root fd before `init` opens a fresh one. + self.close_cwd_fd(); + self.cwd_fd = A::Handle::EMPTY; + self.iter_state = IterState::GetNext; + + match self.init()? { + Err(err) => Ok(Err(err)), + Ok(()) => Ok(Ok(true)), + } + } + fn transition_to_dir_iter_state( &mut self, work_item: WorkItem, @@ -810,9 +851,15 @@ impl<'a, A: Accessor, const SENTINEL: bool> Iterator<'a, A, SENTINEL> { return Ok(Ok(Some(path))); } IterState::GetNext => { - // Done + // Done with the current pattern. If this pattern came from a + // brace expansion, advance to the next expansion and keep + // going; only stop once every expansion is exhausted. if self.walker.workbuf.is_empty() { - return Ok(Ok(None)); + match self.try_advance_to_next_expansion()? { + Err(err) => return Ok(Err(err)), + Ok(true) => continue 'outer, + Ok(false) => return Ok(Ok(None)), + } } let work_item = self.walker.workbuf.pop().unwrap(); match work_item.kind { @@ -1386,6 +1433,8 @@ impl GlobWalker { end_byte_of_basename_excluding_special_syntax: 0, has_relative_components: false, pattern_components: Vec::new(), + brace_expansions: Vec::new(), + expansion_cursor: 0, matched_paths: MatchedMap::default(), i: 0, path_buf: Box::new(PathBuffer::uninit()), @@ -1394,18 +1443,35 @@ impl GlobWalker { _accessor: core::marker::PhantomData, }; - Self::build_pattern_components( - &mut this.pattern_components, - &this.pattern, - &mut this.has_relative_components, - &mut this.end_byte_of_basename_excluding_special_syntax, - &mut this.basename_excluding_special_syntax_component_idx, - )?; + // A brace group containing a path separator (e.g. + // `svc/{src/env.ts,env.ts}`) spans multiple directory levels, which the + // one-component-per-level walker can't represent in a single pass. + // Expand such patterns into separate brace-free patterns, mirroring how + // `match` evaluates braces. `pattern_components` for each is built lazily + // in `Iterator::init` so every entry point (scan, scanSync, shell, + // workspaces) shares one code path. + if brace_group_spans_separator(&this.pattern) { + if let Some(expansions) = expand_braces(&this.pattern) { + if !expansions.is_empty() { + this.brace_expansions = expansions; + } + } + } - // copy arena after all allocations are successful + if this.brace_expansions.is_empty() { + Self::build_pattern_components( + &mut this.pattern_components, + &this.pattern, + &mut this.has_relative_components, + &mut this.end_byte_of_basename_excluding_special_syntax, + &mut this.basename_excluding_special_syntax_component_idx, + )?; - if cfg!(debug_assertions) { - this.debug_pattern_components(); + // copy arena after all allocations are successful + + if cfg!(debug_assertions) { + this.debug_pattern_components(); + } } Ok(Ok(this)) @@ -1430,7 +1496,10 @@ impl GlobWalker { } pub fn walk(&mut self) -> Result, Error> { - if self.pattern_components.is_empty() { + // When `brace_expansions` is non-empty the components are built lazily + // per expansion in `Iterator::init`, so `pattern_components` being empty + // here is expected. + if self.pattern_components.is_empty() && self.brace_expansions.is_empty() { return Ok(Ok(())); } @@ -2071,6 +2140,28 @@ impl GlobWalker { Ok(()) } + + /// Point `pattern` / `pattern_components` at the brace expansion currently + /// selected by `expansion_cursor` and rebuild the components for it. Only + /// called when `brace_expansions` is non-empty. + fn rebuild_components_for_current_expansion(&mut self) -> Result<(), Error> { + self.pattern = self.brace_expansions[self.expansion_cursor].clone(); + self.pattern_components.clear(); + self.has_relative_components = false; + self.end_byte_of_basename_excluding_special_syntax = 0; + self.basename_excluding_special_syntax_component_idx = 0; + Self::build_pattern_components( + &mut self.pattern_components, + &self.pattern, + &mut self.has_relative_components, + &mut self.end_byte_of_basename_excluding_special_syntax, + &mut self.basename_excluding_special_syntax_component_idx, + )?; + if cfg!(debug_assertions) { + self.debug_pattern_components(); + } + Ok(()) + } } // Drop frees Vec/Box fields automatically. @@ -2105,6 +2196,178 @@ pub fn match_wildcard_literal(literal: &[u8], path: &[u8]) -> bool { literal == path } +// ───────────────────────────────────────────────────────────────────────────── +// Brace expansion +// +// The walker models a pattern as one component per directory level. A brace +// group whose alternatives contain a path separator (e.g. +// `svc/{src/env.ts,env.ts}`) spans multiple levels and different alternatives +// can have different depths, so it does not fit that model. Rather than teach +// the NFA about variable-length branches, we expand such patterns into separate +// brace-free patterns (the same set of strings `match` evaluates the braces +// against) and walk each, deduping via `matched_paths`. +// ───────────────────────────────────────────────────────────────────────────── + +/// Upper bound on the number of patterns a brace expansion may produce. Mirrors +/// the matcher's `BRACE_BRANCH_BUDGET`: sequential groups multiply, so an +/// adversarial pattern could otherwise blow up. Over budget, the caller falls +/// back to the unexpanded pattern. +const BRACE_EXPANSION_LIMIT: usize = 10_000; + +/// Bound on brace nesting / chaining depth during expansion. Guards against +/// stack overflow on adversarial input; far beyond any realistic pattern. +const BRACE_EXPANSION_MAX_DEPTH: u32 = 32; + +/// Returns true if `pattern` contains a brace group (`{...}`) with a path +/// separator inside it. Backslash escapes and `[...]` bracket classes are +/// honored the same way [`crate::matcher`] honors them, so commas/braces/ +/// separators inside a bracket class or after a backslash are not counted. +fn brace_group_spans_separator(pattern: &[u8]) -> bool { + let mut depth: u32 = 0; + let mut in_brackets = false; + let mut i = 0usize; + while i < pattern.len() { + let c = pattern[i]; + match c { + b'\\' => { + i += 2; + continue; + } + b'[' if !in_brackets => in_brackets = true, + b']' if in_brackets => in_brackets = false, + b'{' if !in_brackets => depth += 1, + b'}' if !in_brackets && depth > 0 => depth -= 1, + _ if depth > 0 && !in_brackets && bun_core::path_sep::is_sep_native(c) => { + return true; + } + _ => {} + } + i += 1; + } + false +} + +/// Locate the first top-level brace group and split it into its alternatives. +/// Returns `(open_idx, close_idx, alternatives)` where `alternatives` borrow +/// `pattern`, or `None` when there is no (well-formed) top-level brace group. +fn find_first_brace_group(pattern: &[u8]) -> Option<(usize, usize, Vec<&[u8]>)> { + let mut in_brackets = false; + let mut open = None; + let mut i = 0usize; + while i < pattern.len() { + match pattern[i] { + b'\\' => { + i += 2; + continue; + } + b'[' if !in_brackets => in_brackets = true, + b']' if in_brackets => in_brackets = false, + b'{' if !in_brackets => { + open = Some(i); + break; + } + _ => {} + } + i += 1; + } + let open = open?; + + let mut depth: u32 = 0; + let mut in_brackets = false; + let mut alts: Vec<&[u8]> = Vec::new(); + let mut alt_start = open + 1; + let mut j = open; + while j < pattern.len() { + match pattern[j] { + b'\\' => { + j += 2; + continue; + } + b'[' if !in_brackets => in_brackets = true, + b']' if in_brackets => in_brackets = false, + b'{' if !in_brackets => depth += 1, + b'}' if !in_brackets => { + depth -= 1; + if depth == 0 { + alts.push(&pattern[alt_start..j]); + return Some((open, j, alts)); + } + } + b',' if !in_brackets && depth == 1 => { + alts.push(&pattern[alt_start..j]); + alt_start = j + 1; + } + _ => {} + } + j += 1; + } + + // No matching close brace: treat the `{` as a literal (no expansion). + None +} + +/// Expand every brace group in `pattern` into concrete brace-free patterns. +/// Returns `None` if the expansion would exceed [`BRACE_EXPANSION_LIMIT`] or +/// [`BRACE_EXPANSION_MAX_DEPTH`] (caller falls back to the unexpanded pattern). +fn expand_braces(pattern: &[u8]) -> Option>> { + let mut out: Vec> = Vec::new(); + expand_braces_into(pattern, 0, &mut out)?; + Some(out) +} + +fn expand_braces_into(pattern: &[u8], depth: u32, out: &mut Vec>) -> Option<()> { + if depth > BRACE_EXPANSION_MAX_DEPTH { + return None; + } + + let Some((open, close, alts)) = find_first_brace_group(pattern) else { + // No brace group left: this is a concrete pattern. Drop patterns that + // collapse to empty (they match nothing on the filesystem). + if !pattern.is_empty() { + if out.len() >= BRACE_EXPANSION_LIMIT { + return None; + } + out.push(Box::from(pattern)); + } + return Some(()); + }; + + let prefix = &pattern[..open]; + let suffix = &pattern[close + 1..]; + + // The suffix is shared across every alternative, so expand it once. + let mut suffix_expansions: Vec> = Vec::new(); + expand_braces_into(suffix, depth + 1, &mut suffix_expansions)?; + if suffix_expansions.is_empty() { + suffix_expansions.push(Box::from(&b""[..])); + } + + for alt in &alts { + // An alternative may itself contain nested braces. + let mut alt_expansions: Vec> = Vec::new(); + expand_braces_into(alt, depth + 1, &mut alt_expansions)?; + if alt_expansions.is_empty() { + alt_expansions.push(Box::from(&b""[..])); + } + for ae in &alt_expansions { + for se in &suffix_expansions { + if out.len() >= BRACE_EXPANSION_LIMIT { + return None; + } + let mut combined = Vec::with_capacity(prefix.len() + ae.len() + se.len()); + combined.extend_from_slice(prefix); + combined.extend_from_slice(ae); + combined.extend_from_slice(se); + if !combined.is_empty() { + out.push(combined.into_boxed_slice()); + } + } + } + } + + Some(()) +} + // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── @@ -2185,3 +2448,90 @@ impl AccessorDirEntry for DirIterator::IteratorResult { self.kind } } + +#[cfg(test)] +mod brace_expansion_tests { + use super::{brace_group_spans_separator, expand_braces}; + + fn expand(pattern: &str) -> Vec { + let mut v: Vec = expand_braces(pattern.as_bytes()) + .expect("expansion within budget") + .into_iter() + .map(|b| String::from_utf8(b.to_vec()).unwrap()) + .collect(); + v.sort(); + v + } + + #[test] + fn detects_separator_inside_braces() { + assert!(brace_group_spans_separator(b"svc/{src/env.ts,env.ts}")); + assert!(brace_group_spans_separator( + b"src/{helpers/paths.ts,cli.ts}" + )); + assert!(brace_group_spans_separator(b"**/{a/b,c}")); + assert!(brace_group_spans_separator(b"{a,{b/c,d}}")); + } + + #[test] + fn ignores_braces_without_separator() { + assert!(!brace_group_spans_separator(b"{src,tests}/**/*.ts")); + assert!(!brace_group_spans_separator(b"src/helpers/{paths,log}.ts")); + assert!(!brace_group_spans_separator(b"src/{a,b}.ts")); + assert!(!brace_group_spans_separator(b"plain/path/no/braces.ts")); + } + + #[test] + fn separator_in_bracket_class_is_not_a_brace() { + // `[` opens a class; a `/` there is not "inside a brace group". + assert!(!brace_group_spans_separator(b"file[/]name")); + } + + #[test] + fn expands_separator_alternatives() { + assert_eq!( + expand("svc/{src/env.ts,env.ts}"), + vec!["svc/env.ts", "svc/src/env.ts"] + ); + assert_eq!( + expand("src/{helpers/paths.ts,cli.ts}"), + vec!["src/cli.ts", "src/helpers/paths.ts"] + ); + } + + #[test] + fn expands_cartesian_product() { + assert_eq!( + expand("{a,b}/{c/d,e}"), + vec!["a/c/d", "a/e", "b/c/d", "b/e"] + ); + } + + #[test] + fn expands_nested_braces() { + assert_eq!(expand("{a,{b/c,d}}"), vec!["a", "b/c", "d"]); + } + + #[test] + fn single_alternative_group_unwraps() { + assert_eq!(expand("svc/{src/env.ts}"), vec!["svc/src/env.ts"]); + } + + #[test] + fn preserves_other_glob_syntax() { + assert_eq!(expand("**/{a/b,c}"), vec!["**/a/b", "**/c"]); + assert_eq!(expand("{a/*.ts,b}"), vec!["a/*.ts", "b"]); + } + + #[test] + fn comma_in_bracket_class_is_not_a_branch() { + // `{x,[a,b]/y}` -> branches `x` and `[a,b]/y` (comma in class is literal). + assert_eq!(expand("{x,[a,b]/y}"), vec!["[a,b]/y", "x"]); + } + + #[test] + fn empty_alternative_is_dropped_when_pattern_collapses() { + // `{a/b,}` -> `a/b` and empty; the empty pattern matches nothing and is dropped. + assert_eq!(expand("{a/b,}"), vec!["a/b"]); + } +} diff --git a/test/js/bun/glob/scan.test.ts b/test/js/bun/glob/scan.test.ts index c4fa1714f8b8..aad2beafb98d 100644 --- a/test/js/bun/glob/scan.test.ts +++ b/test/js/bun/glob/scan.test.ts @@ -618,6 +618,56 @@ describe("literal fast path", async () => { }); }); +// https://github.com/oven-sh/bun/issues/32596 +describe("brace patterns containing path separators", async () => { + let cwd = ""; + beforeAll(() => { + cwd = tempDirWithFiles("glob-scan-brace-sep", { + "svc": { "src": { "env.ts": "" }, "env.ts": "" }, + "src": { "helpers": { "paths.ts": "" }, "cli.ts": "" }, + "pkg": { "a": { "deep": { "x.ts": "" } }, "b.ts": "" }, + }); + }); + + const sep = (p: string) => p.split("/").join(path.sep); + const cases: Array<[string, string[]]> = [ + // The alternatives span different directory depths. + ["svc/{src/env.ts,env.ts}", ["svc/src/env.ts", "svc/env.ts"]], + ["src/{helpers/paths.ts,cli.ts}", ["src/helpers/paths.ts", "src/cli.ts"]], + // A globstar inside a brace alternative still works. + ["pkg/{a/**/*.ts,b.ts}", ["pkg/a/deep/x.ts", "pkg/b.ts"]], + // Overlapping alternatives are deduplicated. + ["svc/{src/env.ts,src/env.ts}", ["svc/src/env.ts"]], + // A single-alternative group still expands. + ["svc/{src/env.ts}", ["svc/src/env.ts"]], + ]; + + for (const [pattern, expected] of cases) { + const want = expected.map(sep).sort(); + + test(`scan ${pattern}`, async () => { + const entries = await Array.fromAsync(new Glob(pattern).scan({ cwd, dot: true })); + expect(entries.sort()).toEqual(want); + }); + + test(`scanSync ${pattern}`, () => { + const entries = Array.from(new Glob(pattern).scanSync({ cwd, dot: true })); + expect(entries.sort()).toEqual(want); + }); + } + + // scan() and match() must agree on the same pattern. + test("scan agrees with match", async () => { + const pattern = "svc/{src/env.ts,env.ts}"; + const glob = new Glob(pattern); + const entries = await Array.fromAsync(glob.scan({ cwd, dot: true })); + for (const entry of entries) { + expect(glob.match(entry.split(path.sep).join("/"))).toBe(true); + } + expect(entries.length).toBe(2); + }); +}); + describe("trailing directory separator", async () => { test("matches directories absolute", async () => { const tmpdir = tmpdirSync(); From 78542c1be15e983e68e7014e19d2818dc53ca087 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:03:05 +0000 Subject: [PATCH 2/8] glob: dedupe absolute-literal brace expansions and treat Windows `\` as a separator Two fixes from review of the brace-expansion change: - The no-special-syntax fast path in `Iterator::init` set `IterState::Matched` without recording the path in `matched_paths`. Because the iterator now re-runs `init` per brace expansion, an absolute pattern whose alternatives overlap (e.g. `/x/{a,a}` or `/x/{a,*}`) emitted the same file twice, and `walk` (which reads `matched_paths`) dropped fast-path matches entirely. Route the fast path through a new `record_full_match` helper that checks and inserts `matched_paths`, mirroring `prepare_matched_path`'s NUL handling so dedupe is exact in both SENTINEL modes. Absolute literals now surface from `scan` too. - `brace_group_spans_separator` and `find_first_brace_group` consumed `\` as an escape on every platform, but `build_pattern_components` treats `\` as a path separator on Windows (`is_sep_native`). A pattern like `svc/{src\env.ts,b}` therefore skipped expansion yet still got split on the `\`. Gate the escape handling to non-Windows so a `\` inside a brace group triggers expansion on Windows, matching the splitter. Adds scan/scanSync coverage for single-alternative wildcard groups spanning a separator (the `{*/*}` shape) and for absolute-literal expansion surfacing/dedupe. --- src/glob/GlobWalker.rs | 80 +++++++++++++++++++++++++++++------ test/js/bun/glob/scan.test.ts | 19 ++++++++- 2 files changed, 86 insertions(+), 13 deletions(-) diff --git a/src/glob/GlobWalker.rs b/src/glob/GlobWalker.rs index d7c66636cc5b..9340fbbe7dac 100644 --- a/src/glob/GlobWalker.rs +++ b/src/glob/GlobWalker.rs @@ -449,17 +449,19 @@ impl<'a, A: Accessor, const SENTINEL: bool> Iterator<'a, A, SENTINEL> { // // In that case we don't need to do any walking and can just open up the FS entry if starting_component_idx as usize >= self.walker.pattern_components.len() { - // Matched-path payload must respect SENTINEL. The open() - // probe always needs a NUL — use a separate dupeZ for it so - // SENTINEL=false matched paths don't carry a spurious 0x00. - let path = dupe_matched::(path_without_special_syntax); + // The open() probe always needs a NUL-terminated path. let pathz_owned = dupe_z(path_without_special_syntax); // SAFETY: dupe_z appends NUL at len()-1; ZStr len excludes it. let pathz = ZStr::from_slice_with_nul(&pathz_owned[..]); let fd = match A::open(pathz)? { Err(e) => { + // ENOTDIR means a path component is a file, i.e. the + // literal itself exists as a file: that's a match. if e.get_errno() == E::ENOTDIR { - self.iter_state = IterState::Matched(path); + self.iter_state = GlobWalker::::matched_or_next( + &mut self.walker.matched_paths, + path_without_special_syntax, + )?; return Ok(Ok(())); } // Doesn't exist @@ -467,12 +469,16 @@ impl<'a, A: Accessor, const SENTINEL: bool> Iterator<'a, A, SENTINEL> { self.iter_state = IterState::GetNext; return Ok(Ok(())); } + let path = dupe_matched::(path_without_special_syntax); return Ok(Err(e.with_path(matched_as_slice::(&path)))); } Ok(fd) => fd, }; let _ = A::close(fd); - self.iter_state = IterState::Matched(path); + self.iter_state = GlobWalker::::matched_or_next( + &mut self.walker.matched_paths, + path_without_special_syntax, + )?; return Ok(Ok(())); } @@ -1915,6 +1921,51 @@ impl GlobWalker { Ok(Some(name_matched_path)) } + /// Record a fully-resolved match (`full_path` is NUL-less), deduping it + /// against `matched_paths`. Returns the payload to emit, or `None` if it was + /// already matched. Used by `Iterator::init`'s no-special-syntax fast path, + /// which would otherwise emit a `Matched` state without recording it: for + /// `walk` (reads `matched_paths`) the match would be dropped, and across + /// brace expansions an overlapping absolute literal (`/x/{a,a}`) would be + /// emitted twice to `next()` consumers. Mirrors `prepare_matched_path`'s NUL + /// handling so dedupe is exact in both SENTINEL modes (stored keys carry a + /// trailing NUL when SENTINEL is true). + /// + /// Takes `matched_paths` by reference (rather than `&mut self`) so the caller + /// can pass a slice borrowed from a different `self` field (`pattern`). + fn record_full_match( + matched_paths: &mut MatchedMap, + full_path: &[u8], + ) -> Result, AllocError> { + if SENTINEL { + let with_nul = dupe_z(full_path); + if matched_paths.contains_key(&with_nul) { + return Ok(None); + } + matched_paths.insert(&with_nul, ()); + Ok(Some(with_nul)) + } else { + if matched_paths.contains_key(full_path) { + return Ok(None); + } + let slice: Box<[u8]> = Box::from(full_path); + matched_paths.insert(&slice, ()); + Ok(Some(slice)) + } + } + + /// Record `full_path` and return the iterator state to enter: `Matched` when + /// it is a newly seen match, `GetNext` when it was already emitted. + fn matched_or_next( + matched_paths: &mut MatchedMap, + full_path: &[u8], + ) -> Result, AllocError> { + Ok(match Self::record_full_match(matched_paths, full_path)? { + Some(p) => IterState::Matched(p), + None => IterState::GetNext, + }) + } + #[inline] fn join(&self, subdir_parts: &[&[u8]]) -> Result, AllocError> { if !self.absolute { @@ -2219,9 +2270,11 @@ const BRACE_EXPANSION_LIMIT: usize = 10_000; const BRACE_EXPANSION_MAX_DEPTH: u32 = 32; /// Returns true if `pattern` contains a brace group (`{...}`) with a path -/// separator inside it. Backslash escapes and `[...]` bracket classes are -/// honored the same way [`crate::matcher`] honors them, so commas/braces/ -/// separators inside a bracket class or after a backslash are not counted. +/// separator inside it. `[...]` bracket classes are honored, so commas/braces/ +/// separators inside a class are not counted. On non-Windows a backslash +/// escapes the next byte; on Windows a backslash is a native path separator +/// (matching `is_sep_native` / `build_pattern_components`), not an escape, so a +/// `\` inside a brace group triggers expansion there too. fn brace_group_spans_separator(pattern: &[u8]) -> bool { let mut depth: u32 = 0; let mut in_brackets = false; @@ -2229,7 +2282,7 @@ fn brace_group_spans_separator(pattern: &[u8]) -> bool { while i < pattern.len() { let c = pattern[i]; match c { - b'\\' => { + b'\\' if !cfg!(windows) => { i += 2; continue; } @@ -2256,7 +2309,7 @@ fn find_first_brace_group(pattern: &[u8]) -> Option<(usize, usize, Vec<&[u8]>)> let mut i = 0usize; while i < pattern.len() { match pattern[i] { - b'\\' => { + b'\\' if !cfg!(windows) => { i += 2; continue; } @@ -2279,7 +2332,7 @@ fn find_first_brace_group(pattern: &[u8]) -> Option<(usize, usize, Vec<&[u8]>)> let mut j = open; while j < pattern.len() { match pattern[j] { - b'\\' => { + b'\\' if !cfg!(windows) => { j += 2; continue; } @@ -2515,6 +2568,9 @@ mod brace_expansion_tests { #[test] fn single_alternative_group_unwraps() { assert_eq!(expand("svc/{src/env.ts}"), vec!["svc/src/env.ts"]); + // A whole-pattern single-alternative group with a wildcard: `{*/*}` -> `*/*`. + assert!(brace_group_spans_separator(b"{*/*}")); + assert_eq!(expand("{*/*}"), vec!["*/*"]); } #[test] diff --git a/test/js/bun/glob/scan.test.ts b/test/js/bun/glob/scan.test.ts index aad2beafb98d..d11f30934c6c 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"; @@ -640,6 +640,10 @@ describe("brace patterns containing path separators", async () => { ["svc/{src/env.ts,src/env.ts}", ["svc/src/env.ts"]], // A single-alternative group still expands. ["svc/{src/env.ts}", ["svc/src/env.ts"]], + // A single-alternative group wrapping a wildcard that spans a separator, + // the shape of `{*/*}` from https://github.com/oven-sh/bun/issues/24000. + ["{src/*.ts}", ["src/cli.ts"]], + ["pkg/{a/*/*.ts}", ["pkg/a/deep/x.ts"]], ]; for (const [pattern, expected] of cases) { @@ -666,6 +670,19 @@ describe("brace patterns containing path separators", async () => { } expect(entries.length).toBe(2); }); + + // Expanded alternatives that are absolute literals hit the no-special-syntax + // fast path; it must still record matches (so they surface) and dedupe them. + // POSIX-only: the pattern is built with "/" to keep separators unambiguous. + test.skipIf(isWindows)("absolute literal brace expansions surface and dedupe", async () => { + const distinct = await Array.fromAsync( + new Glob(`${cwd}/{svc/env.ts,svc/src/env.ts}`).scan({ dot: true }), + ); + expect(distinct.sort()).toEqual([`${cwd}/svc/env.ts`, `${cwd}/svc/src/env.ts`].sort()); + + const overlapping = await Array.fromAsync(new Glob(`${cwd}/{svc/env.ts,svc/env.ts}`).scan({ dot: true })); + expect(overlapping).toEqual([`${cwd}/svc/env.ts`]); + }); }); describe("trailing directory separator", async () => { From 9e17f1de24fb21a33d2ebb97af9d92bb6ac7c797 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:05:25 +0000 Subject: [PATCH 3/8] [autofix.ci] apply automated fixes --- test/js/bun/glob/scan.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/js/bun/glob/scan.test.ts b/test/js/bun/glob/scan.test.ts index d11f30934c6c..88db3ea7f9b1 100644 --- a/test/js/bun/glob/scan.test.ts +++ b/test/js/bun/glob/scan.test.ts @@ -675,9 +675,7 @@ describe("brace patterns containing path separators", async () => { // fast path; it must still record matches (so they surface) and dedupe them. // POSIX-only: the pattern is built with "/" to keep separators unambiguous. test.skipIf(isWindows)("absolute literal brace expansions surface and dedupe", async () => { - const distinct = await Array.fromAsync( - new Glob(`${cwd}/{svc/env.ts,svc/src/env.ts}`).scan({ dot: true }), - ); + const distinct = await Array.fromAsync(new Glob(`${cwd}/{svc/env.ts,svc/src/env.ts}`).scan({ dot: true })); expect(distinct.sort()).toEqual([`${cwd}/svc/env.ts`, `${cwd}/svc/src/env.ts`].sort()); const overlapping = await Array.fromAsync(new Glob(`${cwd}/{svc/env.ts,svc/env.ts}`).scan({ dot: true })); From a0f7fca27d8a74fd0c20517eb97ecff610ee86a4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:49:36 +0000 Subject: [PATCH 4/8] glob: fix absolute-pattern regressions from brace-expansion dedupe The earlier dedupe change (record_full_match) routed Iterator::init's no-special-syntax fast path through matched_paths. That surfaced absolute literal matches scan/scanSync had always dropped, including directories that onlyFiles excludes: the `../.` matrix case started returning `[""]` instead of `[]`, diverging from the fast-glob oracle. Revert it and restore the original fast path. The double-emit it guarded against only reached next() consumers for absolute brace patterns, which shell (its own brace expansion) and workspace/--filter (relative patterns) do not produce. Separately, walking each brace expansion re-runs init with that expansion's own root, so an absolute alternative with a missing prefix (e.g. `{/missing/*.ts,ok/*.ts}`) threw ENOENT and aborted the whole scan, dependent on alternative order. Brace alternatives are an unordered set, so tolerate ENOENT/ENOTDIR from the per-expansion root open (yield nothing for that alternative) when brace_expansions is non-empty; single patterns keep the original error so a bad cwd still surfaces. Keeps the Windows backslash-in-braces fix. Adds a scan test for the missing-root case and drops the now-invalid absolute-surfacing test. --- src/glob/GlobWalker.rs | 79 +++++++++-------------------------- test/js/bun/glob/scan.test.ts | 21 ++++++---- 2 files changed, 32 insertions(+), 68 deletions(-) diff --git a/src/glob/GlobWalker.rs b/src/glob/GlobWalker.rs index 9340fbbe7dac..f63006790870 100644 --- a/src/glob/GlobWalker.rs +++ b/src/glob/GlobWalker.rs @@ -252,8 +252,8 @@ pub struct GlobWalker { /// `svc/env.ts`). Empty when the pattern needs no expansion, in which case /// `pattern` / `pattern_components` describe the single pattern directly. /// When non-empty, the walker walks each entry in turn (see `walk` and - /// `Iterator::try_advance_to_next_expansion`), rebuilding - /// `pattern_components` per entry and deduping results via `matched_paths`. + /// `Iterator::try_advance_to_next_expansion`), rebuilding `pattern_components` + /// per entry and deduping results via `matched_paths`. pub brace_expansions: Vec>, /// Index into `brace_expansions` of the pattern currently being walked. expansion_cursor: usize, @@ -449,19 +449,17 @@ impl<'a, A: Accessor, const SENTINEL: bool> Iterator<'a, A, SENTINEL> { // // In that case we don't need to do any walking and can just open up the FS entry if starting_component_idx as usize >= self.walker.pattern_components.len() { - // The open() probe always needs a NUL-terminated path. + // Matched-path payload must respect SENTINEL. The open() + // probe always needs a NUL — use a separate dupeZ for it so + // SENTINEL=false matched paths don't carry a spurious 0x00. + let path = dupe_matched::(path_without_special_syntax); let pathz_owned = dupe_z(path_without_special_syntax); // SAFETY: dupe_z appends NUL at len()-1; ZStr len excludes it. let pathz = ZStr::from_slice_with_nul(&pathz_owned[..]); let fd = match A::open(pathz)? { Err(e) => { - // ENOTDIR means a path component is a file, i.e. the - // literal itself exists as a file: that's a match. if e.get_errno() == E::ENOTDIR { - self.iter_state = GlobWalker::::matched_or_next( - &mut self.walker.matched_paths, - path_without_special_syntax, - )?; + self.iter_state = IterState::Matched(path); return Ok(Ok(())); } // Doesn't exist @@ -469,16 +467,12 @@ impl<'a, A: Accessor, const SENTINEL: bool> Iterator<'a, A, SENTINEL> { self.iter_state = IterState::GetNext; return Ok(Ok(())); } - let path = dupe_matched::(path_without_special_syntax); return Ok(Err(e.with_path(matched_as_slice::(&path)))); } Ok(fd) => fd, }; let _ = A::close(fd); - self.iter_state = GlobWalker::::matched_or_next( - &mut self.walker.matched_paths, - path_without_special_syntax, - )?; + self.iter_state = IterState::Matched(path); return Ok(Ok(())); } @@ -521,6 +515,18 @@ impl<'a, A: Accessor, const SENTINEL: bool> Iterator<'a, A, SENTINEL> { let root_path_z = unsafe { ZStr::from_raw(path_buf_ptr, root_path_len) }; let cwd_fd = match A::open(root_path_z)? { Err(err) => { + // Brace expansions are walked as independent patterns, each with + // its own root. Alternatives are an unordered set, so a missing + // or non-directory root for one (e.g. `{/missing/*.ts,ok/*.ts}`) + // must yield no matches for that alternative rather than abort + // the whole scan. Single patterns keep the original error so a + // bad cwd still surfaces. + if !self.walker.brace_expansions.is_empty() + && matches!(err.get_errno(), E::ENOENT | E::ENOTDIR) + { + self.iter_state = IterState::GetNext; + return Ok(Ok(())); + } return Ok(Err(self.walker.handle_sys_err_with_path( &err, // SAFETY: NUL at index `root_path_len` written above. @@ -1921,51 +1927,6 @@ impl GlobWalker { Ok(Some(name_matched_path)) } - /// Record a fully-resolved match (`full_path` is NUL-less), deduping it - /// against `matched_paths`. Returns the payload to emit, or `None` if it was - /// already matched. Used by `Iterator::init`'s no-special-syntax fast path, - /// which would otherwise emit a `Matched` state without recording it: for - /// `walk` (reads `matched_paths`) the match would be dropped, and across - /// brace expansions an overlapping absolute literal (`/x/{a,a}`) would be - /// emitted twice to `next()` consumers. Mirrors `prepare_matched_path`'s NUL - /// handling so dedupe is exact in both SENTINEL modes (stored keys carry a - /// trailing NUL when SENTINEL is true). - /// - /// Takes `matched_paths` by reference (rather than `&mut self`) so the caller - /// can pass a slice borrowed from a different `self` field (`pattern`). - fn record_full_match( - matched_paths: &mut MatchedMap, - full_path: &[u8], - ) -> Result, AllocError> { - if SENTINEL { - let with_nul = dupe_z(full_path); - if matched_paths.contains_key(&with_nul) { - return Ok(None); - } - matched_paths.insert(&with_nul, ()); - Ok(Some(with_nul)) - } else { - if matched_paths.contains_key(full_path) { - return Ok(None); - } - let slice: Box<[u8]> = Box::from(full_path); - matched_paths.insert(&slice, ()); - Ok(Some(slice)) - } - } - - /// Record `full_path` and return the iterator state to enter: `Matched` when - /// it is a newly seen match, `GetNext` when it was already emitted. - fn matched_or_next( - matched_paths: &mut MatchedMap, - full_path: &[u8], - ) -> Result, AllocError> { - Ok(match Self::record_full_match(matched_paths, full_path)? { - Some(p) => IterState::Matched(p), - None => IterState::GetNext, - }) - } - #[inline] fn join(&self, subdir_parts: &[&[u8]]) -> Result, AllocError> { if !self.absolute { diff --git a/test/js/bun/glob/scan.test.ts b/test/js/bun/glob/scan.test.ts index 88db3ea7f9b1..333220437c56 100644 --- a/test/js/bun/glob/scan.test.ts +++ b/test/js/bun/glob/scan.test.ts @@ -671,15 +671,18 @@ describe("brace patterns containing path separators", async () => { expect(entries.length).toBe(2); }); - // Expanded alternatives that are absolute literals hit the no-special-syntax - // fast path; it must still record matches (so they surface) and dedupe them. - // POSIX-only: the pattern is built with "/" to keep separators unambiguous. - test.skipIf(isWindows)("absolute literal brace expansions surface and dedupe", async () => { - const distinct = await Array.fromAsync(new Glob(`${cwd}/{svc/env.ts,svc/src/env.ts}`).scan({ dot: true })); - expect(distinct.sort()).toEqual([`${cwd}/svc/env.ts`, `${cwd}/svc/src/env.ts`].sort()); - - const overlapping = await Array.fromAsync(new Glob(`${cwd}/{svc/env.ts,svc/env.ts}`).scan({ dot: true })); - expect(overlapping).toEqual([`${cwd}/svc/env.ts`]); + // Brace alternatives are an unordered set, so an alternative whose root + // directory is missing must yield nothing for that alternative rather than + // abort the whole scan. POSIX-only: the pattern is built with "/" so the + // absolute expansions have unambiguous separators. + test.skipIf(isWindows)("alternative with a missing root yields the others", async () => { + const want = [`${cwd}/svc/env.ts`]; + // Missing root listed first. + const a = await Array.fromAsync(new Glob(`{${cwd}/nope/*.ts,${cwd}/svc/*.ts}`).scan({ dot: true })); + expect(a).toEqual(want); + // Missing root listed last (order independence). + const b = await Array.fromAsync(new Glob(`{${cwd}/svc/*.ts,${cwd}/nope/*.ts}`).scan({ dot: true })); + expect(b).toEqual(want); }); }); From b34845c1f65845e39b5ca80d2ed03cf26419bbb6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:01:33 +0000 Subject: [PATCH 5/8] glob: count a separator inside a bracket class when detecting brace spans `brace_group_spans_separator` gated its separator check on `!in_brackets`, but `build_pattern_components` splits on a path separator regardless of bracket state. For a degenerate pattern like `{x,a[/]b}` the detector returned false, so expansion was skipped and the splitter cut the group into `{x,a[` / `]b}`, losing the valid `x` alternative. Drop the `!in_brackets` guard from the separator arm (keeping it on the brace/comma arms) so the detector mirrors the splitter and the group's other alternatives expand instead of being mis-split. --- src/glob/GlobWalker.rs | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/glob/GlobWalker.rs b/src/glob/GlobWalker.rs index f63006790870..3f9340a16c20 100644 --- a/src/glob/GlobWalker.rs +++ b/src/glob/GlobWalker.rs @@ -2231,11 +2231,13 @@ const BRACE_EXPANSION_LIMIT: usize = 10_000; const BRACE_EXPANSION_MAX_DEPTH: u32 = 32; /// Returns true if `pattern` contains a brace group (`{...}`) with a path -/// separator inside it. `[...]` bracket classes are honored, so commas/braces/ -/// separators inside a class are not counted. On non-Windows a backslash -/// escapes the next byte; on Windows a backslash is a native path separator -/// (matching `is_sep_native` / `build_pattern_components`), not an escape, so a -/// `\` inside a brace group triggers expansion there too. +/// separator inside it. `[...]` bracket classes are honored for brace depth +/// (commas/braces inside a class are not structural), but a separator inside a +/// class still counts, because `build_pattern_components` splits on separators +/// regardless of bracket state. On non-Windows a backslash escapes the next +/// byte; on Windows a backslash is a native path separator (matching +/// `is_sep_native` / `build_pattern_components`), not an escape, so a `\` inside +/// a brace group triggers expansion there too. fn brace_group_spans_separator(pattern: &[u8]) -> bool { let mut depth: u32 = 0; let mut in_brackets = false; @@ -2251,7 +2253,12 @@ fn brace_group_spans_separator(pattern: &[u8]) -> bool { b']' if in_brackets => in_brackets = false, b'{' if !in_brackets => depth += 1, b'}' if !in_brackets && depth > 0 => depth -= 1, - _ if depth > 0 && !in_brackets && bun_core::path_sep::is_sep_native(c) => { + // Not gated on `!in_brackets`: `build_pattern_components` splits on a + // separator regardless of bracket state, so a `/` inside a `[...]` + // class within a brace group would still be cut there. Counting it + // here keeps the detector faithful to the splitter and lets the + // group's other alternatives be expanded instead of mis-split. + _ if depth > 0 && bun_core::path_sep::is_sep_native(c) => { return true; } _ => {} @@ -2501,6 +2508,14 @@ mod brace_expansion_tests { assert!(!brace_group_spans_separator(b"file[/]name")); } + #[test] + fn separator_in_bracket_class_inside_braces_is_counted() { + // `build_pattern_components` splits on the `/` regardless of the class, + // so the detector must expand to rescue the other alternatives. + assert!(brace_group_spans_separator(b"{x,a[/]b}")); + assert_eq!(expand("{x,a[/]b}"), vec!["a[/]b", "x"]); + } + #[test] fn expands_separator_alternatives() { assert_eq!( From eccfb98863da3c5e4851f5ccf62527b7bf5891d7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:36:53 +0000 Subject: [PATCH 6/8] glob: scope missing-root tolerance to absolute alternatives; count escaped separators Two follow-ups from review of the brace-expansion change: - The ENOENT/ENOTDIR tolerance for a missing per-expansion root also fired for relative brace patterns, whose root is the shared user `cwd`, so `{a/b,c/d}` with a non-existent `cwd` silently returned `[]` instead of throwing. Gate it on `was_absolute` so only an absolute alternative's own literal prefix is tolerated; a bad `cwd` (and single patterns) still surface the error. - `brace_group_spans_separator` consumed a backslash-escaped separator as an escape, but `build_pattern_components` splits on a separator regardless of a preceding backslash, so `svc/{src\/env.ts,env.ts}` skipped expansion and got mis-split. A new `escape_advance` helper stops the backslash arm from consuming a following separator (detector and both `find_first_brace_group` loops), so the detector mirrors the splitter and sibling alternatives expand. Adds a scan test that a missing cwd still throws for a relative brace pattern, and unit tests for the escaped-separator case. --- src/glob/GlobWalker.rs | 56 ++++++++++++++++++++++++++++------- test/js/bun/glob/scan.test.ts | 9 ++++++ 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/src/glob/GlobWalker.rs b/src/glob/GlobWalker.rs index 3f9340a16c20..c49044b03302 100644 --- a/src/glob/GlobWalker.rs +++ b/src/glob/GlobWalker.rs @@ -517,11 +517,15 @@ impl<'a, A: Accessor, const SENTINEL: bool> Iterator<'a, A, SENTINEL> { Err(err) => { // Brace expansions are walked as independent patterns, each with // its own root. Alternatives are an unordered set, so a missing - // or non-directory root for one (e.g. `{/missing/*.ts,ok/*.ts}`) - // must yield no matches for that alternative rather than abort - // the whole scan. Single patterns keep the original error so a - // bad cwd still surfaces. - if !self.walker.brace_expansions.is_empty() + // or non-directory absolute root for one (e.g. + // `{/missing/*.ts,ok/*.ts}`) must yield no matches for that + // alternative rather than abort the whole scan. Gated on + // `was_absolute` so this only covers an expansion's own literal + // prefix: a missing `cwd` (the root for relative alternatives, + // shared by all of them) still surfaces its error, as do single + // patterns. + if was_absolute + && !self.walker.brace_expansions.is_empty() && matches!(err.get_errno(), E::ENOENT | E::ENOTDIR) { self.iter_state = IterState::GetNext; @@ -2230,14 +2234,31 @@ const BRACE_EXPANSION_LIMIT: usize = 10_000; /// stack overflow on adversarial input; far beyond any realistic pattern. const BRACE_EXPANSION_MAX_DEPTH: u32 = 32; +/// Bytes to advance past a non-Windows backslash escape at index `i`. Returns 1 +/// when the next byte is a native separator so the caller still sees it on the +/// following iteration: `build_pattern_components` splits on a separator even +/// when backslash-escaped, so the detector/parser must not hide it. Any other +/// escaped byte is skipped entirely (returns 2). +fn escape_advance(pattern: &[u8], i: usize) -> usize { + if pattern + .get(i + 1) + .is_some_and(|&n| bun_core::path_sep::is_sep_native(n)) + { + 1 + } else { + 2 + } +} + /// Returns true if `pattern` contains a brace group (`{...}`) with a path /// separator inside it. `[...]` bracket classes are honored for brace depth /// (commas/braces inside a class are not structural), but a separator inside a /// class still counts, because `build_pattern_components` splits on separators /// regardless of bracket state. On non-Windows a backslash escapes the next -/// byte; on Windows a backslash is a native path separator (matching -/// `is_sep_native` / `build_pattern_components`), not an escape, so a `\` inside -/// a brace group triggers expansion there too. +/// byte, except a following separator still counts (the splitter cuts on it +/// regardless); on Windows a backslash is itself a native path separator +/// (matching `is_sep_native` / `build_pattern_components`), not an escape, so a +/// `\` inside a brace group triggers expansion there too. fn brace_group_spans_separator(pattern: &[u8]) -> bool { let mut depth: u32 = 0; let mut in_brackets = false; @@ -2246,7 +2267,7 @@ fn brace_group_spans_separator(pattern: &[u8]) -> bool { let c = pattern[i]; match c { b'\\' if !cfg!(windows) => { - i += 2; + i += escape_advance(pattern, i); continue; } b'[' if !in_brackets => in_brackets = true, @@ -2278,7 +2299,7 @@ fn find_first_brace_group(pattern: &[u8]) -> Option<(usize, usize, Vec<&[u8]>)> while i < pattern.len() { match pattern[i] { b'\\' if !cfg!(windows) => { - i += 2; + i += escape_advance(pattern, i); continue; } b'[' if !in_brackets => in_brackets = true, @@ -2301,7 +2322,7 @@ fn find_first_brace_group(pattern: &[u8]) -> Option<(usize, usize, Vec<&[u8]>)> while j < pattern.len() { match pattern[j] { b'\\' if !cfg!(windows) => { - j += 2; + j += escape_advance(pattern, j); continue; } b'[' if !in_brackets => in_brackets = true, @@ -2516,6 +2537,19 @@ mod brace_expansion_tests { assert_eq!(expand("{x,a[/]b}"), vec!["a[/]b", "x"]); } + #[cfg(not(windows))] + #[test] + fn escaped_separator_inside_braces_is_counted() { + // `build_pattern_components` splits on a `/` even when backslash-escaped, + // so the detector counts it and the group expands. (Windows treats `\` + // as a separator, a different path, so this is POSIX-only.) + assert!(brace_group_spans_separator(b"svc/{src\\/env.ts,env.ts}")); + assert_eq!( + expand("svc/{src\\/env.ts,env.ts}"), + vec!["svc/env.ts", "svc/src\\/env.ts"] + ); + } + #[test] fn expands_separator_alternatives() { assert_eq!( diff --git a/test/js/bun/glob/scan.test.ts b/test/js/bun/glob/scan.test.ts index 333220437c56..b739b333fb6f 100644 --- a/test/js/bun/glob/scan.test.ts +++ b/test/js/bun/glob/scan.test.ts @@ -684,6 +684,15 @@ describe("brace patterns containing path separators", async () => { const b = await Array.fromAsync(new Glob(`{${cwd}/svc/*.ts,${cwd}/nope/*.ts}`).scan({ dot: true })); expect(b).toEqual(want); }); + + // The missing-root tolerance is only for an absolute alternative's own + // prefix. A non-existent cwd is the shared root for every relative + // alternative, so it must still surface its error instead of silently + // returning no matches. + test("missing cwd still throws for a relative brace pattern", () => { + const missing = path.join(cwd, "definitely-missing-32596"); + expect(() => [...new Glob("{a/b,c/d}").scanSync({ cwd: missing })]).toThrow(); + }); }); describe("trailing directory separator", async () => { From fb3cb0f971638357b883d810e15658638889d8e7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:08:57 +0000 Subject: [PATCH 7/8] glob: walk a single brace expansion as a plain pattern When a brace group expands to exactly one pattern (a single-alternative group such as `{src/env.ts}`, or an unclosed group pushed verbatim), it is equivalent to the brace-free pattern, so set it as `pattern` and fall through to the single-pattern path instead of the per-expansion machinery. That makes `{X}` byte-for-byte transparent to `X`: it shares the single-pattern fast path, `onlyFiles` filtering, and error handling. Previously the 1-element `brace_expansions` vec tripped the missing-root tolerance, so a single absolute alternative with a missing root returned `[]` while the bare pattern threw. `brace_expansions` is now only populated for genuine multi-alternative groups. Adds a scan test that a single-alternative group throws on a missing absolute root exactly like its bare form. --- src/glob/GlobWalker.rs | 13 ++++++++++--- test/js/bun/glob/scan.test.ts | 10 ++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/glob/GlobWalker.rs b/src/glob/GlobWalker.rs index c49044b03302..310edcd43315 100644 --- a/src/glob/GlobWalker.rs +++ b/src/glob/GlobWalker.rs @@ -1467,9 +1467,16 @@ impl GlobWalker { // in `Iterator::init` so every entry point (scan, scanSync, shell, // workspaces) shares one code path. if brace_group_spans_separator(&this.pattern) { - if let Some(expansions) = expand_braces(&this.pattern) { - if !expansions.is_empty() { - this.brace_expansions = expansions; + if let Some(mut expansions) = expand_braces(&this.pattern) { + match expansions.len() { + 0 => {} + // A single expansion is equivalent to a brace-free pattern, so + // walk it directly: it shares the single-pattern fast path, + // `onlyFiles` filtering, and error handling (a missing root + // throws, as it does without the braces). Only genuine + // multi-alternative groups use the per-expansion machinery. + 1 => this.pattern = expansions.pop().unwrap(), + _ => this.brace_expansions = expansions, } } } diff --git a/test/js/bun/glob/scan.test.ts b/test/js/bun/glob/scan.test.ts index b739b333fb6f..e9821720baba 100644 --- a/test/js/bun/glob/scan.test.ts +++ b/test/js/bun/glob/scan.test.ts @@ -693,6 +693,16 @@ describe("brace patterns containing path separators", async () => { const missing = path.join(cwd, "definitely-missing-32596"); expect(() => [...new Glob("{a/b,c/d}").scanSync({ cwd: missing })]).toThrow(); }); + + // A single-alternative group is transparent to its bare pattern, including + // error handling: a missing absolute root throws the same way either form + // does, rather than the 1-element expansion being treated as "one of several + // alternatives" whose missing root is tolerated. POSIX-only (absolute paths). + test.skipIf(isWindows)("single-alternative group matches the bare pattern on a missing root", () => { + const bare = `${cwd}/nope/sub/*.ts`; + expect(() => [...new Glob(bare).scanSync({ dot: true })]).toThrow(); + expect(() => [...new Glob(`{${bare}}`).scanSync({ dot: true })]).toThrow(); + }); }); describe("trailing directory separator", async () => { From 7ee15846024506798a9f2f548c8279197e4a9c38 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:54:04 +0000 Subject: [PATCH 8/8] glob: keep empty brace branches until the whole pattern is assembled expand_braces filtered empty results at every recursion level and only re-injected the empty case when a sub-expansion was entirely empty. An empty alternative (`{X,}`) filling a whole sub-slot therefore lost its empty branch even when an outer prefix/suffix would have made it a valid non-empty pattern: `{a/b,c}{d,}` dropped `a/b` and `c`, and `x{a/b,{c,}}` dropped `x`, both of which match() accepts. Keep empty pieces through the recursion (an empty input now yields one empty string, so the per-level empty-vec fixups are gone) and drop patterns that stay empty once, in expand_braces. Adds unit tests and a scan case for a trailing optional group. --- src/glob/GlobWalker.rs | 42 +++++++++++++++++++++-------------- test/js/bun/glob/scan.test.ts | 4 ++++ 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/glob/GlobWalker.rs b/src/glob/GlobWalker.rs index 310edcd43315..bc7bb4d852dd 100644 --- a/src/glob/GlobWalker.rs +++ b/src/glob/GlobWalker.rs @@ -2361,6 +2361,12 @@ fn find_first_brace_group(pattern: &[u8]) -> Option<(usize, usize, Vec<&[u8]>)> fn expand_braces(pattern: &[u8]) -> Option>> { let mut out: Vec> = Vec::new(); expand_braces_into(pattern, 0, &mut out)?; + // Empty pieces are kept through the recursion so an empty alternative's + // branch survives when an outer prefix/suffix would make the whole pattern + // non-empty (e.g. `{a/b,c}{d,}` must keep `a/b` and `c`). Patterns that + // still collapse to empty after expansion match nothing, so drop them once + // here rather than at every level. + out.retain(|p| !p.is_empty()); Some(out) } @@ -2370,34 +2376,29 @@ fn expand_braces_into(pattern: &[u8], depth: u32, out: &mut Vec>) -> O } let Some((open, close, alts)) = find_first_brace_group(pattern) else { - // No brace group left: this is a concrete pattern. Drop patterns that - // collapse to empty (they match nothing on the filesystem). - if !pattern.is_empty() { - if out.len() >= BRACE_EXPANSION_LIMIT { - return None; - } - out.push(Box::from(pattern)); + // No brace group left: this is a concrete pattern. Keep it even when + // empty so an empty brace branch is not lost mid-recursion; the + // top-level filter in `expand_braces` drops any that stay empty. + if out.len() >= BRACE_EXPANSION_LIMIT { + return None; } + out.push(Box::from(pattern)); return Some(()); }; let prefix = &pattern[..open]; let suffix = &pattern[close + 1..]; - // The suffix is shared across every alternative, so expand it once. + // Expand the shared suffix once. A successful expansion always yields at + // least one entry (an empty input yields one empty string), so the inner + // loops below always run. let mut suffix_expansions: Vec> = Vec::new(); expand_braces_into(suffix, depth + 1, &mut suffix_expansions)?; - if suffix_expansions.is_empty() { - suffix_expansions.push(Box::from(&b""[..])); - } for alt in &alts { // An alternative may itself contain nested braces. let mut alt_expansions: Vec> = Vec::new(); expand_braces_into(alt, depth + 1, &mut alt_expansions)?; - if alt_expansions.is_empty() { - alt_expansions.push(Box::from(&b""[..])); - } for ae in &alt_expansions { for se in &suffix_expansions { if out.len() >= BRACE_EXPANSION_LIMIT { @@ -2407,9 +2408,7 @@ fn expand_braces_into(pattern: &[u8], depth: u32, out: &mut Vec>) -> O combined.extend_from_slice(prefix); combined.extend_from_slice(ae); combined.extend_from_slice(se); - if !combined.is_empty() { - out.push(combined.into_boxed_slice()); - } + out.push(combined.into_boxed_slice()); } } } @@ -2607,4 +2606,13 @@ mod brace_expansion_tests { // `{a/b,}` -> `a/b` and empty; the empty pattern matches nothing and is dropped. assert_eq!(expand("{a/b,}"), vec!["a/b"]); } + + #[test] + fn empty_alternative_survives_outer_prefix_and_suffix() { + // The empty branch of a trailing `{d,}` must be kept because the outer + // group makes it non-empty: `{a/b,c}{d,}` -> a/bd, a/b, cd, c. + assert_eq!(expand("{a/b,c}{d,}"), vec!["a/b", "a/bd", "c", "cd"]); + // Same for an empty branch in a nested alternative: `x{a/b,{c,}}` keeps x. + assert_eq!(expand("x{a/b,{c,}}"), vec!["x", "xa/b", "xc"]); + } } diff --git a/test/js/bun/glob/scan.test.ts b/test/js/bun/glob/scan.test.ts index e9821720baba..afce27f89d05 100644 --- a/test/js/bun/glob/scan.test.ts +++ b/test/js/bun/glob/scan.test.ts @@ -644,6 +644,10 @@ describe("brace patterns containing path separators", async () => { // the shape of `{*/*}` from https://github.com/oven-sh/bun/issues/24000. ["{src/*.ts}", ["src/cli.ts"]], ["pkg/{a/*/*.ts}", ["pkg/a/deep/x.ts"]], + // The empty branch of a trailing `{,x}` must be kept: the `{,x}` expands to + // "" and "x", and the "" branch yields the bare files (the "x" branch adds + // a suffix that matches nothing here). + ["{svc/env.ts,src/cli.ts}{,x}", ["svc/env.ts", "src/cli.ts"]], ]; for (const [pattern, expected] of cases) {