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