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}