1use crate::{LedgerCommitStore, constants::WASM_PAGE_SIZE_BYTES};
2use ic_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]
51#[derive(Debug, Error)]
52pub enum StableCellLedgerError {
53 #[error(transparent)]
55 Payload(#[from] StableCellPayloadError),
56 #[error("stable-cell ledger record decode failed: {0}")]
58 Record(#[source] ciborium::de::Error<std::io::Error>),
59}
60
61pub fn decode_stable_cell_payload<M: Memory>(
67 memory: &M,
68) -> Result<Vec<u8>, StableCellPayloadError> {
69 let value_len = stable_cell_payload_length(memory, crate::constants::MAX_LEDGER_RECORD_BYTES)?;
70 let mut bytes = vec![0; value_len];
71 memory.read(STABLE_CELL_VALUE_OFFSET, &mut bytes);
72 Ok(bytes)
73}
74
75pub fn stable_cell_payload_length<M: Memory>(
78 memory: &M,
79 maximum: usize,
80) -> Result<usize, StableCellPayloadError> {
81 if memory.size() == 0 {
82 return Err(StableCellPayloadError::NotStableCell);
83 }
84
85 let mut header = [0; STABLE_CELL_HEADER_SIZE];
86 memory.read(0, &mut header);
87 if &header[0..3] != STABLE_CELL_MAGIC {
88 return Err(StableCellPayloadError::NotStableCell);
89 }
90 if header[3] != STABLE_CELL_LAYOUT_VERSION {
91 return Err(StableCellPayloadError::UnexpectedLayoutVersion { version: header[3] });
92 }
93
94 let value_len = u64::from(u32::from_le_bytes([
95 header[4], header[5], header[6], header[7],
96 ]));
97 let available_bytes = memory.size().saturating_mul(WASM_PAGE_SIZE_BYTES);
98 let payload_capacity = available_bytes.saturating_sub(STABLE_CELL_VALUE_OFFSET);
99 if value_len > payload_capacity {
100 return Err(StableCellPayloadError::InvalidLength {
101 value_len,
102 available_bytes: payload_capacity,
103 });
104 }
105 if value_len > maximum as u64 {
106 return Err(StableCellPayloadError::TooLarge { value_len });
107 }
108 #[expect(
109 clippy::cast_possible_truncation,
110 reason = "the admitted caller ceiling is representable as usize"
111 )]
112 let value_len = value_len as usize;
113 Ok(value_len)
114}
115
116pub fn decode_stable_cell_commit_store(
125 bytes: &[u8],
126) -> Result<LedgerCommitStore, ciborium::de::Error<std::io::Error>> {
127 crate::cbor::from_slice_exact(bytes)
128}
129
130pub fn decode_stable_cell_commit_store_from_memory<M: Memory>(
138 memory: &M,
139) -> Result<LedgerCommitStore, StableCellLedgerError> {
140 if memory.size() == 0 {
141 return Ok(LedgerCommitStore::default());
142 }
143
144 let payload = decode_stable_cell_payload(memory)?;
145 decode_stable_cell_commit_store(&payload).map_err(StableCellLedgerError::Record)
146}
147
148pub struct EncodedLedgerRecord {
155 buffer: crate::bounded_writer::BoundedWriter,
156}
157
158impl EncodedLedgerRecord {
159 pub(crate) fn new(record: &LedgerCommitStore) -> Result<Self, usize> {
160 let capacity = record
163 .current()
164 .map_or(0, |slot| slot.payload().len())
165 .saturating_add(std::mem::size_of::<LedgerCommitStore>().saturating_mul(2))
166 .min(crate::constants::MAX_LEDGER_RECORD_BYTES);
167 let mut buffer = crate::bounded_writer::BoundedWriter::new(
168 Vec::with_capacity(capacity),
169 crate::constants::MAX_LEDGER_RECORD_BYTES,
170 );
171 if let Err(error) = ciborium::into_writer(record, &mut buffer) {
172 if buffer.attempted_len > crate::constants::MAX_LEDGER_RECORD_BYTES {
173 return Err(buffer.attempted_len);
174 }
175 panic!("LedgerCommitStore serialize failed: {error}");
176 }
177 Ok(Self { buffer })
178 }
179
180 pub(crate) const fn len(&self) -> usize {
181 self.buffer.bytes.len()
182 }
183}
184
185impl Storable for EncodedLedgerRecord {
186 const BOUND: Bound = Bound::Unbounded;
187
188 fn to_bytes(&self) -> Cow<'_, [u8]> {
189 Cow::Borrowed(&self.buffer.bytes)
190 }
191 fn into_bytes(self) -> Vec<u8> {
192 self.buffer.bytes
193 }
194 fn from_bytes(_: Cow<'_, [u8]>) -> Self {
195 panic!("publication bytes cannot recover ledger authority")
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use crate::test_cbor::hex_fixture;
203 use ic_stable_structures::{Cell, VectorMemory};
204
205 #[test]
206 fn publication_payload_refuses_oversized_chunks_before_buffering() {
207 use std::io::Write as _;
208
209 let mut encoded = EncodedLedgerRecord {
210 buffer: crate::bounded_writer::BoundedWriter::new(
211 Vec::new(),
212 crate::constants::MAX_LEDGER_RECORD_BYTES,
213 ),
214 };
215 let oversized = vec![0; crate::constants::MAX_LEDGER_RECORD_BYTES + 1];
216 assert!(encoded.buffer.write_all(&oversized).is_err());
217 assert_eq!(encoded.len(), 0);
218 assert_eq!(encoded.buffer.bytes.capacity(), 0);
219 }
220
221 #[test]
222 fn stable_cell_ledger_record_round_trips_through_cell() {
223 let memory = VectorMemory::default();
224 let record = LedgerCommitStore::default();
225 drop(Cell::new(
226 memory.clone(),
227 EncodedLedgerRecord::new(&record).unwrap(),
228 ));
229 let payload = decode_stable_cell_payload(&memory).expect("decode stable cell payload");
230 let decoded = decode_stable_cell_commit_store(&payload).unwrap();
231 assert_eq!(decoded, record);
232 assert_eq!(
233 crate::decode_stable_cell_commit_store_from_memory(&memory).unwrap(),
234 record
235 );
236 }
237
238 #[test]
239 fn current_stable_cell_record_fixture_recovers() {
240 let bytes = hex_fixture(include_str!(
241 "../fixtures/current/ledger_commit_store_single_active.cbor.hex"
242 ));
243 let record = decode_stable_cell_commit_store(&bytes).expect("stable-cell fixture");
244
245 assert_eq!(
246 EncodedLedgerRecord::new(&record).unwrap().len(),
247 bytes.len()
248 );
249 let encoded = EncodedLedgerRecord::new(&record).unwrap();
250 assert!(matches!(encoded.to_bytes(), Cow::Borrowed(_)));
251 let memory = VectorMemory::default();
252 drop(Cell::new(memory.clone(), encoded));
253 assert_eq!(decode_stable_cell_payload(&memory).unwrap(), bytes);
254 assert_eq!(
255 EncodedLedgerRecord::new(&record).unwrap().into_bytes(),
256 bytes
257 );
258 assert_eq!(
259 bytes,
260 crate::test_cbor::to_vec(&record).expect("re-encoded stable-cell fixture")
261 );
262 assert_eq!(
263 record
264 .recover()
265 .expect("fixture store recovers")
266 .current_generation(),
267 1
268 );
269 }
270
271 #[test]
272 fn malformed_payload_bytes_fail_closed_without_mutating_memory() {
273 let original = hex_fixture(include_str!(
274 "../fixtures/current/ledger_commit_store_single_active.cbor.hex"
275 ));
276 let start = original
277 .windows(8)
278 .position(|bytes| bytes == b"\x67payload")
279 .unwrap()
280 + 8;
281 let oversized = u32::try_from(crate::constants::MAX_COMMITTED_PAYLOAD_BYTES + 1).unwrap();
282 let mut cases = Vec::new();
283 for replacement in [
284 vec![0x5a, 0xff, 0xff, 0xff, 0xff],
285 vec![0x5f, 0xff],
286 vec![0x41],
287 vec![0xff],
288 ] {
289 let mut bytes = original[..start].to_vec();
290 bytes.extend(replacement);
291 cases.push(bytes);
292 }
293 let mut bytes = original[..start].to_vec();
294 bytes.push(0x5a);
295 bytes.extend_from_slice(&oversized.to_be_bytes());
296 bytes.resize(bytes.len() + oversized as usize, 0);
297 cases.push(bytes);
298 for bytes in cases {
299 let memory = VectorMemory::default();
300 let pages = (bytes.len() + STABLE_CELL_HEADER_SIZE).div_ceil(65_536);
301 memory.grow(pages as u64);
302 memory.write(0, STABLE_CELL_MAGIC);
303 memory.write(3, &[STABLE_CELL_LAYOUT_VERSION]);
304 memory.write(4, &u32::try_from(bytes.len()).unwrap().to_le_bytes());
305 memory.write(STABLE_CELL_VALUE_OFFSET, &bytes);
306 let before = memory.borrow().clone();
307 for _ in 0..2 {
308 assert!(matches!(
309 decode_stable_cell_commit_store_from_memory(&memory),
310 Err(StableCellLedgerError::Record(_))
311 ));
312 }
313 assert_eq!(*memory.borrow(), before);
314 }
315 }
316
317 #[test]
318 fn stable_cell_ledger_record_rejects_trailing_bytes() {
319 let mut bytes = EncodedLedgerRecord::new(&LedgerCommitStore::default())
320 .unwrap()
321 .into_bytes();
322 bytes.push(0);
323
324 let err =
325 decode_stable_cell_commit_store(&bytes).expect_err("trailing bytes must fail closed");
326
327 assert!(err.to_string().contains("trailing bytes"));
328 }
329
330 #[test]
331 fn stable_cell_ledger_record_rejects_unknown_top_level_fields() {
332 use crate::test_cbor::Value;
333
334 let Value::Map(mut map) = crate::test_cbor::to_value(LedgerCommitStore::default()).unwrap()
335 else {
336 panic!("commit store map");
337 };
338 map.push((Value::Text("future_field".to_string()), Value::Bool(true)));
339 let bytes = crate::test_cbor::to_vec(&Value::Map(map)).expect("unknown-field stable cell");
340
341 let err = decode_stable_cell_commit_store(&bytes)
342 .expect_err("unknown stable-cell record field must fail closed");
343
344 assert!(err.to_string().contains("future_field"));
345 }
346
347 #[test]
348 fn stable_cell_ledger_record_requires_an_explicit_current_snapshot() {
349 let bytes = crate::test_cbor::to_vec(&serde_json::json!({})).unwrap();
350 let err = decode_stable_cell_commit_store(&bytes)
351 .expect_err("missing current snapshot must not initialize");
352 assert!(err.to_string().contains("current"));
353 }
354
355 #[test]
356 fn stable_cell_payload_rejects_non_cell_memory() {
357 let memory = VectorMemory::default();
358 memory.grow(1);
359 memory.write(0, b"BAD");
360
361 assert_eq!(
362 decode_stable_cell_payload(&memory),
363 Err(StableCellPayloadError::NotStableCell)
364 );
365 }
366
367 #[test]
368 fn stable_cell_payload_rejects_empty_memory_without_panic() {
369 let memory = VectorMemory::default();
370
371 assert_eq!(
372 decode_stable_cell_payload(&memory),
373 Err(StableCellPayloadError::NotStableCell)
374 );
375 assert_eq!(
376 crate::decode_stable_cell_commit_store_from_memory(&memory).unwrap(),
377 LedgerCommitStore::default()
378 );
379 assert_eq!(
380 memory.size(),
381 0,
382 "reading empty memory must not initialize it"
383 );
384 }
385
386 #[test]
387 fn stable_cell_ledger_memory_reader_classifies_bad_record_without_panic() {
388 let memory = VectorMemory::default();
389 memory.grow(1);
390 memory.write(0, STABLE_CELL_MAGIC);
391 memory.write(3, &[STABLE_CELL_LAYOUT_VERSION]);
392 memory.write(4, &1_u32.to_le_bytes());
393 memory.write(STABLE_CELL_VALUE_OFFSET, &[0xff]);
394
395 let err = decode_stable_cell_commit_store_from_memory(&memory)
396 .expect_err("bad record must be classified");
397
398 assert!(matches!(err, StableCellLedgerError::Record(_)));
399 }
400 #[test]
401 fn oversized_cell_length_is_rejected_before_payload_read() {
402 struct HeaderOnly {
403 value_len: u32,
404 pages: u64,
405 }
406 impl Memory for HeaderOnly {
407 fn size(&self) -> u64 {
408 self.pages
409 }
410 fn grow(&self, _: u64) -> i64 {
411 panic!("must not grow")
412 }
413 fn write(&self, _: u64, _: &[u8]) {
414 panic!("must not write")
415 }
416 fn read(&self, offset: u64, bytes: &mut [u8]) {
417 assert_eq!(offset, 0);
418 assert_eq!(bytes.len(), STABLE_CELL_HEADER_SIZE);
419 bytes[..4].copy_from_slice(b"SCL\x01");
420 bytes[4..8].copy_from_slice(&self.value_len.to_le_bytes());
421 }
422 }
423 for value_len in [
424 u32::try_from(crate::constants::MAX_LEDGER_RECORD_BYTES + 1).unwrap(),
425 u32::MAX,
426 ] {
427 let memory = HeaderOnly {
429 value_len,
430 pages: 65_537,
431 };
432 assert!(matches!(
433 decode_stable_cell_commit_store_from_memory(&memory),
434 Err(StableCellLedgerError::Payload(StableCellPayloadError::TooLarge {
435 value_len: rejected,
436 })) if rejected == u64::from(value_len)
437 ));
438 let memory = HeaderOnly {
440 value_len,
441 pages: 1,
442 };
443 assert_eq!(
444 decode_stable_cell_payload(&memory),
445 Err(StableCellPayloadError::InvalidLength {
446 value_len: u64::from(value_len),
447 available_bytes: WASM_PAGE_SIZE_BYTES - STABLE_CELL_VALUE_OFFSET,
448 })
449 );
450 }
451 for value_len in [16_385, u32::MAX] {
453 let memory = HeaderOnly {
454 value_len,
455 pages: 65_537,
456 };
457 assert_eq!(
458 stable_cell_payload_length(&memory, 16_384),
459 Err(StableCellPayloadError::TooLarge {
460 value_len: u64::from(value_len)
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_commit_store(bytes).is_err());
474 }
475 }
476}