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