Skip to main content

ic_memory/
validation.rs

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