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
52impl Storable for StableCellLedgerRecord {
53 const BOUND: Bound = Bound::Unbounded;
54
55 fn to_bytes(&self) -> Cow<'_, [u8]> {
56 Cow::Owned(serialize_record(self))
57 }
58
59 fn into_bytes(self) -> Vec<u8> {
60 serialize_record(&self)
61 }
62
63 fn from_bytes(bytes: Cow<'_, [u8]>) -> Self {
64 decode_stable_cell_ledger_record(&bytes).unwrap_or_else(|err| {
65 panic!("StableCellLedgerRecord deserialize failed: {err}");
66 })
67 }
68}
69
70#[non_exhaustive]
77#[derive(Clone, Debug, Eq, Error, PartialEq)]
78pub enum StableCellPayloadError {
79 #[error("stable-cell ledger payload length {value_len} exceeds recovery limit")]
81 TooLarge { value_len: u64 },
82 #[error("memory is not an ic-stable-structures Cell")]
84 NotStableCell,
85 #[error("unexpected stable-cell layout version {version}")]
87 UnexpectedLayoutVersion {
88 version: u8,
90 },
91 #[error("stable-cell payload length {value_len} exceeds available bytes {available_bytes}")]
93 InvalidLength {
94 value_len: u64,
96 available_bytes: u64,
98 },
99}
100
101#[non_exhaustive]
106#[derive(Debug, Error)]
107pub enum StableCellLedgerError {
108 #[error(transparent)]
110 Payload(#[from] StableCellPayloadError),
111 #[error("stable-cell ledger record decode failed: {0}")]
113 Record(#[source] ciborium::de::Error<std::io::Error>),
114}
115
116pub fn decode_stable_cell_payload<M: Memory>(
122 memory: &M,
123) -> Result<Vec<u8>, StableCellPayloadError> {
124 if memory.size() == 0 {
125 return Err(StableCellPayloadError::NotStableCell);
126 }
127
128 let mut header = [0; STABLE_CELL_HEADER_SIZE];
129 memory.read(0, &mut header);
130 if &header[0..3] != STABLE_CELL_MAGIC {
131 return Err(StableCellPayloadError::NotStableCell);
132 }
133 if header[3] != STABLE_CELL_LAYOUT_VERSION {
134 return Err(StableCellPayloadError::UnexpectedLayoutVersion { version: header[3] });
135 }
136
137 let value_len = u64::from(u32::from_le_bytes([
138 header[4], header[5], header[6], header[7],
139 ]));
140 let available_bytes = memory.size().saturating_mul(WASM_PAGE_SIZE_BYTES);
141 let payload_capacity = available_bytes.saturating_sub(STABLE_CELL_VALUE_OFFSET);
142 if value_len > payload_capacity {
143 return Err(StableCellPayloadError::InvalidLength {
144 value_len,
145 available_bytes: payload_capacity,
146 });
147 }
148 if value_len > crate::constants::MAX_LEDGER_RECORD_BYTES as u64 {
149 return Err(StableCellPayloadError::TooLarge { value_len });
150 }
151 #[expect(
152 clippy::cast_possible_truncation,
153 reason = "the admitted recovery ceiling is representable as usize"
154 )]
155 let value_len = value_len as usize;
156
157 let mut bytes = vec![0; value_len];
158 memory.read(STABLE_CELL_VALUE_OFFSET, &mut bytes);
159 Ok(bytes)
160}
161
162pub fn decode_stable_cell_ledger_record(
171 bytes: &[u8],
172) -> Result<StableCellLedgerRecord, ciborium::de::Error<std::io::Error>> {
173 crate::cbor::from_slice_exact(bytes)
174}
175
176pub fn decode_stable_cell_ledger_record_from_memory<M: Memory>(
184 memory: &M,
185) -> Result<StableCellLedgerRecord, StableCellLedgerError> {
186 if memory.size() == 0 {
187 return Ok(StableCellLedgerRecord::default());
188 }
189
190 let payload = decode_stable_cell_payload(memory)?;
191 decode_stable_cell_ledger_record(&payload).map_err(StableCellLedgerError::Record)
192}
193
194fn serialize_record(record: &StableCellLedgerRecord) -> Vec<u8> {
195 EncodedLedgerRecord::new(record)
196 .unwrap_or_else(|size| panic!("stable-cell ledger record exceeds limit: {size}"))
197 .bytes
198}
199
200pub struct EncodedLedgerRecord {
207 bytes: Vec<u8>,
208 value_size: usize,
209}
210
211impl EncodedLedgerRecord {
212 pub(crate) fn new(record: &StableCellLedgerRecord) -> Result<Self, usize> {
213 let physical = record.store().physical();
216 let capacity = physical
217 .slot0()
218 .map_or(0, |slot| slot.payload().len())
219 .saturating_add(physical.slot1().map_or(0, |slot| slot.payload().len()))
220 .saturating_add(std::mem::size_of::<StableCellLedgerRecord>().saturating_mul(2))
221 .min(crate::constants::MAX_LEDGER_RECORD_BYTES);
222 let mut encoded = Self {
223 bytes: Vec::with_capacity(capacity),
224 value_size: 0,
225 };
226 if let Err(error) = ciborium::into_writer(record, &mut encoded) {
227 if encoded.value_size > crate::constants::MAX_LEDGER_RECORD_BYTES {
228 return Err(encoded.value_size);
229 }
230 panic!("StableCellLedgerRecord serialize failed: {error}");
231 }
232 Ok(encoded)
233 }
234
235 pub(crate) const fn len(&self) -> usize {
236 self.bytes.len()
237 }
238}
239
240impl Storable for EncodedLedgerRecord {
241 const BOUND: Bound = Bound::Unbounded;
242
243 fn to_bytes(&self) -> Cow<'_, [u8]> {
244 Cow::Borrowed(&self.bytes)
245 }
246 fn into_bytes(self) -> Vec<u8> {
247 self.bytes
248 }
249 fn from_bytes(_: Cow<'_, [u8]>) -> Self {
250 panic!("publication bytes cannot recover ledger authority")
251 }
252}
253
254impl std::io::Write for EncodedLedgerRecord {
255 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
256 self.value_size = self
257 .value_size
258 .checked_add(bytes.len())
259 .ok_or_else(|| std::io::Error::other("encoded record length overflow"))?;
260 if self.value_size > crate::constants::MAX_LEDGER_RECORD_BYTES {
261 return Err(std::io::Error::other(
262 "encoded record exceeds recovery ceiling",
263 ));
264 }
265 self.bytes.extend_from_slice(bytes);
266 Ok(bytes.len())
267 }
268
269 fn flush(&mut self) -> std::io::Result<()> {
270 Ok(())
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use crate::test_cbor::hex_fixture;
278 use ic_stable_structures::{Cell, VectorMemory};
279
280 #[test]
281 fn publication_payload_refuses_oversized_chunks_before_buffering() {
282 use std::io::Write as _;
283
284 let mut encoded = EncodedLedgerRecord {
285 bytes: Vec::new(),
286 value_size: 0,
287 };
288 let oversized = vec![0; crate::constants::MAX_LEDGER_RECORD_BYTES + 1];
289 assert!(encoded.write_all(&oversized).is_err());
290 assert_eq!(encoded.len(), 0);
291 assert_eq!(encoded.bytes.capacity(), 0);
292 }
293
294 #[test]
295 fn stable_cell_ledger_record_round_trips_through_cell() {
296 let memory = VectorMemory::default();
297 let record = StableCellLedgerRecord::default();
298 let cell = Cell::init(memory.clone(), record.clone());
299
300 assert_eq!(cell.get(), &record);
301 let payload = decode_stable_cell_payload(&memory).expect("decode stable cell payload");
302 let decoded = StableCellLedgerRecord::from_bytes(Cow::Owned(payload));
303 assert_eq!(decoded, record);
304 assert_eq!(
305 crate::decode_stable_cell_ledger_record_from_memory(&memory).unwrap(),
306 record
307 );
308 }
309
310 #[test]
311 fn current_stable_cell_record_fixture_recovers() {
312 let bytes = hex_fixture(include_str!(
313 "../fixtures/current/stable_cell_record.cbor.hex"
314 ));
315 let record = decode_stable_cell_ledger_record(&bytes).expect("stable-cell fixture");
316
317 assert_eq!(
318 EncodedLedgerRecord::new(&record).unwrap().len(),
319 bytes.len()
320 );
321 let encoded = EncodedLedgerRecord::new(&record).unwrap();
322 assert!(matches!(encoded.to_bytes(), Cow::Borrowed(_)));
323 let memory = VectorMemory::default();
324 drop(Cell::new(memory.clone(), encoded));
325 assert_eq!(decode_stable_cell_payload(&memory).unwrap(), bytes);
326 assert_eq!(record.to_bytes().as_ref(), bytes.as_slice());
327 assert_eq!(record.clone().into_bytes(), bytes);
328 assert_eq!(
329 bytes,
330 crate::test_cbor::to_vec(&record).expect("re-encoded stable-cell fixture")
331 );
332 assert_eq!(
333 record
334 .store()
335 .recover()
336 .expect("fixture store recovers")
337 .current_generation(),
338 1
339 );
340 }
341
342 #[test]
343 fn malformed_payload_bytes_fail_closed_without_mutating_memory() {
344 let original = hex_fixture(include_str!(
345 "../fixtures/current/stable_cell_record.cbor.hex"
346 ));
347 let start = original
348 .windows(8)
349 .position(|bytes| bytes == b"\x67payload")
350 .unwrap()
351 + 8;
352 let oversized = u32::try_from(crate::constants::MAX_COMMITTED_PAYLOAD_BYTES + 1).unwrap();
353 let mut cases = Vec::new();
354 for replacement in [
355 vec![0x5a, 0xff, 0xff, 0xff, 0xff],
356 vec![0x5f, 0xff],
357 vec![0x41],
358 vec![0xff],
359 ] {
360 let mut bytes = original[..start].to_vec();
361 bytes.extend(replacement);
362 cases.push(bytes);
363 }
364 let mut bytes = original[..start].to_vec();
365 bytes.push(0x5a);
366 bytes.extend_from_slice(&oversized.to_be_bytes());
367 bytes.resize(bytes.len() + oversized as usize, 0);
368 cases.push(bytes);
369 for bytes in cases {
370 let memory = VectorMemory::default();
371 let pages = (bytes.len() + STABLE_CELL_HEADER_SIZE).div_ceil(65_536);
372 memory.grow(pages as u64);
373 memory.write(0, STABLE_CELL_MAGIC);
374 memory.write(3, &[STABLE_CELL_LAYOUT_VERSION]);
375 memory.write(4, &u32::try_from(bytes.len()).unwrap().to_le_bytes());
376 memory.write(STABLE_CELL_VALUE_OFFSET, &bytes);
377 let before = memory.borrow().clone();
378 for _ in 0..2 {
379 assert!(matches!(
380 decode_stable_cell_ledger_record_from_memory(&memory),
381 Err(StableCellLedgerError::Record(_))
382 ));
383 }
384 assert_eq!(*memory.borrow(), before);
385 }
386 }
387
388 #[test]
389 fn stable_cell_ledger_record_rejects_trailing_bytes() {
390 let mut bytes = serialize_record(&StableCellLedgerRecord::default());
391 bytes.push(0);
392
393 let err =
394 decode_stable_cell_ledger_record(&bytes).expect_err("trailing bytes must fail closed");
395
396 assert!(err.to_string().contains("trailing bytes"));
397 }
398
399 #[test]
400 fn stable_cell_ledger_record_rejects_unknown_top_level_fields() {
401 use crate::test_cbor::Value;
402
403 let map = vec![
404 (
405 Value::Text("store".to_string()),
406 crate::test_cbor::to_value(LedgerCommitStore::default()).expect("store value"),
407 ),
408 (Value::Text("future_field".to_string()), Value::Bool(true)),
409 ];
410 let bytes = crate::test_cbor::to_vec(&Value::Map(map)).expect("unknown-field stable cell");
411
412 let err = decode_stable_cell_ledger_record(&bytes)
413 .expect_err("unknown stable-cell record field must fail closed");
414
415 assert!(err.to_string().contains("future_field"));
416 }
417
418 #[test]
419 fn stable_cell_ledger_record_requires_both_commit_slot_fields() {
420 use crate::test_cbor::Value;
421
422 for (missing, present) in [("slot0", "slot1"), ("slot1", "slot0")] {
423 let physical = vec![(Value::Text(present.to_string()), Value::Null)];
424 let store = vec![(Value::Text("physical".to_string()), Value::Map(physical))];
425 let record = vec![(Value::Text("store".to_string()), Value::Map(store))];
426 let bytes = crate::test_cbor::to_vec(&Value::Map(record)).expect("record bytes");
427
428 let err = decode_stable_cell_ledger_record(&bytes)
429 .expect_err("missing commit slot must fail closed");
430
431 assert!(err.to_string().contains(missing));
432 }
433 }
434
435 #[test]
436 fn stable_cell_payload_rejects_non_cell_memory() {
437 let memory = VectorMemory::default();
438 memory.grow(1);
439 memory.write(0, b"BAD");
440
441 assert_eq!(
442 decode_stable_cell_payload(&memory),
443 Err(StableCellPayloadError::NotStableCell)
444 );
445 }
446
447 #[test]
448 fn stable_cell_payload_rejects_empty_memory_without_panic() {
449 let memory = VectorMemory::default();
450
451 assert_eq!(
452 decode_stable_cell_payload(&memory),
453 Err(StableCellPayloadError::NotStableCell)
454 );
455 assert_eq!(
456 crate::decode_stable_cell_ledger_record_from_memory(&memory).unwrap(),
457 StableCellLedgerRecord::default()
458 );
459 assert_eq!(
460 memory.size(),
461 0,
462 "reading empty memory must not initialize it"
463 );
464 }
465
466 #[test]
467 fn stable_cell_ledger_memory_reader_classifies_bad_record_without_panic() {
468 let memory = VectorMemory::default();
469 memory.grow(1);
470 memory.write(0, STABLE_CELL_MAGIC);
471 memory.write(3, &[STABLE_CELL_LAYOUT_VERSION]);
472 memory.write(4, &1_u32.to_le_bytes());
473 memory.write(STABLE_CELL_VALUE_OFFSET, &[0xff]);
474
475 let err = decode_stable_cell_ledger_record_from_memory(&memory)
476 .expect_err("bad record must be classified");
477
478 assert!(matches!(err, StableCellLedgerError::Record(_)));
479 }
480 #[test]
481 fn oversized_cell_length_is_rejected_before_payload_read() {
482 struct HeaderOnly {
483 value_len: u32,
484 pages: u64,
485 }
486 impl Memory for HeaderOnly {
487 fn size(&self) -> u64 {
488 self.pages
489 }
490 fn grow(&self, _: u64) -> i64 {
491 panic!("must not grow")
492 }
493 fn write(&self, _: u64, _: &[u8]) {
494 panic!("must not write")
495 }
496 fn read(&self, offset: u64, bytes: &mut [u8]) {
497 assert_eq!(offset, 0);
498 assert_eq!(bytes.len(), STABLE_CELL_HEADER_SIZE);
499 bytes[..4].copy_from_slice(b"SCL\x01");
500 bytes[4..8].copy_from_slice(&self.value_len.to_le_bytes());
501 }
502 }
503 for value_len in [
504 u32::try_from(crate::constants::MAX_LEDGER_RECORD_BYTES + 1).unwrap(),
505 u32::MAX,
506 ] {
507 let memory = HeaderOnly {
509 value_len,
510 pages: 65_537,
511 };
512 assert!(matches!(
513 decode_stable_cell_ledger_record_from_memory(&memory),
514 Err(StableCellLedgerError::Payload(StableCellPayloadError::TooLarge {
515 value_len: rejected,
516 })) if rejected == u64::from(value_len)
517 ));
518 let memory = HeaderOnly {
520 value_len,
521 pages: 1,
522 };
523 assert_eq!(
524 decode_stable_cell_payload(&memory),
525 Err(StableCellPayloadError::InvalidLength {
526 value_len: u64::from(value_len),
527 available_bytes: WASM_PAGE_SIZE_BYTES - STABLE_CELL_VALUE_OFFSET,
528 })
529 );
530 }
531 }
532
533 #[test]
534 fn hostile_cbor_is_rejected_on_production_record_decode() {
535 let huge_array = [0x9b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff];
536 let huge_string = [0x7b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff];
537 let mut nested = vec![0x81; crate::constants::MAX_LEDGER_NESTING + 1];
538 nested.push(0);
539 for bytes in [&huge_array[..], &huge_string[..], &nested, &[0xa1], &[0xff]] {
540 assert!(decode_stable_cell_ledger_record(bytes).is_err());
541 }
542 }
543}