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}