1use std::collections::HashSet;
2
3use player_plugin::{
4 PLUGIN_CATALOG_MIGRATION_VERSION, PluginCatalogError, PluginProvision, PluginReference,
5 PluginRequirement, PluginTransport, validate_plugin_provisions, validate_plugin_requirements,
6};
7use player_plugin_abi::{
8 VESPER_MAX_CAPABILITY_INSTANCE_ID_BYTES, VESPER_PLUGIN_ABI_MAJOR, VESPER_PLUGIN_ABI_MINOR,
9};
10use semver::{Version, VersionReq};
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use thiserror::Error;
14use uuid::Uuid;
15
16const PLUGIN_DESCRIPTOR_SCHEMA_VERSION: u32 = 1;
17const MAX_PLUGIN_NAME_BYTES: usize = 128;
18const MAX_PLUGIN_DESCRIPTION_BYTES: usize = 1024;
19const MAX_LICENSE_BYTES: usize = 128;
20const MAX_HOST_SDK_REQUIREMENT_BYTES: usize = 128;
21const MAX_CAPABILITIES: usize = 64;
22const MAX_REDISTRIBUTION_ENTRIES: usize = 64;
23const MAX_REDISTRIBUTION_COMPONENT_BYTES: usize = 128;
24const MAX_REDISTRIBUTION_VALUE_BYTES: usize = 512;
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(deny_unknown_fields)]
32pub struct PluginDescriptor {
33 pub schema_version: u32,
34 pub plugin: PluginIdentityDescriptor,
35 pub compatibility: PluginCompatibilityDescriptor,
36 pub capabilities: Vec<PluginCapabilityDescriptor>,
37 #[serde(default)]
38 pub requires: Vec<PluginRequirement>,
39 #[serde(default)]
40 pub provides: Vec<PluginProvision>,
41 #[serde(default)]
42 pub redistribution: Vec<PluginRedistributionDescriptor>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct PluginIdentityDescriptor {
48 pub id: String,
49 pub name: String,
50 pub version: String,
51 pub description: String,
52 pub license: String,
53 pub publisher: String,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct PluginCompatibilityDescriptor {
59 pub host_sdk: String,
60 pub abi_major: u16,
61 pub abi_minor_min: u16,
62 pub abi_minor_max: u16,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(deny_unknown_fields)]
67pub struct PluginCapabilityDescriptor {
68 pub interface_id: String,
69 pub instance_id: String,
70 pub interface_major: u16,
71 pub interface_minor: u16,
72 pub stability: PluginStability,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "lowercase")]
77pub enum PluginStability {
78 Stable,
79 Experimental,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct PluginRedistributionDescriptor {
85 pub component: String,
86 pub license: String,
87 pub notice: String,
88 pub source: String,
89 pub build_configuration: String,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub relinking_materials: Option<String>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub profile_hash: Option<String>,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct CanonicalPluginDescriptor {
98 descriptor: PluginDescriptor,
99 json: Vec<u8>,
100 sha256: String,
101}
102
103impl CanonicalPluginDescriptor {
104 pub fn descriptor(&self) -> &PluginDescriptor {
105 &self.descriptor
106 }
107
108 pub fn json(&self) -> &[u8] {
109 &self.json
110 }
111
112 pub fn sha256(&self) -> &str {
113 &self.sha256
114 }
115}
116
117#[derive(Debug, Error)]
118pub enum PluginDescriptorError {
119 #[error("invalid vesper-plugin.toml: {0}")]
120 Toml(#[from] toml::de::Error),
121 #[error("invalid plugin descriptor field `{field}`: {message}")]
122 InvalidField { field: String, message: String },
123 #[error("duplicate plugin capability `{interface_id}:{instance_id}`")]
124 DuplicateCapability {
125 interface_id: String,
126 instance_id: String,
127 },
128 #[error(transparent)]
129 Catalog(#[from] PluginCatalogError),
130 #[error("failed to serialize canonical plugin descriptor: {0}")]
131 Json(#[from] serde_json::Error),
132}
133
134#[derive(Debug, Error, Clone, PartialEq, Eq)]
135pub enum PluginCompatibilityError {
136 #[error(
137 "plugin `{plugin_id}` does not support host SDK {actual}; required {required}; migration entry: {migration_version}"
138 )]
139 HostSdkMismatch {
140 plugin_id: String,
141 migration_version: String,
142 required: String,
143 actual: Version,
144 },
145 #[error(
146 "plugin `{plugin_id}` requires ABI major {required}, but the host provides ABI major {actual}; migration entry: {migration_version}"
147 )]
148 AbiMajorMismatch {
149 plugin_id: String,
150 migration_version: String,
151 required: u16,
152 actual: u16,
153 },
154 #[error(
155 "plugin `{plugin_id}` requires ABI minor {minimum}..={maximum}, but the host provides ABI minor {actual}; migration entry: {migration_version}"
156 )]
157 AbiMinorMismatch {
158 plugin_id: String,
159 migration_version: String,
160 minimum: u16,
161 maximum: u16,
162 actual: u16,
163 },
164}
165
166impl PluginDescriptor {
167 pub fn from_toml(source: &str) -> Result<Self, PluginDescriptorError> {
168 let descriptor: Self = toml::from_str(source)?;
169 descriptor.validate()?;
170 Ok(descriptor)
171 }
172
173 pub fn canonicalize(&self) -> Result<CanonicalPluginDescriptor, PluginDescriptorError> {
174 self.validate()?;
175 let mut descriptor = self.clone();
176 descriptor.capabilities.sort_by(|left, right| {
177 (&left.interface_id, &left.instance_id).cmp(&(&right.interface_id, &right.instance_id))
178 });
179 descriptor.requires.sort();
180 descriptor.provides.sort();
181 descriptor.redistribution.sort_by(|left, right| {
182 (&left.component, &left.license).cmp(&(&right.component, &right.license))
183 });
184 let json = serde_json::to_vec(&descriptor)?;
185 let sha256 = hex::encode(Sha256::digest(&json));
186 Ok(CanonicalPluginDescriptor {
187 descriptor,
188 json,
189 sha256,
190 })
191 }
192
193 pub fn evaluate_host_compatibility(
195 &self,
196 host_sdk: &Version,
197 host_abi_major: u16,
198 host_abi_minor: u16,
199 ) -> Result<(), PluginCompatibilityError> {
200 let requirement = VersionReq::parse(&self.compatibility.host_sdk).map_err(|_| {
201 PluginCompatibilityError::HostSdkMismatch {
202 plugin_id: self.plugin.id.clone(),
203 migration_version: PLUGIN_CATALOG_MIGRATION_VERSION.to_owned(),
204 required: self.compatibility.host_sdk.clone(),
205 actual: host_sdk.clone(),
206 }
207 })?;
208 if !requirement.matches(host_sdk) {
209 return Err(PluginCompatibilityError::HostSdkMismatch {
210 plugin_id: self.plugin.id.clone(),
211 migration_version: PLUGIN_CATALOG_MIGRATION_VERSION.to_owned(),
212 required: self.compatibility.host_sdk.clone(),
213 actual: host_sdk.clone(),
214 });
215 }
216 if self.compatibility.abi_major != host_abi_major {
217 return Err(PluginCompatibilityError::AbiMajorMismatch {
218 plugin_id: self.plugin.id.clone(),
219 migration_version: PLUGIN_CATALOG_MIGRATION_VERSION.to_owned(),
220 required: self.compatibility.abi_major,
221 actual: host_abi_major,
222 });
223 }
224 if !(self.compatibility.abi_minor_min..=self.compatibility.abi_minor_max)
225 .contains(&host_abi_minor)
226 {
227 return Err(PluginCompatibilityError::AbiMinorMismatch {
228 plugin_id: self.plugin.id.clone(),
229 migration_version: PLUGIN_CATALOG_MIGRATION_VERSION.to_owned(),
230 minimum: self.compatibility.abi_minor_min,
231 maximum: self.compatibility.abi_minor_max,
232 actual: host_abi_minor,
233 });
234 }
235 Ok(())
236 }
237
238 pub fn evaluate_current_host_compatibility(
239 &self,
240 host_sdk: &Version,
241 ) -> Result<(), PluginCompatibilityError> {
242 self.evaluate_host_compatibility(host_sdk, VESPER_PLUGIN_ABI_MAJOR, VESPER_PLUGIN_ABI_MINOR)
243 }
244
245 pub fn validate(&self) -> Result<(), PluginDescriptorError> {
246 if self.schema_version != PLUGIN_DESCRIPTOR_SCHEMA_VERSION {
247 return invalid(
248 "schema_version",
249 format!(
250 "expected {PLUGIN_DESCRIPTOR_SCHEMA_VERSION}, got {}",
251 self.schema_version
252 ),
253 );
254 }
255 validate_identity("plugin.id", &self.plugin.id)?;
256 validate_identity("plugin.publisher", &self.plugin.publisher)?;
257 validate_text("plugin.name", &self.plugin.name, MAX_PLUGIN_NAME_BYTES)?;
258 validate_text(
259 "plugin.description",
260 &self.plugin.description,
261 MAX_PLUGIN_DESCRIPTION_BYTES,
262 )?;
263 validate_text("plugin.license", &self.plugin.license, MAX_LICENSE_BYTES)?;
264 Version::parse(&self.plugin.version)
265 .map_err(|error| field_error("plugin.version", error.to_string()))?;
266
267 validate_text(
268 "compatibility.host_sdk",
269 &self.compatibility.host_sdk,
270 MAX_HOST_SDK_REQUIREMENT_BYTES,
271 )?;
272 VersionReq::parse(&self.compatibility.host_sdk)
273 .map_err(|error| field_error("compatibility.host_sdk", error.to_string()))?;
274 if self.compatibility.abi_major == 0 {
275 return invalid("compatibility.abi_major", "must be greater than zero");
276 }
277 if self.compatibility.abi_minor_min > self.compatibility.abi_minor_max {
278 return invalid(
279 "compatibility.abi_minor_min",
280 "must not exceed compatibility.abi_minor_max",
281 );
282 }
283
284 if self.capabilities.is_empty() || self.capabilities.len() > MAX_CAPABILITIES {
285 return invalid(
286 "capabilities",
287 format!("must contain 1 to {MAX_CAPABILITIES} entries"),
288 );
289 }
290 let mut capability_keys = HashSet::with_capacity(self.capabilities.len());
291 for capability in &self.capabilities {
292 let interface_id = Uuid::parse_str(&capability.interface_id)
293 .map_err(|error| field_error("capabilities.interface_id", error.to_string()))?;
294 if interface_id.hyphenated().to_string() != capability.interface_id {
295 return invalid(
296 "capabilities.interface_id",
297 "must use canonical lowercase hyphenated UUID form",
298 );
299 }
300 validate_identity("capabilities.instance_id", &capability.instance_id)?;
301 if capability.instance_id.len() > VESPER_MAX_CAPABILITY_INSTANCE_ID_BYTES {
302 return invalid(
303 "capabilities.instance_id",
304 format!(
305 "must not exceed {VESPER_MAX_CAPABILITY_INSTANCE_ID_BYTES} UTF-8 bytes"
306 ),
307 );
308 }
309 if capability.interface_major == 0 {
310 return invalid("capabilities.interface_major", "must be greater than zero");
311 }
312 if !capability_keys.insert((&capability.interface_id, &capability.instance_id)) {
313 return Err(PluginDescriptorError::DuplicateCapability {
314 interface_id: capability.interface_id.clone(),
315 instance_id: capability.instance_id.clone(),
316 });
317 }
318 }
319
320 validate_plugin_requirements(&self.requires)?;
321 validate_plugin_provisions(&self.provides)?;
322
323 if self.redistribution.len() > MAX_REDISTRIBUTION_ENTRIES {
324 return invalid(
325 "redistribution",
326 format!("must contain at most {MAX_REDISTRIBUTION_ENTRIES} entries"),
327 );
328 }
329 for entry in &self.redistribution {
330 validate_text(
331 "redistribution.component",
332 &entry.component,
333 MAX_REDISTRIBUTION_COMPONENT_BYTES,
334 )?;
335 validate_text("redistribution.license", &entry.license, MAX_LICENSE_BYTES)?;
336 validate_text(
337 "redistribution.notice",
338 &entry.notice,
339 MAX_REDISTRIBUTION_VALUE_BYTES,
340 )?;
341 validate_text(
342 "redistribution.source",
343 &entry.source,
344 MAX_REDISTRIBUTION_VALUE_BYTES,
345 )?;
346 validate_text(
347 "redistribution.build_configuration",
348 &entry.build_configuration,
349 MAX_REDISTRIBUTION_VALUE_BYTES,
350 )?;
351 if let Some(relinking_materials) = entry.relinking_materials.as_deref() {
352 validate_text(
353 "redistribution.relinking_materials",
354 relinking_materials,
355 MAX_REDISTRIBUTION_VALUE_BYTES,
356 )?;
357 }
358 if let Some(profile_hash) = entry.profile_hash.as_deref() {
359 validate_sha256("redistribution.profile_hash", profile_hash)?;
360 }
361 }
362 Ok(())
363 }
364}
365
366fn validate_identity(field: &str, value: &str) -> Result<(), PluginDescriptorError> {
367 PluginReference::new(value, None, PluginTransport::Native)
368 .map(|_| ())
369 .map_err(|error| field_error(field, error.to_string()))
370}
371
372fn validate_text(
373 field: &str,
374 value: &str,
375 maximum_bytes: usize,
376) -> Result<(), PluginDescriptorError> {
377 if value.is_empty() || value.len() > maximum_bytes {
378 return invalid(
379 field,
380 format!("must contain 1 to {maximum_bytes} UTF-8 bytes"),
381 );
382 }
383 Ok(())
384}
385
386fn validate_sha256(field: &str, value: &str) -> Result<(), PluginDescriptorError> {
387 if value.len() != 64
388 || !value
389 .bytes()
390 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
391 {
392 return invalid(field, "must be 64 lowercase hexadecimal characters");
393 }
394 Ok(())
395}
396
397fn invalid<T>(field: &str, message: impl Into<String>) -> Result<T, PluginDescriptorError> {
398 Err(field_error(field, message))
399}
400
401fn field_error(field: &str, message: impl Into<String>) -> PluginDescriptorError {
402 PluginDescriptorError::InvalidField {
403 field: field.to_owned(),
404 message: message.into(),
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 fn source_checkout_root() -> Option<std::path::PathBuf> {
413 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
414 .canonicalize()
415 .ok()?;
416 let root = manifest_dir.join("../../..");
417 let workspace_member = root
418 .join("crates/plugin/player-plugin-package")
419 .canonicalize()
420 .ok()?;
421 (manifest_dir == workspace_member).then_some(root)
422 }
423
424 fn descriptor_toml(capabilities: &str) -> String {
425 format!(
426 r#"
427schema_version = 1
428
429[plugin]
430id = "dev.vesper.fixture"
431name = "Fixture"
432version = "1.2.3"
433description = "Fixture plugin"
434license = "Apache-2.0"
435publisher = "dev.vesper.publisher"
436
437[compatibility]
438host_sdk = ">=0.4.0, <0.5.0"
439abi_major = 1
440abi_minor_min = 0
441abi_minor_max = 0
442
443{capabilities}
444"#
445 )
446 }
447
448 fn capability(instance_id: &str) -> String {
449 format!(
450 r#"
451[[capabilities]]
452interface_id = "e9479dbc-42d2-575e-b39e-a24bc512fbc7"
453instance_id = "{instance_id}"
454interface_major = 1
455interface_minor = 0
456stability = "stable"
457"#
458 )
459 }
460
461 #[test]
462 fn canonical_descriptor_is_stable_across_capability_order() {
463 let first = capability("dev.vesper.fixture.first");
464 let second = capability("dev.vesper.fixture.second");
465 let left = PluginDescriptor::from_toml(&descriptor_toml(&format!("{second}{first}")))
466 .expect("valid descriptor")
467 .canonicalize()
468 .expect("canonical descriptor");
469 let right = PluginDescriptor::from_toml(&descriptor_toml(&format!("{first}{second}")))
470 .expect("valid descriptor")
471 .canonicalize()
472 .expect("canonical descriptor");
473
474 assert_eq!(left.json(), right.json());
475 assert_eq!(left.sha256(), right.sha256());
476 assert_eq!(left.sha256().len(), 64);
477 assert!(left.json().starts_with(b"{\"schema_version\":1,"));
478 }
479
480 #[test]
481 fn canonical_descriptor_is_stable_across_requirement_and_provision_order() {
482 let first = r#"
483[[requires]]
484service = "dev.vesper.service.audio"
485requirement = ">=1.0.0"
486
487[[provides]]
488service = "dev.vesper.service.audio"
489version = "1.2.0"
490"#;
491 let second = r#"
492[[requires]]
493service = "dev.vesper.service.video"
494requirement = ">=2.0.0"
495
496[[provides]]
497service = "dev.vesper.service.video"
498version = "2.3.0"
499"#;
500 let left = PluginDescriptor::from_toml(&format!(
501 "{}{}{}",
502 descriptor_toml(&capability("dev.vesper.fixture.primary")),
503 first,
504 second
505 ))
506 .expect("valid descriptor")
507 .canonicalize()
508 .expect("canonical descriptor");
509 let right = PluginDescriptor::from_toml(&format!(
510 "{}{}{}",
511 descriptor_toml(&capability("dev.vesper.fixture.primary")),
512 second,
513 first
514 ))
515 .expect("valid descriptor")
516 .canonicalize()
517 .expect("canonical descriptor");
518
519 assert_eq!(left.json(), right.json());
520 assert_eq!(left.sha256(), right.sha256());
521 }
522
523 #[test]
524 fn descriptor_rejects_unknown_fields_and_lossy_identity_forms() {
525 let source = descriptor_toml(&capability("dev.vesper.fixture.primary"));
526 let unknown = source.replace(
527 "name = \"Fixture\"",
528 "name = \"Fixture\"\nunexpected = true",
529 );
530 assert!(matches!(
531 PluginDescriptor::from_toml(&unknown),
532 Err(PluginDescriptorError::Toml(_))
533 ));
534
535 let invalid_identity = source.replace("dev.vesper.fixture", " Dev.Vesper.Fixture ");
536 assert!(matches!(
537 PluginDescriptor::from_toml(&invalid_identity),
538 Err(PluginDescriptorError::InvalidField { ref field, .. }) if field == "plugin.id"
539 ));
540 }
541
542 #[test]
543 fn descriptor_rejects_duplicate_capabilities_and_invalid_abi_ranges() {
544 let duplicate = capability("dev.vesper.fixture.primary");
545 assert!(matches!(
546 PluginDescriptor::from_toml(&descriptor_toml(&format!("{duplicate}{duplicate}"))),
547 Err(PluginDescriptorError::DuplicateCapability { .. })
548 ));
549
550 let invalid_range = descriptor_toml(&capability("dev.vesper.fixture.primary"))
551 .replace("abi_minor_min = 0", "abi_minor_min = 2");
552 assert!(matches!(
553 PluginDescriptor::from_toml(&invalid_range),
554 Err(PluginDescriptorError::InvalidField { ref field, .. })
555 if field == "compatibility.abi_minor_min"
556 ));
557 }
558
559 #[test]
560 fn schema_validation_preserves_future_abi_for_host_compatibility_evaluation() {
561 let source = descriptor_toml(&capability("dev.vesper.fixture.primary"))
562 .replace("abi_major = 1", "abi_major = 2");
563 let descriptor = PluginDescriptor::from_toml(&source).expect("valid future descriptor");
564 descriptor
565 .canonicalize()
566 .expect("future descriptor remains canonicalizable");
567
568 assert_eq!(
569 descriptor.evaluate_current_host_compatibility(&Version::new(0, 4, 0)),
570 Err(PluginCompatibilityError::AbiMajorMismatch {
571 plugin_id: "dev.vesper.fixture".to_owned(),
572 migration_version: PLUGIN_CATALOG_MIGRATION_VERSION.to_owned(),
573 required: 2,
574 actual: VESPER_PLUGIN_ABI_MAJOR,
575 })
576 );
577 }
578
579 #[test]
580 fn descriptor_abi_major_matches_the_nonzero_u16_wire_contract() {
581 let source = descriptor_toml(&capability("dev.vesper.fixture.primary"));
582
583 let zero = source.replace("abi_major = 1", "abi_major = 0");
584 assert!(matches!(
585 PluginDescriptor::from_toml(&zero),
586 Err(PluginDescriptorError::InvalidField { ref field, .. })
587 if field == "compatibility.abi_major"
588 ));
589
590 let maximum = source.replace("abi_major = 1", "abi_major = 65535");
591 assert_eq!(
592 PluginDescriptor::from_toml(&maximum)
593 .expect("u16 maximum ABI major")
594 .compatibility
595 .abi_major,
596 u16::MAX
597 );
598
599 let overflow = source.replace("abi_major = 1", "abi_major = 65536");
600 assert!(matches!(
601 PluginDescriptor::from_toml(&overflow),
602 Err(PluginDescriptorError::Toml(_))
603 ));
604 }
605
606 #[test]
607 fn public_schemas_match_the_nonzero_u16_abi_major_contract() {
608 let Some(workspace) = source_checkout_root() else {
612 return;
613 };
614 for relative_path in [
615 "schemas/vesper-plugin/project.schema.json",
616 "schemas/vesper-plugin/manifest.schema.json",
617 "schemas/vesper-plugin/descriptor.schema.json",
618 ] {
619 let path = workspace.join(relative_path);
620 let bytes = std::fs::read(&path).expect("read plugin schema");
621 let schema: serde_json::Value =
622 serde_json::from_slice(&bytes).expect("parse plugin schema");
623 let abi_major = &schema["$defs"]["compatibility"]["properties"]["abi_major"];
624
625 assert_eq!(abi_major["type"], "integer", "{relative_path}");
626 assert_eq!(abi_major["minimum"], 1, "{relative_path}");
627 assert_eq!(abi_major["maximum"], u16::MAX, "{relative_path}");
628 assert!(abi_major.get("const").is_none(), "{relative_path}");
629 }
630 }
631
632 #[test]
633 fn public_descriptor_schema_matches_canonical_redistribution_contract() {
634 let Some(workspace) = source_checkout_root() else {
635 return;
636 };
637 let relative_path = "schemas/vesper-plugin/descriptor.schema.json";
638 let bytes = std::fs::read(workspace.join(relative_path)).expect("read descriptor schema");
639 let schema: serde_json::Value =
640 serde_json::from_slice(&bytes).expect("parse descriptor schema");
641 let required = schema["required"]
642 .as_array()
643 .expect("descriptor required fields");
644 let redistribution = &schema["properties"]["redistribution"];
645
646 assert!(
647 required.iter().any(|field| field == "redistribution"),
648 "{relative_path}"
649 );
650 assert!(
651 required.iter().any(|field| field == "requires"),
652 "{relative_path}"
653 );
654 assert!(
655 required.iter().any(|field| field == "provides"),
656 "{relative_path}"
657 );
658 assert_eq!(redistribution["type"], "array", "{relative_path}");
659 assert_eq!(
660 redistribution["maxItems"], MAX_REDISTRIBUTION_ENTRIES,
661 "{relative_path}"
662 );
663 }
664
665 #[test]
666 fn project_schema_keeps_dependency_arrays_optional_for_author_input() {
667 let Some(workspace) = source_checkout_root() else {
668 return;
669 };
670 let bytes = std::fs::read(workspace.join("schemas/vesper-plugin/project.schema.json"))
671 .expect("read project schema");
672 let schema: serde_json::Value =
673 serde_json::from_slice(&bytes).expect("parse project schema");
674 let required = schema["required"]
675 .as_array()
676 .expect("project required fields");
677 assert!(!required.iter().any(|field| field == "requires"));
678 assert!(!required.iter().any(|field| field == "provides"));
679 assert_eq!(
680 schema["properties"]["requires"]["default"],
681 serde_json::json!([])
682 );
683 assert_eq!(
684 schema["properties"]["provides"]["default"],
685 serde_json::json!([])
686 );
687 }
688
689 #[test]
690 fn public_project_and_package_schemas_require_artifact_capability_references() {
691 let Some(workspace) = source_checkout_root() else {
692 return;
693 };
694 for (relative_path, artifact_definition) in [
695 (
696 "schemas/vesper-plugin/project.schema.json",
697 "artifactSource",
698 ),
699 ("schemas/vesper-plugin/manifest.schema.json", "artifact"),
700 ] {
701 let path = workspace.join(relative_path);
702 let bytes = std::fs::read(&path).expect("read plugin schema");
703 let schema: serde_json::Value =
704 serde_json::from_slice(&bytes).expect("parse plugin schema");
705 let artifact = &schema["$defs"][artifact_definition];
706 let required = artifact["required"]
707 .as_array()
708 .expect("artifact required fields");
709 let capabilities = &artifact["properties"]["capabilities"];
710 let capability_reference = &schema["$defs"]["artifactCapability"];
711
712 assert!(
713 required.iter().any(|field| field == "capabilities"),
714 "{relative_path}"
715 );
716 assert_eq!(capabilities["type"], "array", "{relative_path}");
717 assert_eq!(capabilities["minItems"], 1, "{relative_path}");
718 assert_eq!(
719 capabilities["maxItems"], MAX_CAPABILITIES,
720 "{relative_path}"
721 );
722 assert_eq!(
723 capabilities["items"]["$ref"], "#/$defs/artifactCapability",
724 "{relative_path}"
725 );
726 assert_eq!(
727 capability_reference["additionalProperties"], false,
728 "{relative_path}"
729 );
730 assert_eq!(
731 capability_reference["required"],
732 serde_json::json!(["interface_id", "instance_id"]),
733 "{relative_path}"
734 );
735 }
736 }
737
738 #[test]
739 fn host_compatibility_checks_sdk_and_abi_minor_range_without_rewriting_them() {
740 let descriptor = PluginDescriptor::from_toml(&descriptor_toml(&capability(
741 "dev.vesper.fixture.primary",
742 )))
743 .expect("descriptor");
744
745 assert!(matches!(
746 descriptor.evaluate_current_host_compatibility(&Version::new(0, 5, 0)),
747 Err(PluginCompatibilityError::HostSdkMismatch { .. })
748 ));
749 assert_eq!(
750 descriptor.evaluate_host_compatibility(&Version::new(0, 4, 0), 1, 1),
751 Err(PluginCompatibilityError::AbiMinorMismatch {
752 plugin_id: "dev.vesper.fixture".to_owned(),
753 migration_version: PLUGIN_CATALOG_MIGRATION_VERSION.to_owned(),
754 minimum: 0,
755 maximum: 0,
756 actual: 1,
757 })
758 );
759 }
760}