Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changes/unreleased/breaking-changes-20260827-002650.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Breaking changes
body: '**`OwnedView<V>` now requires `V: LifetimeParametric`, a new `unsafe` marker trait** (#376). `OwnedView::decode` hands `V::decode_view` a buffer slice forged to `''static`, and nothing previously required `V` to be parametric in that lifetime: a hand-written `impl MessageView<''static> for MyView` (or its own `Debug`/`Clone`/`PartialEq`/`Drop`/`Serialize` impl) could copy a `&''static str` out of the buffer into longer-lived storage and dereference it after the `OwnedView` dropped — a use-after-free reachable from safe code. Every `OwnedView` constructor (`decode`, `decode_with_options`, `from_owned`, `from_parts`) and `HasMessageView::decode_view_handle` / `decode_view_handle_with_options` now require the marker, whose `# Safety` section spells out the contract; codegen emits `buffa::unsafe_impl_lifetime_parametric!(FooView)` for every generated view (a macro, so the expansion stays valid under `#![forbid(unsafe_code)]`), so regenerated code is unaffected. `OwnedView`''s `Debug`, `PartialEq`, `Eq` and `Serialize` impls now delegate through `ViewReborrow::reborrow` and are bounded on `for<''b> V::Reborrowed<''b>: Trait` rather than `V: Trait`, so the view''s own impls are only ever invoked at the real buffer lifetime. Generic code that calls `decode_view_handle` adds `M::View<''static>: LifetimeParametric` at the use site (the bound cannot live on `HasMessageView` itself); `ViewReborrow::Reborrowed` now carries a `Debug` bound so `OwnedView<V>: Debug` holds for every `V: ViewReborrow` without a `for<''b>` clause. Hand-written view types used through `OwnedView` must add `unsafe impl LifetimeParametric for MyView<''static> {}` (and `ViewReborrow`, now a supertrait) after auditing against that contract; code generated by buffa 0.9 or earlier must be regenerated. Reported by HackerOne researcher waynezinn.'
time: 2026-08-27T00:26:50.625980689-07:00
10 changes: 8 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,14 @@ jobs:
- name: Miri setup (build/restore sysroot)
run: cargo +${{ env.MIRI_TOOLCHAIN }} miri setup

- name: Miri (size_cache soundness)
run: cargo +${{ env.MIRI_TOOLCHAIN }} miri test -p buffa size_cache
# `owned_view_lifetime_parametric_contract` drives every `OwnedView`
# path that touches the wrapped view (decode, Debug, Clone, PartialEq,
# reborrow, to_owned_message, drop) so a buffer read after the handle
# drops shows up as a Miri use-after-free rather than a silent bug.
- name: Miri (size_cache + OwnedView soundness)
run: >-
cargo +${{ env.MIRI_TOOLCHAIN }} miri test -p buffa --
size_cache owned_view_lifetime_parametric_contract

# ── no_std and 32-bit compilation checks ─────────────────────────────────
check-nostd:
Expand Down
2 changes: 1 addition & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ let owned: Person = request.to_owned_message()?;

**`OwnedView<V>` — views across async boundaries:**

The scoped `'a` lifetime on `MyMessageView<'a>` prevents it from satisfying `'static` bounds, which tower services, `BoxFuture<'static, _>`, and `tokio::spawn` all require. `OwnedView<V>` solves this by storing the `bytes::Bytes` buffer alongside the decoded view in a self-referential struct. Internally it extends the view's lifetime to `'static` via `transmute`, which is sound because `Bytes` is reference-counted (its heap data pointer is stable across moves), immutable, and a manual `Drop` impl ensures the view is dropped before the buffer. The synthetic `'static` is never exposed: there is no `Deref<Target = V>` impl (that would let field borrows escape the handle's scope), and access goes through `reborrow()`, which returns the view with its lifetime tied to the `OwnedView`. For ergonomics, codegen also emits a per-message `FooOwnedView` wrapper with one `&self`-tied accessor method per field.
The scoped `'a` lifetime on `MyMessageView<'a>` prevents it from satisfying `'static` bounds, which tower services, `BoxFuture<'static, _>`, and `tokio::spawn` all require. `OwnedView<V>` solves this by storing the `bytes::Bytes` buffer alongside the decoded view in a self-referential struct. Internally it extends the view's lifetime to `'static` via `transmute`, which is sound because `Bytes` is reference-counted (its heap data pointer is stable across moves), immutable, and a manual `Drop` impl ensures the view is dropped before the buffer. The synthetic `'static` is never exposed: there is no `Deref<Target = V>` impl (that would let field borrows escape the handle's scope), and access goes through `reborrow()`, which returns the view with its lifetime tied to the `OwnedView`. The view type itself does see the synthetic `'static` (its `decode_view` receives the forged slice), so every `OwnedView` constructor requires `V: LifetimeParametric` — an `unsafe` marker trait asserting that no impl on `V` can retain a borrow of that buffer past the view. Codegen emits the impl for every generated view, whose impls are all parametric in `'a` and so cannot observe the lie; a hand-written view must opt in with an explicit `unsafe impl`. As defence in depth, `OwnedView`'s own `Debug`, `PartialEq`, `Serialize` and `to_owned_message` call the view's impls on a `reborrow()`ed `V::Reborrowed<'_>` rather than on the `'static`-typed value. For ergonomics, codegen also emits a per-message `FooOwnedView` wrapper with one `&self`-tied accessor method per field.

```rust,ignore
// In an RPC handler — bytes arrives as Bytes from hyper
Expand Down
8 changes: 7 additions & 1 deletion buffa-codegen/src/tests/owned_view_codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,13 @@ fn test_owned_view_wrapper_struct_and_value_accessors() {
);
assert!(
content.contains("AsRef<::buffa::OwnedView<ItemView<'static>>> for ItemOwnedView"),
"missing AsRef impl on the wrapper: {content}"
"missing AsRef impl: {content}"
);
// The `unsafe` marker every `OwnedView` constructor requires, emitted
// via the macro so the output stays valid under `forbid(unsafe_code)`.
assert!(
content.contains("::buffa::unsafe_impl_lifetime_parametric!(ItemView);"),
"missing LifetimeParametric marker: {content}"
);
}

Expand Down
14 changes: 14 additions & 0 deletions buffa-codegen/src/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,20 @@ pub(crate) fn generate_view_with_nesting(

::buffa::impl_view_reborrow!(#view_ident);

// SAFETY (for the `unsafe impl` this expands to): the generated view
// struct is generic over `'a` and every impl emitted for it —
// `MessageView`, `Debug`, `Clone`, `Serialize`, reflection — is
// parametric in `'a`, so none can observe `OwnedView`'s forged
// `'static` or retain a buffer borrow past the view itself;
// `ViewReborrow` is the canonical `impl_view_reborrow!` shape, which
// shortens the lifetime, and `DefaultViewInstance` stores only a
// `Default`-constructed view holding no borrows. A
// hand-written view nested as a field (extern-mapped types) is only
// ever driven through those parametric impls, which forces its own
// impls to be parametric too. The macro (rather than a literal
// `unsafe impl`) keeps the output valid under `#![forbid(unsafe_code)]`.
::buffa::unsafe_impl_lifetime_parametric!(#view_ident);

#owned_view_wrapper

#reflect_view_impls
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading