Skip to content

Check ZStr/WStr constructor and errno discriminant preconditions in release builds - #38914

Open
robobun wants to merge 4 commits into
mainfrom
farm/8a969373/release-checked-constructors
Open

Check ZStr/WStr constructor and errno discriminant preconditions in release builds#38914
robobun wants to merge 4 commits into
mainfrom
farm/8a969373/release-checked-constructors

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Soundness fix, found by reading the code (item 7 of a review of the Rust port); no user-visible failure has been reported for it. ZStr::from_buf / from_buf_mut / from_slice_with_nul / from_static, WStr::from_buf / from_slice_with_nul (src/bun_core/util.rs) and Unaligned::slice_align_cast_mut are safe fns whose unsafe block is sound only if the arguments satisfy a precondition (buf[len] == 0 inside the slice, trailing NUL, alignment), and the only check was a debug_assert!. Release builds ([profile.release] has debug assertions off; scripts/build/rust.ts turns them on for the debug, asan and assertions profiles only) therefore trust all ~300 call sites: a caller that gets a length wrong hands a buffer that runs past its end, or has no terminator, to open/stat/symlinkat/CreateFileW.
  • SystemErrno::from_raw (src/errno/lib.rs) transmuted any u16 into the #[repr(u16)] enum behind a debug_assert! on POSIX and with no check at all on Windows, where the enum is sparse; Windows E::from_raw had the same shape. Two callers feed OS-reported codes straight in: impl_get_errno_libc! (lib.rs, libc errno after a -1 return) and Linux's raw-syscall impl GetErrno for usize (linux_errno.rs), which transmuted anything in 1..4096 into an enum with 134 variants. A FUSE file system or a seccomp filter can return any such code. Miri, running the new unit test against main (raw syscall result -134):
    error: Undefined Behavior: constructing invalid value of type linux_errno::SystemErrno: at .<enum-tag>, encountered 0x0086, but expected a valid enum tag
       --> src/errno/linux_errno.rs:192:18
    
  • One in-tree caller currently relies on the missing check: PackageInstall::install_from_link (src/install/PackageInstall.rs:2215) fills a [u8; 512] with a 512 byte name and calls ZStr::from_buf(&buf, 512). A debug build trips the assertion; a release build reads past the array and, because such a name exceeds NAME_MAX anyway, happens to get ENAMETOOLONG. install: stop aborting on linked package names that do not fit the symlink buffers #38571 removes that copy. Landing order: with this PR and without install: stop aborting on linked package names that do not fit the symlink buffers #38571, a name of exactly 512 bytes aborts in release with ZStr::from_buf: NUL must lie within buf instead of failing that one package (longer names already abort on main with a slice-index panic); with install: stop aborting on linked package names that do not fit the symlink buffers #38571 first, nothing user-visible changes. Either order is sound; merging install: stop aborting on linked package names that do not fit the symlink buffers #38571 first avoids the window.
  • The Zig versions of these ran the checks in ReleaseSafe, which is what shipped; the port demoted them to debug-only.

Fix

  • The string and alignment constructors keep their checks as assert! (same conditions and messages as the debug asserts had). They are safe fns, so the check is what makes the unsafe block sound; REVIEW.md's rule that a check which stops memory corruption is not debug-only applies, and bytes_as_slice_mut at the top of the same file already follows it for the same reason. Cost is one or two compares per call, each immediately followed by a syscall.
  • SystemErrno::from_raw and Windows E::from_raw build the variant with from_repr (strum::FromRepr, now derived on the POSIX enums too) and panic on an undeclared discriminant. from_repr is the exhaustive match over the declared variants, so it is the right test on Windows as well, where n < MAX is not. The errno crate no longer contains a transmute.
  • from_raw panicking is right for its remaining callers (table indices in init, and the E <-> SystemErrno hop on Windows, whose discriminant sets are identical by construction and now pinned by a test). Codes that come from the OS must not panic, so those callers are moved off it: impl_get_errno_libc! and the Linux usize impl go through from_errno; node_os homedir stores the getpwuid_r result with Error::from_code_int, keeping the raw number; the Darwin futex code decodes -errno with e_from_negated, so an unknown code reaches its existing _ => panic!("Unexpected ...") arm instead of being cast first. errno == 0 after a -1 return still yields SUCCESS, as before.
  • Decision worth an explicit look: an OS code the enum does not declare now comes out of get_errno(rc) as EIO. E is an exhaustive enum, so the raw number cannot travel through it; the alternatives are a panic on OS input (ruled out by REVIEW.md) or a catch-all variant added to every POSIX enum and its tables. EIO is the fallback the crate already uses for this situation (from_errno, Error::to_zig_err). The scope is narrow: the check!-style wrappers that produce most bun_sys::Errors never call get_errno(rc); they store the errno as an integer (Error::from_code_int), and Error::name() / to_system_error() go on reporting an undeclared code as UNKNOWN with the real errno number, as before. The 51 get_errno(rc) call sites (copy_file, Fd::close, and mostly runtime/install code matching on specific variants) are the ones that now see EIO for such a code; on main they received an invalid enum value. The ~230 Error::get_errno() method calls were already on the checked path and are unchanged.
  • Same-class functions looked at and deliberately not converted here: ZStr::as_cstr (its debug check guards CStr's no-interior-NUL documentation contract, not memory, and a hard check would scan every path before every syscall); bun_sys::windows::attr_only_open (private, one caller, NUL supplied by GetSystemDirectoryW). Three more with the same shape elsewhere are real and were handed off as their own fixes rather than folded in: DynamicBitSetList::at (bun_collections; an out-of-range index becomes an out-of-bounds write), copy_utf16_into_utf8_with_utf8_len and strings::eql_long(.., false) (both want the unsafe fn shape: the precondition is O(n), or the unchecked mode is the point). The lint's header states this boundary: its table pins what has been converted; it does not claim to find the class syntactically.
  • Verified:
    • test/internal/source-lints/safe-fn-release-checks.test.ts: locates each of the nine functions by signature, extracts the body, and requires a release-mode check (assert!/panic!, or unsafe fn) and no debug_assert!; also bans transmute::<u16, E | SystemErrno>. On main: 10 of 12 cases fail (every function, plus the three transmute sites listed by file:line); with this change: 12 pass, with bun bd test as well.
    • cargo test -p bun_errno --lib and bun run rust:miri -p bun_errno (the CI miri job covers this crate): 9 pass, including the new #[should_panic] tests for from_raw(MAX) / from_raw(u16::MAX), the get_errno tests for an undeclared libc errno and an undeclared raw-syscall errno (the latter is the Miri failure quoted above, on main), and the density test (enum_map::Enum::LENGTH == MAX, no hole below MAX) that init's range check relies on. The Windows-only tests (every SystemErrno variant through to_e, every E variant through SystemErrno::from_raw, equal variant counts; E::from_raw(138) panics) were run with cargo miri test -p bun_errno --target x86_64-pc-windows-msvc: 8 pass; the one failure, errno_mapping, fails identically on main (unsuffixed literals pick the i32 init overload on Windows; tracked separately). The test binary does not link on a Windows host, on main as well (81 unresolved bun_core/mimalloc/simdutf externals, already reported for bun_paths), so the Windows binary was exercised through the test suite instead.
    • Windows Server 2019 x64, bun bd of this branch: test/js/node/fs/translate-uv-error-windows, readdir-windows-ntstatus, rm-windows-ntstatus (4 pass, 2 skip; these go through to_e and Error::resolve_system_errno, the two callers of the checked from_raw), fs-path-length (22 pass), fs-mkdir + dir (45 pass), os.test.js (53 pass), fs.test.ts (503 pass; the one failure, readSync > works on large files, is the 5 s timeout on NTFS zero-filling the 4.9 GB file that test creates, 8-11 s on this machine).
    • cargo check / clippy of bun_errno for darwin, freebsd and windows targets, of bun_threading for darwin (the futex change), of bun_paths for windows (comment change in string_paths.rs); cargo fmt --check.
    • Linux bun bd, then bun bd test on test/js/node/fs/fs.test.ts (511 pass), fs-mkdir, fs-path-length, dir, test/js/node/os/os.test.js (homedir passes; userInfo fails here because $USER is unset in the container, same on the unmodified binary), test/js/node/zlib/zlib.test.js (387 pass; exercises slice_align_cast_mut through the zlib bindings), test/cli/install/bun-remove, bun-install-hardlink-fallback, bun-link (its "without crashing" case fails on main with any debug build, install: make the debug-build stack dump on package install failure opt-in #37335).
  • The last commit only updates comments at callers (string_paths.rs, node_fs.rs, Watcher.rs, process.rs) that still described these checks as debug-only, and adds the scope paragraph to the lint.
  • fix: address UB audit soundness findings #30924 contains a version of the errno half; this PR takes a different shape there (checked from_repr plus rerouting the OS-code callers, rather than panicking inside get_errno) and also covers the string constructors, so no code was taken from it.

Background

  • ZStr / WStr are borrowed NUL-terminated byte / UTF-16 strings: len() excludes the NUL, and the type invariant is that the unit at ptr[len] is 0 and belongs to the same allocation. from_buf(buf, len) is the safe way to borrow one out of a stack PathBuffer that was filled to len and NUL-terminated; the slice bound is what proves buf[..=len] is one allocation, which is why len < buf.len() is part of the check.
  • SystemErrno is a #[repr(u16)] enum of errno values. Holding a value that is not a declared discriminant is immediate undefined behaviour in Rust, whether or not it is ever matched on (a match on it may be compiled to a jump table indexed by the value). On POSIX the variants are dense (0..MAX, MAX = 134 on Linux); on Windows there is a dense head (0..=137) plus a tail of UV_E* discriminants around 3000-4095, and E is a second enum declared from the same lists. strum::FromRepr generates fn from_repr(u16) -> Option<Self>, a match over exactly the declared variants.
  • get_errno(rc) turns a syscall's return value into an E: on libc targets it reads the thread-local errno when rc is the failure sentinel; for raw Linux syscalls the kernel returns -errno in the result itself, and any value in -4095..=-1 denotes an error, a range wider than the enum. from_errno is the existing checked conversion (init, falling back to EIO); e_from_negated is the same for values that arrive negated. bun_sys::Error itself stores the errno as a plain integer and validates it when read, which is why the raw number survives on that path.
  • debug_assert! compiles to nothing unless debug-assertions is on. In this repo that is the dev profile (bun bd) and the asan/assertions release variants; the shipped release profile has it off, so a debug-only check in a safe fn is a release-only soundness hole, and a runtime test cannot show the difference under bun bd, which is why the regression test is a source lint.

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

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/internal/source-lints/safe-fn-release-checks.test.ts
ninja: Entering directory `/workspace/bun/build/debug'
[1/85] gen generated_host_exports.rs
generated_host_exports.rs: 92 exports (host=3, lazy=10, generic=79, rust=0); 240 extern-C blocks audited
[2/85] gen BunProcess.lut.h
Generating /workspace/bun/build/debug/codegen/BunProcess.lut.h from /workspace/bun/src/jsc/bindings/BunProcess.cpp
[3/85] gen cpp.rs (cppbind)
[4/85] gen JS modules (bundle-modules)
Preprocess modules (12483ms)
Bundle modules (224ms)
Postprocesss modules (1020ms)
Bundle Functions (1503ms)
Generate Code (17ms)

[15.29s] Bundled "src/js" for development
  2826 kb
  197 internal modules
  13 native modules
  91 internal functions across 17 files
[4/80] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

[51/80] cxx obj/src/jsc/bindings/webcore/streams/JSTextDecoderStream.cpp.o
FAILED: rust-target/x86_64-unknown-linux-gnu/debug/libbun_rust.a 
/
... (truncated)

release without fix: 10 FAILED
bun test v1.4.0-canary.1 (eabb96de7)

test/internal/source-lints/safe-fn-release-checks.test.ts:
(pass) scans a non-empty set of tracked Rust sources [0.16ms]
(pass) the extractor and the check classify the shapes it claims to [0.67ms]
239 | test.each(GUARDED)("$name checks its precondition in release builds", g => {
240 |   const defs = definitions.get(g.name)!;
241 |   // Exactly one definition: a rename or a second copy must update this table
242 |   // rather than silently dropping the function out of the lint.
243 |   expect(defs.map(d => d.source)).toHaveLength(1);
244 |   expect(violations(defs[0])).toEqual([]);
                                    ^
error: expect(received).toEqual(expected)

- []
+ [
+   "precondition is only debug_assert!ed",
+   "safe fn with no assert!/panic! in its body",
+ ]

- Expected  - 1
+ Received  + 4

      at <anonymous> (/workspace/bun/test/internal/source-lints/safe-fn-release-checks.test.ts:244:31)
(fail) ZStr::from_static checks its precondition in release builds [0.47ms]
239 | test.each(GUARDED)("$name checks its precondition in release builds", g => {
240 |   const defs = definitions.get(g.name)!;
241 |   // Exactly one def
... (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/internal/source-lints/safe-fn-release-checks.test.ts
bun test v1.4.0 (5c8268331)

test/internal/source-lints/safe-fn-release-checks.test.ts:
(pass) scans a non-empty set of tracked Rust sources [3.28ms]
(pass) the extractor and the check classify the shapes it claims to [32.44ms]
(pass) ZStr::from_static checks its precondition in release builds [7.96ms]
(pass) ZStr::from_buf checks its precondition in release builds [1.73ms]
(pass) ZStr::from_buf_mut checks its precondition in release builds [0.88ms]
(pass) ZStr::from_slice_with_nul checks its precondition in release builds [0.72ms]
(pass) WStr::from_buf checks its precondition in release builds [0.72ms]
(pass) WStr::from_slice_with_nul checks its precondition in release builds [0.69ms]
(pass) Unaligned::slice_align_cast_mut checks its precondition in release builds [0.68ms]
(pass) SystemErrno::from_raw checks its precondition in release builds [0.93ms]
(pass) E::from_raw (windows) checks its precondition in release builds [0.75ms]
(pass) no errno enum is built by transmuting a r
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     5c82683314
  features     baseline

22 deps, 123 codegen, 1176 objects in 1087ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1238] install /workspace/bun
bun install v1.4.0-canary.1 (eabb96de7)

Checked 107 installs across 153 packages (no changes) [23.00ms]
[2/1238] gen bindgenv2
[3/1238] gen ErrorCode+*.h
[4/1238] fetch zlib
[zlib] up to date
[5/1238] fetch tinycc
[tinycc] up to date
[6/1237] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (eabb96de7)

Checked 1 install across 2 packages (no changes) [5.00ms]
[7/1237] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[8/1237] gen .bind.ts → GeneratedBindings.cpp
[9/1237] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[10/1237] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[11/1237] ins
... (truncated)
diff hotspot
src/bun_core/util.rs                               |  68 +++---
 src/errno/darwin_errno.rs                          |  11 +-
 src/errno/freebsd_errno.rs                         |  11 +-
 src/errno/lib.rs                                   | 110 ++++++++-
 src/errno/linux_errno.rs                           |  22 +-
 src/errno/windows_errno.rs                         |  17 +-
 src/paths/string_paths.rs                          |   8 +-
 src/runtime/node/node_fs.rs                        |   9 +-
 src/runtime/node/node_os.rs                        |   9 +-
 src/spawn/process.rs                               |   4 +-
 src/threading/Futex.rs                             |   7 +-
 src/watcher/Watcher.rs                             |   2 +-
 .../source-lints/safe-fn-release-checks.test.ts    | 249 +++++++++++++++++++++
 13 files changed, 436 insertions(+), 91 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                      reads  edits  tests
src/bun_core/util.rs                                          5     12      0
src/errno/darwin_errno.rs                                     1      2      0
src/errno/freebsd_errno.rs                                    1      1      0
src/errno/lib.rs                                              9     13      0
src/errno/linux_errno.rs                                      2      5      0
src/errno/windows_errno.rs                                    5      6      0
src/paths/string_paths.rs                                     1      1      0
src/runtime/node/node_fs.rs                                   3      4      0
src/runtime/node/node_os.rs                                   1      2      0
src/spawn/process.rs                                          2      2      0
src/threading/Futex.rs                                        2      3      0
src/watcher/Watcher.rs                                        1      1      0
…st/internal/source-lints/safe-fn-release-checks.test.ts      1      6      0

…elease builds

ZStr::from_buf / from_buf_mut / from_slice_with_nul / from_static,
WStr::from_buf / from_slice_with_nul and Unaligned::slice_align_cast_mut are
safe fns whose unsafe block was guarded by debug_assert! only, so release
builds handed an over-long or unterminated buffer (or a misaligned slice) on
to the OS when a caller got a length wrong. Make the checks assert!.

SystemErrno::from_raw (and E::from_raw on Windows) transmuted any u16 into
the enum; build the variant through strum's from_repr and panic on an
undeclared discriminant instead. The two places that fed OS-reported codes
into from_raw (impl_get_errno_libc! and Linux's raw-syscall GetErrno impl)
now go through from_errno, so a code the table does not declare becomes EIO
rather than an invalid enum value; node_os homedir keeps the raw errno via
from_code_int and the Darwin futex code decodes through e_from_negated.

Adds bun_errno unit tests for the rejected inputs and a source lint that
checks the bodies of these functions for a release-mode check.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

Next review available in: 2 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: 824acdd4-704c-4d03-8e14-56a3935ecd32

📥 Commits

Reviewing files that changed from the base of the PR and between 7d276b9 and 5c82683.

📒 Files selected for processing (13)
  • src/bun_core/util.rs
  • src/errno/darwin_errno.rs
  • src/errno/freebsd_errno.rs
  • src/errno/lib.rs
  • src/errno/linux_errno.rs
  • src/errno/windows_errno.rs
  • src/paths/string_paths.rs
  • src/runtime/node/node_fs.rs
  • src/runtime/node/node_os.rs
  • src/spawn/process.rs
  • src/threading/Futex.rs
  • src/watcher/Watcher.rs
  • test/internal/source-lints/safe-fn-release-checks.test.ts

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

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:41 AM PT - Aug 15th, 2026

@robobun, your commit 5c82683 is building: #97988

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fix pushed (head 5c82683), waiting on CI.

Reproduced on main with cargo miri test -p bun_errno and a test that feeds a raw syscall result of -134 to get_errno (Miri reports constructing invalid value of type linux_errno::SystemErrno ... encountered 0x0086 at src/errno/linux_errno.rs:192), and by reading the bodies of the string constructors, whose only checks were debug_assert!s; test/internal/source-lints/safe-fn-release-checks.test.ts fails 10 of 12 cases against main and passes with this branch. The Windows-only unit tests were run with Miri's x86_64-pc-windows-msvc target and the Windows debug build of the branch passes the fs/os suites listed in the description. Review follow-ups (landing order relative to #38571, the EIO fallback decision, the lint's scope, and stale caller comments) are addressed in the description and the last commit.

@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 PR and didn't find any bugs. It's a well-reasoned soundness fix, but it touches core cross-platform infrastructure (ZStr/WStr constructors with ~300 call sites, the errno layer under every syscall wrapper) and embeds a couple of design choices — release-mode assert!s in hot constructors, and collapsing unknown OS errnos to EIO rather than panicking — so a maintainer should sign off.

Checked: strum::FromRepr on 0.26 emits a const fn from_repr, so the const fn from_raw bodies compile; from_errno(0) still yields SUCCESS (init(0)Some(SUCCESS)); bun_sys::e_from_negated and bun_sys::darwin::E resolve to the same bun_errno::E, so the Darwin futex match arms type-check; Error::from_code_int exists and is already the crate convention for raw errno ints. The new source lint follows the existing test/internal/source-lints/ pattern and scripts/glob-sources.ts is present.

Extended reasoning...

Overview

Promotes debug-only precondition checks to release-mode assert! in seven safe constructors in src/bun_core/util.rs (ZStr::from_buf/from_buf_mut/from_slice_with_nul/from_static, WStr::from_buf/from_slice_with_nul, Unaligned::slice_align_cast_mut), and replaces the unchecked transmute::<u16, SystemErrno> in src/errno/ with strum::FromRepr-backed from_raw that panics on undeclared discriminants. Callers that feed OS-supplied codes (impl_get_errno_libc!, Linux raw-syscall GetErrno for usize, node_os::homedir, Darwin Futex) are rerouted through checked paths (from_errno / from_code_int / e_from_negated) so an unknown errno collapses to EIO/SUCCESS instead of panicking. Adds Rust unit tests in bun_errno (round-trip, density, #[should_panic], Windows E/SystemErrno discriminant-set equality) and a new source-lint test that pins these nine functions to a release-mode check and bans transmute::<u16, E|SystemErrno>.

Security risks

None introduced. This closes soundness holes (undefined behaviour from invalid enum tags and unterminated buffers reaching syscalls) rather than opening any. No new attack surface.

Level of scrutiny

High. ZStr/WStr constructors sit under every path-taking syscall wrapper (~300 call sites per the description), and get_errno runs after every failed syscall. Adding a hard assert! here is correct per REVIEW.md ("a check that stops memory corruption is not debug-only"), but a maintainer should confirm they're comfortable with the release-build panic surface and the one-or-two-compare cost per constructor call. The errno rerouting also picks a policy — unknown OS errno → EIO — which matches the crate's existing Error::to_zig_err convention but is worth an explicit ack.

Other factors

  • The PR description notes #30924 covers the errno half with a different shape; a maintainer may want to reconcile.
  • from_raw remains const fn and now calls from_repr; strum 0.26 (per Cargo.lock) generates const fn from_repr for #[repr(u16)] enums with literal discriminants, and the PR was cargo-checked on all four targets, so this holds.
  • Verified bun_sys::e_from_negated is re-exported (src/sys/lib.rs:953) and returns the same E type the Darwin futex match arms use (src/sys/lib.rs:5624), so no type mismatch there. from_errno(posix::errno()) on errno == 0 still yields SUCCESS via init(0).
  • Test coverage is solid: Rust #[should_panic] tests, a density test pinning the 0..MAX invariant that init's range check relies on, and a source lint that fails on main for every changed function.

…ariant

Iterating the declared variants of each enum through to_e and from_raw is
both the production conversion and a few hundred match evaluations, instead
of 2 x 65536 from_repr calls, which took minutes under Miri.
Comment thread src/bun_core/util.rs Outdated
Comment thread src/bun_core/util.rs Outdated
Comment thread src/bun_core/util.rs Outdated
Comment thread src/bun_core/util.rs Outdated
Comment thread src/bun_core/util.rs Outdated
Comment thread src/bun_core/util.rs Outdated
Comment thread src/bun_core/util.rs Outdated
Comment thread src/errno/lib.rs Outdated
Comment thread src/errno/lib.rs Outdated
Comment thread src/errno/lib.rs Outdated
Comment thread src/errno/lib.rs Outdated
Comment thread src/errno/lib.rs Outdated
Comment thread src/errno/lib.rs Outdated
Comment thread src/errno/lib.rs Outdated
Comment thread src/errno/lib.rs Outdated
Comment thread src/errno/lib.rs Outdated
Comment thread src/errno/linux_errno.rs Outdated
Comment thread src/errno/windows_errno.rs Outdated
Comment thread src/threading/Futex.rs 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.

I reviewed this PR and didn't find any bugs. Because it hardens foundational safe-fn preconditions that sit under ~300 ZStr/WStr call sites and every errno decode path, and reroutes several OS-code callers (get_errno, Darwin futex, node_os::homedir) through different helpers with a new fallback semantics (undeclared errno → EIO), a human look at the design tradeoffs would be worthwhile.

What was reviewed:

  • The remaining SystemErrno::from_raw/E::from_raw callers outside bun_errno (Error::resolve_system_errno, SystemErrno::to_e) — each feeds a discriminant already validated as an E, and E/SystemErrno sharing discriminants is now pinned by e_and_system_errno_declare_the_same_discriminants.
  • e_from_negated on the Darwin futex path: an unknown code maps to SUCCESS and reaches the existing _ => panic! arm; the matched errnos (EINTR/EFAULT/ETIMEDOUT/ENOENT/EALREADY) are all under Darwin's MAX = 107.
  • from_errno(0) still yields SUCCESS (via init(0)from_raw(0)), preserving the errno == 0 after -1 behavior.
  • bun_sys::Error::from_code_int, bun_sys::e_from_negated, and bun_core::ffi::errno_ptr all exist and are exported as used.
Extended reasoning...

Overview

This PR promotes debug-only precondition checks to release-mode assert!s in seven ZStr/WStr/Unaligned safe constructors in src/bun_core/util.rs, and replaces transmute::<u16, SystemErrno|E> in src/errno/ with strum::FromRepr-based checked construction that panics on undeclared discriminants. Callers that feed OS-reported codes (impl_get_errno_libc!, Linux raw-syscall GetErrno for usize, Darwin __ulock_wait/__ulock_wake, node_os::homedir's getpwuid_r result) are rerouted to checked helpers (from_errnoEIO fallback, e_from_negated, Error::from_code_int) so an unknown kernel errno cannot panic. A new source-lint test locates each function by signature tree-wide and enforces a release-mode check in its body plus a ban on transmute::<u16, E|SystemErrno>.

Security risks

None new. The change strictly tightens: paths that were release-only UB (reading past a slice, holding an invalid enum tag) now panic. No user-controlled input reaches a new unchecked cast. The new test file spawns git ls-tree on the repo, which is consistent with sibling lints (dead-code-escapes.test.ts).

Level of scrutiny

High. ZStr::from_buf and friends front every open/stat/unlink/CreateFileW in the runtime (~300 call sites per the description), and get_errno runs on every failed syscall. Turning silent UB into a panic is correct, but any latent caller bug (the description already names one in PackageInstall::install_from_link, fixed separately in #38571) will now crash release builds instead of misbehaving. The choice of "undeclared OS errno → EIO" versus panicking, and whether every from_raw caller was audited, are design calls a maintainer should sign off on — especially given #30924 took a different shape for the errno half.

Other factors

  • I traced the remaining from_raw callers outside the errno crate: Error::resolve_system_errno (Windows) feeds either translate_uv_error_to_e's output (always a valid E, falls back to E::UNKNOWN) or a try_from_raw-validated E, and SystemErrno::to_e casts a SystemErrno discriminant — all safe under the now-tested discriminant-set equality.
  • strum::FromRepr generates a const fn for unit-variant enums, so const fn from_raw still compiles.
  • bun_sys::e_from_negated and bun_sys::Error::from_code_int both exist as used; bun_core::ffi::errno_ptr is public for the new unit test.
  • The comment-cop bot fired ~19 times on an earlier commit; commit 56afe0fb ("Trim the comments…") followed. The remaining multi-line comments in the diff are SAFETY comments (required) or pre-existing doc comments with one word changed.
  • Verification in the PR description is thorough (Miri, cross-target cargo check, Windows fs/os test suites, the new source lint failing 10/12 on main), but the Windows-only unit tests can only be type-checked, not run, on the author's setup.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

One correction to the review summary above: the Windows-only unit tests were executed, not only type-checked. cargo miri test -p bun_errno --target x86_64-pc-windows-msvc runs them from a Linux host (8 pass; the one failure, errno_mapping, fails identically on main and is tracked separately), and the Windows debug build of this branch was also run against the fs/os suites listed in the description. The description's verification section has the details.

@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 PR and didn't find any bugs. Because it changes foundational safety invariants (ZStr/WStr constructors used by ~300 syscall call sites, and the errno enum discriminant path used by every syscall wrapper) and makes a design choice between panic-on-invalid vs. the alternative shape in #30924, a human look would still be worthwhile.

Checked that the remaining from_raw callers in src/sys/Error.rs:299,305 and SystemErrno::init feed provably-declared discriminants (the new e_and_system_errno_declare_the_same_discriminants test pins the Windows E↔SystemErrno hop).
Verified bun_sys::e_from_negated and bun_sys::Error::from_code_int exist and are already re-exported/used, so the Darwin futex and node_os::homedir reroutes compile and preserve behavior for declared codes.
Confirmed assert! with a string literal is valid in const fn from_static, and that from_errno's EIO fallback matches the crate's existing convention.

Extended reasoning...

Overview

This PR promotes debug_assert! to assert! in seven safe fns in src/bun_core/util.rs (ZStr::from_static/from_buf/from_buf_mut/from_slice_with_nul, WStr::from_buf/from_slice_with_nul, Unaligned::slice_align_cast_mut) whose unsafe blocks were only sound under the asserted precondition, and replaces the transmute::<u16, SystemErrno|E> in src/errno/ with strum::FromRepr + panic on undeclared discriminants. Callers that feed OS-reported codes (impl_get_errno_libc!, Linux raw-syscall GetErrno for usize, Darwin futex, node_os::homedir) are rerouted through from_errno/e_from_negated/from_code_int so an unknown errno collapses to EIO (or preserves the raw int) rather than panicking. Six other files are comment-only updates reflecting that the checks are no longer debug-only. A new source-lint test pins the nine functions and bans the transmute pattern.

Security risks

None identified. The change strictly tightens release-build checks on paths that were previously UB (out-of-bounds NUL-terminated buffers handed to open/stat/CreateFileW; invalid #[repr(u16)] enum values). No new attack surface, no auth/crypto/permission code touched.

Level of scrutiny

High. ZStr/WStr are borrowed on the way into every path-taking syscall, and SystemErrno::from_raw/get_errno sit under every syscall error path. The PR changes what was silent UB into a release-mode panic for from_raw, which is correct for the audited internal callers but requires confidence that no production caller was missed — I traced the remaining from_raw uses in src/sys/Error.rs and windows_errno.rs::to_e and they all feed values proven valid by the new discriminant-equality test or by init's < MAX gate. The reroutes for OS-fed codes look correct and preserve behavior for declared errnos (checked e_from_negated on Darwin returns the same variant for EINTR/EFAULT/ETIMEDOUT/ENOENT/EALREADY and SUCCESS for anything else, which correctly reaches the existing _ => panic! arms).

Other factors

  • The PR description notes #30924 took a different shape (panicking inside get_errno); a maintainer should confirm this shape (checked from_repr + rerouting OS callers) is the preferred one.
  • Adding a compare-and-branch to every ZStr::from_buf is negligible next to the syscall it precedes, but it is a hot-path change a maintainer should acknowledge.
  • Windows verification was via Miri (--target x86_64-pc-windows-msvc) plus a Windows debug-build fs/os suite run; the errno unit-test binary doesn't link on a Windows host (pre-existing per the description).
  • The comment-cop bot flagged multi-line comments in an earlier revision; those were resolved in 56afe0f and the current diff's comments are one-liners.
  • The new test file follows the test/internal/source-lints/ sibling pattern (unsound-erased-box.test.ts, self-receiver-reclaim.test.ts) and imports globAllSources which exists in scripts/glob-sources.ts.

Given the breadth (13 files, foundational types, cross-platform cfg branches) and the design choice involved, this warrants a maintainer's sign-off rather than automated approval.

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