From dbebc41c110de1afbe7544c6d1c1cdb36e4e732e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:06:16 +0000 Subject: [PATCH 1/3] shell: leave a glob pattern that matches nothing unchanged An unquoted word with a glob that matched no file failed the command with "bun: no matches found: ". POSIX 2.13.3, bash and sh leave the word unchanged and pass it to the command. The interpreter now does the same, so `run-p build:*` and `rsync --include=*/` get their arguments. On Windows the glob walker also passed a pattern component with `:`, `|` or a control character to NtQueryDirectoryFile as a name filter. The kernel rejects that filter, so the scan failed with EINVAL. The walker no longer sends a filter for such a component. --- docs/runtime/shell.mdx | 2 +- src/glob/GlobWalker.rs | 5 +- src/runtime/shell/states/Expansion.rs | 36 ++++++++------ test/cli/install/bun-run.test.ts | 22 ++++++++ test/js/bun/glob/scan.test.ts | 13 +++++ test/js/bun/shell/brace.test.ts | 20 ++++++-- test/js/bun/shell/bunshell.test.ts | 72 ++++++++++++++++++++++----- 7 files changed, 134 insertions(+), 36 deletions(-) diff --git a/docs/runtime/shell.mdx b/docs/runtime/shell.mdx index 4cc6ed402917..d4e807a41fe2 100644 --- a/docs/runtime/shell.mdx +++ b/docs/runtime/shell.mdx @@ -22,7 +22,7 @@ await $`cat < ${response} | wc -c`; // 1256 - **Cross-platform**: works on Windows, Linux & macOS. Instead of installing `rimraf` or `cross-env`, you can use Bun Shell. It implements common shell commands like `ls`, `cd`, and `rm` natively. - **Familiar**: Bun Shell is a bash-like shell that supports redirection, pipes, and environment variables. -- **Globs**: Bun Shell supports glob patterns natively, including `**`, `*`, and `{expansion}`. +- **Globs**: Bun Shell supports glob patterns natively, including `**`, `*`, and `{expansion}`. A pattern that matches no file is passed to the command unchanged, as in bash. - **Template literals**: Template literals execute shell commands and interpolate variables and expressions. - **Safety**: Bun Shell escapes all strings by default, preventing shell injection attacks. - **JavaScript interop**: Use `Response`, `ArrayBuffer`, `Blob`, `Bun.file(path)` and other JavaScript objects as stdin, stdout, and stderr. diff --git a/src/glob/GlobWalker.rs b/src/glob/GlobWalker.rs index a1a13ec2d096..e4701685dc95 100644 --- a/src/glob/GlobWalker.rs +++ b/src/glob/GlobWalker.rs @@ -768,7 +768,10 @@ impl<'a, A: Accessor, const SENTINEL: bool> Iterator<'a, A, SENTINEL> { // `<` `>` `"` are NT wildcards; treating them as literals would over-match, // but they are invalid in Windows filenames so such a pattern never matches // anyway. - if strings::index_of_any(slice, b"?[{\\!<>\"").is_some() { + // A filter with `:`, `|` or a control character fails the whole query with EINVAL. + if strings::index_of_any(slice, b"?[{\\!<>\":|").is_some() + || slice.iter().any(|&b| b < 0x20) + { return None; } diff --git a/src/runtime/shell/states/Expansion.rs b/src/runtime/shell/states/Expansion.rs index 65622f3c3d5a..23aab0414eef 100644 --- a/src/runtime/shell/states/Expansion.rs +++ b/src/runtime/shell/states/Expansion.rs @@ -374,8 +374,7 @@ impl Expansion { /// in a single-character class (`[c]`) — or a one-branch brace group for /// a component-leading `!` — which the matcher provably treats as that /// literal character. - /// `current_out` itself is not mutated: the no-match error message and the - /// assignment-position literal fallback keep using the original word. + /// `current_out` itself is not mutated: a word with no match is emitted as written. fn neutralize_glob_metachars(current_out: &[u8], meta_offsets: &[u32]) -> Vec { let mut pattern: Vec = Vec::with_capacity(current_out.len()); let mut next_meta = 0usize; @@ -677,8 +676,6 @@ impl Expansion { }; if result.is_empty() || walk_err.is_some() { - // In variable assignments a no-match glob - // expands to the literal pattern; otherwise it's an error. let parent = interp.as_expansion(this).base.parent; let in_assign = matches!(interp.node(parent).kind(), StateKind::Assign) || matches!( @@ -689,18 +686,25 @@ impl Expansion { ) ); let me = interp.as_expansion_mut(this); - if in_assign { - Self::push_current_out(me); - me.state = ExpansionState::Done; - } else if let Some(err) = walk_err { - let shell_err = match err { - ShellGlobErr::Syscall(e) => ShellErr::new_sys(&e), - ShellGlobErr::Unknown(e) => ShellErr::Custom(e.to_string().into_bytes().into()), - }; - me.state = ExpansionState::Err(Box::new(shell_err)); - } else { - let msg = format!("no matches found: {}", bstr::BStr::new(&me.current_out)); - me.state = ExpansionState::Err(Box::new(ShellErr::Custom(msg.into_bytes().into()))); + match walk_err { + // Assigns cannot print an expansion error, so it keeps the word as written. + Some(err) if !in_assign => { + let shell_err = match err { + ShellGlobErr::Syscall(e) => ShellErr::new_sys(&e), + ShellGlobErr::Unknown(e) => { + ShellErr::Custom(e.to_string().into_bytes().into()) + } + }; + me.state = ExpansionState::Err(Box::new(shell_err)); + } + _ => { + // POSIX 2.13.3: a pattern that matches nothing is left unchanged. + let brace_variants_already_pushed = me.node.get().has_brace_expansion(); + if !brace_variants_already_pushed { + Self::push_current_out(me); + } + me.state = ExpansionState::Done; + } } Yield::Next(this).run(interp); return; diff --git a/test/cli/install/bun-run.test.ts b/test/cli/install/bun-run.test.ts index 25675aca221d..faf509eec2e7 100644 --- a/test/cli/install/bun-run.test.ts +++ b/test/cli/install/bun-run.test.ts @@ -616,6 +616,28 @@ describe.concurrent("bun run", () => { } }); + // https://github.com/oven-sh/bun/issues/10581 (`run-p build:*`) + it("--shell=bun passes a glob with no match to the command as written", async () => { + using dir = tempDir("bun-run-unmatched-glob", { + "package.json": JSON.stringify({ scripts: { build: "bun print-args.js build:* dist/*" } }), + "print-args.js": `console.log(JSON.stringify(process.argv.slice(2)));`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "run", "--shell=bun", "build"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe("$ bun print-args.js build:* dist/*\n"); + expect(stdout).toBe('["build:*","dist/*"]\n'); + expect(exitCode).toBe(0); + }); + const cases = [ ["yarn run", "run"], ["yarn add", "passthrough"], diff --git a/test/js/bun/glob/scan.test.ts b/test/js/bun/glob/scan.test.ts index ab1536606dfb..ec6b7a4c0d8c 100644 --- a/test/js/bun/glob/scan.test.ts +++ b/test/js/bun/glob/scan.test.ts @@ -1180,3 +1180,16 @@ describe.skipIf(!isWindows)("glob scan descends read-only directories", () => { }, ); }); + +// On Windows a pattern component is also the NtQueryDirectoryFile name filter. +// The kernel rejects `:`, `|` and control characters there, and the scan threw +// EINVAL. No file name on Windows can contain them, so the scan matches nothing. +test("a component with a character Windows rejects in a name filter does not throw", async () => { + using dir = tempDir("glob-nt-filter-chars", { "build.txt": "", "sub/build.txt": "" }); + const cwd = String(dir); + for (const pattern of ["build:*", "*:*", "a|b*", "x\ty*", "http://example.com/*", "sub:/*.txt", "*.txt:stream"]) { + expect({ pattern, sync: Array.from(new Glob(pattern).scanSync({ cwd })) }).toEqual({ pattern, sync: [] }); + expect({ pattern, async: await Array.fromAsync(new Glob(pattern).scan({ cwd })) }).toEqual({ pattern, async: [] }); + } + expect(Array.from(new Glob("build*").scanSync({ cwd }))).toEqual(["build.txt"]); +}); diff --git a/test/js/bun/shell/brace.test.ts b/test/js/bun/shell/brace.test.ts index d7702f235a48..9ee744f2b0af 100644 --- a/test/js/bun/shell/brace.test.ts +++ b/test/js/bun/shell/brace.test.ts @@ -325,11 +325,21 @@ describe("comma-less brace group is literal (bash 5.2)", () => { // `{x},*.txt` sets both the brace and glob hints; after the lexer demotes // `{x}` to text the brace-expand count is 0. The original pattern must // still reach the glob walker rather than being taken as the literal word. - using dir = tempDir("shell-brace-literal-glob", { "a.txt": "" }); - const { stderr, exitCode } = await $`echo {x},*.txt`.cwd(String(dir)).nothrow().quiet(); - expect({ stderr: stderr.toString(), exitCode }).toEqual({ - stderr: "bun: no matches found: {x},*.txt\n", - exitCode: 1, + // The walker reads `{x}` as a one-branch group, hence the `x,` fixture. + using dir = tempDir("shell-brace-literal-glob", { "a.txt": "", "x,a.txt": "" }); + const matched = await $`echo {x},*.txt`.cwd(String(dir)).nothrow().quiet(); + expect(matched.stdout.toString().trim().split(" ")).toContain("x,a.txt"); + + // With no match the word is left unchanged, and it is emitted once. + const unmatched = await $`echo {x},*.nomatch`.cwd(String(dir)).nothrow().quiet(); + expect({ + stdout: unmatched.stdout.toString(), + stderr: unmatched.stderr.toString(), + exitCode: unmatched.exitCode, + }).toEqual({ + stdout: "{x},*.nomatch\n", + stderr: "", + exitCode: 0, }); }); }); diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts index 9ec8eb5c0f99..894b6319cf31 100644 --- a/test/js/bun/shell/bunshell.test.ts +++ b/test/js/bun/shell/bunshell.test.ts @@ -925,10 +925,57 @@ booga" describe("glob expansion", () => { // Issue #8403: https://github.com/oven-sh/bun/issues/8403 + // `ls` must get the unmatched pattern as its argument, not an empty argv. TestBuilder.command`ls *.sdfljsfsdf` + .ensureTempDir() + .file("visible.txt", "") .exitCode(1) - .stderr("bun: no matches found: *.sdfljsfsdf\n") - .runAsTest("No matches should fail"); + .stderr("ls: *.sdfljsfsdf: No such file or directory\n") + .runAsTest("No matches passes the pattern to the command"); + + // Issue #10581: https://github.com/oven-sh/bun/issues/10581 + // POSIX 2.13.3: a pattern that matches no pathname is left unchanged. + describe("a pattern with no match is left unchanged", () => { + TestBuilder.command`echo --include=*/ nomatch*.xyz **/*.nomatch` + .ensureTempDir() + .stdout("--include=*/ nomatch*.xyz **/*.nomatch\n") + .runAsTest("builtin"); + + TestBuilder.command`${BUN} run ./code.ts build:* missing/*` + .ensureTempDir() + .file("code.ts", "console.log(JSON.stringify(process.argv.slice(2)))") + .stdout('["build:*","missing/*"]\n') + .runAsTest("subprocess"); + + TestBuilder.command`echo *.js *.nomatch` + .ensureTempDir() + .file("foo.js", "foo") + .stdout("foo.js *.nomatch\n") + .runAsTest("next to a pattern that matches"); + + TestBuilder.command`echo {a,b}*.nomatch` + .ensureTempDir() + .stdout("a*.nomatch b*.nomatch\n") + .runAsTest("brace variants"); + + TestBuilder.command`rm -rf dist/* && echo *.nomatch | cat && echo $(echo *.nomatch)` + .ensureTempDir() + .stdout("*.nomatch\n*.nomatch\n") + .runAsTest("does not fail the command"); + + TestBuilder.command`export FOO=*.nomatch; echo $FOO` + .ensureTempDir() + .stdout("*.nomatch\n") + .runAsTest("export"); + + // Windows does not allow `*` in a file name. + if (isPosix) { + TestBuilder.command`echo hi > *.nomatch` + .ensureTempDir() + .fileEquals("*.nomatch", "hi\n") + .runAsTest("redirect target"); + } + }); TestBuilder.command`FOO=*.lolwut; echo $FOO` .stdout("*.lolwut\n") @@ -963,15 +1010,13 @@ booga" .file("f.txt", "f") .directory("sub") .file("sub/deep.txt", "deep") - .exitCode(1) - .stderr("bun: no matches found: **/*\n") + .stdout("**/*\n") .runAsTest("injected ** does not recurse"); TestBuilder.command`echo a${"?"}*` .ensureTempDir() .file("ax.txt", "ax") - .exitCode(1) - .stderr("bun: no matches found: a?*\n") + .stdout("a?*\n") .runAsTest("injected ? is literal"); TestBuilder.command`echo ${"!keep"}*` @@ -1024,8 +1069,8 @@ booga" // A run of interpolated `!` longer than the matcher's brace-nesting // limit (10) must still match literally: neutralizing every `!` as its - // own `{!}` group used to overflow the brace stack and turn the whole - // word into "no matches found". + // own `{!}` group used to overflow the brace stack and make the whole + // word match nothing. const bangRun = Buffer.alloc(11, "!").toString(); TestBuilder.command`echo prefix${bangRun}*` @@ -1058,7 +1103,7 @@ booga" { const r = await $\`echo \${missing}/*\`.nothrow().quiet(); - results.push({ exitCode: r.exitCode, stderr: r.stderr.toString() }); + results.push({ exitCode: r.exitCode, stdout: r.stdout.toString(), stderr: r.stderr.toString() }); } try { @@ -1084,8 +1129,8 @@ booga" const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); expect(JSON.parse(stdout)).toEqual([ - { exitCode: 1, stderr: `bun: no matches found: ${missing}/*\n` }, - { threw: true, exitCode: 1, stderr: `bun: no matches found: ${missing}/*\n` }, + { exitCode: 0, stdout: `${missing}/*\n`, stderr: "" }, + { threw: false }, { exitCode: 0, stdout: `${missing}/*\n` }, ]); expect(exitCode).toBe(0); @@ -1097,9 +1142,10 @@ booga" mkdirSync(noaccess); chmodSync(noaccess, 0o000); try { - const { stderr, exitCode } = await $`echo ${noaccess}/*`.quiet().nothrow(); + // Unlike a pattern with no match, this is an error and not the literal word. + const { stdout, stderr, exitCode } = await $`echo ${noaccess}/*`.quiet().nothrow(); expect(stderr.toString()).toContain(`bun: Permission denied: ${noaccess}`); - expect(stderr.toString()).not.toContain("no matches found"); + expect(stdout.toString()).toBe(""); expect(exitCode).toBe(1); const assign = await $`FOO=${noaccess}/*; echo $FOO`.quiet().nothrow(); From c808775607ade958d20f97f1aeff12841f350ae0 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 03:07:17 +0000 Subject: [PATCH 2/3] [autofix.ci] apply automated fixes --- test/js/bun/shell/bunshell.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts index 894b6319cf31..bc7f439c971a 100644 --- a/test/js/bun/shell/bunshell.test.ts +++ b/test/js/bun/shell/bunshell.test.ts @@ -963,10 +963,7 @@ booga" .stdout("*.nomatch\n*.nomatch\n") .runAsTest("does not fail the command"); - TestBuilder.command`export FOO=*.nomatch; echo $FOO` - .ensureTempDir() - .stdout("*.nomatch\n") - .runAsTest("export"); + TestBuilder.command`export FOO=*.nomatch; echo $FOO`.ensureTempDir().stdout("*.nomatch\n").runAsTest("export"); // Windows does not allow `*` in a file name. if (isPosix) { From 5fd2d5f39e4dc5639ed6cbdbc019359c279a5177 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 22 Sep 2026 04:47:07 +0000 Subject: [PATCH 3/3] shell: assert the exact brace + glob output, document the unreadable directory case --- docs/runtime/shell.mdx | 2 +- test/js/bun/shell/brace.test.ts | 20 ++++++++------------ 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/docs/runtime/shell.mdx b/docs/runtime/shell.mdx index d4e807a41fe2..112f596ffabf 100644 --- a/docs/runtime/shell.mdx +++ b/docs/runtime/shell.mdx @@ -22,7 +22,7 @@ await $`cat < ${response} | wc -c`; // 1256 - **Cross-platform**: works on Windows, Linux & macOS. Instead of installing `rimraf` or `cross-env`, you can use Bun Shell. It implements common shell commands like `ls`, `cd`, and `rm` natively. - **Familiar**: Bun Shell is a bash-like shell that supports redirection, pipes, and environment variables. -- **Globs**: Bun Shell supports glob patterns natively, including `**`, `*`, and `{expansion}`. A pattern that matches no file is passed to the command unchanged, as in bash. +- **Globs**: Bun Shell supports glob patterns natively, including `**`, `*`, and `{expansion}`. A pattern that matches no file is passed to the command unchanged, as in bash. A pattern under a directory that cannot be read fails the command. - **Template literals**: Template literals execute shell commands and interpolate variables and expressions. - **Safety**: Bun Shell escapes all strings by default, preventing shell injection attacks. - **JavaScript interop**: Use `Response`, `ArrayBuffer`, `Blob`, `Bun.file(path)` and other JavaScript objects as stdin, stdout, and stderr. diff --git a/test/js/bun/shell/brace.test.ts b/test/js/bun/shell/brace.test.ts index 9ee744f2b0af..acc443c3fc8c 100644 --- a/test/js/bun/shell/brace.test.ts +++ b/test/js/bun/shell/brace.test.ts @@ -327,19 +327,15 @@ describe("comma-less brace group is literal (bash 5.2)", () => { // still reach the glob walker rather than being taken as the literal word. // The walker reads `{x}` as a one-branch group, hence the `x,` fixture. using dir = tempDir("shell-brace-literal-glob", { "a.txt": "", "x,a.txt": "" }); - const matched = await $`echo {x},*.txt`.cwd(String(dir)).nothrow().quiet(); - expect(matched.stdout.toString().trim().split(" ")).toContain("x,a.txt"); + const run = async (cmd: ReturnType) => { + const { stdout, stderr, exitCode } = await cmd.cwd(String(dir)).nothrow().quiet(); + return { stdout: stdout.toString(), stderr: stderr.toString(), exitCode }; + }; + + // A brace word emits its variants next to the matches, here the word itself. + expect(await run($`echo {x},*.txt`)).toEqual({ stdout: "{x},*.txt x,a.txt\n", stderr: "", exitCode: 0 }); // With no match the word is left unchanged, and it is emitted once. - const unmatched = await $`echo {x},*.nomatch`.cwd(String(dir)).nothrow().quiet(); - expect({ - stdout: unmatched.stdout.toString(), - stderr: unmatched.stderr.toString(), - exitCode: unmatched.exitCode, - }).toEqual({ - stdout: "{x},*.nomatch\n", - stderr: "", - exitCode: 0, - }); + expect(await run($`echo {x},*.nomatch`)).toEqual({ stdout: "{x},*.nomatch\n", stderr: "", exitCode: 0 }); }); });