Skip to main content

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}