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
8 changes: 7 additions & 1 deletion src/runtime/shell/builtin/cp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::node::PathLike;
use crate::shell::builtin::{Builtin, BuiltinState, IoKind, Kind};
use crate::shell::interpreter::{
EventLoopHandle, FlagParser, Interpreter, NodeId, OutputSrc, OutputTask, OutputTaskVTable,
ParseFlagResult, ShellTask, parse_flags, unsupported_flag,
ParseFlagResult, ShellTask, parse_flags, reject_empty_path, unsupported_flag,
};
use crate::shell::io_writer::{ChildPtr, WriterTag};
use crate::shell::yield_::Yield;
Expand Down Expand Up @@ -607,6 +607,12 @@ impl ShellCpTask {
) -> Option<ShellErr> {
use resolve_path::{Platform, platform};

if let Err(e) = reject_empty_path(&self.src, bun_sys::Tag::copyfile)
.and_then(|()| reject_empty_path(&self.tgt, bun_sys::Tag::copyfile))
{
return Some(ShellErr::new_sys(&e));
}

let mut buf2 = bun_paths::path_buffer_pool::get();
let mut buf3 = bun_paths::path_buffer_pool::get();
// We have to give an absolute path to our cp implementation for it to
Expand Down
6 changes: 5 additions & 1 deletion src/runtime/shell/builtin/mkdir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::shell::ExitCode;
use crate::shell::builtin::{Builtin, BuiltinState, IoKind, Kind};
use crate::shell::interpreter::{
EventLoopHandle, FlagParser, Interpreter, NodeId, OutputSrc, OutputTask, OutputTaskVTable,
ParseFlagResult, ShellTask, parse_flags, unsupported_flag,
ParseFlagResult, ShellTask, parse_flags, reject_empty_path, unsupported_flag,
};
use crate::shell::io_writer::{ChildPtr, WriterTag};
use crate::shell::yield_::Yield;
Expand Down Expand Up @@ -296,6 +296,10 @@ impl ShellMkdirTask {

fn run_from_thread_pool(this: &mut ShellMkdirTask) {
use bun_paths::{Platform, platform, resolve_path};
if let Err(e) = reject_empty_path(&this.filepath, bun_sys::Tag::mkdir) {
this.err = Some(e);
return;
}
// We have to give an absolute path to our mkdir implementation for it
// to work with cwd.
let mut spill = Vec::new();
Expand Down
6 changes: 5 additions & 1 deletion src/runtime/shell/builtin/mv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ use bun_ptr::BackRef;

use crate::shell::ExitCode;
use crate::shell::builtin::{Builtin, BuiltinState, IoKind, Kind};
use crate::shell::interpreter::{Interpreter, NodeId, ShellTask, closefd, shell_openat};
use crate::shell::interpreter::{
Interpreter, NodeId, ShellTask, closefd, reject_empty_path, shell_openat,
};
use crate::shell::io_writer::{ChildPtr, WriterTag};
use crate::shell::yield_::Yield;

Expand Down Expand Up @@ -484,6 +486,8 @@ impl ShellMvBatchedTask {
dst_dir: bun_sys::Fd,
dst: &ZStr,
) -> Result<(), bun_sys::Error> {
reject_empty_path(src.as_bytes(), bun_sys::Tag::rename)?;
reject_empty_path(dst.as_bytes(), bun_sys::Tag::rename)?;
match bun_sys::renameat(src_dir, src, dst_dir, dst) {
Err(e) if e.get_errno() == bun_sys::E::EXDEV => {
Self::move_across_devices(src_dir, src, dst_dir, dst).map_err(|e| {
Expand Down
10 changes: 8 additions & 2 deletions src/runtime/shell/builtin/rm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use bun_sys::{E, FdExt, dir_iterator};
use crate::shell::ExitCode;
use crate::shell::builtin::{Builtin, IoKind, Kind};
use crate::shell::interpreter::{
EventLoopHandle, Interpreter, NodeId, ShellTask, WorkPoolTask, shell_openat,
EventLoopHandle, Interpreter, NodeId, ShellTask, WorkPoolTask, reject_empty_path, shell_openat,
};
use crate::shell::io_writer::{ChildPtr, WriterTag};
use crate::shell::yield_::Yield;
Expand Down Expand Up @@ -181,6 +181,10 @@ impl Rm {

for i in args_start..argc {
let path = Builtin::of(interp, cmd).arg_bytes(i);
// Joined below, `""` would resolve to the cwd itself.
if path.is_empty() {
continue;
Comment on lines +184 to +186

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 pre-existing, not blocking: Users running rm -r . or rm -r .. in Bun's shell still have the cwd (or its parent) emptied on Linux, while coreutils refuses both. The new empty-operand skip at rm.rs:184-186 fixes only the "" spelling of operand-names-the-cwd; the root check joins ./.. onto the cwd, normalizes them away, and lets them reach the worker, where unlinkat/openat act on the directory itself. Fix: the root check must reject every operand that resolves to the cwd or an ancestor by name — "", ., .., and ./-style spellings — with "refusing to remove '.' or '..' directory" like coreutils, rather than special-casing "" alone.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Trigger: $\rm -rf .`orrm -rf ..via Bun's shell, reaching Rm::next at src/runtime/shell/builtin/rm.rs:182-204. The loop now skips "" (rm.rs:184-186) but for.it joins cwd+"." (rm.rs:190-193), normalize_string_buf collapses it back to the cwd, dirname is non-empty unless the cwd is top-level, so the check passes and the worker opens.and recursively unlinks every child (rm.rs:1019-1085). The existing test at test/js/bun/shell/commands/rm.test.ts:400-419 documents that on Linuxrm -rf .deletes file.txt and sub. The PR frames its bug as an operand that names the cwd; REVIEW.md asks for the whole input class (empty, lone.) in the same PR, and ./..are the sibling inputs with the identical consequence (cwd contents destroyed). Remedy: in the root-check loop, refuse any operand whose last component is.or..` (and the empty one) before scheduling.

Verification: pre-existing. A user runs rm -r . (or ..) in Bun's shell on Linux. The root check at src/runtime/shell/builtin/rm.rs:182-208 only skips ""; for . it joins onto the cwd and normalize_string_buf collapses it back to /cwd, so the operand reaches the worker, where dir_iterator::iterate(fd) (1048) unlinks every entry. The base commit has identical handling, so merging makes nothing worse.

}
let resolved: &[u8] = if Platform::AUTO.is_absolute(path) {
path
} else {
Expand Down Expand Up @@ -1221,7 +1225,9 @@ impl ShellRmTask {
vtable: &mut V,
) -> bun_sys::Maybe<()> {
let dirfd = self.cwd;
match bun_sys::unlinkat_with_flags(dirfd, path, 0) {
match reject_empty_path(path.as_bytes(), bun_sys::Tag::unlink)
.and_then(|()| bun_sys::unlinkat_with_flags(dirfd, path, 0))
{
Ok(()) => self.verbose_deleted(parent_dir_task, path.as_bytes()),
Err(e) => match e.get_errno() {
E::ENOENT => {
Comment on lines +1228 to 1233

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 pre-existing, not blocking: Scripts running rm -fv "$x" with an unset $x get a bare empty line on stdout as if something were deleted, and exit 0. At src/runtime/shell/builtin/rm.rs:1228 reject_empty_path turns "" into ENOENT, and the ENOENT arm at rm.rs:1235 calls verbose_deleted with the empty name when -f is set, which appends "" plus a newline to the deleted list. coreutils prints nothing for a missing operand under -fv. Fix: under -f, skip verbose_deleted for ENOENT (nothing was removed) so no operand, empty or missing, is reported as deleted; at minimum never emit a blank line for "". The new rm tests cover -f and -rf but not -v, so this variant is unpinned.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Trigger: rm -fv "" (commonly rm -fv "$unset_var") through the Bun shell. remove_entry_file at src/runtime/shell/builtin/rm.rs:1228 now returns ENOENT for the empty operand; the ENOENT arm at rm.rs:1233-1235 sees opts.force and returns self.verbose_deleted(parent_dir_task, ""), and verbose_deleted at rm.rs:852-872 pushes the empty slice and a newline into deleted_entries, which is flushed to stdout. The user sees a lone "\n" on stdout with exit 0, i.e. a deletion report for nothing. On the base branch this population did not reach that arm: on Windows the *at() emulation acted on the cwd and errored, and in a top-level cwd on every platform the root check at rm.rs:183 refused with exit 1; the diff routes both into the -f/-v report path. The new tests at test/js/bun/shell/commands/rm.test.ts:496-500 cover -f and -rf without -v, so the blank line is neither pinned nor excluded. Remedy: do not call verbose_deleted on the ENOENT+force path (coreutils reports nothing), or at least not for an empty operand.

Verification: Pre-existing: on POSIX the base already prints the same lone newline by the same route. Trigger: rm -fv "" in a non-top-level cwd. In src/runtime/shell/builtin/rm.rs the E::ENOENT arm at 1233-1236 calls verbose_deleted under force, so stdout gets "\n" and the exit code is 0. The base commit's remove_entry_file has the identical arm, and unlinkat(cwd, "") already returns ENOENT.

Expand Down
6 changes: 5 additions & 1 deletion src/runtime/shell/builtin/touch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use crate::shell::ExitCode;
use crate::shell::builtin::{Builtin, BuiltinState, IoKind, Kind};
use crate::shell::interpreter::{
EventLoopHandle, FlagParser, Interpreter, NodeId, OutputSrc, OutputTask, OutputTaskVTable,
ParseFlagResult, ShellTask, parse_flags, unsupported_flag,
ParseFlagResult, ShellTask, parse_flags, reject_empty_path, unsupported_flag,
};
use crate::shell::io_writer::{ChildPtr, WriterTag};
use crate::shell::yield_::Yield;
Expand Down Expand Up @@ -265,6 +265,10 @@ impl ShellTouchTask {
pub(crate) fn run_from_thread_pool(this: &mut ShellTouchTask) {
use bun_paths::resolve_path::{self, Platform, platform};
use bun_sys::FdExt as _;
if let Err(e) = reject_empty_path(&this.filepath, bun_sys::Tag::utime) {
this.err = Some(e);
return;
}
// We have to give an absolute path. An operand that does not fit the
// path buffer is still passed on whole, so the OS reports ENAMETOOLONG
// for it like for any other operand.
Expand Down
9 changes: 9 additions & 0 deletions src/runtime/shell/interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2416,6 +2416,14 @@ pub(crate) fn shell_lstatat(dir: Fd, path_: &bun_core::ZStr) -> bun_sys::Result<
}
}

/// Resolved by the shell (cwd join, or the Windows `*at()` emulation), `""` would name the cwd.
pub(crate) fn reject_empty_path(path: &[u8], syscall: bun_sys::Tag) -> bun_sys::Result<()> {
if path.is_empty() {
return Err(bun_sys::Error::from_code(bun_sys::E::ENOENT, syscall));
}
Ok(())
}

/// POSIX: `bun_sys::openat` with the error tagged `.with_path(path)`.
/// Windows: for `O_DIRECTORY` opens, rewrite POSIX-absolute paths via
/// `shell_get_path` and use `openDirAtWindowsA(.iterable=true)` +
Expand All @@ -2427,6 +2435,7 @@ pub(crate) fn shell_openat(
flags: i32,
perm: bun_sys::Mode,
) -> bun_sys::Result<Fd> {
reject_empty_path(path.as_bytes(), bun_sys::Tag::open)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): the cat builtin's empty-operand behavior that this guard changes is not pinned by any test. The PR lists cat "" (Windows: exit 21, silent) as fixed through shell_openat, but the new tests only cover ls, rm, mv, cp, mkdir and touch; cat's error path at src/runtime/shell/builtin/cat.rs:200 (task_error_to_string + write_failing_error(..., 1)) is never exercised with "". Fix: add a cat "" case (child bun with BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1 like the cp tests, or under the existing cat builtin suite) asserting stderr cat: No such file or directory\n, exit 1, and that a following non-empty operand is still printed.

Why this was flagged

src/runtime/shell/interpreter.rs:2438 adds reject_empty_path to shell_openat, which is the open cat uses at src/runtime/shell/builtin/cat.rs:200. The PR description names cat "" as one of the fixed commands (on Windows it exited 21 silently). No test in the diff runs cat with an empty operand; test/js/bun/shell/commands/ has no cat.test.ts and bunshell.test.ts is untouched. A later change to cat's error formatting or to the Windows open emulation could regress cat "" without any test failing. Repository review rules require every sibling entry point receiving the same fix to ship a test.

Verification: nit. Triggering condition: any future change to the Windows shell_openat emulation or cat's error path regresses cat "" silently. cat opens every operand through shell_openat at src/runtime/shell/builtin/cat.rs:200. The diff stat shows test changes only in cp/ls/mkdir/mv/rm/touch test files; a grep of test/js/bun/shell/ for cat "" finds no match.

#[cfg(windows)]
{
use bun_sys::FdExt;
Expand Down
55 changes: 53 additions & 2 deletions test/js/bun/shell/commands/cp.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { $ } from "bun";
import { shellInternals } from "bun:internal-for-testing";
import { describe, expect } from "bun:test";
import { tempDirWithFiles } from "harness";
import { describe, expect, test } from "bun:test";
import { bunEnv, tempDir, tempDirWithFiles } from "harness";
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { bunExe, createTestBuilder } from "../test_builder";
import { sortedShellOutput } from "../util";
const { builtinDisabled } = shellInternals;
Expand Down Expand Up @@ -173,6 +175,55 @@ describe.if(!builtinDisabled("cp"))("bunshell cp", async () => {
});
});

// The builtin is the default only on Windows; on POSIX it is enabled by an env
// var that is read once per process, so each of these runs cp in a child bun.
describe.concurrent("bunshell cp with an empty operand", () => {
const builtinEnv = { ...bunEnv, BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS: "1" };

/** Runs `command` through the shell in `cwd`; the child prints cp's exit code, then its stderr. */
async function cp(cwd: string, command: string) {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const result = await Bun.$\`\${{ raw: ${JSON.stringify(command)} }}\`.nothrow().quiet();
process.stdout.write(result.exitCode + "\\n" + result.stderr.toString() + result.stdout.toString());`,
],
cwd,
env: builtinEnv,
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode };
}

const ENOENT = "cp: No such file or directory\n";

// An empty operand used to be joined onto the shell's cwd, which resolved to
// the cwd itself: `cp "" out` complained that "" is a directory, `cp -R "" out`
// copied the cwd into itself, and `cp f ""` copied f onto itself and exited 0.
test.each([
['cp "" out', ENOENT],
['cp -R "" out', ENOENT],
['cp f ""', ENOENT],
['cp -R f ""', ENOENT],
['cp f g ""', ENOENT + ENOENT],
])("%s fails with ENOENT and copies nothing", async (command, stderr) => {
using dir = tempDir("shell-cp-empty-operand", { f: "F\n", g: "G\n" });

expect(await cp(String(dir), command)).toEqual({ stdout: `1\n${stderr}`, stderr: "", exitCode: 0 });
expect(readdirSync(String(dir)).sort()).toEqual(["f", "g"]);
expect(readFileSync(join(String(dir), "f"), "utf8")).toBe("F\n");
});

test("the other sources are still copied when one of them is empty", async () => {
using dir = tempDir("shell-cp-empty-operand-one-of-many", { f: "F\n", out: {} });

expect(await cp(String(dir), 'cp "" f out')).toEqual({ stdout: `1\n${ENOENT}`, stderr: "", exitCode: 0 });
expect(readFileSync(join(String(dir), "out", "f"), "utf8")).toBe("F\n");
});
});

function expectSortedOutput(expected: string) {
return (stdout: string, tempdir: string) =>
expect(sortedShellOutput(stdout).join("\n")).toEqual(
Expand Down
20 changes: 20 additions & 0 deletions test/js/bun/shell/commands/ls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,26 @@ describe.concurrent("bunshell ls", () => {
.run();
});

// On Windows the shell's openat emulation used to resolve an empty operand
// to the cwd itself, so `ls ""` listed the cwd and exited 0.
test.each(['ls ""', 'ls -R ""'])("%s fails with ENOENT", async cmd => {
await TestBuilder.command`${{ raw: cmd }}`
.file("a", "")
.exitCode(1)
.stdout("")
.stderr("ls: No such file or directory\n")
.run();
});

test("the other operands are still listed when one is empty", async () => {
await TestBuilder.command`ls a ""`
.file("a", "")
.exitCode(1)
.stdout("a\n")
.stderr("ls: No such file or directory\n")
.run();
});

test("invalid flag", async () => {
await TestBuilder.command`ls -z`
.exitCode(1)
Expand Down
44 changes: 43 additions & 1 deletion test/js/bun/shell/commands/mkdir.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { expect, test } from "bun:test";
import { $ } from "bun";
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isWindows, tempDir } from "harness";
import { readdirSync } from "node:fs";
import { join } from "node:path";

// A relative operand was joined onto the cwd in a fixed 4096-byte buffer, so an
Expand Down Expand Up @@ -75,3 +77,43 @@ test("operands longer than the path buffers are reported, not a crash", async ()
});
expect(exitCode).toBe(0);
});

$.nothrow();

const ENOENT = "mkdir: No such file or directory\n";

describe.concurrent("bunshell mkdir", () => {
// An empty operand used to be joined onto the shell's cwd, which resolved to
// the cwd itself: `mkdir ""` reported the cwd as existing and `mkdir -p ""`
// exited 0.
test.each([
['mkdir ""', () => $`mkdir ""`],
['mkdir ${""}', () => $`mkdir ${""}`],
['mkdir -p ""', () => $`mkdir -p ""`],
['mkdir -pv ""', () => $`mkdir -pv ""`],
['mkdir -v ""', () => $`mkdir -v ""`],
])("%s fails with ENOENT", async (_name, command) => {
using dir = tempDir("mkdir-empty", {});
const cwd = String(dir);

const { stdout, stderr, exitCode } = await command().cwd(cwd).quiet();

expect(stdout.toString()).toBe("");
expect(stderr.toString()).toBe(ENOENT);
expect(exitCode).toBe(1);
expect(readdirSync(cwd)).toEqual([]);
});

test("the other operands are still created when one is empty", async () => {
using dir = tempDir("mkdir-empty-multi", {});
const cwd = String(dir);

const { stdout, stderr, exitCode } = await $`mkdir -p a "" b/c`.cwd(cwd).quiet();

expect(stdout.toString()).toBe("");
expect(stderr.toString()).toBe(ENOENT);
expect(exitCode).toBe(1);
expect(readdirSync(cwd).sort()).toEqual(["a", "b"]);
expect(readdirSync(join(cwd, "b"))).toEqual(["c"]);
});
});
40 changes: 40 additions & 0 deletions test/js/bun/shell/commands/mv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,46 @@ describe("mv", async () => {
.stderr("mv: a: Not a directory\n")
.runAsTest("move dir -> file fails");

// On Windows the shell's fd-relative open/rename emulation used to resolve an
// empty operand to the cwd itself: `mv a ""` moved a into the cwd (a no-op
// that exited 0) and `mv "" b` tried to rename the cwd.
describe("empty operand", () => {
TestBuilder.command`mv a ""`
.ensureTempDir()
.file("a", "A\n")
.exitCode(2 /* ENOENT */)
.stderr("mv: No such file or directory\n")
.fileEquals("a", "A\n")
.runAsTest("as the destination fails");

TestBuilder.command`mv "" b`
.ensureTempDir()
.file("a", "A\n")
.exitCode(2 /* ENOENT */)
.stderr("mv: No such file or directory\n")
.fileEquals("a", "A\n")
.doesNotExist("b")
.runAsTest("as the source fails");

TestBuilder.command`mkdir d; mv "" d`
.ensureTempDir()
.file("a", "A\n")
.exitCode(2 /* ENOENT */)
.stderr("mv: d: No such file or directory\n")
.fileEquals("a", "A\n")
.runAsTest("as a source moved into a directory fails");

TestBuilder.command`mv a b ""`
.ensureTempDir()
.file("a", "A\n")
.file("b", "B\n")
.exitCode(1)
.stderr("mv: : No such file or directory\n")
.fileEquals("a", "A\n")
.fileEquals("b", "B\n")
.runAsTest("as the directory for several sources fails");
});

// POSIX `mv` must fall back to copy+unlink when `rename()` returns EXDEV
// (source and destination on different filesystems). Requires a writable
// mount on a different device from the harness temp dir.
Expand Down
Loading
Loading