Skip to main content

coil_core/
validation.rs

1use super::*;
2
3#[derive(Debug, Error, PartialEq, Eq)]
4pub enum CapabilityValidationError {
5    #[error(
6        "module `{module}` requires capability `{capability}` but the active auth package does not bind it"
7    )]
8    MissingCapability {
9        module: String,
10        capability: Capability,
11    },
12    #[error("module `{module}` does not declare a capability contract for `{capability}`")]
13    MissingCapabilityContract {
14        module: String,
15        capability: Capability,
16    },
17    #[error(
18        "module `{module}` declares capability `{capability}` as {actual} but {expected} was required"
19    )]
20    CapabilityContractRoleMismatch {
21        module: String,
22        capability: Capability,
23        expected: &'static str,
24        actual: &'static str,
25    },
26    #[error("module `{module}` declares capability `{capability}` without any resource kinds")]
27    EmptyCapabilityResourceKinds {
28        module: String,
29        capability: Capability,
30    },
31    #[error(
32        "module `{module}` declares a capability contract for `{capability}` without listing it as required or optional"
33    )]
34    UndeclaredCapabilityContract {
35        module: String,
36        capability: Capability,
37    },
38    #[error("module `{module}` declares duplicate job `{job}`")]
39    DuplicateModuleJob { module: String, job: String },
40    #[error("module `{module}` subscribes to `{event}` with unknown job `{job}`")]
41    UnknownSubscriptionJob {
42        module: String,
43        event: String,
44        job: String,
45    },
46    #[error("module `{module}` declares duplicate {kind} `{id}`")]
47    DuplicateOperationalContribution {
48        module: String,
49        kind: &'static str,
50        id: String,
51    },
52    #[error("module `{module}` has invalid {kind} `{id}`: {reason}")]
53    InvalidOperationalContribution {
54        module: String,
55        kind: &'static str,
56        id: String,
57        reason: String,
58    },
59    #[error("module `{module}` declares duplicate extension slot `{kind:?}` on `{surface}`")]
60    DuplicateExtensionSlot {
61        module: String,
62        kind: ExtensionSlotKind,
63        surface: String,
64    },
65    #[error("module `{module}` has invalid extension slot `{kind:?}` on `{surface}`: {reason}")]
66    InvalidExtensionSlot {
67        module: String,
68        kind: ExtensionSlotKind,
69        surface: String,
70        reason: String,
71    },
72}
73
74#[derive(Debug, Error, PartialEq, Eq)]
75pub enum ModuleInstallationError {
76    #[error("module `{module}` requires module dependency `{dependency}`")]
77    MissingModuleDependency { module: String, dependency: String },
78    #[error(
79        "module `{module}` requires core dependency `{dependency:?}` but service `{service_id}` is not available"
80    )]
81    MissingCoreServiceDependency {
82        module: String,
83        dependency: CoreServiceDependency,
84        service_id: String,
85    },
86}
87
88pub fn validate_module_capabilities<P>(
89    package: &P,
90    manifest: &ModuleManifest,
91) -> Result<(), CapabilityValidationError>
92where
93    P: AuthModelPackage,
94{
95    for capability in &manifest.required_capabilities {
96        if package.binding_for(*capability).is_none() {
97            return Err(CapabilityValidationError::MissingCapability {
98                module: manifest.name.clone(),
99                capability: *capability,
100            });
101        }
102
103        validate_capability_contract(manifest, *capability, true)?;
104    }
105
106    for capability in &manifest.optional_capabilities {
107        validate_capability_contract(manifest, *capability, false)?;
108    }
109
110    for contract in &manifest.capability_contracts {
111        let declared = manifest
112            .required_capabilities
113            .contains(&contract.capability)
114            || manifest
115                .optional_capabilities
116                .contains(&contract.capability);
117        if !declared {
118            return Err(CapabilityValidationError::UndeclaredCapabilityContract {
119                module: manifest.name.clone(),
120                capability: contract.capability,
121            });
122        }
123    }
124
125    let mut seen_jobs = std::collections::BTreeSet::new();
126    for job in &manifest.jobs {
127        if !seen_jobs.insert(job.name.clone()) {
128            return Err(CapabilityValidationError::DuplicateModuleJob {
129                module: manifest.name.clone(),
130                job: job.name.clone(),
131            });
132        }
133    }
134
135    for subscription in &manifest.event_subscriptions {
136        if let Some(job) = &subscription.job {
137            if !manifest.jobs.iter().any(|declared| declared.name == *job) {
138                return Err(CapabilityValidationError::UnknownSubscriptionJob {
139                    module: manifest.name.clone(),
140                    event: subscription.event.clone(),
141                    job: job.clone(),
142                });
143            }
144        }
145    }
146
147    let mut seen_search = BTreeSet::new();
148    for contribution in &manifest.search_contributions {
149        if !seen_search.insert(contribution.id.clone()) {
150            return Err(
151                CapabilityValidationError::DuplicateOperationalContribution {
152                    module: manifest.name.clone(),
153                    kind: "search contribution",
154                    id: contribution.id.clone(),
155                },
156            );
157        }
158
159        if contribution.fields.is_empty() {
160            return Err(CapabilityValidationError::InvalidOperationalContribution {
161                module: manifest.name.clone(),
162                kind: "search contribution",
163                id: contribution.id.clone(),
164                reason: "at least one indexed field is required".to_string(),
165            });
166        }
167
168        if matches!(contribution.visibility, SearchVisibility::Public)
169            && !contribution.publication_required
170        {
171            return Err(CapabilityValidationError::InvalidOperationalContribution {
172                module: manifest.name.clone(),
173                kind: "search contribution",
174                id: contribution.id.clone(),
175                reason: "public search indexes must require publication state".to_string(),
176            });
177        }
178
179        if let SearchVisibility::Capability(capability) = contribution.visibility {
180            validate_declared_capability(manifest, capability)?;
181        }
182    }
183
184    let mut seen_reports = BTreeSet::new();
185    for definition in &manifest.report_definitions {
186        if !seen_reports.insert(definition.id.clone()) {
187            return Err(
188                CapabilityValidationError::DuplicateOperationalContribution {
189                    module: manifest.name.clone(),
190                    kind: "report definition",
191                    id: definition.id.clone(),
192                },
193            );
194        }
195
196        validate_declared_capability(manifest, definition.required_capability)?;
197    }
198
199    let mut seen_bulk = BTreeSet::new();
200    for definition in &manifest.bulk_operations {
201        if !seen_bulk.insert(definition.id.clone()) {
202            return Err(
203                CapabilityValidationError::DuplicateOperationalContribution {
204                    module: manifest.name.clone(),
205                    kind: "bulk operation",
206                    id: definition.id.clone(),
207                },
208            );
209        }
210
211        validate_declared_capability(manifest, definition.required_capability)?;
212    }
213
214    let mut seen_extension_slots = BTreeSet::new();
215    for slot in &manifest.extension_slots {
216        if slot.surface.trim().is_empty() {
217            return Err(CapabilityValidationError::InvalidExtensionSlot {
218                module: manifest.name.clone(),
219                kind: slot.kind,
220                surface: slot.surface.clone(),
221                reason: "surface must not be empty".to_string(),
222            });
223        }
224
225        if slot.description.trim().is_empty() {
226            return Err(CapabilityValidationError::InvalidExtensionSlot {
227                module: manifest.name.clone(),
228                kind: slot.kind,
229                surface: slot.surface.clone(),
230                reason: "description must not be empty".to_string(),
231            });
232        }
233
234        if !seen_extension_slots.insert((slot.kind, slot.surface.clone())) {
235            return Err(CapabilityValidationError::DuplicateExtensionSlot {
236                module: manifest.name.clone(),
237                kind: slot.kind,
238                surface: slot.surface.clone(),
239            });
240        }
241    }
242
243    Ok(())
244}
245
246pub fn validate_module_installation(
247    manifest: &ModuleManifest,
248    installed_modules: &[String],
249    core_service_ids: &[&str],
250) -> Result<(), ModuleInstallationError> {
251    for dependency in &manifest.module_dependencies {
252        if dependency.kind == ModuleDependencyKind::Required
253            && !installed_modules.contains(&dependency.module)
254        {
255            return Err(ModuleInstallationError::MissingModuleDependency {
256                module: manifest.name.clone(),
257                dependency: dependency.module.clone(),
258            });
259        }
260    }
261
262    for dependency in &manifest.core_service_dependencies {
263        for service_id in dependency.required_service_ids() {
264            if !core_service_ids.contains(service_id) {
265                return Err(ModuleInstallationError::MissingCoreServiceDependency {
266                    module: manifest.name.clone(),
267                    dependency: *dependency,
268                    service_id: (*service_id).to_string(),
269                });
270            }
271        }
272    }
273
274    Ok(())
275}
276
277fn validate_capability_contract(
278    manifest: &ModuleManifest,
279    capability: Capability,
280    required: bool,
281) -> Result<(), CapabilityValidationError> {
282    let Some(contract) = manifest
283        .capability_contracts
284        .iter()
285        .find(|contract| contract.capability == capability)
286    else {
287        return Err(CapabilityValidationError::MissingCapabilityContract {
288            module: manifest.name.clone(),
289            capability,
290        });
291    };
292
293    if contract.required != required {
294        return Err(CapabilityValidationError::CapabilityContractRoleMismatch {
295            module: manifest.name.clone(),
296            capability,
297            expected: if required { "required" } else { "optional" },
298            actual: if contract.required {
299                "required"
300            } else {
301                "optional"
302            },
303        });
304    }
305
306    if contract.resource_kinds.is_empty() {
307        return Err(CapabilityValidationError::EmptyCapabilityResourceKinds {
308            module: manifest.name.clone(),
309            capability,
310        });
311    }
312
313    Ok(())
314}
315
316fn validate_declared_capability(
317    manifest: &ModuleManifest,
318    capability: Capability,
319) -> Result<(), CapabilityValidationError> {
320    if package_capability_is_required(manifest, capability) {
321        validate_capability_contract(manifest, capability, true)
322    } else if manifest.optional_capabilities.contains(&capability) {
323        validate_capability_contract(manifest, capability, false)
324    } else {
325        Err(CapabilityValidationError::UndeclaredCapabilityContract {
326            module: manifest.name.clone(),
327            capability,
328        })
329    }
330}
331
332fn package_capability_is_required(manifest: &ModuleManifest, capability: Capability) -> bool {
333    manifest.required_capabilities.contains(&capability)
334}