Skip to main content

ic_memory/
validation.rs

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