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