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