hopper_runtime/write_policy.rs
1//! Declared write sets enforced at borrow acquisition.
2//!
3//! Sealevel's account model stops at one bit of write granularity: the
4//! transaction-level `writable` flag covers the *entire* account. This
5//! module extends that to **byte-range granularity**: an instruction
6//! declares the exact ranges it is allowed to write, and the
7//! [`Context`](crate::context::Context) rejects any write borrow outside
8//! the declared set at acquisition time, before a byte is exposed mutably.
9//!
10//! ## How it composes
11//!
12//! - `#[hopper::context(strict_writes)]` compiles the context's declared
13//! `mut` / `mut(seg, ...)` attributes into a `static` [`WritePolicy`]
14//! and installs it during `bind()`. The descriptors are constant data, and
15//! the policy scans that slice at each write acquisition.
16//! - Every Context-mediated write path is gated: segment writes
17//! (`segment_mut`, `segment_mut_const`, `segment_mut_typed`,
18//! `split_segments_mut`), whole-account typed loads (`load_mut`), and
19//! the raw escape hatches (`raw_mut`, `as_mut_ptr`).
20//! - A whole-account borrow claims `[0, data_len)`, so under a policy that
21//! declares only field ranges, `load_mut` / `as_mut_ptr` are refused and
22//! the handler must use the declared segment accessors. That is the
23//! discipline the policy exists to enforce.
24//! - Paired with the `touch-map` feature, the declared set can be
25//! compared against the *actual* footprint in tests: declared-vs-actual
26//! write verification from the emitted touch records.
27//!
28//! ## The lamport dimension
29//!
30//! Byte ranges cover **data**; Sealevel writability also covers
31//! **lamport** mutation (close, top-up, transfer). A policy built with
32//! [`WritePolicy::with_lamports`] declares that second dimension: the
33//! listed account indices may have their lamports mutated; other
34//! account's lamport mutation is refused. Because lamport operations
35//! flow through `AccountView` (not `Context`), enforcement uses an
36//! instruction-scoped ambient gate ([`try_install_lamport_gate`])
37//! consulted by the runtime's lamport choke points: the
38//! `native_boundary` `try_set_lamports`/`close` funnel used by the
39//! runtime and `hopper-core` lifecycle helpers, and the
40//! validated CPI tiers' writable-meta construction (a writable CPI
41//! hand-off is unbounded delegation of both dimensions).
42//!
43//! The gate stores address **values** copied at install time rather than a
44//! pointer into the account slice. Checks compare the address of
45//! the live view being mutated against those values. A leaked guard
46//! (`mem::forget`) therefore leaves an observable *stale value policy*
47//! installed (fail-closed for unknown addresses) rather than any form
48//! of memory unsafety; see the gate section below for the full
49//! contract, including the loud fail-closed install errors
50//! (`0xD1__` page).
51//!
52//! ## Enforcement boundary
53//!
54//! The policy governs access **through `Context`** (data ranges) and
55//! through the runtime's `AccountView`/CPI surface (lamports). Generated
56//! `#[hopper::context]` code and the documented safe APIs use those paths.
57//! Direct substrate access (`hopper_native` calls such
58//! as `batch::transfer_lamports`, or `try_borrow_mut` on the raw
59//! backend view) and the `unsafe` unchecked CPI tier are outside the
60//! governed surface, exactly like the documented raw-pointer escape
61//! hatches; they are visible in review and lintable. The Sealevel
62//! `writable` flag is still enforced underneath in all cases.
63//!
64//! For the common no-CPI lamport move the escape hatch has a
65//! first-class **gated** alternative:
66//! [`crate::lamports::transfer_lamports`] (re-exported at the crate
67//! root and through `hopper::prelude`; `lamports(...)` contexts also
68//! expose it as a generated `ctx.transfer_lamports(..)` method) runs
69//! the substrate helper's exact arithmetic through the
70//! `native_boundary` funnel, checking **both** sides against the gate
71//! before any balance changes, so gated programs retain the declared lamport
72//! checks without paying for a System CPI.
73//!
74//! [`AccountView`]: crate::account::AccountView
75
76use crate::address::Address;
77use crate::error::ProgramError;
78use crate::ProgramResult;
79
80mod exact_cell_selector_sealed {
81 pub trait Sealed {}
82
83 impl Sealed for u8 {}
84 impl Sealed for u16 {}
85 impl Sealed for u32 {}
86}
87
88/// A selector whose runtime value and wire representation are guaranteed to
89/// agree with Hopper's exact-cell Effect ABI.
90///
91/// The trait is sealed: aliases of the real unsigned primitives inherit the
92/// implementation, while signed integers and user-defined lookalikes cannot
93/// opt themselves in. Macro-generated code also checks [`WIRE_SIZE`](Self::WIRE_SIZE)
94/// against the authored type spelling, catching primitive-shadowing aliases
95/// such as a local `type u16 = u8` before a manifest can publish the wrong
96/// decoder width.
97pub trait ExactCellSelector: exact_cell_selector_sealed::Sealed + Copy {
98 /// Canonical fixed width of the selector on the instruction wire.
99 const WIRE_SIZE: u16;
100
101 /// Convert to the compact value consumed by the write-policy gate.
102 fn to_u32(self) -> u32;
103}
104
105impl ExactCellSelector for u8 {
106 const WIRE_SIZE: u16 = 1;
107
108 #[inline(always)]
109 fn to_u32(self) -> u32 {
110 self as u32
111 }
112}
113
114impl ExactCellSelector for u16 {
115 const WIRE_SIZE: u16 = 2;
116
117 #[inline(always)]
118 fn to_u32(self) -> u32 {
119 self as u32
120 }
121}
122
123impl ExactCellSelector for u32 {
124 const WIRE_SIZE: u16 = 4;
125
126 #[inline(always)]
127 fn to_u32(self) -> u32 {
128 self
129 }
130}
131
132/// Error page for write-policy violations.
133///
134/// A rejected write on account index `i` surfaces as
135/// `ProgramError::Custom(0xD000 | i)`, mirroring the `0xC000 | i`
136/// convention used for declarative constraint failures. The account
137/// index in the low byte makes the offending account recoverable from
138/// the bare error code in logs and explorers.
139pub const WRITE_POLICY_VIOLATION_PAGE: u32 = 0xD0_00;
140
141/// Build the write-policy-violation error for an account index.
142#[inline(always)]
143pub const fn write_policy_violation(account_index: u8) -> ProgramError {
144 ProgramError::Custom(WRITE_POLICY_VIOLATION_PAGE | account_index as u32)
145}
146
147/// One allowed write range on one instruction account.
148///
149/// `account_index` is the position in the instruction's account list
150/// (the same index handed to [`Context::account`](crate::context::Context::account)).
151/// Offsets are absolute within the account data, including any layout
152/// header bytes, the same convention the segment access primitives use.
153#[derive(Clone, Copy, Debug, PartialEq, Eq)]
154pub struct WriteRange {
155 /// Instruction account-list index this range applies to.
156 pub account_index: u8,
157 /// Absolute byte offset of the allowed range.
158 pub offset: u32,
159 /// Byte size of the allowed range. [`WriteRange::whole_account`]
160 /// uses `u32::MAX`, which contains any request on the account
161 /// (account data is capped far below 4 GiB).
162 pub size: u32,
163}
164
165/// A runtime-selected fixed-size cell inside a statically declared column.
166///
167/// The static [`WriteRange`] remains the conservative envelope used by
168/// tooling. This descriptor narrows that envelope for one invocation to
169/// `base_offset + args[argument_index] * stride .. + cell_size`.
170#[derive(Clone, Copy, Debug, PartialEq, Eq)]
171pub struct ParametricWriteRange {
172 /// Instruction account-list index this column belongs to.
173 pub account_index: u8,
174 /// Absolute offset of element zero.
175 pub base_offset: u32,
176 /// Byte distance between consecutive elements.
177 pub stride: u32,
178 /// Writable bytes in the selected element.
179 pub cell_size: u32,
180 /// Number of cells in the column.
181 pub count: u32,
182 /// Index into the invocation's bound `u32` policy arguments.
183 pub argument_index: u8,
184 /// Stable instruction-argument name for manifests and diagnostics.
185 pub argument_name: &'static str,
186 /// Stable state segment/field name for manifests and diagnostics.
187 pub segment_name: &'static str,
188}
189
190impl ParametricWriteRange {
191 /// Build one parametric column rule.
192 #[inline(always)]
193 #[expect(
194 clippy::too_many_arguments,
195 reason = "the constructor mirrors the eight independent manifest selector fields"
196 )]
197 pub const fn new(
198 account_index: u8,
199 base_offset: u32,
200 stride: u32,
201 cell_size: u32,
202 count: u32,
203 argument_index: u8,
204 argument_name: &'static str,
205 segment_name: &'static str,
206 ) -> Self {
207 Self {
208 account_index,
209 base_offset,
210 stride,
211 cell_size,
212 count,
213 argument_index,
214 argument_name,
215 segment_name,
216 }
217 }
218
219 #[inline(always)]
220 fn envelope_overlaps(&self, offset: u32, size: u32) -> bool {
221 let envelope_start = self.base_offset as u64;
222 let envelope_end = envelope_start
223 + self.stride as u64 * self.count.saturating_sub(1) as u64
224 + self.cell_size as u64;
225 let request_start = offset as u64;
226 let request_end = request_start + size as u64;
227 request_start < envelope_end && request_end > envelope_start
228 }
229
230 #[inline(always)]
231 fn selected_contains(&self, selected: u32, offset: u32, size: u32) -> bool {
232 if selected >= self.count {
233 return false;
234 }
235 let start = self.base_offset as u64 + self.stride as u64 * selected as u64;
236 let end = start + self.cell_size as u64;
237 let request_start = offset as u64;
238 let request_end = request_start + size as u64;
239 request_start >= start && request_end <= end
240 }
241}
242
243impl WriteRange {
244 /// Allow writes to `[offset, offset + size)` on `account_index`.
245 #[inline(always)]
246 pub const fn new(account_index: u8, offset: u32, size: u32) -> Self {
247 Self {
248 account_index,
249 offset,
250 size,
251 }
252 }
253
254 /// Allow whole-account writes on `account_index` (what a plain
255 /// `mut` declaration grants).
256 #[inline(always)]
257 pub const fn whole_account(account_index: u8) -> Self {
258 Self {
259 account_index,
260 offset: 0,
261 size: u32::MAX,
262 }
263 }
264
265 /// Allow writes to the **open-ended tail** `[offset, +inf)` on
266 /// `account_index`, the grant a growable `Seq<T>` tail needs.
267 ///
268 /// `size` is `u32::MAX`, so [`contains`](Self::contains) admits any
269 /// sub-range starting at or after `offset` regardless of how large the
270 /// account grows (the account data cap is far below 4 GiB, and
271 /// `contains` widens to `u64` so `offset + u32::MAX` cannot wrap).
272 ///
273 /// Crucially this is **not** a whole-account grant when `offset != 0`:
274 /// [`allows_whole_account_write`](WritePolicy::allows_whole_account_write)
275 /// requires a range containing `[0, u32::MAX)`, and a tail range that
276 /// starts past the fixed head fails that test. So the fixed head stays
277 /// byte-protected and CPI writable-meta delegation stays refused, while
278 /// the tail region past `offset` remains freely writable and growable.
279 #[inline(always)]
280 pub const fn tail_from(account_index: u8, offset: u32) -> Self {
281 Self {
282 account_index,
283 offset,
284 size: u32::MAX,
285 }
286 }
287
288 /// Whether `[offset, offset + size)` is fully contained in this
289 /// range. Widened to `u64` so `offset + size` cannot wrap.
290 #[inline(always)]
291 pub const fn contains(&self, offset: u32, size: u32) -> bool {
292 let req_start = offset as u64;
293 let req_end = offset as u64 + size as u64;
294 let start = self.offset as u64;
295 let end = self.offset as u64 + self.size as u64;
296 req_start >= start && req_end <= end
297 }
298}
299
300/// The lamport-write dimension of a [`WritePolicy`].
301///
302/// A [`WriteRange`] covers **data** bytes; Sealevel writability also
303/// covers **lamport** credits/debits (close, top-up, transfer). This
304/// enum records whether the instruction declared that second dimension:
305///
306/// - [`Undeclared`](Self::Undeclared): the policy says nothing about
307/// lamports. Lamport mutation stays ungoverned (today's behavior for
308/// every pre-existing `strict_writes` context), and the write set is
309/// **not** mutation-complete.
310/// - [`Declared`](Self::Declared): only the listed account indices may
311/// have their lamports mutated through the runtime's lamport choke
312/// points; every other account's lamport mutation is refused with the
313/// same `Custom(0xD000 | index)` error the data-range gate uses. An
314/// **empty** list is valid and means "no lamport mutation anywhere".
315#[derive(Clone, Copy, Debug, PartialEq, Eq)]
316pub enum LamportPolicy {
317 /// Lamport writes undeclared: passthrough, not mutation-complete.
318 Undeclared,
319 /// Lamport writes permitted only on the listed account indices.
320 Declared(&'static [u8]),
321}
322
323/// Declared write-set for one instruction.
324///
325/// Intended to be a `static` built at macro-expansion time from the
326/// context's `mut` / `mut(seg, ...)` declarations and installed via
327/// [`Context::set_write_policy`](crate::context::Context::set_write_policy).
328/// An **empty** set is a valid policy: it denies every Context-mediated
329/// write, turning the instruction into a machine-checked read-only
330/// contract.
331///
332/// The optional [`lamports`](Self::lamports) dimension extends the set
333/// from data bytes to lamport balances; see [`LamportPolicy`].
334#[derive(Debug)]
335pub struct WritePolicy {
336 /// Allowed write ranges. Scanned linearly; contexts declare a
337 /// handful of ranges, so a bounded scan beats any lookup structure
338 /// at Solana scale.
339 pub allows: &'static [WriteRange],
340 /// Invocation-parametric rules that narrow selected static envelopes.
341 pub parametric: &'static [ParametricWriteRange],
342 /// Declared lamport-write permission set. See
343 /// [`LamportPolicy`] for the exact semantics of each variant.
344 pub lamports: LamportPolicy,
345}
346
347impl WritePolicy {
348 /// Wrap a const slice of allowed ranges as a policy.
349 ///
350 /// The lamport dimension is left [`LamportPolicy::Undeclared`], which
351 /// preserves the behavior without a mutation-completeness contract: lamport mutation is
352 /// ungoverned and the policy is not mutation-complete.
353 #[inline(always)]
354 pub const fn new(allows: &'static [WriteRange]) -> Self {
355 Self {
356 allows,
357 parametric: &[],
358 lamports: LamportPolicy::Undeclared,
359 }
360 }
361
362 /// Build a policy that declares **both** dimensions: data ranges and
363 /// the account indices allowed to have their lamports mutated.
364 #[inline(always)]
365 pub const fn with_lamports(
366 allows: &'static [WriteRange],
367 lamport_accounts: &'static [u8],
368 ) -> Self {
369 Self {
370 allows,
371 parametric: &[],
372 lamports: LamportPolicy::Declared(lamport_accounts),
373 }
374 }
375
376 /// Build a data policy with invocation-parametric cell narrowing.
377 #[inline(always)]
378 pub const fn with_parametric(
379 allows: &'static [WriteRange],
380 parametric: &'static [ParametricWriteRange],
381 ) -> Self {
382 Self {
383 allows,
384 parametric,
385 lamports: LamportPolicy::Undeclared,
386 }
387 }
388
389 /// Build a mutation-complete policy with parametric cell narrowing.
390 #[inline(always)]
391 pub const fn with_parametric_and_lamports(
392 allows: &'static [WriteRange],
393 parametric: &'static [ParametricWriteRange],
394 lamport_accounts: &'static [u8],
395 ) -> Self {
396 Self {
397 allows,
398 parametric,
399 lamports: LamportPolicy::Declared(lamport_accounts),
400 }
401 }
402
403 /// Whether the lamport dimension was declared (making the policy a
404 /// mutation-complete write-set when installed by a `strict_writes`
405 /// context).
406 #[inline(always)]
407 pub const fn lamports_declared(&self) -> bool {
408 matches!(self.lamports, LamportPolicy::Declared(_))
409 }
410
411 /// Whether the policy permits mutating `account_index`'s lamports.
412 ///
413 /// [`LamportPolicy::Undeclared`] permits everything (the dimension
414 /// carries no authority); a declared set permits only its members.
415 #[inline(always)]
416 pub fn allows_lamport_mutation(&self, account_index: u8) -> bool {
417 match self.lamports {
418 LamportPolicy::Undeclared => true,
419 LamportPolicy::Declared(indices) => {
420 let mut i = 0;
421 while i < indices.len() {
422 if indices[i] == account_index {
423 return true;
424 }
425 i += 1;
426 }
427 false
428 }
429 }
430 }
431
432 /// Whether a declared range grants **whole-account** data writes on
433 /// `account_index` (what a plain `mut` / lifecycle declaration
434 /// compiles to). Used by the CPI writable-meta gate: handing an
435 /// account writable to a callee is unbounded data delegation, so it
436 /// requires a whole-account grant, not just field ranges.
437 #[inline(always)]
438 pub fn allows_whole_account_write(&self, account_index: u8) -> bool {
439 let ranges = self.allows;
440 let mut i = 0;
441 while i < ranges.len() {
442 let r = &ranges[i];
443 if r.account_index == account_index && r.contains(0, u32::MAX) {
444 return true;
445 }
446 i += 1;
447 }
448 false
449 }
450
451 /// Whether this policy grants any data-write authority on
452 /// `account_index`.
453 ///
454 /// Length/presence transitions cannot be represented as an ordinary
455 /// byte range because the affected bytes may not exist yet. The ambient
456 /// gate therefore treats a declared data range as the account-level
457 /// authority required to resize that account, while still requiring a
458 /// whole-account grant for raw full-buffer writes and writable CPI
459 /// delegation.
460 #[inline(always)]
461 pub fn allows_any_account_write(&self, account_index: u8) -> bool {
462 let mut i = 0;
463 while i < self.allows.len() {
464 if self.allows[i].account_index == account_index {
465 return true;
466 }
467 i += 1;
468 }
469 false
470 }
471
472 /// `Ok(())` iff `[offset, offset + size)` on `account_index` is
473 /// fully contained in a **single** declared range. Adjacent declared
474 /// ranges are not coalesced at check time; the macro emits ranges
475 /// exactly as declared, so a request straddling two declarations is
476 /// refused (declare a covering range if that access is intended).
477 #[inline(always)]
478 pub fn check_write(
479 &self,
480 account_index: u8,
481 offset: u32,
482 size: u32,
483 ) -> Result<(), ProgramError> {
484 let ranges = self.allows;
485 let mut i = 0;
486 while i < ranges.len() {
487 let r = &ranges[i];
488 if r.account_index == account_index && r.contains(offset, size) {
489 return Ok(());
490 }
491 i += 1;
492 }
493 Err(write_policy_violation(account_index))
494 }
495
496 /// Parametric form of [`check_write`](Self::check_write). A request that
497 /// touches a governed column must fit the invocation-selected cell; other
498 /// requests fall back to the ordinary static policy.
499 #[inline(always)]
500 pub fn check_write_with_args(
501 &self,
502 account_index: u8,
503 offset: u32,
504 size: u32,
505 args: &[u32],
506 ) -> Result<(), ProgramError> {
507 let mut i = 0;
508 while i < self.parametric.len() {
509 let rule = &self.parametric[i];
510 if rule.account_index == account_index && rule.envelope_overlaps(offset, size) {
511 let argument = args.get(rule.argument_index as usize).copied();
512 if argument
513 .map(|selected| rule.selected_contains(selected, offset, size))
514 .unwrap_or(false)
515 {
516 return Ok(());
517 }
518 return Err(write_policy_violation(account_index));
519 }
520 i += 1;
521 }
522 self.check_write(account_index, offset, size)
523 }
524
525 /// Return the first byte in a recorded write touch that is not authorized
526 /// by this invocation's effective policy.
527 ///
528 /// Unlike [`check_write_with_args`](Self::check_write_with_args), this
529 /// method checks the **union** of authorized ranges. The distinction is
530 /// intentional: a write acquire must fit one declaration, but the touch
531 /// ledger may coalesce adjacent, independently authorized acquires into a
532 /// single record. Static ranges authorize bytes outside parametric
533 /// envelopes; inside an envelope, only the invocation-selected cell is
534 /// authorized. Missing or out-of-range selector values therefore fail
535 /// closed at the first governed byte.
536 #[inline]
537 pub fn first_unauthorized_byte_with_args(
538 &self,
539 account_index: u8,
540 offset: u32,
541 size: u32,
542 args: &[u32],
543 ) -> Option<u64> {
544 let mut cursor = offset as u64;
545 let request_end = cursor + size as u64;
546
547 while cursor < request_end {
548 // A parametric envelope replaces the static authority over its
549 // bytes. Rules generated by the context macro are disjoint; for
550 // hand-built overlapping rules, preserve the runtime gate's
551 // first-matching-rule behavior.
552 let mut governing_rule = None;
553 let mut i = 0;
554 while i < self.parametric.len() {
555 let rule = &self.parametric[i];
556 if rule.account_index == account_index {
557 let envelope_start = rule.base_offset as u64;
558 let envelope_end = envelope_start
559 + rule.stride as u64 * rule.count.saturating_sub(1) as u64
560 + rule.cell_size as u64;
561 if envelope_start <= cursor && cursor < envelope_end {
562 governing_rule = Some(rule);
563 break;
564 }
565 }
566 i += 1;
567 }
568
569 if let Some(rule) = governing_rule {
570 let Some(selected) = args.get(rule.argument_index as usize).copied() else {
571 return Some(cursor);
572 };
573 if selected >= rule.count {
574 return Some(cursor);
575 }
576 let selected_start = rule.base_offset as u64 + rule.stride as u64 * selected as u64;
577 let selected_end = selected_start + rule.cell_size as u64;
578 if selected_start <= cursor && cursor < selected_end {
579 cursor = core::cmp::min(selected_end, request_end);
580 continue;
581 }
582 return Some(cursor);
583 }
584
585 // Outside a governed envelope, advance through the union of all
586 // static ranges covering the cursor. Use the furthest end so the
587 // result is independent of declaration order.
588 let mut covered_until = cursor;
589 let mut j = 0;
590 while j < self.allows.len() {
591 let range = &self.allows[j];
592 if range.account_index == account_index {
593 let range_start = range.offset as u64;
594 let range_end = range_start + range.size as u64;
595 if range_start <= cursor && cursor < range_end {
596 covered_until = core::cmp::max(covered_until, range_end);
597 }
598 }
599 j += 1;
600 }
601 if covered_until == cursor {
602 return Some(cursor);
603 }
604
605 // Do not jump across an envelope hidden inside a broad static
606 // range: its start is where parametric authority takes over.
607 let mut k = 0;
608 while k < self.parametric.len() {
609 let rule = &self.parametric[k];
610 let envelope_start = rule.base_offset as u64;
611 if rule.account_index == account_index
612 && cursor < envelope_start
613 && envelope_start < covered_until
614 {
615 covered_until = envelope_start;
616 }
617 k += 1;
618 }
619 cursor = core::cmp::min(covered_until, request_end);
620 }
621
622 None
623 }
624
625 /// Non-erroring form of [`check_write`](Self::check_write).
626 #[inline(always)]
627 pub fn allows_write(&self, account_index: u8, offset: u32, size: u32) -> bool {
628 self.check_write(account_index, offset, size).is_ok()
629 }
630}
631
632// Lamport gate.
633//
634// The data dimension is enforced *inside* `Context`, which sees the
635// account index on every write acquire. Lamport mutation, by contrast,
636// happens through `AccountView`-level operations (`try_set_lamports`,
637// `close`, the CPI writable hand-off) that never see the `Context`.
638// The gate bridges that: `bind()` on a `strict_writes` context whose
639// policy declares the lamport dimension installs an instruction-scoped
640// ambient record derived from `(account slice, policy)`, and the
641// runtime's lamport choke points, `native_boundary::try_set_lamports`,
642// `native_boundary::close`, and the validated CPI tiers' writable-meta
643// construction, consult it before mutating.
644//
645// ## Value-based storage (nothing ambient is ever dereferenced)
646//
647// Storage mirrors `borrow_registry`: the ambient record holds address
648// VALUES copied out of the account slice at install time (plus the
649// per-address permission bits precomputed from the policy), never a
650// pointer into the slice. `check_*` compares the address the caller
651// reads from the live view it is about to mutate against those stored
652// values. Because nothing in the store is pointer-shaped, a leaked
653// guard (`mem::forget`, a forgotten bound context) degrades to a
654// **stale value policy**: the stale gate keeps governing later checks
655// on its tier, observable over-/stale enforcement that fails closed
656// for unknown addresses, until the tier's slots run out, at which
657// point further installs fail loudly. It can never become a dangling
658// read. (An earlier design stored a raw pointer to the account slice
659// and claimed the guard's lifetime bounded every ambient read; safe
660// `mem::forget` skips `Drop` and voided that claim, which is exactly
661// why values are stored instead.)
662//
663// Matching by address value means duplicate-meta accounts (same
664// address ⇒ same underlying account, as the loader hands duplicate
665// metas one `RuntimeAccount`) share one merged permission entry; the
666// permission is a property of the account, not of the meta position,
667// so an OR-merge across duplicate indices preserves the old
668// pointer-identity semantics. Each merged entry remembers the first
669// account index carrying the address so refusals keep the indexed
670// `Custom(0xD000 | index)` error shape.
671//
672// ## Slot store (no prev-chain)
673//
674// Each tier keeps a small fixed array of slots plus a monotonically
675// increasing token counter. Install claims a free slot under a unique
676// token; `Drop` clears exactly the slot holding its own token (scan +
677// match) and nothing else; checks consult the still-active slot with
678// the HIGHEST token, the most recently installed gate; so nested
679// binds shadow outer gates while alive and dropping guards in any
680// order can only ever free the dropper's own slot, never corrupt or
681// resurrect another gate. When no slot is free, install FAILS CLOSED
682// with a loud error instead of truncating, evicting, or silently
683// sharing.
684
685/// Error page for lamport-gate **installation** failures (`0xD1__`).
686///
687/// Deliberately distinct from the `0xD0__` per-account violation page so
688/// an install failure can never be mistaken for a policy refusal on a
689/// specific account index.
690pub const LAMPORT_GATE_INSTALL_ERROR_PAGE: u32 = 0xD1_00;
691
692/// Install refused: the instruction's account slice has more accounts
693/// than [`LAMPORT_GATE_CAPACITY`]. The gate refuses loudly rather than
694/// silently truncating the governed set (a truncated gate would treat
695/// the overflow accounts as foreign, surprising, and wrong the moment
696/// one of them was declared).
697pub const LAMPORT_GATE_TOO_MANY_ACCOUNTS: ProgramError =
698 ProgramError::Custom(LAMPORT_GATE_INSTALL_ERROR_PAGE | 0x01);
699
700/// Install refused: all [`LAMPORT_GATE_DEPTH`] slots on this tier are
701/// occupied by still-active (or leaked) gates. Nesting deeper than the
702/// Solana invoke-depth budget, or leaking guards via `mem::forget`,
703/// exhausts the store; the install fails closed instead of evicting a
704/// live gate.
705pub const LAMPORT_GATE_DEPTH_EXCEEDED: ProgramError =
706 ProgramError::Custom(LAMPORT_GATE_INSTALL_ERROR_PAGE | 0x02);
707
708/// Install refused (host fallback tier only): another still-active gate
709/// occupies the process-global single-slot store. On `no_std`
710/// multi-threaded hosts without the `thread-local-registry` feature the
711/// gate cannot attribute nesting to a thread, so a concurrent second
712/// install is refused loudly, never silently shared with, or allowed
713/// to corrupt, the gate another thread installed. Enable
714/// `thread-local-registry` (or run gated instructions one at a time)
715/// to lift this.
716pub const LAMPORT_GATE_CONTENDED: ProgramError =
717 ProgramError::Custom(LAMPORT_GATE_INSTALL_ERROR_PAGE | 0x03);
718
719/// Install refused because invocation-resolved exact-cell selectors exceed
720/// the bounded ambient ABI.
721pub const AMBIENT_GATE_TOO_MANY_ARGUMENTS: ProgramError =
722 ProgramError::Custom(LAMPORT_GATE_INSTALL_ERROR_PAGE | 0x04);
723
724/// Install refused: this build carries the `unguarded-raw-surfaces` size
725/// opt-out, but the policy declares data write ranges (fixed or
726/// parametric), governance the opt-out build cannot enforce on the raw
727/// `AccountView` surfaces. Refusing at install keeps the bypass loud on
728/// EVERY tier: macro-bound strict contexts are already a compile error in
729/// such builds, and this is the runtime fence for hand-rolled installs.
730/// Lamports-only policies still install (their dimensions stay enforced).
731pub const AMBIENT_GATE_UNGUARDED_BUILD: ProgramError =
732 ProgramError::Custom(LAMPORT_GATE_INSTALL_ERROR_PAGE | 0x05);
733
734/// Per-gate account capacity: the runtime's transaction account bound.
735/// An instruction can never carry more accounts than the transaction
736/// that contains it, so a gate over one instruction's slice always
737/// fits; anything larger is refused loudly at install
738/// ([`LAMPORT_GATE_TOO_MANY_ACCOUNTS`]).
739pub const LAMPORT_GATE_CAPACITY: usize = crate::MAX_TX_ACCOUNTS;
740
741/// Maximum number of invocation-resolved selector values retained by one
742/// ambient gate. This is the same bounded ABI used by [`Context`](crate::Context).
743pub const AMBIENT_GATE_ARG_CAPACITY: usize = 8;
744
745/// Concurrent gates per tier: Solana's nested-CPI depth budget (the
746/// runtime caps the instruction stack at 5 = one top-level + 4 nested
747/// CPI levels). On-chain every CPI level runs in a fresh VM whose
748/// writable data is re-initialized, so a single store only ever sees
749/// the nested binds of one handler frame; on host, hopper's CPI
750/// syscall is a no-op (no nested handler dispatch), so concurrent
751/// gates arise only from manually nested binds. Four slots cover both
752/// with room to spare, and exhaustion fails closed loudly
753/// ([`LAMPORT_GATE_DEPTH_EXCEEDED`]) rather than corrupting a live
754/// gate.
755pub const LAMPORT_GATE_DEPTH: usize = 4;
756
757/// Why a gate installation was refused (tier-independent). Mapped to a
758/// loud [`ProgramError`] by the install entry points; keeping the two
759/// cases distinct lets each tier name its own no-free-slot failure
760/// (nesting depth on the per-thread tiers vs. single-slot contention on
761/// the fallback tier).
762#[derive(Clone, Copy, Debug, PartialEq, Eq)]
763enum GateInstallError {
764 /// More accounts than [`LAMPORT_GATE_CAPACITY`].
765 TooManyAccounts,
766 /// More invocation selector values than
767 /// [`AMBIENT_GATE_ARG_CAPACITY`].
768 TooManyArguments,
769 /// Every slot on the tier is occupied.
770 NoFreeSlot,
771}
772
773/// One gated account: the address VALUE copied at install time plus the
774/// permission bits precomputed from the installed policy. Contains no
775/// pointers and is never dereferenced, only compared.
776#[derive(Clone, Copy)]
777struct GateEntry {
778 /// Address value copied from the account slice at install time.
779 address: Address,
780 /// First instruction account index carrying this address (for the
781 /// indexed `Custom(0xD000 | index)` refusal error).
782 index: u8,
783 /// OR over all indices with this address of
784 /// `policy.allows_lamport_mutation(index)`.
785 allow_mutation: bool,
786 /// OR over all indices with this address of `allows_lamport_mutation
787 /// && allows_whole_account_write` (the CPI writable hand-off needs
788 /// BOTH dimensions on one declared index).
789 allow_delegation: bool,
790 /// Whether the account has any declared data authority. Account-length
791 /// transitions are account-level effects whose newly exposed bytes do
792 /// not yet have an ordinary range, so this is the bounded transition
793 /// capability.
794 #[cfg_attr(feature = "unguarded-raw-surfaces", allow(dead_code))]
795 allow_transition: bool,
796}
797
798impl GateEntry {
799 #[cfg_attr(target_os = "solana", allow(dead_code))]
800 const EMPTY: Self = Self {
801 address: Address::new([0; 32]),
802 index: 0,
803 allow_mutation: false,
804 allow_delegation: false,
805 allow_transition: false,
806 };
807}
808
809/// One installed gate. `token == 0` means the slot is free.
810#[derive(Clone, Copy)]
811struct GateSlot {
812 /// Unique install token (0 = free slot).
813 token: u64,
814 /// Number of initialized entries (distinct addresses).
815 len: usize,
816 /// Static policy installed by generated code. The reference is safe even
817 /// if a guard is leaked: policies have program lifetime and never borrow
818 /// the instruction's account slice.
819 policy: Option<&'static WritePolicy>,
820 /// Invocation-decoded selectors used by parametric exact-cell rules.
821 args: [u32; AMBIENT_GATE_ARG_CAPACITY],
822 args_len: usize,
823 /// Copied `(address, permissions)` values; only `entries[..len]`
824 /// are meaningful.
825 entries: [GateEntry; LAMPORT_GATE_CAPACITY],
826}
827
828impl GateSlot {
829 #[cfg_attr(target_os = "solana", allow(dead_code))]
830 const FREE: Self = Self {
831 token: 0,
832 len: 0,
833 policy: None,
834 args: [0; AMBIENT_GATE_ARG_CAPACITY],
835 args_len: 0,
836 entries: [GateEntry::EMPTY; LAMPORT_GATE_CAPACITY],
837 };
838}
839
840#[derive(Clone, Copy)]
841enum GateCheck {
842 Lamports,
843 // Constructed only by the raw-surface guard wrappers; compiled out
844 // under the `unguarded-raw-surfaces` size opt-out so the variants do
845 // not read as dead code there.
846 #[cfg(not(feature = "unguarded-raw-surfaces"))]
847 Data {
848 offset: u32,
849 size: u32,
850 },
851 #[cfg(not(feature = "unguarded-raw-surfaces"))]
852 Transition,
853 Delegation,
854}
855
856/// Fixed-slot, value-only gate store. `DEPTH` is the number of
857/// concurrently installed gates the tier supports (see
858/// [`LAMPORT_GATE_DEPTH`]; the host fallback tier uses 1).
859struct GateStore<const DEPTH: usize> {
860 /// Count of tokens issued so far. The Nth install receives token `N`
861 /// (see [`GateStore::install`]), so `0` means "none issued yet" and
862 /// `0` remains the free-slot sentinel no live slot can hold.
863 ///
864 /// **This field must stay zero in [`GateStore::new`].** The SBF tier
865 /// holds this store in a `static mut`; an all-zero initializer lets
866 /// the linker place it in `.bss` (`NOBITS`, no file bytes), while a
867 /// *single* non-zero byte anywhere in the struct forces it into
868 /// `.data` (`PROGBITS`) and writes the whole
869 /// `DEPTH x capacity x size_of::<GateEntry>()` array, tens of KiB of
870 /// zeros, into every program's `.so`. That is exactly what a
871 /// `next_token: 1` initializer used to do. `initial_gate_store_is_all_zero_bytes`
872 /// pins this.
873 issued: u64,
874 /// Number of CURRENTLY installed (unremoved) gates, `0..=DEPTH`.
875 ///
876 /// This is the hot-path fast-out: `check_lamport_mutation` /
877 /// `check_lamport_delegation` run on EVERY runtime lamport write and
878 /// EVERY writable CPI meta, including in the vast majority of
879 /// programs that never declare `lamports(...)`. Without this counter
880 /// the no-gate path still walked the `DEPTH` slots per call, and the
881 /// router bench measured that dead scanning at ~+44 CU **per hop**
882 /// (2026-07-09: swap rows regressed 1564/3044/4525 → 1610/3136/4663,
883 /// flipping every row behind Quasar). `installed == 0` must make
884 /// `check` a single load + branch. Zero when zeroed (the all-zero
885 /// invariant below).
886 installed: u64,
887 /// Token of the CURRENT governing gate (highest live token), `0` when
888 /// none. Maintained on install (the fresh token is always the max)
889 /// and on remove (rescan only when the governing gate itself is
890 /// removed), so [`active_slot`](Self::active_slot) is O(1) instead of
891 /// a DEPTH-slot scan on every gated check. Zero when zeroed (the
892 /// all-zero invariant below).
893 top_token: u64,
894 /// Slot index holding [`top_token`](Self::top_token); meaningless
895 /// while `top_token == 0`. Zero when zeroed.
896 top_idx: u8,
897 slots: [GateSlot; DEPTH],
898}
899
900impl<const DEPTH: usize> GateStore<DEPTH> {
901 /// All-zero by construction; see [`GateStore::issued`]. Do not add a
902 /// non-zero field initializer here without re-reading that doc.
903 #[cfg_attr(target_os = "solana", allow(dead_code))]
904 const fn new() -> Self {
905 Self {
906 issued: 0,
907 installed: 0,
908 top_token: 0,
909 top_idx: 0,
910 slots: [GateSlot::FREE; DEPTH],
911 }
912 }
913
914 /// Copy `(address, permission)` values for every account into a free
915 /// slot and return that slot's unique token. Fails closed (no state
916 /// change) when the accounts outnumber the capacity or no slot is
917 /// free.
918 fn install_with_args(
919 &mut self,
920 accounts: &[crate::account::AccountView<'_>],
921 policy: &'static WritePolicy,
922 args: &[u32],
923 ) -> Result<u64, GateInstallError> {
924 if accounts.len() > LAMPORT_GATE_CAPACITY {
925 return Err(GateInstallError::TooManyAccounts);
926 }
927 if args.len() > AMBIENT_GATE_ARG_CAPACITY {
928 return Err(GateInstallError::TooManyArguments);
929 }
930 // NOTE (binary size): every index in this function is derived from a
931 // value LLVM cannot statically bound (a `usize::MAX` sentinel, or the
932 // stored `len`). A `slice[i]` it cannot prove in-bounds emits
933 // `panic_bounds_check`, which *formats* its arguments, dragging
934 // `core::fmt` (Formatter::pad_integral, do_count_chars, the integer
935 // Display impls) into `.text`. One such site costs ~5 KiB in every
936 // Hopper program. So this whole path uses iterators / `get`/`get_mut`,
937 // which are provably panic-free. Keep it that way.
938 let (slot_idx, slot) = self
939 .slots
940 .iter_mut()
941 .enumerate()
942 .find(|(_, s)| s.token == 0)
943 .ok_or(GateInstallError::NoFreeSlot)?;
944
945 let mut len = 0usize;
946 for (i, view) in accounts.iter().enumerate() {
947 // Reading `address()` here is an always-safe read of a live
948 // reference the caller just gave us; only the VALUE is kept.
949 let address = *view.address();
950 // Indices above u8::MAX can never have been declared (the
951 // policy wire format is u8): permissionless. Unreachable
952 // while LAMPORT_GATE_CAPACITY <= 255, kept for honesty.
953 let (index, allow_mutation, allow_delegation, allow_transition) =
954 if i <= u8::MAX as usize {
955 let idx = i as u8;
956 let m = policy.allows_lamport_mutation(idx);
957 (
958 idx,
959 m,
960 policy.lamports_declared() && m && policy.allows_whole_account_write(idx),
961 policy.allows_any_account_write(idx),
962 )
963 } else {
964 (u8::MAX, false, false, false)
965 };
966 // Keep one entry per account position. Duplicate addresses are
967 // still OR-authorized by the check scan, while retaining every
968 // index lets exact byte ranges be evaluated correctly.
969 let Some(entry) = slot.entries.get_mut(len) else {
970 return Err(GateInstallError::TooManyAccounts);
971 };
972 *entry = GateEntry {
973 address,
974 index,
975 allow_mutation,
976 allow_delegation,
977 allow_transition,
978 };
979 len += 1;
980 }
981 slot.len = len;
982 slot.policy = Some(policy);
983 slot.args_len = args.len();
984 for (dst, src) in slot.args.iter_mut().zip(args.iter().copied()) {
985 *dst = src;
986 }
987 // Tokens are 1, 2, 3, ..., `issued` counts up from a zeroed store
988 // (which is what keeps this static in `.bss`; see the field doc).
989 // 0 is the free-slot sentinel, so skip it on the (2^64 installs,
990 // practically unreachable) wrap.
991 self.issued = self.issued.wrapping_add(1);
992 if self.issued == 0 {
993 self.issued = 1;
994 }
995 let token = self.issued;
996 slot.token = token;
997 // The fresh token is strictly the highest live token, so it is
998 // always the new governing gate.
999 self.top_token = token;
1000 self.top_idx = slot_idx as u8;
1001 self.installed += 1;
1002 Ok(token)
1003 }
1004
1005 /// Free exactly the slot installed under `token` (no-op when the
1006 /// token is not present; e.g. an inert guard). A guard can only
1007 /// ever clear its own slot, never another gate's. Returns whether a
1008 /// live slot was actually freed, so tier wrappers can maintain their
1009 /// fast-path liveness flag.
1010 fn remove(&mut self, token: u64) -> bool {
1011 if let Some(slot) = self.slots.iter_mut().find(|s| s.token == token) {
1012 slot.token = 0;
1013 slot.len = 0;
1014 slot.policy = None;
1015 slot.args_len = 0;
1016 // Saturating: a stale/forged token cannot reach here (the
1017 // find above gates on a live token), but keep the counter
1018 // incapable of wrapping regardless.
1019 self.installed = self.installed.saturating_sub(1);
1020 // Removing the governing gate resumes the next-highest live
1021 // one: rescan (rare path, only when the INNER of a nested
1022 // pair drops, never on ordinary single-gate teardown after
1023 // the counter already hit zero).
1024 if token == self.top_token {
1025 let mut best_token = 0u64;
1026 let mut best_idx = 0u8;
1027 for (i, s) in self.slots.iter().enumerate() {
1028 if s.token > best_token {
1029 best_token = s.token;
1030 best_idx = i as u8;
1031 }
1032 }
1033 self.top_token = best_token;
1034 self.top_idx = best_idx;
1035 }
1036 true
1037 } else {
1038 false
1039 }
1040 }
1041
1042 /// The governing gate: the still-active slot with the highest token,
1043 /// i.e. the most recently installed gate. Nested binds therefore
1044 /// shadow outer gates while alive; when the inner guard drops, the
1045 /// outer gate resumes governing.
1046 fn active_slot(&self) -> Option<&GateSlot> {
1047 // Tokens are unique and monotonic, so "highest token" is exactly
1048 // "most recently installed", maintained as `top_token`/`top_idx`
1049 // by install/remove, making this a two-load lookup instead of a
1050 // DEPTH-slot scan on every gated check. `get` (not indexing)
1051 // keeps the path provably panic-free (see the binary-size note in
1052 // `install_with_args`).
1053 if self.top_token == 0 {
1054 return None;
1055 }
1056 self.slots.get(self.top_idx as usize)
1057 }
1058
1059 /// Match `address` against the governing gate's stored values.
1060 /// Passthrough when no gate is installed; fail closed (indexed
1061 /// error, `u8::MAX` for addresses foreign to the gated instruction)
1062 /// otherwise.
1063 #[cfg_attr(feature = "unguarded-raw-surfaces", allow(unused_variables))]
1064 fn check(&self, address: &Address, check: GateCheck) -> ProgramResult {
1065 // Hot-path fast-out: programs that never declare `lamports(...)`
1066 // pay ONE load + branch here, not a slot walk. This check runs on
1067 // every lamport write and every writable CPI meta, so the walk
1068 // was measured at ~+44 CU per router hop before this guard.
1069 if self.installed == 0 {
1070 return Ok(());
1071 }
1072 let Some(slot) = self.active_slot() else {
1073 return Ok(());
1074 };
1075 let Some(policy) = slot.policy else {
1076 // A live token without its policy can only result from corrupted
1077 // ambient state. Refuse it as a foreign-account mutation.
1078 return Err(write_policy_violation(u8::MAX));
1079 };
1080 // A data-only strict policy (bare `strict_writes`, no
1081 // `lamports(...)`) intentionally leaves the LAMPORT dimension
1082 // ungoverned for backward compatibility: direct lamport
1083 // arithmetic AND writable-CPI delegation both pass through. Both
1084 // hand lamports to another party, delegation is a strict superset
1085 // of a direct debit; so a policy that carries no lamport
1086 // authority cannot govern either without retroactively refusing
1087 // lamport moves an already-deployed program performs. The DATA
1088 // dimension (raw data mutation, account transitions, out-of-set
1089 // accounts) stays fully governed. `mutation_complete` policies
1090 // declare `lamports(...)`, so neither carve-out fires for them and
1091 // delegation is refused as before.
1092 if !policy.lamports_declared()
1093 && matches!(check, GateCheck::Lamports | GateCheck::Delegation)
1094 {
1095 return Ok(());
1096 }
1097 // `get(..len)` rather than `entries[i]`: `len` is a stored field, so
1098 // LLVM cannot prove an index derived from it is in bounds and would
1099 // emit a formatting `panic_bounds_check` (~5 KiB of `core::fmt`).
1100 // A corrupt `len` degrades to an empty slice, i.e. fail closed.
1101 let seen = slot.entries.get(..slot.len).unwrap_or(&[]);
1102 let args = slot.args.get(..slot.args_len).unwrap_or(&[]);
1103 let mut first_matching_index = None;
1104 for entry in seen {
1105 if entry.address == *address {
1106 if first_matching_index.is_none() {
1107 first_matching_index = Some(entry.index);
1108 }
1109 let allowed = match check {
1110 GateCheck::Lamports => entry.allow_mutation,
1111 #[cfg(not(feature = "unguarded-raw-surfaces"))]
1112 GateCheck::Data { offset, size } => policy
1113 .check_write_with_args(entry.index, offset, size, args)
1114 .is_ok(),
1115 #[cfg(not(feature = "unguarded-raw-surfaces"))]
1116 GateCheck::Transition => entry.allow_transition,
1117 GateCheck::Delegation => entry.allow_delegation,
1118 };
1119 if allowed {
1120 return Ok(());
1121 }
1122 }
1123 }
1124 Err(write_policy_violation(
1125 first_matching_index.unwrap_or(u8::MAX),
1126 ))
1127 }
1128
1129 /// Whether any gate (governing or shadowed) is installed.
1130 fn any_active(&self) -> bool {
1131 self.installed != 0
1132 }
1133}
1134
1135/// Spinlocked single-slot gate store, the host fallback tier's cell
1136/// (`no_std` multi-threaded hosts without the `thread-local-registry`
1137/// feature). Also compiled under `test` so the tier's occupancy
1138/// semantics stay unit-testable while the thread-local tier is the
1139/// active one.
1140///
1141/// Single-slot occupancy is deliberate: one global store is shared by
1142/// every thread, so "nesting" cannot be attributed to a thread and a
1143/// second install while any gate is active is refused loudly
1144/// ([`LAMPORT_GATE_CONTENDED`]) instead of silently sharing or
1145/// corrupting another thread's gate. All address copies and
1146/// comparisons happen entirely inside [`Self::with_lock`]; nothing
1147/// pointer- or reference-shaped survives past the lock release.
1148#[cfg(all(
1149 not(target_os = "solana"),
1150 any(test, not(feature = "thread-local-registry"))
1151))]
1152struct SpinlockGateStore {
1153 lock: core::sync::atomic::AtomicBool,
1154 cell: core::cell::UnsafeCell<GateStore<1>>,
1155}
1156
1157// SAFETY: all access to `cell` goes through `with_lock`, which
1158// serializes via the acquire/release spinlock, so no two threads can
1159// observe the store concurrently; and the store contains only plain
1160// values (addresses, flags, tokens, no pointers or references), so no
1161// other thread-affine state is smuggled across threads.
1162#[cfg(all(
1163 not(target_os = "solana"),
1164 any(test, not(feature = "thread-local-registry"))
1165))]
1166unsafe impl Sync for SpinlockGateStore {}
1167
1168#[cfg(all(
1169 not(target_os = "solana"),
1170 any(test, not(feature = "thread-local-registry"))
1171))]
1172impl SpinlockGateStore {
1173 const fn new() -> Self {
1174 Self {
1175 lock: core::sync::atomic::AtomicBool::new(false),
1176 cell: core::cell::UnsafeCell::new(GateStore::new()),
1177 }
1178 }
1179
1180 fn with_lock<R>(&self, f: impl FnOnce(&mut GateStore<1>) -> R) -> R {
1181 use core::sync::atomic::Ordering;
1182 while self
1183 .lock
1184 .compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed)
1185 .is_err()
1186 {
1187 core::hint::spin_loop();
1188 }
1189 // SAFETY: the spinlock above grants exclusive access to the
1190 // store until the release store below, and the store operations
1191 // passed as `f` (`install`/`remove`/`check`/`any_active`) never
1192 // call back into this module, so the `&mut` cannot be aliased
1193 // or re-entered while it is live.
1194 let result = f(unsafe { &mut *self.cell.get() });
1195 self.lock.store(false, Ordering::Release);
1196 result
1197 }
1198}
1199
1200/// Gate nesting depth on SBF. Each CPI level runs in its own VM with
1201/// its own heap, so cross-program nesting never shares this store;
1202/// depth is only consumed by one handler binding multiple
1203/// lamport-declared contexts simultaneously in the SAME instruction.
1204/// Two concurrent binds is already exotic; a third fails loudly with
1205/// [`LAMPORT_GATE_DEPTH_EXCEEDED`].
1206#[cfg(any(target_os = "solana", test))]
1207pub(crate) const SBF_GATE_DEPTH: usize = 2;
1208
1209/// Register the policy evaluator only when a gate is installed. Mutation
1210/// sites load this optional callback instead of referencing the evaluator
1211/// directly, so programs with no installation path can discard that code.
1212/// The callback is a program function, never a borrowed account pointer.
1213#[cfg(any(target_os = "solana", test))]
1214type GateChecker = fn(&GateStore<SBF_GATE_DEPTH>, &Address, GateCheck) -> ProgramResult;
1215
1216#[cfg(any(target_os = "solana", test))]
1217#[repr(C)]
1218struct SbfGateState {
1219 checker: Option<GateChecker>,
1220 store: GateStore<SBF_GATE_DEPTH>,
1221}
1222
1223#[cfg(any(target_os = "solana", test))]
1224impl SbfGateState {
1225 fn install_with_args(
1226 &mut self,
1227 accounts: &[crate::account::AccountView<'_>],
1228 policy: &'static WritePolicy,
1229 args: &[u32],
1230 ) -> Result<u64, GateInstallError> {
1231 let token = self.store.install_with_args(accounts, policy, args)?;
1232 self.checker = Some(GateStore::check);
1233 Ok(token)
1234 }
1235
1236 fn remove(&mut self, token: u64) {
1237 self.store.remove(token);
1238 if !self.store.any_active() {
1239 self.checker = None;
1240 }
1241 }
1242
1243 #[inline(always)]
1244 fn check(&self, address: &Address, check: GateCheck) -> ProgramResult {
1245 match self.checker {
1246 Some(checker) => checker(&self.store, address, check),
1247 None => Ok(()),
1248 }
1249 }
1250
1251 #[inline(always)]
1252 fn any_active(&self) -> bool {
1253 self.checker.is_some()
1254 }
1255}
1256
1257/// End offset (exclusive) of the lamport gate store inside the reserved
1258/// VM-heap scratch (`hopper_native::HEAP_RUNTIME_RESERVED`); i.e. the
1259/// first byte a LATER ambient consumer may claim. The touch log
1260/// (`segment_borrow::touch_log`) starts at this offset rounded up to 8.
1261/// Anyone adding a third consumer extends from THAT module's end the
1262/// same way, keeping the reserved-scratch layout a single linear chain.
1263#[cfg(target_os = "solana")]
1264#[allow(dead_code)]
1265pub(crate) const SBF_GATE_HEAP_END: usize =
1266 core::mem::size_of::<usize>() + core::mem::size_of::<SbfGateState>();
1267
1268#[cfg(target_os = "solana")]
1269mod gate_store {
1270 use super::{GateCheck, GateInstallError, SbfGateState, WritePolicy};
1271 use crate::address::Address;
1272 use crate::error::ProgramError;
1273 use crate::ProgramResult;
1274
1275 /// Byte offset of the gate store inside the VM heap region: right
1276 /// after the [`hopper_native::BumpAllocator`] cursor word.
1277 const GATE_HEAP_OFFSET: usize = core::mem::size_of::<usize>();
1278
1279 // The store must fit the reserved runtime scratch at the heap bottom,
1280 // and start 8-aligned (SbfGateState leads with an optional function).
1281 const _: () = assert!(
1282 core::mem::size_of::<SbfGateState>()
1283 <= hopper_native::HEAP_RUNTIME_RESERVED - GATE_HEAP_OFFSET,
1284 "GateStore exceeds HEAP_RUNTIME_RESERVED; grow the reservation in \
1285 hopper-native/src/entrypoint.rs or shrink the store"
1286 );
1287 const _: () = assert!((hopper_native::HEAP_START_ADDRESS + GATE_HEAP_OFFSET) % 8 == 0);
1288
1289 /// This tier supports two simultaneous same-instruction gates;
1290 /// running out means a handler bound three lamport-declared contexts
1291 /// at once (or leaked guards).
1292 pub(super) const NO_FREE_SLOT: ProgramError = super::LAMPORT_GATE_DEPTH_EXCEEDED;
1293
1294 /// Per-invocation gate store, living in the RESERVED BOTTOM of the VM
1295 /// heap (`[HEAP_START + 8, HEAP_START + 8 + size_of::<SbfGateState>...)`,
1296 /// see [`hopper_native::HEAP_RUNTIME_RESERVED`]).
1297 ///
1298 /// Why the heap and not a `static`: deployed SBF programs cannot carry
1299 /// writable sections AT ALL, the loader's ELF parser rejects
1300 /// `.bss`/`.data` (`WritableSectionNotSupported`), so ANY `static mut`
1301 /// here makes every program that links the gate fail to load. (Found
1302 /// empirically: Mollusk refused the parity vault; the loader is the
1303 /// same parser mainnet uses.) The VM heap is the one writable region a
1304 /// program owns, and it is **zero-initialized by the VM on every
1305 /// invocation**; which composes exactly with two invariants this
1306 /// module already pins:
1307 ///
1308 /// - `None` is the all-zero representation of `Option<fn>`, and
1309 /// `GateStore::new()` has zero-valued fields, so zeroed heap is the
1310 /// valid empty state; no init code runs at all.
1311 /// - Heap freshness per invocation gives instruction scoping for free:
1312 /// state can never leak across transactions or CPI levels (each CPI
1313 /// level is its own VM with its own heap).
1314 ///
1315 /// The `BumpAllocator`'s floor excludes this range, so allocations can
1316 /// never overwrite an installed gate.
1317 #[inline(always)]
1318 fn with_store<R>(f: impl FnOnce(&mut SbfGateState) -> R) -> R {
1319 let ptr = (hopper_native::HEAP_START_ADDRESS + GATE_HEAP_OFFSET) as *mut SbfGateState;
1320 // SAFETY: SBF execution is single-threaded and none of the store
1321 // operations passed as `f` (`install`/`remove`/`check`/
1322 // `any_active`) re-enter this module, so this exclusive reference
1323 // is unique for the duration of `f`. The pointee is valid: the VM
1324 // maps and ZEROES the heap region for every invocation, all-zero
1325 // bytes are a valid `SbfGateState` (the checker is None and the
1326 // store is empty), the address is 8-aligned
1327 // (const-asserted above) and the whole object lies inside
1328 // `HEAP_RUNTIME_RESERVED` (const-asserted above), a range the
1329 // `BumpAllocator` floor excludes and no other Hopper code touches.
1330 f(unsafe { &mut *ptr })
1331 }
1332
1333 pub(super) fn install_with_args(
1334 accounts: &[crate::account::AccountView<'_>],
1335 policy: &'static WritePolicy,
1336 args: &[u32],
1337 ) -> Result<u64, GateInstallError> {
1338 with_store(|store| store.install_with_args(accounts, policy, args))
1339 }
1340
1341 pub(super) fn remove(token: u64) {
1342 with_store(|store| store.remove(token));
1343 }
1344
1345 /// One callback load and branch when no gate is installed. Only the
1346 /// installation path references the full evaluator.
1347 #[inline(always)]
1348 pub(super) fn check(address: &Address, check: GateCheck) -> ProgramResult {
1349 with_store(|store| store.check(address, check))
1350 }
1351
1352 /// One heap load and compare; inlined at every guarded mutation site.
1353 #[inline(always)]
1354 pub(super) fn any_active() -> bool {
1355 with_store(|store| store.any_active())
1356 }
1357}
1358
1359#[cfg(all(
1360 not(target_os = "solana"),
1361 any(test, feature = "thread-local-registry")
1362))]
1363mod gate_store {
1364 use super::{GateCheck, GateInstallError, GateStore, WritePolicy, LAMPORT_GATE_DEPTH};
1365 use crate::address::Address;
1366 use crate::error::ProgramError;
1367 use crate::ProgramResult;
1368 use std::cell::RefCell;
1369
1370 // Per-thread gate store: hopper-runtime's own unit tests get it via
1371 // `test`; downstream test binaries opt in through the same
1372 // `thread-local-registry` feature the borrow registry uses, so
1373 // parallel test threads never observe each other's gates.
1374 std::thread_local! {
1375 static STORE: RefCell<GateStore<LAMPORT_GATE_DEPTH>> =
1376 const { RefCell::new(GateStore::new()) };
1377 }
1378
1379 /// This tier supports full nesting; running out of slots means
1380 /// nesting deeper than the Solana invoke-depth budget (or leaking
1381 /// guards).
1382 pub(super) const NO_FREE_SLOT: ProgramError = super::LAMPORT_GATE_DEPTH_EXCEEDED;
1383
1384 fn with_store<R>(f: impl FnOnce(&mut GateStore<LAMPORT_GATE_DEPTH>) -> R) -> R {
1385 STORE.with(|cell| f(&mut cell.borrow_mut()))
1386 }
1387
1388 pub(super) fn install_with_args(
1389 accounts: &[crate::account::AccountView<'_>],
1390 policy: &'static WritePolicy,
1391 args: &[u32],
1392 ) -> Result<u64, GateInstallError> {
1393 with_store(|store| store.install_with_args(accounts, policy, args))
1394 }
1395
1396 pub(super) fn remove(token: u64) {
1397 with_store(|store| store.remove(token));
1398 }
1399
1400 pub(super) fn check(address: &Address, check: GateCheck) -> ProgramResult {
1401 with_store(|store| store.check(address, check))
1402 }
1403
1404 pub(super) fn any_active() -> bool {
1405 with_store(|store| store.any_active())
1406 }
1407}
1408
1409#[cfg(all(
1410 not(target_os = "solana"),
1411 not(any(test, feature = "thread-local-registry"))
1412))]
1413mod gate_store {
1414 use super::{GateCheck, GateInstallError, SpinlockGateStore, WritePolicy};
1415 use crate::address::Address;
1416 use crate::error::ProgramError;
1417 use crate::ProgramResult;
1418
1419 /// Host fallback tier (`no_std` hosts without the thread-local
1420 /// feature): one process-global spinlocked slot. Cross-thread
1421 /// sharing means the one active gate governs every thread's lamport
1422 /// writes in this configuration, the same (documented) imprecision
1423 /// the fallback borrow registry accepts, and it fails closed rather
1424 /// than open. A second install while one gate is active is refused
1425 /// with [`super::LAMPORT_GATE_CONTENDED`] (see
1426 /// [`SpinlockGateStore`]); the per-thread and SBF tiers keep full
1427 /// nesting.
1428 static STORE: SpinlockGateStore = SpinlockGateStore::new();
1429
1430 /// Single-slot occupancy: a second concurrent install is contention,
1431 /// refused loudly instead of shared or corrupted.
1432 pub(super) const NO_FREE_SLOT: ProgramError = super::LAMPORT_GATE_CONTENDED;
1433
1434 pub(super) fn install_with_args(
1435 accounts: &[crate::account::AccountView<'_>],
1436 policy: &'static WritePolicy,
1437 args: &[u32],
1438 ) -> Result<u64, GateInstallError> {
1439 STORE.with_lock(|store| store.install_with_args(accounts, policy, args))
1440 }
1441
1442 pub(super) fn remove(token: u64) {
1443 STORE.with_lock(|store| store.remove(token));
1444 }
1445
1446 pub(super) fn check(address: &Address, check: GateCheck) -> ProgramResult {
1447 STORE.with_lock(|store| store.check(address, check))
1448 }
1449
1450 pub(super) fn any_active() -> bool {
1451 STORE.with_lock(|store| store.any_active())
1452 }
1453}
1454
1455/// RAII installation of the lamport gate for one bound instruction.
1456///
1457/// Returned by [`try_install_lamport_gate`] / [`install_lamport_gate`].
1458/// While this is the most recently installed still-active gate on its
1459/// tier, the runtime's lamport choke points refuse mutation on any
1460/// account the installed policy does not permit; an inner (newer) gate
1461/// shadows it until that inner guard drops. Dropping frees exactly this
1462/// guard's slot (matched by unique token), so guards may be dropped in
1463/// any order without disturbing, or resurrecting, other gates.
1464///
1465/// ## Leak behavior (`mem::forget`)
1466///
1467/// The gate store holds copied address values, never pointers into the
1468/// account slice, so leaking the guard leaves a **stale value policy**
1469/// installed: later checks on this tier keep being governed by it
1470/// (addresses it does not know fail closed) until enough leaks exhaust
1471/// the tier's [`LAMPORT_GATE_DEPTH`] slots and further installs fail
1472/// loudly. That is observable over-/stale enforcement, never memory
1473/// unsafety.
1474///
1475/// The `'accounts` lifetime parameter is retained for API stability
1476/// (macro codegen names `LamportGateGuard<'a>`); it is not load-bearing
1477/// for soundness, because nothing borrowed from the slice outlives the
1478/// install call.
1479#[derive(Debug)]
1480pub struct LamportGateGuard<'accounts> {
1481 /// Unique slot token; 0 = inert guard (nothing installed).
1482 token: u64,
1483 _accounts: core::marker::PhantomData<&'accounts ()>,
1484}
1485
1486impl Drop for LamportGateGuard<'_> {
1487 #[inline]
1488 fn drop(&mut self) {
1489 if self.token != 0 {
1490 gate_store::remove(self.token);
1491 }
1492 }
1493}
1494
1495/// Install the instruction-scoped lamport gate for `accounts` under
1496/// `policy`, the fallible entry point.
1497///
1498/// Returns an inert guard (nothing installed) unless `policy` declares
1499/// its lamport dimension ([`WritePolicy::with_lamports`]), a data-only
1500/// policy keeps the passthrough behavior. `#[hopper::context(
1501/// strict_writes, lamports(...))]`-generated `bind()` calls this (and
1502/// fails the bind on error) and stores the guard in the bound context,
1503/// so the gate lives exactly as long as the bound instruction scope.
1504///
1505/// # Errors
1506///
1507/// Fails closed, changing nothing, with
1508/// [`LAMPORT_GATE_TOO_MANY_ACCOUNTS`] (slice larger than
1509/// [`LAMPORT_GATE_CAPACITY`]), [`LAMPORT_GATE_DEPTH_EXCEEDED`] (all
1510/// [`LAMPORT_GATE_DEPTH`] slots on a nesting tier in use), or
1511/// [`LAMPORT_GATE_CONTENDED`] (host fallback tier: another still-active
1512/// gate occupies the process-global slot).
1513#[inline]
1514pub fn try_install_lamport_gate<'accounts>(
1515 accounts: &'accounts [crate::account::AccountView<'accounts>],
1516 policy: &'static WritePolicy,
1517) -> Result<LamportGateGuard<'accounts>, ProgramError> {
1518 try_install_ambient_gate_with_args(accounts, policy, &[])
1519}
1520
1521/// Install the complete instruction-scoped ambient mutation gate, including
1522/// invocation-resolved selector values for parametric exact-cell policies.
1523///
1524/// Generated `strict_writes` bindings use this entry point. A data-only
1525/// policy still preserves undeclared-lamport passthrough, but raw data
1526/// mutation, account transitions, writable CPI delegation, and accounts
1527/// outside the declared write-set are governed for the guard's lifetime.
1528///
1529/// Under the `unguarded-raw-surfaces` size opt-out, installing a policy
1530/// that declares data write ranges is refused with
1531/// [`AMBIENT_GATE_UNGUARDED_BUILD`], that build cannot enforce the raw
1532/// surfaces, and a silently half-enforced gate is worse than a loud
1533/// refusal. Lamports-only policies still install, with EXACTLY this
1534/// enforcement split (pinned by the opt-out-shape tests): the direct
1535/// lamport funnel (`check_lamport_mutation`) and writable-CPI
1536/// delegation (`check_lamport_delegation`) stay enforced; raw DATA
1537/// borrows and resize/close TRANSITIONS are compiled out with the rest
1538/// of the raw-surface guard, so a lamports-only gate on this build
1539/// refuses neither. A program that needs the data or transition
1540/// dimensions governed must not enable the opt-out.
1541#[inline]
1542pub fn try_install_ambient_gate_with_args<'accounts>(
1543 accounts: &'accounts [crate::account::AccountView<'accounts>],
1544 policy: &'static WritePolicy,
1545 args: &[u32],
1546) -> Result<LamportGateGuard<'accounts>, ProgramError> {
1547 // Runtime fence for the `unguarded-raw-surfaces` size opt-out: a
1548 // policy that declares data write ranges is asking for governance
1549 // this build cannot enforce on the raw `AccountView` surfaces, so
1550 // the install refuses loudly instead of degrading silently. The
1551 // macro tier can never reach this (strict contexts are a compile
1552 // error under the opt-out); this catches hand-rolled installs, and
1553 // costs nothing on default builds.
1554 #[cfg(feature = "unguarded-raw-surfaces")]
1555 if !policy.allows.is_empty() || !policy.parametric.is_empty() {
1556 return Err(AMBIENT_GATE_UNGUARDED_BUILD);
1557 }
1558 match gate_store::install_with_args(accounts, policy, args) {
1559 Ok(token) => Ok(LamportGateGuard {
1560 token,
1561 _accounts: core::marker::PhantomData,
1562 }),
1563 Err(GateInstallError::TooManyAccounts) => Err(LAMPORT_GATE_TOO_MANY_ACCOUNTS),
1564 Err(GateInstallError::TooManyArguments) => Err(AMBIENT_GATE_TOO_MANY_ARGUMENTS),
1565 Err(GateInstallError::NoFreeSlot) => Err(gate_store::NO_FREE_SLOT),
1566 }
1567}
1568
1569/// Infallible wrapper around [`try_install_lamport_gate`], kept for
1570/// call sites that predate the fallible API.
1571///
1572/// # Panics
1573///
1574/// Panics (failing the whole invocation, still fail-closed, still
1575/// loud) if the install is refused: more accounts than
1576/// [`LAMPORT_GATE_CAPACITY`], no free gate slot, or a contended host
1577/// fallback store. Callers that want the refusal as a
1578/// [`ProgramError`], every generated `bind()` does, must use
1579/// [`try_install_lamport_gate`].
1580#[inline]
1581pub fn install_lamport_gate<'accounts>(
1582 accounts: &'accounts [crate::account::AccountView<'accounts>],
1583 policy: &'static WritePolicy,
1584) -> LamportGateGuard<'accounts> {
1585 match try_install_lamport_gate(accounts, policy) {
1586 Ok(guard) => guard,
1587 Err(_) => panic!(
1588 "lamport gate install refused (capacity or slot occupancy); \
1589 use try_install_lamport_gate to handle this as a ProgramError"
1590 ),
1591 }
1592}
1593
1594/// Whether a lamport gate is currently installed on this tier
1595/// (diagnostic/testing). Counts shadowed and leaked (stale) gates.
1596#[inline]
1597pub fn lamport_gate_active() -> bool {
1598 gate_store::any_active()
1599}
1600
1601/// Gate a direct lamport mutation (`try_set_lamports` / `close`) on the
1602/// account with `address`.
1603///
1604/// The caller passes the address it read from the **live view** it is
1605/// about to mutate (an always-safe read); it is compared against the
1606/// address values copied at install time; nothing stored is
1607/// dereferenced. `Ok(())` when no gate is installed (the dimension is
1608/// opt-in) or when the governing gate permits lamport mutation on that
1609/// address.
1610#[inline(always)]
1611pub(crate) fn check_lamport_mutation(address: &Address) -> ProgramResult {
1612 // Test before constructing GateCheck so the no-policy path does not
1613 // spill its payload. On SBF this reads the optional checker word.
1614 if !gate_store::any_active() {
1615 return Ok(());
1616 }
1617 gate_store::check(address, GateCheck::Lamports)
1618}
1619
1620/// Gate a mutable data borrow against the invocation-resolved byte policy.
1621/// AccountView-level raw borrows pass the full buffer; segment paths pass the
1622/// exact range, so parametric cells remain enforceable outside `Context`.
1623#[inline(always)]
1624pub(crate) fn check_data_mutation(address: &Address, offset: u32, size: u32) -> ProgramResult {
1625 #[cfg(not(feature = "unguarded-raw-surfaces"))]
1626 {
1627 if !gate_store::any_active() {
1628 return Ok(());
1629 }
1630 gate_store::check(address, GateCheck::Data { offset, size })
1631 }
1632 #[cfg(feature = "unguarded-raw-surfaces")]
1633 {
1634 // Raw-tier opt-out: the raw-surface guard is compiled out, which
1635 // also unlinks the gate-check machinery from programs that never
1636 // install a gate. `strict_writes` codegen const-asserts
1637 // [`RAW_SURFACES_GUARDED`], so this branch cannot coexist with a
1638 // bound strict context.
1639 let _ = (address, offset, size);
1640 Ok(())
1641 }
1642}
1643
1644/// Gate a data-length/presence transition on an account. Any declared data
1645/// authority grants the transition capability; foreign and remaining-only
1646/// accounts fail closed.
1647#[inline(always)]
1648pub(crate) fn check_account_transition(address: &Address) -> ProgramResult {
1649 #[cfg(not(feature = "unguarded-raw-surfaces"))]
1650 {
1651 if !gate_store::any_active() {
1652 return Ok(());
1653 }
1654 gate_store::check(address, GateCheck::Transition)
1655 }
1656 #[cfg(feature = "unguarded-raw-surfaces")]
1657 {
1658 let _ = address;
1659 Ok(())
1660 }
1661}
1662
1663/// Whether the public raw `AccountView` surfaces are governed by the
1664/// ambient write gate in this build (the default). `false` only under the
1665/// `unguarded-raw-surfaces` opt-out; `strict_writes` codegen const-asserts
1666/// this is `true`, making the opt-out impossible to combine with a bound
1667/// strict context.
1668pub const RAW_SURFACES_GUARDED: bool = !cfg!(feature = "unguarded-raw-surfaces");
1669
1670/// Gate a CPI **writable meta** on the account with `address`. A
1671/// writable hand-off delegates unbounded data AND lamport mutation to
1672/// the callee, so it requires the account to carry both a
1673/// whole-account data grant and lamport permission. Same value-compare
1674/// contract as [`check_lamport_mutation`].
1675#[inline]
1676pub(crate) fn check_lamport_delegation(address: &Address) -> ProgramResult {
1677 gate_store::check(address, GateCheck::Delegation)
1678}
1679
1680// ── Tests ────────────────────────────────────────────────────────────
1681
1682#[cfg(test)]
1683mod gate_store_layout_tests {
1684 use super::*;
1685
1686 #[test]
1687 fn zeroed_sbf_state_has_no_evaluator() {
1688 // SAFETY: every GateStore field admits zero, including None for
1689 // optional policy references. Option<fn> guarantees a zero None.
1690 let state: SbfGateState = unsafe { core::mem::zeroed() };
1691 assert!(!state.any_active());
1692 assert_eq!(state.store.installed, 0);
1693 assert!(state
1694 .check(&Address::default(), GateCheck::Lamports)
1695 .is_ok());
1696 assert_eq!(core::mem::offset_of!(SbfGateState, checker), 0);
1697 assert_eq!(core::mem::size_of::<Option<GateChecker>>(), 8);
1698 assert!(
1699 core::mem::size_of::<SbfGateState>() + core::mem::size_of::<usize>()
1700 <= hopper_native::HEAP_RUNTIME_RESERVED
1701 );
1702 }
1703
1704 /// The SBF tier holds [`GateStore`] in a `static mut`. The linker puts
1705 /// it in `.bss` (`NOBITS`, **zero bytes in the `.so`**) only if its
1706 /// initializer is entirely zero; one non-zero byte moves it to `.data`
1707 /// (`PROGBITS`) and writes the whole ~35 KiB array of zeros into every
1708 /// Hopper program's binary. A `next_token: 1` initializer did exactly
1709 /// that, costing 35,656 file bytes, 61% of the vault's `.so`.
1710 ///
1711 /// This pins every field's initializer at zero. If you add a field to
1712 /// `GateStore`/`GateSlot`/`GateEntry`, its zero value must be valid, or
1713 /// the binary silently regrows.
1714 #[test]
1715 fn initial_gate_store_is_all_zero_bytes() {
1716 let store = GateStore::<2>::new();
1717 assert_eq!(store.issued, 0, "issued must start at 0, not 1");
1718 assert_eq!(store.installed, 0, "installed count must start at 0");
1719 for slot in &store.slots {
1720 assert_eq!(slot.token, 0, "free-slot sentinel is 0");
1721 assert_eq!(slot.len, 0);
1722 for entry in slot.entries.iter() {
1723 assert_eq!(*entry.address.as_array(), [0u8; 32]);
1724 assert_eq!(entry.index, 0);
1725 assert!(!entry.allow_mutation);
1726 assert!(!entry.allow_delegation);
1727 }
1728 }
1729 }
1730
1731 /// Tokens must still be non-zero and monotonic after the zero-init
1732 /// change (0 stays the free-slot sentinel), so nesting/shadowing and
1733 /// out-of-order drops keep working.
1734 #[test]
1735 fn issued_counter_hands_out_nonzero_monotonic_tokens() {
1736 let mut store = GateStore::<2>::new();
1737 store.issued = 0;
1738 store.issued = store.issued.wrapping_add(1);
1739 assert_eq!(store.issued, 1, "first token is 1, never the 0 sentinel");
1740 // Wrap: 2^64 installs must skip the sentinel rather than hand out 0.
1741 store.issued = u64::MAX;
1742 store.issued = store.issued.wrapping_add(1);
1743 if store.issued == 0 {
1744 store.issued = 1;
1745 }
1746 assert_eq!(store.issued, 1, "wrap skips the 0 sentinel");
1747 }
1748}
1749
1750#[cfg(test)]
1751mod tests {
1752 use super::*;
1753
1754 #[test]
1755 fn sbf_dispatch_preserves_nested_and_failed_install_enforcement() {
1756 let (_b0, a0) = make_account(70);
1757 let (_bf, foreign) = make_account(71);
1758 let accounts = [a0];
1759 static ALLOW: WritePolicy =
1760 WritePolicy::with_lamports(&[WriteRange::whole_account(0)], &[0]);
1761 static DENY: WritePolicy = WritePolicy::with_lamports(&[], &[]);
1762 let mut state = SbfGateState {
1763 checker: None,
1764 store: GateStore::new(),
1765 };
1766
1767 let outer = state.install_with_args(&accounts, &ALLOW, &[]).unwrap();
1768 assert!(state.any_active());
1769 assert!(state
1770 .check(accounts[0].address(), GateCheck::Delegation)
1771 .is_ok());
1772 assert_eq!(
1773 state.check(foreign.address(), GateCheck::Lamports),
1774 Err(write_policy_violation(u8::MAX))
1775 );
1776 let inner = state.install_with_args(&accounts, &DENY, &[]).unwrap();
1777 assert!(matches!(
1778 state.install_with_args(&accounts, &ALLOW, &[]),
1779 Err(GateInstallError::NoFreeSlot)
1780 ));
1781 assert_eq!(
1782 state.check(accounts[0].address(), GateCheck::Lamports),
1783 Err(write_policy_violation(0))
1784 );
1785 state.remove(inner);
1786 assert!(state
1787 .check(accounts[0].address(), GateCheck::Lamports)
1788 .is_ok());
1789 let inner = state.install_with_args(&accounts, &DENY, &[]).unwrap();
1790 state.remove(outer);
1791 state.remove(outer); // a stale guard must not clear the inner gate
1792 assert!(state.any_active());
1793 assert_eq!(
1794 state.check(accounts[0].address(), GateCheck::Delegation),
1795 Err(write_policy_violation(0))
1796 );
1797 state.remove(inner);
1798 assert!(!state.any_active());
1799 assert!(state.check(foreign.address(), GateCheck::Lamports).is_ok());
1800 }
1801
1802 #[test]
1803 #[cfg(not(feature = "unguarded-raw-surfaces"))]
1804 fn sbf_dispatch_preserves_byte_ranges_and_data_only_lamport_passthrough() {
1805 let (_b0, a0) = make_account(72);
1806 let (_bf, foreign) = make_account(73);
1807 let accounts = [a0];
1808 static NARROW: WritePolicy = WritePolicy::new(&[WriteRange::new(0, 8, 8)]);
1809 let mut state = SbfGateState {
1810 checker: None,
1811 store: GateStore::new(),
1812 };
1813 let token = state.install_with_args(&accounts, &NARROW, &[]).unwrap();
1814 assert!(state
1815 .check(
1816 accounts[0].address(),
1817 GateCheck::Data { offset: 8, size: 8 }
1818 )
1819 .is_ok());
1820 assert_eq!(
1821 state.check(
1822 accounts[0].address(),
1823 GateCheck::Data { offset: 9, size: 8 }
1824 ),
1825 Err(write_policy_violation(0))
1826 );
1827 assert_eq!(
1828 state.check(foreign.address(), GateCheck::Transition),
1829 Err(write_policy_violation(u8::MAX))
1830 );
1831 assert!(state.check(foreign.address(), GateCheck::Lamports).is_ok());
1832 assert!(state
1833 .check(foreign.address(), GateCheck::Delegation)
1834 .is_ok());
1835 state.remove(token);
1836 assert!(state
1837 .check(
1838 foreign.address(),
1839 GateCheck::Data {
1840 offset: 0,
1841 size: 32
1842 }
1843 )
1844 .is_ok());
1845 }
1846
1847 // vault (account 1): balance [16, 24), nonce [24, 32)
1848 static POLICY: WritePolicy = WritePolicy::new(&[
1849 WriteRange::new(1, 16, 8),
1850 WriteRange::new(1, 24, 8),
1851 WriteRange::whole_account(2),
1852 ]);
1853
1854 #[test]
1855 fn declared_ranges_allow_exact_and_contained_writes() {
1856 assert!(POLICY.check_write(1, 16, 8).is_ok());
1857 assert!(POLICY.check_write(1, 24, 8).is_ok());
1858 // Strictly inside a declared range is also allowed.
1859 assert!(POLICY.check_write(1, 18, 4).is_ok());
1860 // Zero-size request inside a range is trivially contained.
1861 assert!(POLICY.check_write(1, 20, 0).is_ok());
1862 }
1863
1864 #[test]
1865 fn parametric_column_allows_only_the_selected_cell() {
1866 static PARAMETRIC: &[ParametricWriteRange] = &[ParametricWriteRange::new(
1867 1, 100, 8, 8, 20, 0, "slot", "balances",
1868 )];
1869 static P: WritePolicy =
1870 WritePolicy::with_parametric(&[WriteRange::new(1, 100, 20 * 8)], PARAMETRIC);
1871
1872 assert!(P.check_write_with_args(1, 100 + 7 * 8, 8, &[7]).is_ok());
1873 assert!(P.check_write_with_args(1, 100 + 7 * 8 + 2, 4, &[7]).is_ok());
1874 assert_eq!(
1875 P.check_write_with_args(1, 100 + 8 * 8, 8, &[7]),
1876 Err(ProgramError::Custom(0xD0_01)),
1877 );
1878 assert!(P.check_write_with_args(1, 100 + 19 * 8, 8, &[20]).is_err());
1879 assert!(P.check_write_with_args(1, 100 + 7 * 8, 8, &[]).is_err());
1880 }
1881
1882 #[test]
1883 fn containment_oracle_resolves_parametric_cells_and_static_unions() {
1884 static PARAMETRIC: &[ParametricWriteRange] = &[ParametricWriteRange::new(
1885 1, 100, 8, 8, 20, 0, "slot", "balances",
1886 )];
1887 static P: WritePolicy = WritePolicy::with_parametric(
1888 &[
1889 WriteRange::new(1, 96, 4),
1890 WriteRange::new(1, 100, 20 * 8),
1891 WriteRange::new(1, 260, 4),
1892 ],
1893 PARAMETRIC,
1894 );
1895
1896 assert_eq!(
1897 P.first_unauthorized_byte_with_args(1, 100 + 7 * 8, 8, &[7]),
1898 None
1899 );
1900 assert_eq!(
1901 P.first_unauthorized_byte_with_args(1, 100 + 8 * 8, 8, &[7]),
1902 Some((100 + 8 * 8) as u64)
1903 );
1904 // Coalesced records may span an adjacent static acquire and the
1905 // selected first cell even though one runtime acquire may not.
1906 assert_eq!(P.first_unauthorized_byte_with_args(1, 96, 12, &[0]), None);
1907 assert_eq!(
1908 P.first_unauthorized_byte_with_args(1, 100, 8, &[]),
1909 Some(100)
1910 );
1911 assert_eq!(
1912 P.first_unauthorized_byte_with_args(1, 100, 8, &[20]),
1913 Some(100)
1914 );
1915
1916 // The non-parametric oracle walks the union, so a coalesced touch
1917 // over adjacent declarations remains certifiable.
1918 assert_eq!(
1919 POLICY.first_unauthorized_byte_with_args(1, 16, 16, &[]),
1920 None
1921 );
1922 assert_eq!(
1923 POLICY.first_unauthorized_byte_with_args(1, 15, 17, &[]),
1924 Some(15)
1925 );
1926 }
1927
1928 #[test]
1929 fn undeclared_ranges_are_refused_with_indexed_error() {
1930 // Outside every declared range.
1931 assert_eq!(
1932 POLICY.check_write(1, 0, 8),
1933 Err(ProgramError::Custom(0xD0_01))
1934 );
1935 // Overlapping but not contained.
1936 assert!(POLICY.check_write(1, 12, 8).is_err());
1937 // Straddling two adjacent declared ranges is refused: containment
1938 // is per-declaration, not per-union.
1939 assert!(POLICY.check_write(1, 16, 16).is_err());
1940 // Right account, range declared on a different account.
1941 assert_eq!(
1942 POLICY.check_write(0, 16, 8),
1943 Err(ProgramError::Custom(0xD0_00))
1944 );
1945 }
1946
1947 #[test]
1948 fn whole_account_allowance_contains_any_request() {
1949 assert!(POLICY.check_write(2, 0, 8).is_ok());
1950 assert!(POLICY.check_write(2, 0, u32::MAX).is_ok());
1951 assert!(POLICY.check_write(2, 4096, 10 * 1024 * 1024).is_ok());
1952 }
1953
1954 #[test]
1955 fn empty_policy_denies_all_writes() {
1956 static READ_ONLY: WritePolicy = WritePolicy::new(&[]);
1957 assert!(READ_ONLY.check_write(0, 0, 1).is_err());
1958 assert!(READ_ONLY.check_write(255, 0, 0).is_err());
1959 }
1960
1961 #[test]
1962 fn open_ended_tail_range_allows_tail_refuses_head_and_is_not_whole_account() {
1963 // A `Seq<T>` tail on account 1 whose fixed head occupies `[0, 24)`
1964 // (16-byte Hopper header + an 8-byte head field): the tail region
1965 // starts at offset 24 and is open-ended.
1966 const TAIL_OFF: u32 = 24;
1967 static P: WritePolicy = WritePolicy::new(&[WriteRange::tail_from(1, TAIL_OFF)]);
1968
1969 // Any sub-range at or past the tail offset is admitted, no matter
1970 // how large the account grows, the size is u32::MAX and `contains`
1971 // widens to u64 so `TAIL_OFF + u32::MAX` cannot wrap.
1972 assert!(P.check_write(1, TAIL_OFF, 4).is_ok()); // the u32 count prefix
1973 assert!(P.check_write(1, TAIL_OFF + 4, 32).is_ok()); // first element
1974 assert!(P.check_write(1, TAIL_OFF, 10 * 1024 * 1024).is_ok()); // grown far
1975 assert!(P.check_write(1, TAIL_OFF + 1_000_000, 32).is_ok());
1976
1977 // Every byte of the fixed head is refused with the indexed error,
1978 // the open-ended tail range does NOT leak backwards onto the head.
1979 assert_eq!(P.check_write(1, 0, 8), Err(write_policy_violation(1)));
1980 assert_eq!(P.check_write(1, 16, 8), Err(write_policy_violation(1)));
1981 // A write straddling the head/tail boundary is refused (it is not
1982 // fully contained in the tail range).
1983 assert!(P.check_write(1, TAIL_OFF - 1, 8).is_err());
1984
1985 // The load-bearing property: a tail range with `offset != 0` is
1986 // NOT a whole-account grant. CPI writable-meta delegation demands
1987 // `contains(0, u32::MAX)`, which starts at 0 and this range does
1988 // not; so delegation stays refused and the head stays protected.
1989 assert!(!P.allows_whole_account_write(1));
1990 // A tail range anchored at 0 (a degenerate "whole tail from the
1991 // start") IS a whole-account grant, by the same rule.
1992 static P0: WritePolicy = WritePolicy::new(&[WriteRange::tail_from(1, 0)]);
1993 assert!(P0.allows_whole_account_write(1));
1994 }
1995
1996 #[test]
1997 fn containment_survives_u32_boundary_arithmetic() {
1998 static EDGE: WritePolicy = WritePolicy::new(&[WriteRange::new(0, u32::MAX - 8, 8)]);
1999 // `offset + size` at the top of u32 must not wrap into a false allow.
2000 assert!(EDGE.check_write(0, u32::MAX - 8, 8).is_ok());
2001 assert!(EDGE.check_write(0, u32::MAX - 4, 8).is_err());
2002 }
2003
2004 // Lamport dimension.
2005
2006 #[test]
2007 fn undeclared_lamport_dimension_permits_everything_and_is_incomplete() {
2008 assert!(!POLICY.lamports_declared());
2009 assert!(POLICY.allows_lamport_mutation(0));
2010 assert!(POLICY.allows_lamport_mutation(255));
2011 }
2012
2013 #[test]
2014 fn declared_lamport_dimension_permits_only_members() {
2015 static P: WritePolicy =
2016 WritePolicy::with_lamports(&[WriteRange::whole_account(0)], &[0, 3]);
2017 assert!(P.lamports_declared());
2018 assert!(P.allows_lamport_mutation(0));
2019 assert!(P.allows_lamport_mutation(3));
2020 assert!(!P.allows_lamport_mutation(1));
2021 // An empty declared set refuses every account, a valid,
2022 // mutation-complete "no lamport writes anywhere" contract.
2023 static NONE: WritePolicy = WritePolicy::with_lamports(&[], &[]);
2024 assert!(NONE.lamports_declared());
2025 assert!(!NONE.allows_lamport_mutation(0));
2026 }
2027
2028 #[test]
2029 fn whole_account_grant_is_required_for_delegation() {
2030 static P: WritePolicy = WritePolicy::with_lamports(
2031 &[WriteRange::whole_account(0), WriteRange::new(1, 16, 8)],
2032 &[0, 1],
2033 );
2034 assert!(P.allows_whole_account_write(0));
2035 // Field-granular ranges are not a whole-account grant.
2036 assert!(!P.allows_whole_account_write(1));
2037 assert!(!P.allows_whole_account_write(2));
2038 }
2039
2040 // ── Ambient gate behavior ───────────────────────────────────────
2041
2042 use crate::account::AccountView;
2043 use hopper_native::{
2044 AccountView as NativeAccountView, Address as NativeAddress, RuntimeAccount, NOT_BORROWED,
2045 };
2046
2047 fn make_account(seed: u8) -> (std::vec::Vec<u64>, AccountView<'static>) {
2048 let mut backing = std::vec![0u64; (RuntimeAccount::SIZE + 32).div_ceil(8)];
2049 let raw = backing.as_mut_ptr() as *mut RuntimeAccount;
2050 // SAFETY: the test owns `backing`, writes one valid RuntimeAccount
2051 // header, and keeps the buffer alive for the returned view.
2052 unsafe {
2053 raw.write(RuntimeAccount {
2054 borrow_state: NOT_BORROWED,
2055 is_signer: 1,
2056 is_writable: 1,
2057 executable: 0,
2058 resize_delta: 0,
2059 address: NativeAddress::new_from_array([seed; 32]),
2060 owner: NativeAddress::new_from_array([2; 32]),
2061 lamports: 100,
2062 data_len: 32,
2063 });
2064 }
2065 // SAFETY: `raw` points at the RuntimeAccount just initialized above.
2066 let backend = unsafe { NativeAccountView::new_unchecked(raw) };
2067 (backing, AccountView::from_backend(backend))
2068 }
2069
2070 // Guarded-tier semantics: installs a data-declaring policy, which the
2071 // `unguarded-raw-surfaces` fence refuses at install (covered by its
2072 // own explicit test in that shape).
2073 #[test]
2074 #[cfg(not(feature = "unguarded-raw-surfaces"))]
2075 fn gate_refuses_undeclared_and_allows_declared_lamport_mutation() {
2076 let (_b0, a0) = make_account(10);
2077 let (_b1, a1) = make_account(11);
2078 let accounts = [a0, a1];
2079
2080 static P: WritePolicy = WritePolicy::with_lamports(&[WriteRange::whole_account(0)], &[0]);
2081
2082 // No gate installed: passthrough for everything.
2083 assert!(check_lamport_mutation(accounts[1].address()).is_ok());
2084
2085 {
2086 let _gate = install_lamport_gate(&accounts, &P);
2087 assert!(lamport_gate_active());
2088 // Declared index 0: allowed. Undeclared index 1: refused
2089 // with the indexed policy error.
2090 assert!(check_lamport_mutation(accounts[0].address()).is_ok());
2091 assert_eq!(
2092 check_lamport_mutation(accounts[1].address()),
2093 Err(write_policy_violation(1))
2094 );
2095 // An address foreign to the gated slice fails closed.
2096 let (_bf, foreign) = make_account(99);
2097 assert_eq!(
2098 check_lamport_mutation(foreign.address()),
2099 Err(write_policy_violation(u8::MAX))
2100 );
2101 }
2102
2103 // Guard dropped: gate cleared, passthrough restored.
2104 assert!(!lamport_gate_active());
2105 assert!(check_lamport_mutation(accounts[1].address()).is_ok());
2106 }
2107
2108 // Guarded-tier semantics: installs a data-declaring policy, which the
2109 // `unguarded-raw-surfaces` fence refuses at install (covered by its
2110 // own explicit test in that shape).
2111 #[test]
2112 #[cfg(not(feature = "unguarded-raw-surfaces"))]
2113 fn gate_delegation_requires_both_dimensions() {
2114 let (_b0, a0) = make_account(20);
2115 let (_b1, a1) = make_account(21);
2116 let (_b2, a2) = make_account(22);
2117 let accounts = [a0, a1, a2];
2118
2119 // 0: whole data + lamports (delegable). 1: lamports only.
2120 // 2: field range only.
2121 static P: WritePolicy = WritePolicy::with_lamports(
2122 &[WriteRange::whole_account(0), WriteRange::new(2, 16, 8)],
2123 &[0, 1],
2124 );
2125 let _gate = install_lamport_gate(&accounts, &P);
2126
2127 assert!(check_lamport_delegation(accounts[0].address()).is_ok());
2128 assert_eq!(
2129 check_lamport_delegation(accounts[1].address()),
2130 Err(write_policy_violation(1))
2131 );
2132 assert_eq!(
2133 check_lamport_delegation(accounts[2].address()),
2134 Err(write_policy_violation(2))
2135 );
2136 // Direct lamport mutation on 1 is still fine (declared).
2137 assert!(check_lamport_mutation(accounts[1].address()).is_ok());
2138 }
2139
2140 /// The runtime fence for hand-rolled installs under the size opt-out:
2141 /// a data-declaring policy is REFUSED at install (never a silently
2142 /// half-enforced gate), while a lamports-only policy installs and its
2143 /// dimensions stay fully enforced.
2144 #[test]
2145 #[cfg(feature = "unguarded-raw-surfaces")]
2146 fn unguarded_build_refuses_data_declaring_installs_loudly() {
2147 let (_b0, a0) = make_account(95);
2148 let accounts = [a0];
2149
2150 // Data-declaring policy: refused with the dedicated install error.
2151 static DATA: WritePolicy = WritePolicy::new(&[WriteRange::whole_account(0)]);
2152 assert_eq!(
2153 try_install_lamport_gate(&accounts, &DATA).map(|_| ()),
2154 Err(AMBIENT_GATE_UNGUARDED_BUILD),
2155 );
2156 assert!(!lamport_gate_active(), "a refused install leaves no gate");
2157
2158 // Lamports-only policy: installs, and the lamport dimension still
2159 // enforces (declared account 0 passes, a foreign account fails).
2160 static LAMPORTS_ONLY: WritePolicy = WritePolicy::with_lamports(&[], &[0]);
2161 let _gate = install_lamport_gate(&accounts, &LAMPORTS_ONLY);
2162 let (_bf, foreign) = make_account(96);
2163 assert!(check_lamport_mutation(accounts[0].address()).is_ok());
2164 assert_eq!(
2165 check_lamport_mutation(foreign.address()),
2166 Err(write_policy_violation(u8::MAX)),
2167 );
2168 // Writable-CPI delegation is the other dimension that SURVIVES
2169 // the opt-out: account 0 has lamport permission but no
2170 // whole-account data grant, so delegation refuses it; a foreign
2171 // account fails closed.
2172 assert_eq!(
2173 check_lamport_delegation(accounts[0].address()),
2174 Err(write_policy_violation(0)),
2175 );
2176 assert_eq!(
2177 check_lamport_delegation(foreign.address()),
2178 Err(write_policy_violation(u8::MAX)),
2179 );
2180 // The DATA and TRANSITION dimensions are compiled out with the
2181 // raw-surface guard in this build shape, the documented,
2182 // deliberate enforcement split (on the default build the same
2183 // install refuses BOTH of these on a foreign account; see
2184 // `public_raw_surfaces_are_governed_by_the_ambient_gate`). Pinned
2185 // so the split is loud, not an accident of cfg.
2186 assert!(check_data_mutation(foreign.address(), 0, 1).is_ok());
2187 assert!(check_account_transition(foreign.address()).is_ok());
2188 }
2189
2190 #[test]
2191 #[cfg(not(feature = "unguarded-raw-surfaces"))]
2192 fn public_raw_surfaces_are_governed_by_the_ambient_gate() {
2193 // The historical `strict_writes` bypass: raw `AccountView` writes
2194 // outside a `Context`. With the gate wired into the public
2195 // surfaces, a bound policy governs them too.
2196 let (_b0, a0) = make_account(90);
2197 let (_bf, foreign) = make_account(91);
2198 let accounts = [a0];
2199 // Account 0 may write ONLY bytes [8, 16) of its 32-byte data.
2200 static P: WritePolicy = WritePolicy::new(&[WriteRange::new(0, 8, 8)]);
2201
2202 // Without a gate every surface passes through untouched.
2203 {
2204 let mut reg = crate::segment_borrow::SegmentBorrowRegistry::new();
2205 assert!(accounts[0].try_borrow_mut().is_ok());
2206 assert!(accounts[0].segment_mut::<[u8; 8]>(&mut reg, 20, 8).is_ok());
2207 }
2208
2209 let _gate = install_lamport_gate(&accounts, &P);
2210
2211 // Raw whole-account borrow: the narrow declaration does not cover
2212 // the full data range, so the former bypass is refused with the
2213 // account's indexed policy error.
2214 assert_eq!(
2215 accounts[0].try_borrow_mut().map(|_| ()),
2216 Err(write_policy_violation(0)),
2217 );
2218 // A foreign account fails closed at u8::MAX.
2219 assert_eq!(
2220 foreign.try_borrow_mut().map(|_| ()),
2221 Err(write_policy_violation(u8::MAX)),
2222 );
2223
2224 // Direct segment access outside a `Context`: the declared cell is
2225 // allowed; a range shifted one byte past it is refused.
2226 let mut reg = crate::segment_borrow::SegmentBorrowRegistry::new();
2227 assert!(accounts[0].segment_mut::<[u8; 8]>(&mut reg, 8, 8).is_ok());
2228 let mut reg2 = crate::segment_borrow::SegmentBorrowRegistry::new();
2229 assert_eq!(
2230 accounts[0]
2231 .segment_mut::<[u8; 8]>(&mut reg2, 9, 8)
2232 .map(|_| ()),
2233 Err(write_policy_violation(0)),
2234 );
2235
2236 // Data-length / presence transitions on a foreign account are
2237 // refused before any native-boundary work happens.
2238 assert_eq!(foreign.resize(16), Err(write_policy_violation(u8::MAX)));
2239 assert_eq!(foreign.close(), Err(write_policy_violation(u8::MAX)));
2240 // The `close_to_unchecked` escape waives owner/writable
2241 // PREconditions but not the installed policy: the same transition
2242 // rule refuses it before any lamport moves or data zeroing.
2243 assert_eq!(
2244 foreign.close_to_unchecked(&accounts[0]),
2245 Err(write_policy_violation(u8::MAX)),
2246 );
2247 }
2248
2249 #[test]
2250 #[cfg(not(feature = "unguarded-raw-surfaces"))]
2251 fn data_only_policy_governs_data_but_passes_the_lamport_dimension() {
2252 // A data-only `strict_writes` policy (no `lamports(...)`), the shape
2253 // a BARE `strict_writes` context installs, governs the DATA
2254 // dimension (raw data mutation, transitions, out-of-set accounts)
2255 // while leaving the whole LAMPORT dimension passthrough: both direct
2256 // lamport arithmetic AND writable-CPI delegation. That carve-out is
2257 // what lets a bare-strict program keep performing writable CPIs
2258 // (System CreateAccount, Token transfer) after the gate is installed,
2259 // governing delegation would refuse every one of them, since a
2260 // data-only policy declares no lamport authority to hand a callee.
2261 let (_b0, a0) = make_account(30);
2262 let accounts = [a0];
2263 static P: WritePolicy = WritePolicy::new(&[WriteRange::whole_account(0)]);
2264 let _gate = install_lamport_gate(&accounts, &P);
2265 assert!(
2266 lamport_gate_active(),
2267 "a data-only policy installs the ambient gate"
2268 );
2269
2270 let (_bf, foreign) = make_account(31);
2271
2272 // Lamport dimension (arithmetic): undeclared, so BOTH the declared
2273 // account and a foreign one pass through.
2274 assert!(check_lamport_mutation(accounts[0].address()).is_ok());
2275 assert!(check_lamport_mutation(foreign.address()).is_ok());
2276
2277 // Lamport dimension (writable-CPI delegation): also passthrough for a
2278 // data-only policy, the backward-compat carve-out, so bare-strict
2279 // programs can still delegate accounts to CPI callees.
2280 assert!(check_lamport_delegation(accounts[0].address()).is_ok());
2281 assert!(check_lamport_delegation(foreign.address()).is_ok());
2282
2283 // Data dimension IS governed: the declared whole-account write is
2284 // permitted, but a foreign account's data write is refused fail-closed.
2285 assert!(check_data_mutation(accounts[0].address(), 0, 1).is_ok());
2286 assert_eq!(
2287 check_data_mutation(foreign.address(), 0, 1),
2288 Err(write_policy_violation(u8::MAX)),
2289 );
2290 // Transitions on a foreign account are refused; on the declared
2291 // (whole-account) one, permitted.
2292 assert!(check_account_transition(accounts[0].address()).is_ok());
2293 assert_eq!(
2294 check_account_transition(foreign.address()),
2295 Err(write_policy_violation(u8::MAX)),
2296 );
2297 }
2298
2299 #[test]
2300 #[cfg(not(feature = "unguarded-raw-surfaces"))]
2301 fn mutation_complete_policy_still_governs_delegation() {
2302 // The delegation carve-out is scoped to data-only policies by
2303 // `!lamports_declared()`. A `mutation_complete` policy DECLARES the
2304 // lamport dimension, so delegation stays governed: the declared
2305 // whole-account+lamport account is permitted, a lamport-but-not-data
2306 // account is refused, and a foreign one fails closed.
2307 let (_b0, a0) = make_account(32); // whole-account data + lamport
2308 let (_b1, a1) = make_account(33); // lamport only, no data grant
2309 let accounts = [a0, a1];
2310 static P: WritePolicy =
2311 WritePolicy::with_lamports(&[WriteRange::whole_account(0)], &[0, 1]);
2312 let _gate = install_lamport_gate(&accounts, &P);
2313
2314 let (_bf, foreign) = make_account(34);
2315 // Account 0: lamport-declared AND whole-account data grant -> may be
2316 // delegated.
2317 assert!(check_lamport_delegation(accounts[0].address()).is_ok());
2318 // Account 1: lamport-declared but no whole-account data grant ->
2319 // delegation refused with its own index.
2320 assert_eq!(
2321 check_lamport_delegation(accounts[1].address()),
2322 Err(write_policy_violation(1)),
2323 );
2324 // Foreign: fails closed.
2325 assert_eq!(
2326 check_lamport_delegation(foreign.address()),
2327 Err(write_policy_violation(u8::MAX)),
2328 );
2329 }
2330
2331 #[test]
2332 fn nested_gates_shadow_and_resume_like_a_stack() {
2333 let (_b0, a0) = make_account(40);
2334 let (_b1, a1) = make_account(41);
2335 let outer_accounts = [a0];
2336 let inner_accounts = [a1];
2337 static OUTER: WritePolicy = WritePolicy::with_lamports(&[], &[0]);
2338 static INNER: WritePolicy = WritePolicy::with_lamports(&[], &[]);
2339
2340 let _outer = install_lamport_gate(&outer_accounts, &OUTER);
2341 assert!(check_lamport_mutation(outer_accounts[0].address()).is_ok());
2342 {
2343 let _inner = install_lamport_gate(&inner_accounts, &INNER);
2344 // The inner gate (highest token) shadows the outer one:
2345 // inner's empty set refuses its own account 0...
2346 assert_eq!(
2347 check_lamport_mutation(inner_accounts[0].address()),
2348 Err(write_policy_violation(0))
2349 );
2350 }
2351 // ...and dropping the inner guard frees only its slot, so the
2352 // outer gate resumes governing.
2353 assert!(lamport_gate_active());
2354 assert!(check_lamport_mutation(outer_accounts[0].address()).is_ok());
2355 }
2356
2357 // ── Redesign regression tests (value store, tokens, fail-closed) ─
2358
2359 // Guarded-tier semantics: installs a data-declaring policy, which the
2360 // `unguarded-raw-surfaces` fence refuses at install (covered by its
2361 // own explicit test in that shape).
2362 #[test]
2363 #[cfg(not(feature = "unguarded-raw-surfaces"))]
2364 fn forgotten_guard_leaves_stale_value_policy_never_ub() {
2365 static P: WritePolicy = WritePolicy::with_lamports(&[WriteRange::whole_account(0)], &[0]);
2366 let stale_address;
2367 {
2368 let (_b0, a0) = make_account(50);
2369 let accounts = [a0];
2370 stale_address = *accounts[0].address();
2371 let guard = install_lamport_gate(&accounts, &P);
2372 // Safe code can always skip Drop. With the value store this
2373 // leaks a slot; the old pointer store turned every later
2374 // check into a dangling dereference.
2375 core::mem::forget(guard);
2376 // `accounts` and its backing buffer are freed here.
2377 }
2378
2379 // The stale gate is still installed, as VALUES, so probing it
2380 // after the accounts are gone is plain comparison, not UB.
2381 assert!(lamport_gate_active());
2382
2383 // A fresh, unrelated account is foreign to the stale policy:
2384 // fail closed (observable stale enforcement, the documented
2385 // residual of leaking a guard).
2386 let (_bf, fresh) = make_account(51);
2387 assert_eq!(
2388 check_lamport_mutation(fresh.address()),
2389 Err(write_policy_violation(u8::MAX))
2390 );
2391
2392 // An address equal to the stale entry is governed by the stale
2393 // permission bits (allowed here), stale-value semantics, by
2394 // value, no liveness required.
2395 assert!(check_lamport_mutation(&stale_address).is_ok());
2396
2397 // The leaked slot stays occupied for this thread-local tier;
2398 // the test thread ends here, taking the store with it.
2399 }
2400
2401 #[test]
2402 fn out_of_order_guard_drops_cannot_corrupt_other_gates() {
2403 let (_b0, a0) = make_account(60);
2404 let (_b1, a1) = make_account(61);
2405 let outer_accounts = [a0];
2406 let inner_accounts = [a1];
2407 static OUTER: WritePolicy = WritePolicy::with_lamports(&[], &[0]);
2408 static INNER: WritePolicy = WritePolicy::with_lamports(&[], &[]);
2409
2410 let outer = install_lamport_gate(&outer_accounts, &OUTER);
2411 let inner = install_lamport_gate(&inner_accounts, &INNER);
2412
2413 // Inner (most recently installed) governs while both are alive.
2414 assert_eq!(
2415 check_lamport_mutation(inner_accounts[0].address()),
2416 Err(write_policy_violation(0))
2417 );
2418
2419 // Drop the OUTER guard first, out of creation order. Under the
2420 // old prev-chain this restored a stale snapshot over the live
2421 // inner gate; under the token store it frees only outer's slot.
2422 drop(outer);
2423 assert!(lamport_gate_active());
2424 assert_eq!(
2425 check_lamport_mutation(inner_accounts[0].address()),
2426 Err(write_policy_violation(0))
2427 );
2428 // Outer's account is now foreign to the (still governing) inner
2429 // gate, fail closed, not fail open.
2430 assert_eq!(
2431 check_lamport_mutation(outer_accounts[0].address()),
2432 Err(write_policy_violation(u8::MAX))
2433 );
2434
2435 // Dropping inner empties the store: passthrough, nothing stale.
2436 drop(inner);
2437 assert!(!lamport_gate_active());
2438 assert!(check_lamport_mutation(outer_accounts[0].address()).is_ok());
2439 }
2440
2441 #[test]
2442 fn depth_exhaustion_fails_closed_loudly() {
2443 static P: WritePolicy = WritePolicy::with_lamports(&[], &[]);
2444 let (_b, a) = make_account(70);
2445 let accounts = [a];
2446 let _g1 = try_install_lamport_gate(&accounts, &P).unwrap();
2447 let _g2 = try_install_lamport_gate(&accounts, &P).unwrap();
2448 let _g3 = try_install_lamport_gate(&accounts, &P).unwrap();
2449 let _g4 = try_install_lamport_gate(&accounts, &P).unwrap();
2450 // All LAMPORT_GATE_DEPTH slots occupied: the next install is
2451 // refused loudly, nothing is evicted or corrupted.
2452 assert_eq!(
2453 try_install_lamport_gate(&accounts, &P).unwrap_err(),
2454 LAMPORT_GATE_DEPTH_EXCEEDED
2455 );
2456 // The existing gates keep enforcing.
2457 assert_eq!(
2458 check_lamport_mutation(accounts[0].address()),
2459 Err(write_policy_violation(0))
2460 );
2461 }
2462
2463 #[test]
2464 fn install_fails_closed_when_accounts_exceed_capacity() {
2465 static P: WritePolicy = WritePolicy::with_lamports(&[], &[]);
2466 let mut backings = std::vec::Vec::new();
2467 let mut views = std::vec::Vec::new();
2468 let mut i = 0;
2469 while i < LAMPORT_GATE_CAPACITY + 1 {
2470 let (backing, view) = make_account((i % 256) as u8);
2471 backings.push(backing);
2472 views.push(view);
2473 i += 1;
2474 }
2475 // More accounts than the gate can copy: refuse loudly rather
2476 // than silently truncate the governed set.
2477 assert_eq!(
2478 try_install_lamport_gate(&views, &P).unwrap_err(),
2479 LAMPORT_GATE_TOO_MANY_ACCOUNTS
2480 );
2481 assert!(!lamport_gate_active());
2482 }
2483
2484 #[test]
2485 fn duplicate_addresses_share_one_merged_permission() {
2486 // Two views with the SAME address at indices 0 and 1; lamports
2487 // declared only on index 1. Permission is a property of the
2488 // account (the loader hands duplicate metas one RuntimeAccount),
2489 // so the merged entry allows mutation through either position.
2490 let (_b0, a0) = make_account(75);
2491 let (_b1, a1) = make_account(75);
2492 let accounts = [a0, a1];
2493 static P: WritePolicy = WritePolicy::with_lamports(&[], &[1]);
2494 let _gate = install_lamport_gate(&accounts, &P);
2495 assert!(check_lamport_mutation(accounts[0].address()).is_ok());
2496 assert!(check_lamport_mutation(accounts[1].address()).is_ok());
2497 }
2498
2499 #[test]
2500 fn fallback_tier_second_install_is_refused_fail_closed() {
2501 // Direct exercise of the host fallback tier's single-slot store
2502 // (the process-global tier is not active under `test`, but its
2503 // store type is compiled and its semantics are tier-independent:
2504 // GateStore<1> + spinlock).
2505 static P: WritePolicy = WritePolicy::with_lamports(&[], &[0]);
2506 let (_b0, a0) = make_account(80);
2507 let (_b1, a1) = make_account(81);
2508 let first_accounts = [a0];
2509 let second_accounts = [a1];
2510
2511 let store = SpinlockGateStore::new();
2512 let token = store
2513 .with_lock(|s| s.install_with_args(&first_accounts, &P, &[]))
2514 .unwrap();
2515
2516 // Second install while one gate is active: refused loudly (the
2517 // tier maps NoFreeSlot to LAMPORT_GATE_CONTENDED), never shared
2518 // and never corrupting the first gate.
2519 assert_eq!(
2520 store
2521 .with_lock(|s| s.install_with_args(&second_accounts, &P, &[]))
2522 .unwrap_err(),
2523 GateInstallError::NoFreeSlot
2524 );
2525
2526 // The first gate still enforces, entirely under the lock.
2527 store.with_lock(|s| {
2528 assert!(s
2529 .check(first_accounts[0].address(), GateCheck::Lamports)
2530 .is_ok());
2531 assert_eq!(
2532 s.check(second_accounts[0].address(), GateCheck::Lamports),
2533 Err(write_policy_violation(u8::MAX))
2534 );
2535 });
2536
2537 // Releasing the first gate frees the slot for the next install.
2538 store.with_lock(|s| s.remove(token));
2539 assert!(store
2540 .with_lock(|s| s.install_with_args(&second_accounts, &P, &[]))
2541 .is_ok());
2542 }
2543}