Skip to main content

ic_memory/
validation.rs

1use crate::{
2    capability::ValidatedAllocations,
3    declaration::{DeclarationSnapshot, DeclarationSnapshotError},
4    key::StableKey,
5    ledger::{
6        AllocationLedger, ClaimConflict, RecoveredLedger, claim_conflict_record,
7        validate_declaration_claim,
8    },
9    policy::AllocationPolicy,
10    slot::AllocationSlotDescriptor,
11};
12
13///
14/// AllocationValidationError
15///
16/// Failure to validate declarations against policy and historical ledger facts.
17/// Recovered ledger integrity is established before this boundary.
18///
19
20#[non_exhaustive]
21#[derive(Clone, Debug, Eq, thiserror::Error, PartialEq)]
22pub enum AllocationValidationError<P> {
23    /// Declaration snapshot was decoded or assembled with invalid DTOs.
24    #[error(transparent)]
25    Snapshot(DeclarationSnapshotError),
26    /// Policy adapter rejected the declaration.
27    #[error("allocation policy rejected a declaration")]
28    Policy(P),
29    /// Stable key was historically bound to a different slot.
30    #[error("stable key '{stable_key}' was historically bound to a different allocation slot")]
31    StableKeySlotConflict {
32        /// Stable key that was redeclared.
33        stable_key: StableKey,
34        /// Historical slot for the stable key.
35        historical_slot: AllocationSlotDescriptor,
36        /// Slot claimed by the current declaration.
37        declared_slot: AllocationSlotDescriptor,
38    },
39    /// Slot was historically bound to a different stable key.
40    #[error("allocation slot '{slot:?}' was historically bound to stable key '{historical_key}'")]
41    SlotStableKeyConflict {
42        /// Slot claimed by the current declaration.
43        slot: AllocationSlotDescriptor,
44        /// Historical stable key for the slot.
45        historical_key: StableKey,
46        /// Stable key claimed by the current declaration.
47        declared_key: StableKey,
48    },
49    /// Current declaration attempted to revive a retired allocation.
50    #[error("stable key '{stable_key}' was explicitly retired and cannot be redeclared")]
51    RetiredAllocation {
52        /// Retired stable key.
53        stable_key: StableKey,
54        /// Retired allocation slot.
55        slot: AllocationSlotDescriptor,
56    },
57}
58
59/// Validate a committed ledger and current declarations before opening.
60///
61/// This produces a pre-commit [`ValidatedAllocations`] value: the historical
62/// ledger must pass current-format and committed-integrity checks before current
63/// declarations are checked against framework policy and ledger history. The
64/// result can be staged, but it cannot open storage. Open authority is granted
65/// only by [`crate::CommittedAllocations`] after persistence confirmation.
66pub fn validate_allocations<P: AllocationPolicy>(
67    recovered: &RecoveredLedger,
68    snapshot: DeclarationSnapshot,
69    policy: &P,
70) -> Result<ValidatedAllocations, AllocationValidationError<P::Error>> {
71    check_allocations(recovered, &snapshot, policy)?;
72    let (declarations, runtime_fingerprint) = snapshot.into_parts();
73
74    Ok(ValidatedAllocations::new(
75        recovered.current_generation(),
76        declarations,
77        runtime_fingerprint,
78    ))
79}
80
81// Doctor needs the same checks as bootstrap, but does not consume declarations
82// or mint a capability. Keep check ordering and error ownership in one place.
83pub fn check_allocations<P: AllocationPolicy>(
84    recovered: &RecoveredLedger,
85    snapshot: &DeclarationSnapshot,
86    policy: &P,
87) -> Result<(), AllocationValidationError<P::Error>> {
88    let ledger = recovered.ledger();
89
90    snapshot
91        .validate()
92        .map_err(AllocationValidationError::Snapshot)?;
93
94    for declaration in snapshot.declarations() {
95        policy
96            .validate_key(&declaration.stable_key)
97            .map_err(AllocationValidationError::Policy)?;
98        policy
99            .validate_slot(&declaration.stable_key, &declaration.slot)
100            .map_err(AllocationValidationError::Policy)?;
101
102        validate_declaration_history(ledger, declaration)?;
103    }
104
105    Ok(())
106}
107
108fn validate_declaration_history<P>(
109    ledger: &AllocationLedger,
110    declaration: &crate::declaration::AllocationDeclaration,
111) -> Result<(), AllocationValidationError<P>> {
112    validate_declaration_claim(ledger, declaration)
113        .map(|_| ())
114        .map_err(|conflict| map_validation_claim_conflict(ledger, declaration, conflict))
115}
116
117fn map_validation_claim_conflict<P>(
118    ledger: &AllocationLedger,
119    declaration: &crate::declaration::AllocationDeclaration,
120    conflict: ClaimConflict,
121) -> AllocationValidationError<P> {
122    let record = claim_conflict_record(ledger, conflict);
123    match conflict {
124        ClaimConflict::StableKeyMoved { .. } => AllocationValidationError::StableKeySlotConflict {
125            stable_key: declaration.stable_key.clone(),
126            historical_slot: record.slot.clone(),
127            declared_slot: declaration.slot.clone(),
128        },
129        ClaimConflict::SlotReused { .. } => AllocationValidationError::SlotStableKeyConflict {
130            slot: declaration.slot.clone(),
131            historical_key: record.stable_key.clone(),
132            declared_key: declaration.stable_key.clone(),
133        },
134        ClaimConflict::Tombstoned { .. } => AllocationValidationError::RetiredAllocation {
135            stable_key: declaration.stable_key.clone(),
136            slot: record.slot.clone(),
137        },
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::{
145        declaration::AllocationDeclaration,
146        ledger::{AllocationHistory, AllocationRecord, AllocationState, GenerationRecord},
147        schema::SchemaMetadata,
148        slot::AllocationSlotDescriptor,
149    };
150
151    #[derive(Debug, Eq, PartialEq)]
152    struct TestPolicy;
153
154    impl AllocationPolicy for TestPolicy {
155        type Error = &'static str;
156
157        fn validate_key(&self, key: &StableKey) -> Result<(), Self::Error> {
158            if key.as_str().starts_with("bad.") {
159                return Err("bad key");
160            }
161            Ok(())
162        }
163
164        fn validate_slot(
165            &self,
166            _key: &StableKey,
167            slot: &AllocationSlotDescriptor,
168        ) -> Result<(), Self::Error> {
169            if slot
170                == &AllocationSlotDescriptor::memory_manager_unchecked(
171                    crate::MEMORY_MANAGER_INVALID_ID,
172                )
173            {
174                return Err("bad slot");
175            }
176            Ok(())
177        }
178
179        fn validate_reserved_slot(
180            &self,
181            _key: &StableKey,
182            _slot: &AllocationSlotDescriptor,
183        ) -> Result<(), Self::Error> {
184            Ok(())
185        }
186    }
187
188    fn ledger(records: Vec<AllocationRecord>) -> AllocationLedger {
189        let generations = (1..=7)
190            .map(|generation| {
191                GenerationRecord::new(
192                    generation,
193                    if generation == 1 { 0 } else { generation - 1 },
194                    None,
195                    0,
196                    None,
197                )
198                .expect("generation record")
199            })
200            .collect();
201
202        AllocationLedger {
203            current_generation: 7,
204            allocation_history: AllocationHistory::from_parts(records, generations),
205        }
206    }
207
208    fn declaration(key: &str, id: u8) -> AllocationDeclaration {
209        AllocationDeclaration::new(
210            key,
211            AllocationSlotDescriptor::memory_manager(id).expect("usable slot"),
212            None,
213            SchemaMetadata::default(),
214        )
215        .expect("declaration")
216    }
217
218    fn active_record(key: &str, id: u8) -> AllocationRecord {
219        AllocationRecord::active(1, declaration(key, id))
220    }
221
222    fn recovered(records: Vec<AllocationRecord>) -> RecoveredLedger {
223        RecoveredLedger::from_trusted_ledger(ledger(records))
224    }
225
226    #[test]
227    fn accepts_matching_historical_owner() {
228        let snapshot =
229            DeclarationSnapshot::new(vec![declaration("app.users.v1", 100)]).expect("snapshot");
230
231        let validated = validate_allocations(
232            &recovered(vec![active_record("app.users.v1", 100)]),
233            snapshot,
234            &TestPolicy,
235        )
236        .expect("validated");
237
238        assert_eq!(validated.base_generation(), 7);
239    }
240
241    #[test]
242    fn omitted_historical_records_do_not_fail_validation() {
243        let snapshot =
244            DeclarationSnapshot::new(vec![declaration("app.users.v1", 100)]).expect("snapshot");
245
246        validate_allocations(
247            &recovered(vec![
248                active_record("app.users.v1", 100),
249                active_record("app.orders.v1", 101),
250            ]),
251            snapshot,
252            &TestPolicy,
253        )
254        .expect("omitted records are preserved, not retired");
255    }
256
257    #[test]
258    fn rejects_same_key_different_slot() {
259        let snapshot =
260            DeclarationSnapshot::new(vec![declaration("app.users.v1", 101)]).expect("snapshot");
261
262        let err = validate_allocations(
263            &recovered(vec![active_record("app.users.v1", 100)]),
264            snapshot,
265            &TestPolicy,
266        )
267        .expect_err("conflict");
268
269        assert!(matches!(
270            err,
271            AllocationValidationError::StableKeySlotConflict { .. }
272        ));
273    }
274
275    #[test]
276    fn rejects_same_slot_different_key() {
277        let snapshot =
278            DeclarationSnapshot::new(vec![declaration("app.orders.v1", 100)]).expect("snapshot");
279
280        let err = validate_allocations(
281            &recovered(vec![active_record("app.users.v1", 100)]),
282            snapshot,
283            &TestPolicy,
284        )
285        .expect_err("conflict");
286
287        assert!(matches!(
288            err,
289            AllocationValidationError::SlotStableKeyConflict { .. }
290        ));
291    }
292
293    #[test]
294    fn rejects_retired_redeclaration() {
295        let mut record = active_record("app.users.v1", 100);
296        record.state = AllocationState::Retired { generation: 3 };
297        let snapshot =
298            DeclarationSnapshot::new(vec![declaration("app.users.v1", 100)]).expect("snapshot");
299
300        let err = validate_allocations(&recovered(vec![record]), snapshot, &TestPolicy)
301            .expect_err("retired");
302
303        assert!(matches!(
304            err,
305            AllocationValidationError::RetiredAllocation { .. }
306        ));
307    }
308
309    #[test]
310    fn policy_rejections_fail_before_validation_succeeds() {
311        let snapshot =
312            DeclarationSnapshot::new(vec![declaration("bad.users.v1", 100)]).expect("snapshot");
313
314        let err = validate_allocations(&recovered(Vec::new()), snapshot, &TestPolicy)
315            .expect_err("policy failure");
316
317        assert_eq!(err, AllocationValidationError::Policy("bad key"));
318    }
319
320    #[test]
321    fn decoded_snapshot_rejects_invalid_schema_and_count_before_minting_authority() {
322        let recovered = recovered(Vec::new());
323        let source = serde_json::to_value(
324            DeclarationSnapshot::new(vec![declaration("app.users.v1", 100)]).unwrap(),
325        )
326        .unwrap();
327        let mut invalid_schema = source.clone();
328        invalid_schema["declarations"][0]["schema"]["schema_version"] = 0.into();
329        let mut oversized = source;
330        oversized["declarations"] =
331            serde_json::Value::Array(vec![oversized["declarations"][0].clone(); 256]);
332
333        for (value, expected) in [
334            (
335                invalid_schema,
336                DeclarationSnapshotError::SchemaMetadata(
337                    crate::SchemaMetadataError::InvalidVersion,
338                ),
339            ),
340            (oversized, DeclarationSnapshotError::TooManyDeclarations),
341        ] {
342            let snapshot: DeclarationSnapshot = serde_json::from_value(value).unwrap();
343            assert_eq!(
344                validate_allocations(&recovered, snapshot, &TestPolicy),
345                Err(AllocationValidationError::Snapshot(expected))
346            );
347        }
348    }
349
350    #[test]
351    fn full_slot_domain_validates_stages_and_commits_through_public_boundaries() {
352        let mut store = crate::LedgerCommitStore::default();
353        let genesis = AllocationLedger::new(0, AllocationHistory::default()).unwrap();
354        let recovered = store.recover_or_initialize(&genesis).unwrap();
355        let snapshot = DeclarationSnapshot::new(
356            (0..=crate::MEMORY_MANAGER_MAX_ID)
357                .map(|id| declaration(&format!("app.store{id}.v1"), id))
358                .collect(),
359        )
360        .unwrap();
361        let validated = validate_allocations(&recovered, snapshot, &TestPolicy).unwrap();
362        let staged = recovered
363            .ledger()
364            .stage_validated_generation(&validated, None)
365            .unwrap();
366        assert_eq!(staged.allocation_history().records().len(), 255);
367        assert_eq!(
368            staged.allocation_history().generations()[0].declaration_count(),
369            255
370        );
371        let committed = store.commit(&staged).unwrap();
372        assert_eq!(committed.current_generation(), 1);
373        assert_eq!(store.recover().unwrap(), committed);
374    }
375}