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