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