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