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