Skip to main content

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}