Skip to content

standalone: check the enum bytes of each module record before reading it - #39507

Open
robobun wants to merge 4 commits into
ali/standalone-entry-point-id-boundfrom
farm/ebadf5f4/standalone-module-record-enums
Open

robobun wants to merge 4 commits into
ali/standalone-entry-point-id-boundfrom
farm/ebadf5f4/standalone-module-record-enums

Conversation

@robobun

@robobun robobun commented Aug 18, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #37639: the base branch is that PR's branch, so this diff is only the follow-up its review asked for. When #37639 lands I rebase this onto main.

Problem

  • StandaloneModuleGraph::from_bytes read each 52-byte CompiledModuleGraphFile record out of the executable with read_unaligned, so the record's four #[repr(u8)] enum bytes (encoding, loader, module_format, side, offsets 48..51) became enum values without a check.
  • A byte that is not a discriminant of the enum is not a value of that enum. Reading it as one is undefined behavior at the read, before the offset checks from standalone: check every offset in the embedded module graph and reject a repeated module name #37639 run. Miri reports it at that line (see the details below).
  • Such a byte is not only damage. bun build --compile --target=bun-<os>-<arch>-v<x.y.z> and executablePath embed a graph written by this Bun into a runtime of another version, so a loader this Bun knows can be a byte the runtime does not.
  • In practice the executable ran as if the byte were valid. With 0xff in any of the four bytes, bun 1.4.0 and the debug build both printed the program's output and exited 0. Every other field of the record reports "Corrupted module graph: ..." after standalone: check every offset in the embedded module graph and reject a repeated module name #37639.

Fix

  • CompiledModuleGraphFile::read decodes a record field by field. The six StringPointers are read at their offset_of! positions (any bytes are a valid StringPointer). Each enum byte goes through from_repr (strum::FromRepr, derived on Encoding, ModuleFormat, FileSide and bun_ast::Loader). A byte that is not a discriminant returns Corruption::ModuleEncoding / ModuleLoader / ModuleFormat / ModuleSide, and the executable exits 1 with "Corrupted module graph: module is out of range".
  • from_bytes splits the module list with as_chunks and builds each File from the decoded record, so no enum value is made from an unchecked byte.
  • This is correct because to_bytes writes these bytes from the enums, so an executable whose runtime matches decodes to the same values as before (the debug round-trip at the end of to_bytes now runs the decoder on every record it writes). Any other byte is damage or a version mismatch, and standalone: check every offset in the embedded module graph and reject a repeated module name #37639 made a bad field of this record an error rather than something to act on.
  • from_repr is derived from the variant list, so an appended Loader variant keeps the check correct. Delete the schema::api mirror types and bun_api; one loader numbering across Rust/C++/JS #37095 adds the same derive to Loader for the plugin ABI, in the same position, so either landing order merges to one derive. bun_errno uses from_repr the same way for untrusted errno values.
  • Tests:
    • test/bundler/bun-build-compile.test.ts: four new cases in the corruptedGraphs table, one per enum byte. Each writes 0xff into the first record and expects exit 1 with the message that names the field. Without the src/ change all four fail (the executable runs, stdout should not run, exit 0). With it the whole file passes.
    • src/standalone_graph/StandaloneModuleGraph.rs: a record with a distinct value in every field round-trips through read, and every byte value in each enum slot is accepted exactly when it is a discriminant of that slot's enum. The test also asserts that the enum slots are the whole tail of the record, so a later field needs an entry. bun_standalone_graph joins the Miri crate set in scripts/rust-miri.ts (5 s to run, the whole set still passes), so the sweep runs under Miri, which is the tool that sees this class of bug.
  • Also run with the change: bun-build-compile-sourcemap.test.ts, compile-asset-bunfs.test.ts, bundler_html_server.test.ts (client-side records), the bytecode and embedded-file subset of bundler_compile.test.ts, cargo clippy on both crates, and bun run rust:miri.

Background

  • The module graph is the byte section bun build --compile appends to a copy of bun. from_bytes parses it at startup. Its module list is an array of #[repr(C)] CompiledModuleGraphFile records: six StringPointers ({offset: u32, length: u32} into the section) followed by the four one-byte enums.
  • A #[repr(u8)] enum is one byte wide, but only its declared discriminants are values of the type. Materializing any other byte as the enum (read_unaligned, transmute) is undefined behavior at that point, not at a later match, so the check has to look at the byte. Nothing at runtime detects it (ASAN does not, Miri does), which is why the unfixed executable appears to work.
  • strum::FromRepr derives fn from_repr(u8) -> Option<Self> from the variant list.
  • Corruption (src/standalone_graph/error.rs, from standalone: check every offset in the embedded module graph and reject a repeated module name #37639) has one variant per field of the graph that can be bad. Error::CorruptedModuleGraph(Corruption) is what prints "Corrupted module graph: ..." and exits 1.
  • The mordant check is red on the base branch too (a finding on checked_range, which standalone: check every offset in the embedded module graph and reject a repeated module name #37639 adds). This PR adds no finding.
Miri on the old shape, and the repro before and after

The new sweep test, run against a read that reads the whole record the way the old loop did (read_unaligned(record.as_ptr().cast::<Self>())), under cargo miri test -p bun_standalone_graph:

error: Undefined Behavior: constructing invalid value of type std::ptr::Unaligned<StandaloneModuleGraph::CompiledModuleGraphFile>:
       at .0.encoding.<enum-tag>, encountered 0x03, but expected a valid enum tag
   --> src/standalone_graph/StandaloneModuleGraph.rs
    |         Ok(unsafe { core::ptr::read_unaligned(record.as_ptr().cast::<Self>()) })

A compiled one-file app with 0xff written into each enum byte of its first record. Before (bun 1.4.0 release and the debug build, the same for all four bytes):

{ exitCode: 0, stdout: "should not run\n", stderr: "" }

After (debug build with this change):

error: 'main' returned error.Corrupted module graph: module encoding is out of range   (exit 1)
error: 'main' returned error.Corrupted module graph: module loader is out of range     (exit 1)
error: 'main' returned error.Corrupted module graph: module format is out of range     (exit 1)
error: 'main' returned error.Corrupted module graph: module side is out of range       (exit 1)

no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/bundler/bun-build-compile.test.ts

from_bytes read every 52-byte CompiledModuleGraphFile record out of the
executable with read_unaligned, which also turned its four repr(u8) enum
bytes (encoding, loader, module_format, side) into enum values. A byte
outside an enum's discriminants is not a value of that enum, so a damaged
executable produced undefined behavior before any of the offset checks ran;
in practice it ran as if the byte were valid.

Decode each record field by field instead: the StringPointers are read at
their offsets and the four enum bytes go through from_repr, returning a
Corruption naming the field for a byte that is not a discriminant, so the
executable exits with "Corrupted module graph: module <field> is out of
range" like the other damaged fields of the record.
@robobun

robobun commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fix and test pushed, stacked on #37639 (this PR's base branch is that PR's branch; it should retarget to main when #37639 lands).

Reproduced by compiling a one-file app and writing 0xff into each of the four enum bytes (offsets 48..51) of the first module record: bun 1.4.0 and a debug build both run the program and exit 0. With this change each one exits 1 with Corrupted module graph: module <encoding|loader|format|side> is out of range.

The four new cases in test/bundler/bun-build-compile.test.ts fail without the src/ change and pass with it; the rest of the file and the other compile suites listed in the description pass with 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 — closes the last unchecked field of the module record by validating each enum byte through from_repr before it becomes a value.

What was reviewed:

  • CompiledModuleGraphFile::read: offset_of! StringPointer reads and per-byte from_repr on the four enums; as_chunks preserves the old record count (integer division of the blob length).
  • Downstream loop: module is now owned instead of &module; all field accesses are Copy, no behavior change.
  • Test offsets 48..51 line up with the #[repr(C)] field order (6×StringPointer + 4×u8 = 52), and error strings match the new Corruption variants.
Extended reasoning...

Overview

Follow-up to #37639 in the same loop of StandaloneModuleGraph::from_bytes. The prior PR added bounds checks on every StringPointer offset in each 52-byte module record; this PR covers the remaining four bytes — the #[repr(u8)] enums encoding, loader, module_format, side — which were previously materialized directly via read_unaligned::<CompiledModuleGraphFile>(). Materializing a byte outside a #[repr(u8)] enum's discriminant set as that enum is immediate UB, so the check has to happen on the byte. The fix introduces CompiledModuleGraphFile::read which reads each StringPointer at its offset_of! position and routes each enum byte through strum::FromRepr::from_repr, returning a new Corruption::Module{Encoding,Loader,Format,Side} variant on miss. strum::FromRepr is derived on Encoding, ModuleFormat, FileSide and bun_ast::Loader. Four table-driven test cases write 0xff into each byte of the first record and assert exit 1 with the specific message.

Security risks

None. This is defensive parsing of the executable's own embedded section — it turns UB on damaged bytes into a clean error exit. No new attack surface; the input was already read, just not validated. The unsafe block in read is a read_unaligned of two u32s at an in-bounds offset of a fixed-size array, with an accurate SAFETY comment.

Level of scrutiny

Medium. The record decoder touches an unsafe read path, but it's a strict narrowing of the prior read_unaligned on the whole struct: the new code reads the same bytes at the same offsets, just with validation on the four that need it. The as_chunks::<52>() split yields the same record count as the old len / size_of (both discard any trailing remainder), and records.len() replaces modules_list_count in the entry-point-id check and reserve. The dropped let module = &module; re-borrow is fine — every downstream field access is on a Copy field.

Other factors

  • Tests extend the existing corruptedGraphs table exactly as the prior four cases do; the offsets (48–51) match the #[repr(C)] layout (6 × 8-byte StringPointer + 4 × u8), and the error substrings match the new Corruption::message arms verbatim (including "module format" for module_format).
  • from_repr is derived from the variant list, so the append-only Loader enum stays in sync automatically — the same pattern already used for errno per the PR description.
  • The PR description states the four new tests fail without the src change and pass with it, and that adjacent compile suites still pass, satisfying the "test fails for the right reason" bar.

@robobun

robobun commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Nothing to change from the review above: it confirms the record count (as_chunks vs the old len / size_of), the Copy field accesses after dropping the reborrow, and the 48..51 offsets the tests patch. No open threads on the PR; waiting on CI.

… under Miri

Two tests in the crate: a record with a distinct value in every field
round-trips through read, and every byte value in each enum slot is
accepted exactly when it is a discriminant of that slot's enum, with the
Corruption naming the slot otherwise. The second test also asserts that the
enum slots are the whole tail of the record, so a field added later needs
an entry. bun_standalone_graph joins the Miri crate set so the sweep runs
under Miri, which reports the old whole-record read as UB at the first
byte outside an enum.

The FromRepr derive on Loader moves after VariantNames, the position
another open change adds it in, so either landing order merges to one
derive.
Comment thread src/standalone_graph/StandaloneModuleGraph.rs Outdated
Comment thread src/standalone_graph/StandaloneModuleGraph.rs Outdated
Comment thread src/standalone_graph/StandaloneModuleGraph.rs Outdated
Comment thread src/standalone_graph/error.rs Outdated
Comment thread src/standalone_graph/StandaloneModuleGraph.rs Outdated
Comment thread src/standalone_graph/StandaloneModuleGraph.rs Outdated
Comment thread src/standalone_graph/error.rs Outdated
Comment thread src/standalone_graph/StandaloneModuleGraph.rs
Comment thread src/standalone_graph/StandaloneModuleGraph.rs
@robobun

robobun commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Since the first push (539f028):

The description is updated to match. The mordant check is red on the base branch as well (a finding in #37639's checked_range), and this PR adds no finding.

@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 rewrites the unsafe record-decode path in the standalone module graph loader (runs at every compiled-binary startup) and is stacked on #37639, a human look would still be worthwhile. Note that comment-cop fired again after the latest push (lines ~342 and ~709), though the flagged blocks are the required SAFETY comment and a two-line alignment note.

What was reviewed:

  • CompiledModuleGraphFile::read: verified the six offset_of! StringPointer reads stay in-bounds of the 52-byte array and that StringPointer is valid for all bit patterns (#[repr(C)] two u32s).
  • as_chunks vs the old len / size_of yields the same record count and discards the same remainder; downstream field accesses (module.name, .loader, etc.) are Copy so dropping the &module reborrow is fine.
  • Test offsets 48..51 match the repr(C) layout (6×8-byte pointers then four u8 enums, no padding); the unit test's no-padding claim in record() holds (52 bytes, align 4).
Extended reasoning...

Overview

This PR closes a UB hole in StandaloneModuleGraph::from_bytes: the loop over 52-byte CompiledModuleGraphFile records previously did a single read_unaligned::<CompiledModuleGraphFile>(), which materializes four #[repr(u8)] enum fields (encoding, loader, module_format, side) directly from disk bytes. A byte outside an enum's discriminant set is UB at the read. The fix adds CompiledModuleGraphFile::read() which reads the six StringPointer fields via read_unaligned at their offset_of! positions and each enum byte via array indexing + strum::FromRepr::from_repr, returning a new Corruption::Module{Encoding,Loader,Format,Side} error on an invalid byte. The record loop now uses as_chunks::<52>() instead of manual pointer arithmetic. Also adds Debug/PartialEq/Eq derives (for the tests), four new Corruption variants with messages, an exhaustive unit test (all 256 byte values × 4 enum positions), four integration-test cases in bun-build-compile.test.ts, and bun_standalone_graph in the Miri crate list.

Security risks

The path parses bytes from an on-disk executable that may be corrupted or tampered with. The change strictly reduces risk: it replaces UB-on-bad-input with a checked error and removes a whole-struct read_unaligned in favor of per-field reads whose safety argument is simpler (StringPointer accepts any bit pattern; enum bytes go through a checked from_repr). No new attack surface.

Level of scrutiny

Medium-high. This is startup-critical code for every bun build --compile binary and contains unsafe. The change is small and net-negative in unsafe complexity, but the layout assumptions (offsets 48..51, no padding, 52-byte records) and the switch from a reborrowed &module to an owned value both deserve a second pair of eyes even though I verified them against the #[repr(C)] definition and downstream uses.

Other factors

  • Stacked on #37639; base branch is that PR's branch, so merge order matters.
  • The comment-cop bot fired at 14:24:19–20 UTC, ~17s after the latest commit f1d517e4 (14:24:02), so two flags are still outstanding on the current diff. They target the SAFETY block inside pointer and the two-line as_chunks comment — the SAFETY comment is required by REVIEW.md, so this may just need a manual dismissal, but the author should confirm.
  • Test coverage is strong: the unit test exhaustively covers every byte value at every enum offset and asserts the enum-tail length so a new enum field forces a new table entry; the integration tests write 0xff at each offset and check for exit 1 with the field-specific message.
  • as_chunks is already used across the codebase (encoding.rs, Blob.rs, npm.rs, etc.), so no MSRV concern.
  • strum was already a dependency of bun_standalone_graph.

@robobun

robobun commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

On the two comment-cop flags from the latest push: both threads are answered and resolved. The block at line 342 is the doc comment of read (the SAFETY comment inside pointer was not flagged), and the one at line 709 is the alignment note. Both stay, for the reasons in the threads.

On this push the cargo miri test job is green with bun_standalone_graph in the set, and clippy, format and source lints pass. Buildkite is still running. Mordant is red here and on the base branch for the same checked_range finding that #37639 adds.

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