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