Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/runtime/shell.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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.
Expand Down
5 changes: 4 additions & 1 deletion src/glob/GlobWalker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
36 changes: 20 additions & 16 deletions src/runtime/shell/states/Expansion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8> {
let mut pattern: Vec<u8> = Vec::with_capacity(current_out.len());
let mut next_meta = 0usize;
Expand Down Expand Up @@ -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!(
Expand All @@ -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 => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.
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));
}
_ => {
Comment thread
robobun marked this conversation as resolved.
// 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);
Comment thread
robobun marked this conversation as resolved.
}
me.state = ExpansionState::Done;
}
}
Yield::Next(this).run(interp);
return;
Expand Down
22 changes: 22 additions & 0 deletions test/cli/install/bun-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
13 changes: 13 additions & 0 deletions test/js/bun/glob/scan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
});
18 changes: 12 additions & 6 deletions test/js/bun/shell/brace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,11 +325,17 @@ 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 run = async (cmd: ReturnType<typeof $>) => {
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.
expect(await run($`echo {x},*.nomatch`)).toEqual({ stdout: "{x},*.nomatch\n", stderr: "", exitCode: 0 });
});
});
69 changes: 56 additions & 13 deletions test/js/bun/shell/bunshell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -925,10 +925,54 @@ 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")
Expand Down Expand Up @@ -963,15 +1007,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"}*`
Expand Down Expand Up @@ -1024,8 +1066,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}*`
Expand Down Expand Up @@ -1058,7 +1100,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 {
Expand All @@ -1084,8 +1126,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);
Expand All @@ -1097,9 +1139,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();
Expand Down
Loading