Skip to main content

ic_memory/
stable_cell.rs

1use crate::{LedgerCommitStore, constants::WASM_PAGE_SIZE_BYTES};
2use ic_platform::stable_structures::{Memory, Storable, storable::Bound};
3use std::borrow::Cow;
4use thiserror::Error;
5
6/// Stable-cell magic prefix written by `ic-stable-structures::Cell`.
7pub const STABLE_CELL_MAGIC: &[u8; 3] = b"SCL";
8/// Stable-cell layout version supported by this adapter.
9pub const STABLE_CELL_LAYOUT_VERSION: u8 = 1;
10/// Stable-cell header byte length.
11pub const STABLE_CELL_HEADER_SIZE: usize = 8;
12/// Byte offset where the stable-cell value payload starts.
13pub const STABLE_CELL_VALUE_OFFSET: u64 = 8;
14
15///
16/// StableCellPayloadError
17///
18/// Stable-cell payload decode failure.
19///
20
21#[non_exhaustive]
22#[derive(Clone, Debug, Eq, Error, PartialEq)]
23pub enum StableCellPayloadError {
24    /// Declared bytes exceed the current recovery ceiling before allocation.
25    #[error("stable-cell ledger payload length {value_len} exceeds recovery limit")]
26    TooLarge { value_len: u64 },
27    /// Memory contents do not start with the stable-cell marker.
28    #[error("memory is not an ic-stable-structures Cell")]
29    NotStableCell,
30    /// Stable-cell layout version does not match the adapter's current shape.
31    #[error("unexpected stable-cell layout version {version}")]
32    UnexpectedLayoutVersion {
33        /// Observed stable-cell version.
34        version: u8,
35    },
36    /// Stable-cell header length does not fit inside the memory.
37    #[error("stable-cell payload length {value_len} exceeds available bytes {available_bytes}")]
38    InvalidLength {
39        /// Encoded value length.
40        value_len: u64,
41        /// Available payload bytes in memory.
42        available_bytes: u64,
43    },
44}
45
46///
47/// StableCellLedgerError
48///
49/// Stable-cell ledger record validation failure.
50///
51/// Record errors retain the IC Wire decoder's typed source. Ledger recovery and
52/// allocation integrity remain separate checks after decoding.
53///
54
55#[non_exhaustive]
56#[derive(Debug, Error)]
57pub enum StableCellLedgerError {
58    /// Stable-cell envelope is corrupt or unexpected.
59    #[error(transparent)]
60    Payload(#[from] StableCellPayloadError),
61    /// Stable-cell value bytes are not a valid ledger record.
62    #[error("stable-cell ledger record decode failed: {0}")]
63    Record(#[source] ic_wire::Error),
64}
65
66/// Decode the raw value payload from an `ic-stable-structures::Cell` memory.
67///
68/// This helper is intentionally narrow: it recognizes the physical stable-cell
69/// envelope and returns the value bytes. It does not deserialize those bytes or
70/// decide whether they represent a valid allocation ledger.
71pub fn decode_stable_cell_payload<M: Memory>(
72    memory: &M,
73) -> Result<Vec<u8>, StableCellPayloadError> {
74    let value_len = stable_cell_payload_length(memory, crate::constants::MAX_LEDGER_RECORD_BYTES)?;
75    let mut bytes = vec![0; value_len];
76    memory.read(STABLE_CELL_VALUE_OFFSET, &mut bytes);
77    Ok(bytes)
78}
79
80/// Validate a stable-cell envelope and return its payload length.
81///
82/// Reject empty/foreign memory, unsupported layouts, lengths beyond the backing
83/// extent, and lengths above the caller's `maximum`. No payload is allocated or
84/// read, and memory is never written or grown. This does not validate the value's
85/// codec or schema. The ledger reader retains its separate recovery ceiling.
86///
87/// # Panics
88/// Propagates backing `size`/`read` failures.
89pub fn stable_cell_payload_length<M: Memory>(
90    memory: &M,
91    maximum: usize,
92) -> Result<usize, StableCellPayloadError> {
93    if memory.size() == 0 {
94        return Err(StableCellPayloadError::NotStableCell);
95    }
96
97    let mut header = [0; STABLE_CELL_HEADER_SIZE];
98    memory.read(0, &mut header);
99    if &header[0..3] != STABLE_CELL_MAGIC {
100        return Err(StableCellPayloadError::NotStableCell);
101    }
102    if header[3] != STABLE_CELL_LAYOUT_VERSION {
103        return Err(StableCellPayloadError::UnexpectedLayoutVersion { version: header[3] });
104    }
105
106    let value_len = u64::from(u32::from_le_bytes([
107        header[4], header[5], header[6], header[7],
108    ]));
109    let available_bytes = memory.size().saturating_mul(WASM_PAGE_SIZE_BYTES);
110    let payload_capacity = available_bytes.saturating_sub(STABLE_CELL_VALUE_OFFSET);
111    if value_len > payload_capacity {
112        return Err(StableCellPayloadError::InvalidLength {
113            value_len,
114            available_bytes: payload_capacity,
115        });
116    }
117    if value_len > maximum as u64 {
118        return Err(StableCellPayloadError::TooLarge { value_len });
119    }
120    #[expect(
121        clippy::cast_possible_truncation,
122        reason = "the admitted caller ceiling is representable as usize"
123    )]
124    let value_len = value_len as usize;
125    Ok(value_len)
126}
127
128/// Decode a `LedgerCommitStore` from stable-cell value bytes.
129///
130/// This decodes only the cell value payload, not the enclosing stable-cell
131/// header. Use [`decode_stable_cell_payload`] first when inspecting raw stable
132/// memory.
133///
134/// The returned record is decoded DTO state, not authority. The runtime
135/// validates protected commit recovery before trusting any ledger payload.
136pub fn decode_stable_cell_commit_store(bytes: &[u8]) -> Result<LedgerCommitStore, ic_wire::Error> {
137    use crate::encoding::{RECORD_LIMITS, RECORD_MAGIC};
138    if bytes.len() > crate::constants::MAX_LEDGER_RECORD_BYTES {
139        return Err(ic_wire::Error::Limit);
140    }
141    let payload = bytes
142        .strip_prefix(RECORD_MAGIC)
143        .ok_or(ic_wire::Error::Malformed)?;
144    ic_wire::native::decode(payload, RECORD_LIMITS)
145}
146
147/// Fallibly decode a ledger record from its stable-cell envelope and value.
148///
149/// Empty memory returns an uninitialized record without writing a cell. Nonempty
150/// memory must pass the current envelope, byte bounds and record decoding. The
151/// returned DTO still requires protected ledger recovery before it is authority.
152/// The runtime retains this decoded record for recovery and separately prepares
153/// bounded publication bytes; reading never initializes a Cell.
154pub fn decode_stable_cell_commit_store_from_memory<M: Memory>(
155    memory: &M,
156) -> Result<LedgerCommitStore, StableCellLedgerError> {
157    if memory.size() == 0 {
158        return Ok(LedgerCommitStore::default());
159    }
160
161    let payload = decode_stable_cell_payload(memory)?;
162    decode_stable_cell_commit_store(&payload).map_err(StableCellLedgerError::Record)
163}
164
165///
166/// EncodedLedgerRecord
167///
168/// Temporary publication bytes, never recovered authority or retained runtime state.
169///
170
171pub struct EncodedLedgerRecord {
172    bytes: Vec<u8>,
173}
174
175impl EncodedLedgerRecord {
176    pub(crate) fn new(record: &LedgerCommitStore) -> Result<Self, ic_wire::Error> {
177        use crate::encoding::{RECORD_LIMITS, RECORD_MAGIC};
178        let payload = ic_wire::native::encode(record, RECORD_LIMITS)?;
179        let mut bytes = Vec::with_capacity(RECORD_MAGIC.len() + payload.len());
180        bytes.extend_from_slice(RECORD_MAGIC);
181        bytes.extend_from_slice(&payload);
182        Ok(Self { bytes })
183    }
184
185    pub(crate) const fn len(&self) -> usize {
186        self.bytes.len()
187    }
188}
189
190impl Storable for EncodedLedgerRecord {
191    const BOUND: Bound = Bound::Unbounded;
192
193    fn to_bytes(&self) -> Cow<'_, [u8]> {
194        Cow::Borrowed(&self.bytes)
195    }
196    fn into_bytes(self) -> Vec<u8> {
197        self.bytes
198    }
199    fn from_bytes(_: Cow<'_, [u8]>) -> Self {
200        panic!("publication bytes cannot recover ledger authority")
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::test_wire::hex_fixture;
208    use ic_platform::stable_structures::{Cell, VectorMemory};
209
210    #[test]
211    fn publication_refuses_oversized_payload_before_cell_mutation() {
212        let record = LedgerCommitStore {
213            current: Some(crate::physical::CommittedGenerationBytes::new(
214                1,
215                vec![0; crate::constants::MAX_COMMITTED_PAYLOAD_BYTES + 1],
216            )),
217        };
218        assert!(EncodedLedgerRecord::new(&record).is_err());
219    }
220
221    #[test]
222    fn stable_cell_ledger_record_round_trips_through_cell() {
223        let memory = VectorMemory::default();
224        let record = LedgerCommitStore::default();
225        drop(Cell::new(
226            memory.clone(),
227            EncodedLedgerRecord::new(&record).unwrap(),
228        ));
229        let payload = decode_stable_cell_payload(&memory).expect("decode stable cell payload");
230        let decoded = decode_stable_cell_commit_store(&payload).unwrap();
231        assert_eq!(decoded, record);
232        assert_eq!(
233            crate::decode_stable_cell_commit_store_from_memory(&memory).unwrap(),
234            record
235        );
236    }
237
238    #[test]
239    fn current_stable_cell_record_fixture_recovers() {
240        let bytes = hex_fixture(include_str!(
241            "../fixtures/current/ledger_commit_store_single.wire.hex"
242        ));
243        let record = decode_stable_cell_commit_store(&bytes).expect("stable-cell fixture");
244
245        assert_eq!(
246            EncodedLedgerRecord::new(&record).unwrap().len(),
247            bytes.len()
248        );
249        let encoded = EncodedLedgerRecord::new(&record).unwrap();
250        assert!(matches!(encoded.to_bytes(), Cow::Borrowed(_)));
251        let memory = VectorMemory::default();
252        drop(Cell::new(memory.clone(), encoded));
253        assert_eq!(decode_stable_cell_payload(&memory).unwrap(), bytes);
254        assert_eq!(
255            EncodedLedgerRecord::new(&record).unwrap().into_bytes(),
256            bytes
257        );
258        assert_eq!(
259            bytes,
260            EncodedLedgerRecord::new(&record).unwrap().into_bytes()
261        );
262        assert_eq!(
263            record
264                .recover()
265                .expect("fixture store recovers")
266                .current_generation(),
267            1
268        );
269    }
270
271    #[test]
272    fn malformed_payload_bytes_fail_closed_without_mutating_memory() {
273        use crate::encoding::RECORD_MAGIC;
274        let original = hex_fixture(include_str!(
275            "../fixtures/current/ledger_commit_store_single.wire.hex"
276        ));
277        let mut cases = vec![original[..original.len() - 1].to_vec()];
278        // Valid prefix and present snapshot followed by hostile/truncated integers.
279        for tail in [vec![255; 32], vec![1], vec![2]] {
280            let mut bytes = RECORD_MAGIC.to_vec();
281            bytes.extend_from_slice(&tail);
282            cases.push(bytes);
283        }
284        for bytes in cases {
285            let memory = VectorMemory::default();
286            memory.grow(1);
287            memory.write(0, STABLE_CELL_MAGIC);
288            memory.write(3, &[STABLE_CELL_LAYOUT_VERSION]);
289            memory.write(4, &u32::try_from(bytes.len()).unwrap().to_le_bytes());
290            memory.write(STABLE_CELL_VALUE_OFFSET, &bytes);
291            let before = memory.borrow().clone();
292            for _ in 0..2 {
293                assert!(matches!(
294                    decode_stable_cell_commit_store_from_memory(&memory),
295                    Err(StableCellLedgerError::Record(_))
296                ));
297            }
298            assert_eq!(*memory.borrow(), before);
299        }
300    }
301
302    #[test]
303    fn stable_cell_ledger_record_rejects_trailing_bytes() {
304        let mut bytes = EncodedLedgerRecord::new(&LedgerCommitStore::default())
305            .unwrap()
306            .into_bytes();
307        bytes.push(0);
308
309        let err =
310            decode_stable_cell_commit_store(&bytes).expect_err("trailing bytes must fail closed");
311
312        assert_eq!(err, ic_wire::Error::Trailing);
313    }
314
315    #[test]
316    fn stable_cell_ledger_record_rejects_extra_top_level_data() {
317        let mut bytes = EncodedLedgerRecord::new(&LedgerCommitStore::default())
318            .unwrap()
319            .into_bytes();
320        bytes.push(1);
321        assert_eq!(
322            decode_stable_cell_commit_store(&bytes),
323            Err(ic_wire::Error::Trailing)
324        );
325    }
326
327    #[test]
328    fn stable_cell_ledger_record_requires_an_explicit_current_snapshot() {
329        assert!(decode_stable_cell_commit_store(crate::encoding::RECORD_MAGIC).is_err());
330    }
331
332    #[test]
333    fn stable_cell_payload_rejects_non_cell_memory() {
334        let memory = VectorMemory::default();
335        memory.grow(1);
336        memory.write(0, b"BAD");
337
338        assert_eq!(
339            decode_stable_cell_payload(&memory),
340            Err(StableCellPayloadError::NotStableCell)
341        );
342    }
343
344    #[test]
345    fn stable_cell_payload_rejects_empty_memory_without_panic() {
346        let memory = VectorMemory::default();
347
348        assert_eq!(
349            decode_stable_cell_payload(&memory),
350            Err(StableCellPayloadError::NotStableCell)
351        );
352        assert_eq!(
353            crate::decode_stable_cell_commit_store_from_memory(&memory).unwrap(),
354            LedgerCommitStore::default()
355        );
356        assert_eq!(
357            memory.size(),
358            0,
359            "reading empty memory must not initialize it"
360        );
361    }
362
363    #[test]
364    fn stable_cell_ledger_memory_reader_classifies_bad_record_without_panic() {
365        let memory = VectorMemory::default();
366        memory.grow(1);
367        memory.write(0, STABLE_CELL_MAGIC);
368        memory.write(3, &[STABLE_CELL_LAYOUT_VERSION]);
369        memory.write(4, &1_u32.to_le_bytes());
370        memory.write(STABLE_CELL_VALUE_OFFSET, &[0xff]);
371
372        let err = decode_stable_cell_commit_store_from_memory(&memory)
373            .expect_err("bad record must be classified");
374
375        assert!(matches!(err, StableCellLedgerError::Record(_)));
376    }
377    #[test]
378    fn oversized_cell_length_is_rejected_before_payload_read() {
379        struct HeaderOnly {
380            value_len: u32,
381            pages: u64,
382        }
383        impl Memory for HeaderOnly {
384            fn size(&self) -> u64 {
385                self.pages
386            }
387            fn grow(&self, _: u64) -> i64 {
388                panic!("must not grow")
389            }
390            fn write(&self, _: u64, _: &[u8]) {
391                panic!("must not write")
392            }
393            fn read(&self, offset: u64, bytes: &mut [u8]) {
394                assert_eq!(offset, 0);
395                assert_eq!(bytes.len(), STABLE_CELL_HEADER_SIZE);
396                bytes[..4].copy_from_slice(b"SCL\x01");
397                bytes[4..8].copy_from_slice(&self.value_len.to_le_bytes());
398            }
399        }
400        for value_len in [
401            u32::try_from(crate::constants::MAX_LEDGER_RECORD_BYTES + 1).unwrap(),
402            u32::MAX,
403        ] {
404            // The advertised value fits the backing, but exceeds the recovery ceiling.
405            let memory = HeaderOnly {
406                value_len,
407                pages: 65_537,
408            };
409            assert!(matches!(
410                decode_stable_cell_commit_store_from_memory(&memory),
411                Err(StableCellLedgerError::Payload(StableCellPayloadError::TooLarge {
412                    value_len: rejected,
413                })) if rejected == u64::from(value_len)
414            ));
415            // Physical-capacity rejection keeps precedence when both checks fail.
416            let memory = HeaderOnly {
417                value_len,
418                pages: 1,
419            };
420            assert_eq!(
421                decode_stable_cell_payload(&memory),
422                Err(StableCellPayloadError::InvalidLength {
423                    value_len: u64::from(value_len),
424                    available_bytes: WASM_PAGE_SIZE_BYTES - STABLE_CELL_VALUE_OFFSET,
425                })
426            );
427        }
428        // The same header-only boundary admits a fixture's selected ceiling.
429        for value_len in [16_385, u32::MAX] {
430            let memory = HeaderOnly {
431                value_len,
432                pages: 65_537,
433            };
434            assert_eq!(
435                stable_cell_payload_length(&memory, 16_384),
436                Err(StableCellPayloadError::TooLarge {
437                    value_len: u64::from(value_len)
438                }),
439            );
440        }
441    }
442
443    #[test]
444    fn hostile_wire_lengths_reject_on_production_record_decode() {
445        use crate::encoding::{RECORD_LIMITS, RECORD_MAGIC};
446        // Schema-correct snapshot prefix, then an unbacked payload length.
447        let prefix = ic_wire::native::encode(&Some((1_u64, 0_u64, 0_u64)), RECORD_LIMITS).unwrap();
448        for length in [
449            crate::constants::MAX_COMMITTED_PAYLOAD_BYTES + 1,
450            usize::MAX,
451        ] {
452            let mut bytes = RECORD_MAGIC.to_vec();
453            bytes.extend_from_slice(&prefix);
454            bytes.extend(ic_wire::native::encode(&length, RECORD_LIMITS).unwrap());
455            assert!(decode_stable_cell_commit_store(&bytes).is_err());
456        }
457        // A fully backed oversized opaque payload also fails its static bound.
458        let payload = vec![0; crate::constants::MAX_COMMITTED_PAYLOAD_BYTES + 1];
459        let oversized =
460            ic_wire::BytesRef::<{ crate::constants::MAX_COMMITTED_PAYLOAD_BYTES + 1 }>::new(
461                &payload,
462            )
463            .unwrap();
464        let mut bytes = RECORD_MAGIC.to_vec();
465        bytes.extend(
466            ic_wire::native::encode(&Some((1_u64, 0_u64, 0_u64, oversized)), RECORD_LIMITS)
467                .unwrap(),
468        );
469        assert!(decode_stable_cell_commit_store(&bytes).is_err());
470        assert_eq!(
471            decode_stable_cell_commit_store(&vec![
472                0;
473                crate::constants::MAX_LEDGER_RECORD_BYTES + 1
474            ]),
475            Err(ic_wire::Error::Limit)
476        );
477    }
478}