hopper_runtime/migrate.rs
1//! Schema-epoch in-place migration runtime.
2//!
3//! Closes the Hopper Safety Audit's innovation item I4 ("Schema epoch
4//! with in-place migration helpers"). The header's `schema_epoch: u32`
5//! lets accounts self-identify the ABI version they were written in.
6//! When a program later loads an account written at an older epoch,
7//! the runtime consults a declared migration chain, applies each edge
8//! in sequence atomically with a `schema_epoch` bump, and only then
9//! hands the caller a typed `Ref<'_, T>` of the current shape.
10//!
11//! # Design rules
12//!
13//! * **In-place**. no allocation, no CPI. Migration rewrites the
14//! account body (within its existing byte range) and the 16-byte
15//! Hopper header.
16//! * **Atomic per edge**. each migration edge updates both the body
17//! *and* the `schema_epoch` header field under a single mutable
18//! byte borrow. A mid-migration abort leaves the header and body
19//! consistent with *one* of the two endpoints, never a hybrid.
20//! * **Idempotent**. re-running an already-applied edge is a no-op
21//! (the header epoch mismatch returns `MigrationMismatch`).
22//! * **Deterministic**. edges are applied in strict
23//! `from_epoch → to_epoch` order, and any gap in the chain fails.
24
25use crate::account::AccountView;
26use crate::error::ProgramError;
27use crate::layout::{HopperHeader, LayoutContract};
28use crate::zerocopy::AccountLayout;
29
30/// One step in a layout's migration chain.
31///
32/// An edge takes the raw account *body* (the bytes after the 16-byte
33/// Hopper header), mutates them in place to match the new epoch's
34/// shape, and returns `Ok(())` on success. The runtime then atomically
35/// bumps the header's `schema_epoch` to `to_epoch` under the same
36/// mutable borrow.
37///
38/// Migration functions must not call CPIs (no CreateAccount, no
39/// Transfer) and must not resize the account (use `realloc` for that
40/// separately). They may read and write arbitrary bytes within the
41/// body, which is why the signature takes `&mut [u8]`. `ZeroCopy`
42/// safety has deliberately been stepped out of because the user is
43/// explicitly translating between two different byte layouts.
44#[derive(Clone, Copy)]
45pub struct MigrationEdge {
46 /// Epoch the body is expected to be in before this edge runs.
47 pub from_epoch: u32,
48 /// Epoch the body will be in after this edge runs successfully.
49 pub to_epoch: u32,
50 /// In-place mutator. Called exactly once per upgrade sequence.
51 pub migrator: fn(body: &mut [u8]) -> Result<(), ProgramError>,
52}
53
54impl MigrationEdge {
55 /// Reject edges that would decrement or stay at the same epoch .
56 /// migrations always move forward.
57 pub const fn is_forward(&self) -> bool {
58 self.to_epoch > self.from_epoch
59 }
60}
61
62/// Layouts opt into in-place migration by providing a `MIGRATIONS`
63/// constant. The default (empty slice) means "no migrations declared"
64/// and any mismatch between header and `AccountLayout::SCHEMA_EPOCH`
65/// is a hard failure.
66///
67/// The trait is sealed-by-convention: downstream crates should
68/// express migrations via the `#[hopper::migrate(...)]` attribute
69/// macro and the `hopper::layout_migrations!` composition helper,
70/// never by hand-writing `impl LayoutMigration for T`.
71pub trait LayoutMigration {
72 /// Ordered migration chain. `MIGRATIONS[i].to_epoch ==
73 /// MIGRATIONS[i + 1].from_epoch` must hold for every adjacent
74 /// pair, and the whole chain must be strictly monotonic.
75 const MIGRATIONS: &'static [MigrationEdge];
76}
77
78// No blanket impl. stable Rust doesn't allow specialization, so a
79// blanket `impl<T: AccountLayout> LayoutMigration for T` would lock
80// out user opt-ins. Types without migrations simply never implement
81// `LayoutMigration` and are therefore ineligible for
82// `apply_pending_migrations::<T>`. which is the correct behaviour:
83// you opt in to in-place migration by declaring a chain.
84
85/// Apply all pending migrations needed to bring the account at
86/// `current_epoch` up to `AccountLayout::SCHEMA_EPOCH`.
87///
88/// Returns `Ok(applied_count)` if everything up-migrated cleanly.
89/// Returns `Err(MigrationMismatch)` if the declared chain is
90/// incomplete, non-monotonic, or doesn't start at `current_epoch`.
91/// Returns `Err(MigrationRejected)` if a user migrator function
92/// returned an error.
93#[inline]
94pub fn apply_pending_migrations<T>(
95 account: &AccountView,
96 current_epoch: u32,
97) -> Result<u32, ProgramError>
98where
99 T: AccountLayout + LayoutContract + LayoutMigration,
100{
101 let target_epoch = <T as AccountLayout>::SCHEMA_EPOCH;
102 if current_epoch == target_epoch {
103 return Ok(0);
104 }
105 if current_epoch > target_epoch {
106 // Account is from a FUTURE epoch. forward-compatibility is
107 // out of scope for in-place migration. Caller must refuse
108 // or route to a different program.
109 return Err(ProgramError::InvalidAccountData);
110 }
111
112 let edges = <T as LayoutMigration>::MIGRATIONS;
113 let mut applied = 0u32;
114 let mut epoch = current_epoch;
115
116 // Single mutable borrow across the whole chain. atomicity per
117 // edge is maintained by rewriting the header's schema_epoch byte
118 // range before the borrow is released.
119 let mut data = account.try_borrow_mut()?;
120 let header_len = core::mem::size_of::<HopperHeader>();
121 if data.len() < header_len {
122 return Err(ProgramError::AccountDataTooSmall);
123 }
124
125 while epoch < target_epoch {
126 let edge = find_edge(edges, epoch)?;
127 let (header_bytes, body_bytes) = data.split_at_mut(header_len);
128 // Step 1: mutate the body.
129 (edge.migrator)(body_bytes)?;
130 // Step 2: atomically bump the header's schema_epoch field.
131 // Header layout is `#[repr(C, packed)]`: bytes 12..16 are
132 // `schema_epoch: u32 LE` per `layout.rs`.
133 let new_epoch_bytes = edge.to_epoch.to_le_bytes();
134 header_bytes[12..16].copy_from_slice(&new_epoch_bytes);
135 epoch = edge.to_epoch;
136 applied += 1;
137 }
138
139 Ok(applied)
140}
141
142/// Locate the edge whose `from_epoch == epoch`. Returns an
143/// `InvalidAccountData` error if the chain is discontinuous.
144#[inline]
145fn find_edge(edges: &[MigrationEdge], epoch: u32) -> Result<&MigrationEdge, ProgramError> {
146 for edge in edges {
147 if edge.from_epoch == epoch {
148 if !edge.is_forward() {
149 // A declared migration that doesn't advance the
150 // epoch is malformed by construction.
151 return Err(ProgramError::InvalidAccountData);
152 }
153 return Ok(edge);
154 }
155 }
156 Err(ProgramError::InvalidAccountData)
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 fn identity(_body: &mut [u8]) -> Result<(), ProgramError> {
164 Ok(())
165 }
166
167 #[test]
168 fn migration_edge_is_forward_detects_non_monotonic() {
169 let forward = MigrationEdge {
170 from_epoch: 1,
171 to_epoch: 2,
172 migrator: identity,
173 };
174 let backward = MigrationEdge {
175 from_epoch: 3,
176 to_epoch: 2,
177 migrator: identity,
178 };
179 let same = MigrationEdge {
180 from_epoch: 2,
181 to_epoch: 2,
182 migrator: identity,
183 };
184 assert!(forward.is_forward());
185 assert!(!backward.is_forward());
186 assert!(!same.is_forward());
187 }
188
189 #[test]
190 fn find_edge_returns_matching_edge() {
191 let edges = [
192 MigrationEdge {
193 from_epoch: 1,
194 to_epoch: 2,
195 migrator: identity,
196 },
197 MigrationEdge {
198 from_epoch: 2,
199 to_epoch: 3,
200 migrator: identity,
201 },
202 ];
203 let e1 = find_edge(&edges, 1).expect("edge exists");
204 assert_eq!(e1.to_epoch, 2);
205 let e2 = find_edge(&edges, 2).expect("edge exists");
206 assert_eq!(e2.to_epoch, 3);
207 }
208
209 #[test]
210 fn find_edge_errs_on_missing_epoch() {
211 let edges = [MigrationEdge {
212 from_epoch: 1,
213 to_epoch: 2,
214 migrator: identity,
215 }];
216 // No edge starts at epoch 5.
217 assert!(find_edge(&edges, 5).is_err());
218 }
219
220 #[test]
221 fn find_edge_rejects_non_forward_edge() {
222 let edges = [MigrationEdge {
223 from_epoch: 3,
224 to_epoch: 2,
225 migrator: identity,
226 }];
227 assert!(find_edge(&edges, 3).is_err());
228 }
229}