Skip to main content

ic_memory/runtime/
layout.rs

1//! Read-only adapter for the documented ic-stable-structures 0.7.2 MGR V1
2//! layout. No ledger or payload reads. Keep the dependency pinned when changing
3//! this adapter; layout changes require a new review, not a fallback decoder.
4use super::RuntimeConstructionError;
5use crate::WASM_PAGE_SIZE_BYTES;
6use ic_platform::stable_structures::Memory;
7
8pub(super) const IDS: usize = 255;
9pub(super) const BUCKET_CAPACITY: u16 = 32_768;
10pub(super) const BUCKETS: usize = BUCKET_CAPACITY as usize;
11pub(super) const HEADER_BYTES: usize = 40 + IDS * 8;
12pub(super) const METADATA_BYTES: usize = HEADER_BYTES + BUCKETS;
13
14///
15/// MemoryManagerLayoutError
16///
17/// Invalid or unsupported persisted manager metadata. No writes are performed
18/// when reporting these failures. Memory implementations must obey [`Memory`].
19///
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
22#[non_exhaustive]
23pub enum MemoryManagerLayoutError {
24    /// This adapter supports the little-endian IC/current native layout only.
25    #[error("unsupported big-endian manager layout")]
26    UnsupportedByteOrder,
27    /// The persisted bucket size is zero.
28    #[error("persisted bucket size is zero")]
29    ZeroBucketSize,
30    /// Reserved header fields are not recognized by this adapter.
31    #[error("nonzero reserved manager header bytes")]
32    ReservedHeader,
33    /// The allocated count exceeds the finite bucket table.
34    #[error("allocated bucket count {count} exceeds 32768")]
35    BucketCount { count: u16 },
36    /// Allocated buckets must be a dense prefix and all later entries unused.
37    #[error("invalid bucket table entry at index {index}")]
38    BucketTable { index: u16 },
39    /// Virtual extent disagrees with the number of assigned buckets.
40    #[error("virtual extent and bucket count disagree for memory ID {id}")]
41    VirtualExtent { id: u8 },
42    /// Backing memory does not cover every assigned bucket.
43    #[error("physical extent {physical_pages} pages is below assigned end {required_pages}")]
44    TruncatedBacking {
45        physical_pages: u64,
46        required_pages: u64,
47    },
48    /// Backing size cannot be represented in bytes.
49    #[error("backing memory extent overflows bytes")]
50    ExtentOverflow,
51    /// A live manager's cached state differs from persisted metadata.
52    #[error("persisted manager metadata differs from runtime authority")]
53    RuntimeMismatch,
54}
55
56pub(super) struct Layout {
57    pub metadata: [u8; METADATA_BYTES],
58    pub physical_pages: u64,
59    pub bucket_pages: u16,
60    pub allocated_buckets: u16,
61    pub pages: [u64; IDS],
62    pub buckets: [u16; IDS],
63}
64
65// Exact expected metadata, never refreshed from foreign mutations. The pinned
66// manager is still the only writer. Growth projects its known header/table
67// edits into this read-only authority before comparing the persisted result.
68pub(super) struct ManagerAuthority {
69    expected: Box<[u8]>,
70    poisoned: std::cell::Cell<bool>,
71}
72
73impl ManagerAuthority {
74    pub fn capture(memory: &impl Memory) -> Self {
75        let mut expected = vec![0; METADATA_BYTES].into_boxed_slice();
76        memory.read(0, &mut expected);
77        Self {
78            expected,
79            poisoned: std::cell::Cell::new(false),
80        }
81    }
82
83    pub fn allocated_buckets(&self) -> u16 {
84        u16::from_le_bytes([self.expected[4], self.expected[5]])
85    }
86
87    pub fn verify(&self, memory: &impl Memory) -> Result<(), MemoryManagerLayoutError> {
88        self.check_healthy()?;
89        #[expect(
90            clippy::large_stack_arrays,
91            reason = "fixed manager metadata bound, no payload reads or heap allocation"
92        )]
93        let mut actual = [0; METADATA_BYTES];
94        let required_pages = 1 + u64::from(self.allocated_buckets())
95            * u64::from(u16::from_le_bytes([self.expected[6], self.expected[7]]));
96        if memory.size() < required_pages {
97            self.poisoned.set(true);
98            return Err(MemoryManagerLayoutError::RuntimeMismatch);
99        }
100        memory.read(0, &mut actual);
101        self.verify_observed(&actual)
102    }
103
104    pub const fn check_healthy(&self) -> Result<(), MemoryManagerLayoutError> {
105        if self.poisoned.get() {
106            Err(MemoryManagerLayoutError::RuntimeMismatch)
107        } else {
108            Ok(())
109        }
110    }
111
112    pub fn poison(&self) {
113        self.poisoned.set(true);
114    }
115
116    pub fn verify_observed(&self, actual: &[u8]) -> Result<(), MemoryManagerLayoutError> {
117        // MGR V1's complete 34848-byte image is an exact number of words.
118        // Explicit word comparison avoids Wasm's byte-at-a-time memcmp while
119        // preserving every byte of the admission boundary, including unused IDs.
120        #[cfg(target_arch = "wasm32")]
121        let matches = {
122            let (actual_words, actual_tail) = actual.as_chunks::<8>();
123            let (expected_words, expected_tail) = self.expected.as_chunks::<8>();
124            actual.len() == METADATA_BYTES
125                && self.expected.len() == METADATA_BYTES
126                && actual_tail.is_empty()
127                && expected_tail.is_empty()
128                && actual_words
129                    .iter()
130                    .zip(expected_words)
131                    .all(|(actual, expected)| {
132                        u64::from_le_bytes(*actual) == u64::from_le_bytes(*expected)
133                    })
134        };
135        // Native slice equality uses the optimized host comparator, including
136        // in debug test builds. Both paths compare the complete exact image.
137        #[cfg(not(target_arch = "wasm32"))]
138        let matches = actual.len() == METADATA_BYTES && actual == self.expected.as_ref();
139        if self.poisoned.get() || !matches {
140            self.poisoned.set(true);
141            return Err(MemoryManagerLayoutError::RuntimeMismatch);
142        }
143        Ok(())
144    }
145
146    pub fn record_growth(&mut self, id: u8, pages: u64, allocated: u16) {
147        let old = usize::from(self.allocated_buckets());
148        self.expected[HEADER_BYTES + old..HEADER_BYTES + usize::from(allocated)].fill(id);
149        self.expected[4..6].copy_from_slice(&allocated.to_le_bytes());
150        let offset = 40 + usize::from(id) * 8;
151        self.expected[offset..offset + 8].copy_from_slice(&pages.to_le_bytes());
152    }
153}
154
155pub(super) fn read<M: Memory>(memory: &M) -> Result<Layout, RuntimeConstructionError> {
156    if cfg!(target_endian = "big") {
157        return Err(MemoryManagerLayoutError::UnsupportedByteOrder.into());
158    }
159    let physical_pages = memory.size();
160    physical_pages
161        .checked_mul(WASM_PAGE_SIZE_BYTES)
162        .ok_or(MemoryManagerLayoutError::ExtentOverflow)?;
163    // The caller handles empty memory. Even one page covers all metadata.
164    if physical_pages == 0 {
165        return Err(MemoryManagerLayoutError::TruncatedBacking {
166            physical_pages,
167            required_pages: 1,
168        }
169        .into());
170    }
171    #[expect(
172        clippy::large_stack_arrays,
173        reason = "bounded manager image is also the diagnostic authority observation"
174    )]
175    let mut metadata = [0; METADATA_BYTES];
176    let (header, table) = metadata.split_at_mut(HEADER_BYTES);
177    memory.read(0, header);
178    let observed_magic = [header[0], header[1], header[2]];
179    if observed_magic != *b"MGR" {
180        return Err(RuntimeConstructionError::ForeignMemory { observed_magic });
181    }
182    if header[3] != 1 {
183        return Err(RuntimeConstructionError::UnsupportedMemoryManagerVersion {
184            observed: header[3],
185            supported: 1,
186        });
187    }
188    if header[8..40].iter().any(|byte| *byte != 0) {
189        return Err(MemoryManagerLayoutError::ReservedHeader.into());
190    }
191    let allocated_buckets = u16::from_le_bytes([header[4], header[5]]);
192    let bucket_pages = u16::from_le_bytes([header[6], header[7]]);
193    if bucket_pages == 0 {
194        return Err(MemoryManagerLayoutError::ZeroBucketSize.into());
195    }
196    if usize::from(allocated_buckets) > BUCKETS {
197        return Err(MemoryManagerLayoutError::BucketCount {
198            count: allocated_buckets,
199        }
200        .into());
201    }
202    let required_pages = 1 + u64::from(allocated_buckets) * u64::from(bucket_pages);
203    if physical_pages < required_pages {
204        return Err(MemoryManagerLayoutError::TruncatedBacking {
205            physical_pages,
206            required_pages,
207        }
208        .into());
209    }
210    // Fixed local buffer and read bound, independent of physical size/history.
211    memory.read(HEADER_BYTES as u64, table);
212    let mut buckets = [0_u16; IDS];
213    for (index, id) in (0_u16..32_768).zip(table.iter().copied()) {
214        if (index < allocated_buckets) != (id != 255) {
215            return Err(MemoryManagerLayoutError::BucketTable { index }.into());
216        }
217        if id != 255 {
218            buckets[usize::from(id)] += 1;
219        }
220    }
221    let mut pages = [0; IDS];
222    for (id, value) in (0_u8..255).zip(&mut pages) {
223        let offset = 40 + usize::from(id) * 8;
224        *value = u64::from_le_bytes(header[offset..offset + 8].try_into().expect("eight bytes"));
225        if value.div_ceil(u64::from(bucket_pages)) != u64::from(buckets[usize::from(id)]) {
226            return Err(MemoryManagerLayoutError::VirtualExtent { id }.into());
227        }
228    }
229    Ok(Layout {
230        metadata,
231        physical_pages,
232        bucket_pages,
233        allocated_buckets,
234        pages,
235        buckets,
236    })
237}