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