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