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