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::MemoryManagerSlot,
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: MemoryManagerSlot,
33        /// Slot claimed by the current declaration.
34        declared_slot: MemoryManagerSlot,
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: MemoryManagerSlot,
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: MemoryManagerSlot,
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::MemoryManagerSlot,
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: &MemoryManagerSlot,
165        ) -> Result<(), Self::Error> {
166            Ok(())
167        }
168
169        fn validate_reserved_slot(
170            &self,
171            _key: &StableKey,
172            _slot: &MemoryManagerSlot,
173        ) -> Result<(), Self::Error> {
174            Ok(())
175        }
176    }
177
178    fn ledger(records: Vec<AllocationRecord>) -> AllocationLedger {
179        let generations = (1..=7)
180            .map(|generation| {
181                GenerationRecord::new(
182                    generation,
183                    if generation == 1 { 0 } else { generation - 1 },
184                    None,
185                    0,
186                    None,
187                )
188                .expect("generation record")
189            })
190            .collect();
191
192        AllocationLedger {
193            current_generation: 7,
194            allocation_history: AllocationHistory::from_parts(records, generations),
195        }
196    }
197
198    fn declaration(key: &str, id: u8) -> AllocationDeclaration {
199        AllocationDeclaration::new(
200            key,
201            MemoryManagerSlot::new(id).expect("usable slot"),
202            None,
203            SchemaMetadata::default(),
204        )
205        .expect("declaration")
206    }
207
208    fn active_record(key: &str, id: u8) -> AllocationRecord {
209        AllocationRecord::active(1, &declaration(key, id))
210    }
211
212    fn recovered(records: Vec<AllocationRecord>) -> RecoveredLedger {
213        RecoveredLedger::from_trusted_ledger(ledger(records))
214    }
215
216    #[test]
217    fn accepts_matching_historical_owner() {
218        let snapshot =
219            DeclarationSnapshot::new(vec![declaration("app.users.v1", 100)]).expect("snapshot");
220
221        let validated = validate_allocations(
222            &recovered(vec![active_record("app.users.v1", 100)]),
223            snapshot,
224            &TestPolicy,
225        )
226        .expect("validated");
227
228        assert_eq!(validated.base_generation(), 7);
229    }
230
231    #[test]
232    fn omitted_historical_records_do_not_fail_validation() {
233        let snapshot =
234            DeclarationSnapshot::new(vec![declaration("app.users.v1", 100)]).expect("snapshot");
235
236        validate_allocations(
237            &recovered(vec![
238                active_record("app.users.v1", 100),
239                active_record("app.orders.v1", 101),
240            ]),
241            snapshot,
242            &TestPolicy,
243        )
244        .expect("omitted records are preserved, not retired");
245    }
246
247    #[test]
248    fn rejects_same_key_different_slot() {
249        let snapshot =
250            DeclarationSnapshot::new(vec![declaration("app.users.v1", 101)]).expect("snapshot");
251
252        let err = validate_allocations(
253            &recovered(vec![active_record("app.users.v1", 100)]),
254            snapshot,
255            &TestPolicy,
256        )
257        .expect_err("conflict");
258
259        assert!(matches!(
260            err,
261            AllocationValidationError::StableKeySlotConflict { .. }
262        ));
263    }
264
265    #[test]
266    fn rejects_same_slot_different_key() {
267        let snapshot =
268            DeclarationSnapshot::new(vec![declaration("app.orders.v1", 100)]).expect("snapshot");
269
270        let err = validate_allocations(
271            &recovered(vec![active_record("app.users.v1", 100)]),
272            snapshot,
273            &TestPolicy,
274        )
275        .expect_err("conflict");
276
277        assert!(matches!(
278            err,
279            AllocationValidationError::SlotStableKeyConflict { .. }
280        ));
281    }
282
283    #[test]
284    fn rejects_retired_redeclaration() {
285        let mut record = active_record("app.users.v1", 100);
286        record.state = AllocationState::Retired { generation: 3 };
287        let snapshot =
288            DeclarationSnapshot::new(vec![declaration("app.users.v1", 100)]).expect("snapshot");
289
290        let err = validate_allocations(&recovered(vec![record]), snapshot, &TestPolicy)
291            .expect_err("retired");
292
293        assert!(matches!(
294            err,
295            AllocationValidationError::RetiredAllocation { .. }
296        ));
297    }
298
299    #[test]
300    fn policy_rejections_fail_before_validation_succeeds() {
301        let snapshot =
302            DeclarationSnapshot::new(vec![declaration("bad.users.v1", 100)]).expect("snapshot");
303
304        let err = validate_allocations(&recovered(Vec::new()), snapshot, &TestPolicy)
305            .expect_err("policy failure");
306
307        assert_eq!(err, AllocationValidationError::Policy("bad key"));
308    }
309
310    #[test]
311    fn decoded_snapshot_rejects_invalid_schema_and_count_before_minting_authority() {
312        let recovered = recovered(Vec::new());
313        let source = serde_json::to_value(
314            DeclarationSnapshot::new(vec![declaration("app.users.v1", 100)]).unwrap(),
315        )
316        .unwrap();
317        let mut invalid_schema = source.clone();
318        invalid_schema["declarations"][0]["schema"]["schema_version"] = 0.into();
319        let mut oversized = source;
320        oversized["declarations"] =
321            serde_json::Value::Array(vec![oversized["declarations"][0].clone(); 256]);
322
323        for (value, expected) in [
324            (
325                invalid_schema,
326                DeclarationSnapshotError::SchemaMetadata(
327                    crate::SchemaMetadataError::InvalidVersion,
328                ),
329            ),
330            (oversized, DeclarationSnapshotError::TooManyDeclarations),
331        ] {
332            let snapshot: DeclarationSnapshot = serde_json::from_value(value).unwrap();
333            assert_eq!(
334                validate_allocations(&recovered, snapshot, &TestPolicy),
335                Err(AllocationValidationError::Snapshot(expected))
336            );
337        }
338    }
339
340    #[test]
341    fn full_slot_domain_validates_stages_and_commits_through_public_boundaries() {
342        let mut store = crate::LedgerCommitStore::default();
343        let genesis = AllocationLedger::new(0, AllocationHistory::default()).unwrap();
344        let recovered = store.recover_or_initialize(&genesis).unwrap();
345        let snapshot = DeclarationSnapshot::new(
346            (0..=crate::MEMORY_MANAGER_MAX_ID)
347                .map(|id| declaration(&format!("app.store{id}.v1"), id))
348                .collect(),
349        )
350        .unwrap();
351        let validated = validate_allocations(&recovered, snapshot, &TestPolicy).unwrap();
352        let staged = recovered
353            .ledger()
354            .stage_validated_generation(&validated, None)
355            .unwrap();
356        assert_eq!(staged.allocation_history().records().len(), 255);
357        assert_eq!(
358            staged.allocation_history().generations()[0].declaration_count(),
359            255
360        );
361        let committed = store.commit(&staged).unwrap();
362        assert_eq!(committed.current_generation(), 1);
363        assert_eq!(store.recover().unwrap(), committed);
364    }
365}