Skip to content
142 changes: 106 additions & 36 deletions src/glob/GlobWalker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1041,23 +1041,26 @@
continue;
}
bun_sys::FileKind::SymLink => {
if self.walker.follow_symlinks {
if !self.walker.eval_impl(&active, entry_name) {
continue;
}

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);
let entry_start: u32 =
u32::try_from(joined.len() - strings::basename(joined).len())
.unwrap();
// 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)
};
Comment thread
robobun marked this conversation as resolved.

self.walker.workbuf.push(WorkItem::new_symlink(
subdir_entry_name,
active,
entry_start,
));
if let Some(follow_active) = follow_active {
self.walker.push_symlink_work_item(
dir_dir_path,
entry_name,
follow_active,
)?;
continue;
}

Expand Down Expand Up @@ -1129,19 +1132,21 @@
}
}
bun_sys::FileKind::SymLink => {
if self.walker.follow_symlinks {
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);
let entry_start: u32 = u32::try_from(
joined.len() - strings::basename(joined).len(),
)
.unwrap();
self.walker.workbuf.push(WorkItem::new_symlink(
subdir_entry_name,
active,
entry_start,
));
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 {
self.walker.push_symlink_work_item(
dir_dir_path,
entry_name,
follow_active,
)?;
} else if !self.walker.only_files {
if self.walker.eval_file(&active, entry_name) {
match self
Expand Down Expand Up @@ -1601,13 +1606,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 +1626,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 @@ -1634,13 +1643,20 @@
return Some(2);
}

// `**` on its own does not match dotfiles without `dot: true`.
if hidden {
return None;
}
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 +1704,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 @@ -1730,6 +1751,7 @@
let mut child = self.make_set();
let comps = &self.pattern_components;
let len: u32 = u32::try_from(comps.len()).expect("int cast");
let hidden = !self.dot && Self::starts_with_dot(entry_name);
let mut it = active.iterator::<true, true>();
while let Some(i) = it.next() {
let idx: u32 = u32::try_from(i).expect("int cast");
Expand All @@ -1751,8 +1773,12 @@
) {
child.set(self.normalize_idx(idx + bump) as usize);
// At `**/X` boundaries, keep the outer `**` alive unless
// idx+2 is itself `**` (whose recursion already covers it).
if bump == 2 && comps[(idx + 2) as usize].syntax_hint != SyntaxHint::Double {
// idx+2 is itself `**` (whose recursion already covers it)
// or the entry is hidden (a `**` must not traverse dotdirs).
if bump == 2
&& !hidden
&& comps[(idx + 2) as usize].syntax_hint != SyntaxHint::Double
{
child.set(idx as usize);
}
}
Expand Down Expand Up @@ -1784,15 +1810,41 @@
}

fn eval_impl(&self, active: &ComponentSet, entry_name: &[u8]) -> bool {
let comps = &self.pattern_components;
let mut it = active.iterator::<true, true>();
while let Some(idx) = it.next() {
if self.match_pattern_impl(&self.pattern_components[idx], entry_name) {
let comp = &comps[idx];
if self.match_pattern_impl(comp, entry_name) {
return true;
}
// Mirror match_pattern_dir's `**/X` peek so the SymLink/Unknown
// pre-filter doesn't drop entries eval_dir would accept.
if comp.syntax_hint == SyntaxHint::Double
&& idx + 1 < comps.len()
&& self.match_pattern_impl(&comps[idx + 1], entry_name)
{
return true;
}
}
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

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

View check run for this annotation

Claude / Claude Code Review

eval_literal_subset doesn't peek past ** — **/linkdir/... with followSymlinks:false skips literally-named symlinks

`eval_literal_subset` only checks currently-active components for `SyntaxHint::Literal` and never peeks at the segment after `**` — so with `followSymlinks:false`, `**/linkdir/file.txt` returns `[]` even though `linkdir` is a literal segment, while `linkdir/file.txt` works. This is the same structural gap already fixed twice in this PR for `eval_dir` (0eb5ff1c) and `eval_impl` (3d206550); `eval_literal_subset` needs the same `**`-peek (when `comp[idx]` is `Double` and `comp[idx+1]` is `Literal`
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 Expand Up @@ -1858,6 +1910,24 @@
Ok(bun_join::<SENTINEL>(subdir_parts))
}

fn push_symlink_work_item(
&mut self,
dir_path: &[u8],
entry_name: &[u8],
active: ComponentSet,
) -> Result<(), AllocError> {
let subdir_entry_name = self.join(&[dir_path, entry_name])?;
let joined = work_item_logical_path(&subdir_entry_name);
let entry_start: u32 =
u32::try_from(joined.len() - strings::basename(joined).len()).unwrap();
self.workbuf.push(WorkItem::new_symlink(
subdir_entry_name,
active,
entry_start,
));
Ok(())
}

#[inline]
fn starts_with_dot(filepath: &[u8]) -> bool {
!filepath.is_empty() && filepath[0] == b'.'
Expand Down
147 changes: 147 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,150 @@ 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",
".dotdir/foo/.dotdir/inner.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"]],
// `**` may advance to an explicit `.dotdir` segment but must not itself
// recurse through a hidden dir: `.dotdir/foo/.dotdir/inner.txt` must not
// match since the only decomposition needs `**` to consume `.dotdir/foo`.
["**/.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.
const canCreateDirSymlink = (() => {
using probe = tempDir("glob-scan-symlink-probe", { "target/x": "" });
try {
fs.symlinkSync("target", path.join(String(probe), "link"), "dir");
return true;
} catch (err: any) {
if (err.code === "EPERM" || err.code === "EACCES") return false;
throw err;
}
})();

describe.skipIf(!canCreateDirSymlink)("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",
});
fs.symlinkSync("realdir", path.join(String(dir), "linkdir"), "dir");
return dir;
}

test("literal segment resolves through a symlink with followSymlinks:false", () => {
using dir = makeTree("glob-scan-symlink-literal");
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"]);
});

test("wildcard segment still respects followSymlinks:false", () => {
using dir = makeTree("glob-scan-symlink-wildcard");
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([]);
});

test("followSymlinks:true still traverses via wildcards", () => {
using dir = makeTree("glob-scan-symlink-follow");
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"]);
});

// The SymLink (and DT_UNKNOWN) readdir arms pre-filter entries through
// eval_impl before eval_dir runs. eval_impl must therefore admit the same
// `**/.X` peek that eval_dir does, or a symlinked `.dotdir` (and a real
// `.dotdir` reported as DT_UNKNOWN on NFS/overlayfs/FUSE) would be dropped
// before the explicit-dot logic ever sees it.
test("**/.dotdir peek works when .dotdir is a symlink", () => {
using dir = tempDir("glob-scan-symlink-dotdir", {
"realdir/inner.txt": "x",
});
fs.symlinkSync("realdir", path.join(String(dir), ".dotdir"), "dir");
const cwd = String(dir);
const scan = (p: string, opts: GlobScanOptions) => norm(Array.from(new Glob(p).scanSync({ cwd, ...opts })));

expect(scan("**/.dotdir/inner.txt", { followSymlinks: true })).toEqual([".dotdir/inner.txt"]);
expect(scan(".dotdir/inner.txt", { followSymlinks: true })).toEqual([".dotdir/inner.txt"]);
expect(scan(".dotdir/inner.txt", { followSymlinks: false })).toEqual([".dotdir/inner.txt"]);
});

test("symlink cycles do not loop when reached via a literal segment", () => {
using dir = tempDir("glob-scan-symlink-cycle", {
"top/file.txt": "x",
});
fs.symlinkSync(".", path.join(String(dir), "top", "loop"), "dir");
// `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 () => {
using dir = makeTree("glob-scan-symlink-literal-async");
const result = await Array.fromAsync(
new Glob("linkdir/file.txt").scan({ cwd: String(dir), followSymlinks: false }),
);
expect(norm(result)).toEqual(["linkdir/file.txt"]);
});
});
Loading