Skip to main content

hopper_core/account/
realloc_guard.rs

1//! Realloc session guard -- cumulative growth-limit enforcement.
2//!
3//! Tracks original account sizes at instruction entry and enforces
4//! a per-instruction growth budget. Prevents runaway realloc chains
5//! that could exhaust transaction compute or memory budgets.
6//!
7//! ## Wire model
8//!
9//! Entirely stack-based. No account data overhead -- the guard lives
10//! in the instruction handler's stack frame and is discarded at return.
11//!
12//! ## Usage
13//!
14//! ```ignore
15//! let mut guard = ReallocGuard::<8>::new(10240); // 10KB budget
16//! guard.register(0, vault_account.data_len());
17//! guard.register(1, config_account.data_len());
18//!
19//! // Later, when reallocating:
20//! guard.check_growth(0, new_size)?;
21//! safe_realloc(vault_account, new_size, payer)?;
22//! guard.commit_growth(0, new_size);
23//! ```
24
25use hopper_runtime::error::ProgramError;
26
27#[inline(always)]
28fn checked_size_u32(size: usize) -> Result<u32, ProgramError> {
29    u32::try_from(size).map_err(|_| ProgramError::InvalidRealloc)
30}
31
32/// Per-instruction realloc budget guard.
33///
34/// `N` is the maximum number of accounts to track (const generic, stack-allocated).
35/// Typical values: 4, 8, or 16.
36pub struct ReallocGuard<const N: usize> {
37    /// Original sizes of tracked accounts.
38    original: [u32; N],
39    /// Current sizes of tracked accounts (updated after each realloc).
40    current: [u32; N],
41    /// Number of registered accounts.
42    count: usize,
43    /// Maximum cumulative growth allowed (bytes).
44    budget: u32,
45    /// Cumulative growth consumed so far.
46    consumed: u32,
47}
48
49impl<const N: usize> ReallocGuard<N> {
50    /// Create a new guard with the given growth budget (bytes).
51    #[inline(always)]
52    pub const fn new(budget: u32) -> Self {
53        Self {
54            original: [0u32; N],
55            current: [0u32; N],
56            count: 0,
57            budget,
58            consumed: 0,
59        }
60    }
61
62    /// Register an account's original size for tracking.
63    ///
64    /// `slot` is the local tracking index (0..N-1), not the account index.
65    /// `size` is the current data length.
66    /// Returns `Err` if `slot >= N`.
67    #[inline(always)]
68    pub fn register(&mut self, slot: usize, size: usize) -> Result<(), ProgramError> {
69        if slot >= N {
70            return Err(ProgramError::InvalidArgument);
71        }
72        let size32 = checked_size_u32(size)?;
73        self.original[slot] = size32;
74        self.current[slot] = size32;
75        if slot >= self.count {
76            self.count = slot + 1;
77        }
78        Ok(())
79    }
80
81    /// Check if growing account `slot` to `new_size` is within budget.
82    ///
83    /// Does NOT commit the growth -- call `commit_growth` after the
84    /// actual realloc succeeds.
85    #[inline]
86    pub fn check_growth(&self, slot: usize, new_size: usize) -> Result<(), ProgramError> {
87        if slot >= self.count {
88            return Err(ProgramError::InvalidArgument);
89        }
90        let new_size32 = checked_size_u32(new_size)?;
91        let current = self.current[slot];
92
93        if new_size32 <= current {
94            // Shrinking or same -- always allowed.
95            return Ok(());
96        }
97
98        let delta = new_size32 - current;
99        let new_consumed = self
100            .consumed
101            .checked_add(delta)
102            .ok_or(ProgramError::ArithmeticOverflow)?;
103
104        if new_consumed > self.budget {
105            return Err(ProgramError::InvalidRealloc);
106        }
107
108        Ok(())
109    }
110
111    /// Commit a growth after successful realloc.
112    ///
113    /// Must be called after the actual `safe_realloc` succeeds. This repeats
114    /// the checked growth accounting instead of trusting callers to have run
115    /// [`Self::check_growth`] first, so the commit step is safe on its own.
116    /// Returns `Err` if `slot` is not registered or the budget would be exceeded.
117    #[inline(always)]
118    pub fn commit_growth(&mut self, slot: usize, new_size: usize) -> Result<(), ProgramError> {
119        if slot >= self.count {
120            return Err(ProgramError::InvalidArgument);
121        }
122        let new_size32 = checked_size_u32(new_size)?;
123        let current = self.current[slot];
124
125        if new_size32 > current {
126            let delta = new_size32 - current;
127            let new_consumed = self
128                .consumed
129                .checked_add(delta)
130                .ok_or(ProgramError::ArithmeticOverflow)?;
131            if new_consumed > self.budget {
132                return Err(ProgramError::InvalidRealloc);
133            }
134            self.consumed = new_consumed;
135        } else if new_size32 < current {
136            // Shrinking: return budget credit.
137            let credit = current - new_size32;
138            self.consumed = self.consumed.saturating_sub(credit);
139        }
140
141        self.current[slot] = new_size32;
142        Ok(())
143    }
144
145    /// Remaining budget (bytes).
146    #[inline(always)]
147    pub const fn remaining(&self) -> u32 {
148        self.budget.saturating_sub(self.consumed)
149    }
150
151    /// Total consumed growth (bytes).
152    #[inline(always)]
153    pub const fn consumed(&self) -> u32 {
154        self.consumed
155    }
156
157    /// Budget cap (bytes).
158    #[inline(always)]
159    pub const fn budget(&self) -> u32 {
160        self.budget
161    }
162
163    /// Growth of a specific slot from its original size (bytes).
164    #[inline(always)]
165    pub fn slot_growth(&self, slot: usize) -> i64 {
166        if slot >= self.count {
167            return 0;
168        }
169        i64::from(self.current[slot]) - i64::from(self.original[slot])
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn basic_growth_tracking() {
179        let mut guard = ReallocGuard::<4>::new(1024);
180        guard.register(0, 100).unwrap();
181        guard.register(1, 200).unwrap();
182
183        // Growth within budget
184        assert!(guard.check_growth(0, 200).is_ok());
185        guard.commit_growth(0, 200).unwrap();
186        assert_eq!(guard.consumed(), 100);
187        assert_eq!(guard.remaining(), 924);
188
189        // Check slot growth
190        assert_eq!(guard.slot_growth(0), 100);
191        assert_eq!(guard.slot_growth(1), 0);
192    }
193
194    #[test]
195    fn budget_exceeded() {
196        let mut guard = ReallocGuard::<4>::new(100);
197        guard.register(0, 50).unwrap();
198
199        // Try to grow beyond budget
200        assert!(guard.check_growth(0, 200).is_err()); // 150 > 100 budget
201    }
202
203    #[test]
204    fn commit_growth_enforces_budget_without_precheck() {
205        let mut guard = ReallocGuard::<4>::new(100);
206        guard.register(0, 50).unwrap();
207
208        assert_eq!(
209            guard.commit_growth(0, 200),
210            Err(ProgramError::InvalidRealloc)
211        );
212        assert_eq!(guard.consumed(), 0);
213        assert_eq!(guard.slot_growth(0), 0);
214    }
215
216    #[test]
217    fn shrink_returns_credit() {
218        let mut guard = ReallocGuard::<4>::new(200);
219        guard.register(0, 100).unwrap();
220
221        // Grow
222        guard.commit_growth(0, 200).unwrap();
223        assert_eq!(guard.consumed(), 100);
224
225        // Shrink -- returns credit
226        guard.commit_growth(0, 150).unwrap();
227        assert_eq!(guard.consumed(), 50);
228        assert_eq!(guard.remaining(), 150);
229    }
230
231    #[test]
232    fn same_size_is_noop() {
233        let mut guard = ReallocGuard::<4>::new(100);
234        guard.register(0, 100).unwrap();
235
236        assert!(guard.check_growth(0, 100).is_ok());
237        guard.commit_growth(0, 100).unwrap();
238        assert_eq!(guard.consumed(), 0);
239    }
240
241    #[test]
242    fn slot_growth_reports_full_u32_range_without_wrapping() {
243        let mut guard = ReallocGuard::<1>::new(u32::MAX);
244        guard.register(0, 0).unwrap();
245        guard
246            .commit_growth(0, usize::try_from(u32::MAX).unwrap())
247            .unwrap();
248
249        assert_eq!(guard.slot_growth(0), i64::from(u32::MAX));
250    }
251
252    #[test]
253    fn register_out_of_bounds() {
254        let mut guard = ReallocGuard::<2>::new(1024);
255        assert!(guard.register(0, 100).is_ok());
256        assert!(guard.register(1, 200).is_ok());
257        assert!(guard.register(2, 300).is_err()); // N=2, slot 2 is OOB
258    }
259
260    #[test]
261    fn commit_unregistered_slot() {
262        let mut guard = ReallocGuard::<4>::new(1024);
263        guard.register(0, 100).unwrap();
264        // slot 3 was never registered
265        assert!(guard.commit_growth(3, 200).is_err());
266    }
267
268    #[test]
269    fn oversized_usize_realloc_is_rejected_explicitly() {
270        let mut guard = ReallocGuard::<2>::new(u32::MAX);
271        let too_large = (u32::MAX as usize).saturating_add(1);
272
273        assert_eq!(
274            guard.register(0, too_large),
275            Err(ProgramError::InvalidRealloc)
276        );
277        guard.register(0, 16).unwrap();
278        assert_eq!(
279            guard.check_growth(0, too_large),
280            Err(ProgramError::InvalidRealloc)
281        );
282        assert_eq!(
283            guard.commit_growth(0, too_large),
284            Err(ProgramError::InvalidRealloc)
285        );
286    }
287}