Skip to content

shell: accept -- end-of-options delimiter in builtins - #33995

Open
robobun wants to merge 5 commits into
mainfrom
farm/e4dff553/shell-double-dash
Open

shell: accept -- end-of-options delimiter in builtins#33995
robobun wants to merge 5 commits into
mainfrom
farm/e4dff553/shell-double-dash

Conversation

@robobun

@robobun robobun commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

What

Bun Shell's file builtins reject the POSIX -- end-of-options delimiter. rm -- "$f" is the standard hardening idiom (shellcheck recommends it for every operand that might start with -), and under Bun Shell it fails unconditionally:

import { $ } from "bun";
$.nothrow();
await $`touch a; rm -- a`.quiet();
// exit=1  stderr="rm: illegal option -- -"  (file survives)

Same for mv -- a b, mkdir -- d, touch -- t, ls --, seq -- 2: all exit 1 with illegal option and do nothing. Inside &&/|| chains the exit 1 silently takes the wrong branch.

Cause

Each builtin's flag loop treats any argument starting with - as an option cluster with no terminator arm, so -- falls through to the unknown-option error. cat/cp appeared to accept it only because they fall through to the system binary on POSIX.

Fix

Add a flag == b"--" arm to the shared flag parser in interpreter.rs (covers touch, mkdir, cat, cp) and to each builtin that has its own parser (rm, mv, ls, seq). Builtins with no flag parser (cd, basename, dirname, which, yes, pwd, exit, export) skip a leading -- via a new Builtin::operand_start() helper. -- is consumed and everything after it is treated as an operand, matching POSIX Utility Syntax Guideline 10 and coreutils/bash. echo is intentionally left alone (POSIX says echo does not recognise --).

Verification

test/js/bun/shell/commands/double-dash.test.ts (36 cases) covers each builtin with plain operands, dash-prefixed operands, and bare --. All fail on the released build, all pass with this change.

Fixes #23851


[review] gate passed · iteration 0 · 15 files touched

fails on main (without fix)
ASAN without fix: 35 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/shell/commands/double-dash.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (695a6ed99)

test/js/bun/shell/commands/double-dash.test.ts:
rm: illegal option -- -
214 |           this.expected_stdout(stdout.toString(), tempdir);
215 |         }
216 |       }
217 |       if (this.expected_stderr !== undefined) {
218 |         if (typeof this.expected_stderr === "string") {
219 |           expect(stderr.toString()).toEqual(this.expected_stderr.replaceAll("$TEMP_DIR", tempdir));
                                          ^
error: expect(received).toEqual(expected)

- ""
+ "rm: illegal option -- -
+ "

- Expected  - 1
+ Received  + 2

      at doChecks (/workspace/bun/test/js/bun/shell/test_builder.ts:219:37)
      at doChecks (/workspace/bun/test/js/bun/shell/test_builder.ts:208:20)
      at run (/wor
... (truncated)

release without fix: 4 FAILED
bun test v1.4.0-canary.1 (46cc3eb27)

test/js/bun/shell/commands/double-dash.test.ts:
(pass) -- end-of-options delimiter > rm > rm -- file [1.62ms]
a
(pass) -- end-of-options delimiter > rm > rm -v -- file applies flag before -- [0.52ms]
(pass) -- end-of-options delimiter > rm > rm -- -f treats -f as an operand [0.16ms]
usage: rm [-f | -i] [-dIPRrvWx] file ...
       unlink [--] file
(pass) -- end-of-options delimiter > rm > rm -- with no operands shows usage [0.10ms]
(pass) -- end-of-options delimiter > mv > mv -- src dst [0.28ms]
(pass) -- end-of-options delimiter > mv > mv -- -n out treats -n as an operand [0.22ms]
usage: mv [-f | -i | -n] [-hv] source target
       mv [-f | -i | -n] [-v] source ... directory
(pass) -- end-of-options delimiter > mv > mv -- with one operand shows usage [0.08ms]
d
(pass) -- end-of-options delimiter > mkdir > mkdir -- dir [0.14ms]
b
(pass) -- end-of-options delimiter > mkdir > mkdir -p -- nested applies flag before -- [0.15ms]
-p
(pass) -- end-of-options delimiter > mkdir > mkdir -- -p treats -p as an operand [0.13ms]
t
(pass) -- end-of-options delimiter > touch > touch -- file [0.10ms]
-a
(pass) -- end-of-options delimiter > touch 
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/shell/commands/double-dash.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (695a6ed99)

test/js/bun/shell/commands/double-dash.test.ts:
(pass) -- end-of-options delimiter > rm > rm -- file [61.53ms]
a
(pass) -- end-of-options delimiter > rm > rm -v -- file applies flag before -- [16.89ms]
(pass) -- end-of-options delimiter > rm > rm -- -f treats -f as an operand [12.59ms]
usage: rm [-f | -i] [-dIPRrvWx] file ...
       unlink [--] file
(pass) -- end-of-options delimiter > rm > rm -- with no operands shows usage [6.24ms]
(pass) -- end-of-options delimiter > mv > mv -- src dst [14.84ms]
(pass) -- end-of-options delimiter > mv > mv -- -n out treats -n as an operand [7.72ms]
usage: mv [-f | -i | -n] [-hv] source target
       mv [-f | -i | -n] [-v] source ... directory
(pass) -- end-of-options deli
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 722ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 243 extern-C blocks audited
[1/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

info: checking for self-update (current version: 1.29.0)
�[1m�[92m    Blocking�[0m waiting for file lock on build directory
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92
... (truncated)
diff hotspot
src/runtime/shell/Builtin.rs                   |   7 +
 src/runtime/shell/builtin/basename.rs          |   5 +-
 src/runtime/shell/builtin/cd.rs                |   7 +-
 src/runtime/shell/builtin/dirname.rs           |   5 +-
 src/runtime/shell/builtin/exit.rs              |   5 +-
 src/runtime/shell/builtin/export.rs            |   5 +-
 src/runtime/shell/builtin/ls.rs                |   4 +
 src/runtime/shell/builtin/mv.rs                |  27 +--
 src/runtime/shell/builtin/pwd.rs               |   3 +-
 src/runtime/shell/builtin/rm.rs                |  14 +-
 src/runtime/shell/builtin/seq.rs               |   3 +
 src/runtime/shell/builtin/which.rs             |   7 +-
 src/runtime/shell/builtin/yes.rs               |   7 +-
 src/runtime/shell/interpreter.rs               |   4 +
 test/js/bun/shell/commands/double-dash.test.ts | 228 +++++++++++++++++++++++++
 15 files changed, 298 insertions(+), 33 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                            reads  edits  tests
src/runtime/shell/Builtin.rs                        3      1      0
src/runtime/shell/builtin/basename.rs               2      3      0
src/runtime/shell/builtin/cd.rs                     2      2      0
src/runtime/shell/builtin/dirname.rs                2      3      0
src/runtime/shell/builtin/exit.rs                   2      1      0
src/runtime/shell/builtin/export.rs                 2      1      0
src/runtime/shell/builtin/ls.rs                     2      1      0
src/runtime/shell/builtin/mv.rs                     2      1      0
src/runtime/shell/builtin/pwd.rs                    2      1      0
src/runtime/shell/builtin/rm.rs                     3      1      0
src/runtime/shell/builtin/seq.rs                    1      1      0
src/runtime/shell/builtin/which.rs                  2      4      0
src/runtime/shell/builtin/yes.rs                    2      3      0
src/runtime/shell/interpreter.rs                    1      1      0
test/js/bun/shell/commands/double-dash.test.ts      3      6      0

root cause · written by the author bot

The option-parsing loops in Bun Shell's builtins treated any argument starting with - as a flag with no special case for --, so the POSIX end-of-options delimiter was rejected as an illegal option and the command aborted before touching its operands. The fix adds a -- arm to each affected builtin's flag loop that stops option parsing and treats all remaining arguments as operands, matching the behavior already present in cat, cd, and which. This lets the standard hardening idiom cmd -- "$f" work and allows operands that begin with - to be passed safely.

POSIX Utility Syntax Guideline 10 says the first `--` argument ends
option parsing and everything after it is an operand, even when it
starts with `-`. Bun Shell's builtins rejected it as an illegal
option, so `rm -- "$f"` (the standard shellcheck-recommended
hardening) failed unconditionally:

    rm -- a    -> rm: illegal option -- -   (file not removed)
    mv -- a b  -> mv: illegal option -- -   (nothing moved)
    mkdir -- d -> mkdir: illegal option --
    touch -- t -> touch: illegal option --
    ls --      -> ls: illegal option -- -
    seq -- 2   -> seq: invalid argument

Add a `--` arm to the shared flag parser (touch/mkdir/cat/cp) and to
each builtin with its own parser (rm/mv/ls/seq/cd/basename/dirname/
which/yes) so operands after `--` are passed through untouched.
@robobun

robobun commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:04 PM PT - Jul 11th, 2026

@robobun, your commit 695a6ed has 1 failures in Build #72031 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33995

That installs a local version of the PR into your bun-33995 executable, so you can run:

bun-33995 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. cp -R -- illegal option in bun shell (either in package.json scripts or bun.$ shell) #23851 - Reports that cp -R -- fails with "illegal option" because the shell's shared parse_flags doesn't recognize -- as an end-of-options delimiter — this PR adds exactly that support

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #23851

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 29 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fcc9eeb1-3c5b-4eee-a2d5-28e62d5129b4

📥 Commits

Reviewing files that changed from the base of the PR and between 183ca3f and 695a6ed.

📒 Files selected for processing (1)
  • test/js/bun/shell/commands/double-dash.test.ts

Walkthrough

Changes

-- is now recognized as an end-of-options delimiter by shared shell parsing and multiple builtins. Commands skip the delimiter when processing operands, and tests cover positional arguments, errors, outputs, and exit codes.

Double-dash option delimiter support

Layer / File(s) Summary
Parser and option-aware builtin handling
src/runtime/shell/interpreter.rs, src/runtime/shell/Builtin.rs, src/runtime/shell/builtin/{ls,mv,rm,seq}.rs
Flag parsing stops at --, and affected commands validate and process operands after the delimiter.
Operand-oriented builtin handling
src/runtime/shell/builtin/{basename,cd,dirname,exit,export,pwd,which,yes}.rs
These builtins skip a leading -- and handle remaining arguments or no-argument cases accordingly.
End-of-options command tests
test/js/bun/shell/commands/double-dash.test.ts
Tests cover hyphen-prefixed operands, usage errors, outputs, exit codes, file operations, and redirected yes output.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed [#23851] The shared -- parsing and builtin operand handling cover the cp -R -- behavior requested by the issue.
Out of Scope Changes check ✅ Passed The additional builtin updates and tests all support the same -- delimiter feature and do not appear unrelated.
Title check ✅ Passed The title clearly and concisely matches the PR’s main change: adding -- end-of-options support in shell builtins.
Description check ✅ Passed The description covers what changed and how it was verified, including context, fix details, and test coverage.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/shell/builtin/basename.rs`:
- Around line 25-30: Extract the duplicated leading “--” end-of-options
validation from the basename flow and the equivalent checks in dirname, yes,
which, and cd into a shared helper on Builtin. Update each builtin to call that
helper while preserving its existing invalid-usage failure behavior and
argument-start semantics.

In `@src/runtime/shell/builtin/cd.rs`:
- Around line 26-37: Update the argument handling in the cd builtin around the
skip calculation and first_arg processing so the previous-directory shortcut is
only used when the single argument is “-” without a preceding “--”. Preserve
“--” as an option terminator, allowing cd -- - to resolve a directory literally
named “-”.

In `@test/js/bun/shell/commands/double-dash.test.ts`:
- Around line 139-176: Extend the affected `cd`, `basename`, `dirname`, and
`which` test blocks to cover both ordinary and dash-prefixed operands, including
cases such as `cd -- -dir`, `basename -- -name`, `dirname -- -name`, and `which
-- -name`; create local fixtures where required and assert the expected output
and exit status for each variant.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7b1f6eac-0b39-4d86-b7f5-bfcc19675f8d

📥 Commits

Reviewing files that changed from the base of the PR and between 062ebaf and 863f00c.

📒 Files selected for processing (11)
  • src/runtime/shell/builtin/basename.rs
  • src/runtime/shell/builtin/cd.rs
  • src/runtime/shell/builtin/dirname.rs
  • src/runtime/shell/builtin/ls.rs
  • src/runtime/shell/builtin/mv.rs
  • src/runtime/shell/builtin/rm.rs
  • src/runtime/shell/builtin/seq.rs
  • src/runtime/shell/builtin/which.rs
  • src/runtime/shell/builtin/yes.rs
  • src/runtime/shell/interpreter.rs
  • test/js/bun/shell/commands/double-dash.test.ts

Comment thread src/runtime/shell/builtin/basename.rs Outdated
Comment thread src/runtime/shell/builtin/cd.rs Outdated
Comment thread test/js/bun/shell/commands/double-dash.test.ts
Also cover dash-prefixed operands for cd/basename/dirname/which.
Comment thread src/runtime/shell/interpreter.rs

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/shell/commands/double-dash.test.ts`:
- Around line 218-229: Strengthen the export tests in the describe("export")
block so they distinguish the fixed behavior from the old implementation: after
exporting FOO=bar, invoke export -- and assert the output includes the known
FOO=bar assignment while still excluding --=. Update the existing command
assertions without changing unrelated coverage.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 60756978-bdb0-4791-8a09-871fb1d4b471

📥 Commits

Reviewing files that changed from the base of the PR and between 863f00c and 183ca3f.

📒 Files selected for processing (10)
  • src/runtime/shell/Builtin.rs
  • src/runtime/shell/builtin/basename.rs
  • src/runtime/shell/builtin/cd.rs
  • src/runtime/shell/builtin/dirname.rs
  • src/runtime/shell/builtin/exit.rs
  • src/runtime/shell/builtin/export.rs
  • src/runtime/shell/builtin/pwd.rs
  • src/runtime/shell/builtin/which.rs
  • src/runtime/shell/builtin/yes.rs
  • test/js/bun/shell/commands/double-dash.test.ts

Comment thread test/js/bun/shell/commands/double-dash.test.ts Outdated

@claude claude Bot left a comment

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.

No bugs found, but this touches argument parsing across 15 shell-builtin files including rm and mv, with a control-flow refactor in mv::parse_opts — worth a human glance.

Checked: operand_start() returns 0/1 correctly and each of the 8 no-parser builtins iterates start..argc with the right empty-operand fallback (usage vs. print-all vs. no-op).
Checked: the mv.rs loop refactor (Done => break + post-loop validation) preserves pre-PR behavior for mv a b, mv -f a b, and all-flags-no-operands.
Checked: rm's args_start = idx + is_end_of_options and the new >= argc usage guard; rm -f -- a and rm -- route correctly.
Checked: seq's -- arm doesn't break negative-number operands (seq -1 3 still parses -1 as positional).

Extended reasoning...

Overview

Adds POSIX -- end-of-options delimiter support to Bun Shell builtins, fixing #23851. Introduces Builtin::operand_start() (returns 1 if argv[0] is --, else 0) and routes 8 no-flag-parser builtins (basename, cd, dirname, exit, export, pwd, which, yes) through it. Adds a flag == b"--" arm to the shared parse_flags in interpreter.rs (covers touch/mkdir/cat/cp) and to each builtin with its own parser (ls, mv, rm, seq). Ships a 228-line test file with 36 cases covering plain operands, dash-prefixed operands, and bare -- for every affected builtin.

Security risks

None identified. This is option-parsing for in-process shell builtins; no auth, crypto, or network surface. The change actually improves safety by enabling the standard rm -- "$f" hardening idiom. I checked that the rm change cannot cause -- itself to be treated as a delete target, and that the root-path guard (args_start..argc loop) still runs over the correct operand range.

Level of scrutiny

Medium. Each individual change is small and follows one of two consistent patterns, and the test coverage is thorough. But this is 14 Rust files in the shell runtime, two of which (rm, mv) mutate the filesystem — a parsing regression there could delete or move the wrong files. The mv::parse_opts change is a genuine control-flow refactor (moving operand-count validation from inside the Done match arm to after the loop), not a pure addition. I traced it and it's behavior-preserving, but that's the kind of change a maintainer should eyeball.

Other factors

All prior review feedback is addressed and resolved: CodeRabbit's dedup suggestion (→ operand_start()), dash-prefixed-operand test coverage, the export -- test-strength fix, and my earlier nit about pwd/exit/export. The cd -- - → OLDPWD behavior was correctly kept (matches bash). echo is intentionally excluded per POSIX. CI build #72031 is in progress.

@robobun

robobun commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

CI build #72031: the new double-dash.test.ts and all existing shell tests pass on every lane. The red lanes are unrelated to this diff (which only touches src/runtime/shell/):

  • webview-chrome.test.ts (animation timing, linux x64-asan)
  • napi.test.ts (GC timing, windows aarch64)
  • serve-http3.test.ts (crash, macOS aarch64)
  • no-orphans.test.ts (timeout, macOS x64)
  • filter-workspace.test.ts (TUI output, alpine aarch64)
  • test-fs-promises-file-handle-readFile.js (FileHandle GC, 3 lanes)
  • test-repl-close.js (EPIPE, windows x64-baseline)

Ready for review.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up for the rebase: #39225 moves rm's no-operands decision to the start of the RmParseFlag::Done arm (let args_start = idx; if args_start >= argc { exit 0 under -f, usage otherwise }). The args_start >= argc usage check this PR adds after -- lands on the same lines, so on rebase it should fold into that one check (just compute args_start with the -- offset) rather than stay a second exit; otherwise rm -f -- keeps printing usage and exiting 1 where GNU and BSD exit 0. A rm -f -- -> exit 0 case next to the existing rm -- -> usage test would pin it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cp -R -- illegal option in bun shell (either in package.json scripts or bun.$ shell)

1 participant