Skip to content

util.parseArgs: fix panic when an args element converts to a different string a second time - #46324

Open
robobun wants to merge 2 commits into
mainfrom
robobun/41a3d441/parse-args-unstable-tostring
Open

robobun wants to merge 2 commits into
mainfrom
robobun/41a3d441/parse-args-unstable-tostring

Conversation

@robobun

@robobun robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • util.parseArgs aborts the process when two string conversions of one args element differ (a changing toString()): panic: called `Option::unwrap()` on a `None` value or range end index 3 out of range for slice of length 2, exit 134.
  • tokenize_args classifies the first conversion, but kept the element as raw. RawNameFormatter and make_raw_name_js_value (src/runtime/node/util/parse_args.rs:115, :142) converted it again and cut the new string by the shape of the first.

Fix

  • The three token shapes whose raw name is cut out of the string keep the classified string: raw: ValueRef::Bunstr(&arg) at :635, :659 (short option group) and :743 (--name=value).
  • Correct because the readers cut the string that classify_token read. A lone option keeps the element, its whole raw name, as in Node.
  • Verified: test/js/node/util/parse_args/parse-args-non-string-args.test.ts (16 tests, 15 fail before). Also the parse_args suites and Node's test-parse-args.mjs.
  • Self-reviewed: 28 concerns raised, 26 addressed. Not done: two tracking issues (Notes).

Background

Downsides

  • None found for string args: 0 differing results in 120,000 fuzz calls, default-path instructions 25,323 to 25,330 (spread 24), wall-clock inside A/A noise, same binary size.
  • For such elements rawName is from the first conversion. Node differs (Notes).
Notes

Repro. Each file prints a result under Node v26.3.0 and aborts under Bun 1.4.0 to 1.4.4-canary.

// ts1.js: panic: called `Option::unwrap()` on a `None` value
let n = 0;
const arg = { toString() { return n++ === 0 ? "--foo=bar" : "nothing"; } };
console.log(JSON.stringify(require("node:util").parseArgs({ args: [arg], strict: false, tokens: true })));
// ts2.js: the same with `n++ === 0 ? "-abc" : "-a"`: panic: range end index 3 out of range for slice of length 2
// ts3.js: the ts1 object with `parseArgs({ args: [arg] })`: panic in `impl Display for RawNameFormatter`

With this change:

ts1: {"values":{"foo":"bar"},"positionals":[],"tokens":[{"kind":"option","name":"foo","rawName":"--foo","index":0,"value":"bar","inlineValue":true}]}
ts2: {"values":{"a":true,"b":true,"c":true},"positionals":[],"tokens":[{"kind":"option","name":"a","rawName":"-a","index":0},{"kind":"option","name":"b","rawName":"-b","index":0},{"kind":"option","name":"c","rawName":"-c","index":0}]}
ts3: throws ERR_PARSE_ARGS_UNKNOWN_OPTION, "Unknown option '--foo'"

Node v26.3.0. Node documents args as string[]. It does not check or convert the elements. It reads arg.length from the raw element and converts the element again in every String.prototype call. So it never aborts, and it never parses such an element like its first string (0 of the 70 cells of the test fixture). For the three repros it returns an empty result ({"values":{},"positionals":[],"tokens":[]} for ts1 and ts2). A plain object gives an empty result in 11 of the 14 fixture cases. A String subclass gives options from the later string (-o -t -h -i -n for "nothing"). An array with one item gives a positional. Bun classifies a non-string element by its string conversion, before and after this change.

History. The readers that convert again date from #7310. Bun 1.0.16, 1.2.23, 1.3.13 and 1.3.14 exit 0 on the repros, with a rawName read past the end of the second string ("", - and a NUL character, "-A"). Bun 1.4.0, 1.4.2 and 1.4.4-canary exit 134.

Other triggers. A toString() is not needed. A Buffer over a SharedArrayBuffer that a Worker rewrites aborts main in 3 of 3 runs. With this change it ran about 4 million calls per run with no abort.

Left as is, on purpose.

Not in this PR. Branch robobun/41a3d441/parse-args-raw-name builds the raw name in the tokenizer for every shape, so no reader holds the element. It also removes the second conversion for lone options and positionals, which changes two strict-mode messages for these elements. On release builds it ran fewer instructions on the default path (25,323 to 25,056) but more time on plain strings with tokens off: 64 positionals 2,586 to 3,516 ns per call, 64 lone long options 6,272 to 7,348 ns.

Measurements. Release builds of main and of main plus this change, same tree and flags. The fuzz scripts are my own.

  • Fuzz with elements whose conversion changes (120 seeds x 400 calls): runs that exit 134: 120 -> 0.
  • Fuzz with string and stable elements (100 seeds x 1,200 calls): 0 differing results. Strict messages and tokens for every option shape: 0 differing of 19.
  • toString() calls for one element (main -> this PR, Node in brackets): --foo=bar with tokens 2 -> 1 [4]. -abc with tokens 4 -> 1 [3]. Strict unknown --foo=bar 2 -> 1 [5]. Lone option and positional errors 2 -> 2.
  • BunString__fromJS calls per parseArgs call, string args (gdb breakpoint hits): tokens: true with ['--foo=bar','-abc'] 13 -> 9. The same without tokens 9 -> 9. Strict unknown --nope=1 9 -> 8.
  • Instructions for one default-path call (gdb stepi, 11th call, ['--foo=bar','-abc','-fX','pos'], 8 runs each): main 25,311 to 25,335, median 25,323. This PR 25,330 to 25,352, median 25,330.
  • Wall-clock, ns per call, 64 elements, one pinned core, median of 9 alternating runs (main / main again / this PR). Positionals: 2,740 / 2,773 / 2,757. Lone long options: 6,497 / 6,434 / 6,466. --k=v: 9,806 / 9,860 / 9,842. -abc groups: 12,495 / 12,468 / 12,451. With tokens: true, --k=v: 45,122 / 45,410 / 43,907. -abc groups: 106,317 / 106,890 / 100,635.
  • Allocations per call (gdb hits on mi_* and WTF::fastMalloc): 14.23 -> 14.27 without tokens, 45.32 -> 45.33 with tokens (run-to-run noise about 0.05). Live GC cells per call: 5 -> 5 and 21 -> 21.
  • Size: parseArgs text 23,114 -> 22,897 bytes (nm -S). size text -256 bytes. The stripped binary is 107,271,904 bytes in both builds.
  • Not measured with valgrind, perf, bloaty or strace: they are not installed in the build container.

Crash reports. Not checked. A query for someone with access: frames in parse_args.rs, releases from 1.4.0.

Tracking issues. None filed. This PR records the abort, and #36037 records the missing dash.

Tests. One child process runs 14 cases, each for 5 kinds of element (object with toString, String subclass, array, Symbol.toPrimitive, valueOf). The test compares each result with the result for the plain first string and checks that the element was converted once. The file is new because parse-args.test.mjs has a stress test (100 mixed several times) that exceeds the 5 s default under a debug build on my machine, with and without this change.

Suites on the debug build. New file: 16 pass. parse-args-null-config 10, parse-args-token-key-order 1, default-args 11, Node's test-parse-args.mjs 113. parse-args.test.mjs: 109 pass and the stress test above.

Merge check. git merge-file of this change with the heads of #36037, #43905, #44521 and #40404 gives 0 conflict regions in parse_args.rs.

…t string a second time

parseArgs converts an args element to a string, classifies that string,
and kept the element as the token's raw. For --name=value and for an
option in a short option group, the raw name was cut out of a second
conversion of the element, by the shape of the first one. When the two
conversions differed, an unwrap() or a slice bound panicked and the
process aborted.

These three token shapes now keep the string that was classified. A lone
option keeps the element, because its raw name is the whole element.
@robobun

robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:14 AM PT - Oct 21st, 2026

✅ @robobun, your commit 5033db45346579feb08b92ef746e7e6c4990ff0d passed in Build #130094! 🎉


🧪   To try this PR locally:

bunx bun-pr 46324

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

bun-46324 --bun

@robobun

robobun commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review

How it is reproduced, on Bun 1.4.0, 1.4.2 and on a release build of main:

let n = 0;
const arg = { toString() { return n++ === 0 ? "--foo=bar" : "nothing"; } };
console.log(JSON.stringify(require("node:util").parseArgs({ args: [arg], strict: false, tokens: true })));
  • Main aborts with panic: called `Option::unwrap()` on a `None` value, exit 134. Node v26.3.0 prints a result.
  • This branch prints {"values":{"foo":"bar"},"positionals":[],"tokens":[{"kind":"option","name":"foo","rawName":"--foo","index":0,"value":"bar","inlineValue":true}]}.
  • test/js/node/util/parse_args/parse-args-non-string-args.test.ts: 15 of 16 tests fail on main, and 16 pass on this branch.

The self-review and the release measurements against main are in the description (Notes).

@coderabbitai

coderabbitai Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 08c28d29-f217-4a67-83bf-20ce12bee62c
📥 Commits

Reviewing files that changed from the base of the PR and between 9634520 and db3587e.

📒 Files selected for processing: 3
  • src/runtime/node/util/parse_args.rs
  • test/js/node/util/parse_args/parse-args-non-string-args-fixture.ts
  • test/js/node/util/parse_args/parse-args-non-string-args.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 3 remain after this review.

Walkthrough

OptionToken.raw now stores the borrowed argument string for grouped short options and options with inline values. New tests cover non-string argument conversion, conversion counts, parsing outcomes, and raw names for lone string-subclass options.

Changes

Non-string parseArgs option handling

Layer / File(s) Summary
Option token values and validation
src/runtime/node/util/parse_args.rs, test/js/node/util/parse_args/parse-args-non-string-args-fixture.ts, test/js/node/util/parse_args/parse-args-non-string-args.test.ts
Grouped short-option tokens and options with inline values store the borrowed argument string in raw. The fixture and tests cover non-string conversions across parsing modes and verify raw names for lone string-subclass options.

Suggested reviewers: jarred-sumner

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to db358

No actionable merge-blocking issue remains; this change is ready to merge after normal checks.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly identifies the main change: preventing a util.parseArgs panic when an args element produces different strings across conversions.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description check Passed The description clearly explains the problem, fix, scope, risks, and verification. It does not use the exact template headings, but it provides the required information in equivalent sections.
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/runtime/node/util/parse_args.rs — Strict-mode callers passing a lone -f or --foo element that is not a string still get a second user toString run, so the error they see names a different option or is replaced by whatever that second conversion throws. The two sibling producers at parse_args.rs:589 and :720 keep raw: arg_ref (the element), and check_option_usage at :326 and :270 plus check_option_like_value at :228 call token.raw.as_bun_string(global)? again. The three cut sites were switched to ValueRef::Bunstr(&arg) but these siblings of the same class were not. Fix: every strict-mode reader must format the string classify_token read at :535; store the classified string for Lone* tokens too and keep the element only for make_raw_name_js_value (:158) so rawName identity is unchanged. [also at: src/runtime/node/util/parse_args.rs:806 - Callers that disable positionals and pass a non-string element get an Unexpected argument message naming a string the parser never saw, or their element's second toString exception instead of ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL.]

    Why this was flagged

    Trigger: parseArgs({ args: [obj], options: {} }) (strict is the default) where obj.toString() returns "--foo" the first time and "--bar" or throws the second time. tokenize_args converts once at parse_args.rs:533, classifies "--foo" as LoneLongOption, and stores the element at :720 (raw: arg_ref). handle_token at :787 calls check_option_usage, which at :326 calls token.raw.as_bun_string(global)? on the JSValue, running toString a second time. The user gets Unknown option '--bar' for an option that was parsed as foo, or the user's own exception instead of ERR_PARSE_ARGS_UNKNOWN_OPTION. Same at :589 for -f and at :228/:270. The base branch behaves the same, but the PR fixes exactly this class at :635, :659 and :743 and leaves these two producers, contrary to the fix-the-whole-class rule. Remedy: store ValueRef::Bunstr(&arg) for Lone* tokens as well and have only make_raw_name_js_value return the element for rawName.

    Verification: pre-existing, acknowledged in diff: the doc comment at src/runtime/node/util/parse_args.rs:106 ("The element only for a lone option") records that lone options still hold the JSValue. The producers at :589 and :720 keep raw: arg_ref; the diff changed only :635, :659, :743. :326, :270 and :228 call token.raw.as_bun_string(global)?, re-running user conversion, so strict mode throws Unknown option '--bar' although "--foo" was classified.

  • 🟣 src/runtime/node/util/parse_args.rs — Callers whose args array is shrunk by an element's toString get values.foo set to true and the following argument silently skipped, where Node parses the array as it was when called. The first conversion at parse_args.rs:533 runs user code; args.end was fixed at :529 but args.get(global, index + 1) at :618, :573 and :710 reads the live array, so a removed index yields undefined as the consumed value and index += 1 at :641/:596/:727 skips it. Node copies args with ArrayPrototypeSlice before tokenizing. Fix: snapshot every element (or at least its length and values) before any conversion so user code during toString cannot change which element a string option consumes.

    Why this was flagged

    Trigger: parseArgs({ args, options: { foo: { type: "string" } }, strict: false }) with args = [obj, "x"] where obj.toString() does args.pop() and returns "--foo". tokenize_args computes num_args = 2 at parse_args.rs:529 and converts obj at :533, running the pop. Since index + 1 < num_args still holds, :710 calls args.get(global, 1), which is get_index on a now-shorter array and returns undefined (parse_args.rs:27). The token at :714-724 stores value: undefined with inline_value: false; store_option writes values.foo = undefined and :727 advances past the vanished element. Node v26 runs ArrayPrototypeSlice(args) first and consumes "x". The base branch has the same lines, so this is not a regression, but the PR is about elements whose conversion runs user code and this is the remaining way such code changes the parse. Population: any caller forwarding caller-controlled arrays with non-string entries. Remedy: read all elements into a MarkedArgumentBuffer-style snapshot before the loop, or at minimum re-check get_length after each conversion.

    Verification: pre-existing. Trigger: an args element whose toString mutates the caller's args array while returning an option-like string. num_args is frozen at src/runtime/node/util/parse_args.rs:529; the conversion at :533 runs user code; :573, :618 and :710 then read the live array gated only by the stale index + 1 < num_args. The base branch has the identical code, so merging makes nothing worse.

Comment thread test/js/node/util/parse_args/parse-args-non-string-args.test.ts
…it code

A fixture process that aborts again now shows its panic message in the
failure, not only exit code 134.
@robobun

robobun commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. One change is pushed (5033db4). Two points stay as they are.

stderr of the fixture process (inline). Done. The test keeps stderr and asserts { stderr, exitCode } together. When the fixture aborts, the failure now shows the panic message. On main it prints panic: called `Option::unwrap()` on a `None` value with exit code 134.

Lone options and positionals are converted a second time in strict mode. Left as is on purpose. The description has it under "Left as is, on purpose".

  • It cannot abort. These readers print the second string whole, and nothing is cut out of it. An exception from the second conversion is a normal JavaScript exception.
  • Bun 1.0.16, 1.2.23, 1.3.14 and 1.4.2 do the same. Node v26.3.0 also prints a later conversion in these messages: Unknown option '--other' for a String subclass that converts to --foo and then to --other.
  • To print the first string there changes error messages for input that never crashed. This PR is only the crash fix. The version that removes every second conversion is on the branch robobun/41a3d441/parse-args-raw-name, and the Notes give its measured cost.

args is read live while a conversion can change the array. Not part of this PR. It does not abort: an index that was removed reads as undefined. With [obj, "x"], where obj.toString() pops the array and returns --foo, non-strict mode stores foo: true and strict mode throws ERR_PARSE_ARGS_INVALID_OPTION_VALUE. Main does the same. A copy of args before the loop is a separate change, and every call would pay for it.

@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 three raw sites now borrow the string that classify_token read, and the latest commit asserts the fixture's stderr alongside its exit code, which covers the one nit from the previous pass.

What was reviewed:

  • Traced every consumer of OptionToken.raw (make_raw_name_js_value, RawNameFormatter, and the three strict-mode error sites): for the short-group and --name=value shapes all go through as_bun_string, which now returns the borrowed classified string, so the = index and the substring_with_len offsets are computed on the same string; the lone-option branches still return the original element via as_js_value.
  • Checked the &arg borrow lifetime: tokens are consumed synchronously inside handle_token within the loop iteration, matching the existing Bunstr(&raw) pattern in the ShortOptionAndValue arm.
  • Checked the test for vacuity: the fixture is spawned once with both pipes drained concurrently, each test.each case compares the five element kinds against the plain-string result with toEqual (including conversions: 1), and an abort on the unfixed build leaves fromElements empty so every case fails rather than passing.
  • A separate file instead of extending parse-args.test.mjs matches the directory's existing precedent of per-topic files.
Extended reasoning...

The change is three one-line edits in src/runtime/node/util/parse_args.rs replacing raw: arg_ref with raw: ValueRef::Bunstr(&arg) for the short-option-group (both branches) and --name=value token shapes, plus a spawned fixture and test file under test/js/node/util/parse_args/. It touches no security-sensitive surface; it removes a user-reachable panic (unwrap on None / slice out of range) triggered by an args element whose string conversion changes between calls. The fix is small, self-contained, and its correctness follows directly from reading the raw-name consumers, which all slice the string via as_bun_string. No CODEOWNERS entry covers the changed paths, no CHANGES_REQUESTED review is outstanding, and the only prior inline nit (unasserted fixture stderr) was addressed in commit 5033db4.

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.

1 participant