Skip to content

shell: export rejects invalid identifiers - #40710

Open
robobun wants to merge 7 commits into
mainfrom
farm/cd5f441e/shell-export-invalid-identifier
Open

robobun wants to merge 7 commits into
mainfrom
farm/cd5f441e/shell-export-invalid-identifier

Conversation

@robobun

@robobun robobun commented Aug 28, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • The Bun shell export builtin accepts any name. export 1abc a-b=5 puts 1abc= and a-b=5 into every child environment, prints nothing, and exits 0. Found by comparison with bash, no user report.
  • src/runtime/shell/builtin/export.rs:33-44 splits each word at the first = and inserts the pair into export_env without calling is_valid_var_name (src/shell_parser/parse.rs:3788).

Fix

  • Export::start checks each name with is_valid_var_name (now pub in bun_shell_parser). An invalid word is not exported and stderr gets export: `1abc`: not a valid identifier. Valid words in the same command are still exported. The exit code is 1, as in bash.
  • Errors go through Builtin::write_failing_error. A new State::Err variant makes on_io_writer_chunk report exit 1 after an async write (a write error still wins). A leading -- is skipped.
  • Land shell: stop field splitting command substitution in export operands and assignments #40764 first or together. It stops the field split of export NAME=$(cmd). Without it, a split word such as 28 now fails the command (Notes).
  • Verified: test/js/bun/shell/bunshell.test.ts, eight new tests in env variables (two also via bun run script.bun.sh, one with .quiet()). The released bun fails nine of ten runs. Other shell tests and cargo clippy -p bun_runtime pass.

Background

  • When stderr is an fd, a builtin's write is asynchronous: it enqueues bytes on an IOWriter and yields. on_io_writer_chunk fires when the write completes and passes the exit code to Builtin::done. $ uses this path by default.
  • With $.quiet(), stderr is a buffer: write_no_io appends and done runs at once.
  • is_valid_var_name accepts [A-Za-z_][A-Za-z0-9_]*, as bash does.
Notes
  • bash output for the same command:
    $ bash -c 'export 1abc a-b=5 ok=1; echo "exit=$?"; env | grep -E "^(1abc|a-b|ok)="'
    bash: line 1: export: `1abc': not a valid identifier
    bash: line 1: export: `a-b=5': not a valid identifier
    exit=1
    ok=1
    
  • The Zig implementation (before the Rust port) checked the name only when the word had no =, returned on the first bad word, and exited 0. The port dropped the check. This change validates both forms, processes every word, and exits 1.
  • Interaction with a pre-existing bug, Bun shell: export NAME=$(cmd) field-splits the command substitution output #40763, fixed by shell: stop field splitting command substitution in export operands and assignments #40764: the Bun shell field-splits the output of $(cmd) in export NAME=$(cmd). bash does not. With export DATE=$(date), the old code set DATE to the first word only and silently exported Aug=, 28=, 12:37:27= and so on, exit 0. With this change alone, DATE is still the first word and the split words that are not identifiers (28, 12:37:27, 2026) are reported, exit 1. On Windows, package.json scripts run in the Bun shell, so such a script would start to fail. The value of DATE is wrong either way. The workaround is export DATE="$(date)". shell: stop field splitting command substitution in export operands and assignments #40764 removes the split, so the two changes compose. Both insert tests after exported vars 2 in bunshell.test.ts, so whichever lands second needs a small rebase.
  • Exit code of on_io_writer_chunk: a write error gives 1, then State::Err gives 1, else 0. shell: exit with the positive errno when a builtin's output write fails #40702 (open) changes the write error arm to the errno. The two changes are independent. Whichever lands second rebases one arm.
  • shell(export): reject invalid identifiers #32298 is an older open PR with the same fix (same files, same check, same message, exit 1). Its five test cases pass on this branch. The three cases this PR lacked (export "" exits 1, and the exit code when valid and invalid words are mixed, for bare names and for assignments) are now in this PR. shell(export): reject invalid identifiers #32298 is closed as a duplicate.
  • shell: export NAME without a value no longer clobbers the variable #33549 and shell: reassigning an exported variable updates the child environment #33550 (open, conflicting) change what export NAME does when the variable already exists and how a reassignment reaches the child environment. Those are separate behaviors and are not part of this change.
  • An empty argument (export "") is rejected too, like bash. The old code skipped it.
  • The bash options -p, -n and -f are not supported by the Bun shell, before or after this change. Before, export -p put -p= into the environment. Now it is reported as an invalid word. shell: accept -- end-of-options delimiter in builtins #33995 (open) adds -- handling to every builtin with a shared helper. It will need a rebase on this change.
  • A script run with bun run x.bun.sh (stderr is a real fd) exits 1 for each bad name with stderr redirected to /dev/null, to a file, and through a pipe.
  • The three tests in test/js/bun/shell/shell-load.test.ts and commands/ls.test.ts that fail locally also fail on main in this container (root user, timing). They are unrelated.

no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/shell/bunshell.test.ts

The export builtin inserted every argument into the exported
environment without a check on the name. `export 1abc a-b=5` put
`1abc=` and `a-b=5` into the environment of child processes.

Validate each name with is_valid_var_name. An invalid word is reported
on stderr as "export: `<word>`: not a valid identifier" and is not
exported. The valid words are still exported. The exit code is 1 when
any word was rejected, like bash.
@coderabbitai

coderabbitai Bot commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 23 days. After that, they cost $0.25 per reviewed file.

Or wait 10 minutes for your next included review.

View limit details

Limit details: You’ve used all 5 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 435dc150-32aa-48f4-b30d-5cd3292d3045

📥 Commits

Reviewing files that changed from the base of the PR and between a92d84e and 510edee.

📒 Files selected for processing (5)
  • src/runtime/shell/builtin/export.rs
  • src/runtime/shell/mod.rs
  • src/shell_parser/lib.rs
  • src/shell_parser/parse.rs
  • test/js/bun/shell/bunshell.test.ts

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

@robobun

robobun commented Aug 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced with the released bun:

USE_SYSTEM_BUN=1 bun test test/js/bun/shell/bunshell.test.ts -t "env variables"

Before the fix, nine of the ten new runs fail: stderr is empty, the exit code is 0, and the child sees 1abc= and a-b=5. With the fix (bun bd test) all of them pass, and the full file passes (433 pass, 0 fail).

#32298 was a second PR for the same bug. It is closed. Its test cases pass on this branch, and the three it had that this PR lacked are now included.

CI at 510edee36b: every lane passes except darwin x64, where test/js/web/url/url.test.ts (also failing on main since build 107413) and test/js/web/workers/worker-terminate-lifetime.test.ts (a worker lifecycle test) fail. Neither touches the shell, and both are with main-break triage. The diff is ready for review.

Comment thread src/runtime/shell/builtin/export.rs Outdated
An empty argument is not a valid identifier either. Drop the skip so
the same check reports it, like bash.
Comment thread src/runtime/shell/builtin/export.rs Outdated
@robobun

robobun commented Aug 28, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:55 AM PT - Aug 28th, 2026

❌ @robobun, your commit 510edee has 2 failures in Build #107700 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 40710

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

bun-40710 --bun

Comment thread src/runtime/shell/builtin/export.rs
POSIX scripts write `export -- NAME=value`. The identifier check would
report `--` as an invalid name and exit 1. Treat a leading `--` as the
end of options, like bash.

@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.

LGTM — both earlier points (empty-arg bypass and -- being rejected) are now fixed and covered by tests.

Checked that on_io_writer_chunk still returns exit 0 for the print_all/WaitingIo path (only State::Err or a write error yields 1), that write_failing_error's no-io branch already passes exit_code=1 directly so State::Err is only needed for the async fd path, and that is_valid_var_name(b"") returns false so the removed is_empty() skip is safe.

Extended reasoning...

Overview

The PR adds identifier validation to the shell export builtin: each argument's name portion is checked with is_valid_var_name (promoted from pub(crate) to pub in bun_shell_parser and re-exported through crate::shell), invalid words emit a bash-style stderr line and set exit code 1 while valid words in the same invocation are still exported, and a leading -- is treated as the POSIX end-of-options marker. A new State::Err variant lets the async-write completion callback report exit 1. Four new TestBuilder cases in test/js/bun/shell/bunshell.test.ts cover invalid/mixed args, exit code, valid identifiers, and -- handling, with .testMini() exercising the fd path.

Security risks

None. This is input validation on shell-builtin argument names; the change tightens rather than loosens behavior. No new unsafe blocks, no filesystem or network surface, and the byte search for = continues to use the SIMD bun_core::strings::index_of_char_usize helper per src/CLAUDE.md.

Level of scrutiny

Low-to-moderate. The change is ~30 lines in one builtin plus visibility re-exports, follows the exact write_failing_error / state-machine pattern used by sibling builtins (cat, mv, yes), and is well covered by tests that assert stderr content, exit code, and that valid names are still exported. Both concerns I raised on earlier revisions — the s.is_empty() early-continue bypassing validation, and -- being rejected as an invalid identifier — have been addressed in the current diff and each has a dedicated test assertion.

Other factors

I verified the shared Builtin::write_failing_error helper: its no-io branch calls Builtin::done with the passed exit_code directly, so State::Err only matters on the async fd branch where on_io_writer_chunk fires — and that callback still yields exit 0 for the pre-existing print_all → WaitingIo flow. No third-party CHANGES_REQUESTED reviews are outstanding; the author-resolved threads correspond to code that has demonstrably changed. Exit reason was dry_streak.

@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.

LGTM — both earlier concerns (empty-word bypass, -- rejected as an identifier) are fixed and now have dedicated tests.

What was reviewed:

  • is_valid_var_name gate covers all invalid forms (empty, =x, leading digit, hyphen); valid siblings still exported, exit 1 on any failure.
  • State::Err is read in on_io_writer_chunk before being overwritten to Done, so the async-fd stderr path keeps exit 1; .testMini() cases exercise that path.
  • -- handling: only a single leading -- is skipped, export -- falls through to print_all, and a second -- after it is correctly rejected.
Extended reasoning...

Overview

The PR makes the Bun shell export builtin validate variable names against is_valid_var_name (the same [A-Za-z_][A-Za-z0-9_]* check the parser already uses for NAME=value prefixes), accumulates one bash-style error line per bad word, still exports the valid words, and exits 1 if anything was rejected. It also adds POSIX -- end-of-options handling. Source changes are ~30 lines in src/runtime/shell/builtin/export.rs plus two one-line visibility/re-export changes; tests add 7 cases to test/js/bun/shell/bunshell.test.ts.

Security risks

None. This tightens validation of shell builtin arguments and changes an exit code from 0 to 1 for previously-accepted garbage. No new parsing of untrusted external data, no allocation sized by attacker input, no unsafe added. The one pre-existing unsafe block (env map insert) is unchanged.

Level of scrutiny

Low-to-moderate. The builtin is a small self-contained state machine; the new State::Err variant is handled on the only completion path (on_io_writer_chunk), and the sync captured-output path goes through write_failing_error(.., 1) directly. I raised two issues on earlier pushes (empty-arg bypass and -- being rejected) — both were fixed in follow-up commits with tests, and the most recent commit is test-only, adding coverage for the empty word and mixed valid/invalid arg lists.

Other factors

No CODEOWNERS entry matches the shell paths. The bug hunt exited on dry_streak with no findings. Test coverage now spans the variant matrix REVIEW.md asks for: leading digit, hyphen, empty, =x, mixed valid/invalid (both bare-name and assignment forms), -- alone and -- FOO=bar, and &&/|| chaining to observe the exit code. Two tests use .testMini() to exercise the real-fd async write path where State::Err matters.

@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.

LGTM — both earlier concerns (empty-word bypass and -- end-of-options) are now addressed with tests, and the added .quiet() case covers the synchronous stderr path.

Checked: is_valid_var_name re-export is a visibility-only change; State::Err is threaded through on_io_writer_chunk so the async-fd path exits 1; valid assignments in a mixed invocation are still exported before the error write; start >= argc correctly falls through to print_all for both export and export --. The error buffer echoes the full argv word (matching bash) and no EnvStr is allocated for rejected names.

Extended reasoning...

Overview

The PR makes the shell export builtin validate identifier names via the parser's existing is_valid_var_name, buffer per-arg diagnostics to stderr, and exit 1 when any name is invalid — while still exporting the valid ones. It also adds -- end-of-options handling and drops the silent empty-arg skip. Touched files are src/runtime/shell/builtin/export.rs (the actual logic, ~25 lines net), a pub(crate)→pub visibility bump in src/shell_parser/parse.rs, two one-line re-exports, and eight new tests in test/js/bun/shell/bunshell.test.ts.

Security risks

None identified. The change tightens validation of shell builtin arguments and does not introduce new parsing, allocation on untrusted sizes, or FFI. The error message echoes the argv word verbatim, which is the same behavior bash has and is written to the caller's own stderr.

Level of scrutiny

Low-to-moderate. The builtin is a small self-contained state machine; the diff follows the file's existing patterns (Builtin::write_failing_error, the State enum + on_io_writer_chunk completion). The -- skip is a one-liner that correctly degrades to print_all when it is the only arg. Both concerns raised in earlier rounds — the empty-word bypass and -- being rejected as an identifier — have been fixed and each has a dedicated test. The most recent push only adds a .quiet() variant to exercise the synchronous write_no_io branch alongside the async-fd branch.

Other factors

No CODEOWNERS entry covers src/runtime/shell/ or src/shell_parser/. No outstanding CHANGES_REQUESTED reviews. Exit reason was dry_streak. Test coverage spans mixed valid/invalid args, bare names vs assignments, empty word, -- in both forms, || fallthrough proving valid vars were set, and both stderr I/O paths (.testMini() fd write and .quiet() buffer). The -p/-n/-f option words now error instead of being silently exported as -p= — the PR description names #33995 as the tracked follow-up for real option parsing, and erroring is strictly less wrong than the prior behavior.

clippy denies needless_pass_by_value on the err argument. A write error
keeps precedence over the identifier failure.

@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.

LGTM — the earlier feedback (empty-arg bypass, -- end-of-options) is now handled and covered by tests, and the latest push is just the clippy-driven match err reshape in on_io_writer_done, which is behavior-preserving.

Checked: is_valid_var_name re-export chain resolves through bun_shell_parser → crate::shell; the = split still uses bun_core::strings::index_of_char_usize; error text/exit code/ordering match the bash reference in the PR description; both the async-fd (on_io_writer_done with State::Err) and .quiet() synchronous paths are exercised; export -- alone falls through to print_all and -- is not itself exported.

Extended reasoning...

Overview

This PR makes the Bun shell's export builtin validate identifier names against is_valid_var_name ([A-Za-z_][A-Za-z0-9_]*), matching bash: invalid words produce export: `WORD`: not a valid identifier on stderr and exit 1, while valid words in the same command are still exported. The change touches src/runtime/shell/builtin/export.rs (validation loop, new State::Err, -- end-of-options handling, exit-code plumbing in on_io_writer_done), promotes is_valid_var_name to pub and re-exports it through src/shell_parser/lib.rs and src/runtime/shell/mod.rs, and adds eight tests in test/js/bun/shell/bunshell.test.ts.

Security risks

None. The change tightens validation of shell-builtin arguments and rejects more inputs than before; it does not introduce new parsing, allocation sizing driven by untrusted lengths, or filesystem/network access. Error messages echo user-supplied argv bytes back to stderr, which is the same behavior as bash and not a disclosure concern.

Level of scrutiny

Low-to-moderate. The Rust change is ~30 net lines in a single builtin, follows the existing Builtin::write_failing_error / State pattern used by sibling builtins, and reuses the parser's existing is_valid_var_name. Earlier review rounds raised two correctness gaps (empty-arg skip acting as a validation bypass; -- being rejected as an invalid identifier and flipping exit 0→1) — both are fixed in the current diff with dedicated tests. The most recent commit only reshapes on_io_writer_done to consume err by value for clippy's needless_pass_by_value, keeping write-error precedence over State::Err; the resulting exit code is identical to the prior err.is_some() || failed form.

Other factors

Test coverage is thorough for the size of the change: mixed valid/invalid args (child env inspected via a spawned bun -e), exit code 1 for bare invalid names and invalid assignments separately, the .quiet() in-memory-buffer path vs the default async fd path, export -- both with and without following args, export "", and || fallthrough confirming valid assignments persist. No CODEOWNERS entries cover these paths. All prior inline threads on this PR correspond to commits that landed the requested change plus a test, so author-resolution is backed by code.

Jarred-Sumner pushed a commit that referenced this pull request Aug 29, 2026
…nd assignments (#40764)

Fixes #40763.

### Problem
- `export NAME=$(echo "hello world")` sets `NAME=hello` and exports a
stray empty variable `world`. bash and dash give `NAME=hello world`.
- The expansion of a command argument field splits unquoted command
substitution output into several argv words
(`src/runtime/shell/states/Cmd.rs`, the `ExpandingArgs` arm of
`child_done`). The expansion does not know the word is an assignment
operand of `export`.
- A plain assignment `NAME=$(cmd)` hides the same split:
`Assigns::child_done` re-joins the words with single spaces, so runs of
whitespace and newlines collapse.

### Fix
- Add an `assign_ctx` flag to `Expansion`. When set, the command
substitution output is not field split (the same path as a quoted
`"$(...)"`). POSIX 2.9.1: an assignment word undergoes no field
splitting.
- `Cmd` sets the flag for an operand of `export` (the only declaration
builtin) that starts with a literal `NAME=` prefix where `NAME` is a
valid identifier. A word whose name comes from an expansion, for example
`export $(echo "A=a b")`, still splits, like in bash.
- `Assigns` sets the flag for every assignment value, so `VAR=$(echo a
&& echo b)` now keeps the newline instead of collapsing to `a b`.
- Verified: three new tests in `test/js/bun/shell/bunshell.test.ts` fail
on current bun and pass with the fix. The full `test/js/bun/shell/`
suite passes except pre-existing environment failures (root-user
permission tests, ASAN timeouts), which fail the same way without the
change.

### Background
- The shell interpreter is a tree of state nodes. A `Cmd` expands each
argv atom through an `Expansion` child, which returns a buffer plus
`bounds`, the offsets that split the buffer into argv words.
- An unquoted `$(...)` runs `post_subshell_expansion`, which turns
newlines into spaces and splits on space runs. A quoted `"$(...)"` skips
that and only trims trailing whitespace. `assign_ctx` reuses the quoted
path.
- Glob and brace expansion of assignment values are unchanged. Only the
field split of command substitution output is suppressed.

<details><summary>Notes</summary>

- Repro: `bun -e 'await Bun.$`export NAME=$(echo "hello world"); echo
"NAME=[$NAME]"`'` prints `NAME=[hello]` before, `NAME=[hello world]`
after.
- With #40710 (identifier validation in `export`), the split also turns
into a hard error when a split word is not a valid identifier, for
example `export NAME=$(echo "a b-c")`. This fix removes the split, so
the two compose.
- `CondExpr` passes `assign_ctx: false` to keep its behavior unchanged.
Bash also suppresses splitting inside `[[ ]]`, but that is a separate
concern.
- Variable expansion (`$X`) was never field split in the Bun shell, so
only command substitution output was affected.
- Known divergence: the AST folds quoted and unquoted text into the same
`SimpleAtom::Text`, so `export "NAME="$(cmd)` also gets assignment
treatment, while bash splits there. Restoring the quoting bit needs a
parser AST change. The effect is benign: `export` already treats any
operand with `=` as an assignment at runtime, so the flag only stops
split words from becoming stray exported variables. zsh does not split
here either.
</details>

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · platform-specific test(s) that do not
run on this machine, deferring to CI, which covers all platforms:
test/js/bun/shell/bunshell.test.ts

<!-- robobun:evidence:end -->

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

This branch has not been deployed

No deployments
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.

2 participants