Skip to main content

ic_memory/
stable_cell.rs

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