Skip to main content

ic_memory/ledger/
mod.rs

1mod claim;
2mod codec;
3mod error;
4mod integrity;
5mod record;
6mod stage;
7
8use crate::physical::{CommitSnapshotDiagnostic, CommittedGenerationBytes, SnapshotCommitStore};
9use serde::{Deserialize, Serialize};
10
11pub use claim::{ClaimConflict, ClaimOutcome, validate_declaration_claim};
12pub use error::{AllocationStageError, LedgerCommitError, LedgerIntegrityError};
13pub use record::{AllocationLedger, AllocationRecord, AllocationState, RecoveredLedger};
14pub use stage::stage_generation;
15
16use codec::decode_ledger;
17
18///
19/// LedgerCommitStore
20///
21/// Generation-scoped allocation ledger commit store.
22///
23/// This type owns the logical commit lifecycle and the crate-owned CBOR ledger
24/// encoding. It deliberately does not own stable-memory IO; that remains the
25/// substrate or framework owner's responsibility.
26///
27/// The runtime commits allocation ledger generations through this passive DTO.
28/// Decoding it grants no memory access or persistence authority. It does not
29/// open stable-memory handles or allocate application slots.
30///
31#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
32#[serde(deny_unknown_fields)]
33pub struct LedgerCommitStore {
34    /// Protected current physical snapshot.
35    physical: SnapshotCommitStore,
36}
37
38impl LedgerCommitStore {
39    /// Borrow the protected physical commit store for diagnostics.
40    #[must_use]
41    pub(crate) const fn physical(&self) -> &SnapshotCommitStore {
42        &self.physical
43    }
44
45    /// Recover the authoritative allocation ledger using the native CBOR ledger codec.
46    pub(crate) fn recover(&self) -> Result<RecoveredLedger, LedgerCommitError> {
47        let committed = self
48            .physical
49            .authoritative()
50            .map_err(LedgerCommitError::Recovery)?;
51        Self::recover_committed(committed)
52    }
53
54    pub(crate) fn recover_with_diagnostic(
55        &self,
56    ) -> (
57        Result<RecoveredLedger, LedgerCommitError>,
58        CommitSnapshotDiagnostic,
59    ) {
60        let (committed, diagnostic) = self.physical.authoritative_with_diagnostic();
61        let recovered = committed
62            .map_err(LedgerCommitError::Recovery)
63            .and_then(Self::recover_committed);
64        (recovered, diagnostic)
65    }
66
67    fn recover_committed(
68        committed: &CommittedGenerationBytes,
69    ) -> Result<RecoveredLedger, LedgerCommitError> {
70        let ledger = decode_ledger(committed.payload()).map_err(LedgerCommitError::Codec)?;
71        if committed.generation() != ledger.current_generation {
72            return Err(LedgerCommitError::PhysicalLogicalGenerationMismatch {
73                physical_generation: committed.generation(),
74                logical_generation: ledger.current_generation,
75            });
76        }
77        ledger
78            .validate_integrity()
79            .map_err(LedgerCommitError::Integrity)?;
80        Ok(RecoveredLedger::from_trusted_ledger(ledger))
81    }
82
83    /// Recover the authoritative ledger, or use logical genesis for an empty store.
84    ///
85    /// Only explicit snapshot absence admits the empty generation-zero base.
86    /// No payload is encoded or committed here. Corrupt or partially written
87    /// stores fail closed; the caller must persist its snapshot successor
88    /// before publishing allocation authority.
89    pub(crate) fn recover_or_genesis(&self) -> Result<RecoveredLedger, LedgerCommitError> {
90        self.recover()
91            .or_else(|error| RecoveredLedger::genesis_for_absent_snapshot(&error).ok_or(error))
92    }
93
94    /// Commit one logical allocation ledger generation through the native CBOR ledger codec.
95    ///
96    /// # Panics
97    ///
98    /// Panics only if the private concrete ledger-encoding invariant is broken.
99    #[cfg(test)]
100    pub fn commit(
101        &mut self,
102        ledger: &AllocationLedger,
103    ) -> Result<RecoveredLedger, LedgerCommitError> {
104        self.commit_generation(ledger)?;
105        // The current writer encoded this integrity-checked ledger, and the
106        // physical commit checked its predecessor and established this generation.
107        // Existing persisted bytes still cross the full recovery boundary.
108        Ok(RecoveredLedger::from_trusted_ledger(ledger.clone()))
109    }
110
111    // Bootstrap already owns its staged ledger. Share the checked mutation
112    // without cloning that ledger into a proof only to unwrap it immediately.
113    pub(crate) fn commit_generation(
114        &mut self,
115        ledger: &AllocationLedger,
116    ) -> Result<(), LedgerCommitError> {
117        ledger
118            .validate_integrity()
119            .map_err(LedgerCommitError::Integrity)?;
120        let payload = codec::encode_ledger(ledger).map_err(LedgerCommitError::Integrity)?;
121        self.physical
122            .commit_payload_at_generation(ledger.current_generation, payload)
123            .map(|_| ())
124            .map_err(LedgerCommitError::Recovery)
125    }
126
127    /// Simulate corruption of the current logical ledger snapshot.
128    #[cfg(test)]
129    pub fn write_corrupt_ledger(
130        &mut self,
131        ledger: &AllocationLedger,
132    ) -> Result<(), LedgerCommitError> {
133        let payload = codec::encode_ledger(ledger).map_err(LedgerCommitError::Integrity)?;
134        self.physical
135            .write_corrupt_snapshot(ledger.current_generation, payload);
136        Ok(())
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::{CommitRecoveryError, test_cbor::hex_fixture};
144
145    #[test]
146    fn repeated_commits_and_schema_churn_keep_metadata_bounded() {
147        let mut store = LedgerCommitStore::default();
148        store
149            .commit(&AllocationLedger::new(65_530, Vec::new()).unwrap())
150            .unwrap();
151        let mut largest_payload = 0;
152        for version in 1..=1024 {
153            let snapshot = DeclarationSnapshot::canonicalize(vec![declaration(
154                "app.users.v1",
155                100,
156                Some(version),
157            )])
158            .unwrap();
159            let recovered = store.recover().unwrap();
160            let pending = crate::runtime::bootstrap::prepare_commit(
161                &mut store,
162                recovered,
163                snapshot,
164                &crate::GenericAllocationPolicy,
165            )
166            .unwrap();
167            let committed = pending.confirm_persisted();
168            assert_eq!(committed.generation(), 65_530 + u64::from(version));
169            let recovered = store.recover().unwrap();
170            assert_eq!(recovered.ledger().records().len(), 1);
171            assert_eq!(
172                recovered.ledger().records()[0].schema().schema_version(),
173                Some(version)
174            );
175            largest_payload =
176                largest_payload.max(store.physical().authoritative().unwrap().payload().len());
177        }
178        // CBOR integer widths may grow; the number of records and metadata do not.
179        assert!(largest_payload < 200);
180        assert!(
181            crate::stable_cell::EncodedLedgerRecord::new(&store)
182                .unwrap()
183                .len()
184                < 600
185        );
186    }
187
188    #[test]
189    fn largest_current_record_set_fits_writer_and_reader_bounds() {
190        let records = (0..255_u8)
191            .map(|id| {
192                let key = format!("app.store{id:03}.{}.v1", "x".repeat(112));
193                assert_eq!(key.len(), 128);
194                let declaration = declaration(&key, id, Some(u32::MAX));
195                let mut record = AllocationRecord::active(&declaration);
196                // Reserved is the longest encoded lifecycle variant.
197                record.state = AllocationState::Reserved;
198                record
199            })
200            .collect();
201        let ledger = AllocationLedger::new(u64::MAX - 1, records).unwrap();
202        let payload = encoded_payload(&ledger);
203        assert!(payload.len() <= crate::constants::MAX_COMMITTED_PAYLOAD_BYTES);
204        let mut store = LedgerCommitStore::default();
205        store.commit(&ledger).unwrap();
206        let next = AllocationLedger::new(u64::MAX, ledger.records().to_vec()).unwrap();
207        store.commit(&next).unwrap();
208        let record = store;
209        let bytes = crate::stable_cell::EncodedLedgerRecord::new(&record)
210            .unwrap()
211            .into_bytes();
212        assert!(bytes.len() <= crate::constants::MAX_LEDGER_RECORD_BYTES);
213        let decoded = crate::decode_stable_cell_commit_store(&bytes).unwrap();
214        assert_eq!(decoded.recover().unwrap().ledger(), &next);
215    }
216
217    #[test]
218    fn validated_counter_overflow_rejects_without_commit_mutation() {
219        let mut store = LedgerCommitStore::default();
220        store
221            .commit(&AllocationLedger::new(u64::MAX, Vec::new()).unwrap())
222            .unwrap();
223        let before = store.clone();
224        let recovered = store.recover().unwrap();
225        let result = crate::runtime::bootstrap::prepare_commit(
226            &mut store,
227            recovered,
228            DeclarationSnapshot::canonicalize(Vec::new()).unwrap(),
229            &crate::GenericAllocationPolicy,
230        );
231        assert!(matches!(
232            result,
233            Err(crate::RuntimeBootstrapError::Staging(
234                AllocationStageError::GenerationOverflow {
235                    generation: u64::MAX
236                }
237            ))
238        ));
239        assert_eq!(store, before);
240    }
241
242    #[test]
243    fn unsupported_discriminator_precedes_logical_decode_and_preserves_storage() {
244        let mut committed = CommittedGenerationBytes::new(1, vec![0xff]);
245        committed.commit_marker = u64::MAX;
246        let store = LedgerCommitStore {
247            physical: SnapshotCommitStore {
248                current: Some(committed),
249            },
250        };
251        let before = store.clone();
252        let expected = LedgerCommitError::Recovery(CommitRecoveryError::UnsupportedFormat {
253            marker: u64::MAX,
254        });
255        assert_eq!(store.recover(), Err(expected.clone()));
256        assert_eq!(store.recover_or_genesis(), Err(expected));
257        assert_eq!(store, before);
258    }
259    use crate::{
260        declaration::{AllocationDeclaration, DeclarationSnapshot},
261        key::StableKey,
262        physical::CommittedGenerationBytes,
263        schema::SchemaMetadata,
264        slot::MemoryManagerSlot,
265    };
266    use ic_stable_structures::Storable;
267    fn declaration(key: &str, id: u8, schema_version: Option<u32>) -> AllocationDeclaration {
268        AllocationDeclaration::new(
269            key,
270            MemoryManagerSlot::new(id).expect("usable slot"),
271            SchemaMetadata::new(schema_version).expect("schema metadata"),
272        )
273        .expect("declaration")
274    }
275
276    fn ledger() -> AllocationLedger {
277        AllocationLedger {
278            current_generation: 3,
279            records: Vec::new(),
280        }
281    }
282
283    fn committed_ledger(current_generation: u64) -> AllocationLedger {
284        AllocationLedger {
285            current_generation,
286            records: Vec::new(),
287        }
288    }
289
290    fn active_record(key: &str, id: u8) -> AllocationRecord {
291        AllocationRecord::active(&declaration(key, id, None))
292    }
293
294    fn snapshot_from(declarations: Vec<AllocationDeclaration>) -> DeclarationSnapshot {
295        DeclarationSnapshot::canonicalize(declarations).unwrap()
296    }
297
298    fn stage(
299        ledger: AllocationLedger,
300        snapshot: &DeclarationSnapshot,
301    ) -> Result<AllocationLedger, crate::RuntimeBootstrapError<std::convert::Infallible>> {
302        stage_generation(
303            RecoveredLedger::from_trusted_ledger(ledger),
304            snapshot_from(snapshot.declarations().to_vec()),
305            &crate::GenericAllocationPolicy,
306        )
307        .map(|(ledger, _)| ledger)
308    }
309
310    fn record<'ledger>(ledger: &'ledger AllocationLedger, key: &str) -> &'ledger AllocationRecord {
311        ledger
312            .records()
313            .iter()
314            .find(|record| record.stable_key.as_str() == key)
315            .expect("allocation record")
316    }
317
318    fn encoded_payload(ledger: &AllocationLedger) -> Vec<u8> {
319        codec::encode_ledger(ledger).expect("fixture ledger")
320    }
321
322    fn ledger_from_payload_fixture(contents: &str) -> AllocationLedger {
323        let bytes = hex_fixture(contents);
324        let ledger = decode_ledger(&bytes).expect("fixture ledger");
325        ledger
326            .validate_integrity()
327            .expect("fixture ledger integrity");
328        assert_eq!(bytes, encoded_payload(&ledger));
329        ledger
330    }
331
332    fn store_from_fixture(contents: &str) -> LedgerCommitStore {
333        let bytes = hex_fixture(contents);
334        let store: LedgerCommitStore = crate::test_cbor::from_slice(&bytes).expect("fixture store");
335        assert_eq!(
336            bytes,
337            crate::test_cbor::to_vec(&store).expect("re-encoded fixture store")
338        );
339        store
340    }
341
342    fn active_committed_ledger() -> AllocationLedger {
343        AllocationLedger {
344            current_generation: 1,
345            records: vec![active_record("app.users.v1", 100)],
346        }
347    }
348
349    fn active_ledger_value() -> crate::test_cbor::Value {
350        crate::test_cbor::to_value(active_committed_ledger()).expect("ledger value")
351    }
352
353    #[test]
354    fn recovery_rejects_unknown_fields_inside_retirement_state() {
355        use crate::LedgerCommitStore;
356
357        let retired = AllocationLedger::new(
358            2,
359            vec![crate::testing::allocation_record(
360                &declaration("app.users.v1", 100, None),
361                AllocationState::Retired,
362            )],
363        )
364        .unwrap();
365        let mut store = LedgerCommitStore::default();
366        let committed = store.commit(&retired).unwrap();
367        assert_eq!(committed, store.recover().unwrap());
368
369        let mut value = crate::test_cbor::to_value(&retired).unwrap();
370        let records = value_array_mut(map_field_mut(value_map_mut(&mut value), "records"));
371        let state = map_field_mut(value_map_mut(&mut records[0]), "state");
372        *state = serde_json::from_value::<crate::test_cbor::Value>(
373            serde_json::json!({"Retired": {"unexpected": true}}),
374        )
375        .unwrap();
376        let bytes = crate::test_cbor::to_vec(&value).unwrap();
377        let payload = bytes;
378        let mut physical = SnapshotCommitStore::default();
379        physical.commit_payload_at_generation(2, payload).unwrap();
380        let record = LedgerCommitStore { physical };
381        let encoded = crate::test_cbor::to_vec(&record).unwrap();
382        let decoded = crate::decode_stable_cell_commit_store(&encoded).unwrap();
383        let before = decoded.clone();
384        let error = decoded.recover().unwrap_err();
385        assert!(matches!(error, LedgerCommitError::Codec(_)));
386
387        assert_eq!(decoded, before);
388    }
389
390    fn value_map_mut(
391        value: &mut crate::test_cbor::Value,
392    ) -> &mut Vec<(crate::test_cbor::Value, crate::test_cbor::Value)> {
393        let crate::test_cbor::Value::Map(map) = value else {
394            panic!("expected CBOR map");
395        };
396        map
397    }
398
399    fn map_field_mut<'map>(
400        map: &'map mut [(crate::test_cbor::Value, crate::test_cbor::Value)],
401        field: &str,
402    ) -> &'map mut crate::test_cbor::Value {
403        map.iter_mut()
404            .find(|(key, _)| key == &crate::test_cbor::Value::Text(field.to_string()))
405            .map(|(_, value)| value)
406            .expect("CBOR map field")
407    }
408
409    fn remove_map_field(
410        map: &mut Vec<(crate::test_cbor::Value, crate::test_cbor::Value)>,
411        field: &str,
412    ) {
413        let field = crate::test_cbor::Value::Text(field.to_string());
414        let index = map
415            .iter()
416            .position(|(key, _)| key == &field)
417            .expect("CBOR map field");
418        map.remove(index);
419    }
420
421    fn value_array_mut(value: &mut crate::test_cbor::Value) -> &mut Vec<crate::test_cbor::Value> {
422        let crate::test_cbor::Value::Array(values) = value else {
423            panic!("expected CBOR array");
424        };
425        values
426    }
427
428    fn decode_mutated_ledger(value: crate::test_cbor::Value) -> String {
429        let bytes = crate::test_cbor::to_vec(&value).expect("mutated ledger bytes");
430        decode_ledger(&bytes).expect_err("mutated ledger must fail closed")
431    }
432
433    #[test]
434    fn allocation_record_accessors_expose_read_only_views() {
435        let ledger = AllocationLedger::new(1, vec![active_record("app.users.v1", 100)]).unwrap();
436        assert_eq!(ledger.records().len(), 1);
437        let record = &ledger.records()[0];
438        assert_eq!(record.stable_key().as_str(), "app.users.v1");
439        assert_eq!(record.slot().id(), 100);
440        assert_eq!(record.state(), AllocationState::Active);
441        assert_eq!(record.schema(), &SchemaMetadata::default());
442    }
443
444    #[test]
445    fn ledger_cbor_round_trips_allocation_ledger() {
446        let ledger = committed_ledger(2);
447
448        let encoded = crate::test_cbor::to_vec(&ledger).expect("encode ledger");
449        let decoded = decode_ledger(&encoded).expect("decode ledger");
450
451        assert_eq!(decoded, ledger);
452    }
453
454    #[test]
455    fn ledger_cbor_rejects_trailing_bytes() {
456        let mut encoded = crate::test_cbor::to_vec(&committed_ledger(2)).expect("encode ledger");
457        encoded.push(0);
458
459        let err = decode_ledger(&encoded).expect_err("trailing bytes must fail closed");
460
461        assert!(err.contains("trailing bytes"));
462    }
463
464    #[test]
465    fn cbor_ledger_codec_rejects_unknown_top_level_fields() {
466        use crate::test_cbor::Value;
467
468        let map = vec![
469            (
470                Value::Text("current_generation".to_string()),
471                Value::Integer(0.into()),
472            ),
473            (Value::Text("records".to_string()), Value::Array(vec![])),
474            (Value::Text("future_field".to_string()), Value::Bool(true)),
475        ];
476        let bytes = crate::test_cbor::to_vec(&Value::Map(map)).expect("unknown-field ledger");
477
478        let err = decode_ledger(&bytes).expect_err("unknown ledger field must fail closed");
479
480        assert!(err.contains("future_field"));
481    }
482
483    #[test]
484    fn cbor_ledger_codec_rejects_unknown_nested_record_fields() {
485        let mut value = active_ledger_value();
486        let records = map_field_mut(value_map_mut(&mut value), "records");
487        let record = value_array_mut(records)
488            .first_mut()
489            .expect("allocation record");
490        value_map_mut(record).push((
491            crate::test_cbor::Value::Text("future_record_field".to_string()),
492            crate::test_cbor::Value::Bool(true),
493        ));
494
495        let err = decode_mutated_ledger(value);
496
497        assert!(err.contains("future_record_field"));
498    }
499
500    #[test]
501    fn cbor_ledger_codec_requires_schema_version_field() {
502        let mut value = active_ledger_value();
503        let records = map_field_mut(value_map_mut(&mut value), "records");
504        let record = value_array_mut(records)
505            .first_mut()
506            .expect("allocation record");
507        let schema = map_field_mut(value_map_mut(record), "schema");
508        remove_map_field(value_map_mut(schema), "schema_version");
509
510        let err = decode_mutated_ledger(value);
511
512        assert!(err.contains("schema_version"));
513    }
514
515    #[test]
516    fn ledger_commit_store_rejects_unknown_top_level_fields() {
517        use crate::test_cbor::Value;
518
519        let map = vec![
520            (
521                Value::Text("physical".to_string()),
522                crate::test_cbor::to_value(SnapshotCommitStore::default()).expect("physical value"),
523            ),
524            (Value::Text("future_field".to_string()), Value::Bool(true)),
525        ];
526        let bytes = crate::test_cbor::to_vec(&Value::Map(map)).expect("unknown-field store");
527
528        let err = crate::test_cbor::from_slice::<LedgerCommitStore>(&bytes)
529            .expect_err("unknown store field must fail closed");
530
531        assert!(err.to_string().contains("future_field"));
532    }
533
534    #[test]
535    fn current_empty_genesis_payload_fixture_recovers() {
536        let ledger = ledger_from_payload_fixture(include_str!(
537            "../../fixtures/current/empty_genesis_ledger.cbor.hex"
538        ));
539
540        assert_eq!(ledger.current_generation, 0);
541        assert_eq!(ledger.records(), []);
542    }
543
544    #[test]
545    fn current_single_active_payload_fixture_recovers() {
546        let ledger = ledger_from_payload_fixture(include_str!(
547            "../../fixtures/current/single_active_allocation_ledger.cbor.hex"
548        ));
549        let record = record(&ledger, "app.users.v1");
550
551        assert_eq!(ledger.current_generation, 1);
552        assert_eq!(record.state(), AllocationState::Active);
553        assert_eq!(record.slot().id(), 100);
554    }
555
556    #[test]
557    fn current_reserved_payload_fixture_recovers() {
558        let ledger = ledger_from_payload_fixture(include_str!(
559            "../../fixtures/current/reserved_allocation_ledger.cbor.hex"
560        ));
561        let record = record(&ledger, "app.future_store.v1");
562
563        assert_eq!(ledger.current_generation, 1);
564        assert_eq!(record.state(), AllocationState::Reserved);
565        assert_eq!(record.slot().id(), 101);
566    }
567
568    #[test]
569    fn current_retired_payload_fixture_recovers() {
570        let ledger = ledger_from_payload_fixture(include_str!(
571            "../../fixtures/current/retired_allocation_ledger.cbor.hex"
572        ));
573        let record = record(&ledger, "app.users.v1");
574
575        assert_eq!(ledger.current_generation, 2);
576        assert_eq!(record.state(), AllocationState::Retired);
577    }
578
579    #[test]
580    fn current_memory_manager_slot_fixture_decodes() {
581        let bytes = hex_fixture(include_str!(
582            "../../fixtures/current/memory_manager_slot.cbor.hex"
583        ));
584        let slot: MemoryManagerSlot = crate::test_cbor::from_slice(&bytes).expect("slot fixture");
585
586        assert_eq!(slot.id(), 100);
587        assert_eq!(
588            bytes,
589            crate::test_cbor::to_vec(&slot).expect("re-encoded slot")
590        );
591    }
592
593    #[test]
594    fn current_ledger_commit_store_single_active_fixture_recovers() {
595        let store = store_from_fixture(include_str!(
596            "../../fixtures/current/ledger_commit_store_single_active.cbor.hex"
597        ));
598
599        let recovered = store.recover().expect("fixture store recovers");
600        assert_eq!(recovered.current_generation(), 1);
601        assert_eq!(record(recovered.ledger(), "app.users.v1").slot().id(), 100);
602    }
603
604    #[test]
605    fn current_snapshot_fixture_recovers() {
606        let store = store_from_fixture(include_str!(
607            "../../fixtures/current/snapshot_store_valid.cbor.hex"
608        ));
609
610        assert_eq!(
611            store.physical().diagnostic(),
612            crate::CommitSnapshotDiagnostic::Valid { generation: 2 }
613        );
614        let recovered = store.recover().expect("fixture store recovers");
615        assert_eq!(recovered.current_generation(), 2);
616    }
617
618    #[test]
619    fn current_corrupt_snapshot_fixture_fails_closed() {
620        let store = store_from_fixture(include_str!(
621            "../../fixtures/current/snapshot_store_corrupt.cbor.hex"
622        ));
623
624        let diagnostic = store.physical().diagnostic();
625        assert_eq!(
626            diagnostic,
627            crate::CommitSnapshotDiagnostic::Invalid { generation: 2 }
628        );
629        let err = store
630            .recover()
631            .expect_err("corrupt fixture must fail closed");
632        assert!(matches!(
633            err,
634            LedgerCommitError::Recovery(CommitRecoveryError::InvalidSnapshot { generation: 2 })
635        ));
636    }
637
638    #[test]
639    fn stage_generation_records_new_allocations() {
640        let declarations = vec![declaration("app.users.v1", 100, Some(1))];
641        let snapshot = snapshot_from(declarations);
642
643        let staged = stage(ledger(), &snapshot).expect("staged generation");
644
645        assert_eq!(staged.current_generation, 4);
646        assert_eq!(staged.records().len(), 1);
647    }
648
649    #[test]
650    fn stage_generation_allows_empty_generation_boundary() {
651        let snapshot = snapshot_from(Vec::new());
652
653        let staged = stage(ledger(), &snapshot).expect("empty snapshot generation");
654
655        assert_eq!(staged.current_generation, 4);
656        assert_eq!(staged.records(), []);
657    }
658
659    #[test]
660    fn stage_generation_rejects_same_key_different_slot() {
661        let mut ledger = committed_ledger(3);
662        ledger.records = vec![active_record("app.users.v1", 100)];
663        ledger.validate_integrity().unwrap();
664        let snapshot = snapshot_from(vec![declaration("app.users.v1", 101, None)]);
665
666        let err = stage(ledger, &snapshot).expect_err("stable key cannot move slots");
667
668        assert!(matches!(
669            err,
670            crate::RuntimeBootstrapError::Validation(
671                crate::AllocationValidationError::StableKeySlotConflict { .. }
672            )
673        ));
674    }
675
676    #[test]
677    fn stage_generation_rejects_same_slot_different_key() {
678        let mut ledger = ledger();
679        ledger.records = vec![active_record("app.users.v1", 100)];
680        let snapshot = snapshot_from(vec![declaration("app.orders.v1", 100, None)]);
681
682        let err = stage(ledger, &snapshot).expect_err("slot cannot be reused by another key");
683
684        assert!(matches!(
685            err,
686            crate::RuntimeBootstrapError::Validation(
687                crate::AllocationValidationError::SlotStableKeyConflict { .. }
688            )
689        ));
690    }
691
692    #[test]
693    fn stage_generation_rejects_retired_redeclaration() {
694        let mut ledger = ledger();
695        let mut record = active_record("app.users.v1", 100);
696        record.state = AllocationState::Retired;
697        ledger.records = vec![record];
698        let snapshot = snapshot_from(vec![declaration("app.users.v1", 100, None)]);
699
700        let err = stage(ledger, &snapshot).expect_err("retired allocation cannot be redeclared");
701
702        assert!(matches!(
703            err,
704            crate::RuntimeBootstrapError::Validation(
705                crate::AllocationValidationError::RetiredAllocation { .. }
706            )
707        ));
708    }
709
710    #[test]
711    fn stage_generation_preserves_omitted_records() {
712        let first = snapshot_from(vec![
713            declaration("app.users.v1", 100, Some(1)),
714            declaration("app.orders.v1", 101, Some(1)),
715        ]);
716        let second = snapshot_from(vec![declaration("app.users.v1", 100, Some(1))]);
717
718        let staged = stage(ledger(), &first).expect("first generation");
719        let staged = stage(staged, &second).expect("second generation");
720
721        assert_eq!(staged.current_generation, 5);
722        assert_eq!(staged.records().len(), 2);
723        let omitted = staged
724            .records()
725            .iter()
726            .find(|record| record.stable_key.as_str() == "app.orders.v1")
727            .expect("omitted record");
728        assert_eq!(omitted.state, AllocationState::Active);
729    }
730
731    #[test]
732    fn stage_generation_replaces_current_schema_metadata() {
733        let first = snapshot_from(vec![declaration("app.users.v1", 100, Some(1))]);
734        let second = snapshot_from(vec![declaration("app.users.v1", 100, Some(2))]);
735        let initial = stage(ledger(), &first).unwrap();
736        let updated = stage(initial.clone(), &second).unwrap();
737        assert_eq!(initial.records()[0].schema().schema_version(), Some(1));
738        assert_eq!(updated.records()[0].schema().schema_version(), Some(2));
739        assert_eq!(updated.records().len(), 1);
740        assert_eq!(updated.records()[0].slot(), initial.records()[0].slot());
741    }
742
743    #[test]
744    fn stage_generation_activates_reserved_record() {
745        let staged = AllocationLedger::new(
746            4,
747            vec![crate::testing::allocation_record(
748                &declaration("app.future_store.v1", 100, Some(1)),
749                AllocationState::Reserved,
750            )],
751        )
752        .unwrap();
753        let active = snapshot_from(vec![declaration("app.future_store.v1", 100, Some(2))]);
754
755        let staged = stage(staged, &active).expect("active generation");
756        let record = &staged.records()[0];
757
758        assert_eq!(record.state, AllocationState::Active);
759    }
760
761    #[test]
762    fn transition_matrix_preserves_committed_invariants() {
763        fn visit(ledger: AllocationLedger, depth: usize) {
764            ledger.validate_integrity().unwrap();
765            if depth == 0 {
766                return;
767            }
768            for declarations in [
769                vec![],
770                vec![declaration("app.users.v1", 100, Some(1))],
771                vec![declaration("app.users.v1", 100, Some(2))],
772                vec![declaration("app.orders.v1", 101, None)],
773                vec![declaration("app.other.v1", 100, None)],
774            ] {
775                let next = stage(ledger.clone(), &snapshot_from(declarations))
776                    .unwrap_or_else(|_| ledger.clone());
777                visit(next, depth - 1);
778            }
779        }
780        visit(AllocationLedger::empty_genesis(), 4);
781        for state in [
782            AllocationState::Reserved,
783            AllocationState::Active,
784            AllocationState::Retired,
785        ] {
786            let record = crate::testing::allocation_record(
787                &declaration("app.users.v1", 100, Some(1)),
788                state,
789            );
790            visit(AllocationLedger::new(1, vec![record]).unwrap(), 4);
791        }
792    }
793
794    #[test]
795    fn validate_integrity_rejects_duplicate_stable_keys() {
796        let mut ledger = ledger();
797        for second_id in [101, 100] {
798            ledger.records = vec![
799                active_record("app.users.v1", 100),
800                active_record("app.users.v1", second_id),
801            ];
802
803            let err = ledger
804                .validate_integrity()
805                .expect_err("duplicate key precedes duplicate slot");
806
807            assert_eq!(
808                err,
809                LedgerIntegrityError::DuplicateStableKey {
810                    stable_key: StableKey::parse("app.users.v1").unwrap(),
811                }
812            );
813        }
814    }
815
816    #[test]
817    fn validate_integrity_rejects_duplicate_slots() {
818        let mut ledger = ledger();
819        ledger.records = vec![
820            active_record("app.users.v1", 100),
821            active_record("app.orders.v1", 100),
822        ];
823
824        let err = ledger.validate_integrity().expect_err("duplicate slot");
825
826        assert_eq!(
827            err,
828            LedgerIntegrityError::DuplicateSlot {
829                slot: MemoryManagerSlot::new(100).unwrap(),
830            }
831        );
832    }
833
834    #[test]
835    fn recovery_rejects_invalid_stable_key_during_ledger_decode() {
836        let mut ledger = committed_ledger(1);
837        ledger.records.push(active_record("app.users.v1", 100));
838        let mut bytes = crate::test_cbor::to_vec(&ledger).expect("encode ledger");
839        let key_start = bytes
840            .windows(b"app.users.v1".len())
841            .position(|window| window == b"app.users.v1")
842            .expect("encoded stable key");
843        bytes[key_start] = b'A';
844        assert!(crate::test_cbor::from_slice::<AllocationLedger>(&bytes).is_err());
845        let payload = bytes;
846        let mut physical = SnapshotCommitStore::default();
847        physical.commit_payload_at_generation(1, payload).unwrap();
848        let store = LedgerCommitStore { physical };
849        let before = store.clone();
850        assert!(matches!(store.recover(), Err(LedgerCommitError::Codec(_))));
851        assert_eq!(store, before, "malformed-key recovery must preserve slots");
852    }
853
854    #[test]
855    fn ledger_decode_rejects_unusable_memory_manager_slot() {
856        let mut ledger = committed_ledger(1);
857        ledger.records.push(active_record("app.users.v1", 100));
858        let mut value = serde_json::to_value(ledger).unwrap();
859        value["records"][0]["slot"] = serde_json::json!(255);
860        let bytes = crate::test_cbor::to_vec(&value).unwrap();
861        assert!(decode_ledger(&bytes).is_err());
862    }
863
864    #[test]
865    fn recovery_rejects_zero_schema_version_during_ledger_decode() {
866        let mut ledger = committed_ledger(1);
867        ledger.records.push(AllocationRecord::active(&declaration(
868            "app.users.v1",
869            100,
870            Some(1),
871        )));
872        let mut value = serde_json::to_value(&ledger).unwrap();
873        value["records"][0]["schema"]["schema_version"] = 0.into();
874        let bytes = crate::test_cbor::to_vec(&value).unwrap();
875        assert!(crate::test_cbor::from_slice::<AllocationLedger>(&bytes).is_err());
876        let payload = bytes;
877        let mut physical = SnapshotCommitStore::default();
878        physical.commit_payload_at_generation(1, payload).unwrap();
879        let store = LedgerCommitStore { physical };
880        let before = store.clone();
881        assert!(matches!(store.recover(), Err(LedgerCommitError::Codec(_))));
882        assert_eq!(
883            store, before,
884            "malformed-schema recovery must preserve slots"
885        );
886    }
887
888    #[test]
889    fn integrity_rejects_nonempty_genesis_before_commit() {
890        let invalid = AllocationLedger {
891            current_generation: 0,
892            records: vec![active_record("app.users.v1", 100)],
893        };
894        assert_eq!(
895            invalid.validate_integrity(),
896            Err(LedgerIntegrityError::NonemptyGenesis)
897        );
898        let mut store = LedgerCommitStore::default();
899        let before = store.clone();
900        assert_eq!(
901            store.commit(&invalid),
902            Err(LedgerCommitError::Integrity(
903                LedgerIntegrityError::NonemptyGenesis
904            ))
905        );
906        assert_eq!(store, before);
907    }
908
909    #[test]
910    fn ledger_commit_store_rejects_invalid_ledger_before_write() {
911        let mut store = LedgerCommitStore::default();
912        let mut invalid = ledger();
913        invalid.records = vec![
914            active_record("app.users.v1", 100),
915            active_record("app.orders.v1", 100),
916        ];
917
918        let err = store.commit(&invalid).expect_err("invalid ledger");
919
920        assert!(matches!(
921            err,
922            LedgerCommitError::Integrity(LedgerIntegrityError::DuplicateSlot { .. })
923        ));
924        assert!(store.physical().current().is_none());
925    }
926
927    #[test]
928    fn ledger_commit_store_recovers_latest_committed_ledger() {
929        let mut store = LedgerCommitStore::default();
930        let first = committed_ledger(1);
931        let second = committed_ledger(2);
932
933        store.commit(&first).expect("first commit");
934        store.commit(&second).expect("second commit");
935        let recovered = store.recover().expect("recovered ledger");
936
937        assert_eq!(recovered.current_generation(), 2);
938    }
939
940    #[test]
941    fn ledger_commit_store_encodes_direct_logical_cbor() {
942        let mut store = LedgerCommitStore::default();
943        let ledger = committed_ledger(1);
944
945        store.commit(&ledger).expect("commit");
946
947        let committed = store.physical().authoritative().expect("authoritative");
948        assert_eq!(
949            committed.payload(),
950            crate::test_cbor::to_vec(&ledger).unwrap(),
951            "direct commit encoding matches logical CBOR"
952        );
953    }
954
955    #[test]
956    fn ledger_commit_store_rejects_malformed_logical_cbor() {
957        let committed = CommittedGenerationBytes::new(1, vec![b'X'; 28]);
958        let store = LedgerCommitStore {
959            physical: SnapshotCommitStore {
960                current: Some(committed),
961            },
962        };
963
964        let err = store.recover().expect_err("bad logical CBOR");
965
966        assert!(matches!(err, LedgerCommitError::Codec(_)));
967    }
968
969    #[test]
970    fn recovery_and_initialization_preserve_physical_diagnostics_and_logical_failures() {
971        let genesis = encoded_payload(&committed_ledger(0));
972        let committed = CommittedGenerationBytes::new(0, genesis.clone());
973        let mut corrupt = committed.clone();
974        corrupt.checksum ^= 1;
975        let mut unsupported = committed.clone();
976        unsupported.commit_marker = u64::MAX;
977        let cases = [
978            ("empty", None),
979            ("current", Some(committed)),
980            ("corrupt", Some(corrupt)),
981            ("unsupported format", Some(unsupported)),
982            (
983                "invalid CBOR",
984                Some(CommittedGenerationBytes::new(0, vec![255])),
985            ),
986            (
987                "generation mismatch",
988                Some(CommittedGenerationBytes::new(1, genesis)),
989            ),
990            (
991                "invalid committed integrity",
992                Some(CommittedGenerationBytes::new(3, encoded_payload(&ledger()))),
993            ),
994        ];
995
996        for (label, current) in cases {
997            let empty = current.is_none();
998            let store = LedgerCommitStore {
999                physical: SnapshotCommitStore { current },
1000            };
1001            let before = store.clone();
1002            let (recovered, diagnostic) = store.recover_with_diagnostic();
1003            assert_eq!(recovered, store.recover(), "{label}");
1004            assert_eq!(diagnostic, store.physical().diagnostic(), "{label}");
1005            assert_eq!(store, before, "{label}");
1006
1007            let initializing = store.clone();
1008            let initialized = initializing.recover_or_genesis();
1009            if empty {
1010                let initialized = initialized.expect("empty store accepts genesis");
1011                assert_eq!(initialized.current_generation(), 0);
1012                assert_eq!(initialized.ledger().records(), []);
1013            } else {
1014                assert_eq!(initialized, recovered, "{label}");
1015            }
1016            assert_eq!(initializing, before, "{label}");
1017
1018            if let Err(LedgerCommitError::Recovery(error)) = recovered {
1019                let expected = match error {
1020                    CommitRecoveryError::NoValidGeneration => {
1021                        crate::CommitSnapshotDiagnostic::Empty
1022                    }
1023                    CommitRecoveryError::InvalidSnapshot { generation } => {
1024                        crate::CommitSnapshotDiagnostic::Invalid { generation }
1025                    }
1026                    CommitRecoveryError::UnsupportedFormat { .. } => {
1027                        crate::CommitSnapshotDiagnostic::Invalid {
1028                            generation: before.physical.current().unwrap().generation(),
1029                        }
1030                    }
1031                    _ => panic!("unexpected physical recovery failure: {error}"),
1032                };
1033                assert_eq!(diagnostic, expected, "{label}");
1034            } else {
1035                assert!(
1036                    matches!(diagnostic, crate::CommitSnapshotDiagnostic::Valid { .. }),
1037                    "{label}"
1038                );
1039            }
1040        }
1041    }
1042
1043    #[test]
1044    fn ledger_commit_store_recovers_current_format_genesis_and_first_real_commit() {
1045        let mut store = LedgerCommitStore::default();
1046        let recovered = store
1047            .recover_or_genesis()
1048            .expect("current-format genesis ledger");
1049        assert_eq!(recovered.current_generation(), 0);
1050
1051        assert!(store.physical().current().is_none());
1052        assert!(matches!(
1053            store.recover(),
1054            Err(LedgerCommitError::Recovery(
1055                CommitRecoveryError::NoValidGeneration
1056            ))
1057        ));
1058
1059        let first = stage(
1060            recovered.ledger().clone(),
1061            &snapshot_from(vec![declaration("app.users.v1", 100, Some(1))]),
1062        )
1063        .expect("first real generation");
1064        let recovered = store.commit(&first).expect("first commit");
1065
1066        assert_eq!(recovered, store.recover().unwrap());
1067        assert_eq!(recovered.current_generation(), 1);
1068    }
1069
1070    #[test]
1071    fn ledger_commit_store_rejects_corrupt_current_snapshot() {
1072        let mut store = LedgerCommitStore::default();
1073        let genesis = committed_ledger(0);
1074        store.commit(&genesis).expect("genesis commit");
1075        let first = stage(
1076            genesis,
1077            &snapshot_from(vec![declaration("app.users.v1", 100, Some(1))]),
1078        )
1079        .expect("first generation");
1080        let first = store.commit(&first).expect("first commit");
1081        let second = stage(
1082            first.ledger().clone(),
1083            &snapshot_from(vec![declaration("app.users.v1", 100, Some(2))]),
1084        )
1085        .expect("second generation");
1086
1087        store.commit(&second).expect("second commit");
1088        store
1089            .physical
1090            .current
1091            .as_mut()
1092            .expect("latest slot")
1093            .checksum ^= 1;
1094
1095        let err = store
1096            .recover()
1097            .expect_err("corrupt latest must not roll back");
1098
1099        assert_eq!(
1100            err,
1101            LedgerCommitError::Recovery(CommitRecoveryError::InvalidSnapshot { generation: 2 })
1102        );
1103    }
1104
1105    #[test]
1106    fn ledger_commit_store_rejects_corrupt_current_ledger() {
1107        let mut store = LedgerCommitStore::default();
1108        let first = committed_ledger(1);
1109        let second = committed_ledger(2);
1110
1111        store.commit(&first).expect("first commit");
1112        store.write_corrupt_ledger(&second).expect("corrupt write");
1113        let err = store.recover().expect_err("corrupt slot");
1114
1115        assert_eq!(
1116            err,
1117            LedgerCommitError::Recovery(CommitRecoveryError::InvalidSnapshot { generation: 2 })
1118        );
1119    }
1120
1121    #[test]
1122    fn ledger_commit_store_rejects_physical_logical_generation_mismatch() {
1123        let store = LedgerCommitStore {
1124            physical: SnapshotCommitStore {
1125                current: Some(CommittedGenerationBytes::new(
1126                    7,
1127                    encoded_payload(&committed_ledger(6)),
1128                )),
1129            },
1130        };
1131        let err = store.recover().expect_err("mismatch");
1132
1133        assert_eq!(
1134            err,
1135            LedgerCommitError::PhysicalLogicalGenerationMismatch {
1136                physical_generation: 7,
1137                logical_generation: 6
1138            }
1139        );
1140    }
1141
1142    #[test]
1143    fn ledger_commit_store_rejects_non_next_logical_generation() {
1144        let mut store = LedgerCommitStore::default();
1145        store.commit(&committed_ledger(1)).expect("first commit");
1146
1147        let err = store
1148            .commit(&committed_ledger(3))
1149            .expect_err("skipped generation");
1150
1151        assert_eq!(
1152            err,
1153            LedgerCommitError::Recovery(CommitRecoveryError::UnexpectedGeneration {
1154                expected: 2,
1155                actual: 3
1156            })
1157        );
1158    }
1159
1160    #[test]
1161    fn ledger_commit_store_rejects_corrupt_store_even_with_genesis() {
1162        let mut store = LedgerCommitStore::default();
1163        store
1164            .write_corrupt_ledger(&ledger())
1165            .expect("corrupt write");
1166
1167        let err = store.recover_or_genesis().expect_err("corrupt state");
1168
1169        assert!(matches!(
1170            err,
1171            LedgerCommitError::Recovery(CommitRecoveryError::InvalidSnapshot { generation: 3 })
1172        ));
1173    }
1174
1175    #[test]
1176    fn record_count_boundary_and_malicious_count_reject_on_recovery() {
1177        let mut ledger = committed_ledger(1);
1178        ledger.records = (0..255_u8)
1179            .map(|id| active_record(&format!("app.slot{id}.v1"), id))
1180            .collect();
1181        let mut store = LedgerCommitStore::default();
1182        store.commit(&ledger).unwrap();
1183        assert_eq!(store.recover().unwrap().ledger().records().len(), 255);
1184        ledger.records.push(active_record("app.extra.v1", 254));
1185        assert!(matches!(
1186            store.commit(&ledger),
1187            Err(LedgerCommitError::Integrity(
1188                LedgerIntegrityError::LimitExceeded { .. }
1189            ))
1190        ));
1191        let payload = encoded_payload(&ledger);
1192        let invalid = LedgerCommitStore {
1193            physical: SnapshotCommitStore {
1194                current: Some(CommittedGenerationBytes::new(1, payload)),
1195            },
1196        };
1197        assert!(invalid.recover().is_err());
1198        assert!(invalid.recover_or_genesis().is_err());
1199        for bytes in [
1200            vec![0x9b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff],
1201            vec![0x81; 34],
1202            vec![0xa1],
1203        ] {
1204            let payload = bytes;
1205            let invalid = LedgerCommitStore {
1206                physical: SnapshotCommitStore {
1207                    current: Some(CommittedGenerationBytes::new(1, payload)),
1208                },
1209            };
1210            assert!(matches!(
1211                invalid.recover(),
1212                Err(LedgerCommitError::Codec(_))
1213            ));
1214        }
1215    }
1216
1217    #[test]
1218    fn oversized_logical_text_remains_opaque_to_record_decode_and_fails_closed() {
1219        for len in [257, 8192] {
1220            let mut malformed = serde_json::to_value(active_committed_ledger()).unwrap();
1221            malformed["records"][0]["stable_key"] = "x".repeat(len).into();
1222            let payload = crate::test_cbor::to_vec(&malformed).unwrap();
1223            let mut store = LedgerCommitStore::default();
1224            store
1225                .physical
1226                .commit_payload_at_generation(1, payload)
1227                .unwrap();
1228            let record = store;
1229            let bytes = crate::stable_cell::EncodedLedgerRecord::new(&record)
1230                .unwrap()
1231                .into_bytes();
1232            let decoded = crate::decode_stable_cell_commit_store(&bytes).unwrap();
1233            assert_eq!(decoded, record);
1234            let before = decoded.clone();
1235            assert!(matches!(
1236                decoded.recover(),
1237                Err(LedgerCommitError::Codec(_))
1238            ));
1239            assert!(matches!(
1240                decoded.recover_or_genesis(),
1241                Err(LedgerCommitError::Codec(_))
1242            ));
1243            assert_eq!(decoded, before);
1244        }
1245    }
1246}