Skip to content

terminal: throw when a termios flag is set to a value that is not a uint32 - #42589

Open
robobun wants to merge 2 commits into
mainfrom
robobun/f882015b/terminal-termios-flag-validation
Open

robobun wants to merge 2 commits into
mainfrom
robobun/f882015b/terminal-termios-flag-validation

Conversation

@robobun

@robobun robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Assigning undefined, NaN, "bogus" or {} to inputFlags, outputFlags, localFlags or controlFlags of an open Bun.Terminal writes tcflag_t::MAX (every bit) to the PTY and throws nothing.
  • The cause is set_termios_flag (src/runtime/api/bun/Terminal.rs:1338). It coerces with ToNumber, then clamps with num.min(max_val).max(0.0). f64::min returns the operand that is not NaN, so NaN becomes the maximum. Fix Bun.Terminal termios flag NaN clamping #32354 chose that order to match the Zig code.
  • The clamp also turns a negative number into 0. JS bitwise operators return signed 32-bit numbers, so flags & ~ECHO clears every flag when bit 31 is set.

Fix

  • The setter validates with the in-tree validate_uint32 and does not coerce. A value that is not a number throws a TypeError (ERR_INVALID_ARG_TYPE). NaN, an infinity, a fraction, a negative number and a number above 0xffffffff throw a RangeError (ERR_OUT_OF_RANGE), before any syscall.
  • Assignment on a closed terminal and on Windows stays a no-op. Every defined termios flag fits in 32 bits. All 16 assignments in the repo pass such a value.
  • Verified: test/js/bun/terminal/terminal.test.ts (13 new tests, 12 fail on 1.4.3-canary), and all of test/js/bun/terminal/.
  • Self-reviewed: 4 concerns raised, 4 addressed. Notes has one question for a maintainer.

Background

  • termios is the POSIX terminal attribute struct. Its four flag words have the type tcflag_t: 32 bits everywhere except macOS (64 bits).
  • A flag setter reads the struct from the PTY master with tcgetattr, replaces one word, and writes it back with tcsetattr.
  • validate_uint32 (src/runtime/node/util/validators.rs:169) ports Node's validateUint32. It never runs user JS.
Notes

Question for a maintainer: strict uint32, or accept a negative int32 as its bit pattern?

This PR is strict. t.localFlags = t.localFlags & ~ECHO throws ERR_OUT_OF_RANGE when bit 31 is set (CRTSCTS in c_cflag on Linux, NOFLSH in c_lflag on macOS and the BSDs), because the expression is negative. The caller must write (t.localFlags & ~ECHO) >>> 0. The new JSDoc says so, and a test pins it.

  • For strict: Buffer.writeUInt32LE(1 << 31), zlib.crc32("x", -1), fs.chmodSync(p, ~0o22) and process.umask(-1) all throw ERR_OUT_OF_RANGE for a negative int32, with the same message. The released build already breaks this idiom: it writes 0, which clears every flag without an error. A later change from strict to lenient does not break anyone. The reverse does.
  • For lenient (accept -2147483648..4294967295, take a negative value as its unsigned 32-bit pattern): the read-modify-write idiom then works with no >>> 0, as it does in C. It is validate_integer with a different range plus one cast.

Say which one you want and I will switch it.

Order with #39162. #39162 is open and edits the same function. It makes tcgetattr and tcsetattr failures throw. The two PRs conflict in Terminal.rs and in the test file. If this PR lands first, #39162 keeps its syscall error throws and drops three things that no longer apply: the coerce_f64 call with the closed check moved after it, the test setting flags on a terminal closed while coercing the value is a no-op (an object is now a TypeError, valueOf() is never called), and its edit of the NaN test.

Replaced test. NaN clamps to tcflag_t max, same as Infinity came from #32354 and pinned the behaviour this PR removes. The 13 new tests replace it.

JSDoc. The four properties said "Setting returns true on success, false on failure". A setter cannot return a value, so that sentence is gone. The new text gives the range, the two error types, the no-op cases, and the >>> 0 rule on the two properties where a defined flag uses bit 31. #39162 rewrites the same sentence, so that hunk needs a manual merge too.

Why validate_uint32 and not global.validate_integer_range. validate_integer_range maps undefined and NaN to a default, so a setter needs two checks in front of it, and it reports a fraction as must be of type integer. Received number. validate_uint32 covers every case in one call and gives the same messages as fs.chmodSync and process.umask for the same kind of value. The tests pin the error class, the code, and that the message names the property. They do not pin the full text, because #38663, #35423 and #35429 rewrite the ERR_INVALID_ARG_TYPE wording of the shared validators.

Before and after, open terminal on Linux x64 (localFlags, initial 0x8a3b).

assigned 1.4.3-canary this PR
undefined, "bogus", {} 0xffffffff TypeError ERR_INVALID_ARG_TYPE
null, [] 0 TypeError ERR_INVALID_ARG_TYPE
"8", true 8, 1 TypeError ERR_INVALID_ARG_TYPE
{ valueOf() {...} } calls valueOf() TypeError, valueOf() not called
NaN, Infinity, 2**32 0xffffffff RangeError ERR_OUT_OF_RANGE
-Infinity, -1 0 RangeError ERR_OUT_OF_RANGE
1.5 1 RangeError ERR_OUT_OF_RANGE
0 to 0xffffffff (integers) applied applied

Other checks. cargo clippy -p bun_runtime --no-deps and cargo check -p bun_runtime are clean for x86_64-unknown-linux-gnu and aarch64-apple-darwin (the cast to tcflag_t widens on macOS). macOS can hold flag bits above 31, but no flag is defined there and JS bitwise operators cannot produce them.


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

…int32

The `inputFlags`, `outputFlags`, `localFlags` and `controlFlags` setters of
`Bun.Terminal` coerced the assigned value with ToNumber and clamped it with
`f64::min`, which returns the operand that is not NaN. So `undefined`,
`NaN`, `"bogus"` and `{}` all wrote `tcflag_t::MAX` (every bit) to the PTY.

The setters now validate the value with `validate_uint32`. A value that is
not a number throws a TypeError (ERR_INVALID_ARG_TYPE). NaN, an infinity, a
fraction, a negative number and a number above 0xffffffff throw a
RangeError (ERR_OUT_OF_RANGE). The PTY is not touched in those cases.

An assignment on a closed terminal, and on Windows, stays a no-op.
@robobun

robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: the fix is complete and waits for a maintainer. The diff is green on every platform. CI is red only on tests that this PR does not touch.

How to reproduce (bun 1.4.3-canary and main, Linux x64, deterministic):

const t = new Bun.Terminal({ cols: 80, rows: 24, data() {} });
const before = t.localFlags; // 35387 (0x8a3b)
for (const v of [undefined, NaN, "bogus", {}]) {
  t.localFlags = before;
  t.localFlags = v;
  console.log(String(v), t.localFlags); // 4294967295 (0xffffffff) every time
}
t.close();

The same happens for inputFlags, outputFlags and controlFlags. With this PR each assignment throws (TypeError ERR_INVALID_ARG_TYPE, or RangeError ERR_OUT_OF_RANGE for NaN) and t.localFlags stays 35387.

Tests: USE_SYSTEM_BUN=1 bun test test/js/bun/terminal/terminal.test.ts -t "termios flags" fails 12 of the 13 new tests. bun bd test on this branch passes all 20 in that block, and all of test/js/bun/terminal/ (145 pass). In CI, test/js/bun/terminal/terminal.test.ts passed on every lane of both builds. That includes the three macOS lanes, where tcflag_t is 64 bits.

CI:

  • Build #115111 was red only on test/js/bun/s3/s3.test.ts. The R2 "upload large files" tests timed out on all 12 lanes. The builds of other branches failed the same test from about 10:51 to 12:26 UTC. It passes again since about 12:32 UTC.
  • Build #115125 (one rerun) is red only on test/js/bun/spawn/spawn-pipe-leak.test.ts on ubuntu 25.04 x64: 'pipe' stdout if read before exit should not leak memory, peak RSS 74 MB to 774 MB against a limit of 80%. The same file is flaky in builds of main (#115091) and of other branches.
  • Every other failure in both builds passed on retry. This PR does not touch S3 or the Bun.spawn pipes. I reported both red tests as failures on main.

Needs from a maintainer: a review, and an answer to the question in the PR notes: keep strict uint32, or accept a negative int32 as its bit pattern.

@coderabbitai

coderabbitai Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 691dfc0f-065d-4bf8-8d38-b575677e2455

📥 Commits

Reviewing files that changed from the base of the PR and between 09bb546 and cbb9fb9.

📒 Files selected for processing (3)
  • packages/bun-types/bun.d.ts
  • src/runtime/api/bun/Terminal.rs
  • test/js/bun/terminal/terminal.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 2 remain after this review.


Walkthrough

Terminal flag setters now validate unsigned 32-bit inputs. Documentation and tests describe behavior for invalid values, accepted boundaries, closed terminals, Windows, and object inputs.

Changes

Terminal flag validation

Layer / File(s) Summary
Setter validation semantics
packages/bun-types/bun.d.ts, src/runtime/api/bun/Terminal.rs
The four Terminal flag setters use named unsigned 32-bit validation. Documentation describes thrown errors, ignored assignments, and unsigned conversion guidance.
Cross-property setter tests
test/js/bun/terminal/terminal.test.ts
Tests cover invalid and valid values, mutation preservation, object coercion, closed terminals, and all four flag properties.

Suggested reviewers: jarred-sumner

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to fc357

The terminal flag validation changes have no remaining identified merge-blocking issue.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly and concisely identifies the main change: termios flag setters now reject values that are not valid uint32 values.
Description check ✅ Passed The description explains the problem, implementation, behavior, testing, and verification results. It does not use the exact template headings, but it provides the required information and is complete…

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.

I reviewed this and found no bugs. Because it changes user-facing setter semantics (previously-coerced values now throw), the author has an open strict-vs-lenient question for a maintainer, and packages/bun-types/ has a CODEOWNER, a human sign-off is still worthwhile.

What was reviewed:

  • set_termios_flag: closed/Windows short-circuit still precedes validation; validate_uint32 never runs user JS, so the old valueOf-during-coercion reentrancy path is gone; u32 as libc::tcflag_t widens on macOS and is identity on Linux.
  • Tests: test.each matrix asserts error class + code + property name (not full message), >>> 0 boundary and 0/0xffffffff accepted, toPrimitive mock proves no coercion; the closed-terminal no-op test is meaningful on POSIX and vacuously true on Windows (setter short-circuits before validation there anyway).
  • JSDoc: matches the new runtime contract; no */ hazards.
Extended reasoning...

Overview

The PR replaces the f64 coerce-and-clamp in Terminal::set_termios_flag (src/runtime/api/bun/Terminal.rs) with a call to the shared crate::node::validators::validate_uint32, so assigning a non-number to inputFlags/outputFlags/localFlags/controlFlags throws ERR_INVALID_ARG_TYPE and a non-uint32 number throws ERR_OUT_OF_RANGE. It updates the JSDoc for those four properties in packages/bun-types/bun.d.ts and replaces the old "NaN clamps to max" test in test/js/bun/terminal/terminal.test.ts with a describe.each-style matrix covering type errors, range boundaries, the >>> 0 bit-31 case, no-coercion, and closed-terminal no-op.

Security risks

None. The change tightens input validation on a local PTY attribute setter and removes a path that ran user JS (coerce_f64 → valueOf/Symbol.toPrimitive) while holding terminal state; validate_uint32 checks is_number() before as_number() and never enters user code. No auth, crypto, network, or filesystem surface is touched.

Level of scrutiny

Medium. The native diff is small and mechanical (swap hand-rolled clamp for the in-tree validator), and the test coverage is thorough. However, this is a user-facing behavioral change on a Bun.* API: values that previously coerced silently (e.g. "8", true, or a negative int32 from flags & ~ECHO when bit 31 is set) now throw. The PR description explicitly asks a maintainer to choose between strict uint32 and accepting negative int32 bit patterns — that is an API-design call a human should make. Additionally, packages/bun-types/ is CODEOWNER-gated.

Other factors

The implementation follows REVIEW.md guidance: it uses the shared validator instead of hand-rolled range logic, the tests assert error code and class rather than pinning message text (which other open PRs are rewriting), boundaries are tested at and one-past, and the deleted test is fully superseded by the new suite. The u32 as libc::tcflag_t cast is safe on all supported targets (u32 on Linux/BSD, u64 on macOS — widening). The closed-terminal no-op test is not skipIf(isWindows)-gated; on Windows it passes for a different reason (the setter is unconditionally a no-op there), which is harmless but means it only guards the ordering invariant on POSIX. The PR also notes a textual conflict with open PR #39162 in the same function that will need manual resolution.

@robobun

robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:05 AM PT - Sep 13th, 2026

❌ @robobun, your commit fc35767 has 1 failures in Build #115125 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 42589

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

bun-42589 --bun

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