Skip to content

[Rust] Enable unsafe_op_in_unsafe_fn and document the runtime's unsafe operations - #9231

Open
sanil18 wants to merge 1 commit into
google:masterfrom
sanil18:harden-rust-scalar-reads
Open

[Rust] Enable unsafe_op_in_unsafe_fn and document the runtime's unsafe operations#9231
sanil18 wants to merge 1 commit into
google:masterfrom
sanil18:harden-rust-scalar-reads

Conversation

@sanil18

@sanil18 sanil18 commented Sep 9, 2026

Copy link
Copy Markdown

The gap

On edition 2018 the body of an unsafe fn is itself an implicit unsafe block, so
unsafe operations inside one never have to be acknowledged. The runtime has 38 such
bodies. Enabling the lint makes the compiler count what is sitting in them:

error: could not compile `flatbuffers` (lib) due to 54 previous errors

54 unsafe operations, none carrying a safety comment — the compiler cannot tell
"carefully reasoned about" from "happens to sit inside an unsafe fn".

#8638 closed exactly this for generated code ahead of edition 2024, but it only
touched *_generated.rs and idl_gen_rust.cpp — never the runtime crate that
generated code calls into. This closes it there.

The change

  • #![deny(unsafe_op_in_unsafe_fn)] on the runtime crate, so new unsafe code
    cannot silently regress.
  • The 54 operations wrapped in explicit unsafe blocks, each with a // SAFETY:
    comment naming the invariant and who establishes it.
  • Four safety contracts corrected — they were wrong, not merely missing.
  • A copy-pasted debug_assert! in read_scalar that reported itself as
    emplace_scalar.

The four wrong contracts

Function Documented Actually required
read_scalar s.len() > size_of::<T>() s.len() >= size_of::<T::Scalar>()
read_scalar_at s.len() >= loc + size_of::<T>() loc + size_of::<T::Scalar>()
emplace_scalar s.len() >= size_of::<T>() size_of::<T::Scalar>()
emplace_scalar_array s.len() >= size_of::<[T; N]>() buf.len() >= loc + size_of::<[T::Scalar; N]>()

read_scalar also said > where the requirement is >=. emplace_scalar_array
named a parameter that does not exist and ignored loc entirely, so a caller
honouring the documented bound could still write out of bounds.

EndianScalar is public and only its associated type is sealed, so an out-of-crate
implementation may pair a small Self with a wider Scalar. A caller honouring the
documented bound then reads out of bounds: with Self one byte and Scalar four,
Miri reports a 4-byte read from a 1-byte allocation.

No in-tree type is affected — every in-crate implementor has
size_of::<T>() == size_of::<T::Scalar>(). These are latent contract defects, but
they are what external unsafe callers are told to rely on.

No functional change

Verified rather than assumed: monster_example built with --emit asm before and
after gives 9,153 instruction lines each, zero differing. The .s files are not
byte-identical only because three .asciz panic-location records shift when lines
are added.

Test suite 316 passed / 0 failed. Builds clean for default, --no-default-features
and --features serialize. unsafe_op_in_unsafe_fn stabilised in 1.52; the crate
declares rust-version = "1.51", but that is already unreachable on master
(bitflags 2.8 requires 1.56), so the effective MSRV does not move.

Your call

  • deny vs warndeny is what stops backsliding, but it is a one-word change.
  • // SAFETY: vs // Safety: — I used the former because
    clippy::undocumented_unsafe_blocks recognises it. The crate currently has 28 of
    the latter and 1 of the former; happy to match the majority instead.

…fe ops

The Rust runtime is a zero-copy parser: `Follow::follow` and the primitives
beneath it read attacker-controlled buffers through raw pointer operations whose
correctness rests on invariants established elsewhere, usually by `Verifier`.

Where those reads appear as `unsafe` blocks inside safe functions, the crate
already documents them -- 30 blocks with 29 safety comments. The gap is the
other side: the 38 `unsafe fn` bodies. On edition 2018 the body of an `unsafe fn`
is itself an implicit `unsafe` block, so the unsafe operations inside those
functions never had to be acknowledged at all, and none of them carry a safety
comment. The compiler cannot distinguish "carefully reasoned about" from "happens
to sit inside an `unsafe fn`". Enabling the lint on the crate as it stands makes
the compiler count them: 54 errors, none documented.

That is the same gap google#8638 closed for generated code ahead of edition 2024. But
google#8638 only touched `*_generated.rs` and `idl_gen_rust.cpp`; it never touched the
runtime crate the generated code calls into, and added no safety comments. This
commit closes it for the runtime.

* Enable `#![deny(unsafe_op_in_unsafe_fn)]`, so every unsafe operation must be
  explicitly acknowledged and new unsafe code cannot silently regress.
* Wrap the 54 previously-implicit unsafe operations in explicit `unsafe` blocks,
  each with a `// SAFETY:` comment naming the invariant that makes it sound and
  who establishes it. Every `unsafe` block in the crate is now documented.
* Fix four safety contracts that were wrong rather than merely missing. Three
  documented `size_of::<T>()` where the implementation reads
  `size_of::<T::Scalar>()`:
  - `read_scalar`, which also documented `>` where the requirement is `>=`.
  - `read_scalar_at`.
  - `emplace_scalar`.
  `EndianScalar` is public and only its associated type is sealed, so an
  out-of-crate implementation may pair a small `Self` with a wider `Scalar`. A
  caller honouring the documented bound then reads out of bounds: with
  `Self` one byte and `Scalar` eight, Miri reports an 8-byte read from a 2-byte
  allocation.
  - `emplace_scalar_array` documented `s.len() >= size_of::<[T; N]>()`, naming a
    parameter that does not exist and ignoring `loc` entirely. The write starts
    at `loc`, so a caller honouring the documented bound could still write out of
    bounds. Now `buf.len() >= loc + size_of::<[T::Scalar; N]>()`.
  No in-tree caller is affected by any of these: every in-crate `EndianScalar`
  implementor has `size_of::<T>() == size_of::<T::Scalar>()`, and `flatc` emits
  offsets that fit the struct. They are latent contract defects rather than live
  bugs, but they are what external `unsafe` callers are told to rely on.
* Fix a copy-pasted `debug_assert!` message in `read_scalar` that reported itself
  as `emplace_scalar`.

No functional change. `unsafe` blocks and comments carry no runtime semantics,
and this was verified rather than assumed: compiling `monster_example` with
`--emit asm` before and after yields an identical instruction stream across
9,153 instruction lines, with zero differing instruction lines. The two `.s`
files are not byte-identical -- three embedded panic-location records differ,
because their line numbers shift when lines are added.
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