Skip to content
74 changes: 63 additions & 11 deletions src/glob/GlobWalker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1041,11 +1041,22 @@
continue;
}
bun_sys::FileKind::SymLink => {
if self.walker.follow_symlinks {
if !self.walker.eval_impl(&active, entry_name) {
continue;
}
// Follow the link when follow_symlinks is enabled, or
// when the pattern names this segment literally. The
// followSymlinks option governs wildcard traversal,
// not explicitly-spelled path segments.
let follow_active: Option<ComponentSet> =
if self.walker.follow_symlinks {
self.walker
.eval_impl(&active, entry_name)
.then(|| active.clone().expect("OOM"))
} else {
let subset =
self.walker.eval_literal_subset(&active, entry_name);
(subset.count() != 0).then_some(subset)
};

if let Some(follow_active) = follow_active {
let subdir_parts: &[&[u8]] = &[dir_dir_path, entry_name];
let subdir_entry_name = self.walker.join(subdir_parts)?;
let joined = work_item_logical_path(&subdir_entry_name);
Expand All @@ -1055,7 +1066,7 @@

self.walker.workbuf.push(WorkItem::new_symlink(
subdir_entry_name,
active,
follow_active,
entry_start,
));
continue;
Expand Down Expand Up @@ -1129,7 +1140,16 @@
}
}
bun_sys::FileKind::SymLink => {
if self.walker.follow_symlinks {
let follow_active: Option<ComponentSet> =
if self.walker.follow_symlinks {
Some(active.clone().expect("OOM"))
} else {
let subset = self
.walker
.eval_literal_subset(&active, entry_name);
(subset.count() != 0).then_some(subset)
};
if let Some(follow_active) = follow_active {
let subdir_parts: &[&[u8]] = &[dir_dir_path, entry_name];
let subdir_entry_name = self.walker.join(subdir_parts)?;
let joined = work_item_logical_path(&subdir_entry_name);
Expand All @@ -1139,7 +1159,7 @@
.unwrap();
self.walker.workbuf.push(WorkItem::new_symlink(
subdir_entry_name,
active,
follow_active,
entry_start,
));
} else if !self.walker.only_files {
Expand Down Expand Up @@ -1601,13 +1621,12 @@
is_last: bool,
add: &mut bool,
) -> Option<u32> {
if !self.dot && Self::starts_with_dot(entry_name) {
return None;
}
if (self.is_ignored)(entry_name) {
return None;
}

let hidden = !self.dot && Self::starts_with_dot(entry_name);

// Handle double wildcard `**`, this could possibly
// propagate the `**` to the directory's children
if pattern.syntax_hint == SyntaxHint::Double {
Expand All @@ -1622,6 +1641,11 @@
// children
if (component_idx + 1) as usize == self.pattern_components.len() - 1 {
*add = true;
// Matched via the explicit next segment; don't keep the
// wildcard recursion alive through a hidden directory.
if hidden {
return None;
}
return Some(0);
}

Expand All @@ -1631,16 +1655,23 @@
// ^
// AFTER: src/**/node_modules/**/*.js
// ^
return Some(2);
}

// `**` on its own does not match dotfiles without `dot: true`.
if hidden {
return None;
}

Check failure on line 1664 in src/glob/GlobWalker.rs

View check run for this annotation

Claude / Claude Code Review

** recursion leaks into hidden directory via Some(2) advance

When a hidden directory matches the segment after `**` and that segment is not the last component, `match_pattern_dir` returns `Some(2)` without a `hidden` guard, and `eval_dir` then re-adds the `**` index — so `**` keeps recursing *inside* a hidden directory it should never have traversed. With `dot:false`, pattern `**/.dotdir/inner.txt` over a tree containing `.dotdir/foo/.dotdir/inner.txt` now spuriously matches that path; bash/picomatch/minimatch/fast-glob all return nothing because `**` can
Comment thread
robobun marked this conversation as resolved.

if is_last {
*add = true;
}

return Some(0);
}

// For non-`**` components the dot check lives in match_pattern_impl,
// which lets patterns that explicitly start with `.` through.
let matches = self.match_pattern_impl(pattern, entry_name);
if matches {
if is_last {
Expand Down Expand Up @@ -1688,7 +1719,12 @@

fn match_pattern_impl(&self, pattern_component: &Component, filepath: &[u8]) -> bool {
log!("matchPatternImpl: {}", bstr::BStr::new(filepath));
if !self.dot && Self::starts_with_dot(filepath) {
// A pattern segment that itself starts with a literal `.` opts into
// matching dotfiles for that segment, regardless of the `dot` flag.
if !self.dot
&& Self::starts_with_dot(filepath)
&& !Self::starts_with_dot(pattern_component.pattern_slice(&self.pattern))
{
return false;
}
if (self.is_ignored)(filepath) {
Expand Down Expand Up @@ -1793,6 +1829,22 @@
false
}

/// Subset of `active` whose components are non-wildcard literals that
/// match `entry_name`. Used to descend into a symlinked directory that the
/// pattern names explicitly even when `follow_symlinks` is off.
fn eval_literal_subset(&self, active: &ComponentSet, entry_name: &[u8]) -> ComponentSet {
let mut subset = self.make_set();
let mut it = active.iterator::<true, true>();
while let Some(idx) = it.next() {
let comp = &self.pattern_components[idx];
if comp.syntax_hint == SyntaxHint::Literal && self.match_pattern_impl(comp, entry_name)
{
subset.set(idx);
}
}
subset
Comment thread
robobun marked this conversation as resolved.
}

#[inline]
fn normalize_idx(&self, idx: u32) -> u32 {
if (idx as usize) < self.pattern_components.len()
Expand Down
149 changes: 149 additions & 0 deletions test/js/bun/glob/scan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -936,3 +936,152 @@ test("scan handles a cwd with redundant trailing separators when following symli
);
expect(exitCode).toBe(0);
});

// A pattern segment that spells out a leading `.` is an explicit request for
// that dotfile/dot-directory, so the `dot: false` default must not hide it.
// This matches bash, picomatch, minimatch and fast-glob.
describe("explicit dotfile segments match without dot:true", () => {
const norm = (a: string[]) => a.map(p => p.replaceAll("\\", "/")).sort();
const files = {
".dotdir/inner.txt": "x",
".dotdir/.hidden.txt": "x",
".env": "x",
"sub/.dotdir/inner.txt": "x",
"sub/visible.txt": "x",
"visible.txt": "x",
};

test.each([
[".dotdir/inner.txt", [".dotdir/inner.txt"]],
[".dotdir/*.txt", [".dotdir/inner.txt"]],
[".*/inner.txt", [".dotdir/inner.txt"]],
[".env", [".env"]],
[".*", [".env"]],
["**/.dotdir/inner.txt", [".dotdir/inner.txt", "sub/.dotdir/inner.txt"]],
["sub/.dotdir/*.txt", ["sub/.dotdir/inner.txt"]],
])("pattern %j finds explicitly-named dotfiles", (pattern, expected) => {
using dir = tempDir("glob-scan-explicit-dot", files);
const result = Array.from(new Glob(pattern).scanSync({ cwd: String(dir) }));
expect(norm(result)).toEqual(expected.sort());
});

test.each([
["*", ["visible.txt"]],
["*.txt", ["visible.txt"]],
["*/inner.txt", []],
["**/inner.txt", []],
["**/*.txt", ["visible.txt", "sub/visible.txt"]],
])("wildcard pattern %j still hides dotfiles by default", (pattern, expected) => {
using dir = tempDir("glob-scan-wildcard-dot", files);
const result = Array.from(new Glob(pattern).scanSync({ cwd: String(dir) }));
expect(norm(result)).toEqual(expected.sort());
});

test("async scan finds explicitly-named dotfiles", async () => {
using dir = tempDir("glob-scan-explicit-dot-async", files);
const result = await Array.fromAsync(new Glob(".dotdir/inner.txt").scan({ cwd: String(dir) }));
expect(norm(result)).toEqual([".dotdir/inner.txt"]);
});
});

// `followSymlinks` controls whether wildcard traversal descends through
// symlinked directories. A segment that names the symlink literally is an
// explicit path the user wrote; it should resolve regardless, matching
// fast-glob and bash.
describe("literal path segment through a symlinked directory", () => {
const norm = (a: string[]) => a.map(p => p.replaceAll("\\", "/")).sort();

function makeTree(prefix: string) {
const dir = tempDir(prefix, {
"realdir/file.txt": "x",
"realdir/nested/deep.txt": "x",
"plain/file.txt": "x",
});
try {
fs.symlinkSync("realdir", path.join(String(dir), "linkdir"), "dir");
} catch (err: any) {
if (err.code === "EPERM" || err.code === "EACCES") {
dir[Symbol.dispose]();
return null;
}
throw err;
}
return dir;
}

test("literal segment resolves through a symlink with followSymlinks:false", () => {
const dir = makeTree("glob-scan-symlink-literal");
if (dir === null) return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
try {
const cwd = String(dir);
const scan = (p: string) => norm(Array.from(new Glob(p).scanSync({ cwd, followSymlinks: false })));

expect(scan("linkdir/file.txt")).toEqual(["linkdir/file.txt"]);
expect(scan("linkdir/*.txt")).toEqual(["linkdir/file.txt"]);
expect(scan("linkdir/nested/deep.txt")).toEqual(["linkdir/nested/deep.txt"]);
expect(scan("linkdir/**/*.txt")).toEqual(["linkdir/file.txt", "linkdir/nested/deep.txt"]);
} finally {
dir[Symbol.dispose]();
}
});

test("wildcard segment still respects followSymlinks:false", () => {
const dir = makeTree("glob-scan-symlink-wildcard");
if (dir === null) return;
try {
const cwd = String(dir);
const scan = (p: string) => norm(Array.from(new Glob(p).scanSync({ cwd, followSymlinks: false })));

expect(scan("*/file.txt")).toEqual(["plain/file.txt", "realdir/file.txt"]);
expect(scan("**/file.txt")).toEqual(["plain/file.txt", "realdir/file.txt"]);
expect(scan("link*/file.txt")).toEqual([]);
} finally {
dir[Symbol.dispose]();
}
});

test("followSymlinks:true still traverses via wildcards", () => {
const dir = makeTree("glob-scan-symlink-follow");
if (dir === null) return;
try {
const cwd = String(dir);
const scan = (p: string) => norm(Array.from(new Glob(p).scanSync({ cwd, followSymlinks: true })));

expect(scan("*/file.txt")).toEqual(["linkdir/file.txt", "plain/file.txt", "realdir/file.txt"]);
expect(scan("linkdir/file.txt")).toEqual(["linkdir/file.txt"]);
} finally {
dir[Symbol.dispose]();
}
});

test("symlink cycles do not loop when reached via a literal segment", () => {
using dir = tempDir("glob-scan-symlink-cycle", {
"top/file.txt": "x",
});
try {
fs.symlinkSync(".", path.join(String(dir), "top", "loop"), "dir");
} catch (err: any) {
if (err.code === "EPERM" || err.code === "EACCES") return;
throw err;
}
// `top` is reached literally; the `loop -> .` symlink inside is only ever
// reached via `**`, which must not follow it with followSymlinks:false.
const result = norm(
Array.from(new Glob("top/**/*.txt").scanSync({ cwd: String(dir), followSymlinks: false })),
);
expect(result).toEqual(["top/file.txt"]);
});

test("async scan resolves a literal path through a symlink", async () => {
const dir = makeTree("glob-scan-symlink-literal-async");
if (dir === null) return;
try {
const result = await Array.fromAsync(
new Glob("linkdir/file.txt").scan({ cwd: String(dir), followSymlinks: false }),
);
expect(norm(result)).toEqual(["linkdir/file.txt"]);
} finally {
dir[Symbol.dispose]();
}
});
});
Loading