Skip to main content

player_plugin_package/
plugin_descriptor.rs

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/// Artifact-independent plugin identity and capability metadata.
27///
28/// Its canonical hash can be embedded inside an AAR or framework without
29/// creating a cycle with the outer package manifest's artifact hashes.
30#[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    /// Evaluates this transport-neutral descriptor against one concrete host.
194    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        // Public schemas intentionally live outside Rust crates. Keep the
609        // repository drift check without making packaged crate tests depend on
610        // files that Cargo cannot include from the workspace root.
611        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}