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}