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
7pub const STABLE_CELL_MAGIC: &[u8; 3] = b"SCL";
9pub const STABLE_CELL_LAYOUT_VERSION: u8 = 1;
11pub const STABLE_CELL_HEADER_SIZE: usize = 8;
13pub const STABLE_CELL_VALUE_OFFSET: u64 = 8;
15
16#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
27#[serde(deny_unknown_fields)]
28pub struct StableCellLedgerRecord {
29 store: LedgerCommitStore,
30}
31
32impl StableCellLedgerRecord {
33 #[must_use]
35 #[cfg(test)]
36 pub(crate) const fn new(store: LedgerCommitStore) -> Self {
37 Self { store }
38 }
39
40 #[must_use]
42 pub(crate) const fn store(&self) -> &LedgerCommitStore {
43 &self.store
44 }
45
46 pub(crate) const fn store_mut(&mut self) -> &mut LedgerCommitStore {
48 &mut self.store
49 }
50
51 pub(crate) fn encoded_size(&self) -> usize {
53 let mut writer = CountingWriter(0);
54 encode_record(self, &mut writer);
55 writer.0
56 }
57}
58
59impl Storable for StableCellLedgerRecord {
60 const BOUND: Bound = Bound::Unbounded;
61
62 fn to_bytes(&self) -> Cow<'_, [u8]> {
63 Cow::Owned(serialize_record(self))
64 }
65
66 fn into_bytes(self) -> Vec<u8> {
67 serialize_record(&self)
68 }
69
70 fn from_bytes(bytes: Cow<'_, [u8]>) -> Self {
71 decode_stable_cell_ledger_record(&bytes).unwrap_or_else(|err| {
72 panic!("StableCellLedgerRecord deserialize failed: {err}");
73 })
74 }
75}
76
77#[non_exhaustive]
84#[derive(Clone, Debug, Eq, Error, PartialEq)]
85pub enum StableCellPayloadError {
86 #[error("stable-cell ledger payload length {value_len} exceeds recovery limit")]
88 TooLarge { value_len: u64 },
89 #[error("memory is not an ic-stable-structures Cell")]
91 NotStableCell,
92 #[error("unexpected stable-cell layout version {version}")]
94 UnexpectedLayoutVersion {
95 version: u8,
97 },
98 #[error("stable-cell payload length {value_len} exceeds available bytes {available_bytes}")]
100 InvalidLength {
101 value_len: u64,
103 available_bytes: u64,
105 },
106}
107
108#[non_exhaustive]
113#[derive(Debug, Error)]
114pub enum StableCellLedgerError {
115 #[error(transparent)]
117 Payload(#[from] StableCellPayloadError),
118 #[error("stable-cell ledger record decode failed: {0}")]
120 Record(#[source] ciborium::de::Error<std::io::Error>),
121}
122
123pub fn decode_stable_cell_payload<M: Memory>(
129 memory: &M,
130) -> Result<Vec<u8>, StableCellPayloadError> {
131 if memory.size() == 0 {
132 return Err(StableCellPayloadError::NotStableCell);
133 }
134
135 let mut header = [0; STABLE_CELL_HEADER_SIZE];
136 memory.read(0, &mut header);
137 if &header[0..3] != STABLE_CELL_MAGIC {
138 return Err(StableCellPayloadError::NotStableCell);
139 }
140 if header[3] != STABLE_CELL_LAYOUT_VERSION {
141 return Err(StableCellPayloadError::UnexpectedLayoutVersion { version: header[3] });
142 }
143
144 let value_len = u64::from(u32::from_le_bytes([
145 header[4], header[5], header[6], header[7],
146 ]));
147 let available_bytes = memory.size().saturating_mul(WASM_PAGE_SIZE_BYTES);
148 let payload_capacity = available_bytes.saturating_sub(STABLE_CELL_VALUE_OFFSET);
149 if value_len > payload_capacity {
150 return Err(StableCellPayloadError::InvalidLength {
151 value_len,
152 available_bytes: payload_capacity,
153 });
154 }
155 if value_len > crate::constants::MAX_LEDGER_RECORD_BYTES as u64 {
156 return Err(StableCellPayloadError::TooLarge { value_len });
157 }
158 #[expect(
159 clippy::cast_possible_truncation,
160 reason = "the admitted recovery ceiling is representable as usize"
161 )]
162 let value_len = value_len as usize;
163
164 let mut bytes = vec![0; value_len];
165 memory.read(STABLE_CELL_VALUE_OFFSET, &mut bytes);
166 Ok(bytes)
167}
168
169pub fn decode_stable_cell_ledger_record(
178 bytes: &[u8],
179) -> Result<StableCellLedgerRecord, ciborium::de::Error<std::io::Error>> {
180 crate::cbor::from_slice_exact(bytes)
181}
182
183pub fn decode_stable_cell_ledger_record_from_memory<M: Memory>(
191 memory: &M,
192) -> Result<StableCellLedgerRecord, StableCellLedgerError> {
193 if memory.size() == 0 {
194 return Ok(StableCellLedgerRecord::default());
195 }
196
197 let payload = decode_stable_cell_payload(memory)?;
198 decode_stable_cell_ledger_record(&payload).map_err(StableCellLedgerError::Record)
199}
200
201fn serialize_record(record: &StableCellLedgerRecord) -> Vec<u8> {
202 let mut bytes = Vec::with_capacity(record.encoded_size());
203 encode_record(record, &mut bytes);
204 bytes
205}
206
207fn encode_record(record: &StableCellLedgerRecord, writer: impl std::io::Write) {
208 ciborium::into_writer(record, writer).unwrap_or_else(|err| {
209 panic!("StableCellLedgerRecord serialize failed: {err}");
210 });
211}
212
213struct CountingWriter(usize);
214
215impl std::io::Write for CountingWriter {
216 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
217 self.0 = self
218 .0
219 .checked_add(bytes.len())
220 .ok_or_else(|| std::io::Error::other("encoded record length overflow"))?;
221 Ok(bytes.len())
222 }
223
224 fn flush(&mut self) -> std::io::Result<()> {
225 Ok(())
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use crate::test_cbor::hex_fixture;
233 use ic_stable_structures::{Cell, VectorMemory};
234
235 #[test]
236 fn stable_cell_ledger_record_round_trips_through_cell() {
237 let memory = VectorMemory::default();
238 let record = StableCellLedgerRecord::default();
239 let cell = Cell::init(memory.clone(), record.clone());
240
241 assert_eq!(cell.get(), &record);
242 let payload = decode_stable_cell_payload(&memory).expect("decode stable cell payload");
243 let decoded = StableCellLedgerRecord::from_bytes(Cow::Owned(payload));
244 assert_eq!(decoded, record);
245 assert_eq!(
246 crate::decode_stable_cell_ledger_record_from_memory(&memory).unwrap(),
247 record
248 );
249 }
250
251 #[test]
252 fn current_stable_cell_record_fixture_recovers() {
253 let bytes = hex_fixture(include_str!(
254 "../fixtures/current/stable_cell_record.cbor.hex"
255 ));
256 let record = decode_stable_cell_ledger_record(&bytes).expect("stable-cell fixture");
257
258 assert_eq!(record.encoded_size(), bytes.len());
259 assert_eq!(record.to_bytes().as_ref(), bytes.as_slice());
260 assert_eq!(record.clone().into_bytes(), bytes);
261 assert_eq!(
262 bytes,
263 crate::test_cbor::to_vec(&record).expect("re-encoded stable-cell fixture")
264 );
265 assert_eq!(
266 record
267 .store()
268 .recover()
269 .expect("fixture store recovers")
270 .current_generation(),
271 1
272 );
273 }
274
275 #[test]
276 fn malformed_payload_bytes_fail_closed_without_mutating_memory() {
277 let original = hex_fixture(include_str!(
278 "../fixtures/current/stable_cell_record.cbor.hex"
279 ));
280 let start = original
281 .windows(8)
282 .position(|bytes| bytes == b"\x67payload")
283 .unwrap()
284 + 8;
285 let oversized = u32::try_from(crate::constants::MAX_COMMITTED_PAYLOAD_BYTES + 1).unwrap();
286 let mut cases = Vec::new();
287 for replacement in [
288 vec![0x5a, 0xff, 0xff, 0xff, 0xff],
289 vec![0x5f, 0xff],
290 vec![0x41],
291 vec![0xff],
292 ] {
293 let mut bytes = original[..start].to_vec();
294 bytes.extend(replacement);
295 cases.push(bytes);
296 }
297 let mut bytes = original[..start].to_vec();
298 bytes.push(0x5a);
299 bytes.extend_from_slice(&oversized.to_be_bytes());
300 bytes.resize(bytes.len() + oversized as usize, 0);
301 cases.push(bytes);
302 for bytes in cases {
303 let memory = VectorMemory::default();
304 let pages = (bytes.len() + STABLE_CELL_HEADER_SIZE).div_ceil(65_536);
305 memory.grow(pages as u64);
306 memory.write(0, STABLE_CELL_MAGIC);
307 memory.write(3, &[STABLE_CELL_LAYOUT_VERSION]);
308 memory.write(4, &u32::try_from(bytes.len()).unwrap().to_le_bytes());
309 memory.write(STABLE_CELL_VALUE_OFFSET, &bytes);
310 let before = memory.borrow().clone();
311 for _ in 0..2 {
312 assert!(matches!(
313 decode_stable_cell_ledger_record_from_memory(&memory),
314 Err(StableCellLedgerError::Record(_))
315 ));
316 }
317 assert_eq!(*memory.borrow(), before);
318 }
319 }
320
321 #[test]
322 fn stable_cell_ledger_record_rejects_trailing_bytes() {
323 let mut bytes = serialize_record(&StableCellLedgerRecord::default());
324 bytes.push(0);
325
326 let err =
327 decode_stable_cell_ledger_record(&bytes).expect_err("trailing bytes must fail closed");
328
329 assert!(err.to_string().contains("trailing bytes"));
330 }
331
332 #[test]
333 fn stable_cell_ledger_record_rejects_unknown_top_level_fields() {
334 use crate::test_cbor::Value;
335
336 let map = vec![
337 (
338 Value::Text("store".to_string()),
339 crate::test_cbor::to_value(LedgerCommitStore::default()).expect("store value"),
340 ),
341 (Value::Text("future_field".to_string()), Value::Bool(true)),
342 ];
343 let bytes = crate::test_cbor::to_vec(&Value::Map(map)).expect("unknown-field stable cell");
344
345 let err = decode_stable_cell_ledger_record(&bytes)
346 .expect_err("unknown stable-cell record field must fail closed");
347
348 assert!(err.to_string().contains("future_field"));
349 }
350
351 #[test]
352 fn stable_cell_ledger_record_requires_both_commit_slot_fields() {
353 use crate::test_cbor::Value;
354
355 for (missing, present) in [("slot0", "slot1"), ("slot1", "slot0")] {
356 let physical = vec![(Value::Text(present.to_string()), Value::Null)];
357 let store = vec![(Value::Text("physical".to_string()), Value::Map(physical))];
358 let record = vec![(Value::Text("store".to_string()), Value::Map(store))];
359 let bytes = crate::test_cbor::to_vec(&Value::Map(record)).expect("record bytes");
360
361 let err = decode_stable_cell_ledger_record(&bytes)
362 .expect_err("missing commit slot must fail closed");
363
364 assert!(err.to_string().contains(missing));
365 }
366 }
367
368 #[test]
369 fn stable_cell_payload_rejects_non_cell_memory() {
370 let memory = VectorMemory::default();
371 memory.grow(1);
372 memory.write(0, b"BAD");
373
374 assert_eq!(
375 decode_stable_cell_payload(&memory),
376 Err(StableCellPayloadError::NotStableCell)
377 );
378 }
379
380 #[test]
381 fn stable_cell_payload_rejects_empty_memory_without_panic() {
382 let memory = VectorMemory::default();
383
384 assert_eq!(
385 decode_stable_cell_payload(&memory),
386 Err(StableCellPayloadError::NotStableCell)
387 );
388 assert_eq!(
389 crate::decode_stable_cell_ledger_record_from_memory(&memory).unwrap(),
390 StableCellLedgerRecord::default()
391 );
392 assert_eq!(
393 memory.size(),
394 0,
395 "reading empty memory must not initialize it"
396 );
397 }
398
399 #[test]
400 fn stable_cell_ledger_memory_reader_classifies_bad_record_without_panic() {
401 let memory = VectorMemory::default();
402 memory.grow(1);
403 memory.write(0, STABLE_CELL_MAGIC);
404 memory.write(3, &[STABLE_CELL_LAYOUT_VERSION]);
405 memory.write(4, &1_u32.to_le_bytes());
406 memory.write(STABLE_CELL_VALUE_OFFSET, &[0xff]);
407
408 let err = decode_stable_cell_ledger_record_from_memory(&memory)
409 .expect_err("bad record must be classified");
410
411 assert!(matches!(err, StableCellLedgerError::Record(_)));
412 }
413 #[test]
414 fn oversized_cell_length_is_rejected_before_payload_read() {
415 struct HeaderOnly {
416 value_len: u32,
417 pages: u64,
418 }
419 impl Memory for HeaderOnly {
420 fn size(&self) -> u64 {
421 self.pages
422 }
423 fn grow(&self, _: u64) -> i64 {
424 panic!("must not grow")
425 }
426 fn write(&self, _: u64, _: &[u8]) {
427 panic!("must not write")
428 }
429 fn read(&self, offset: u64, bytes: &mut [u8]) {
430 assert_eq!(offset, 0);
431 assert_eq!(bytes.len(), STABLE_CELL_HEADER_SIZE);
432 bytes[..4].copy_from_slice(b"SCL\x01");
433 bytes[4..8].copy_from_slice(&self.value_len.to_le_bytes());
434 }
435 }
436 for value_len in [
437 u32::try_from(crate::constants::MAX_LEDGER_RECORD_BYTES + 1).unwrap(),
438 u32::MAX,
439 ] {
440 let memory = HeaderOnly {
442 value_len,
443 pages: 65_537,
444 };
445 assert!(matches!(
446 decode_stable_cell_ledger_record_from_memory(&memory),
447 Err(StableCellLedgerError::Payload(StableCellPayloadError::TooLarge {
448 value_len: rejected,
449 })) if rejected == u64::from(value_len)
450 ));
451 let memory = HeaderOnly {
453 value_len,
454 pages: 1,
455 };
456 assert_eq!(
457 decode_stable_cell_payload(&memory),
458 Err(StableCellPayloadError::InvalidLength {
459 value_len: u64::from(value_len),
460 available_bytes: WASM_PAGE_SIZE_BYTES - STABLE_CELL_VALUE_OFFSET,
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_ledger_record(bytes).is_err());
474 }
475 }
476}