Skip to content
Merged
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
27 changes: 16 additions & 11 deletions src/runtime/shell/states/Expansion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -649,18 +649,17 @@ impl Expansion {
) {
use crate::shell::dispatch_tasks::ShellGlobErr;
log!("Expansion {} onGlobWalkDone", this);
if let Some(err) = err {
let shell_err = match err {
ShellGlobErr::Syscall(e) => ShellErr::new_sys(&e),
ShellGlobErr::Unknown(e) => ShellErr::Custom(e.to_string().into_bytes().into()),
};
interp.throw(shell_err);
interp.as_expansion_mut(this).state = ExpansionState::Done;
Yield::Next(this).run(interp);
return;
}
let walk_err = match err {
Some(ShellGlobErr::Syscall(e))
if matches!(e.get_errno(), bun_sys::E::ENOENT | bun_sys::E::ENOTDIR) =>
{
log!("Expansion {} glob walk failed: {}", this, e);
None
}
other => other,
};

if result.is_empty() {
if result.is_empty() || walk_err.is_some() {
Comment thread
robobun marked this conversation as resolved.
// Spec lines 559-578: 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;
Expand All @@ -676,6 +675,12 @@ impl Expansion {
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));
Comment thread
robobun marked this conversation as resolved.
} 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())));
Expand Down
65 changes: 64 additions & 1 deletion test/js/bun/shell/bunshell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ afterAll(async () => {
});

const BUN = bunExe();
const isRoot = process.getuid?.() === 0;

describe("bunshell", () => {
describe("exit codes", async () => {
Expand Down Expand Up @@ -791,6 +792,68 @@ booga"
})
.runAsTest("long leading run of injected ! stays literal");
});

test("glob on a nonexistent absolute directory does not crash the process", async () => {
const missing = join(tmpdirSync(), "does-not-exist").replaceAll("\\", "/");
const script = `
import { $ } from "bun";
const missing = ${JSON.stringify(missing)};
const results = [];

{
const r = await $\`echo \${missing}/*\`.nothrow().quiet();
results.push({ exitCode: r.exitCode, stderr: r.stderr.toString() });
}

try {
await $\`echo \${missing}/*\`.quiet();
results.push({ threw: false });
} catch (e) {
results.push({ threw: true, exitCode: e.exitCode, stderr: e.stderr.toString() });
}

{
const r = await $\`FOO=\${missing}/*; echo $FOO\`.nothrow().quiet();
results.push({ exitCode: r.exitCode, stdout: r.stdout.toString() });
}

console.log(JSON.stringify(results));
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", script],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
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` },
]);
expect(exitCode).toBe(0);
});

test.if(isPosix && !isRoot)("glob over an unreadable directory reports the real error", async () => {
const dir = tempDirWithFiles("glob-eacces", { "placeholder.txt": "" });
const noaccess = join(dir, "noaccess").replaceAll("\\", "/");
mkdirSync(noaccess);
chmodSync(noaccess, 0o000);
try {
const { stderr, exitCode } = await $`echo ${noaccess}/*`.quiet().nothrow();
expect(stderr.toString()).toContain(`bun: Permission denied: ${noaccess}`);
expect(stderr.toString()).not.toContain("no matches found");
expect(exitCode).toBe(1);

const assign = await $`FOO=${noaccess}/*; echo $FOO`.quiet().nothrow();
expect(assign.stderr.toString()).toBe("");
expect(assign.stdout.toString()).toBe(`${noaccess}/*\n`);
expect(assign.exitCode).toBe(0);
} finally {
chmodSync(noaccess, 0o755);
}
});
});

describe("brace expansion", () => {
Expand Down Expand Up @@ -980,7 +1043,7 @@ booga"
// handleChangeCwdErr's `else` arm previously returned `.failed` without writing
// to stderr or calling done(), so any errno other than NOTDIR/NOENT/NAMETOOLONG
// (e.g. EACCES, ELOOP) left the shell promise unresolved forever.
test.if(isPosix)("cd with EACCES fails with exit code 1 instead of hanging", async () => {
test.if(isPosix && !isRoot)("cd with EACCES fails with exit code 1 instead of hanging", async () => {
const dir = tempDirWithFiles("cd-eacces", { "placeholder.txt": "" });
const noaccess = join(dir, "noaccess");
mkdirSync(noaccess);
Expand Down
Loading