Skip to main content

ic_memory/
physical.rs

1use crate::hash::{FNV_OFFSET, fnv64};
2use serde::{Deserialize, Serialize};
3
4// ICMSNAP1 identifies the flat current-snapshot direct-CBOR contract.
5const COMMIT_MARKER: u64 = 0x4943_4D53_4E41_5031;
6
7///
8/// CommittedGenerationBytes
9///
10/// Committed ledger generation payload protected by a checksum.
11///
12/// This private DTO is the current physical snapshot representation. Its
13/// recovered bytes are untrusted until marker/checksum validation and ledger
14/// decoding/integrity validation have both succeeded.
15/// The payload encodes as a bounded CBOR byte string. Diagnostic JSON output
16/// is permitted, but JSON arrays are not a snapshot input format. Direct serde
17/// decoding is a DTO operation; maintained durable readers additionally
18/// preflight CBOR before allocation.
19///
20
21#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
22#[serde(deny_unknown_fields)]
23pub struct CommittedGenerationBytes {
24    /// Generation number represented by this payload.
25    pub(crate) generation: u64,
26    /// Physical commit marker. Readers reject records with an invalid marker.
27    pub(crate) commit_marker: u64,
28    /// Checksum over the generation, marker, and payload bytes.
29    pub(crate) checksum: u64,
30    /// Encoded ledger generation payload.
31    #[serde(with = "opaque_payload")]
32    pub(crate) payload: Vec<u8>,
33}
34
35// The persisted codec admits opaque byte strings; fixtures use this same shape.
36mod opaque_payload {
37    use crate::constants::MAX_COMMITTED_PAYLOAD_BYTES;
38    use serde::{Deserializer, Serializer, de::Visitor};
39
40    pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
41        if bytes.len() > MAX_COMMITTED_PAYLOAD_BYTES {
42            return Err(serde::ser::Error::custom(
43                "ledger byte string exceeds payload bound",
44            ));
45        }
46        serializer.serialize_bytes(bytes)
47    }
48
49    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
50        struct Bytes;
51        impl Visitor<'_> for Bytes {
52            type Value = Vec<u8>;
53
54            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55                f.write_str("a bounded opaque ledger byte string")
56            }
57
58            fn visit_bytes<E: serde::de::Error>(self, bytes: &[u8]) -> Result<Self::Value, E> {
59                if bytes.len() > MAX_COMMITTED_PAYLOAD_BYTES {
60                    return Err(E::custom("ledger byte string exceeds payload bound"));
61                }
62                Ok(bytes.to_vec())
63            }
64
65            fn visit_byte_buf<E: serde::de::Error>(self, bytes: Vec<u8>) -> Result<Self::Value, E> {
66                if bytes.len() > MAX_COMMITTED_PAYLOAD_BYTES {
67                    return Err(E::custom("ledger byte string exceeds payload bound"));
68                }
69                Ok(bytes)
70            }
71        }
72        deserializer.deserialize_byte_buf(Bytes)
73    }
74}
75
76impl CommittedGenerationBytes {
77    /// Build a committed generation record.
78    #[must_use]
79    pub fn new(generation: u64, payload: Vec<u8>) -> Self {
80        let mut record = Self {
81            generation,
82            commit_marker: COMMIT_MARKER,
83            checksum: 0,
84            payload,
85        };
86        record.checksum = generation_checksum(&record);
87        record
88    }
89
90    /// Return the generation number represented by this payload.
91    #[must_use]
92    pub const fn generation(&self) -> u64 {
93        self.generation
94    }
95
96    /// Return the physical commit marker.
97    ///
98    /// This is diagnostic data from a recovered record. Callers should use
99    /// [`CommittedGenerationBytes::validates`] before treating the record as
100    /// authoritative.
101    #[must_use]
102    #[cfg(test)]
103    pub const fn commit_marker(&self) -> u64 {
104        self.commit_marker
105    }
106
107    /// Return the checksum over the generation, marker, and payload bytes.
108    ///
109    /// The checksum is non-cryptographic and detects accidental corruption
110    /// only.
111    #[must_use]
112    #[cfg(test)]
113    pub const fn checksum(&self) -> u64 {
114        self.checksum
115    }
116
117    /// Borrow the encoded ledger generation payload.
118    #[must_use]
119    pub fn payload(&self) -> &[u8] {
120        &self.payload
121    }
122
123    /// Return whether the marker and checksum validate.
124    #[must_use]
125    pub fn validates(&self) -> bool {
126        self.commit_marker == COMMIT_MARKER && self.checksum == generation_checksum(self)
127    }
128}
129
130impl crate::ledger::LedgerCommitStore {
131    /// Borrow untrusted bytes only for encoding and capacity planning.
132    pub(crate) const fn current(&self) -> Option<&CommittedGenerationBytes> {
133        self.current.as_ref()
134    }
135
136    /// Recover the current snapshot only after marker and checksum validation.
137    pub(crate) fn authoritative(&self) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
138        let current = self
139            .current()
140            .ok_or(CommitRecoveryError::NoValidGeneration)?;
141        if current.commit_marker != COMMIT_MARKER {
142            return Err(CommitRecoveryError::UnsupportedFormat {
143                marker: current.commit_marker,
144            });
145        }
146        if !current.validates() {
147            return Err(CommitRecoveryError::InvalidSnapshot {
148                generation: current.generation(),
149            });
150        }
151        Ok(current)
152    }
153
154    pub(crate) fn authoritative_with_diagnostic(
155        &self,
156    ) -> (
157        Result<&CommittedGenerationBytes, CommitRecoveryError>,
158        CommitSnapshotDiagnostic,
159    ) {
160        let recovery = self.authoritative();
161        let current = match self.current() {
162            None => CommitSnapshotDiagnostic::Empty,
163            Some(record) if recovery.is_ok() => CommitSnapshotDiagnostic::Valid {
164                generation: record.generation(),
165            },
166            Some(record) => CommitSnapshotDiagnostic::Invalid {
167                generation: record.generation(),
168            },
169        };
170        (recovery, current)
171    }
172
173    /// Report the same checked current record without decoding logical ownership.
174    pub(crate) fn diagnostic(&self) -> CommitSnapshotDiagnostic {
175        self.authoritative_with_diagnostic().1
176    }
177
178    /// Replace the current snapshot after checking its successor and byte bound.
179    /// The owner must persist the enclosing record before publishing authority.
180    pub(crate) fn commit_payload_at_generation(
181        &mut self,
182        generation: u64,
183        payload: Vec<u8>,
184    ) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
185        if payload.len() > crate::constants::MAX_COMMITTED_PAYLOAD_BYTES {
186            return Err(CommitRecoveryError::PayloadTooLarge {
187                len: payload.len(),
188                limit: crate::constants::MAX_COMMITTED_PAYLOAD_BYTES,
189            });
190        }
191        match self.authoritative() {
192            Ok(prior) => {
193                let prior_generation = prior.generation();
194                let expected = prior_generation.checked_add(1).ok_or(
195                    CommitRecoveryError::GenerationOverflow {
196                        generation: prior_generation,
197                    },
198                )?;
199                if generation != expected {
200                    return Err(CommitRecoveryError::UnexpectedGeneration {
201                        expected,
202                        actual: generation,
203                    });
204                }
205            }
206            // The current field was explicitly present and empty, never corrupt.
207            Err(CommitRecoveryError::NoValidGeneration) => (),
208            Err(error) => return Err(error),
209        }
210        Ok(self
211            .current
212            .insert(CommittedGenerationBytes::new(generation, payload)))
213    }
214
215    #[cfg(test)]
216    pub(crate) fn write_corrupt_snapshot(&mut self, generation: u64, payload: Vec<u8>) {
217        let mut corrupt = CommittedGenerationBytes::new(generation, payload);
218        corrupt.checksum ^= 1;
219        self.current = Some(corrupt);
220    }
221}
222
223///
224/// CommitSnapshotDiagnostic
225///
226/// Read-only marker/checksum status of the one current snapshot.
227///
228
229#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
230pub enum CommitSnapshotDiagnostic {
231    /// No current snapshot has been committed.
232    Empty,
233    /// The current snapshot passed marker/checksum validation.
234    Valid {
235        /// Current generation.
236        generation: u64,
237    },
238    /// The current snapshot failed marker/checksum validation.
239    Invalid {
240        /// Untrusted generation observed in the rejected snapshot.
241        generation: u64,
242    },
243}
244
245///
246/// CommitRecoveryError
247///
248/// Current physical snapshot recovery or successor admission failure.
249///
250
251#[non_exhaustive]
252#[derive(Clone, Copy, Debug, Deserialize, Eq, thiserror::Error, PartialEq, Serialize)]
253pub enum CommitRecoveryError {
254    /// Candidate bytes exceed the bounded opaque payload ceiling.
255    #[error("committed payload length {len} exceeds byte limit {limit}")]
256    PayloadTooLarge {
257        /// Candidate byte length.
258        len: usize,
259        /// Maximum admitted byte length.
260        limit: usize,
261    },
262    /// The current snapshot is explicitly absent.
263    #[error("no committed ledger generation is present")]
264    NoValidGeneration,
265    /// The snapshot marker does not identify the current direct-CBOR format.
266    #[error("unsupported physical ledger format marker {marker:#x}")]
267    UnsupportedFormat { marker: u64 },
268    /// A present snapshot failed marker/checksum validation.
269    #[error("current ledger snapshot validation failed at generation {generation}")]
270    InvalidSnapshot {
271        /// Untrusted generation observed in the rejected snapshot.
272        generation: u64,
273    },
274    /// The current generation cannot advance.
275    #[error("committed ledger generation {generation} cannot be advanced without overflow")]
276    GenerationOverflow {
277        /// Last checked generation.
278        generation: u64,
279    },
280    /// A commit requested a generation other than the checked successor.
281    #[error("expected committed ledger generation {expected}, got {actual}")]
282    UnexpectedGeneration {
283        /// Required successor.
284        expected: u64,
285        /// Requested generation.
286        actual: u64,
287    },
288}
289
290fn generation_checksum(generation: &CommittedGenerationBytes) -> u64 {
291    let header = [
292        generation.generation.to_le_bytes(),
293        generation.commit_marker.to_le_bytes(),
294        (generation.payload.len() as u64).to_le_bytes(),
295    ];
296    let hash = fnv64(FNV_OFFSET, header.as_flattened());
297    fnv64(hash, &generation.payload)
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn rejected_successors_and_oversized_payloads_preserve_current_and_allow_retry() {
306        let limit = crate::constants::MAX_COMMITTED_PAYLOAD_BYTES;
307        for populated in [false, true] {
308            let mut store = crate::LedgerCommitStore::default();
309            if populated {
310                store.commit_payload_at_generation(7, vec![1]).unwrap();
311            }
312            let before = store.clone();
313            let generation = if populated { 8 } else { 0 };
314            assert_eq!(
315                store.commit_payload_at_generation(generation, vec![0; limit + 1]),
316                Err(CommitRecoveryError::PayloadTooLarge {
317                    len: limit + 1,
318                    limit
319                })
320            );
321            assert_eq!(store, before);
322            if populated {
323                assert_eq!(
324                    store.commit_payload_at_generation(9, vec![2]),
325                    Err(CommitRecoveryError::UnexpectedGeneration {
326                        expected: 8,
327                        actual: 9
328                    })
329                );
330                assert_eq!(store, before);
331            }
332            store
333                .commit_payload_at_generation(generation, vec![3])
334                .unwrap();
335            assert_eq!(store.authoritative().unwrap().generation(), generation);
336            let bytes = crate::test_cbor::to_vec(&store).unwrap();
337            assert_eq!(
338                crate::cbor::from_slice_exact::<crate::LedgerCommitStore>(&bytes).unwrap(),
339                store
340            );
341        }
342    }
343
344    #[test]
345    fn commits_replace_the_snapshot_and_diagnostics_share_recovery() {
346        let mut store = crate::LedgerCommitStore::default();
347        assert_eq!(store.diagnostic(), CommitSnapshotDiagnostic::Empty);
348        for generation in 0..4 {
349            let payload = vec![u8::try_from(generation).unwrap(); 4];
350            store
351                .commit_payload_at_generation(generation, payload.clone())
352                .unwrap();
353            let record = store.current().unwrap();
354            assert_eq!(record.payload(), payload);
355            assert_eq!(record.commit_marker(), COMMIT_MARKER);
356            assert_eq!(record.checksum(), generation_checksum(record));
357            let diagnostic = store.diagnostic();
358            assert_eq!(diagnostic, CommitSnapshotDiagnostic::Valid { generation });
359            assert_eq!(
360                serde_json::to_value(diagnostic).unwrap(),
361                serde_json::json!({"Valid": {"generation": generation}})
362            );
363        }
364    }
365
366    #[test]
367    fn corrupt_current_fails_recovery_and_commit_without_mutation() {
368        for field in ["checksum", "marker", "payload", "generation"] {
369            let mut store = crate::LedgerCommitStore::default();
370            store
371                .commit_payload_at_generation(7, vec![1, 2, 3])
372                .unwrap();
373            let current = store.current.as_mut().unwrap();
374            match field {
375                "checksum" => current.checksum ^= 1,
376                "marker" => {
377                    current.commit_marker ^= 1;
378                    current.checksum = generation_checksum(current);
379                }
380                "payload" => current.payload[0] ^= 1,
381                _ => current.generation ^= 1,
382            }
383            let generation = current.generation();
384            let expected = if field == "marker" {
385                CommitRecoveryError::UnsupportedFormat {
386                    marker: current.commit_marker,
387                }
388            } else {
389                CommitRecoveryError::InvalidSnapshot { generation }
390            };
391            let before = store.clone();
392            assert_eq!(store.authoritative(), Err(expected));
393            assert_eq!(
394                store.diagnostic(),
395                CommitSnapshotDiagnostic::Invalid { generation }
396            );
397            assert_eq!(
398                store.commit_payload_at_generation(8, vec![4]),
399                Err(expected)
400            );
401            assert_eq!(store, before);
402        }
403    }
404
405    #[test]
406    fn physical_generation_overflow_preserves_current() {
407        let mut store = crate::LedgerCommitStore::default();
408        store
409            .commit_payload_at_generation(u64::MAX, vec![1])
410            .unwrap();
411        let before = store.clone();
412        assert_eq!(
413            store.commit_payload_at_generation(0, vec![2]),
414            Err(CommitRecoveryError::GenerationOverflow {
415                generation: u64::MAX
416            })
417        );
418        assert_eq!(store, before);
419    }
420
421    #[test]
422    fn maximum_payload_writer_reader_agree() {
423        let mut store = crate::LedgerCommitStore::default();
424        store
425            .commit_payload_at_generation(0, vec![0; crate::constants::MAX_COMMITTED_PAYLOAD_BYTES])
426            .unwrap();
427        let bytes = crate::test_cbor::to_vec(&store).unwrap();
428        assert!(bytes.len() <= crate::constants::MAX_LEDGER_RECORD_BYTES);
429        let decoded: crate::LedgerCommitStore = crate::cbor::from_slice_exact(&bytes).unwrap();
430        assert_eq!(decoded, store);
431        assert!(decoded.authoritative().is_ok());
432        let oversized = CommittedGenerationBytes::new(
433            0,
434            vec![0; crate::constants::MAX_COMMITTED_PAYLOAD_BYTES + 1],
435        );
436        assert!(crate::test_cbor::to_vec(&oversized).is_err());
437    }
438
439    #[test]
440    fn payload_uses_bounded_binary_bytes() {
441        let record = CommittedGenerationBytes::new(7, vec![0, 24, 255]);
442        let bytes = crate::test_cbor::to_vec(&record).unwrap();
443        let value: ciborium::Value = crate::cbor::from_slice_exact(&bytes).unwrap();
444        let ciborium::Value::Map(fields) = value else {
445            panic!("record map")
446        };
447        let (_, payload) = fields
448            .iter()
449            .find(|(key, _)| key.as_text() == Some("payload"))
450            .unwrap();
451        assert_eq!(*payload, ciborium::Value::Bytes(vec![0, 24, 255]));
452        assert_eq!(
453            crate::cbor::from_slice_exact::<CommittedGenerationBytes>(&bytes).unwrap(),
454            record
455        );
456    }
457}