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