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