Skip to main content

ic_memory/
stable_cell.rs

1use crate::{LedgerCommitStore, constants::WASM_PAGE_SIZE_BYTES};
2use ic_stable_structures::{Memory, Storable, storable::Bound};
3use serde::{Deserialize, Serialize};
4use std::borrow::Cow;
5use thiserror::Error;
6
7/// Stable-cell magic prefix written by `ic-stable-structures::Cell`.
8pub const STABLE_CELL_MAGIC: &[u8; 3] = b"SCL";
9/// Stable-cell layout version supported by this adapter.
10pub const STABLE_CELL_LAYOUT_VERSION: u8 = 1;
11/// Stable-cell header byte length.
12pub const STABLE_CELL_HEADER_SIZE: usize = 8;
13/// Byte offset where the stable-cell value payload starts.
14pub const STABLE_CELL_VALUE_OFFSET: u64 = 8;
15
16///
17/// StableCellLedgerRecord
18///
19/// `ic-stable-structures::Cell` record containing an `ic-memory` allocation
20/// ledger commit store.
21///
22/// This is a substrate adapter DTO. It owns no framework policy and does not
23/// open application allocations.
24///
25
26#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
27#[serde(deny_unknown_fields)]
28pub struct StableCellLedgerRecord {
29    store: LedgerCommitStore,
30}
31
32impl StableCellLedgerRecord {
33    /// Construct a record from a commit store.
34    #[must_use]
35    #[cfg(test)]
36    pub(crate) const fn new(store: LedgerCommitStore) -> Self {
37        Self { store }
38    }
39
40    /// Borrow the embedded commit store.
41    #[must_use]
42    pub(crate) const fn store(&self) -> &LedgerCommitStore {
43        &self.store
44    }
45
46    /// Mutably borrow the embedded commit store.
47    pub(crate) const fn store_mut(&mut self) -> &mut LedgerCommitStore {
48        &mut self.store
49    }
50}
51
52impl Storable for StableCellLedgerRecord {
53    const BOUND: Bound = Bound::Unbounded;
54
55    fn to_bytes(&self) -> Cow<'_, [u8]> {
56        Cow::Owned(serialize_record(self))
57    }
58
59    fn into_bytes(self) -> Vec<u8> {
60        serialize_record(&self)
61    }
62
63    fn from_bytes(bytes: Cow<'_, [u8]>) -> Self {
64        decode_stable_cell_ledger_record(&bytes).unwrap_or_else(|err| {
65            panic!("StableCellLedgerRecord deserialize failed: {err}");
66        })
67    }
68}
69
70///
71/// StableCellPayloadError
72///
73/// Stable-cell payload decode failure.
74///
75
76#[non_exhaustive]
77#[derive(Clone, Debug, Eq, Error, PartialEq)]
78pub enum StableCellPayloadError {
79    /// Declared bytes exceed the current recovery ceiling before allocation.
80    #[error("stable-cell ledger payload length {value_len} exceeds recovery limit")]
81    TooLarge { value_len: u64 },
82    /// Memory contents do not start with the stable-cell marker.
83    #[error("memory is not an ic-stable-structures Cell")]
84    NotStableCell,
85    /// Stable-cell layout version does not match the adapter's current shape.
86    #[error("unexpected stable-cell layout version {version}")]
87    UnexpectedLayoutVersion {
88        /// Observed stable-cell version.
89        version: u8,
90    },
91    /// Stable-cell header length does not fit inside the memory.
92    #[error("stable-cell payload length {value_len} exceeds available bytes {available_bytes}")]
93    InvalidLength {
94        /// Encoded value length.
95        value_len: u64,
96        /// Available payload bytes in memory.
97        available_bytes: u64,
98    },
99}
100
101///
102/// StableCellLedgerError
103///
104/// Stable-cell ledger record validation failure.
105#[non_exhaustive]
106#[derive(Debug, Error)]
107pub enum StableCellLedgerError {
108    /// Stable-cell envelope is corrupt or unexpected.
109    #[error(transparent)]
110    Payload(#[from] StableCellPayloadError),
111    /// Stable-cell value bytes are not a valid ledger record.
112    #[error("stable-cell ledger record decode failed: {0}")]
113    Record(#[source] ciborium::de::Error<std::io::Error>),
114}
115
116/// Decode the raw value payload from an `ic-stable-structures::Cell` memory.
117///
118/// This helper is intentionally narrow: it recognizes the physical stable-cell
119/// envelope and returns the value bytes. It does not deserialize those bytes or
120/// decide whether they represent a valid allocation ledger.
121pub fn decode_stable_cell_payload<M: Memory>(
122    memory: &M,
123) -> Result<Vec<u8>, StableCellPayloadError> {
124    if memory.size() == 0 {
125        return Err(StableCellPayloadError::NotStableCell);
126    }
127
128    let mut header = [0; STABLE_CELL_HEADER_SIZE];
129    memory.read(0, &mut header);
130    if &header[0..3] != STABLE_CELL_MAGIC {
131        return Err(StableCellPayloadError::NotStableCell);
132    }
133    if header[3] != STABLE_CELL_LAYOUT_VERSION {
134        return Err(StableCellPayloadError::UnexpectedLayoutVersion { version: header[3] });
135    }
136
137    let value_len = u64::from(u32::from_le_bytes([
138        header[4], header[5], header[6], header[7],
139    ]));
140    let available_bytes = memory.size().saturating_mul(WASM_PAGE_SIZE_BYTES);
141    let payload_capacity = available_bytes.saturating_sub(STABLE_CELL_VALUE_OFFSET);
142    if value_len > payload_capacity {
143        return Err(StableCellPayloadError::InvalidLength {
144            value_len,
145            available_bytes: payload_capacity,
146        });
147    }
148    if value_len > crate::constants::MAX_LEDGER_RECORD_BYTES as u64 {
149        return Err(StableCellPayloadError::TooLarge { value_len });
150    }
151    #[expect(
152        clippy::cast_possible_truncation,
153        reason = "the admitted recovery ceiling is representable as usize"
154    )]
155    let value_len = value_len as usize;
156
157    let mut bytes = vec![0; value_len];
158    memory.read(STABLE_CELL_VALUE_OFFSET, &mut bytes);
159    Ok(bytes)
160}
161
162/// Decode a `StableCellLedgerRecord` from stable-cell value bytes.
163///
164/// This decodes only the cell value payload, not the enclosing stable-cell
165/// header. Use [`decode_stable_cell_payload`] first when inspecting raw stable
166/// memory.
167///
168/// The returned record is decoded DTO state, not authority. The runtime
169/// validates protected commit recovery before trusting any ledger payload.
170pub fn decode_stable_cell_ledger_record(
171    bytes: &[u8],
172) -> Result<StableCellLedgerRecord, ciborium::de::Error<std::io::Error>> {
173    crate::cbor::from_slice_exact(bytes)
174}
175
176/// Fallibly decode a ledger record from its stable-cell envelope and value.
177///
178/// Empty memory returns an uninitialized record without writing a cell. Nonempty
179/// memory must pass the current envelope, byte bounds and record decoding. The
180/// returned DTO still requires protected ledger recovery before it is authority.
181/// Keep this record for recovery rather than decoding again through the
182/// panic-based [`Storable::from_bytes`] used by `Cell::init`.
183pub fn decode_stable_cell_ledger_record_from_memory<M: Memory>(
184    memory: &M,
185) -> Result<StableCellLedgerRecord, StableCellLedgerError> {
186    if memory.size() == 0 {
187        return Ok(StableCellLedgerRecord::default());
188    }
189
190    let payload = decode_stable_cell_payload(memory)?;
191    decode_stable_cell_ledger_record(&payload).map_err(StableCellLedgerError::Record)
192}
193
194fn serialize_record(record: &StableCellLedgerRecord) -> Vec<u8> {
195    EncodedLedgerRecord::new(record)
196        .unwrap_or_else(|size| panic!("stable-cell ledger record exceeds limit: {size}"))
197        .bytes
198}
199
200///
201/// EncodedLedgerRecord
202///
203/// Temporary publication bytes, never recovered authority or retained runtime state.
204///
205
206pub struct EncodedLedgerRecord {
207    bytes: Vec<u8>,
208    value_size: usize,
209}
210
211impl EncodedLedgerRecord {
212    pub(crate) fn new(record: &StableCellLedgerRecord) -> Result<Self, usize> {
213        // Payload lengths plus small DTO-sized slack are only a capacity hint,
214        // not a second CBOR layout calculation. The writer owns the exact bound.
215        let physical = record.store().physical();
216        let capacity = physical
217            .slot0()
218            .map_or(0, |slot| slot.payload().len())
219            .saturating_add(physical.slot1().map_or(0, |slot| slot.payload().len()))
220            .saturating_add(std::mem::size_of::<StableCellLedgerRecord>().saturating_mul(2))
221            .min(crate::constants::MAX_LEDGER_RECORD_BYTES);
222        let mut encoded = Self {
223            bytes: Vec::with_capacity(capacity),
224            value_size: 0,
225        };
226        if let Err(error) = ciborium::into_writer(record, &mut encoded) {
227            if encoded.value_size > crate::constants::MAX_LEDGER_RECORD_BYTES {
228                return Err(encoded.value_size);
229            }
230            panic!("StableCellLedgerRecord serialize failed: {error}");
231        }
232        Ok(encoded)
233    }
234
235    pub(crate) const fn len(&self) -> usize {
236        self.bytes.len()
237    }
238}
239
240impl Storable for EncodedLedgerRecord {
241    const BOUND: Bound = Bound::Unbounded;
242
243    fn to_bytes(&self) -> Cow<'_, [u8]> {
244        Cow::Borrowed(&self.bytes)
245    }
246    fn into_bytes(self) -> Vec<u8> {
247        self.bytes
248    }
249    fn from_bytes(_: Cow<'_, [u8]>) -> Self {
250        panic!("publication bytes cannot recover ledger authority")
251    }
252}
253
254impl std::io::Write for EncodedLedgerRecord {
255    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
256        self.value_size = self
257            .value_size
258            .checked_add(bytes.len())
259            .ok_or_else(|| std::io::Error::other("encoded record length overflow"))?;
260        if self.value_size > crate::constants::MAX_LEDGER_RECORD_BYTES {
261            return Err(std::io::Error::other(
262                "encoded record exceeds recovery ceiling",
263            ));
264        }
265        self.bytes.extend_from_slice(bytes);
266        Ok(bytes.len())
267    }
268
269    fn flush(&mut self) -> std::io::Result<()> {
270        Ok(())
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::test_cbor::hex_fixture;
278    use ic_stable_structures::{Cell, VectorMemory};
279
280    #[test]
281    fn publication_payload_refuses_oversized_chunks_before_buffering() {
282        use std::io::Write as _;
283
284        let mut encoded = EncodedLedgerRecord {
285            bytes: Vec::new(),
286            value_size: 0,
287        };
288        let oversized = vec![0; crate::constants::MAX_LEDGER_RECORD_BYTES + 1];
289        assert!(encoded.write_all(&oversized).is_err());
290        assert_eq!(encoded.len(), 0);
291        assert_eq!(encoded.bytes.capacity(), 0);
292    }
293
294    #[test]
295    fn stable_cell_ledger_record_round_trips_through_cell() {
296        let memory = VectorMemory::default();
297        let record = StableCellLedgerRecord::default();
298        let cell = Cell::init(memory.clone(), record.clone());
299
300        assert_eq!(cell.get(), &record);
301        let payload = decode_stable_cell_payload(&memory).expect("decode stable cell payload");
302        let decoded = StableCellLedgerRecord::from_bytes(Cow::Owned(payload));
303        assert_eq!(decoded, record);
304        assert_eq!(
305            crate::decode_stable_cell_ledger_record_from_memory(&memory).unwrap(),
306            record
307        );
308    }
309
310    #[test]
311    fn current_stable_cell_record_fixture_recovers() {
312        let bytes = hex_fixture(include_str!(
313            "../fixtures/current/stable_cell_record.cbor.hex"
314        ));
315        let record = decode_stable_cell_ledger_record(&bytes).expect("stable-cell fixture");
316
317        assert_eq!(
318            EncodedLedgerRecord::new(&record).unwrap().len(),
319            bytes.len()
320        );
321        let encoded = EncodedLedgerRecord::new(&record).unwrap();
322        assert!(matches!(encoded.to_bytes(), Cow::Borrowed(_)));
323        let memory = VectorMemory::default();
324        drop(Cell::new(memory.clone(), encoded));
325        assert_eq!(decode_stable_cell_payload(&memory).unwrap(), bytes);
326        assert_eq!(record.to_bytes().as_ref(), bytes.as_slice());
327        assert_eq!(record.clone().into_bytes(), bytes);
328        assert_eq!(
329            bytes,
330            crate::test_cbor::to_vec(&record).expect("re-encoded stable-cell fixture")
331        );
332        assert_eq!(
333            record
334                .store()
335                .recover()
336                .expect("fixture store recovers")
337                .current_generation(),
338            1
339        );
340    }
341
342    #[test]
343    fn malformed_payload_bytes_fail_closed_without_mutating_memory() {
344        let original = hex_fixture(include_str!(
345            "../fixtures/current/stable_cell_record.cbor.hex"
346        ));
347        let start = original
348            .windows(8)
349            .position(|bytes| bytes == b"\x67payload")
350            .unwrap()
351            + 8;
352        let oversized = u32::try_from(crate::constants::MAX_COMMITTED_PAYLOAD_BYTES + 1).unwrap();
353        let mut cases = Vec::new();
354        for replacement in [
355            vec![0x5a, 0xff, 0xff, 0xff, 0xff],
356            vec![0x5f, 0xff],
357            vec![0x41],
358            vec![0xff],
359        ] {
360            let mut bytes = original[..start].to_vec();
361            bytes.extend(replacement);
362            cases.push(bytes);
363        }
364        let mut bytes = original[..start].to_vec();
365        bytes.push(0x5a);
366        bytes.extend_from_slice(&oversized.to_be_bytes());
367        bytes.resize(bytes.len() + oversized as usize, 0);
368        cases.push(bytes);
369        for bytes in cases {
370            let memory = VectorMemory::default();
371            let pages = (bytes.len() + STABLE_CELL_HEADER_SIZE).div_ceil(65_536);
372            memory.grow(pages as u64);
373            memory.write(0, STABLE_CELL_MAGIC);
374            memory.write(3, &[STABLE_CELL_LAYOUT_VERSION]);
375            memory.write(4, &u32::try_from(bytes.len()).unwrap().to_le_bytes());
376            memory.write(STABLE_CELL_VALUE_OFFSET, &bytes);
377            let before = memory.borrow().clone();
378            for _ in 0..2 {
379                assert!(matches!(
380                    decode_stable_cell_ledger_record_from_memory(&memory),
381                    Err(StableCellLedgerError::Record(_))
382                ));
383            }
384            assert_eq!(*memory.borrow(), before);
385        }
386    }
387
388    #[test]
389    fn stable_cell_ledger_record_rejects_trailing_bytes() {
390        let mut bytes = serialize_record(&StableCellLedgerRecord::default());
391        bytes.push(0);
392
393        let err =
394            decode_stable_cell_ledger_record(&bytes).expect_err("trailing bytes must fail closed");
395
396        assert!(err.to_string().contains("trailing bytes"));
397    }
398
399    #[test]
400    fn stable_cell_ledger_record_rejects_unknown_top_level_fields() {
401        use crate::test_cbor::Value;
402
403        let map = vec![
404            (
405                Value::Text("store".to_string()),
406                crate::test_cbor::to_value(LedgerCommitStore::default()).expect("store value"),
407            ),
408            (Value::Text("future_field".to_string()), Value::Bool(true)),
409        ];
410        let bytes = crate::test_cbor::to_vec(&Value::Map(map)).expect("unknown-field stable cell");
411
412        let err = decode_stable_cell_ledger_record(&bytes)
413            .expect_err("unknown stable-cell record field must fail closed");
414
415        assert!(err.to_string().contains("future_field"));
416    }
417
418    #[test]
419    fn stable_cell_ledger_record_requires_both_commit_slot_fields() {
420        use crate::test_cbor::Value;
421
422        for (missing, present) in [("slot0", "slot1"), ("slot1", "slot0")] {
423            let physical = vec![(Value::Text(present.to_string()), Value::Null)];
424            let store = vec![(Value::Text("physical".to_string()), Value::Map(physical))];
425            let record = vec![(Value::Text("store".to_string()), Value::Map(store))];
426            let bytes = crate::test_cbor::to_vec(&Value::Map(record)).expect("record bytes");
427
428            let err = decode_stable_cell_ledger_record(&bytes)
429                .expect_err("missing commit slot must fail closed");
430
431            assert!(err.to_string().contains(missing));
432        }
433    }
434
435    #[test]
436    fn stable_cell_payload_rejects_non_cell_memory() {
437        let memory = VectorMemory::default();
438        memory.grow(1);
439        memory.write(0, b"BAD");
440
441        assert_eq!(
442            decode_stable_cell_payload(&memory),
443            Err(StableCellPayloadError::NotStableCell)
444        );
445    }
446
447    #[test]
448    fn stable_cell_payload_rejects_empty_memory_without_panic() {
449        let memory = VectorMemory::default();
450
451        assert_eq!(
452            decode_stable_cell_payload(&memory),
453            Err(StableCellPayloadError::NotStableCell)
454        );
455        assert_eq!(
456            crate::decode_stable_cell_ledger_record_from_memory(&memory).unwrap(),
457            StableCellLedgerRecord::default()
458        );
459        assert_eq!(
460            memory.size(),
461            0,
462            "reading empty memory must not initialize it"
463        );
464    }
465
466    #[test]
467    fn stable_cell_ledger_memory_reader_classifies_bad_record_without_panic() {
468        let memory = VectorMemory::default();
469        memory.grow(1);
470        memory.write(0, STABLE_CELL_MAGIC);
471        memory.write(3, &[STABLE_CELL_LAYOUT_VERSION]);
472        memory.write(4, &1_u32.to_le_bytes());
473        memory.write(STABLE_CELL_VALUE_OFFSET, &[0xff]);
474
475        let err = decode_stable_cell_ledger_record_from_memory(&memory)
476            .expect_err("bad record must be classified");
477
478        assert!(matches!(err, StableCellLedgerError::Record(_)));
479    }
480    #[test]
481    fn oversized_cell_length_is_rejected_before_payload_read() {
482        struct HeaderOnly {
483            value_len: u32,
484            pages: u64,
485        }
486        impl Memory for HeaderOnly {
487            fn size(&self) -> u64 {
488                self.pages
489            }
490            fn grow(&self, _: u64) -> i64 {
491                panic!("must not grow")
492            }
493            fn write(&self, _: u64, _: &[u8]) {
494                panic!("must not write")
495            }
496            fn read(&self, offset: u64, bytes: &mut [u8]) {
497                assert_eq!(offset, 0);
498                assert_eq!(bytes.len(), STABLE_CELL_HEADER_SIZE);
499                bytes[..4].copy_from_slice(b"SCL\x01");
500                bytes[4..8].copy_from_slice(&self.value_len.to_le_bytes());
501            }
502        }
503        for value_len in [
504            u32::try_from(crate::constants::MAX_LEDGER_RECORD_BYTES + 1).unwrap(),
505            u32::MAX,
506        ] {
507            // The advertised value fits the backing, but exceeds the recovery ceiling.
508            let memory = HeaderOnly {
509                value_len,
510                pages: 65_537,
511            };
512            assert!(matches!(
513                decode_stable_cell_ledger_record_from_memory(&memory),
514                Err(StableCellLedgerError::Payload(StableCellPayloadError::TooLarge {
515                    value_len: rejected,
516                })) if rejected == u64::from(value_len)
517            ));
518            // Physical-capacity rejection keeps precedence when both checks fail.
519            let memory = HeaderOnly {
520                value_len,
521                pages: 1,
522            };
523            assert_eq!(
524                decode_stable_cell_payload(&memory),
525                Err(StableCellPayloadError::InvalidLength {
526                    value_len: u64::from(value_len),
527                    available_bytes: WASM_PAGE_SIZE_BYTES - STABLE_CELL_VALUE_OFFSET,
528                })
529            );
530        }
531    }
532
533    #[test]
534    fn hostile_cbor_is_rejected_on_production_record_decode() {
535        let huge_array = [0x9b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff];
536        let huge_string = [0x7b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff];
537        let mut nested = vec![0x81; crate::constants::MAX_LEDGER_NESTING + 1];
538        nested.push(0);
539        for bytes in [&huge_array[..], &huge_string[..], &nested, &[0xa1], &[0xff]] {
540            assert!(decode_stable_cell_ledger_record(bytes).is_err());
541        }
542    }
543}