Skip to main content

hopper_native/
raw_input.rs

1//! Raw loader input parsing for Hopper Native.
2//!
3//! This is the single source of truth for Solana loader input decoding. It owns
4//! duplicate-account resolution, canonical-account lookup, and original-index
5//! tracking so higher layers operate on already-resolved account views.
6
7use core::mem::MaybeUninit;
8
9use crate::account_view::AccountView;
10use crate::address::Address;
11use crate::raw_account::RuntimeAccount;
12use crate::MAX_PERMITTED_DATA_INCREASE;
13
14const BPF_ALIGN_OF_U128: usize = 8;
15
16/// Malformed-input trap.
17///
18/// The Solana loader guarantees duplicate markers refer only to **earlier**
19/// account slots (Solana's account serialization documents the marker as
20/// "the index of the first account it is a duplicate of". necessarily a
21/// lower index). A forward-pointing marker therefore cannot be the result
22/// of a well-formed invocation: it either indicates a loader bug or
23/// adversarial input attempting to synthesize an aliasing `AccountView`.
24/// Pre-audit the parser silently fell back to account zero (or null for
25/// slot 0), which produced either a null-pointer `AccountView` or an
26/// aliasing view to an unrelated account. The Hopper Safety Audit flagged
27/// this as the most urgent must-fix. We now trap immediately via
28/// `sol_panic_` (on Solana) so the transaction fails at parse time.
29#[inline(never)]
30#[cold]
31pub(crate) fn malformed_duplicate_marker(marker: u8, slot: usize) -> ! {
32    #[cfg(target_os = "solana")]
33    // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
34    unsafe {
35        // Keep the message short and on-chain-cheap. The loader log
36        // attaches the program id automatically.
37        const MSG: &[u8] = b"hopper: malformed duplicate marker";
38        crate::syscalls::sol_panic_(MSG.as_ptr(), MSG.len() as u64, slot as u64, marker as u64);
39    }
40    #[cfg(not(target_os = "solana"))]
41    {
42        panic!(
43            "hopper: malformed duplicate marker at slot {}: marker {} points forward",
44            slot, marker
45        );
46    }
47}
48
49/// Metadata for one parsed account slot in the loader input.
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub struct RawAccountIndex {
52    /// Index of this slot in the original loader account array.
53    pub original_index: usize,
54    /// Canonical account index this slot resolves to, if duplicated.
55    pub duplicate_of: Option<usize>,
56}
57
58impl RawAccountIndex {
59    /// Whether this slot is a duplicate reference to an earlier account.
60    #[inline(always)]
61    pub const fn is_duplicate(&self) -> bool {
62        self.duplicate_of.is_some()
63    }
64}
65
66/// Instruction tail discovered after scanning the loader input buffer.
67#[derive(Clone)]
68pub struct RawInstructionFrame {
69    pub accounts_start: *mut u8,
70    pub account_count: usize,
71    pub instruction_data: &'static [u8],
72    pub program_id: Address,
73}
74
75/// Deserialize the loader input into `AccountView`s.
76///
77/// Duplicate-account resolution happens here. A duplicate slot reuses the
78/// canonical `RuntimeAccount` pointer of the earlier slot it references, and
79/// its `original_index` remains the loader slot where it appeared.
80///
81/// # Safety
82///
83/// `input` must point to a valid Solana BPF input buffer.
84#[inline(always)]
85pub unsafe fn deserialize_accounts<const MAX: usize>(
86    input: *mut u8,
87    accounts: &mut [MaybeUninit<AccountView>; MAX],
88) -> (Address, usize, &'static [u8]) {
89    // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
90    let frame = unsafe { scan_instruction_frame(input) };
91
92    let mut offset = 8usize;
93    let count = frame.account_count.min(MAX);
94
95    let mut slot = 0usize;
96    while slot < count {
97        // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
98        let marker = unsafe { *input.add(offset) };
99        if marker == u8::MAX {
100            let raw = unsafe { input.add(offset) as *mut RuntimeAccount };
101            // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
102            unsafe {
103                *accounts.get_unchecked_mut(slot) =
104                    MaybeUninit::new(AccountView::new_unchecked(raw))
105            };
106
107            let data_len = unsafe { (*raw).data_len as usize };
108            offset += RuntimeAccount::SIZE;
109            offset += data_len + MAX_PERMITTED_DATA_INCREASE;
110            // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
111            offset += unsafe { input.add(offset).align_offset(BPF_ALIGN_OF_U128) };
112            offset += 8;
113        } else {
114            let duplicate_of = marker as usize;
115            // The marker must refer strictly to an earlier slot. Anything
116            // else (forward reference, or a duplicate marker on slot 0
117            // which has no prior slot to reference) is malformed loader
118            // input. we trap rather than synthesize a null or aliasing
119            // `AccountView`.
120            if duplicate_of >= slot {
121                malformed_duplicate_marker(marker, slot);
122            }
123            // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
124            let raw = unsafe {
125                accounts
126                    .get_unchecked(duplicate_of)
127                    .assume_init_ref()
128                    .raw_ptr()
129            };
130            // SAFETY: `slot < count <= MAX`, and `raw` came from a validated
131            // earlier slot in this same frame.
132            unsafe {
133                *accounts.get_unchecked_mut(slot) =
134                    MaybeUninit::new(AccountView::new_unchecked(raw))
135            };
136            offset += 8;
137        }
138
139        slot += 1;
140    }
141
142    while slot < frame.account_count {
143        // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
144        let marker = unsafe { *input.add(offset) };
145        if marker == u8::MAX {
146            // SAFETY: `offset` is on a Solana account record boundary produced
147            // by the loader input format.
148            let raw = unsafe { input.add(offset) as *const RuntimeAccount };
149            // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
150            let data_len = unsafe { (*raw).data_len as usize };
151            offset += RuntimeAccount::SIZE;
152            offset += data_len + MAX_PERMITTED_DATA_INCREASE;
153            // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
154            offset += unsafe { input.add(offset).align_offset(BPF_ALIGN_OF_U128) };
155            offset += 8;
156        } else {
157            offset += 8;
158        }
159        slot += 1;
160    }
161
162    (frame.program_id, count, frame.instruction_data)
163}
164
165/// Fast two-argument deserialize: instruction data and program id are provided
166/// directly by the caller (from the SVM's second entrypoint register), so the
167/// full account-scan pass is skipped entirely.
168///
169/// # Safety
170///
171/// * `input` must point to a valid Solana BPF input buffer.
172/// * `ix_data` must point to the instruction data with its length stored as
173///   `u64` at offset `-8`.
174/// * `program_id` must be the correct program id for this invocation.
175#[inline(always)]
176pub unsafe fn deserialize_accounts_fast<const MAX: usize>(
177    input: *mut u8,
178    accounts: &mut [MaybeUninit<AccountView>; MAX],
179    instruction_data: &'static [u8],
180    program_id: Address,
181) -> (Address, usize, &'static [u8]) {
182    // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
183    let num_accounts = unsafe { *(input as *const u64) as usize };
184    let count = num_accounts.min(MAX);
185    let mut offset = 8usize;
186
187    let mut slot = 0usize;
188    while slot < count {
189        // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
190        let marker = unsafe { *input.add(offset) };
191        if marker == u8::MAX {
192            // SAFETY: `offset` is on a Solana account record boundary produced
193            // by the loader input format.
194            let raw = unsafe { input.add(offset) as *mut RuntimeAccount };
195            // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
196            unsafe {
197                *accounts.get_unchecked_mut(slot) =
198                    MaybeUninit::new(AccountView::new_unchecked(raw))
199            };
200
201            // SAFETY: `raw` points to the RuntimeAccount header just decoded
202            // from the current input slot.
203            let data_len = unsafe { (*raw).data_len as usize };
204            offset += RuntimeAccount::SIZE;
205            offset += data_len + MAX_PERMITTED_DATA_INCREASE;
206            // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
207            offset += unsafe { input.add(offset).align_offset(BPF_ALIGN_OF_U128) };
208            offset += 8;
209        } else {
210            let duplicate_of = marker as usize;
211            // Identical well-formedness check as the scanning-variant above.
212            if duplicate_of >= slot {
213                malformed_duplicate_marker(marker, slot);
214            }
215            // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
216            let raw = unsafe {
217                accounts
218                    .get_unchecked(duplicate_of)
219                    .assume_init_ref()
220                    .raw_ptr()
221            };
222            // SAFETY: `slot < count <= MAX`, and `raw` came from a validated
223            // earlier slot in this same frame.
224            unsafe {
225                *accounts.get_unchecked_mut(slot) =
226                    MaybeUninit::new(AccountView::new_unchecked(raw))
227            };
228            offset += 8;
229        }
230
231        slot += 1;
232    }
233
234    // Skip remaining accounts. not needed, but slot tracking isn't required
235    // since we don't need to find the instruction tail.
236
237    (program_id, count, instruction_data)
238}
239
240/// Parse just the instruction tail and account span from the loader input.
241///
242/// This supports both eager entrypoint parsing and lazy account iteration.
243/// The returned frame carries the original account span start so duplicate and
244/// canonical-account relationships remain defined at the loader level.
245///
246/// # Safety
247///
248/// `input` must point to a valid Solana BPF input buffer.
249#[inline(always)]
250pub unsafe fn scan_instruction_frame(input: *mut u8) -> RawInstructionFrame {
251    let mut scan = input;
252
253    // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
254    let num_accounts = unsafe { *(scan as *const u64) as usize };
255    scan = unsafe { scan.add(8) };
256    let accounts_start = scan;
257
258    let mut slot = 0usize;
259    while slot < num_accounts {
260        // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
261        let marker = unsafe { *scan };
262        if marker == u8::MAX {
263            let raw = scan as *const RuntimeAccount;
264            // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
265            let data_len = unsafe { (*raw).data_len as usize };
266            let mut step = RuntimeAccount::SIZE + data_len + MAX_PERMITTED_DATA_INCREASE;
267            step += unsafe { scan.add(step).align_offset(BPF_ALIGN_OF_U128) };
268            step += 8;
269            // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
270            scan = unsafe { scan.add(step) };
271        } else {
272            scan = unsafe { scan.add(8) };
273        }
274        slot += 1;
275    }
276
277    // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
278    let data_len = unsafe { *(scan as *const u64) as usize };
279    scan = unsafe { scan.add(8) };
280    let instruction_data = unsafe { core::slice::from_raw_parts(scan as *const u8, data_len) };
281    // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
282    scan = unsafe { scan.add(data_len) };
283
284    let program_id_ptr = scan as *const [u8; 32];
285    // SAFETY: This block is part of Hopper's audited zero-copy/backend boundary; surrounding checks and caller contracts uphold the required raw-pointer, layout, and aliasing invariants.
286    let program_id = Address::new_from_array(unsafe { *program_id_ptr });
287
288    RawInstructionFrame {
289        accounts_start,
290        account_count: num_accounts.min(254),
291        instruction_data,
292        program_id,
293    }
294}
295
296// =====================================================================
297// Safe bounds-checked loader-input parser (fuzz and off-chain harness).
298// =====================================================================
299//
300// The primary parser above is a pure-pointer fast path: on-chain it
301// consumes an SVM-loaded byte buffer whose layout is guaranteed by the
302// loader. Off-chain tools (`hopper dump`, `hopper test`, fuzz harnesses,
303// RPC decoders) do **not** have that guarantee. they receive arbitrary
304// byte slices. Feeding one to `scan_instruction_frame` would invite OOB
305// reads on any short / truncated input.
306//
307// `parse_instruction_frame_checked` is the safe companion: it walks a
308// `&[u8]` using a bounds-checked cursor and returns structured
309// `Result<FrameInfo, FrameError>`. It enforces exactly the same
310// duplicate-marker well-formedness rules (forward references are
311// rejected, not silently-aliased) and the same loader framing (88-byte
312// `RuntimeAccount` header, `MAX_PERMITTED_DATA_INCREASE` reserve, u128
313// alignment padding, `rent_epoch` tail, instruction_data with u64-LE
314// length prefix, 32-byte program id trailer).
315
316/// Hard cap on accounts the safe parser will record slot offsets for.
317///
318/// Matches Solana's own 256-account cap per instruction. Buffers that
319/// declare more than this are rejected with
320/// [`FrameError::AccountCountOutOfRange`].
321pub const MAX_SAFE_ACCOUNT_SLOTS: usize = 256;
322
323/// Summary of a safely-parsed loader input frame.
324///
325/// Only metadata is returned. the full `AccountView` construction
326/// requires the raw pointer path. This struct is what off-chain tools
327/// (and fuzz harnesses) need to verify a buffer is well-formed.
328///
329/// The `slot_offsets` array is a fixed `[usize; MAX_SAFE_ACCOUNT_SLOTS]`
330/// with the first `account_count` entries populated. Remaining entries
331/// are zero. Callers can distinguish duplicate vs canonical slots by
332/// checking whether `buffer[offset]` equals `0xFF`.
333#[derive(Clone, Debug, PartialEq, Eq)]
334pub struct FrameInfo {
335    /// Number of accounts the loader would hand to the program.
336    pub account_count: usize,
337    /// Byte range of the instruction data within the original buffer.
338    pub instruction_data_range: core::ops::Range<usize>,
339    /// Byte offset of the 32-byte program id within the original buffer.
340    pub program_id_offset: usize,
341    /// Byte offsets of each account slot, indexable 0..account_count.
342    pub slot_offsets: [usize; MAX_SAFE_ACCOUNT_SLOTS],
343}
344
345/// Errors returned by the safe parser.
346#[derive(Clone, Copy, Debug, PartialEq, Eq)]
347pub enum FrameError {
348    /// Buffer ended before the full frame could be parsed.
349    UnexpectedEof { needed: usize, at: usize },
350    /// Account count exceeds the compiled-in cap (256).
351    AccountCountOutOfRange(u64),
352    /// Duplicate marker refers to a non-earlier slot (forward ref or self).
353    MalformedDuplicateMarker { slot: usize, marker: u8 },
354    /// Data length field larger than the remaining buffer.
355    DataLenOutOfRange { slot: usize, data_len: u64 },
356    /// Arithmetic overflow while computing the next slot offset.
357    OffsetOverflow { slot: usize },
358}
359
360impl core::fmt::Display for FrameError {
361    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
362        match self {
363            Self::UnexpectedEof { needed, at } => {
364                write!(f, "unexpected EOF: need {needed} bytes at offset {at}")
365            }
366            Self::AccountCountOutOfRange(n) => {
367                write!(f, "account count {n} exceeds cap 256")
368            }
369            Self::MalformedDuplicateMarker { slot, marker } => {
370                write!(
371                    f,
372                    "malformed duplicate marker at slot {slot}: marker {marker} does not refer to an earlier slot"
373                )
374            }
375            Self::DataLenOutOfRange { slot, data_len } => {
376                write!(
377                    f,
378                    "slot {slot}: data_len {data_len} exceeds remaining buffer"
379                )
380            }
381            Self::OffsetOverflow { slot } => {
382                write!(f, "slot {slot}: offset arithmetic overflow")
383            }
384        }
385    }
386}
387
388/// Parse a loader-input byte buffer with full bounds checking.
389///
390/// This is the safe companion to `scan_instruction_frame` /
391/// `deserialize_accounts`. It returns `Err` (never panics, never reads
392/// out of bounds) for any malformed or truncated input, and preserves
393/// the exact same forward-duplicate-marker rejection rule that the
394/// pointer parser uses (see `malformed_duplicate_marker`).
395///
396/// Off-chain tools, fuzz harnesses, and RPC decoders should prefer
397/// this function. On-chain entrypoints continue to use the pointer
398/// parser for zero-overhead access.
399pub fn parse_instruction_frame_checked(buf: &[u8]) -> Result<FrameInfo, FrameError> {
400    // Helper: read a u64 LE at `pos`, bumping the cursor. Returns
401    // `UnexpectedEof` if the 8 bytes aren't in range.
402    fn read_u64_le(buf: &[u8], pos: &mut usize) -> Result<u64, FrameError> {
403        let end = pos
404            .checked_add(8)
405            .ok_or(FrameError::OffsetOverflow { slot: 0 })?;
406        let slice = buf.get(*pos..end).ok_or(FrameError::UnexpectedEof {
407            needed: 8,
408            at: *pos,
409        })?;
410        let mut bytes = [0u8; 8];
411        bytes.copy_from_slice(slice);
412        *pos = end;
413        Ok(u64::from_le_bytes(bytes))
414    }
415
416    fn read_u8(buf: &[u8], pos: &mut usize) -> Result<u8, FrameError> {
417        let byte = *buf.get(*pos).ok_or(FrameError::UnexpectedEof {
418            needed: 1,
419            at: *pos,
420        })?;
421        *pos += 1;
422        Ok(byte)
423    }
424
425    fn advance(buf: &[u8], pos: &mut usize, n: usize) -> Result<(), FrameError> {
426        let end = pos
427            .checked_add(n)
428            .ok_or(FrameError::OffsetOverflow { slot: 0 })?;
429        if end > buf.len() {
430            return Err(FrameError::UnexpectedEof {
431                needed: n,
432                at: *pos,
433            });
434        }
435        *pos = end;
436        Ok(())
437    }
438
439    let mut pos = 0usize;
440    let account_count = read_u64_le(buf, &mut pos)?;
441    if account_count > MAX_SAFE_ACCOUNT_SLOTS as u64 {
442        return Err(FrameError::AccountCountOutOfRange(account_count));
443    }
444    let account_count = account_count as usize;
445
446    let mut slot_offsets = [0usize; MAX_SAFE_ACCOUNT_SLOTS];
447
448    for slot in 0..account_count {
449        let slot_start = pos;
450        slot_offsets[slot] = slot_start;
451
452        let marker = read_u8(buf, &mut pos)?;
453        if marker == u8::MAX {
454            // Canonical account: the remaining 87 bytes of RuntimeAccount
455            // follow (we already consumed the marker byte).
456            advance(buf, &mut pos, RuntimeAccount::SIZE - 1).map_err(|_| {
457                FrameError::UnexpectedEof {
458                    needed: RuntimeAccount::SIZE - 1,
459                    at: pos,
460                }
461            })?;
462            // data_len lives at offset 80 in RuntimeAccount; we read it
463            // directly from the slot header. Offset within this slot:
464            // borrow_state(1) + flags(3) + resize_delta(4) + address(32) +
465            // owner(32) + lamports(8) = 80 -> data_len(8).
466            let data_len_pos = slot_start
467                .checked_add(80)
468                .ok_or(FrameError::OffsetOverflow { slot })?;
469            let mut dl_bytes = [0u8; 8];
470            let dl_slice =
471                buf.get(data_len_pos..data_len_pos + 8)
472                    .ok_or(FrameError::UnexpectedEof {
473                        needed: 8,
474                        at: data_len_pos,
475                    })?;
476            dl_bytes.copy_from_slice(dl_slice);
477            let data_len = u64::from_le_bytes(dl_bytes);
478
479            // data_bytes + realloc reserve + u128 alignment padding + rent_epoch
480            let data_sz: usize = (data_len as usize)
481                .checked_add(MAX_PERMITTED_DATA_INCREASE)
482                .ok_or(FrameError::DataLenOutOfRange { slot, data_len })?;
483            advance(buf, &mut pos, data_sz)
484                .map_err(|_| FrameError::DataLenOutOfRange { slot, data_len })?;
485            let pad = pos.wrapping_neg() & (BPF_ALIGN_OF_U128 - 1);
486            advance(buf, &mut pos, pad).map_err(|_| FrameError::UnexpectedEof {
487                needed: pad,
488                at: pos,
489            })?;
490            advance(buf, &mut pos, 8)
491                .map_err(|_| FrameError::UnexpectedEof { needed: 8, at: pos })?;
492        } else {
493            // Duplicate marker: must refer to a strictly earlier slot.
494            // This is the Hopper Safety Audit Must-Fix #1 invariant.
495            let duplicate_of = marker as usize;
496            if duplicate_of >= slot {
497                return Err(FrameError::MalformedDuplicateMarker { slot, marker });
498            }
499            // 7 padding bytes follow the marker.
500            advance(buf, &mut pos, 7)
501                .map_err(|_| FrameError::UnexpectedEof { needed: 7, at: pos })?;
502        }
503    }
504
505    // Instruction data: u64 LE length prefix + bytes.
506    let ix_data_len = read_u64_le(buf, &mut pos)? as usize;
507    let ix_start = pos;
508    advance(buf, &mut pos, ix_data_len).map_err(|_| FrameError::UnexpectedEof {
509        needed: ix_data_len,
510        at: pos,
511    })?;
512    let instruction_data_range = ix_start..pos;
513
514    // 32-byte program id trailer.
515    let program_id_offset = pos;
516    advance(buf, &mut pos, 32).map_err(|_| FrameError::UnexpectedEof {
517        needed: 32,
518        at: pos,
519    })?;
520
521    Ok(FrameInfo {
522        account_count,
523        instruction_data_range,
524        program_id_offset,
525        slot_offsets,
526    })
527}
528
529#[cfg(test)]
530mod checked_parser_tests {
531    use super::*;
532
533    /// Size of the single-account canonical frame used by tests.
534    /// 8 (account_count) + 88 (RuntimeAccount) + 10240 (realloc reserve)
535    /// + 0 (already u128-aligned at 10336) + 8 (rent_epoch)
536    /// + 8 (ix_data_len) + 32 (program_id) = 10384
537    const MINIMAL_FRAME_LEN: usize = 8 + 88 + MAX_PERMITTED_DATA_INCREASE + 0 + 8 + 8 + 32;
538
539    /// Build a valid one-canonical-account frame with zero-byte data.
540    fn build_minimal_frame() -> [u8; MINIMAL_FRAME_LEN] {
541        let mut buf = [0u8; MINIMAL_FRAME_LEN];
542        buf[0..8].copy_from_slice(&1u64.to_le_bytes()); // account_count = 1
543        buf[8] = 0xFF; // marker = canonical
544                       // remaining bytes of RuntimeAccount stay zero
545                       // realloc reserve stays zero
546                       // rent_epoch zero
547                       // ix_data_len = 0 (already zero)
548                       // program_id stays zero
549        buf
550    }
551
552    #[test]
553    fn parses_minimal_valid_frame() {
554        let buf = build_minimal_frame();
555        let frame = parse_instruction_frame_checked(&buf).expect("well-formed");
556        assert_eq!(frame.account_count, 1);
557        assert_eq!(frame.instruction_data_range.len(), 0);
558        assert_eq!(frame.program_id_offset + 32, buf.len());
559    }
560
561    #[test]
562    fn truncated_header_is_rejected() {
563        let buf = [0u8; 4]; // less than 8 bytes = no room for account_count
564        let err = parse_instruction_frame_checked(&buf).unwrap_err();
565        assert!(matches!(err, FrameError::UnexpectedEof { .. }));
566    }
567
568    #[test]
569    fn oversized_account_count_is_rejected() {
570        let mut buf = [0u8; 8];
571        buf.copy_from_slice(&1_000u64.to_le_bytes());
572        let err = parse_instruction_frame_checked(&buf).unwrap_err();
573        assert!(matches!(err, FrameError::AccountCountOutOfRange(1000)));
574    }
575
576    #[test]
577    fn forward_duplicate_marker_is_rejected() {
578        // 2-account frame where slot 0 is a duplicate of slot 1
579        // (forward reference). Must be rejected.
580        let mut buf = [0u8; 16];
581        buf[0..8].copy_from_slice(&2u64.to_le_bytes());
582        buf[8] = 1; // slot 0 marker = 1 (forward ref)
583        let err = parse_instruction_frame_checked(&buf).unwrap_err();
584        assert!(matches!(
585            err,
586            FrameError::MalformedDuplicateMarker { slot: 0, marker: 1 }
587        ));
588    }
589
590    #[test]
591    fn self_duplicate_marker_is_rejected() {
592        // Slot 0 marker=0 is self-reference: forbidden.
593        let mut buf = [0u8; 16];
594        buf[0..8].copy_from_slice(&1u64.to_le_bytes());
595        buf[8] = 0; // marker = 0, referring to slot 0 itself
596        let err = parse_instruction_frame_checked(&buf).unwrap_err();
597        assert!(matches!(
598            err,
599            FrameError::MalformedDuplicateMarker { slot: 0, marker: 0 }
600        ));
601    }
602
603    #[test]
604    fn arbitrary_short_input_never_panics() {
605        // Bounds-checking contract: feeding every length from 0..=256
606        // bytes of zeroes must never panic or UB.
607        let buf = [0u8; 256];
608        for len in 0..=256 {
609            let _ = parse_instruction_frame_checked(&buf[..len]);
610        }
611    }
612
613    #[test]
614    fn arbitrary_ff_input_never_panics() {
615        let buf = [0xFFu8; 256];
616        for len in 0..=256 {
617            let _ = parse_instruction_frame_checked(&buf[..len]);
618        }
619    }
620}