Skip to content

standalone_graph: treat embedded bytecode as read-only - #34801

Closed
robobun wants to merge 4 commits into
mainfrom
farm/d43d438f/standalone-bytecode-readonly
Closed

robobun wants to merge 4 commits into
mainfrom
farm/d43d438f/standalone-bytecode-readonly

Conversation

@robobun

@robobun robobun commented Jul 20, 2026 •

Copy link
Copy Markdown
Collaborator

What

Removes the "JSC mutates the bytecode buffer in place" claim and the *mut [u8] write-provenance plumbing it justified in StandaloneModuleGraph. Also corrects the stale ELF alignment comment.

Why

There was a contradiction in the --compile code: the PE writer emits .bun as IMAGE_SCN_CNT_INITIALIZED_DATA | IMAGE_SCN_MEM_READ only (src/exe_format/pe.rs:647), and on Windows the payload is read straight out of the loaded image (src/jsc/bindings/c-bindings.cpp:1123-1130). Yet StandaloneModuleGraph insisted JSC mutates the buffer in place and carried *mut [u8] write-provenance end to end, while ELF/Mach-O map RW. Only one could be right.

Verification

Built a --compile --bytecode executable on Windows (released bun 1.4.0-canary) and ran it:

PE_SECTION .bun VA=0x4d4b000 size=28097 char=0x40000040 MEM_READ=true MEM_WRITE=false
VIRTUALQUERY Protect=0x2 PAGE_READONLY AllocProtect=0x80
RESULT 23 610
DONE

The section is PAGE_READONLY at runtime, bytecode is embedded (28 KB with --bytecode vs 3 KB without), and the program runs to completion (exit 0) for both --format=cjs and --format=esm. Any in-place write would have faulted with 0xC0000005; none does.

From JSC source:

  • CachedBytecode::span() returns std::span<const uint8_t> (vendor/WebKit/Source/JavaScriptCore/runtime/CachedBytecode.h:72)
  • Decoder::ptrForOffsetFromBase() returns const void*; decoded pointers are cached in a side m_offsetToPtrMap HashMap (CachedTypes.h:94-108), never written into the buffer
  • Bun's SourceProvider::updateCache / cacheBytecode / commitCachedBytecode are early-return; no-ops (ZigSourceProvider.cpp:294-322)
  • module_info is consumed via create_from_cached_record(&*file.module_info) which only reads and copies out

The mutation claim first appeared in the Rust rewrite (#30412); it was never in the Zig source and was never observed behavior.

Changes

  • File.bytecode / File.module_info: *mut [u8] -> &'static [u8]
  • StandaloneModuleGraph.bytes: *const [u8] -> &'static [u8]
  • get_data() (macho/pe/elf): *mut u8 -> *const u8
  • ResolvedSource.bytecode_cache: *mut u8 -> *const u8 (Rust + headers-handwritten.h)
  • ZigSourceProvider.cpp: const_cast at the CachedBytecode::create boundary (its signature takes span<uint8_t> but the payload is only ever exposed as span<const>)
  • Drops slice_to_mut and the write-provenance commentary (net -79 lines)
  • Rewrites the ELF alignment comment: the payload is PT_LOAD-mapped from the executable with the same 8-byte u64 length prefix as PE/Mach-O (verified via /proc/self/maps + readelf -l), so target_mod = 120 is required on ELF for the same reason, not incidental overhead

Mach-O PROT::WRITE (macho.rs:142-143) and the ELF RW PT_LOAD extension (elf.rs:402-424) are left as-is; they are not needed for this reason but changing segment permissions is a separate concern.

Tests

bun bd test test/bundler/bundler_compile.test.ts -t Bytecode: 16/16 bytecode tests pass. cargo check clean on all targets (linux/macos/windows x x64/aarch64).


no test proof · iteration 1 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/regression/issue/pe-codesigning-integrity.test.ts

The PE writer emits the .bun section with IMAGE_SCN_MEM_READ only (no
write bit), and on Windows the payload is read straight out of the
loaded image. StandaloneModuleGraph claimed JSC mutates the bytecode
buffer in place and therefore carried *mut [u8] write-provenance end to
end, while ELF and Mach-O map the payload RW.

Verified on Windows that a --compile --bytecode executable runs to
completion with the .bun section at PAGE_READONLY (VirtualQuery reports
Protect=0x2), for both --format=cjs and --format=esm. Any in-place write
would have faulted with STATUS_ACCESS_VIOLATION; none does. On the JSC
side CachedBytecode::span() returns std::span<const uint8_t>, and
Decoder caches decoded pointers in a side HashMap (m_offsetToPtrMap),
never in the buffer. Bun's SourceProvider::updateCache / cacheBytecode /
commitCachedBytecode are early-return no-ops.

The mutation claim first appeared in the Rust rewrite; it was never in
the Zig source and was never observed behavior.

This change:
- File.bytecode / File.module_info: *mut [u8] -> &'static [u8]
- StandaloneModuleGraph.bytes: *const [u8] -> &'static [u8]
- get_data() on all three platforms: *mut u8 -> *const u8
- ResolvedSource.bytecode_cache: *mut u8 -> *const u8 (Rust and C++)
- Drops slice_to_mut and the write-provenance commentary
- Rewrites the ELF alignment comment: the payload is PT_LOAD-mapped with
  the same 8-byte length prefix as PE/Mach-O (not heap-copied), so
  target_mod=120 is required on ELF for the same reason, not incidental
  overhead

Mach-O PROT::WRITE and the ELF RW PT_LOAD extension are left as-is;
they are not needed for this reason but changing segment permissions is
a separate concern.
@robobun

robobun commented Jul 20, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:18 AM PT - Jul 20th, 2026

❌ @robobun, your commit 72c3aa6 has some failures in Build #76182 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 34801

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

bun-34801 --bun

@coderabbitai

coderabbitai Bot commented Jul 20, 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: a51a5216-3c1e-4fa8-9e42-aaf1669b9817

📥 Commits

Reviewing files that changed from the base of the PR and between a9c7037 and 72c3aa6.

📒 Files selected for processing (7)
  • src/jsc/ResolvedSource.rs
  • src/jsc/RuntimeTranspilerStore.rs
  • src/jsc/bindings/ZigSourceProvider.cpp
  • src/jsc/bindings/headers-handwritten.h
  • src/runtime/jsc_hooks.rs
  • src/standalone_graph/StandaloneModuleGraph.rs
  • test/regression/issue/pe-codesigning-integrity.test.ts

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

autofix-ci Bot and others added 2 commits July 20, 2026 08:31
Encodes the invariant the StandaloneModuleGraph read-only refactor
depends on: the PE writer marks .bun without IMAGE_SCN_MEM_WRITE, so
Windows maps it PAGE_READONLY, and a --compile --bytecode binary must
still run to completion against those pages (i.e. the JSC decoder never
writes the embedded bytecode).

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

Beyond the inline nit, I also checked: the rewritten ELF alignment comment is accurate (elf.rs appends the payload at a page-aligned vaddr with the same 8-byte u64 prefix as PE/Mach-O — the old "heap-allocated buffer" claim was stale); bytecode_cache has no other C++ consumers, and the destructorPtr free path is unaffected (standalone uses needsDeref=false → no-op, and for the heap path .cast_const() doesn't strip deallocation provenance); and File.bytecode/.module_info have no other Rust consumers that need write access.

Extended reasoning...

Verified the factual claims underpinning the const-ification: the ELF writer (src/exe_format/elf.rs:211-358) PT_LOAD-maps the payload at a page-aligned vaddr with an 8-byte length prefix, so the rewritten target_mod = 120 comment is correct for all three platforms and the old "heap-allocated" text was wrong. Grep confirmed bytecode_cache is only read in ZigSourceProvider.cpp and file.bytecode/file.module_info only in jsc_hooks.rs (both read-only after this change). The const_cast at CachedBytecode::create is scoped and commented; updateCache/cacheBytecode/commitCachedBytecode remain early-return no-ops so no write path is reachable. Deferring to a human for sign-off on the core "JSC never mutates the bytecode span" claim since it governs unsafe provenance reasoning across the FFI boundary — the Windows PAGE_READONLY evidence is compelling but a maintainer should confirm.

Comment thread test/regression/issue/pe-codesigning-integrity.test.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing the stderr assertion — the combined { stdout, exitCode } check in 72c3aa6 looks good. No further issues found, but deferring to a human on this one: it rewrites unsafe-Rust provenance invariants in StandaloneModuleGraph and adds a const_cast justified by a reading of JSC's CachedBytecode internals, which a maintainer should sign off on.

Checked: all file.bytecode/file.module_info consumers in jsc_hooks.rs now go through .as_ptr()/&[u8] cleanly; from_bytes debug round-trip still type-checks (*mut u8 → *const u8 coercion); bytecode_cache has no other C++ write sites; the hint_source_pages_dont_need MADV_DONTNEED path is unaffected (file-backed pages fault back with identical bytes).

Extended reasoning...

Overview

This PR removes the "JSC mutates the bytecode buffer in place" claim from StandaloneModuleGraph and the *mut [u8] write-provenance plumbing it justified. Concretely: File.bytecode/File.module_info become &'static [u8], get_data() on all three platforms returns *const u8, ResolvedSource.bytecode_cache becomes *const u8 (Rust + headers-handwritten.h), slice_to_mut and ~79 lines of Stacked-Borrows commentary are deleted, and a const_cast is added at the CachedBytecode::create boundary in ZigSourceProvider.cpp. The ELF alignment comment is also rewritten to state the payload is PT_LOAD-mapped with the same 8-byte prefix as PE/Mach-O. A Windows-only regression test builds a --compile --bytecode exe, asserts .bun has no IMAGE_SCN_MEM_WRITE, and runs it to completion.

Security risks

None identified. The change tightens types (mutable → const) rather than loosening them; no new untrusted-input parsing, no auth/crypto surface. The const_cast in C++ is at a boundary where JSC's own span() accessor returns span<const uint8_t>, so it does not enable writes that weren't already possible.

Level of scrutiny

High. This is exactly the category REVIEW.md flags as most-blocked: unsafe Rust memory-provenance reasoning across an FFI boundary. The PR deletes deliberate write-provenance commentary added in #30412, and REVIEW.md is explicit that "before deleting odd-looking code, git-blame why it was written — it is usually load-bearing." The author has done that homework thoroughly (empirical PAGE_READONLY verification on Windows, JSC source citations for CachedBytecode::span()/Decoder::ptrForOffsetFromBase(), confirmation that Bun's updateCache/cacheBytecode/commitCachedBytecode are early-return no-ops), and the argument is compelling — but a maintainer who owns this code path should confirm the JSC-never-writes claim and the rewritten ELF alignment rationale.

Other factors

  • My earlier inline nit (exact-empty stderr assertion) was addressed in 72c3aa6; the test now uses a combined { stdout, exitCode } assertion and drains stderr without asserting on it.
  • Verified no other consumers of file.bytecode/file.module_info beyond jsc_hooks.rs (which the diff updates), and no other C++ sites read bytecode_cache as non-const.
  • The uint8_t* → const uint8_t* change in headers-handwritten.h is ABI-identical.
  • The debug-assertions round-trip in to_bytes still compiles (string_builder.ptr.unwrap().as_ptr() coerces *mut u8 → *const u8).
  • The new Windows test cannot be run on this machine; CI is the source of truth for it.

@robobun

robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

CI build #76182: 284 jobs passed. The two red jobs are both darwin 26 aarch64 - test-bun failing at the tart boot hook with The number of VMs exceeds the system limit (fleet capacity; the test suite never ran on that lane). Every test-level failure annotation is a known flake in unrelated subsystems (fs-promises FileHandle GC, http-client-leaky, spawn, jsonwebtoken, repl, no-orphans, fetch.stream, 20144); none touches standalone_graph / ResolvedSource / ZigSourceProvider / the new PE test.

The new Windows-only test in test/regression/issue/pe-codesigning-integrity.test.ts passed on all Windows lanes.

Diff is ready for review.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-20, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 2026
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