1use minco_core::{
2 ConfigurationValueKind, DistributionOperation, DistributionResource, Plugin, PluginDescriptor,
3 PluginDistributionKind, PluginDistributionManifest, PluginManager, PluginSelection,
4 ResourceIntent, WakeSource,
5};
6use serde::Serialize;
7use std::{
8 collections::BTreeSet,
9 path::{Path, PathBuf},
10 sync::Arc,
11};
12
13pub const PLUGIN_CONFORMANCE_PROFILE: &str = "minco-plugin-v1";
14pub const ADAPTER_CONFORMANCE_PROFILE: &str = "minco-adapter-v1";
15pub const RUNTIME_CONFORMANCE_PROFILE: &str = "minco-runtime-v1";
16const MAX_DISTRIBUTION_BYTES: u64 = 1024 * 1024;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
19#[serde(rename_all = "snake_case")]
20pub enum ConformanceStatus {
21 Passed,
22 Failed,
23 NotAssessed,
24 NotRun,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
28pub struct ConformanceAssurance {
29 pub plugin_contract: ConformanceStatus,
30 pub plugin_lifecycle: ConformanceStatus,
31 pub application_readiness: ConformanceStatus,
32 pub provider_live: ConformanceStatus,
33 pub production_readiness: ConformanceStatus,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
37pub struct ConformanceDiagnostic {
38 pub code: String,
39 pub path: String,
40 pub message: String,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
44pub struct PluginConformanceReport {
45 pub profile: String,
46 pub plugin_id: String,
47 pub status: ConformanceStatus,
48 pub assurance: ConformanceAssurance,
49 pub diagnostics: Vec<ConformanceDiagnostic>,
50}
51
52impl PluginConformanceReport {
53 #[must_use]
54 pub const fn is_passed(&self) -> bool {
55 matches!(self.status, ConformanceStatus::Passed)
56 }
57
58 pub fn assert_passed(&self) {
59 assert!(
60 self.is_passed(),
61 "plugin conformance failed: {}",
62 serde_json::to_string_pretty(self).expect("serialize conformance report")
63 );
64 }
65}
66
67pub struct PluginConformance {
68 package_root: PathBuf,
69 target_descriptor: Option<PluginDescriptor>,
70 target_plugin: Option<Arc<dyn Plugin>>,
71 supporting_plugins: Vec<Arc<dyn Plugin>>,
72 configuration: Option<serde_json::Value>,
73}
74
75impl std::fmt::Debug for PluginConformance {
76 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 formatter
78 .debug_struct("PluginConformance")
79 .field("package_root", &self.package_root)
80 .field(
81 "target_plugin",
82 &self
83 .target_descriptor
84 .as_ref()
85 .map(|descriptor| &descriptor.id),
86 )
87 .field("supporting_plugin_count", &self.supporting_plugins.len())
88 .field("configuration_present", &self.configuration.is_some())
89 .finish()
90 }
91}
92
93impl PluginConformance {
94 #[must_use]
95 pub fn for_package(package_root: impl Into<PathBuf>) -> Self {
96 Self {
97 package_root: package_root.into(),
98 target_descriptor: None,
99 target_plugin: None,
100 supporting_plugins: Vec::new(),
101 configuration: None,
102 }
103 }
104
105 #[must_use]
106 pub fn with_descriptor(mut self, descriptor: PluginDescriptor) -> Self {
107 self.target_descriptor = Some(descriptor);
108 self
109 }
110
111 #[must_use]
112 pub fn with_plugin<P>(mut self, plugin: P) -> Self
113 where
114 P: Plugin,
115 {
116 self.target_descriptor = Some(plugin.descriptor());
117 self.target_plugin = Some(Arc::new(plugin));
118 self
119 }
120
121 #[must_use]
122 pub fn with_supporting_plugin<P>(mut self, plugin: P) -> Self
123 where
124 P: Plugin,
125 {
126 self.supporting_plugins.push(Arc::new(plugin));
127 self
128 }
129
130 #[must_use]
131 pub fn with_configuration(mut self, configuration: serde_json::Value) -> Self {
132 self.configuration = Some(configuration);
133 self
134 }
135
136 #[must_use]
137 pub fn run(self) -> PluginConformanceReport {
138 let mut diagnostics = Vec::new();
139 let cargo_path = self.package_root.join("Cargo.toml");
140 let cargo_source = match std::fs::read_to_string(&cargo_path) {
141 Ok(source) => source,
142 Err(error) => {
143 diagnostics.push(diagnostic(
144 "package_manifest_unreadable",
145 "Cargo.toml",
146 format!("cannot read package manifest: {error}"),
147 ));
148 return report("unknown", "unknown", diagnostics);
149 }
150 };
151 let cargo: toml::Value = match toml::from_str(&cargo_source) {
152 Ok(cargo) => cargo,
153 Err(error) => {
154 diagnostics.push(diagnostic(
155 "package_manifest_invalid",
156 "Cargo.toml",
157 format!("package manifest is invalid TOML: {error}"),
158 ));
159 return report("unknown", "unknown", diagnostics);
160 }
161 };
162 let package_name = cargo
163 .get("package")
164 .and_then(|value| value.get("name"))
165 .and_then(toml::Value::as_str)
166 .unwrap_or("unknown");
167 let Some(distribution_file) = cargo
168 .get("package")
169 .and_then(|value| value.get("metadata"))
170 .and_then(|value| value.get("minco"))
171 .and_then(|value| value.get("plugin"))
172 .and_then(toml::Value::as_str)
173 else {
174 diagnostics.push(diagnostic(
175 "distribution_pointer_missing",
176 "package.metadata.minco.plugin",
177 "package metadata must name one distribution record",
178 ));
179 return report("unknown", package_name, diagnostics);
180 };
181
182 let distribution_path = Path::new(distribution_file);
183 if distribution_path.components().count() != 1
184 || distribution_path.file_name().and_then(|name| name.to_str())
185 != Some(distribution_file)
186 || !distribution_path
187 .extension()
188 .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
189 {
190 diagnostics.push(diagnostic(
191 "distribution_pointer_invalid",
192 "package.metadata.minco.plugin",
193 "distribution pointer must be one package-root JSON filename",
194 ));
195 return report("unknown", package_name, diagnostics);
196 }
197
198 let included = cargo
199 .get("package")
200 .and_then(|value| value.get("include"))
201 .and_then(toml::Value::as_array)
202 .is_some_and(|entries| {
203 entries.iter().any(|entry| {
204 entry
205 .as_str()
206 .is_some_and(|entry| package_include_covers(entry, distribution_file))
207 })
208 });
209 if !included {
210 diagnostics.push(diagnostic(
211 "distribution_not_packaged",
212 "package.include",
213 format!("package include list omits {distribution_file}"),
214 ));
215 }
216
217 let distribution_path = self.package_root.join(distribution_path);
218 let distribution_metadata = match std::fs::symlink_metadata(&distribution_path) {
219 Ok(metadata) => metadata,
220 Err(error) => {
221 diagnostics.push(diagnostic(
222 "distribution_unreadable",
223 distribution_file,
224 format!("cannot inspect distribution record: {error}"),
225 ));
226 return report("unknown", package_name, diagnostics);
227 }
228 };
229 if !distribution_metadata.file_type().is_file() {
230 diagnostics.push(diagnostic(
231 "distribution_not_regular_file",
232 distribution_file,
233 "distribution record must be a regular package-root file",
234 ));
235 return report("unknown", package_name, diagnostics);
236 }
237 if distribution_metadata.len() > MAX_DISTRIBUTION_BYTES {
238 diagnostics.push(diagnostic(
239 "distribution_too_large",
240 distribution_file,
241 format!("distribution record exceeds {MAX_DISTRIBUTION_BYTES} bytes"),
242 ));
243 return report("unknown", package_name, diagnostics);
244 }
245 let distribution_source = match std::fs::read_to_string(&distribution_path) {
246 Ok(source) => source,
247 Err(error) => {
248 diagnostics.push(diagnostic(
249 "distribution_unreadable",
250 distribution_file,
251 format!("cannot read distribution record: {error}"),
252 ));
253 return report("unknown", package_name, diagnostics);
254 }
255 };
256 let distribution: PluginDistributionManifest =
257 match serde_json::from_str(&distribution_source) {
258 Ok(distribution) => distribution,
259 Err(error) => {
260 diagnostics.push(diagnostic(
261 "distribution_invalid",
262 distribution_file,
263 format!("distribution record is invalid: {error}"),
264 ));
265 return report("unknown", package_name, diagnostics);
266 }
267 };
268
269 validate_distribution(&distribution, &mut diagnostics);
270 validate_provider_dependencies(&cargo, distribution.kind, &mut diagnostics);
271 validate_package_assets(&self.package_root, &cargo, &distribution, &mut diagnostics);
272 if let Some(descriptor) = &self.target_descriptor {
273 validate_linked_descriptor(&distribution, descriptor, &mut diagnostics);
274 }
275 let contract_status = status_for(&diagnostics);
276 let lifecycle = if contract_status == ConformanceStatus::Passed {
277 self.target_plugin
278 .as_ref()
279 .map_or(ConformanceStatus::NotAssessed, |target| {
280 validate_lifecycle(
281 Arc::clone(target),
282 &self.supporting_plugins,
283 self.configuration.as_ref(),
284 &mut diagnostics,
285 )
286 })
287 } else {
288 ConformanceStatus::NotAssessed
289 };
290 report_with_assurance(
291 &distribution.conformance.profile,
292 distribution.id.as_str(),
293 diagnostics,
294 contract_status,
295 lifecycle,
296 )
297 }
298}
299
300fn validate_provider_dependencies(
301 cargo: &toml::Value,
302 kind: PluginDistributionKind,
303 diagnostics: &mut Vec<ConformanceDiagnostic>,
304) {
305 if kind != PluginDistributionKind::Plugin {
306 return;
307 }
308 for section in ["dependencies", "build-dependencies"] {
309 validate_dependency_table(cargo.get(section), section, diagnostics);
310 }
311 for (target, configuration) in cargo
312 .get("target")
313 .and_then(toml::Value::as_table)
314 .into_iter()
315 .flat_map(|targets| targets.iter())
316 {
317 for section in ["dependencies", "build-dependencies"] {
318 validate_dependency_table(
319 configuration.get(section),
320 &format!("target.{target}.{section}"),
321 diagnostics,
322 );
323 }
324 }
325}
326
327fn validate_dependency_table(
328 dependencies: Option<&toml::Value>,
329 path: &str,
330 diagnostics: &mut Vec<ConformanceDiagnostic>,
331) {
332 for (dependency, specification) in dependencies
333 .and_then(toml::Value::as_table)
334 .into_iter()
335 .flat_map(toml::Table::iter)
336 {
337 let package = specification
338 .as_table()
339 .and_then(|specification| specification.get("package"))
340 .and_then(toml::Value::as_str)
341 .unwrap_or(dependency);
342 if is_provider_runtime_dependency(package) {
343 diagnostics.push(diagnostic(
344 "provider_dependency_leakage",
345 format!("{path}.{dependency}"),
346 format!(
347 "provider-neutral plugins must place {package} in an explicit adapter or runtime crate"
348 ),
349 ));
350 }
351 }
352}
353
354fn validate_package_assets(
355 package_root: &Path,
356 cargo: &toml::Value,
357 distribution: &PluginDistributionManifest,
358 diagnostics: &mut Vec<ConformanceDiagnostic>,
359) {
360 for migration in &distribution.migrations {
361 validate_package_asset(
362 package_root,
363 cargo,
364 "migration",
365 &migration.id,
366 &migration.path,
367 diagnostics,
368 );
369 }
370 for seed in &distribution.seeds {
371 validate_package_asset(
372 package_root,
373 cargo,
374 "seed",
375 &seed.id,
376 &seed.path,
377 diagnostics,
378 );
379 }
380 let features = cargo.get("features").and_then(toml::Value::as_table);
381 for resource in &distribution.resources {
382 if let Some(feature) = &resource.feature
383 && !features.is_some_and(|features| features.contains_key(feature))
384 {
385 diagnostics.push(diagnostic(
386 "resource_feature_unknown",
387 format!("resources.{}.feature", resource.id),
388 format!("resource feature {feature} is absent from Cargo.toml"),
389 ));
390 }
391 }
392}
393
394fn validate_package_asset(
395 package_root: &Path,
396 cargo: &toml::Value,
397 kind: &str,
398 id: &str,
399 relative: &str,
400 diagnostics: &mut Vec<ConformanceDiagnostic>,
401) {
402 let field_path = format!("{kind}s.{id}.path");
403 let relative_path = Path::new(relative);
404 if relative.is_empty()
405 || relative_path.is_absolute()
406 || !relative_path
407 .components()
408 .all(|component| matches!(component, std::path::Component::Normal(_)))
409 {
410 diagnostics.push(diagnostic(
411 format!("{kind}_path_invalid"),
412 field_path,
413 "asset path must be normalized and package-relative",
414 ));
415 return;
416 }
417 let asset = package_root.join(relative_path);
418 if !asset.exists() {
419 diagnostics.push(diagnostic(
420 format!("{kind}_path_missing"),
421 field_path,
422 format!("declared asset {relative} does not exist"),
423 ));
424 return;
425 }
426 let inside_package = std::fs::canonicalize(package_root)
427 .and_then(|root| std::fs::canonicalize(&asset).map(|asset| asset.starts_with(root)))
428 .unwrap_or(false);
429 if !inside_package {
430 diagnostics.push(diagnostic(
431 format!("{kind}_path_escapes_package"),
432 field_path,
433 "declared asset resolves outside the package root",
434 ));
435 return;
436 }
437 let included = cargo
438 .get("package")
439 .and_then(|value| value.get("include"))
440 .and_then(toml::Value::as_array)
441 .is_some_and(|entries| {
442 entries.iter().any(|entry| {
443 entry
444 .as_str()
445 .is_some_and(|entry| package_include_covers(entry, relative))
446 })
447 });
448 if !included {
449 diagnostics.push(diagnostic(
450 format!("{kind}_not_packaged"),
451 field_path,
452 format!("package include list omits {relative}"),
453 ));
454 }
455}
456
457fn package_include_covers(pattern: &str, relative: &str) -> bool {
458 let pattern = pattern.trim_start_matches('/').trim_end_matches('/');
459 let relative = relative.trim_start_matches('/').trim_end_matches('/');
460 if let Some(prefix) = pattern.strip_suffix("/**") {
461 relative == prefix || relative.starts_with(&format!("{prefix}/"))
462 } else {
463 pattern == relative
464 }
465}
466
467fn is_provider_runtime_dependency(name: &str) -> bool {
468 name == "aws-config"
469 || name.starts_with("aws-sdk-")
470 || name.starts_with("lambda-")
471 || name.starts_with("lambda_")
472 || name == "aws-lambda-events"
473 || name == "aws_lambda_events"
474}
475
476fn validate_linked_descriptor(
477 distribution: &PluginDistributionManifest,
478 descriptor: &PluginDescriptor,
479 diagnostics: &mut Vec<ConformanceDiagnostic>,
480) {
481 if distribution.id != descriptor.id {
482 diagnostics.push(diagnostic(
483 "descriptor_id_mismatch",
484 "descriptor.id",
485 format!(
486 "linked descriptor ID {} does not match distribution ID {}",
487 descriptor.id, distribution.id
488 ),
489 ));
490 }
491 if distribution.plugin_version != descriptor.version {
492 diagnostics.push(diagnostic(
493 "descriptor_version_mismatch",
494 "descriptor.version",
495 format!(
496 "linked descriptor version {} does not match distribution version {}",
497 descriptor.version, distribution.plugin_version
498 ),
499 ));
500 }
501 if distribution.core_compatibility != descriptor.core_compatibility {
502 diagnostics.push(diagnostic(
503 "descriptor_core_compatibility_mismatch",
504 "descriptor.core_compatibility",
505 format!(
506 "linked descriptor core compatibility {} does not match distribution {}",
507 descriptor.core_compatibility, distribution.core_compatibility
508 ),
509 ));
510 }
511 if distribution.stability != descriptor.stability {
512 diagnostics.push(diagnostic(
513 "descriptor_stability_mismatch",
514 "descriptor.stability",
515 "linked descriptor stability does not match the distribution record",
516 ));
517 }
518 if distribution.default_enabled != descriptor.default_enabled {
519 diagnostics.push(diagnostic(
520 "descriptor_default_selection_mismatch",
521 "descriptor.default_enabled",
522 "linked descriptor default selection does not match the distribution record",
523 ));
524 }
525 compare_descriptor_field(
526 "plugin_dependencies",
527 &distribution.plugin_dependencies,
528 &descriptor.plugin_dependencies,
529 diagnostics,
530 );
531 compare_descriptor_field(
532 "requires",
533 &distribution.requires,
534 &descriptor.requires,
535 diagnostics,
536 );
537 compare_descriptor_field(
538 "provides",
539 &distribution.provides,
540 &descriptor.provides,
541 diagnostics,
542 );
543 compare_descriptor_field(
544 "configuration",
545 &distribution.configuration,
546 &descriptor.configuration,
547 diagnostics,
548 );
549 compare_descriptor_field(
550 "health_checks",
551 &distribution.health_checks,
552 &descriptor.health_checks,
553 diagnostics,
554 );
555 compare_descriptor_field(
556 "data_classes",
557 &distribution.data_classes,
558 &descriptor.data_classes,
559 diagnostics,
560 );
561 let distribution_operations = distribution
562 .operations
563 .iter()
564 .cloned()
565 .map(|mut operation| {
566 operation.headers.clear();
567 operation
568 })
569 .collect::<Vec<_>>();
570 let descriptor_operations = descriptor
571 .operations
572 .iter()
573 .map(|operation| DistributionOperation {
574 operation_id: operation.operation_id.clone(),
575 method: operation.method.clone(),
576 path: operation.path.clone(),
577 public: operation.public,
578 idempotent: operation.idempotent,
579 headers: Vec::new(),
580 })
581 .collect::<Vec<_>>();
582 compare_descriptor_field(
583 "operations",
584 &distribution_operations,
585 &descriptor_operations,
586 diagnostics,
587 );
588 if descriptor
589 .documentation
590 .as_deref()
591 .is_some_and(|reference| reference != distribution.documentation.reference)
592 {
593 diagnostics.push(diagnostic(
594 "descriptor_documentation_mismatch",
595 "descriptor.documentation",
596 "linked descriptor documentation does not match the distribution reference",
597 ));
598 }
599 for migration in &descriptor.migrations {
600 if !distribution.migrations.contains(migration) {
601 diagnostics.push(diagnostic(
602 "descriptor_migration_missing",
603 format!("descriptor.migrations.{}", migration.id),
604 "linked migration is absent from the distribution union",
605 ));
606 }
607 }
608 for resource in &descriptor.resources {
609 if !distribution
610 .resources
611 .iter()
612 .any(|candidate| resource_matches(candidate, resource))
613 {
614 diagnostics.push(diagnostic(
615 "descriptor_resource_missing",
616 format!("descriptor.resources.{}", resource.id),
617 "linked resource is absent from the distribution union",
618 ));
619 }
620 }
621}
622
623fn compare_descriptor_field<T: PartialEq>(
624 field: &str,
625 distribution: &[T],
626 descriptor: &[T],
627 diagnostics: &mut Vec<ConformanceDiagnostic>,
628) {
629 if distribution != descriptor {
630 diagnostics.push(diagnostic(
631 format!("descriptor_{field}_mismatch"),
632 format!("descriptor.{field}"),
633 format!("linked descriptor {field} do not match the distribution record"),
634 ));
635 }
636}
637
638fn resource_matches(distribution: &DistributionResource, runtime: &ResourceIntent) -> bool {
639 distribution.id == runtime.id
640 && distribution.kind == runtime.kind
641 && distribution.idle_cost == runtime.idle_cost
642 && distribution.wake_sources == runtime.wake_sources
643 && distribution.dependencies == runtime.dependencies
644}
645
646fn validate_lifecycle(
647 target: Arc<dyn Plugin>,
648 supporting_plugins: &[Arc<dyn Plugin>],
649 configuration: Option<&serde_json::Value>,
650 diagnostics: &mut Vec<ConformanceDiagnostic>,
651) -> ConformanceStatus {
652 let target_descriptor = target.descriptor();
653 let target_id = target_descriptor.id.clone();
654 let mut manager = PluginManager::default();
655 for plugin in supporting_plugins {
656 if let Err(error) = manager.register_arc(Arc::clone(plugin)) {
657 diagnostics.push(diagnostic(
658 "supporting_plugin_registration_failed",
659 "lifecycle.registration",
660 format!("supporting plugin registration failed: {error}"),
661 ));
662 return ConformanceStatus::Failed;
663 }
664 }
665 if let Err(error) = manager.register_arc(target) {
666 diagnostics.push(diagnostic(
667 "plugin_registration_failed",
668 "lifecycle.registration",
669 format!("plugin registration failed: {error}"),
670 ));
671 return ConformanceStatus::Failed;
672 }
673 let mut selection = PluginSelection::default();
674 selection.enabled.insert(target_id.clone());
675 if let Some(configuration) = configuration {
676 selection
677 .configuration
678 .insert(target_id, configuration.clone());
679 }
680 match manager.compose(&selection) {
681 Ok(application) => {
682 let first_provenance = match serde_json::to_value(application.registration_provenance())
683 {
684 Ok(provenance) => provenance,
685 Err(error) => {
686 diagnostics.push(diagnostic(
687 "registration_provenance_unserializable",
688 "lifecycle.provenance",
689 format!("registration provenance is not serializable: {error}"),
690 ));
691 return ConformanceStatus::Failed;
692 }
693 };
694 let second_application = match manager.compose(&selection) {
695 Ok(application) => application,
696 Err(error) => {
697 diagnostics.push(diagnostic(
698 "plugin_recomposition_failed",
699 "lifecycle.composition",
700 format!("repeated plugin composition failed: {error}"),
701 ));
702 return ConformanceStatus::Failed;
703 }
704 };
705 let second_provenance =
706 match serde_json::to_value(second_application.registration_provenance()) {
707 Ok(provenance) => provenance,
708 Err(error) => {
709 diagnostics.push(diagnostic(
710 "registration_provenance_unserializable",
711 "lifecycle.provenance",
712 format!("registration provenance is not serializable: {error}"),
713 ));
714 return ConformanceStatus::Failed;
715 }
716 };
717 if first_provenance != second_provenance {
718 diagnostics.push(diagnostic(
719 "registration_provenance_nondeterministic",
720 "lifecycle.provenance",
721 "repeated composition produced different registration provenance",
722 ));
723 return ConformanceStatus::Failed;
724 }
725 if !target_descriptor.configuration.is_empty()
726 && !rejects_unknown_configuration(&manager, &selection, &target_descriptor)
727 {
728 diagnostics.push(diagnostic(
729 "unknown_configuration_accepted",
730 "lifecycle.configuration",
731 "plugin composition accepted a field absent from the descriptor schema",
732 ));
733 ConformanceStatus::Failed
734 } else {
735 ConformanceStatus::Passed
736 }
737 }
738 Err(error) => {
739 diagnostics.push(diagnostic(
740 "plugin_composition_failed",
741 "lifecycle.composition",
742 format!("plugin composition failed: {error}"),
743 ));
744 ConformanceStatus::Failed
745 }
746 }
747}
748
749fn rejects_unknown_configuration(
750 manager: &PluginManager,
751 selection: &PluginSelection,
752 descriptor: &PluginDescriptor,
753) -> bool {
754 let mut probe = selection.clone();
755 let mut supplied = probe
756 .configuration
757 .get(&descriptor.id)
758 .and_then(serde_json::Value::as_object)
759 .cloned()
760 .unwrap_or_default();
761 let mut unknown = "__minco_conformance_unknown".to_owned();
762 while descriptor
763 .configuration
764 .iter()
765 .any(|field| field.key == unknown)
766 {
767 unknown.push('_');
768 }
769 supplied.insert(unknown, serde_json::Value::Bool(true));
770 probe
771 .configuration
772 .insert(descriptor.id.clone(), serde_json::Value::Object(supplied));
773 matches!(
774 manager.build_graph(&probe),
775 Err(minco_core::PluginError::UnknownConfigurationField { .. })
776 )
777}
778
779fn validate_distribution(
780 distribution: &PluginDistributionManifest,
781 diagnostics: &mut Vec<ConformanceDiagnostic>,
782) {
783 if distribution.schema != 1 {
784 diagnostics.push(diagnostic(
785 "distribution_schema_unsupported",
786 "schema",
787 format!("unsupported distribution schema {}", distribution.schema),
788 ));
789 }
790 match minco_core::CORE_API_VERSION.parse() {
791 Ok(current) if !distribution.core_compatibility.matches(¤t) => {
792 diagnostics.push(diagnostic(
793 "core_compatibility_excludes_current",
794 "core_compatibility",
795 format!(
796 "{} excludes current Minco core {}",
797 distribution.core_compatibility,
798 minco_core::CORE_API_VERSION
799 ),
800 ));
801 }
802 Ok(_) => {}
803 Err(error) => diagnostics.push(diagnostic(
804 "current_core_version_invalid",
805 "core_compatibility",
806 format!(
807 "Minco core version {} is invalid: {error}",
808 minco_core::CORE_API_VERSION
809 ),
810 )),
811 }
812 let expected_profile = match distribution.kind {
813 PluginDistributionKind::Plugin => PLUGIN_CONFORMANCE_PROFILE,
814 PluginDistributionKind::Adapter => ADAPTER_CONFORMANCE_PROFILE,
815 PluginDistributionKind::Runtime => RUNTIME_CONFORMANCE_PROFILE,
816 };
817 if distribution.conformance.profile != expected_profile {
818 diagnostics.push(diagnostic(
819 "conformance_profile_mismatch",
820 "conformance.profile",
821 format!(
822 "{} components require conformance profile {expected_profile}",
823 kind_name(distribution.kind)
824 ),
825 ));
826 }
827 if distribution.runtimes.is_empty() {
828 diagnostics.push(diagnostic(
829 "runtime_missing",
830 "runtimes",
831 "at least one supported runtime is required",
832 ));
833 }
834 push_duplicate_strings(
835 "runtime_duplicate",
836 "runtimes",
837 &distribution.runtimes,
838 diagnostics,
839 );
840 push_duplicate_strings(
841 "database_duplicate",
842 "databases",
843 &distribution.databases,
844 diagnostics,
845 );
846 if !distribution.documentation.reference.starts_with("https://") {
847 diagnostics.push(diagnostic(
848 "documentation_reference_insecure",
849 "documentation.reference",
850 "reference documentation must use HTTPS",
851 ));
852 }
853 if distribution.failure_policy.description.trim().is_empty() {
854 diagnostics.push(diagnostic(
855 "failure_policy_undocumented",
856 "failure_policy.description",
857 "failure policy requires a non-empty description",
858 ));
859 }
860 if distribution.conformance.evidence.is_empty()
861 || distribution
862 .conformance
863 .evidence
864 .iter()
865 .any(|item| item.trim().is_empty())
866 {
867 diagnostics.push(diagnostic(
868 "conformance_evidence_missing",
869 "conformance.evidence",
870 "at least one inert conformance evidence label is required",
871 ));
872 }
873 let mut capability_provisions = BTreeSet::new();
874 for provision in &distribution.provides {
875 if !capability_provisions.insert(provision.name.as_str()) {
876 diagnostics.push(diagnostic(
877 "capability_provision_duplicate",
878 format!("provides.{}", provision.name),
879 "provided capability names must be unique",
880 ));
881 }
882 }
883 let mut capability_requirements = BTreeSet::new();
884 for requirement in &distribution.requires {
885 if !capability_requirements.insert(requirement.name.as_str()) {
886 diagnostics.push(diagnostic(
887 "capability_requirement_duplicate",
888 format!("requires.{}", requirement.name),
889 "required capability names must be unique",
890 ));
891 }
892 }
893 let mut configuration_keys = BTreeSet::new();
894 for field in &distribution.configuration {
895 if !configuration_keys.insert(field.key.as_str()) {
896 diagnostics.push(diagnostic(
897 "configuration_key_duplicate",
898 format!("configuration.{}", field.key),
899 "configuration field keys must be unique",
900 ));
901 }
902 if field.secret && field.default.is_some() {
903 diagnostics.push(diagnostic(
904 "secret_configuration_default",
905 format!("configuration.{}.default", field.key),
906 "secret configuration fields must not publish default values",
907 ));
908 }
909 if let Some(default) = &field.default
910 && !configuration_value_matches(field.kind, default)
911 {
912 diagnostics.push(diagnostic(
913 "configuration_default_type_mismatch",
914 format!("configuration.{}.default", field.key),
915 format!("default value must match declared type {:?}", field.kind),
916 ));
917 }
918 }
919 let mut operation_ids = BTreeSet::new();
920 let mut operation_routes = BTreeSet::new();
921 for operation in &distribution.operations {
922 if !operation_ids.insert(operation.operation_id.as_str()) {
923 diagnostics.push(diagnostic(
924 "operation_id_duplicate",
925 format!("operations.{}", operation.operation_id),
926 "operation IDs must be unique",
927 ));
928 }
929 let route = (
930 operation.method.to_ascii_uppercase(),
931 operation.path.as_str(),
932 );
933 if !operation_routes.insert(route) {
934 diagnostics.push(diagnostic(
935 "operation_route_duplicate",
936 format!("operations.{}", operation.operation_id),
937 format!(
938 "route {} {} is declared more than once",
939 operation.method, operation.path
940 ),
941 ));
942 }
943 for header in &operation.headers {
944 if !is_http_token(header) {
945 diagnostics.push(diagnostic(
946 "operation_header_invalid",
947 format!("operations.{}.headers", operation.operation_id),
948 format!("{header:?} is not a valid HTTP field name"),
949 ));
950 }
951 }
952 }
953 let databases = distribution
954 .databases
955 .iter()
956 .map(String::as_str)
957 .collect::<BTreeSet<_>>();
958 let mut migration_ids = BTreeSet::new();
959 for migration in &distribution.migrations {
960 if !migration_ids.insert(migration.id.as_str()) {
961 diagnostics.push(diagnostic(
962 "migration_id_duplicate",
963 format!("migrations.{}", migration.id),
964 "migration set IDs must be unique",
965 ));
966 }
967 if !databases.contains(migration.database.as_str()) {
968 diagnostics.push(diagnostic(
969 "migration_database_undeclared",
970 format!("migrations.{}.database", migration.id),
971 format!("database {} is not declared", migration.database),
972 ));
973 }
974 }
975 let mut seed_ids = BTreeSet::new();
976 for seed in &distribution.seeds {
977 if !seed_ids.insert(seed.id.as_str()) {
978 diagnostics.push(diagnostic(
979 "seed_id_duplicate",
980 format!("seeds.{}", seed.id),
981 "seed IDs must be unique",
982 ));
983 }
984 if !databases.contains(seed.database.as_str()) {
985 diagnostics.push(diagnostic(
986 "seed_database_undeclared",
987 format!("seeds.{}.database", seed.id),
988 format!("database {} is not declared", seed.database),
989 ));
990 }
991 }
992 let resource_ids = distribution
993 .resources
994 .iter()
995 .map(|resource| resource.id.as_str())
996 .collect::<BTreeSet<_>>();
997 if resource_ids.len() != distribution.resources.len() {
998 diagnostics.push(diagnostic(
999 "resource_id_duplicate",
1000 "resources",
1001 "resource IDs must be unique",
1002 ));
1003 }
1004 for resource in &distribution.resources {
1005 for dependency in &resource.dependencies {
1006 if !resource_ids.contains(dependency.as_str()) {
1007 diagnostics.push(diagnostic(
1008 "resource_dependency_unknown",
1009 format!("resources.{}.dependencies", resource.id),
1010 format!("resource dependency {dependency} is not declared"),
1011 ));
1012 }
1013 }
1014 for action in &resource.iam_actions {
1015 if !is_iam_action(action) {
1016 diagnostics.push(diagnostic(
1017 "resource_iam_action_invalid",
1018 format!("resources.{}.iam_actions", resource.id),
1019 format!("{action:?} is not a valid IAM action"),
1020 ));
1021 }
1022 }
1023 for wake_source in &resource.wake_sources {
1024 if matches!(wake_source, WakeSource::Schedule { expression } if expression.trim().is_empty())
1025 {
1026 diagnostics.push(diagnostic(
1027 "schedule_expression_empty",
1028 format!("resources.{}.wake_sources", resource.id),
1029 "scheduled wake sources require a non-empty expression",
1030 ));
1031 }
1032 }
1033 }
1034 let mut health_check_ids = BTreeSet::new();
1035 for check in &distribution.health_checks {
1036 if !health_check_ids.insert(check.id.as_str()) {
1037 diagnostics.push(diagnostic(
1038 "health_check_id_duplicate",
1039 format!("health_checks.{}", check.id),
1040 "health check IDs must be unique",
1041 ));
1042 }
1043 }
1044 let mut data_classes = BTreeSet::new();
1045 for class in &distribution.data_classes {
1046 if !data_classes.insert(*class) {
1047 diagnostics.push(diagnostic(
1048 "data_class_duplicate",
1049 "data_classes",
1050 "data classes must be unique",
1051 ));
1052 }
1053 }
1054}
1055
1056fn push_duplicate_strings(
1057 code: &str,
1058 path: &str,
1059 values: &[String],
1060 diagnostics: &mut Vec<ConformanceDiagnostic>,
1061) {
1062 let mut unique = BTreeSet::new();
1063 for value in values {
1064 if !unique.insert(value.as_str()) {
1065 diagnostics.push(diagnostic(
1066 code,
1067 path,
1068 format!("{value:?} is declared more than once"),
1069 ));
1070 }
1071 }
1072}
1073
1074fn configuration_value_matches(kind: ConfigurationValueKind, value: &serde_json::Value) -> bool {
1075 match kind {
1076 ConfigurationValueKind::String => value.is_string(),
1077 ConfigurationValueKind::Integer => value.as_i64().is_some() || value.as_u64().is_some(),
1078 ConfigurationValueKind::Number => value.is_number(),
1079 ConfigurationValueKind::Boolean => value.is_boolean(),
1080 ConfigurationValueKind::StringList => value
1081 .as_array()
1082 .is_some_and(|values| values.iter().all(serde_json::Value::is_string)),
1083 ConfigurationValueKind::Object => value.is_object(),
1084 }
1085}
1086
1087fn is_http_token(value: &str) -> bool {
1088 !value.is_empty()
1089 && value.bytes().all(|byte| {
1090 byte.is_ascii_alphanumeric()
1091 || matches!(
1092 byte,
1093 b'!' | b'#'
1094 | b'$'
1095 | b'%'
1096 | b'&'
1097 | b'\''
1098 | b'*'
1099 | b'+'
1100 | b'-'
1101 | b'.'
1102 | b'^'
1103 | b'_'
1104 | b'`'
1105 | b'|'
1106 | b'~'
1107 )
1108 })
1109}
1110
1111fn is_iam_action(value: &str) -> bool {
1112 let Some((service, action)) = value.split_once(':') else {
1113 return false;
1114 };
1115 !service.is_empty()
1116 && !action.is_empty()
1117 && !action.contains(':')
1118 && service
1119 .bytes()
1120 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
1121 && action
1122 .bytes()
1123 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'*' | b'-' | b'_'))
1124}
1125
1126const fn kind_name(kind: PluginDistributionKind) -> &'static str {
1127 match kind {
1128 PluginDistributionKind::Plugin => "plugin",
1129 PluginDistributionKind::Adapter => "adapter",
1130 PluginDistributionKind::Runtime => "runtime",
1131 }
1132}
1133
1134fn diagnostic(
1135 code: impl Into<String>,
1136 path: impl Into<String>,
1137 message: impl Into<String>,
1138) -> ConformanceDiagnostic {
1139 ConformanceDiagnostic {
1140 code: code.into(),
1141 path: path.into(),
1142 message: message.into(),
1143 }
1144}
1145
1146fn report(
1147 profile: &str,
1148 plugin_id: &str,
1149 diagnostics: Vec<ConformanceDiagnostic>,
1150) -> PluginConformanceReport {
1151 let plugin_contract = status_for(&diagnostics);
1152 report_with_assurance(
1153 profile,
1154 plugin_id,
1155 diagnostics,
1156 plugin_contract,
1157 ConformanceStatus::NotAssessed,
1158 )
1159}
1160
1161fn report_with_assurance(
1162 profile: &str,
1163 plugin_id: &str,
1164 mut diagnostics: Vec<ConformanceDiagnostic>,
1165 plugin_contract: ConformanceStatus,
1166 plugin_lifecycle: ConformanceStatus,
1167) -> PluginConformanceReport {
1168 diagnostics.sort_by(|left, right| {
1169 (&left.code, &left.path, &left.message).cmp(&(&right.code, &right.path, &right.message))
1170 });
1171 let status = if plugin_contract == ConformanceStatus::Failed
1172 || plugin_lifecycle == ConformanceStatus::Failed
1173 {
1174 ConformanceStatus::Failed
1175 } else {
1176 ConformanceStatus::Passed
1177 };
1178 PluginConformanceReport {
1179 profile: profile.to_owned(),
1180 plugin_id: plugin_id.to_owned(),
1181 status,
1182 assurance: ConformanceAssurance {
1183 plugin_contract,
1184 plugin_lifecycle,
1185 application_readiness: ConformanceStatus::NotAssessed,
1186 provider_live: ConformanceStatus::NotRun,
1187 production_readiness: ConformanceStatus::NotAssessed,
1188 },
1189 diagnostics,
1190 }
1191}
1192
1193const fn status_for(diagnostics: &[ConformanceDiagnostic]) -> ConformanceStatus {
1194 if diagnostics.is_empty() {
1195 ConformanceStatus::Passed
1196 } else {
1197 ConformanceStatus::Failed
1198 }
1199}