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)]
28#[serde(deny_unknown_fields)]
29pub struct StableCellLedgerRecord {
30 store: LedgerCommitStore,
31}
32
33impl StableCellLedgerRecord {
34 #[must_use]
36 #[cfg(test)]
37 pub(crate) const fn new(store: LedgerCommitStore) -> Self {
38 Self { store }
39 }
40
41 #[must_use]
43 pub(crate) const fn store(&self) -> &LedgerCommitStore {
44 &self.store
45 }
46
47 pub(crate) const fn store_mut(&mut self) -> &mut LedgerCommitStore {
49 &mut self.store
50 }
51}
52
53#[non_exhaustive]
60#[derive(Clone, Debug, Eq, Error, PartialEq)]
61pub enum StableCellPayloadError {
62 #[error("stable-cell ledger payload length {value_len} exceeds recovery limit")]
64 TooLarge { value_len: u64 },
65 #[error("memory is not an ic-stable-structures Cell")]
67 NotStableCell,
68 #[error("unexpected stable-cell layout version {version}")]
70 UnexpectedLayoutVersion {
71 version: u8,
73 },
74 #[error("stable-cell payload length {value_len} exceeds available bytes {available_bytes}")]
76 InvalidLength {
77 value_len: u64,
79 available_bytes: u64,
81 },
82}
83
84#[non_exhaustive]
89#[derive(Debug, Error)]
90pub enum StableCellLedgerError {
91 #[error(transparent)]
93 Payload(#[from] StableCellPayloadError),
94 #[error("stable-cell ledger record decode failed: {0}")]
96 Record(#[source] ciborium::de::Error<std::io::Error>),
97}
98
99pub fn decode_stable_cell_payload<M: Memory>(
105 memory: &M,
106) -> Result<Vec<u8>, StableCellPayloadError> {
107 if memory.size() == 0 {
108 return Err(StableCellPayloadError::NotStableCell);
109 }
110
111 let mut header = [0; STABLE_CELL_HEADER_SIZE];
112 memory.read(0, &mut header);
113 if &header[0..3] != STABLE_CELL_MAGIC {
114 return Err(StableCellPayloadError::NotStableCell);
115 }
116 if header[3] != STABLE_CELL_LAYOUT_VERSION {
117 return Err(StableCellPayloadError::UnexpectedLayoutVersion { version: header[3] });
118 }
119
120 let value_len = u64::from(u32::from_le_bytes([
121 header[4], header[5], header[6], header[7],
122 ]));
123 let available_bytes = memory.size().saturating_mul(WASM_PAGE_SIZE_BYTES);
124 let payload_capacity = available_bytes.saturating_sub(STABLE_CELL_VALUE_OFFSET);
125 if value_len > payload_capacity {
126 return Err(StableCellPayloadError::InvalidLength {
127 value_len,
128 available_bytes: payload_capacity,
129 });
130 }
131 if value_len > crate::constants::MAX_LEDGER_RECORD_BYTES as u64 {
132 return Err(StableCellPayloadError::TooLarge { value_len });
133 }
134 #[expect(
135 clippy::cast_possible_truncation,
136 reason = "the admitted recovery ceiling is representable as usize"
137 )]
138 let value_len = value_len as usize;
139
140 let mut bytes = vec![0; value_len];
141 memory.read(STABLE_CELL_VALUE_OFFSET, &mut bytes);
142 Ok(bytes)
143}
144
145pub fn decode_stable_cell_ledger_record(
154 bytes: &[u8],
155) -> Result<StableCellLedgerRecord, ciborium::de::Error<std::io::Error>> {
156 crate::cbor::from_slice_exact(bytes)
157}
158
159pub fn decode_stable_cell_ledger_record_from_memory<M: Memory>(
167 memory: &M,
168) -> Result<StableCellLedgerRecord, StableCellLedgerError> {
169 if memory.size() == 0 {
170 return Ok(StableCellLedgerRecord::default());
171 }
172
173 let payload = decode_stable_cell_payload(memory)?;
174 decode_stable_cell_ledger_record(&payload).map_err(StableCellLedgerError::Record)
175}
176
177pub struct EncodedLedgerRecord {
184 buffer: crate::bounded_writer::BoundedWriter,
185}
186
187impl EncodedLedgerRecord {
188 pub(crate) fn new(record: &StableCellLedgerRecord) -> Result<Self, usize> {
189 let physical = record.store().physical();
192 let capacity = physical
193 .current()
194 .map_or(0, |slot| slot.payload().len())
195 .saturating_add(std::mem::size_of::<StableCellLedgerRecord>().saturating_mul(2))
196 .min(crate::constants::MAX_LEDGER_RECORD_BYTES);
197 let mut buffer = crate::bounded_writer::BoundedWriter::new(
198 Vec::with_capacity(capacity),
199 crate::constants::MAX_LEDGER_RECORD_BYTES,
200 );
201 if let Err(error) = ciborium::into_writer(record, &mut buffer) {
202 if buffer.attempted_len > crate::constants::MAX_LEDGER_RECORD_BYTES {
203 return Err(buffer.attempted_len);
204 }
205 panic!("StableCellLedgerRecord serialize failed: {error}");
206 }
207 Ok(Self { buffer })
208 }
209
210 pub(crate) const fn len(&self) -> usize {
211 self.buffer.bytes.len()
212 }
213}
214
215impl Storable for EncodedLedgerRecord {
216 const BOUND: Bound = Bound::Unbounded;
217
218 fn to_bytes(&self) -> Cow<'_, [u8]> {
219 Cow::Borrowed(&self.buffer.bytes)
220 }
221 fn into_bytes(self) -> Vec<u8> {
222 self.buffer.bytes
223 }
224 fn from_bytes(_: Cow<'_, [u8]>) -> Self {
225 panic!("publication bytes cannot recover ledger authority")
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 publication_payload_refuses_oversized_chunks_before_buffering() {
237 use std::io::Write as _;
238
239 let mut encoded = EncodedLedgerRecord {
240 buffer: crate::bounded_writer::BoundedWriter::new(
241 Vec::new(),
242 crate::constants::MAX_LEDGER_RECORD_BYTES,
243 ),
244 };
245 let oversized = vec![0; crate::constants::MAX_LEDGER_RECORD_BYTES + 1];
246 assert!(encoded.buffer.write_all(&oversized).is_err());
247 assert_eq!(encoded.len(), 0);
248 assert_eq!(encoded.buffer.bytes.capacity(), 0);
249 }
250
251 #[test]
252 fn stable_cell_ledger_record_round_trips_through_cell() {
253 let memory = VectorMemory::default();
254 let record = StableCellLedgerRecord::default();
255 drop(Cell::new(
256 memory.clone(),
257 EncodedLedgerRecord::new(&record).unwrap(),
258 ));
259 let payload = decode_stable_cell_payload(&memory).expect("decode stable cell payload");
260 let decoded = decode_stable_cell_ledger_record(&payload).unwrap();
261 assert_eq!(decoded, record);
262 assert_eq!(
263 crate::decode_stable_cell_ledger_record_from_memory(&memory).unwrap(),
264 record
265 );
266 }
267
268 #[test]
269 fn current_stable_cell_record_fixture_recovers() {
270 let bytes = hex_fixture(include_str!(
271 "../fixtures/current/stable_cell_record.cbor.hex"
272 ));
273 let record = decode_stable_cell_ledger_record(&bytes).expect("stable-cell fixture");
274
275 assert_eq!(
276 EncodedLedgerRecord::new(&record).unwrap().len(),
277 bytes.len()
278 );
279 let encoded = EncodedLedgerRecord::new(&record).unwrap();
280 assert!(matches!(encoded.to_bytes(), Cow::Borrowed(_)));
281 let memory = VectorMemory::default();
282 drop(Cell::new(memory.clone(), encoded));
283 assert_eq!(decode_stable_cell_payload(&memory).unwrap(), bytes);
284 assert_eq!(
285 EncodedLedgerRecord::new(&record).unwrap().into_bytes(),
286 bytes
287 );
288 assert_eq!(
289 bytes,
290 crate::test_cbor::to_vec(&record).expect("re-encoded stable-cell fixture")
291 );
292 assert_eq!(
293 record
294 .store()
295 .recover()
296 .expect("fixture store recovers")
297 .current_generation(),
298 1
299 );
300 }
301
302 #[test]
303 fn malformed_payload_bytes_fail_closed_without_mutating_memory() {
304 let original = hex_fixture(include_str!(
305 "../fixtures/current/stable_cell_record.cbor.hex"
306 ));
307 let start = original
308 .windows(8)
309 .position(|bytes| bytes == b"\x67payload")
310 .unwrap()
311 + 8;
312 let oversized = u32::try_from(crate::constants::MAX_COMMITTED_PAYLOAD_BYTES + 1).unwrap();
313 let mut cases = Vec::new();
314 for replacement in [
315 vec![0x5a, 0xff, 0xff, 0xff, 0xff],
316 vec![0x5f, 0xff],
317 vec![0x41],
318 vec![0xff],
319 ] {
320 let mut bytes = original[..start].to_vec();
321 bytes.extend(replacement);
322 cases.push(bytes);
323 }
324 let mut bytes = original[..start].to_vec();
325 bytes.push(0x5a);
326 bytes.extend_from_slice(&oversized.to_be_bytes());
327 bytes.resize(bytes.len() + oversized as usize, 0);
328 cases.push(bytes);
329 for bytes in cases {
330 let memory = VectorMemory::default();
331 let pages = (bytes.len() + STABLE_CELL_HEADER_SIZE).div_ceil(65_536);
332 memory.grow(pages as u64);
333 memory.write(0, STABLE_CELL_MAGIC);
334 memory.write(3, &[STABLE_CELL_LAYOUT_VERSION]);
335 memory.write(4, &u32::try_from(bytes.len()).unwrap().to_le_bytes());
336 memory.write(STABLE_CELL_VALUE_OFFSET, &bytes);
337 let before = memory.borrow().clone();
338 for _ in 0..2 {
339 assert!(matches!(
340 decode_stable_cell_ledger_record_from_memory(&memory),
341 Err(StableCellLedgerError::Record(_))
342 ));
343 }
344 assert_eq!(*memory.borrow(), before);
345 }
346 }
347
348 #[test]
349 fn stable_cell_ledger_record_rejects_trailing_bytes() {
350 let mut bytes = EncodedLedgerRecord::new(&StableCellLedgerRecord::default())
351 .unwrap()
352 .into_bytes();
353 bytes.push(0);
354
355 let err =
356 decode_stable_cell_ledger_record(&bytes).expect_err("trailing bytes must fail closed");
357
358 assert!(err.to_string().contains("trailing bytes"));
359 }
360
361 #[test]
362 fn stable_cell_ledger_record_rejects_unknown_top_level_fields() {
363 use crate::test_cbor::Value;
364
365 let map = vec![
366 (
367 Value::Text("store".to_string()),
368 crate::test_cbor::to_value(LedgerCommitStore::default()).expect("store value"),
369 ),
370 (Value::Text("future_field".to_string()), Value::Bool(true)),
371 ];
372 let bytes = crate::test_cbor::to_vec(&Value::Map(map)).expect("unknown-field stable cell");
373
374 let err = decode_stable_cell_ledger_record(&bytes)
375 .expect_err("unknown stable-cell record field must fail closed");
376
377 assert!(err.to_string().contains("future_field"));
378 }
379
380 #[test]
381 fn stable_cell_ledger_record_requires_an_explicit_current_snapshot() {
382 let bytes = crate::test_cbor::to_vec(&serde_json::json!({
383 "store": { "physical": {} }
384 }))
385 .unwrap();
386 let err = decode_stable_cell_ledger_record(&bytes)
387 .expect_err("missing current snapshot must not initialize");
388 assert!(err.to_string().contains("current"));
389 }
390
391 #[test]
392 fn stable_cell_payload_rejects_non_cell_memory() {
393 let memory = VectorMemory::default();
394 memory.grow(1);
395 memory.write(0, b"BAD");
396
397 assert_eq!(
398 decode_stable_cell_payload(&memory),
399 Err(StableCellPayloadError::NotStableCell)
400 );
401 }
402
403 #[test]
404 fn stable_cell_payload_rejects_empty_memory_without_panic() {
405 let memory = VectorMemory::default();
406
407 assert_eq!(
408 decode_stable_cell_payload(&memory),
409 Err(StableCellPayloadError::NotStableCell)
410 );
411 assert_eq!(
412 crate::decode_stable_cell_ledger_record_from_memory(&memory).unwrap(),
413 StableCellLedgerRecord::default()
414 );
415 assert_eq!(
416 memory.size(),
417 0,
418 "reading empty memory must not initialize it"
419 );
420 }
421
422 #[test]
423 fn stable_cell_ledger_memory_reader_classifies_bad_record_without_panic() {
424 let memory = VectorMemory::default();
425 memory.grow(1);
426 memory.write(0, STABLE_CELL_MAGIC);
427 memory.write(3, &[STABLE_CELL_LAYOUT_VERSION]);
428 memory.write(4, &1_u32.to_le_bytes());
429 memory.write(STABLE_CELL_VALUE_OFFSET, &[0xff]);
430
431 let err = decode_stable_cell_ledger_record_from_memory(&memory)
432 .expect_err("bad record must be classified");
433
434 assert!(matches!(err, StableCellLedgerError::Record(_)));
435 }
436 #[test]
437 fn oversized_cell_length_is_rejected_before_payload_read() {
438 struct HeaderOnly {
439 value_len: u32,
440 pages: u64,
441 }
442 impl Memory for HeaderOnly {
443 fn size(&self) -> u64 {
444 self.pages
445 }
446 fn grow(&self, _: u64) -> i64 {
447 panic!("must not grow")
448 }
449 fn write(&self, _: u64, _: &[u8]) {
450 panic!("must not write")
451 }
452 fn read(&self, offset: u64, bytes: &mut [u8]) {
453 assert_eq!(offset, 0);
454 assert_eq!(bytes.len(), STABLE_CELL_HEADER_SIZE);
455 bytes[..4].copy_from_slice(b"SCL\x01");
456 bytes[4..8].copy_from_slice(&self.value_len.to_le_bytes());
457 }
458 }
459 for value_len in [
460 u32::try_from(crate::constants::MAX_LEDGER_RECORD_BYTES + 1).unwrap(),
461 u32::MAX,
462 ] {
463 let memory = HeaderOnly {
465 value_len,
466 pages: 65_537,
467 };
468 assert!(matches!(
469 decode_stable_cell_ledger_record_from_memory(&memory),
470 Err(StableCellLedgerError::Payload(StableCellPayloadError::TooLarge {
471 value_len: rejected,
472 })) if rejected == u64::from(value_len)
473 ));
474 let memory = HeaderOnly {
476 value_len,
477 pages: 1,
478 };
479 assert_eq!(
480 decode_stable_cell_payload(&memory),
481 Err(StableCellPayloadError::InvalidLength {
482 value_len: u64::from(value_len),
483 available_bytes: WASM_PAGE_SIZE_BYTES - STABLE_CELL_VALUE_OFFSET,
484 })
485 );
486 }
487 }
488
489 #[test]
490 fn hostile_cbor_is_rejected_on_production_record_decode() {
491 let huge_array = [0x9b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff];
492 let huge_string = [0x7b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff];
493 let mut nested = vec![0x81; crate::constants::MAX_LEDGER_NESTING + 1];
494 nested.push(0);
495 for bytes in [&huge_array[..], &huge_string[..], &nested, &[0xa1], &[0xff]] {
496 assert!(decode_stable_cell_ledger_record(bytes).is_err());
497 }
498 }
499}