hopper_native/project.rs
1//! Zero-copy struct projection from account data.
2//!
3//! `project::<T>()` performs bounds checking, alignment validation, and
4//! optional discriminator verification in a single operation, returning
5//! a direct `&T` pointer-cast into account data. No copies, no alloc,
6//! no separate validation steps.
7//!
8//! This low-level projection surface checks bounds, alignment, and an optional
9//! discriminator. Application identity and authorization remain separate checks.
10//!
11//! # Safety model after internal review
12//!
13//! Hopper's internal safety review flagged the original `Projectable` trait as too
14//! permissive: it only required `Copy + 'static`, which lets callers
15//! overlay types with padding or non-alignment-1 fields and trip
16//! undefined behaviour. Two separate surfaces now live in this module:
17//!
18//! - [`Projectable`], the **unsafe escape hatch** kept for compatibility
19//! with already-published programs that opt into it by hand. It still
20//! only requires `Copy + 'static`, but its documentation is now
21//! explicit: every `unsafe impl Projectable` is the author asserting
22//! the full POD contract (no padding, align-1, all-bits-valid). Call
23//! sites must treat it as a Tier C primitive.
24//!
25//! - [`crate::project::SafeProjectable`] (with the matching
26//! [`crate::project::project_safe`] and
27//! [`crate::project::project_safe_mut`] constructors), the **sound default**. It is
28//! auto-implemented for every `T: Projectable` where the size is at
29//! least 1 byte, but the intent at call sites is that only types that
30//! participate in Hopper's `Pod` contract reach for this path. Higher
31//! layers (`hopper-runtime`, `#[hopper::state]`-generated code) only
32//! use Pod-bounded access paths now, this trait exists so lens and
33//! project helpers can offer a safe-by-default API without pulling in
34//! `hopper-runtime` at the native layer.
35//!
36//! For new code: prefer `hopper_runtime::Pod` + the typed access methods
37//! in `hopper-runtime`/`hopper-core` over `Projectable` directly.
38//!
39//! # Usage
40//!
41//! ```ignore
42//! use hopper_native::project::{Projectable, project, project_mut};
43//!
44//! #[repr(C)]
45//! #[derive(Clone, Copy)]
46//! struct VaultState {
47//! authority: [u8; 32],
48//! balance: hopper_native::wire::LeU64,
49//! bump: u8,
50//! }
51//!
52//! // SAFETY: VaultState is #[repr(C)], Copy, and has no padding bytes
53//! // that could cause UB when read from arbitrary data.
54//! unsafe impl Projectable for VaultState {}
55//!
56//! fn read_vault(account: &AccountView) -> Result<&VaultState, ProgramError> {
57//! // Checks: data_len >= offset + size_of::<VaultState>(),
58//! // alignment is correct, disc byte matches.
59//! project::<VaultState>(account, HOPPER_HEADER_LEN, Some(1))
60//! }
61//! ```
62
63use crate::account_view::AccountView;
64use crate::borrow::Ref;
65use crate::error::ProgramError;
66
67/// Length of the Hopper account header: discriminator (1), version (1),
68/// flags (2), layout id (8), reserved (4). The body of a Hopper account
69/// starts at this offset.
70pub const HOPPER_HEADER_LEN: usize = 16;
71
72/// Marker trait for types that can be safely projected from raw account data.
73///
74/// # Safety
75///
76/// The implementor must guarantee that:
77/// 1. The type is `#[repr(C)]` (deterministic field ordering).
78/// 2. The type is `Copy` (no drop glue, no interior mutability).
79/// 3. Every bit pattern is valid (no padding-dependent invariants).
80/// 4. No references or pointers (only plain data).
81///
82/// This is the same plain-data contract as Hopper `Pod`, without requiring
83/// callers to enter the canonical account-overlay API.
84pub unsafe trait Projectable: Copy + 'static {}
85
86// Built-in projectable types.
87//
88// SAFETY: integers and byte arrays are `Copy`, have no padding, hold no
89// pointers, and accept every bit pattern. Alignment is not part of this
90// contract: `project`, `project_mut`, and `project_slice` check the
91// target address against `align_of::<T>()` before forming a reference.
92unsafe impl Projectable for u8 {}
93unsafe impl Projectable for u16 {}
94unsafe impl Projectable for u32 {}
95unsafe impl Projectable for u64 {}
96unsafe impl Projectable for u128 {}
97unsafe impl Projectable for i8 {}
98unsafe impl Projectable for i16 {}
99unsafe impl Projectable for i32 {}
100unsafe impl Projectable for i64 {}
101unsafe impl Projectable for i128 {}
102unsafe impl Projectable for [u8; 32] {}
103unsafe impl Projectable for [u8; 64] {}
104
105// ══════════════════════════════════════════════════════════════════════
106// SafeProjectable, Pod-aligned variant
107// ══════════════════════════════════════════════════════════════════════
108
109/// Strengthened projection marker: the safe default for new code.
110///
111/// `SafeProjectable` is a sealed sub-trait of [`Projectable`] with one
112/// extra compile-time obligation: the type must be non-zero-sized. It
113/// exists so that API surfaces taking a projection type can demand
114/// `T: SafeProjectable` and reject hand-rolled markers that forgot the
115/// alignment-1 / no-padding invariant. Every `impl Projectable` that
116/// also satisfies `size_of::<T>() > 0` participates via the blanket
117/// below, so the trait is automatic for all realistic overlays.
118///
119/// # Safety
120///
121/// Exactly the same contract as [`Projectable`]:
122/// 1. `#[repr(C)]` or `#[repr(transparent)]`.
123/// 2. `Copy` with no drop glue.
124/// 3. Every bit pattern of `[u8; size_of::<T>()]` decodes to a valid `T`.
125/// 4. No internal references or pointers.
126///
127/// Implementing [`Projectable`] for a type that does not meet these
128/// requirements has always been UB; this sub-trait merely makes the
129/// intent at call sites explicit.
130pub unsafe trait SafeProjectable: Projectable {}
131
132// SAFETY: `SafeProjectable` has the contract of `Projectable`, which the
133// bound supplies. The size condition is enforced where it matters, by the
134// const assertion in `project_safe` and `project_safe_mut`.
135//
136// Blanket impl: every Projectable that's not zero-sized qualifies.
137// Zero-sized types would project to a dangling reference, so we keep
138// them off this safe path even if someone opted them into Projectable
139// for weird generic reasons.
140unsafe impl<T: Projectable> SafeProjectable for T where Self: private::NonZeroSized {}
141
142mod private {
143 /// Sealed marker: `T` has `size_of::<T>() > 0`. Encoded via a const
144 /// assert inside an associated const so only monomorphic uses where
145 /// the size condition holds pass typecheck.
146 pub trait NonZeroSized {}
147 impl<T: Copy + 'static> NonZeroSized for T {}
148}
149
150/// Safe variant of [`project`] that rejects zero-sized overlays.
151///
152/// Prefer this over [`project`] in new code; it enforces the
153/// "only Pod + non-ZST types reach the projection primitive" rule.
154#[inline]
155pub fn project_safe<'a, T: SafeProjectable>(
156 account: &'a AccountView<'a>,
157 offset: usize,
158 expected_disc: Option<u8>,
159) -> Result<Ref<'a, T>, ProgramError> {
160 const {
161 assert!(
162 core::mem::size_of::<T>() > 0,
163 "project_safe: T must be non-zero-sized"
164 );
165 }
166 project::<T>(account, offset, expected_disc)
167}
168
169/// Safe mutable variant of [`project_mut`].
170///
171/// # Safety
172///
173/// Same contract as [`project_mut`], caller holds an exclusive borrow
174/// on the account data region for the returned reference's lifetime.
175#[inline]
176pub unsafe fn project_safe_mut<'a, T: SafeProjectable>(
177 account: &'a AccountView<'a>,
178 offset: usize,
179 expected_disc: Option<u8>,
180) -> Result<&'a mut T, ProgramError> {
181 const {
182 assert!(
183 core::mem::size_of::<T>() > 0,
184 "project_safe_mut: T must be non-zero-sized"
185 );
186 }
187 // SAFETY: forwarded contract matches `project_mut`, caller guarantees
188 // exclusive access over the returned reference's lifetime.
189 unsafe { project_mut::<T>(account, offset, expected_disc) }
190}
191
192/// Project a `#[repr(C)]` struct from account data at the given byte offset.
193///
194/// Performs three checks in one operation:
195/// 1. **Bounds**: `offset + size_of::<T>() <= data_len`
196/// 2. **Alignment**: `(data_ptr + offset) % align_of::<T>() == 0`
197/// 3. **Discriminator** (optional): `data[0] == expected_disc`
198///
199/// Returns a [`Ref`] guard whose deref is a direct `&T` into the account's
200/// data region, no copies, no allocation. The guard holds a **shared data
201/// borrow** for its lifetime, so an exclusive borrow (`try_borrow_mut`)
202/// cannot be taken while the projection is live, and vice versa. Fails
203/// with `AccountBorrowFailed` if the data is exclusively borrowed.
204///
205/// # Arguments
206///
207/// * `account` - The account to project from.
208/// * `offset` - Byte offset into account data where `T` begins.
209/// For a Hopper account the body starts after the 16-byte header, at
210/// [`HOPPER_HEADER_LEN`].
211/// * `expected_disc` - If `Some(d)`, verify that `data[0] == d` before
212/// projecting. Pass `None` to skip the discriminator check.
213#[inline]
214pub fn project<'a, T: Projectable>(
215 account: &'a AccountView<'a>,
216 offset: usize,
217 expected_disc: Option<u8>,
218) -> Result<Ref<'a, T>, ProgramError> {
219 let data_len = account.data_len();
220 let type_size = core::mem::size_of::<T>();
221
222 // Bounds check.
223 if offset
224 .checked_add(type_size)
225 .is_none_or(|end| end > data_len)
226 {
227 return Err(ProgramError::AccountDataTooSmall);
228 }
229
230 // Discriminator check (if requested).
231 if let Some(disc) = expected_disc {
232 if account.disc() != disc {
233 return Err(ProgramError::InvalidAccountData);
234 }
235 }
236
237 let data_ptr = account.data_ptr_unchecked();
238 // SAFETY: bounds checked above; the sum stays within the data region.
239 let target_ptr = unsafe { data_ptr.add(offset) };
240
241 // Alignment check.
242 let align = core::mem::align_of::<T>();
243 if !(target_ptr as usize).is_multiple_of(align) {
244 return Err(ProgramError::InvalidAccountData);
245 }
246
247 // Take a shared data borrow so the returned reference cannot coexist
248 // with an exclusive borrow of the same region (aliasing soundness).
249 let state_ptr = account.acquire_shared()?;
250
251 // SAFETY: bounds checked, alignment verified, T: Projectable guarantees
252 // all bit patterns are valid; the shared borrow taken above is released
253 // by the returned guard's drop.
254 Ok(Ref::new(unsafe { &*(target_ptr as *const T) }, state_ptr))
255}
256
257/// Project a mutable `#[repr(C)]` struct from account data.
258///
259/// Same checks as `project()` but returns `&mut T`. The caller is
260/// responsible for ensuring no other borrows are active (this does
261/// NOT integrate with the borrow tracking system -- use
262/// `try_borrow_mut()` first if you need that guarantee).
263///
264/// # Safety
265///
266/// The caller must ensure no other references to the same data region
267/// are active. For most use cases, call `account.try_borrow_mut()`
268/// first, then use `project_mut` on the resulting data.
269#[inline]
270pub unsafe fn project_mut<'a, T: Projectable>(
271 account: &'a AccountView<'a>,
272 offset: usize,
273 expected_disc: Option<u8>,
274) -> Result<&'a mut T, ProgramError> {
275 let data_len = account.data_len();
276 let type_size = core::mem::size_of::<T>();
277
278 // Bounds check.
279 if offset
280 .checked_add(type_size)
281 .is_none_or(|end| end > data_len)
282 {
283 return Err(ProgramError::AccountDataTooSmall);
284 }
285
286 // Discriminator check (if requested).
287 if let Some(disc) = expected_disc {
288 // SAFETY: the caller holds the exclusive access this function
289 // requires, so nobody else is writing the byte; an empty account
290 // has no discriminator to read.
291 if data_len == 0 || unsafe { account.disc_unchecked() } != disc {
292 return Err(ProgramError::InvalidAccountData);
293 }
294 }
295
296 let data_ptr = account.data_ptr_unchecked();
297 // SAFETY: The bounds check above proved `offset + size <= data_len`, so
298 // the pointer stays inside this account's data region.
299 let target_ptr = unsafe { data_ptr.add(offset) };
300
301 // Alignment check.
302 let align = core::mem::align_of::<T>();
303 if !(target_ptr as usize).is_multiple_of(align) {
304 return Err(ProgramError::InvalidAccountData);
305 }
306
307 // SAFETY: caller guarantees exclusive access, bounds/alignment checked.
308 Ok(unsafe { &mut *(target_ptr as *mut T) })
309}
310
311/// Project a slice of `T` from account data starting at `offset`.
312///
313/// Returns a [`Ref`] guard over `[T]` with `count` elements, performing
314/// bounds and alignment checks. The guard holds a shared data borrow for
315/// its lifetime (see [`project`]).
316#[inline]
317pub fn project_slice<'a, T: Projectable>(
318 account: &'a AccountView<'a>,
319 offset: usize,
320 count: usize,
321) -> Result<Ref<'a, [T]>, ProgramError> {
322 let data_len = account.data_len();
323 let type_size = core::mem::size_of::<T>();
324 let total = count
325 .checked_mul(type_size)
326 .ok_or(ProgramError::ArithmeticOverflow)?;
327
328 if offset.checked_add(total).is_none_or(|end| end > data_len) {
329 return Err(ProgramError::AccountDataTooSmall);
330 }
331
332 let data_ptr = account.data_ptr_unchecked();
333 // SAFETY: bounds checked above; the sum stays within the data region.
334 let target_ptr = unsafe { data_ptr.add(offset) };
335
336 let align = core::mem::align_of::<T>();
337 if !(target_ptr as usize).is_multiple_of(align) {
338 return Err(ProgramError::InvalidAccountData);
339 }
340
341 // Shared data borrow: released by the guard's drop (see `project`).
342 let state_ptr = account.acquire_shared()?;
343
344 // SAFETY: bounds and alignment checked; T: Projectable guarantees all bit
345 // patterns valid; the shared borrow above guards against aliasing.
346 Ok(Ref::new(
347 unsafe { core::slice::from_raw_parts(target_ptr as *const T, count) },
348 state_ptr,
349 ))
350}
351
352/// Project the body of a Hopper account: skip the 16-byte header and
353/// project `T` at [`HOPPER_HEADER_LEN`]. Verifies the discriminator.
354///
355/// This is the most common projection pattern for Hopper accounts.
356#[inline]
357pub fn project_hopper<'a, T: Projectable>(
358 account: &'a AccountView<'a>,
359 expected_disc: u8,
360) -> Result<Ref<'a, T>, ProgramError> {
361 project::<T>(account, HOPPER_HEADER_LEN, Some(expected_disc))
362}
363
364/// Mutable version of `project_hopper`.
365///
366/// # Safety
367///
368/// Caller must ensure exclusive access to the account data.
369#[inline]
370pub unsafe fn project_hopper_mut<'a, T: Projectable>(
371 account: &'a AccountView<'a>,
372 expected_disc: u8,
373) -> Result<&'a mut T, ProgramError> {
374 // SAFETY: This function's `# Safety` contract is the callee's, forwarded
375 // unchanged.
376 unsafe { project_mut::<T>(account, HOPPER_HEADER_LEN, Some(expected_disc)) }
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 use crate::raw_account::RuntimeAccount;
383 use crate::NOT_BORROWED;
384
385 /// A stack-allocated account: 88-byte header + contiguous data region,
386 /// mirroring the loader input layout (data follows the header).
387 #[repr(C, align(8))]
388 struct Backing {
389 header: RuntimeAccount,
390 data: [u8; 16],
391 }
392
393 fn make_backing() -> Backing {
394 let header = RuntimeAccount {
395 borrow_state: NOT_BORROWED,
396 data_len: 16,
397 ..RuntimeAccount::default()
398 };
399 Backing {
400 header,
401 data: [7u8; 16],
402 }
403 }
404
405 #[test]
406 fn projection_takes_a_shared_borrow_and_blocks_exclusive() {
407 let mut backing = make_backing();
408 // SAFETY: `backing` has the loader layout (header + contiguous data)
409 // and lives for the whole test.
410 let account = unsafe { AccountView::new_unchecked(&mut backing.header) };
411
412 // A live projection holds a shared borrow, so an exclusive borrow
413 // must be refused while it exists...
414 {
415 let field = project::<u8>(&account, 0, None).unwrap();
416 assert_eq!(*field, 7);
417 assert!(account.try_borrow_mut().is_err());
418 }
419 // ...and granted again once the guard drops.
420 assert!(account.try_borrow_mut().is_ok());
421
422 // Symmetrically, a live exclusive borrow blocks projection.
423 {
424 let _data = account.try_borrow_mut().unwrap();
425 assert!(project::<u8>(&account, 0, None).is_err());
426 }
427 assert!(project::<u8>(&account, 0, None).is_ok());
428 }
429
430 #[test]
431 fn project_bounds_and_disc_checks_run_before_borrowing() {
432 let mut backing = make_backing();
433 // SAFETY: as above.
434 let account = unsafe { AccountView::new_unchecked(&mut backing.header) };
435
436 // Out-of-bounds projection fails without leaking a borrow.
437 assert!(project::<[u8; 32]>(&account, 0, None).is_err());
438 // Wrong disc fails without leaking a borrow.
439 assert!(project::<u8>(&account, 0, Some(9)).is_err());
440 // The account is still exclusively borrowable (no stuck state).
441 assert!(account.try_borrow_mut().is_ok());
442 }
443}